// 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(); } })();