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.
This commit is contained in:
Joseph Doherty
2026-05-21 09:36:36 -04:00
parent a3eb659b75
commit e36f0bf9c8
7 changed files with 282 additions and 144 deletions

View File

@@ -13,13 +13,17 @@ namespace ScadaLink.CentralUI.Tests.Components.Audit;
/// <summary>
/// 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
/// pin: (1) the full filter set renders; (2) Apply raises <c>OnFilterChanged</c>
/// with collapsed values; (3) the Channel→Kind narrowing map drives Kind chip
/// visibility; (4) the Errors-only toggle ORs <c>Failed</c> 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.
/// The bar carries the 10 spec filter elements plus the Errors-only toggle. The
/// Channel / Kind / Status / Site dimensions are rendered as
/// <see cref="ScadaLink.CentralUI.Components.Shared.MultiSelectDropdown{TValue}"/>
/// controls; each option is a checkbox tagged
/// <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
/// 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>
public class AuditFilterBarTests : BunitContext
{
@@ -71,8 +75,8 @@ public class AuditFilterBarTests : BunitContext
var cut = Render<AuditFilterBar>(p => p
.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.
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<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(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<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>())
{
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<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(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);