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

@@ -12,12 +12,20 @@
}
<div class="table-responsive">
<table class="table table-sm table-hover align-middle">
<table class="table table-sm table-hover align-middle" @ref="_tableRef">
<thead class="table-light">
<tr>
@foreach (var col in OrderedColumns())
{
<th data-test="col-header-@col.Key">@col.Label</th>
<th class="audit-grid-th"
data-test="col-header-@col.Key"
data-col-key="@col.Key"
style="@ColumnWidthStyle(col.Key)">
@col.Label
<span class="audit-grid-resize-handle"
data-test="col-resize-@col.Key"
aria-hidden="true"></span>
</th>
}
</tr>
</thead>
@@ -48,7 +56,7 @@
@onclick="() => HandleRowClick(row)">
@foreach (var col in OrderedColumns())
{
<td>
<td class="audit-grid-td" style="@ColumnWidthStyle(col.Key)">
@RenderCell(col.Key, row)
</td>
}

View File

@@ -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.
///
/// <para>
/// <b>Column model.</b> Each column has a stable string key; the visible order
/// is the <see cref="ColumnOrder"/> 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 <c>.table-responsive</c> wrapper.
/// <b>Column model.</b> Each column has a stable string key. The default
/// visible order is the <see cref="ColumnOrder"/> 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 <c>sessionStorage</c>, 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.
/// </para>
///
/// <para>
@@ -33,10 +38,17 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// end" signal for keyset paging without a count query.
/// </para>
/// </summary>
public partial class AuditResultsGrid
public partial class AuditResultsGrid : IAsyncDisposable
{
private const int DefaultPageSize = 100;
/// <summary>Minimum persisted column width — mirrors <c>auditGrid.minWidth</c>.</summary>
private const int MinColumnWidthPx = 64;
/// <summary>sessionStorage keys (namespaced under <c>auditGrid:</c> by the JS helper).</summary>
private const string ColumnOrderStorageKey = "columnOrder";
private const string ColumnWidthsStorageKey = "columnWidths";
private readonly List<AuditEvent> _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<AuditResultsGrid>? _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<string>? _columnOrder;
private readonly Dictionary<string, int> _columnWidths = new();
/// <summary>
/// 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);
/// <summary>
/// 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.
/// </summary>
private static IReadOnlyList<(string Key, string Label)> ResolveOrder(IReadOnlyList<string>? 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<string>();
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;
}
/// <summary>
/// Inline style for a column's cells: emits the <c>--audit-col-width</c>
/// custom property the scoped stylesheet reads, or an empty string when
/// the column has no persisted width (auto layout).
/// </summary>
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.
}
}
}
/// <summary>
/// Reads the persisted column order + widths from <c>sessionStorage</c> 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.
/// </summary>
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<List<string>>(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<Dictionary<string, int>>(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<string?> TryLoadAsync(string key)
{
try
{
return await JS.InvokeAsync<string?>("auditGrid.load", key);
}
catch (JSDisconnectedException)
{
return null;
}
}
/// <summary>
/// JS callback: the user finished resizing a column. Persists the new
/// per-column width and re-renders so the body cells track the header.
/// </summary>
[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();
}
/// <summary>
/// JS callback: the user dropped column <paramref name="fromKey"/> onto the
/// header of <paramref name="toKey"/>. Moves the dragged column into the
/// target's slot, persists the resulting order, and re-renders.
/// </summary>
[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",

View File

@@ -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 <th> and matching <td> 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);
}

View File

@@ -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 <th>'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 <table> 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 <th> 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 <th>. 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 <th> 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 <th> 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");
}
}
};

View File

@@ -77,6 +77,7 @@
</script>
<script src="/js/treeview-storage.js"></script>
<script src="_content/ScadaLink.CentralUI/js/monaco-init.js"></script>
<script src="_content/ScadaLink.CentralUI/js/audit-grid.js"></script>
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
</body>
</html>