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