Files
member-console/internal/embeds/static/sortable-reorder.js
T
cgalo5758 8514df5b50 Remediate operator UX audit findings
Fix HTMX expired-session handling, CSP-blocked form behaviors, reorder
recovery, billing currency display, plan/checkout guards, FedWiki quota
edge cases, and operator/member empty/error states.

Add entitlement uniqueness migrations, canonical migration source
wiring,
and regression coverage for the remediated flows. Update status docs
with
the audit triage and model inventory.
2026-07-03 11:58:03 -05:00

108 lines
4.1 KiB
JavaScript

// Generic drag-to-reorder wiring for htmx + SortableJS — CSP-safe (self-hosted
// library, external init, class-based feedback; no inline scripts or styles).
//
// Any element carrying `data-sortable` becomes a SortableJS container; its
// options come from data attributes:
// data-sortable-direction "horizontal" | "vertical" (default vertical)
// data-sortable-draggable CSS selector for the draggable children
// data-sortable-handle CSS selector for the drag handle (optional)
// data-sortable-filter CSS selector for pinned/non-draggable items;
// also blocks dropping relative to such items
//
// On drop, SortableJS fires `end`; the container's own hx-* attributes POST the
// new order (hidden inputs serialized by htmx in their new DOM order). Used by
// the topology ladder columns (horizontal) and the ladder tier rows (vertical).
// Re-inits after every htmx swap via htmx.onLoad; a per-element guard prevents
// double-init, and onEnd freezes the instance while the request is in flight
// (the swapped-in container is a fresh element and gets its own instance).
(function () {
"use strict";
function init(root) {
var nodes = (root || document).querySelectorAll("[data-sortable]");
Array.prototype.forEach.call(nodes, function (el) {
if (el._sortableReady || typeof Sortable === "undefined") {
return;
}
el._sortableReady = true;
var opts = {
animation: 150,
direction: el.getAttribute("data-sortable-direction") || "vertical",
ghostClass: "sortable-ghost",
chosenClass: "sortable-chosen",
onEnd: function () {
this.option("disabled", true);
},
};
var draggable = el.getAttribute("data-sortable-draggable");
if (draggable) {
opts.draggable = draggable;
}
var handle = el.getAttribute("data-sortable-handle");
if (handle) {
opts.handle = handle;
}
var filterSel = el.getAttribute("data-sortable-filter");
if (filterSel) {
opts.filter = filterSel;
// Never let a draggable cross a pinned/filtered item.
opts.onMove = function (evt) {
return !evt.related.matches(filterSel);
};
}
new Sortable(el, opts);
});
}
// Recover a frozen drag-to-reorder after a suppressed swap. onEnd disables
// the instance while the reorder POST is in flight; on CSRF expiry / 5xx /
// network error, error-handler.js sets shouldSwap=false and fires
// `operator:reorder-recover`. Without recovery the table would keep the
// dropped row order with stale rank badges and drag would stay dead until a
// full refresh. Re-enable any frozen instance (belt-and-suspenders if the
// re-fetch also fails) and re-fetch the current page's #operator-body so the
// server's true order and rank badges are restored (audit finding #55).
function recover() {
var nodes = document.querySelectorAll("[data-sortable]");
var wasFrozen = false;
Array.prototype.forEach.call(nodes, function (el) {
var inst =
window.Sortable && typeof Sortable.get === "function"
? Sortable.get(el)
: null;
if (inst && inst.option("disabled")) {
inst.option("disabled", false);
wasFrozen = true;
}
});
// Only re-fetch when a reorder was actually in flight (a frozen instance).
// A frozen instance is re-enabled above BEFORE the re-fetch, so if that
// GET also fails the next recover event finds nothing frozen and stops —
// no reload loop.
if (wasFrozen && window.htmx && typeof htmx.ajax === "function") {
htmx.ajax("GET", window.location.pathname + window.location.search, {
target: "#operator-body",
swap: "outerHTML",
select: "#operator-body",
});
}
}
function boot() {
if (window.htmx && typeof htmx.onLoad === "function") {
htmx.onLoad(init);
}
document.body.addEventListener("operator:reorder-recover", recover);
init(document);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
})();