diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor index df000aa..3610cc8 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor @@ -12,12 +12,20 @@ }
- +
@foreach (var col in OrderedColumns()) { - + } @@ -48,7 +56,7 @@ @onclick="() => HandleRowClick(row)"> @foreach (var col in OrderedColumns()) { - } diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs index cfbae61..928a050 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs @@ -1,4 +1,6 @@ +using System.Text.Json; using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Types.Audit; using ScadaLink.Commons.Types.Enums; @@ -14,12 +16,15 @@ namespace ScadaLink.CentralUI.Components.Audit; /// source without standing up EF Core. /// /// -/// Column model. Each column has a stable string key; the visible order -/// is the parameter. M7 scope: the column-model -/// framework is in place but resize / drag-reorder UX is intentionally NOT -/// implemented — the full spec calls for persisted-per-user reordering and -/// resizing, which M7.x can ship without rewriting the column model. Resizing -/// today is CSS-based via Bootstrap's .table-responsive wrapper. +/// Column model. Each column has a stable string key. The default +/// visible order is the parameter (or the spec +/// order from Component-AuditLog.md §10 when the parameter is null). On top of +/// that default the grid layers a per-browser override: drag-to-reorder and +/// drag-to-resize UX (audit-grid.js) writes the chosen order + per-column +/// widths to sessionStorage, and the grid restores them on first +/// render. A stored order that names an unknown/removed column degrades +/// gracefully — unknown keys are dropped, missing columns appended in default +/// order — so it never throws. /// /// /// @@ -33,10 +38,17 @@ namespace ScadaLink.CentralUI.Components.Audit; /// end" signal for keyset paging without a count query. /// /// -public partial class AuditResultsGrid +public partial class AuditResultsGrid : IAsyncDisposable { private const int DefaultPageSize = 100; + /// Minimum persisted column width — mirrors auditGrid.minWidth. + private const int MinColumnWidthPx = 64; + + /// sessionStorage keys (namespaced under auditGrid: by the JS helper). + private const string ColumnOrderStorageKey = "columnOrder"; + private const string ColumnWidthsStorageKey = "columnWidths"; + private readonly List _rows = new(); private int _pageNumber = 1; private bool _loading; @@ -44,6 +56,18 @@ public partial class AuditResultsGrid private AuditLogQueryFilter? _activeFilter; + [Inject] private IJSRuntime JS { get; set; } = default!; + + private ElementReference _tableRef; + private DotNetObjectReference? _selfRef; + + // Effective column state. _columnOrder is the live display order (seeded + // from the ColumnOrder parameter / spec default, then overridden by any + // persisted sessionStorage order). _columnWidths holds per-key pixel + // widths from a prior resize; absent keys render at auto width. + private List? _columnOrder; + private readonly Dictionary _columnWidths = new(); + /// /// Filter to apply. When this parameter changes the grid resets to page 1 and /// reissues the query — that's the contract the parent page relies on so the @@ -90,24 +114,57 @@ public partial class AuditResultsGrid }; private IReadOnlyList<(string Key, string Label)> OrderedColumns() + => ResolveOrder(_columnOrder ?? ColumnOrder); + + /// + /// Resolves a candidate list of column keys into the concrete display + /// columns. Degrades gracefully so a stale persisted order is never fatal: + /// unknown keys are dropped, and any column not named in the candidate + /// list is appended in its default (spec) position. A null/empty candidate + /// yields the full default order. + /// + private static IReadOnlyList<(string Key, string Label)> ResolveOrder(IReadOnlyList? candidate) { - if (ColumnOrder is null || ColumnOrder.Count == 0) + if (candidate is null || candidate.Count == 0) { return AllColumns; } var byKey = AllColumns.ToDictionary(c => c.Key, c => c); - var ordered = new List<(string Key, string Label)>(ColumnOrder.Count); - foreach (var key in ColumnOrder) + var ordered = new List<(string Key, string Label)>(AllColumns.Count); + var seen = new HashSet(); + foreach (var key in candidate) { - if (byKey.TryGetValue(key, out var col)) + // Drop unknown keys (removed/renamed columns) and any duplicates. + if (byKey.TryGetValue(key, out var col) && seen.Add(key)) { ordered.Add(col); } } - return ordered.Count == 0 ? AllColumns : ordered; + + // Append any columns the candidate omitted, in default order, so a + // newly-added column still appears after a restore of an older order. + foreach (var col in AllColumns) + { + if (seen.Add(col.Key)) + { + ordered.Add(col); + } + } + + return ordered; } + /// + /// Inline style for a column's cells: emits the --audit-col-width + /// custom property the scoped stylesheet reads, or an empty string when + /// the column has no persisted width (auto layout). + /// + private string ColumnWidthStyle(string key) + => _columnWidths.TryGetValue(key, out var width) + ? $"--audit-col-width: {width}px;" + : string.Empty; + protected override async Task OnParametersSetAsync() { // Reset & reload whenever the filter reference changes. AuditLogQueryFilter @@ -180,6 +237,173 @@ public partial class AuditResultsGrid } } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + // Restore any persisted order + widths first; the StateHasChanged + // inside triggers a re-render so the restored layout is on screen. + await LoadPersistedStateAsync(); + _selfRef = DotNetObjectReference.Create(this); + } + + // Wire (or re-wire) the JS drag handlers on every render. auditGrid.init + // is idempotent — already-bound cells are skipped, and the .NET + // reference is refreshed — so a re-render after a reorder still leaves + // every header cell wired without leaking handlers. + if (_selfRef is not null) + { + try + { + await JS.InvokeVoidAsync("auditGrid.init", _tableRef, _selfRef); + } + catch (JSDisconnectedException) + { + // Circuit gone before init completed — nothing to wire. + } + } + } + + /// + /// Reads the persisted column order + widths from sessionStorage and + /// applies them. A missing, empty, or corrupt payload is treated as "no + /// prior state" — the grid keeps its default order/widths and never throws. + /// + private async Task LoadPersistedStateAsync() + { + var orderJson = await TryLoadAsync(ColumnOrderStorageKey); + var widthsJson = await TryLoadAsync(ColumnWidthsStorageKey); + + var changed = false; + + if (!string.IsNullOrEmpty(orderJson)) + { + try + { + var stored = JsonSerializer.Deserialize>(orderJson); + if (stored is { Count: > 0 }) + { + // Normalise through ResolveOrder so a stale key never sticks. + _columnOrder = ResolveOrder(stored).Select(c => c.Key).ToList(); + changed = true; + } + } + catch (JsonException) + { + // Corrupt payload — ignore, keep the default order. + } + } + + if (!string.IsNullOrEmpty(widthsJson)) + { + try + { + var stored = JsonSerializer.Deserialize>(widthsJson); + if (stored is not null) + { + var validKeys = AllColumns.Select(c => c.Key).ToHashSet(); + _columnWidths.Clear(); + foreach (var (key, width) in stored) + { + // Drop widths for unknown columns; clamp to the minimum. + if (validKeys.Contains(key)) + { + _columnWidths[key] = Math.Max(MinColumnWidthPx, width); + } + } + changed = _columnWidths.Count > 0 || changed; + } + } + catch (JsonException) + { + // Corrupt payload — ignore, keep auto widths. + } + } + + if (changed) + { + StateHasChanged(); + } + } + + private async Task TryLoadAsync(string key) + { + try + { + return await JS.InvokeAsync("auditGrid.load", key); + } + catch (JSDisconnectedException) + { + return null; + } + } + + /// + /// JS callback: the user finished resizing a column. Persists the new + /// per-column width and re-renders so the body cells track the header. + /// + [JSInvokable] + public async Task OnColumnResized(string columnKey, int widthPx) + { + if (!AllColumns.Any(c => c.Key == columnKey)) + { + return; + } + + _columnWidths[columnKey] = Math.Max(MinColumnWidthPx, widthPx); + await SaveAsync(ColumnWidthsStorageKey, JsonSerializer.Serialize(_columnWidths)); + StateHasChanged(); + } + + /// + /// JS callback: the user dropped column onto the + /// header of . Moves the dragged column into the + /// target's slot, persists the resulting order, and re-renders. + /// + [JSInvokable] + public async Task OnColumnReordered(string fromKey, string toKey) + { + // Start from the current effective order so successive drags compose. + var order = OrderedColumns().Select(c => c.Key).ToList(); + var fromIndex = order.IndexOf(fromKey); + var toIndex = order.IndexOf(toKey); + if (fromIndex < 0 || toIndex < 0 || fromIndex == toIndex) + { + return; + } + + order.RemoveAt(fromIndex); + // After the removal the target index shifts left by one when the + // dragged column originally sat before it. + if (fromIndex < toIndex) + { + toIndex--; + } + order.Insert(toIndex, fromKey); + + _columnOrder = order; + await SaveAsync(ColumnOrderStorageKey, JsonSerializer.Serialize(order)); + StateHasChanged(); + } + + private async Task SaveAsync(string key, string json) + { + try + { + await JS.InvokeVoidAsync("auditGrid.save", key, json); + } + catch (JSDisconnectedException) + { + // Circuit gone — the in-memory state still drives this render. + } + } + + public ValueTask DisposeAsync() + { + _selfRef?.Dispose(); + return ValueTask.CompletedTask; + } + private static string StatusBadgeClass(AuditStatus status) => status switch { AuditStatus.Delivered => "badge bg-success", diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.css b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.css new file mode 100644 index 0000000..f89edad --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.css @@ -0,0 +1,82 @@ +/* Audit results grid — column resize + reorder UX (#23 follow-ups Task 10). + The base .table classes come from Bootstrap; the rules below add the + resize-handle affordance and the drag-to-reorder drop feedback. The + interaction itself lives in wwwroot/js/audit-grid.js — this file is purely + the visual treatment. Internal-tool aesthetic: subtle, no flashy motion. */ + +/* A persisted width is delivered as the --audit-col-width custom property on + the
@col.Label + @col.Label + +
+ @RenderCell(col.Key, row) and matching cells (set inline by the component / by + audit-grid.js during a drag). When present it pins the cell; when absent + the column falls back to Bootstrap auto-layout. The body cells also clip + overflowing text so a narrowed column stays tidy. */ +.audit-grid-th[style*="--audit-col-width"], +.audit-grid-td[style*="--audit-col-width"] { + width: var(--audit-col-width); + min-width: var(--audit-col-width); + max-width: var(--audit-col-width); +} + +.audit-grid-td[style*="--audit-col-width"] { + overflow: hidden; + text-overflow: ellipsis; +} + +/* The header cell hosts the resize handle on its right edge, so it must be a + positioning context. Padding on the right is trimmed so the 6px handle does + not crowd the label text. */ +.audit-grid-th { + position: relative; + padding-right: 0.75rem; + /* The whole header is draggable for reorder — a grab cursor signals it. */ + cursor: grab; + user-select: none; + white-space: nowrap; +} + +.audit-grid-th:active { + cursor: grabbing; +} + +/* V — resize handle. A thin invisible hit-strip on the right edge: 6px wide + for a comfortable grab target, transparent at rest so the header reads + clean. On hover a hairline primary rule fades in via the inset box-shadow + so the affordance is discoverable without being visually noisy. */ +.audit-grid-resize-handle { + position: absolute; + top: 0; + right: 0; + width: 6px; + height: 100%; + cursor: col-resize; + /* Sit above the draggable header so a resize never starts a reorder. */ + z-index: 1; + transition: box-shadow 0.08s linear, background-color 0.08s linear; +} + +.audit-grid-resize-handle:hover { + /* Hairline rule centred on the strip's right edge. */ + box-shadow: inset -2px 0 0 -1px rgba(var(--bs-primary-rgb), 0.55); + background-color: rgba(var(--bs-primary-rgb), 0.06); +} + +/* While a drag-resize is in progress the column gets a steady primary rule on + its right edge so the user keeps a clear visual anchor. */ +.audit-grid-th.resizing { + box-shadow: inset -2px 0 0 0 var(--bs-primary); +} + +.audit-grid-th.resizing .audit-grid-resize-handle { + background-color: rgba(var(--bs-primary-rgb), 0.55); +} + +/* V — reorder feedback. The dragged header dims slightly; the prospective + drop target gets a left-edge accent rule + a faint info wash, matching the + TreeView drop-target idiom (a quiet, unmistakable cue, not an animation). */ +.audit-grid-th.dragging { + opacity: 0.45; +} + +.audit-grid-th.drop-target { + background-color: rgba(var(--bs-info-rgb), 0.18); + box-shadow: inset 2px 0 0 0 var(--bs-info); +} diff --git a/src/ScadaLink.CentralUI/wwwroot/js/audit-grid.js b/src/ScadaLink.CentralUI/wwwroot/js/audit-grid.js new file mode 100644 index 0000000..a1ce628 --- /dev/null +++ b/src/ScadaLink.CentralUI/wwwroot/js/audit-grid.js @@ -0,0 +1,184 @@ +// Audit results grid column UX (#23 follow-ups Task 10). +// +// A tiny, dependency-free helper for the AuditResultsGrid component: +// - drag-to-resize: a pointer-driven handle on each 's right edge, +// - drag-to-reorder: native HTML5 drag-and-drop on the header row, +// - save/load: a sessionStorage round-trip, mirroring treeview-storage.js. +// +// The Blazor component owns the column model; this file is purely the +// browser-side drag plumbing. After a resize or reorder it calls back into +// .NET via a DotNetObjectReference so the component can persist + re-render. +// +// No drag-drop libraries — hand-rolled pointer + native-DnD handlers only. +window.auditGrid = { + // --- sessionStorage wrapper (mirrors window.treeviewStorage) ----------- + // Keys are namespaced under "auditGrid:" so they never collide with the + // treeview's "treeview:" namespace. + save: function (key, json) { + try { + sessionStorage.setItem("auditGrid:" + key, json); + } catch { + // Quota / privacy-mode failures are non-fatal — the grid simply + // falls back to defaults on the next load. + } + }, + + load: function (key) { + try { + return sessionStorage.getItem("auditGrid:" + key); + } catch { + return null; + } + }, + + // Minimum column width in pixels. A column can never be dragged narrower + // than this so a header can't collapse to an unclickable sliver. + minWidth: 64, + + // --- wire-up ---------------------------------------------------------- + // `table` is the element, `dotNet` is a DotNetObjectReference + // exposing OnColumnResized / OnColumnReordered. Safe to call on every + // render: it re-scans the header and binds only cells not already bound, + // and always refreshes the live .NET reference. Handlers read the column + // key live from data-col-key at event time, so Blazor reusing a
DOM + // node for a different column (after a reorder re-render) is harmless. + init: function (table, dotNet) { + if (!table) { + return; + } + table.__auditGridDotNet = dotNet; + + var headerRow = table.tHead && table.tHead.rows[0]; + if (!headerRow) { + return; + } + + for (var i = 0; i < headerRow.cells.length; i++) { + this._bindHeaderCell(table, headerRow.cells[i]); + } + }, + + // Bind resize + reorder handlers to a single . Idempotent — a cell + // already carrying handlers is skipped. The handlers resolve the column + // key live (th.getAttribute) so they stay correct if the renderer reuses + // the element for another column. + _bindHeaderCell: function (table, th) { + var self = this; + if (th.__auditGridCellBound) { + return; + } + th.__auditGridCellBound = true; + + // --- resize: pointer drag on the handle --------------------------- + var handle = th.querySelector(".audit-grid-resize-handle"); + if (handle) { + handle.addEventListener("pointerdown", function (ev) { + ev.preventDefault(); + // Stop the pointerdown from also starting a header drag. + ev.stopPropagation(); + + var startX = ev.clientX; + var startWidth = th.getBoundingClientRect().width; + handle.setPointerCapture(ev.pointerId); + th.classList.add("resizing"); + + function onMove(moveEv) { + var next = Math.max(self.minWidth, startWidth + (moveEv.clientX - startX)); + self._applyWidth(th, next); + } + + function onUp() { + handle.releasePointerCapture(ev.pointerId); + handle.removeEventListener("pointermove", onMove); + handle.removeEventListener("pointerup", onUp); + handle.removeEventListener("pointercancel", onUp); + th.classList.remove("resizing"); + + var key = th.getAttribute("data-col-key"); + var finalWidth = Math.round(th.getBoundingClientRect().width); + var dn = table.__auditGridDotNet; + if (key && dn) { + dn.invokeMethodAsync("OnColumnResized", key, finalWidth); + } + } + + handle.addEventListener("pointermove", onMove); + handle.addEventListener("pointerup", onUp); + handle.addEventListener("pointercancel", onUp); + }); + } + + // --- reorder: native HTML5 drag-and-drop on the header ------------ + // The whole is draggable; dropping it onto another header swaps + // the dragged column into the drop target's position. + th.setAttribute("draggable", "true"); + + th.addEventListener("dragstart", function (ev) { + // A resize in progress sets .resizing; never start a reorder then. + if (th.classList.contains("resizing")) { + ev.preventDefault(); + return; + } + var key = th.getAttribute("data-col-key"); + if (!key) { + ev.preventDefault(); + return; + } + table.__auditGridDragKey = key; + ev.dataTransfer.effectAllowed = "move"; + // Some browsers require data to be set for the drag to begin. + try { ev.dataTransfer.setData("text/plain", key); } catch { /* ignore */ } + th.classList.add("dragging"); + }); + + th.addEventListener("dragend", function () { + th.classList.remove("dragging"); + table.__auditGridDragKey = null; + self._clearDropTargets(table); + }); + + th.addEventListener("dragover", function (ev) { + // Allowing the drop is what lets dragover/drop fire at all. + var key = th.getAttribute("data-col-key"); + if (key && table.__auditGridDragKey && table.__auditGridDragKey !== key) { + ev.preventDefault(); + ev.dataTransfer.dropEffect = "move"; + th.classList.add("drop-target"); + } + }); + + th.addEventListener("dragleave", function () { + th.classList.remove("drop-target"); + }); + + th.addEventListener("drop", function (ev) { + ev.preventDefault(); + th.classList.remove("drop-target"); + var key = th.getAttribute("data-col-key"); + var fromKey = table.__auditGridDragKey; + table.__auditGridDragKey = null; + if (!key || !fromKey || fromKey === key) { + return; + } + var dn = table.__auditGridDotNet; + if (dn) { + // fromKey moves to occupy toKey's slot; the component computes + // the resulting order and re-renders + persists. + dn.invokeMethodAsync("OnColumnReordered", fromKey, key); + } + }); + }, + + // Apply a width to a via a CSS custom property. The scoped stylesheet + // reads --audit-col-width; absent it, the column falls back to auto. + _applyWidth: function (th, widthPx) { + th.style.setProperty("--audit-col-width", widthPx + "px"); + }, + + _clearDropTargets: function (table) { + var hits = table.querySelectorAll(".drop-target, .dragging"); + for (var i = 0; i < hits.length; i++) { + hits[i].classList.remove("drop-target", "dragging"); + } + } +}; diff --git a/src/ScadaLink.Host/Components/App.razor b/src/ScadaLink.Host/Components/App.razor index ac6d3d2..c426b3e 100644 --- a/src/ScadaLink.Host/Components/App.razor +++ b/src/ScadaLink.Host/Components/App.razor @@ -77,6 +77,7 @@ + diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditGridColumnTests.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditGridColumnTests.cs new file mode 100644 index 0000000..e6fb771 --- /dev/null +++ b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditGridColumnTests.cs @@ -0,0 +1,233 @@ +using Microsoft.Playwright; +using Xunit; + +namespace ScadaLink.CentralUI.PlaywrightTests.Audit; + +/// +/// 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 sessionStorage. +/// +/// +/// The drag interaction is browser-side (wwwroot/js/audit-grid.js), 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 +/// AuditLog row via so the grid has a +/// header row to act on, then best-effort deletes it. +/// +/// +/// +/// The DB-seeding tests are + Skip.IfNot: +/// when the cluster / MSSQL is unreachable they report as Skipped (not Failed), +/// matching the established idiom. +/// +/// +[Collection("Playwright")] +public class AuditGridColumnTests +{ + private const string AuditLogUrl = "/audit/log"; + + /// Skip reason shared by the DB-seeding tests when MSSQL is down. + 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; + } + + /// + /// 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. + /// + private async Task 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; + } + + /// Pixel width of a header cell, measured from its bounding box. + private static async Task HeaderWidthAsync(IPage page, string columnKey) + { + var box = await page.Locator($"[data-col-key='{columnKey}']").BoundingBoxAsync(); + Assert.NotNull(box); + return box!.Width; + } + + /// The ordered list of column keys as currently rendered in the header. + private static async Task> HeaderOrderAsync(IPage page) + { + return await page.Locator("thead th[data-col-key]") + .EvaluateAllAsync("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( + "() => sessionStorage.getItem('auditGrid:columnOrder')"); + var widthsJson = await page.EvaluateAsync( + "() => 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); + } + } +} diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs index fa8fdec..ab30a70 100644 --- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs @@ -43,6 +43,12 @@ public class AuditResultsGridTests : BunitContext _service = Substitute.For(); _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 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. + + /// Column keys in default (spec) order — the fallback used everywhere. + 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(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); + + foreach (var key in DefaultOrder) + { + // Each 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(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(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(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("auditGrid.load", i => (string)i.Arguments[0]! == "columnOrder") + .SetResult("[\"Status\",\"LegacyCol\",\"Site\"]"); + JSInterop.Setup("auditGrid.load", i => (string)i.Arguments[0]! == "columnWidths") + .SetResult((string?)null); + + var cut = Render(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("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("auditGrid.load", i => (string)i.Arguments[0]! == "columnWidths") + .SetResult("{\"Target\":220,\"LegacyCol\":300}"); + + var cut = Render(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); + } } diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs index e3a754d..e510815 100644 --- a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs @@ -46,6 +46,15 @@ namespace ScadaLink.CentralUI.Tests.Pages; /// 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 { new("Username", "tester") }; diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs index f117d20..328048e 100644 --- a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs @@ -28,6 +28,15 @@ namespace ScadaLink.CentralUI.Tests.Pages; /// 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 { new("Username", "tester") };