- @* Channel chip multi-select. *@
-
-
Channel
-
- @foreach (var channel in Enum.GetValues
())
- {
- var selected = _model.Channels.Contains(channel);
- ToggleChannel(channel)">
- @channel
-
- }
-
-
-
- @* Kind chip multi-select — narrowed by Channel selection. *@
-
-
Kind
-
- @foreach (var kind in _model.VisibleKinds())
- {
- var selected = _model.Kinds.Contains(kind);
- ToggleKind(kind)">
- @kind
-
- }
-
-
-
- @* Status chip multi-select. *@
-
-
Status
-
- @foreach (var status in Enum.GetValues
())
- {
- var selected = _model.Statuses.Contains(status);
- ToggleStatus(status)">
- @status
-
- }
-
-
-
- @* Site chip multi-select — populated from ISiteRepository. *@
-
-
Site
-
- @if (_sites.Count == 0)
- {
- No sites available.
- }
- else
- {
- @foreach (var site in _sites)
- {
- var selected = _model.SiteIdentifiers.Contains(site.SiteIdentifier);
- ToggleSite(site.SiteIdentifier)">
- @site.Name
-
- }
- }
-
-
-
+ @* 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. *@
+
+
+ @* Kind options are narrowed by the Channel selection (VisibleKinds). *@
+
+
+
+
+
+
Time range
/// Filter bar for the central Audit Log page (#23 M7-T2). Owns the
-/// binding state, renders the 10 filter elements
-/// plus the Errors-only toggle, and publishes a collapsed
-/// via when the
-/// user clicks Apply. See for the multi-select →
-/// single-value collapse contract.
+/// binding state and renders the filter controls
+/// — Channel / Kind / Status / Site as compact
+///
+/// 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.
///
public partial class AuditFilterBar
{
private readonly AuditQueryModel _model = new();
private List _sites = new();
+ /// Channel options — the full enum, fixed for the component's lifetime.
+ private static readonly IReadOnlyList _channels = Enum.GetValues();
+
+ /// Status options — the full enum, fixed for the component's lifetime.
+ private static readonly IReadOnlyList _statuses = Enum.GetValues();
+
+ /// Site identifiers in display order; rebuilt once when sites load.
+ private IReadOnlyList _siteIds = Array.Empty();
+
///
- /// Raised when the user clicks Apply. Carries the collapsed
+ /// Raised when the user clicks Apply. Carries the
/// the parent page hands to
/// .
///
@@ -51,10 +63,9 @@ public partial class AuditFilterBar
_model.InstanceSearch = InitialInstanceSearch.Trim();
}
-
- // Populate the Site chips at component init. Failure is non-fatal — the chip
- // section just shows "No sites available." Sites are listed by Name to match
- // operator expectations from the Notification Report.
+ // Populate the Site dropdown at component init. Failure is non-fatal — the
+ // dropdown just shows "No sites available." Sites are listed by Name to
+ // match operator expectations from the Notification Report.
try
{
var sites = await SiteRepository.GetAllSitesAsync();
@@ -62,48 +73,31 @@ public partial class AuditFilterBar
}
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).
_sites = new();
}
+
+ _siteIds = _sites.Select(s => s.SiteIdentifier).ToArray();
}
- private void ToggleChannel(AuditChannel channel)
+ ///
+ /// Runs after a Channel selection changes. Drops any Kind selections that fell
+ /// outside the new visible set — without this, removing a channel could leave
+ /// stale Kind selections that no longer match any visible option.
+ ///
+ private void OnChannelsChanged()
{
- if (!_model.Channels.Add(channel))
- {
- _model.Channels.Remove(channel);
- }
-
- // Drop Kind chips that fall outside the new visible set. Keeps "Channel and
- // Kind both picked" coherent — without this, removing a channel could leave
- // stale Kind chips selected that no longer match any visible chip.
var visible = _model.VisibleKinds().ToHashSet();
_model.Kinds.RemoveWhere(k => !visible.Contains(k));
}
- private void ToggleKind(AuditKind kind)
+ /// Display label for a site identifier — its friendly Name, id as fallback.
+ private string SiteName(string siteIdentifier)
{
- if (!_model.Kinds.Add(kind))
- {
- _model.Kinds.Remove(kind);
- }
- }
-
- 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);
- }
+ var site = _sites.FirstOrDefault(s =>
+ string.Equals(s.SiteIdentifier, siteIdentifier, StringComparison.OrdinalIgnoreCase));
+ return site?.Name ?? siteIdentifier;
}
private void ClearFilters()
@@ -129,11 +123,6 @@ public partial class AuditFilterBar
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
{
AuditTimeRangePreset.Last5Minutes => "now − 5 min → now",
diff --git a/src/ScadaLink.CentralUI/Components/Shared/MultiSelectDropdown.razor b/src/ScadaLink.CentralUI/Components/Shared/MultiSelectDropdown.razor
new file mode 100644
index 0000000..4715f84
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Components/Shared/MultiSelectDropdown.razor
@@ -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.
+*@
+
+
+ @Summary()
+
+
+
diff --git a/src/ScadaLink.CentralUI/Components/Shared/MultiSelectDropdown.razor.cs b/src/ScadaLink.CentralUI/Components/Shared/MultiSelectDropdown.razor.cs
new file mode 100644
index 0000000..18471c2
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Components/Shared/MultiSelectDropdown.razor.cs
@@ -0,0 +1,95 @@
+using Microsoft.AspNetCore.Components;
+
+namespace ScadaLink.CentralUI.Components.Shared;
+
+///
+/// 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.
+///
+///
+/// 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
+/// collection in place and raises
+/// after every toggle so the parent can react
+/// (re-render, prune dependent selections, …).
+///
+///
+///
+/// Requires the Bootstrap JS bundle (loaded in App.razor ) for the
+/// dropdown toggle; data-bs-auto-close="outside" keeps the menu open
+/// while the operator ticks several boxes.
+///
+///
+/// The option value type (an enum or string).
+public partial class MultiSelectDropdown where TValue : notnull
+{
+ /// The options shown in the menu, in display order.
+ [Parameter, EditorRequired]
+ public IReadOnlyList Items { get; set; } = Array.Empty();
+
+ ///
+ /// The caller-owned selection set. Mutated in place by .
+ ///
+ [Parameter, EditorRequired]
+ public ICollection Selected { get; set; } = default!;
+
+ /// Maps an option to its display label. Defaults to ToString() .
+ [Parameter]
+ public Func Display { get; set; } = static v => v.ToString() ?? string.Empty;
+
+ /// Raised after each toggle, once has been updated.
+ [Parameter]
+ public EventCallback SelectionChanged { get; set; }
+
+ /// Summary text shown on the toggle button when nothing is selected.
+ [Parameter]
+ public string AllLabel { get; set; } = "All";
+
+ /// Text shown in the menu when there are no options.
+ [Parameter]
+ public string EmptyText { get; set; } = "None available";
+
+ /// data-test root for this control, its toggle and its options.
+ [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";
+ }
+}
diff --git a/src/ScadaLink.CentralUI/Components/Shared/MultiSelectDropdown.razor.css b/src/ScadaLink.CentralUI/Components/Shared/MultiSelectDropdown.razor.css
new file mode 100644
index 0000000..1a0e4a7
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Components/Shared/MultiSelectDropdown.razor.css
@@ -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 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;
+}
diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs
index 59cffcf..8bce836 100644
--- a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs
+++ b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs
@@ -16,7 +16,7 @@ namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
///
/// Scenarios covered (per the M7-T16 brief):
///
-/// FilterNarrowing — channel chip narrows the results grid.
+/// FilterNarrowing — the channel filter narrows the results grid.
/// DrilldownDrawer_JsonPrettyPrint — JSON request bodies pretty-print.
/// CopyAsCurlButton_VisibleOnApiInbound — cURL action visible for API rows.
/// DrillInFromCorrelationId_AutoLoadsAuditLog — query-string drill-in
@@ -45,7 +45,7 @@ public class AuditLogPageTests
}
[Fact]
- public async Task FilterNarrowing_ChannelChipShrinksGrid()
+ public async Task FilterNarrowing_ChannelFilterShrinksGrid()
{
// 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
@@ -91,13 +91,14 @@ public class AuditLogPageTests
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// Pre-Apply, both rows are absent because the grid stays empty until
- // the user filters. Click the ApiOutbound chip then Apply.
- await page.Locator("[data-test='chip-channel-ApiOutbound']").ClickAsync();
+ // 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();
await page.Locator("[data-test='filter-apply']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// 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 dbRow = page.Locator($"[data-test='grid-row-{dbEventId}']");
await Assertions.Expect(apiRow).ToBeVisibleAsync();
diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs
index 484c2bb..673971e 100644
--- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs
+++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs
@@ -13,13 +13,17 @@ 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. Tests
-/// pin: (1) the full filter set renders; (2) Apply raises OnFilterChanged
-/// with collapsed values; (3) the Channel→Kind narrowing map drives Kind chip
-/// visibility; (4) the Errors-only toggle ORs Failed into Status when
-/// Status is otherwise empty; (5) the "Last hour" preset populates
-/// FromUtc to roughly an hour before "now" — proves the time-window
-/// collapse without freezing the clock.
+/// The bar carries the 10 spec filter elements plus the Errors-only toggle. The
+/// Channel / Kind / Status / Site dimensions are rendered as
+///
+/// controls; each option is a checkbox 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
+/// visibility; (4) the Errors-only toggle ORs the error statuses into Status when
+/// Status is otherwise empty; (5) the "Last hour" preset populates FromUtc
+/// to roughly an hour before "now" — proves the time-window collapse without
+/// freezing the clock.
///
public class AuditFilterBarTests : BunitContext
{
@@ -71,8 +75,8 @@ public class AuditFilterBarTests : BunitContext
var cut = Render(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create(this, f => captured = f)));
- // Drive UI: toggle a Channel chip, type in the Target search box, click Apply.
- cut.Find("[data-test=\"chip-channel-ApiOutbound\"]").Click();
+ // 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);
cut.Find("[data-test=\"filter-target\"] input").Change("Plant-A-OPC");
cut.Find("[data-test=\"filter-apply\"]").Click();
@@ -90,8 +94,8 @@ public class AuditFilterBarTests : BunitContext
var cut = Render(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create(this, f => captured = f)));
- cut.Find("[data-test=\"chip-channel-ApiOutbound\"]").Click();
- cut.Find("[data-test=\"chip-channel-Notification\"]").Click();
+ 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-apply\"]").Click();
Assert.NotNull(captured);
@@ -106,23 +110,23 @@ public class AuditFilterBarTests : BunitContext
{
var cut = Render();
- // 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())
{
- 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.
- cut.Find("[data-test=\"chip-channel-ApiOutbound\"]").Click();
+ // Select only ApiOutbound; Kind options outside the channel-kind map drop out.
+ cut.Find("[data-test=\"filter-channel-ms-opt-ApiOutbound\"]").Change(true);
var apiKinds = AuditQueryModel.KindsByChannel[AuditChannel.ApiOutbound];
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.
- Assert.DoesNotContain($"data-test=\"chip-kind-{AuditKind.NotifySend}\"", cut.Markup);
- Assert.DoesNotContain($"data-test=\"chip-kind-{AuditKind.InboundRequest}\"", cut.Markup);
+ Assert.DoesNotContain($"data-test=\"filter-kind-ms-opt-{AuditKind.NotifySend}\"", cut.Markup);
+ Assert.DoesNotContain($"data-test=\"filter-kind-ms-opt-{AuditKind.InboundRequest}\"", cut.Markup);
}
[Fact]
@@ -144,8 +148,8 @@ public class AuditFilterBarTests : BunitContext
Assert.Contains(AuditStatus.Parked, captured.Statuses);
Assert.Contains(AuditStatus.Discarded, captured.Statuses);
- // Now pin an explicit Status chip — Errors-only must yield (chip wins).
- cut.Find("[data-test=\"chip-status-Delivered\"]").Click();
+ // Now pin an explicit Status option — Errors-only must yield (explicit wins).
+ cut.Find("[data-test=\"filter-status-ms-opt-Delivered\"]").Change(true);
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.Equal(new[] { AuditStatus.Delivered }, captured!.Statuses);
@@ -160,8 +164,8 @@ public class AuditFilterBarTests : BunitContext
var cut = Render(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create(this, f => captured = f)));
- cut.Find("[data-test=\"chip-status-Delivered\"]").Click();
- cut.Find("[data-test=\"chip-status-Failed\"]").Click();
+ cut.Find("[data-test=\"filter-status-ms-opt-Delivered\"]").Change(true);
+ cut.Find("[data-test=\"filter-status-ms-opt-Failed\"]").Change(true);
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);