4 Commits

Author SHA1 Message Date
Joseph Doherty
77922abb33 feat(centralui): single-select Channel filter on the Audit Log page
Channel narrows the Kind options to the chosen channel, so filtering by more
than one channel at a time is incoherent. Replace the Channel multi-select
dropdown with a native single-select (matching the Time range control); Kind,
Status and Site stay multi-select. The query filter contract is unchanged —
Channels just carries 0 or 1 value.
2026-05-21 10:02:17 -04:00
Joseph Doherty
5f544bfe1e Merge branch 'feature/audit-actor-identity': populate audit Actor column
Stamp the audit Actor column on outbound rows (calling script identity) and
central-dispatch rows (system identity); the original emission code left it
null on every channel except Inbound API.
2026-05-21 09:56:43 -04:00
Joseph Doherty
aaa6df24cf Merge branch 'feature/audit-filter-dropdowns': compact audit filter dropdowns
Replace the four stacked chip-button groups on the Audit Log filter bar with a
reusable MultiSelectDropdown component, collapsing the bar from four full-width
chip blocks to four inline dropdowns in one wrapped filter row.
2026-05-21 09:56:43 -04:00
Joseph Doherty
e36f0bf9c8 feat(centralui): compact multi-select dropdowns for the audit filter bar
Replace the four stacked chip-button groups (Channel, Kind, Status, Site) on
the Audit Log filter bar with a reusable MultiSelectDropdown component, so the
bar collapses from four full-width chip blocks to four inline dropdowns sharing
one wrapped filter row. Bootstrap dropdown + checkbox menu (data-bs-auto-close
=outside); no third-party UI libraries.
2026-05-21 09:36:36 -04:00
7 changed files with 314 additions and 149 deletions

View File

@@ -6,78 +6,58 @@
<div class="card mb-3" data-test="audit-filter-bar"> <div class="card mb-3" data-test="audit-filter-bar">
<div class="card-body py-2"> <div class="card-body py-2">
@* Channel chip multi-select. *@ @* All filters sit in one wrapped row. Kind / Status / Site use compact
<div class="mb-2" data-test="filter-channel"> MultiSelectDropdown controls; Channel is a single-select because the
<label class="form-label small mb-1">Channel</label> Kind options narrow to the chosen channel — so the bar stays a row or
<div> two tall instead of four stacked blocks of chip buttons. *@
@foreach (var channel in Enum.GetValues<AuditChannel>())
{
var selected = _model.Channels.Contains(channel);
<button type="button" data-test="chip-channel-@channel"
class="@ChipClass(selected)"
@onclick="() => ToggleChannel(channel)">
@channel
</button>
}
</div>
</div>
@* Kind chip multi-select — narrowed by Channel selection. *@
<div class="mb-2" data-test="filter-kind">
<label class="form-label small mb-1">Kind</label>
<div>
@foreach (var kind in _model.VisibleKinds())
{
var selected = _model.Kinds.Contains(kind);
<button type="button" data-test="chip-kind-@kind"
class="@ChipClass(selected)"
@onclick="() => ToggleKind(kind)">
@kind
</button>
}
</div>
</div>
@* Status chip multi-select. *@
<div class="mb-2" data-test="filter-status">
<label class="form-label small mb-1">Status</label>
<div>
@foreach (var status in Enum.GetValues<AuditStatus>())
{
var selected = _model.Statuses.Contains(status);
<button type="button" data-test="chip-status-@status"
class="@ChipClass(selected)"
@onclick="() => ToggleStatus(status)">
@status
</button>
}
</div>
</div>
@* Site chip multi-select — populated from ISiteRepository. *@
<div class="mb-2" data-test="filter-site">
<label class="form-label small mb-1">Site</label>
<div>
@if (_sites.Count == 0)
{
<span class="text-muted small">No sites available.</span>
}
else
{
@foreach (var site in _sites)
{
var selected = _model.SiteIdentifiers.Contains(site.SiteIdentifier);
<button type="button" data-test="chip-site-@site.SiteIdentifier"
class="@ChipClass(selected)"
@onclick="() => ToggleSite(site.SiteIdentifier)">
@site.Name
</button>
}
}
</div>
</div>
<div class="row g-2 align-items-end"> <div class="row g-2 align-items-end">
@* Single-select: one channel at a time, so the Kind options below
narrow cleanly to that channel. "All channels" clears it. *@
<div class="col-auto" data-test="filter-channel">
<label class="form-label small mb-1" for="audit-channel">Channel</label>
<select id="audit-channel" data-test="filter-channel-select"
class="form-select form-select-sm" @bind="SelectedChannel">
<option value="">All channels</option>
@foreach (var channel in _channels)
{
<option value="@channel">@channel</option>
}
</select>
</div>
@* Kind options are narrowed by the Channel selection (VisibleKinds). *@
<div class="col-auto" data-test="filter-kind">
<label class="form-label small mb-1">Kind</label>
<div>
<MultiSelectDropdown TValue="AuditKind"
Items="_model.VisibleKinds()"
Selected="_model.Kinds"
DataTest="filter-kind-ms" />
</div>
</div>
<div class="col-auto" data-test="filter-status">
<label class="form-label small mb-1">Status</label>
<div>
<MultiSelectDropdown TValue="AuditStatus"
Items="_statuses"
Selected="_model.Statuses"
DataTest="filter-status-ms" />
</div>
</div>
<div class="col-auto" data-test="filter-site">
<label class="form-label small mb-1">Site</label>
<div>
<MultiSelectDropdown TValue="string"
Items="_siteIds"
Selected="_model.SiteIdentifiers"
Display="SiteName"
EmptyText="No sites available"
DataTest="filter-site-ms" />
</div>
</div>
<div class="col-auto" data-test="filter-time-range"> <div class="col-auto" data-test="filter-time-range">
<label class="form-label small mb-1" for="audit-time-range">Time range</label> <label class="form-label small mb-1" for="audit-time-range">Time range</label>
<select id="audit-time-range" class="form-select form-select-sm" <select id="audit-time-range" class="form-select form-select-sm"

View File

@@ -7,19 +7,32 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// <summary> /// <summary>
/// Filter bar for the central Audit Log page (#23 M7-T2). Owns the /// Filter bar for the central Audit Log page (#23 M7-T2). Owns the
/// <see cref="AuditQueryModel"/> binding state, renders the 10 filter elements /// <see cref="AuditQueryModel"/> binding state and renders the filter controls
/// plus the Errors-only toggle, and publishes a collapsed /// — Channel as a single-select (one channel at a time, so the Kind options
/// <see cref="AuditLogQueryFilter"/> via <see cref="OnFilterChanged"/> when the /// narrow to it cleanly); Kind / Status / Site as compact
/// user clicks Apply. See <see cref="AuditQueryModel"/> for the multi-select /// <see cref="ScadaLink.CentralUI.Components.Shared.MultiSelectDropdown{TValue}"/>
/// single-value collapse contract. /// controls; plus the time range, free-text searches and the Errors-only
/// toggle — and publishes an <see cref="AuditLogQueryFilter"/> via
/// <see cref="OnFilterChanged"/> when the user clicks Apply. The selected
/// dimensions map through to the filter's list fields; see
/// <see cref="AuditQueryModel"/> for the Errors-only and time-range rules.
/// </summary> /// </summary>
public partial class AuditFilterBar public partial class AuditFilterBar
{ {
private readonly AuditQueryModel _model = new(); private readonly AuditQueryModel _model = new();
private List<Site> _sites = new(); private List<Site> _sites = new();
/// <summary>Channel options — the full enum, fixed for the component's lifetime.</summary>
private static readonly IReadOnlyList<AuditChannel> _channels = Enum.GetValues<AuditChannel>();
/// <summary>Status options — the full enum, fixed for the component's lifetime.</summary>
private static readonly IReadOnlyList<AuditStatus> _statuses = Enum.GetValues<AuditStatus>();
/// <summary>Site identifiers in display order; rebuilt once when sites load.</summary>
private IReadOnlyList<string> _siteIds = Array.Empty<string>();
/// <summary> /// <summary>
/// Raised when the user clicks Apply. Carries the collapsed /// Raised when the user clicks Apply. Carries the
/// <see cref="AuditLogQueryFilter"/> the parent page hands to /// <see cref="AuditLogQueryFilter"/> the parent page hands to
/// <see cref="ScadaLink.CentralUI.Services.IAuditLogQueryService"/>. /// <see cref="ScadaLink.CentralUI.Services.IAuditLogQueryService"/>.
/// </summary> /// </summary>
@@ -51,10 +64,9 @@ public partial class AuditFilterBar
_model.InstanceSearch = InitialInstanceSearch.Trim(); _model.InstanceSearch = InitialInstanceSearch.Trim();
} }
// Populate the Site dropdown at component init. Failure is non-fatal — the
// Populate the Site chips at component init. Failure is non-fatal — the chip // dropdown just shows "No sites available." Sites are listed by Name to
// section just shows "No sites available." Sites are listed by Name to match // match operator expectations from the Notification Report.
// operator expectations from the Notification Report.
try try
{ {
var sites = await SiteRepository.GetAllSitesAsync(); var sites = await SiteRepository.GetAllSitesAsync();
@@ -62,48 +74,52 @@ public partial class AuditFilterBar
} }
catch catch
{ {
// Swallowed: filter bar still renders without the Site chips. The page // Swallowed: filter bar still renders without the Site options. The page
// surfaces site-load errors elsewhere (the grid query path). // surfaces site-load errors elsewhere (the grid query path).
_sites = new(); _sites = new();
} }
_siteIds = _sites.Select(s => s.SiteIdentifier).ToArray();
} }
private void ToggleChannel(AuditChannel channel) /// <summary>
/// Single-select Channel binding for the filter bar. The Audit Log filters one
/// channel at a time so the Kind options narrow cleanly to it; the model still
/// stores the selection as a set (0 or 1 entry) so <see cref="AuditQueryModel.ToFilter"/>
/// and <see cref="AuditQueryModel.VisibleKinds"/> are unchanged. <c>null</c> = all channels.
/// </summary>
private AuditChannel? SelectedChannel
{ {
if (!_model.Channels.Add(channel)) get => _model.Channels.Count > 0 ? _model.Channels.First() : null;
set
{ {
_model.Channels.Remove(channel); _model.Channels.Clear();
} if (value is { } channel)
{
_model.Channels.Add(channel);
}
// Drop Kind chips that fall outside the new visible set. Keeps "Channel and OnChannelsChanged();
// Kind both picked" coherent — without this, removing a channel could leave }
// stale Kind chips selected that no longer match any visible chip. }
/// <summary>
/// Runs after the Channel selection changes. Drops any Kind selections that fell
/// outside the new visible set — without this, changing the channel could leave
/// stale Kind selections that no longer match any visible option.
/// </summary>
private void OnChannelsChanged()
{
var visible = _model.VisibleKinds().ToHashSet(); var visible = _model.VisibleKinds().ToHashSet();
_model.Kinds.RemoveWhere(k => !visible.Contains(k)); _model.Kinds.RemoveWhere(k => !visible.Contains(k));
} }
private void ToggleKind(AuditKind kind) /// <summary>Display label for a site identifier — its friendly Name, id as fallback.</summary>
private string SiteName(string siteIdentifier)
{ {
if (!_model.Kinds.Add(kind)) var site = _sites.FirstOrDefault(s =>
{ string.Equals(s.SiteIdentifier, siteIdentifier, StringComparison.OrdinalIgnoreCase));
_model.Kinds.Remove(kind); return site?.Name ?? siteIdentifier;
}
}
private void ToggleStatus(AuditStatus status)
{
if (!_model.Statuses.Add(status))
{
_model.Statuses.Remove(status);
}
}
private void ToggleSite(string siteIdentifier)
{
if (!_model.SiteIdentifiers.Add(siteIdentifier))
{
_model.SiteIdentifiers.Remove(siteIdentifier);
}
} }
private void ClearFilters() private void ClearFilters()
@@ -129,11 +145,6 @@ public partial class AuditFilterBar
await OnFilterChanged.InvokeAsync(filter); await OnFilterChanged.InvokeAsync(filter);
} }
private static string ChipClass(bool selected) =>
selected
? "btn btn-sm btn-primary me-1 mb-1"
: "btn btn-sm btn-outline-secondary me-1 mb-1";
private static string TimeRangeLabel(AuditTimeRangePreset preset) => preset switch private static string TimeRangeLabel(AuditTimeRangePreset preset) => preset switch
{ {
AuditTimeRangePreset.Last5Minutes => "now 5 min → now", AuditTimeRangePreset.Last5Minutes => "now 5 min → now",

View File

@@ -0,0 +1,40 @@
@typeparam TValue
@*
Compact multi-select control: a Bootstrap dropdown whose toggle button
summarises the current selection over a checkbox menu. Replaces a wrapped
block of chip buttons with a single control of one row's height.
*@
<div class="dropdown msd" data-test="@DataTest">
<button type="button"
class="btn btn-sm btn-outline-secondary dropdown-toggle msd-toggle text-start"
data-bs-toggle="dropdown"
data-bs-auto-close="outside"
aria-expanded="false"
disabled="@(Items.Count == 0)"
data-test="@($"{DataTest}-toggle")">
<span class="msd-summary">@Summary()</span>
</button>
<ul class="dropdown-menu msd-menu">
@if (Items.Count == 0)
{
<li><span class="dropdown-item-text text-muted small">@EmptyText</span></li>
}
else
{
@foreach (var item in Items)
{
var isSelected = Selected.Contains(item);
<li>
<label class="dropdown-item msd-item">
<input type="checkbox"
class="form-check-input msd-check"
checked="@isSelected"
@onchange="() => Toggle(item)"
data-test="@($"{DataTest}-opt-{item}")" />
<span>@Display(item)</span>
</label>
</li>
}
}
</ul>
</div>

View File

@@ -0,0 +1,95 @@
using Microsoft.AspNetCore.Components;
namespace ScadaLink.CentralUI.Components.Shared;
/// <summary>
/// A compact multi-select control: a Bootstrap dropdown whose toggle button
/// summarises the current selection ("All" when empty, the single item's label
/// when one is picked, or "N selected" otherwise) over a checkbox menu.
///
/// <para>
/// It exists to keep multi-value filter controls one row tall instead of a
/// wrapped block of chip buttons. The component mutates the caller-owned
/// <see cref="Selected"/> collection in place and raises
/// <see cref="SelectionChanged"/> after every toggle so the parent can react
/// (re-render, prune dependent selections, …).
/// </para>
///
/// <para>
/// Requires the Bootstrap JS bundle (loaded in <c>App.razor</c>) for the
/// dropdown toggle; <c>data-bs-auto-close="outside"</c> keeps the menu open
/// while the operator ticks several boxes.
/// </para>
/// </summary>
/// <typeparam name="TValue">The option value type (an enum or string).</typeparam>
public partial class MultiSelectDropdown<TValue> where TValue : notnull
{
/// <summary>The options shown in the menu, in display order.</summary>
[Parameter, EditorRequired]
public IReadOnlyList<TValue> Items { get; set; } = Array.Empty<TValue>();
/// <summary>
/// The caller-owned selection set. Mutated in place by <see cref="Toggle"/>.
/// </summary>
[Parameter, EditorRequired]
public ICollection<TValue> Selected { get; set; } = default!;
/// <summary>Maps an option to its display label. Defaults to <c>ToString()</c>.</summary>
[Parameter]
public Func<TValue, string> Display { get; set; } = static v => v.ToString() ?? string.Empty;
/// <summary>Raised after each toggle, once <see cref="Selected"/> has been updated.</summary>
[Parameter]
public EventCallback SelectionChanged { get; set; }
/// <summary>Summary text shown on the toggle button when nothing is selected.</summary>
[Parameter]
public string AllLabel { get; set; } = "All";
/// <summary>Text shown in the menu when there are no options.</summary>
[Parameter]
public string EmptyText { get; set; } = "None available";
/// <summary><c>data-test</c> root for this control, its toggle and its options.</summary>
[Parameter]
public string DataTest { get; set; } = "multi-select";
private async Task Toggle(TValue item)
{
// ICollection.Remove returns false when the item was absent — that is the
// "not currently selected" case, so add it. This is a plain toggle.
if (!Selected.Remove(item))
{
Selected.Add(item);
}
await SelectionChanged.InvokeAsync();
}
private string Summary()
{
var count = Selected.Count;
if (count == 0)
{
return AllLabel;
}
if (count == 1)
{
// Prefer the single selection's label over a bare "1 selected".
foreach (var item in Items)
{
if (Selected.Contains(item))
{
return Display(item);
}
}
// The one selected value is not in the current Items list (e.g. a Kind
// narrowed out by a Channel change before the parent pruned it).
return "1 selected";
}
return $"{count} selected";
}
}

View File

@@ -0,0 +1,32 @@
/* Compact multi-select dropdown. Tuned to sit inline with form-select-sm /
form-control-sm controls in a filter row. */
.msd-toggle {
min-width: 9rem;
max-width: 15rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Keep a long option list from running off-screen — scroll within the menu. */
.msd-menu {
max-height: 16rem;
overflow-y: auto;
}
/* The whole row is a <label> so a click anywhere toggles the checkbox; the
menu stays open thanks to data-bs-auto-close="outside". */
.msd-item {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
/* Neutralise the default form-check-input top margin so the box lines up with
the option text inside the dropdown-item. */
.msd-check {
flex: 0 0 auto;
margin: 0;
}

View File

@@ -16,7 +16,7 @@ namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
/// <para> /// <para>
/// Scenarios covered (per the M7-T16 brief): /// Scenarios covered (per the M7-T16 brief):
/// <list type="bullet"> /// <list type="bullet">
/// <item><c>FilterNarrowing</c> — channel chip narrows the results grid.</item> /// <item><c>FilterNarrowing</c> — the channel filter narrows the results grid.</item>
/// <item><c>DrilldownDrawer_JsonPrettyPrint</c> — JSON request bodies pretty-print.</item> /// <item><c>DrilldownDrawer_JsonPrettyPrint</c> — JSON request bodies pretty-print.</item>
/// <item><c>CopyAsCurlButton_VisibleOnApiInbound</c> — cURL action visible for API rows.</item> /// <item><c>CopyAsCurlButton_VisibleOnApiInbound</c> — cURL action visible for API rows.</item>
/// <item><c>DrillInFromCorrelationId_AutoLoadsAuditLog</c> — query-string drill-in /// <item><c>DrillInFromCorrelationId_AutoLoadsAuditLog</c> — query-string drill-in
@@ -45,7 +45,7 @@ public class AuditLogPageTests
} }
[Fact] [Fact]
public async Task FilterNarrowing_ChannelChipShrinksGrid() public async Task FilterNarrowing_ChannelFilterShrinksGrid()
{ {
// Skip with a clear message when MSSQL is not reachable — the rest of // Skip with a clear message when MSSQL is not reachable — the rest of
// the Playwright suite is UI-only and does not need the DB, so this // the Playwright suite is UI-only and does not need the DB, so this
@@ -91,13 +91,13 @@ public class AuditLogPageTests
await page.WaitForLoadStateAsync(LoadState.NetworkIdle); await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// Pre-Apply, both rows are absent because the grid stays empty until // Pre-Apply, both rows are absent because the grid stays empty until
// the user filters. Click the ApiOutbound chip then Apply. // the user filters. Pick the ApiOutbound channel, then Apply.
await page.Locator("[data-test='chip-channel-ApiOutbound']").ClickAsync(); await page.Locator("[data-test='filter-channel-select']").SelectOptionAsync("ApiOutbound");
await page.Locator("[data-test='filter-apply']").ClickAsync(); await page.Locator("[data-test='filter-apply']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle); await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// The seeded ApiOutbound row is visible; the DbOutbound row is not // The seeded ApiOutbound row is visible; the DbOutbound row is not
// (it was filtered out by the channel chip). // (it was filtered out by the channel filter).
var apiRow = page.Locator($"[data-test='grid-row-{apiEventId}']"); var apiRow = page.Locator($"[data-test='grid-row-{apiEventId}']");
var dbRow = page.Locator($"[data-test='grid-row-{dbEventId}']"); var dbRow = page.Locator($"[data-test='grid-row-{dbEventId}']");
await Assertions.Expect(apiRow).ToBeVisibleAsync(); await Assertions.Expect(apiRow).ToBeVisibleAsync();

View File

@@ -13,13 +13,18 @@ namespace ScadaLink.CentralUI.Tests.Components.Audit;
/// <summary> /// <summary>
/// bUnit tests for <see cref="AuditFilterBar"/> (#23 M7-T2 / Bundle B). /// bUnit tests for <see cref="AuditFilterBar"/> (#23 M7-T2 / Bundle B).
/// ///
/// The bar carries the 10 spec filter elements plus the Errors-only toggle. Tests /// The bar carries the 10 spec filter elements plus the Errors-only toggle.
/// pin: (1) the full filter set renders; (2) Apply raises <c>OnFilterChanged</c> /// Channel is a single-select <c>&lt;select data-test="filter-channel-select"&gt;</c>;
/// with collapsed values; (3) the Channel→Kind narrowing map drives Kind chip /// Kind / Status / Site are
/// visibility; (4) the Errors-only toggle ORs <c>Failed</c> into Status when /// <see cref="ScadaLink.CentralUI.Components.Shared.MultiSelectDropdown{TValue}"/>
/// Status is otherwise empty; (5) the "Last hour" preset populates /// controls whose options are checkboxes tagged
/// <c>FromUtc</c> to roughly an hour before "now" — proves the time-window /// <c>data-test="filter-&lt;dim&gt;-ms-opt-&lt;value&gt;"</c>. Tests pin:
/// collapse without freezing the clock. /// (1) the full filter set renders; (2) Apply raises <c>OnFilterChanged</c> with
/// the selected values; (3) the Channel→Kind narrowing map drives Kind option
/// visibility; (4) the Errors-only toggle ORs the error statuses into Status when
/// Status is otherwise empty; (5) the "Last hour" preset populates <c>FromUtc</c>
/// to roughly an hour before "now" — proves the time-window collapse without
/// freezing the clock.
/// </summary> /// </summary>
public class AuditFilterBarTests : BunitContext public class AuditFilterBarTests : BunitContext
{ {
@@ -71,8 +76,8 @@ public class AuditFilterBarTests : BunitContext
var cut = Render<AuditFilterBar>(p => p var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f))); .Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
// Drive UI: toggle a Channel chip, type in the Target search box, click Apply. // Drive UI: pick a Channel, type in the Target search box, click Apply.
cut.Find("[data-test=\"chip-channel-ApiOutbound\"]").Click(); cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
cut.Find("[data-test=\"filter-target\"] input").Change("Plant-A-OPC"); cut.Find("[data-test=\"filter-target\"] input").Change("Plant-A-OPC");
cut.Find("[data-test=\"filter-apply\"]").Click(); cut.Find("[data-test=\"filter-apply\"]").Click();
@@ -82,23 +87,25 @@ public class AuditFilterBarTests : BunitContext
} }
[Fact] [Fact]
public void Apply_WithMultipleChannelChips_PassesAllSelectedChannels() public void ChangingChannel_ReplacesTheSelection_SingleSelect()
{ {
// Task 9: ToFilter no longer collapses the chip multi-select — every // Channel is single-select: picking a second channel replaces the first
// selected channel chip reaches the filter's Channels list. // rather than adding to it (the page filters one channel at a time).
AuditLogQueryFilter? captured = null; AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f))); .Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
cut.Find("[data-test=\"chip-channel-ApiOutbound\"]").Click(); cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
cut.Find("[data-test=\"chip-channel-Notification\"]").Click(); cut.Find("[data-test=\"filter-channel-select\"]").Change("Notification");
cut.Find("[data-test=\"filter-apply\"]").Click(); cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured); Assert.NotNull(captured);
Assert.NotNull(captured!.Channels); Assert.Equal(new[] { AuditChannel.Notification }, captured!.Channels);
Assert.Equal(2, captured.Channels!.Count);
Assert.Contains(AuditChannel.ApiOutbound, captured.Channels); // Selecting "All channels" clears the channel filter entirely.
Assert.Contains(AuditChannel.Notification, captured.Channels); cut.Find("[data-test=\"filter-channel-select\"]").Change(string.Empty);
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.Null(captured!.Channels);
} }
[Fact] [Fact]
@@ -106,23 +113,23 @@ public class AuditFilterBarTests : BunitContext
{ {
var cut = Render<AuditFilterBar>(); var cut = Render<AuditFilterBar>();
// With no Channel selected, every kind chip is in the DOM. // With no Channel selected, every kind option is in the DOM.
foreach (var kind in Enum.GetValues<AuditKind>()) foreach (var kind in Enum.GetValues<AuditKind>())
{ {
Assert.Contains($"data-test=\"chip-kind-{kind}\"", cut.Markup); Assert.Contains($"data-test=\"filter-kind-ms-opt-{kind}\"", cut.Markup);
} }
// Select only ApiOutbound; Kind chips outside the channel-kind map drop out. // Select ApiOutbound; Kind options outside the channel-kind map drop out.
cut.Find("[data-test=\"chip-channel-ApiOutbound\"]").Click(); cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
var apiKinds = AuditQueryModel.KindsByChannel[AuditChannel.ApiOutbound]; var apiKinds = AuditQueryModel.KindsByChannel[AuditChannel.ApiOutbound];
foreach (var kind in apiKinds) foreach (var kind in apiKinds)
{ {
Assert.Contains($"data-test=\"chip-kind-{kind}\"", cut.Markup); Assert.Contains($"data-test=\"filter-kind-ms-opt-{kind}\"", cut.Markup);
} }
// Sanity: an unrelated kind is gone. // Sanity: an unrelated kind is gone.
Assert.DoesNotContain($"data-test=\"chip-kind-{AuditKind.NotifySend}\"", cut.Markup); Assert.DoesNotContain($"data-test=\"filter-kind-ms-opt-{AuditKind.NotifySend}\"", cut.Markup);
Assert.DoesNotContain($"data-test=\"chip-kind-{AuditKind.InboundRequest}\"", cut.Markup); Assert.DoesNotContain($"data-test=\"filter-kind-ms-opt-{AuditKind.InboundRequest}\"", cut.Markup);
} }
[Fact] [Fact]
@@ -144,8 +151,8 @@ public class AuditFilterBarTests : BunitContext
Assert.Contains(AuditStatus.Parked, captured.Statuses); Assert.Contains(AuditStatus.Parked, captured.Statuses);
Assert.Contains(AuditStatus.Discarded, captured.Statuses); Assert.Contains(AuditStatus.Discarded, captured.Statuses);
// Now pin an explicit Status chip — Errors-only must yield (chip wins). // Now pin an explicit Status option — Errors-only must yield (explicit wins).
cut.Find("[data-test=\"chip-status-Delivered\"]").Click(); cut.Find("[data-test=\"filter-status-ms-opt-Delivered\"]").Change(true);
cut.Find("[data-test=\"filter-apply\"]").Click(); cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.Equal(new[] { AuditStatus.Delivered }, captured!.Statuses); Assert.Equal(new[] { AuditStatus.Delivered }, captured!.Statuses);
@@ -160,8 +167,8 @@ public class AuditFilterBarTests : BunitContext
var cut = Render<AuditFilterBar>(p => p var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f))); .Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
cut.Find("[data-test=\"chip-status-Delivered\"]").Click(); cut.Find("[data-test=\"filter-status-ms-opt-Delivered\"]").Change(true);
cut.Find("[data-test=\"chip-status-Failed\"]").Click(); cut.Find("[data-test=\"filter-status-ms-opt-Failed\"]").Change(true);
cut.Find("[data-test=\"filter-apply\"]").Click(); cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured); Assert.NotNull(captured);