From 77922abb33a5b3c24f5f944c407fb570784609e2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 10:02:17 -0400 Subject: [PATCH] feat(centralui): single-select Channel filter on the Audit Log page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Components/Audit/AuditFilterBar.razor | 27 +++++++------- .../Components/Audit/AuditFilterBar.razor.cs | 36 +++++++++++++++---- .../Audit/AuditLogPageTests.cs | 5 ++- .../Components/Audit/AuditFilterBarTests.cs | 35 +++++++++--------- 4 files changed, 65 insertions(+), 38 deletions(-) diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor index 22195f4..21f2fe1 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor @@ -6,20 +6,23 @@
- @* All filters sit in one wrapped row. The four multi-value dimensions - (Channel / Kind / Status / Site) use compact MultiSelectDropdown - controls so the bar stays a row or two tall instead of four stacked - blocks of chip buttons. *@ + @* All filters sit in one wrapped row. Kind / Status / Site use compact + MultiSelectDropdown controls; Channel is a single-select because the + Kind options narrow to the chosen channel — so the bar stays a row or + two tall instead of four stacked blocks of chip buttons. *@
+ @* Single-select: one channel at a time, so the Kind options below + narrow cleanly to that channel. "All channels" clears it. *@
- -
- -
+ +
@* Kind options are narrowed by the Channel selection (VisibleKinds). *@ diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs index 051b0c6..8966503 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs @@ -8,13 +8,14 @@ namespace ScadaLink.CentralUI.Components.Audit; /// /// Filter bar for the central Audit Log page (#23 M7-T2). Owns the /// 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 /// -/// 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 via -/// when the user clicks Apply. The four -/// multi-value dimensions map straight through to the filter's list fields; -/// see for the Errors-only and time-range rules. +/// when the user clicks Apply. The selected +/// dimensions map through to the filter's list fields; see +/// for the Errors-only and time-range rules. /// public partial class AuditFilterBar { @@ -82,8 +83,29 @@ public partial class AuditFilterBar } /// - /// Runs after a Channel selection changes. Drops any Kind selections that fell - /// outside the new visible set — without this, removing a channel could leave + /// 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 + /// and are unchanged. null = all channels. + /// + 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(); + } + } + + /// + /// 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. /// private void OnChannelsChanged() diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs index 8bce836..68305d0 100644 --- a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs +++ b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs @@ -91,9 +91,8 @@ public class AuditLogPageTests await page.WaitForLoadStateAsync(LoadState.NetworkIdle); // Pre-Apply, both rows are absent because the grid stays empty until - // the user filters. Open the Channel dropdown, tick ApiOutbound, Apply. - await page.Locator("[data-test='filter-channel-ms-toggle']").ClickAsync(); - await page.Locator("[data-test='filter-channel-ms-opt-ApiOutbound']").ClickAsync(); + // the user filters. Pick the ApiOutbound channel, then Apply. + await page.Locator("[data-test='filter-channel-select']").SelectOptionAsync("ApiOutbound"); await page.Locator("[data-test='filter-apply']").ClickAsync(); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs index 673971e..d62918d 100644 --- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs @@ -13,10 +13,11 @@ namespace ScadaLink.CentralUI.Tests.Components.Audit; /// /// bUnit tests for (#23 M7-T2 / Bundle B). /// -/// The bar carries the 10 spec filter elements plus the Errors-only toggle. The -/// Channel / Kind / Status / Site dimensions are rendered as +/// The bar carries the 10 spec filter elements plus the Errors-only toggle. +/// Channel is a single-select <select data-test="filter-channel-select">; +/// Kind / Status / Site are /// -/// controls; each option is a checkbox tagged +/// controls whose options are checkboxes tagged /// data-test="filter-<dim>-ms-opt-<value>". Tests pin: /// (1) the full filter set renders; (2) Apply raises OnFilterChanged with /// the selected values; (3) the Channel→Kind narrowing map drives Kind option @@ -75,8 +76,8 @@ public class AuditFilterBarTests : BunitContext var cut = Render(p => p .Add(c => c.OnFilterChanged, EventCallback.Factory.Create(this, f => captured = f))); - // Drive UI: tick a Channel option, type in the Target search box, click Apply. - cut.Find("[data-test=\"filter-channel-ms-opt-ApiOutbound\"]").Change(true); + // Drive UI: pick a Channel, type in the Target search box, click Apply. + 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-apply\"]").Click(); @@ -86,23 +87,25 @@ public class AuditFilterBarTests : BunitContext } [Fact] - public void Apply_WithMultipleChannelChips_PassesAllSelectedChannels() + public void ChangingChannel_ReplacesTheSelection_SingleSelect() { - // Task 9: ToFilter no longer collapses the chip multi-select — every - // selected channel chip reaches the filter's Channels list. + // Channel is single-select: picking a second channel replaces the first + // rather than adding to it (the page filters one channel at a time). AuditLogQueryFilter? captured = null; var cut = Render(p => p .Add(c => c.OnFilterChanged, EventCallback.Factory.Create(this, f => captured = f))); - cut.Find("[data-test=\"filter-channel-ms-opt-ApiOutbound\"]").Change(true); - cut.Find("[data-test=\"filter-channel-ms-opt-Notification\"]").Change(true); + cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound"); + cut.Find("[data-test=\"filter-channel-select\"]").Change("Notification"); cut.Find("[data-test=\"filter-apply\"]").Click(); Assert.NotNull(captured); - Assert.NotNull(captured!.Channels); - Assert.Equal(2, captured.Channels!.Count); - Assert.Contains(AuditChannel.ApiOutbound, captured.Channels); - Assert.Contains(AuditChannel.Notification, captured.Channels); + Assert.Equal(new[] { AuditChannel.Notification }, captured!.Channels); + + // Selecting "All channels" clears the channel filter entirely. + cut.Find("[data-test=\"filter-channel-select\"]").Change(string.Empty); + cut.Find("[data-test=\"filter-apply\"]").Click(); + Assert.Null(captured!.Channels); } [Fact] @@ -116,8 +119,8 @@ public class AuditFilterBarTests : BunitContext Assert.Contains($"data-test=\"filter-kind-ms-opt-{kind}\"", cut.Markup); } - // Select only ApiOutbound; Kind options outside the channel-kind map drop out. - cut.Find("[data-test=\"filter-channel-ms-opt-ApiOutbound\"]").Change(true); + // Select ApiOutbound; Kind options outside the channel-kind map drop out. + cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound"); var apiKinds = AuditQueryModel.KindsByChannel[AuditChannel.ApiOutbound]; foreach (var kind in apiKinds)