Pure `nix fmt` output from the commit before this one — no hand edits. 203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs. Reproduce with `nix develop -c nix fmt` on the parent commit; the result should be byte-identical to this tree. None of the 13 `.prettierignore` entries appears here — verified by intersecting the changed-file list against the ignore file, with a control proving the intersection finds a match when one exists.
131 lines
5.2 KiB
JavaScript
131 lines
5.2 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`) and free-text prompts (`data-prompt` /
|
|
// `data-prompt-field`) are surfaced via the themed dialogs in `modal.js`
|
|
// rather than native `confirm()`/`prompt()`, and errors via `themedToast`
|
|
// rather than `alert()`, so every page gets the same in-theme experience.
|
|
import { themedConfirm, themedPrompt, 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;
|
|
if (f.dataset.prompt) {
|
|
const ans = await themedPrompt({ message: f.dataset.prompt });
|
|
if (ans === null) return; // operator hit Cancel
|
|
// Drop into a hidden input named after `data-prompt-field` (or
|
|
// 'note' by default) so the value rides along on the POST.
|
|
const field = f.dataset.promptField || "note";
|
|
let input = f.querySelector(`input[name="${field}"]`);
|
|
if (!input) {
|
|
input = document.createElement("input");
|
|
input.type = "hidden";
|
|
input.name = field;
|
|
f.append(input);
|
|
}
|
|
input.value = ans;
|
|
}
|
|
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();
|
|
});
|
|
}
|