Y3R C4LL is the operator inbox alone: no P3NDING APPR0VALS section, no approval cards or history, no approval_resolved subscription, no approval browser notifications, and the tab count and title prefix count unread operator messages only. The H0M3 tile text drops "approvals". The deny form was the only data-prompt form, so bindAsyncForms' prompt branch, themedPrompt and its dialog styles go too, along with the approval-only CSS (.approval*, .approvals-history, .btn-deny, the status/glyph colours and .history-note).
115 lines
4.5 KiB
JavaScript
115 lines
4.5 KiB
JavaScript
// Shared async-form primitives. Used by both the dashboard and the
|
|
// per-agent UI for any button/form that triggers a network action.
|
|
// Two pieces: `asyncBtn` (below) is the low-level per-button primitive;
|
|
// `bindAsyncForms` (further down) is the page-level `data-async` form
|
|
// submit interceptor built on top of it + the themed dialogs in
|
|
// `modal.js`.
|
|
//
|
|
// `asyncBtn(btn, fn)` — the single reusable component:
|
|
// 1. Guards double-click: returns immediately if `btn` is already
|
|
// disabled (prevents a second identical request from firing).
|
|
// 2. Saves `btn.innerHTML` and replaces it with a spinner during the
|
|
// async operation.
|
|
// 3. Re-enables the button and restores the original content when `fn`
|
|
// resolves or rejects (via `finally`), so callers don't need
|
|
// save/restore boilerplate.
|
|
//
|
|
// Usage:
|
|
// btn.addEventListener('click', () => asyncBtn(btn, async () => {
|
|
// const resp = await fetch('/api/...');
|
|
// if (!resp.ok) throw new Error(await resp.text());
|
|
// // handle success
|
|
// }));
|
|
//
|
|
// Error handling: `asyncBtn` restores the button on any thrown error /
|
|
// rejected promise but does NOT surface the error — callers must catch
|
|
// and display it themselves (via `themedToast`, `alert`, a status span,
|
|
// etc.) inside `fn` without re-throwing. `fn` must not let errors escape
|
|
// unhandled: `asyncBtn` returns the `fn().finally(...)` promise so
|
|
// callers can optionally chain `.catch` or `await`, but does not add its
|
|
// own catch — an unhandled rejection from `fn` will propagate normally.
|
|
export function asyncBtn(btn, fn) {
|
|
if (btn.disabled) return; // double-click guard
|
|
const orig = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="spinner">◐</span>';
|
|
return fn().finally(() => {
|
|
btn.disabled = false;
|
|
btn.innerHTML = orig;
|
|
});
|
|
}
|
|
|
|
// Page-level submit interceptor for `data-async` forms — the pattern every
|
|
// dashboard/agent action button uses (meta-update, spawn, cancel, purge,
|
|
// rebuild, …). Without this a `data-async` form POSTs natively and the
|
|
// browser navigates to the bare `ok` response page. Each page that renders
|
|
// such forms must call this once at boot. `onSuccess` runs after a
|
|
// successful submit unless the form opts out with `data-no-refresh` (forms
|
|
// whose mutation arrives faster via an SSE event).
|
|
//
|
|
// Confirmation (`data-confirm`) is surfaced via the themed dialog in
|
|
// `modal.js` rather than native `confirm()`, and errors via `themedToast`
|
|
// rather than `alert()`, so every page gets the same in-theme experience.
|
|
import { themedConfirm, themedToast } from "./modal.js";
|
|
|
|
export function bindAsyncForms(onSuccess) {
|
|
document.addEventListener("submit", async (e) => {
|
|
const f = e.target;
|
|
if (!(f instanceof HTMLFormElement) || !f.hasAttribute("data-async"))
|
|
return;
|
|
e.preventDefault();
|
|
if (
|
|
f.dataset.confirm &&
|
|
!(await themedConfirm({ message: f.dataset.confirm }))
|
|
)
|
|
return;
|
|
const btn = f.querySelector(
|
|
'button[type="submit"], button:not([type]), .btn-inline',
|
|
);
|
|
// Inner action: POST, clear inputs, call onSuccess.
|
|
// Errors are surfaced via themedToast; the caller does not re-throw
|
|
// so asyncBtn's finally always runs (restoring the button).
|
|
const doSubmit = async () => {
|
|
let resp;
|
|
try {
|
|
resp = await fetch(f.action, {
|
|
method: f.method || "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams(new FormData(f)),
|
|
redirect: "manual",
|
|
});
|
|
} catch (err) {
|
|
themedToast("action failed: " + err, { type: "error" });
|
|
return;
|
|
}
|
|
const ok =
|
|
resp.ok ||
|
|
resp.type === "opaqueredirect" ||
|
|
(resp.status >= 200 && resp.status < 400);
|
|
if (!ok) {
|
|
const text = await resp.text().catch(() => "");
|
|
themedToast(
|
|
"action failed: " + resp.status + (text ? "\n\n" + text : ""),
|
|
{ type: "error" },
|
|
);
|
|
return;
|
|
}
|
|
// Clear text inputs whose value was just submitted.
|
|
f.querySelectorAll(
|
|
'input[type="text"], input:not([type]), textarea',
|
|
).forEach((i) => {
|
|
i.value = "";
|
|
});
|
|
if (
|
|
!f.hasAttribute("data-no-refresh") &&
|
|
typeof onSuccess === "function"
|
|
) {
|
|
onSuccess();
|
|
}
|
|
};
|
|
// asyncBtn guards double-submit and shows a spinner while in-flight.
|
|
// When there is no submit button (unusual), fall through without a guard.
|
|
if (btn) asyncBtn(btn, doSubmit);
|
|
else await doSubmit();
|
|
});
|
|
}
|