/* * Dashboard action behaviour: turns the htmx-swapped confirm/result partials * into a modal (confirm) and a stack of auto-dismissing toasts * (results + busy spinners). Pure vanilla JS, no dependencies beyond htmx's * own swap/request events. * * - confirm.html -> in #action-slot : opened with * showModal() so it is a real focus-trapping, backdrop-dimming modal; * Cancel / ESC clear the slot. (No-JS path renders instead.) * - result.html -> .toast appended to #toast-stack : the confirm form swaps * its terminal result there with hx-swap="beforeend", so concurrent actions * (e.g. two template backups at once) each keep their own toast instead of * the later one replacing the earlier. A MutationObserver wires each new * toast; a close button dismisses it and everything but an error also * auto-dismisses after AUTODISMISS_MS. * - busy toast -> .toast.toast-busy appended to #toast-stack while a * confirmed (possibly slow, multi-GB) action is in flight; removed when its * request settles. */ (function () { "use strict"; var AUTODISMISS_MS = 6000; var FADE_MS = 200; function toastStack() { return document.getElementById("toast-stack"); } function clearSlot() { var slot = document.getElementById("action-slot"); if (slot) { slot.innerHTML = ""; } } function dismissToast(toast) { if (!toast || toast.dataset.closing === "true") { return; } toast.dataset.closing = "true"; toast.classList.add("toast--closing"); window.setTimeout(function () { if (toast.parentNode) { toast.parentNode.removeChild(toast); } }, FADE_MS); } function wireToast(toast) { if (toast.dataset.wired === "true") { return; } toast.dataset.wired = "true"; var closeBtn = toast.querySelector("[data-toast-close]"); if (closeBtn) { closeBtn.addEventListener("click", function () { dismissToast(toast); }); } // Errors stay put until dismissed (data-autodismiss="false"); every other // result fades on its own after a few seconds. if (toast.dataset.autodismiss !== "false") { window.setTimeout(function () { dismissToast(toast); }, AUTODISMISS_MS); } } function showBusyToast(dialog) { // A non-dismissable spinner toast shown while a (possibly slow) confirmed // action is in flight -- e.g. a multi-GB template backup. Appended to the // stack so it coexists with any other running action's toast, and returned // so the caller can remove it once the request settles. var stack = toastStack(); if (!stack) { return null; } var label = dialog.getAttribute("data-action-label") || "Working"; var toast = document.createElement("div"); toast.className = "toast toast-busy"; toast.setAttribute("role", "status"); toast.setAttribute("aria-live", "polite"); toast.innerHTML = '' + '
' + '

Working… this can take a while for large templates.

'; // textContent, so a template/label can never inject markup. toast.querySelector(".toast-title").textContent = label + " — in progress"; stack.appendChild(toast); return toast; } function wireModal(dialog) { // Upgrade an inert/closed dialog into a true modal where supported. On the // no-JS full-page path it already renders `open`; closing then re-opening // via showModal() gives the backdrop + focus trap without a double-open. if (typeof dialog.showModal === "function") { if (dialog.open) { dialog.close(); } // showModal() is called here from an htmx response handler, not // synchronously inside the user's own click -- some browsers then // swallow the very first click on the dialog's contents (the // top-layer promotion/focus handoff hasn't settled), so Confirm // silently does nothing until clicked a second time. One animation // frame is enough for that to settle; explicitly focusing Confirm // afterwards makes the browser's hit-testing land on the button. requestAnimationFrame(function () { dialog.showModal(); var confirmBtn = dialog.querySelector('button[type="submit"]'); if (confirmBtn) { confirmBtn.focus(); } }); } var cancel = dialog.querySelector("[data-modal-cancel]"); if (cancel) { cancel.addEventListener("click", function () { if (dialog.open) { dialog.close(); } clearSlot(); }); } // ESC / backdrop dismiss fires `cancel` (not `close`, and never on a // programmatic close()); clear the slot once the native close settles. dialog.addEventListener("cancel", function () { window.setTimeout(clearSlot, 0); }); // On Confirm: close the modal at once and show a busy spinner toast in the // stack until the request settles. The terminal result is appended to the // stack by htmx (hx-swap="beforeend"); on afterRequest we drop our busy // toast and clear the now-defunct dialog from #action-slot. Uses // htmx:beforeSend so the XHR is already prepared -- closing the dialog here // cannot cancel it. busyEl is per-request, captured in this closure. var form = dialog.querySelector("form"); if (form) { var busyEl = null; form.addEventListener("htmx:beforeSend", function () { if (dialog.open) { dialog.close(); } busyEl = showBusyToast(dialog); }); form.addEventListener("htmx:afterRequest", function () { if (busyEl) { dismissToast(busyEl); busyEl = null; } // The dialog stays in #action-slot after the result went to the stack; // remove it now that the action is done. isConnected guards a slot // already cleared by an ESC/Cancel race. if (dialog.isConnected) { clearSlot(); } }); } } function processSlot(root) { // #action-slot only ever holds a confirm now; the no-JS full-page // result path also drops a bare .toast here, so wire that too for that case. if (!root || !root.querySelector) { return; } var dialog = root.querySelector("dialog.modal"); if (dialog) { wireModal(dialog); } var toast = root.querySelector(".toast"); if (toast) { wireToast(toast); } } function observeStack() { // Result toasts arrive via htmx beforeend swaps (not into #action-slot), so // wire them as they are inserted. Busy toasts are created + managed by // showBusyToast/wireModal, so skip them here. var stack = toastStack(); if (!stack || typeof MutationObserver !== "function") { return; } new MutationObserver(function (mutations) { mutations.forEach(function (mutation) { Array.prototype.forEach.call(mutation.addedNodes, function (node) { if ( node.nodeType === 1 && node.classList.contains("toast") && !node.classList.contains("toast-busy") ) { wireToast(node); } }); }); }).observe(stack, { childList: true }); } // Executions "Started" cell: click swaps between the relative and // absolute time already rendered into the button's own data attributes -- // no request. Delegated on body (not the button itself) so it keeps // working after the Executions card's own 5s htmx poll replaces the row. // // The poll re-renders the whole card from the server every 5s, always // starting back at "relative" -- so the choice is remembered here, keyed // by the button's own id (stable across polls: run id/attempt or vmx), // and re-applied to the fresh button restoreTimeToggles() finds after each // swap, rather than freezing the node itself (which would also freeze its // relative-time text, badge, duration -- everything else in the row). var timeToggleState = {}; function applyTimeToggle(btn, showing) { var shown = showing === "relative" ? "absolute" : "relative"; btn.textContent = btn.dataset[showing]; btn.title = btn.dataset[shown]; btn.dataset.showing = showing; } function restoreTimeToggles(root) { var buttons = root.querySelectorAll ? root.querySelectorAll(".time-toggle[id]") : []; Array.prototype.forEach.call(buttons, function (btn) { var saved = timeToggleState[btn.id]; if (saved && saved !== btn.dataset.showing) { applyTimeToggle(btn, saved); } }); } document.body.addEventListener("click", function (evt) { var btn = evt.target.closest(".time-toggle"); if (!btn) { return; } var next = btn.dataset.showing === "relative" ? "absolute" : "relative"; applyTimeToggle(btn, next); if (btn.id) { timeToggleState[btn.id] = next; } }); document.body.addEventListener("htmx:afterSwap", function (evt) { var target = evt.detail && evt.detail.target; if (target && target.id === "action-slot") { processSlot(target); } if (target) { restoreTimeToggles(target); } }); document.addEventListener("DOMContentLoaded", function () { observeStack(); processSlot(document.getElementById("action-slot")); }); })();