hyperhive/frontend/packages/shared/src/forms.js
iris 7110a25cf6 frontend: extract themed dialogs + async-form handler to shared, wire agent UI
The dashboard has a themed modal/dialog system (modal.js: themedToast/
themedConfirm/themedPrompt) and a data-async form submit interceptor
(bindAsyncForms) that every dashboard action routes through. The
per-agent UI never adopted either — it had its own more primitive
data-async handler using native window.confirm()/alert() (8 call
sites) and a duplicated el() DOM helper.

- Moved el() out of dashboard/common.js into shared/src/dom.js.
- Moved modal.js + modal.css from dashboard/src/ to shared/src/,
  updating its internal el import.
- Moved bindAsyncForms from dashboard/common.js into shared/forms.js,
  alongside the asyncBtn primitive it's built on.
- Updated every dashboard file's imports to the new shared locations
  (no re-export shims).
- agent.css now @imports shared/modal.css so the dialogs render
  themed there too.
- agent/app.js: dropped its local el()/data-async duplicate, wired
  bindAsyncForms(), and replaced all 8 window.confirm() sites with
  themedConfirm (async, wrapped in a fire-and-forget IIFE where the
  call site needs a synchronous boolean return, e.g. the slash-command
  dispatcher).

Closes hyperhive#2791. Verified with a full frontend build
(npm run build) — both dashboard and agent bundles compile clean and
agent.css picks up the .tc-* dialog styles it previously lacked.
2026-07-27 18:55:17 +02:00

112 lines
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`) 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();
});
}