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:
@@ -0,0 +1,233 @@
|
||||
using Microsoft.Playwright;
|
||||
using Xunit;
|
||||
|
||||
namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end coverage for the Audit Log results-grid column UX (#23
|
||||
/// follow-ups Task 10): drag-to-resize and drag-to-reorder columns, with the
|
||||
/// chosen widths + order persisted in the browser's <c>sessionStorage</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// The drag interaction is browser-side (<c>wwwroot/js/audit-grid.js</c>), so
|
||||
/// Playwright — not bUnit — is the right tool: bUnit cannot drive the native
|
||||
/// HTML5 drag-and-drop or pointer-capture resize. Each test seeds one
|
||||
/// <c>AuditLog</c> row via <see cref="AuditDataSeeder"/> so the grid has a
|
||||
/// header row to act on, then best-effort deletes it.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The DB-seeding tests are <see cref="SkippableFactAttribute"/> + <c>Skip.IfNot</c>:
|
||||
/// when the cluster / MSSQL is unreachable they report as Skipped (not Failed),
|
||||
/// matching the established <see cref="SiteCalls.SiteCallsPageTests"/> idiom.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class AuditGridColumnTests
|
||||
{
|
||||
private const string AuditLogUrl = "/audit/log";
|
||||
|
||||
/// <summary>Skip reason shared by the DB-seeding tests when MSSQL is down.</summary>
|
||||
private const string DbUnavailableSkipReason =
|
||||
"AuditDataSeeder cannot reach MSSQL at localhost:1433 — bring up infra/docker-compose and docker/deploy.sh, " +
|
||||
"or set SCADALINK_PLAYWRIGHT_DB to a reachable connection string.";
|
||||
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
|
||||
public AuditGridColumnTests(PlaywrightFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds one audit row, opens the Audit Log page, and clicks Apply so the
|
||||
/// results grid renders a header row the column tests can act on.
|
||||
/// </summary>
|
||||
private async Task<IPage> OpenGridWithSeededRowAsync(string targetPrefix, Guid eventId)
|
||||
{
|
||||
await AuditDataSeeder.InsertAuditEventAsync(
|
||||
eventId: eventId,
|
||||
occurredAtUtc: DateTime.UtcNow,
|
||||
channel: "ApiOutbound",
|
||||
kind: "ApiCall",
|
||||
status: "Delivered",
|
||||
target: targetPrefix + "endpoint",
|
||||
httpStatus: 200,
|
||||
durationMs: 25);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{AuditLogUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Apply with no chips — the default LastHour range matches the fresh row.
|
||||
await page.Locator("[data-test='filter-apply']").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
var row = page.Locator($"[data-test='grid-row-{eventId}']");
|
||||
await Assertions.Expect(row).ToBeVisibleAsync();
|
||||
return page;
|
||||
}
|
||||
|
||||
/// <summary>Pixel width of a header cell, measured from its bounding box.</summary>
|
||||
private static async Task<double> HeaderWidthAsync(IPage page, string columnKey)
|
||||
{
|
||||
var box = await page.Locator($"[data-col-key='{columnKey}']").BoundingBoxAsync();
|
||||
Assert.NotNull(box);
|
||||
return box!.Width;
|
||||
}
|
||||
|
||||
/// <summary>The ordered list of column keys as currently rendered in the header.</summary>
|
||||
private static async Task<IReadOnlyList<string>> HeaderOrderAsync(IPage page)
|
||||
{
|
||||
return await page.Locator("thead th[data-col-key]")
|
||||
.EvaluateAllAsync<string[]>("els => els.map(e => e.getAttribute('data-col-key'))");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task ResizeHandle_DraggingWidensColumn_AndSurvivesReload()
|
||||
{
|
||||
Skip.IfNot(await AuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var targetPrefix = $"playwright-test/grid-resize/{runId}/";
|
||||
var eventId = Guid.NewGuid();
|
||||
|
||||
try
|
||||
{
|
||||
var page = await OpenGridWithSeededRowAsync(targetPrefix, eventId);
|
||||
|
||||
const string columnKey = "Target";
|
||||
var before = await HeaderWidthAsync(page, columnKey);
|
||||
|
||||
// Drag the resize handle on the column's right edge 120px to the
|
||||
// right. The handle is a thin strip; grab its centre and drag.
|
||||
var handle = page.Locator($"[data-test='col-resize-{columnKey}']");
|
||||
var handleBox = await handle.BoundingBoxAsync();
|
||||
Assert.NotNull(handleBox);
|
||||
var startX = handleBox!.X + handleBox.Width / 2;
|
||||
var startY = handleBox.Y + handleBox.Height / 2;
|
||||
|
||||
await page.Mouse.MoveAsync(startX, startY);
|
||||
await page.Mouse.DownAsync();
|
||||
await page.Mouse.MoveAsync(startX + 120, startY, new MouseMoveOptions { Steps = 8 });
|
||||
await page.Mouse.UpAsync();
|
||||
|
||||
var after = await HeaderWidthAsync(page, columnKey);
|
||||
Assert.True(after > before + 40,
|
||||
$"Expected the {columnKey} column to widen after the resize drag (before={before}, after={after}).");
|
||||
|
||||
// Reload: the persisted width is restored from sessionStorage.
|
||||
await page.ReloadAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
await page.Locator("[data-test='filter-apply']").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
var afterReload = await HeaderWidthAsync(page, columnKey);
|
||||
// Allow a small tolerance for sub-pixel layout rounding.
|
||||
Assert.True(Math.Abs(afterReload - after) < 8,
|
||||
$"Expected the resized width to survive a reload (after={after}, afterReload={afterReload}).");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task ReorderDrag_MovesColumn_AndSurvivesReload()
|
||||
{
|
||||
Skip.IfNot(await AuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var targetPrefix = $"playwright-test/grid-reorder/{runId}/";
|
||||
var eventId = Guid.NewGuid();
|
||||
|
||||
try
|
||||
{
|
||||
var page = await OpenGridWithSeededRowAsync(targetPrefix, eventId);
|
||||
|
||||
var initialOrder = await HeaderOrderAsync(page);
|
||||
// Default order opens with OccurredAtUtc first, Status fifth.
|
||||
Assert.Equal("OccurredAtUtc", initialOrder[0]);
|
||||
Assert.Contains("Status", initialOrder);
|
||||
|
||||
// Drag the Status header onto the OccurredAtUtc header — Status
|
||||
// should move into the leading slot.
|
||||
var source = page.Locator("[data-col-key='Status']");
|
||||
var target = page.Locator("[data-col-key='OccurredAtUtc']");
|
||||
await source.DragToAsync(target);
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
var afterOrder = await HeaderOrderAsync(page);
|
||||
Assert.Equal("Status", afterOrder[0]);
|
||||
Assert.True(afterOrder.ToList().IndexOf("Status") < afterOrder.ToList().IndexOf("OccurredAtUtc"),
|
||||
"Expected Status to be reordered ahead of OccurredAtUtc.");
|
||||
|
||||
// Reload: the persisted order is restored from sessionStorage.
|
||||
await page.ReloadAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
await page.Locator("[data-test='filter-apply']").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
var afterReload = await HeaderOrderAsync(page);
|
||||
Assert.Equal("Status", afterReload[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task ColumnOrderAndWidths_PersistAcrossReload_ViaSessionStorage()
|
||||
{
|
||||
Skip.IfNot(await AuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var targetPrefix = $"playwright-test/grid-persist/{runId}/";
|
||||
var eventId = Guid.NewGuid();
|
||||
|
||||
try
|
||||
{
|
||||
var page = await OpenGridWithSeededRowAsync(targetPrefix, eventId);
|
||||
|
||||
// Reorder then resize, then confirm sessionStorage carries both.
|
||||
await page.Locator("[data-col-key='Status']")
|
||||
.DragToAsync(page.Locator("[data-col-key='OccurredAtUtc']"));
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
var handle = page.Locator("[data-test='col-resize-Target']");
|
||||
var handleBox = await handle.BoundingBoxAsync();
|
||||
Assert.NotNull(handleBox);
|
||||
var startX = handleBox!.X + handleBox.Width / 2;
|
||||
var startY = handleBox.Y + handleBox.Height / 2;
|
||||
await page.Mouse.MoveAsync(startX, startY);
|
||||
await page.Mouse.DownAsync();
|
||||
await page.Mouse.MoveAsync(startX + 90, startY, new MouseMoveOptions { Steps = 6 });
|
||||
await page.Mouse.UpAsync();
|
||||
|
||||
// Both keys are written under the auditGrid: namespace.
|
||||
var orderJson = await page.EvaluateAsync<string?>(
|
||||
"() => sessionStorage.getItem('auditGrid:columnOrder')");
|
||||
var widthsJson = await page.EvaluateAsync<string?>(
|
||||
"() => sessionStorage.getItem('auditGrid:columnWidths')");
|
||||
Assert.NotNull(orderJson);
|
||||
Assert.Contains("Status", orderJson!);
|
||||
Assert.NotNull(widthsJson);
|
||||
Assert.Contains("Target", widthsJson!);
|
||||
|
||||
// After a reload the restored grid reflects the stored order.
|
||||
await page.ReloadAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
await page.Locator("[data-test='filter-apply']").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
var restoredOrder = await HeaderOrderAsync(page);
|
||||
Assert.Equal("Status", restoredOrder[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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