feat(centralui): column resize and reorder for the audit results grid

Adds drag-to-resize and drag-to-reorder column UX to AuditResultsGrid,
with chosen widths + column order persisted in browser sessionStorage.

- wwwroot/js/audit-grid.js: dependency-free helper — pointer-driven
  resize handles, native HTML5 drag-and-drop reorder, and a
  sessionStorage save/load wrapper (mirrors treeview-storage.js).
- AuditResultsGrid: renders a resize handle per <th>, makes headers
  draggable, applies persisted widths via a --audit-col-width custom
  property, and wires reorder into the existing ColumnOrder /
  OrderedColumns() mechanism. JS-invokable OnColumnResized /
  OnColumnReordered persist + re-render. A stored order naming an
  unknown column degrades gracefully (drops unknown keys, appends
  missing columns in default order); widths clamp to a 64px minimum.
- AuditResultsGrid.razor.css: subtle scoped styling for the resize
  handle affordance and the reorder drop-target highlight.
- App.razor references audit-grid.js alongside the other scripts.
- Tests: 6 new bUnit tests for the load/apply/persist logic and
  graceful degradation; a new AuditGridColumnTests Playwright suite
  for the drag UX + reload persistence. Audit page bUnit tests set
  loose JSInterop mode since the grid now calls into audit-grid.js.
This commit is contained in:
Joseph Doherty
2026-05-21 06:27:46 -04:00
parent f64a7aed02
commit f1478c5a19
9 changed files with 900 additions and 15 deletions

View File

@@ -43,6 +43,12 @@ public class AuditResultsGridTests : BunitContext
_service = Substitute.For<IAuditLogQueryService>();
_service.DefaultPageSize.Returns(100);
Services.AddSingleton(_service);
// The grid's OnAfterRenderAsync calls into audit-grid.js (init + the
// sessionStorage load). Loose mode lets those unconfigured calls no-op
// — auditGrid.load returns null (no prior state) unless a test sets up
// an explicit JSInterop.Setup to return a stored payload.
JSInterop.Mode = JSRuntimeMode.Loose;
}
private void StubPage(IReadOnlyList<AuditEvent> rows)
@@ -131,4 +137,133 @@ public class AuditResultsGridTests : BunitContext
var deliveredBadge = cut.Find($"[data-test=\"status-badge-{delivered.EventId}\"]");
Assert.Contains("bg-success", deliveredBadge.GetAttribute("class") ?? string.Empty);
}
// --- column resize + reorder UX (#23 follow-ups Task 10) ---------------
//
// The drag interaction itself is browser-side (audit-grid.js) and covered
// by the Playwright suite. The bUnit tests below exercise the .NET-side
// load/apply/persist logic that the JS callbacks drive: graceful handling
// of stored orders, the reorder slot-move maths, and the resize minimum.
/// <summary>Column keys in default (spec) order — the fallback used everywhere.</summary>
private static readonly string[] DefaultOrder =
{
"OccurredAtUtc", "Site", "Channel", "Kind", "Status",
"Target", "Actor", "DurationMs", "HttpStatus", "ErrorMessage",
};
private static int HeaderIndex(string markup, string key)
=> markup.IndexOf($"data-col-key=\"{key}\"", StringComparison.Ordinal);
[Fact]
public void Headers_RenderResizeHandleAndDragKey_ForEveryColumn()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
foreach (var key in DefaultOrder)
{
// Each <th> carries the stable drag key and a resize handle.
Assert.Contains($"data-col-key=\"{key}\"", cut.Markup);
Assert.Contains($"data-test=\"col-resize-{key}\"", cut.Markup);
}
}
[Fact]
public void ColumnOrderParameter_DrivesHeaderOrder()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
var cut = Render<AuditResultsGrid>(p => p
.Add(c => c.Filter, new AuditLogQueryFilter())
.Add(c => c.ColumnOrder, new[] { "Status", "Site" }));
// Status + Site move to the front; the omitted columns still render,
// appended in default order — Status precedes Site precedes Channel.
Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "Site"));
Assert.True(HeaderIndex(cut.Markup, "Site") < HeaderIndex(cut.Markup, "Channel"));
// No column is dropped — all ten headers are present.
foreach (var key in DefaultOrder)
{
Assert.Contains($"data-col-key=\"{key}\"", cut.Markup);
}
}
[Fact]
public async Task OnColumnReordered_MovesColumnIntoTargetSlot_AndPersists()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// Drag Status onto OccurredAtUtc — Status should land in slot 0.
await cut.InvokeAsync(() => cut.Instance.OnColumnReordered("Status", "OccurredAtUtc"));
Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "OccurredAtUtc"));
// The new order was persisted to sessionStorage under the order key.
// Loose-mode JSInterop records every InvokeVoidAsync; find the save call.
var save = JSInterop.Invocations
.Single(i => i.Identifier == "auditGrid.save" && (string)i.Arguments[0]! == "columnOrder");
Assert.Contains("Status", (string)save.Arguments[1]!);
}
[Fact]
public async Task OnColumnResized_BelowMinimum_ClampsTo64px_AndPersists()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// A drag that would shrink the column to 10px must clamp to the 64px floor.
await cut.InvokeAsync(() => cut.Instance.OnColumnResized("Target", 10));
// The clamped width is reflected as the --audit-col-width custom property.
Assert.Contains("--audit-col-width: 64px", cut.Markup);
// The width was persisted to sessionStorage under the widths key.
Assert.Contains(JSInterop.Invocations,
i => i.Identifier == "auditGrid.save" && (string)i.Arguments[0]! == "columnWidths");
}
[Fact]
public void StoredOrder_WithUnknownKey_DegradesGracefully()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
// A stale persisted order naming a removed column ("LegacyCol") plus a
// subset of real columns — the unknown key must be dropped and the
// omitted real columns appended in default order, never throwing.
JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnOrder")
.SetResult("[\"Status\",\"LegacyCol\",\"Site\"]");
JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnWidths")
.SetResult((string?)null);
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// Restored order applied: Status then Site at the front.
Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "Site"));
// The unknown key produced no header and did not break rendering.
Assert.DoesNotContain("LegacyCol", cut.Markup);
// All ten real columns still present.
foreach (var key in DefaultOrder)
{
Assert.Contains($"data-col-key=\"{key}\"", cut.Markup);
}
}
[Fact]
public void StoredWidths_ForUnknownColumn_AreIgnored()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnOrder")
.SetResult((string?)null);
// A width for a real column and one for a removed column.
JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnWidths")
.SetResult("{\"Target\":220,\"LegacyCol\":300}");
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// The valid column's width was applied; the stale one silently ignored.
Assert.Contains("--audit-col-width: 220px", cut.Markup);
Assert.DoesNotContain("300px", cut.Markup);
}
}

View File

@@ -46,6 +46,15 @@ namespace ScadaLink.CentralUI.Tests.Pages;
/// </summary>
public class AuditLogPagePermissionTests : BunitContext
{
public AuditLogPagePermissionTests()
{
// The page hosts AuditResultsGrid, whose OnAfterRenderAsync wires the
// column resize/reorder UX via audit-grid.js (a sessionStorage load +
// an init call). Loose mode lets those unconfigured JS calls no-op so
// the permission-gating tests need not configure browser interop.
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
{
var claims = new List<Claim> { new("Username", "tester") };

View File

@@ -28,6 +28,15 @@ namespace ScadaLink.CentralUI.Tests.Pages;
/// </summary>
public class AuditLogPageScaffoldTests : BunitContext
{
public AuditLogPageScaffoldTests()
{
// The page hosts AuditResultsGrid, whose OnAfterRenderAsync wires the
// column resize/reorder UX via audit-grid.js (a sessionStorage load +
// an init call). Loose mode lets those unconfigured JS calls no-op so
// the page scaffold smoke tests need not configure browser interop.
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
{
var claims = new List<Claim> { new("Username", "tester") };