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.
108 lines
4.1 KiB
JavaScript
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();
|
|
}
|
|
})();
|