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:
@@ -12,12 +12,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="table-responsive">
|
<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">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
@foreach (var col in OrderedColumns())
|
@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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -48,7 +56,7 @@
|
|||||||
@onclick="() => HandleRowClick(row)">
|
@onclick="() => HandleRowClick(row)">
|
||||||
@foreach (var col in OrderedColumns())
|
@foreach (var col in OrderedColumns())
|
||||||
{
|
{
|
||||||
<td>
|
<td class="audit-grid-td" style="@ColumnWidthStyle(col.Key)">
|
||||||
@RenderCell(col.Key, row)
|
@RenderCell(col.Key, row)
|
||||||
</td>
|
</td>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.JSInterop;
|
||||||
using ScadaLink.Commons.Entities.Audit;
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
using ScadaLink.Commons.Types.Audit;
|
using ScadaLink.Commons.Types.Audit;
|
||||||
using ScadaLink.Commons.Types.Enums;
|
using ScadaLink.Commons.Types.Enums;
|
||||||
@@ -14,12 +16,15 @@ namespace ScadaLink.CentralUI.Components.Audit;
|
|||||||
/// source without standing up EF Core.
|
/// source without standing up EF Core.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Column model.</b> Each column has a stable string key; the visible order
|
/// <b>Column model.</b> Each column has a stable string key. The default
|
||||||
/// is the <see cref="ColumnOrder"/> parameter. M7 scope: the column-model
|
/// visible order is the <see cref="ColumnOrder"/> parameter (or the spec
|
||||||
/// framework is in place but resize / drag-reorder UX is intentionally NOT
|
/// order from Component-AuditLog.md §10 when the parameter is null). On top of
|
||||||
/// implemented — the full spec calls for persisted-per-user reordering and
|
/// that default the grid layers a per-browser override: drag-to-reorder and
|
||||||
/// resizing, which M7.x can ship without rewriting the column model. Resizing
|
/// drag-to-resize UX (audit-grid.js) writes the chosen order + per-column
|
||||||
/// today is CSS-based via Bootstrap's <c>.table-responsive</c> wrapper.
|
/// 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>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -33,10 +38,17 @@ namespace ScadaLink.CentralUI.Components.Audit;
|
|||||||
/// end" signal for keyset paging without a count query.
|
/// end" signal for keyset paging without a count query.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class AuditResultsGrid
|
public partial class AuditResultsGrid : IAsyncDisposable
|
||||||
{
|
{
|
||||||
private const int DefaultPageSize = 100;
|
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 readonly List<AuditEvent> _rows = new();
|
||||||
private int _pageNumber = 1;
|
private int _pageNumber = 1;
|
||||||
private bool _loading;
|
private bool _loading;
|
||||||
@@ -44,6 +56,18 @@ public partial class AuditResultsGrid
|
|||||||
|
|
||||||
private AuditLogQueryFilter? _activeFilter;
|
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>
|
/// <summary>
|
||||||
/// Filter to apply. When this parameter changes the grid resets to page 1 and
|
/// 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
|
/// 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()
|
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;
|
return AllColumns;
|
||||||
}
|
}
|
||||||
|
|
||||||
var byKey = AllColumns.ToDictionary(c => c.Key, c => c);
|
var byKey = AllColumns.ToDictionary(c => c.Key, c => c);
|
||||||
var ordered = new List<(string Key, string Label)>(ColumnOrder.Count);
|
var ordered = new List<(string Key, string Label)>(AllColumns.Count);
|
||||||
foreach (var key in ColumnOrder)
|
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);
|
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()
|
protected override async Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
// Reset & reload whenever the filter reference changes. AuditLogQueryFilter
|
// 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
|
private static string StatusBadgeClass(AuditStatus status) => status switch
|
||||||
{
|
{
|
||||||
AuditStatus.Delivered => "badge bg-success",
|
AuditStatus.Delivered => "badge bg-success",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
184
src/ScadaLink.CentralUI/wwwroot/js/audit-grid.js
Normal file
184
src/ScadaLink.CentralUI/wwwroot/js/audit-grid.js
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -77,6 +77,7 @@
|
|||||||
</script>
|
</script>
|
||||||
<script src="/js/treeview-storage.js"></script>
|
<script src="/js/treeview-storage.js"></script>
|
||||||
<script src="_content/ScadaLink.CentralUI/js/monaco-init.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>
|
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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 = Substitute.For<IAuditLogQueryService>();
|
||||||
_service.DefaultPageSize.Returns(100);
|
_service.DefaultPageSize.Returns(100);
|
||||||
Services.AddSingleton(_service);
|
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)
|
private void StubPage(IReadOnlyList<AuditEvent> rows)
|
||||||
@@ -131,4 +137,133 @@ public class AuditResultsGridTests : BunitContext
|
|||||||
var deliveredBadge = cut.Find($"[data-test=\"status-badge-{delivered.EventId}\"]");
|
var deliveredBadge = cut.Find($"[data-test=\"status-badge-{delivered.EventId}\"]");
|
||||||
Assert.Contains("bg-success", deliveredBadge.GetAttribute("class") ?? string.Empty);
|
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>
|
/// </summary>
|
||||||
public class AuditLogPagePermissionTests : BunitContext
|
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)
|
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
|
||||||
{
|
{
|
||||||
var claims = new List<Claim> { new("Username", "tester") };
|
var claims = new List<Claim> { new("Username", "tester") };
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ namespace ScadaLink.CentralUI.Tests.Pages;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuditLogPageScaffoldTests : BunitContext
|
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)
|
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
|
||||||
{
|
{
|
||||||
var claims = new List<Claim> { new("Username", "tester") };
|
var claims = new List<Claim> { new("Username", "tester") };
|
||||||
|
|||||||
Reference in New Issue
Block a user