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.
This commit is contained in:
parent
a1263a9ed6
commit
7110a25cf6
19 changed files with 206 additions and 193 deletions
|
|
@ -4,12 +4,8 @@
|
|||
// infrastructure for the side panel.
|
||||
|
||||
import { linkify as termLinkify } from '@hive/shared/terminal.js';
|
||||
import { asyncBtn } from '@hive/shared/forms.js';
|
||||
import { el } from '@hive/shared/dom.js';
|
||||
import DOMPurify from 'dompurify';
|
||||
// Themed dialog/toast helpers (modal.js imports `el` back from here — a safe
|
||||
// deferred cycle: neither side uses the other at module-init time, only inside
|
||||
// runtime handlers).
|
||||
import { themedConfirm, themedPrompt, themedToast } from './modal.js';
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────
|
||||
export const $ = (id) => document.getElementById(id);
|
||||
|
|
@ -21,21 +17,6 @@ export const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
|
|||
({ '&':'&', '<':'<', '>':'>', '"':'"' }[c])
|
||||
);
|
||||
|
||||
export const el = (tag, attrs = {}, ...children) => {
|
||||
const e = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k === 'class') e.className = v;
|
||||
else if (k === 'html') e.innerHTML = v;
|
||||
else if (k.startsWith('data-')) e.setAttribute(k, v);
|
||||
else e.setAttribute(k, v);
|
||||
}
|
||||
for (const c of children) {
|
||||
if (c == null) continue;
|
||||
e.append(c.nodeType ? c : document.createTextNode(c));
|
||||
}
|
||||
return e;
|
||||
};
|
||||
|
||||
export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts = {}) => {
|
||||
const f = el('form', {
|
||||
method: 'POST', action, class: 'inline', 'data-async': '',
|
||||
|
|
@ -52,70 +33,9 @@ export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts =
|
|||
return f;
|
||||
};
|
||||
|
||||
// Page-level submit interceptor for `data-async` forms — the pattern every
|
||||
// dashboard action button uses (meta-update, spawn, cancel, purge, …).
|
||||
// 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 (dashboard via ./tabs.js, /core.html via ./core.js).
|
||||
// `onSuccess` runs after a successful submit unless the form opts out with
|
||||
// `data-no-refresh` (forms whose mutation arrives faster via an SSE event).
|
||||
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();
|
||||
});
|
||||
}
|
||||
// `bindAsyncForms` (the `data-async` form submit interceptor) now lives in
|
||||
// `@hive/shared/forms.js` alongside `asyncBtn` — both the dashboard and the
|
||||
// per-agent UI import it directly from there rather than through this file.
|
||||
|
||||
// `truncate`, `fmtAgo`, `fmtElapsed`, `fmtDuration` + the `paintAtomic`
|
||||
// render helper live in the dashboard-internal `./util.js`, not here —
|
||||
|
|
|
|||
Loading…
Reference in a new issue