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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") };
|
||||
|
||||
@@ -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") };
|
||||
|
||||
Reference in New Issue
Block a user