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.
This commit is contained in:
Joseph Doherty
2026-05-21 10:02:17 -04:00
parent 5f544bfe1e
commit 77922abb33
4 changed files with 65 additions and 38 deletions

View File

@@ -6,20 +6,23 @@
<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">
@* All filters sit in one wrapped row. The four multi-value dimensions @* All filters sit in one wrapped row. Kind / Status / Site use compact
(Channel / Kind / Status / Site) use compact MultiSelectDropdown MultiSelectDropdown controls; Channel is a single-select because the
controls so the bar stays a row or two tall instead of four stacked Kind options narrow to the chosen channel — so the bar stays a row or
blocks of chip buttons. *@ two tall instead of four stacked blocks of chip buttons. *@
<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"> <div class="col-auto" data-test="filter-channel">
<label class="form-label small mb-1">Channel</label> <label class="form-label small mb-1" for="audit-channel">Channel</label>
<div> <select id="audit-channel" data-test="filter-channel-select"
<MultiSelectDropdown TValue="AuditChannel" class="form-select form-select-sm" @bind="SelectedChannel">
Items="_channels" <option value="">All channels</option>
Selected="_model.Channels" @foreach (var channel in _channels)
SelectionChanged="OnChannelsChanged" {
DataTest="filter-channel-ms" /> <option value="@channel">@channel</option>
</div> }
</select>
</div> </div>
@* Kind options are narrowed by the Channel selection (VisibleKinds). *@ @* Kind options are narrowed by the Channel selection (VisibleKinds). *@

View File

@@ -8,13 +8,14 @@ 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 and renders the filter controls /// <see cref="AuditQueryModel"/> binding state and renders the filter controls
/// — Channel / Kind / Status / Site as compact /// — Channel as a single-select (one channel at a time, so the Kind options
/// narrow to it cleanly); Kind / Status / Site as compact
/// <see cref="ScadaLink.CentralUI.Components.Shared.MultiSelectDropdown{TValue}"/> /// <see cref="ScadaLink.CentralUI.Components.Shared.MultiSelectDropdown{TValue}"/>
/// controls, plus the time range, free-text searches and the Errors-only /// controls; plus the time range, free-text searches and the Errors-only
/// toggle — and publishes an <see cref="AuditLogQueryFilter"/> via /// toggle — and publishes an <see cref="AuditLogQueryFilter"/> via
/// <see cref="OnFilterChanged"/> when the user clicks Apply. The four /// <see cref="OnFilterChanged"/> when the user clicks Apply. The selected
/// multi-value dimensions map straight through to the filter's list fields; /// dimensions map through to the filter's list fields; see
/// see <see cref="AuditQueryModel"/> for the Errors-only and time-range rules. /// <see cref="AuditQueryModel"/> for the Errors-only and time-range rules.
/// </summary> /// </summary>
public partial class AuditFilterBar public partial class AuditFilterBar
{ {
@@ -82,8 +83,29 @@ public partial class AuditFilterBar
} }
/// <summary> /// <summary>
/// Runs after a Channel selection changes. Drops any Kind selections that fell /// Single-select Channel binding for the filter bar. The Audit Log filters one
/// outside the new visible set — without this, removing a channel could leave /// 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
{
get => _model.Channels.Count > 0 ? _model.Channels.First() : null;
set
{
_model.Channels.Clear();
if (value is { } channel)
{
_model.Channels.Add(channel);
}
OnChannelsChanged();
}
}
/// <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. /// stale Kind selections that no longer match any visible option.
/// </summary> /// </summary>
private void OnChannelsChanged() private void OnChannelsChanged()

View File

@@ -91,9 +91,8 @@ 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. Open the Channel dropdown, tick ApiOutbound, Apply. // the user filters. Pick the ApiOutbound channel, then Apply.
await page.Locator("[data-test='filter-channel-ms-toggle']").ClickAsync(); await page.Locator("[data-test='filter-channel-select']").SelectOptionAsync("ApiOutbound");
await page.Locator("[data-test='filter-channel-ms-opt-ApiOutbound']").ClickAsync();
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);

View File

@@ -13,10 +13,11 @@ 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. The /// The bar carries the 10 spec filter elements plus the Errors-only toggle.
/// Channel / Kind / Status / Site dimensions are rendered as /// Channel is a single-select <c>&lt;select data-test="filter-channel-select"&gt;</c>;
/// Kind / Status / Site are
/// <see cref="ScadaLink.CentralUI.Components.Shared.MultiSelectDropdown{TValue}"/> /// <see cref="ScadaLink.CentralUI.Components.Shared.MultiSelectDropdown{TValue}"/>
/// controls; each option is a checkbox tagged /// controls whose options are checkboxes tagged
/// <c>data-test="filter-&lt;dim&gt;-ms-opt-&lt;value&gt;"</c>. Tests pin: /// <c>data-test="filter-&lt;dim&gt;-ms-opt-&lt;value&gt;"</c>. Tests pin:
/// (1) the full filter set renders; (2) Apply raises <c>OnFilterChanged</c> with /// (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 /// the selected values; (3) the Channel→Kind narrowing map drives Kind option
@@ -75,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: tick a Channel option, 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=\"filter-channel-ms-opt-ApiOutbound\"]").Change(true); 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();
@@ -86,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=\"filter-channel-ms-opt-ApiOutbound\"]").Change(true); cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
cut.Find("[data-test=\"filter-channel-ms-opt-Notification\"]").Change(true); 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]
@@ -116,8 +119,8 @@ public class AuditFilterBarTests : BunitContext
Assert.Contains($"data-test=\"filter-kind-ms-opt-{kind}\"", cut.Markup); Assert.Contains($"data-test=\"filter-kind-ms-opt-{kind}\"", cut.Markup);
} }
// Select only ApiOutbound; Kind options outside the channel-kind map drop out. // Select ApiOutbound; Kind options outside the channel-kind map drop out.
cut.Find("[data-test=\"filter-channel-ms-opt-ApiOutbound\"]").Change(true); 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)