fix(dashboard): wire data-async form submit handler on the C0R3 page
Clicking "update & rebuild" (and cancel / respawn / purge) on /core.html navigated to the bare `ok` response page instead of submitting async. The C0R3 page (split out of the dashboard) carries `data-async` forms but its bundle never had the global submit interceptor — that handler lived inline in tabs.js, so only the dashboard bundle had it. The forms POSTed natively and the browser followed the response. Fix: lift the `data-async` submit handler out of tabs.js into a shared `bindAsyncForms(onSuccess)` in common.js (which already owns the `form` helper that builds these forms), and call it from both pages: - tabs.js: `bindAsyncForms(() => refreshState())` — behaviour-preserving (same handler, now imported). - core.js: add a `refreshState()` (re-fetch /api/state + re-render) used for the cold load and as the post-submit refresh, and call `bindAsyncForms(() => refreshState())` at boot. Forms with `data-no-refresh` (e.g. meta-update, which gets its update via the meta SSE events) skip the refresh, same as before.
This commit is contained in:
parent
48347a01c9
commit
878f95205b
3 changed files with 87 additions and 69 deletions
|
|
@ -46,6 +46,67 @@ export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts =
|
||||||
return f;
|
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 && !confirm(f.dataset.confirm)) return;
|
||||||
|
if (f.dataset.prompt) {
|
||||||
|
const ans = prompt(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');
|
||||||
|
const original = btn ? btn.innerHTML : '';
|
||||||
|
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner">◐</span>'; }
|
||||||
|
try {
|
||||||
|
const 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',
|
||||||
|
});
|
||||||
|
const ok = resp.ok || resp.type === 'opaqueredirect'
|
||||||
|
|| (resp.status >= 200 && resp.status < 400);
|
||||||
|
if (!ok) {
|
||||||
|
const text = await resp.text().catch(() => '');
|
||||||
|
alert('action failed: ' + resp.status + (text ? '\n\n' + text : ''));
|
||||||
|
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Re-enable the button — the refresh rebuilds most lists but skips
|
||||||
|
// forms that didn't change, so without this the spinner sticks.
|
||||||
|
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('action failed: ' + err);
|
||||||
|
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// `truncate`, `fmtAgo`, `fmtElapsed`, `fmtDuration` + the `paintAtomic`
|
// `truncate`, `fmtAgo`, `fmtElapsed`, `fmtDuration` + the `paintAtomic`
|
||||||
// render helper live in the dashboard-internal `./util.js`, not here —
|
// render helper live in the dashboard-internal `./util.js`, not here —
|
||||||
// their phrasing ("X running", "X ago") is dashboard-specific, so they
|
// their phrasing ("X running", "X ago") is dashboard-specific, so they
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
// dashboard SYST3M tab); the dashboard keeps its own copies for now —
|
// dashboard SYST3M tab); the dashboard keeps its own copies for now —
|
||||||
// de-duplication + removing the SYST3M tab is a deliberate follow-up.
|
// de-duplication + removing the SYST3M tab is a deliberate follow-up.
|
||||||
|
|
||||||
import { $, el, form, openStream, initServerWarnings } from './common.js';
|
import { $, el, form, openStream, initServerWarnings, bindAsyncForms } from './common.js';
|
||||||
import { fmtAgo, fmtElapsed, truncate } from './util.js';
|
import { fmtAgo, fmtElapsed, truncate } from './util.js';
|
||||||
import { createTabStrip } from '@hive/shared/tabs.js';
|
import { createTabStrip } from '@hive/shared/tabs.js';
|
||||||
|
|
||||||
|
|
@ -503,9 +503,28 @@ const SSE_HANDLERS = {
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── boot ─────────────────────────────────────────────────────────────────
|
// ─── boot ─────────────────────────────────────────────────────────────────
|
||||||
|
// Re-fetch /api/state, re-sync the derived state, re-render. Used for the
|
||||||
|
// cold load and as the post-submit refresh for `data-async` forms whose
|
||||||
|
// mutation doesn't arrive via an SSE event (e.g. the meta-update / spawn /
|
||||||
|
// purge actions that opt out of `data-no-refresh`).
|
||||||
|
async function refreshState() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/state');
|
||||||
|
if (resp.ok) syncFromSnapshot(await resp.json());
|
||||||
|
} catch {
|
||||||
|
// best-effort: the page keeps its last-rendered state
|
||||||
|
}
|
||||||
|
renderAll();
|
||||||
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
initServerWarnings();
|
initServerWarnings();
|
||||||
|
|
||||||
|
// `data-async` form submit interceptor — without this the meta-update /
|
||||||
|
// cancel / respawn / purge buttons POST natively and the browser navigates
|
||||||
|
// to the bare `ok` response page.
|
||||||
|
bindAsyncForms(() => refreshState());
|
||||||
|
|
||||||
// Hash-routed sub-tab strip; default Rebuild Queue. Container-load
|
// Hash-routed sub-tab strip; default Rebuild Queue. Container-load
|
||||||
// polling runs only while the LOAD sub-tab is open (cpu is a short
|
// polling runs only while the LOAD sub-tab is open (cpu is a short
|
||||||
// two-sample read each refresh on the server).
|
// two-sample read each refresh on the server).
|
||||||
|
|
@ -517,15 +536,7 @@ async function init() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
await refreshState();
|
||||||
const resp = await fetch('/api/state');
|
|
||||||
if (resp.ok) {
|
|
||||||
syncFromSnapshot(await resp.json());
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// best-effort: the page still renders its empty states
|
|
||||||
}
|
|
||||||
renderAll();
|
|
||||||
|
|
||||||
const es = openStream('/dashboard/stream');
|
const es = openStream('/dashboard/stream');
|
||||||
if (es) {
|
if (es) {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
fmtAgeSecs,
|
fmtAgeSecs,
|
||||||
Panel, NOTIF,
|
Panel, NOTIF,
|
||||||
makePathLink, appendText, appendLinkified,
|
makePathLink, appendText, appendLinkified,
|
||||||
openStream, renderServerWarnings,
|
openStream, renderServerWarnings, bindAsyncForms,
|
||||||
} from './common.js';
|
} from './common.js';
|
||||||
import { createTabStrip } from '@hive/shared/tabs.js';
|
import { createTabStrip } from '@hive/shared/tabs.js';
|
||||||
import { containersState, syncContainersFromSnapshot } from './state.js';
|
import { containersState, syncContainersFromSnapshot } from './state.js';
|
||||||
|
|
@ -90,64 +90,10 @@ window.marked = marked;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── async forms ────────────────────────────────────────────────────────
|
// ─── async forms ────────────────────────────────────────────────────────
|
||||||
document.addEventListener('submit', async (e) => {
|
// Shared `data-async` submit interceptor (now in common.js so /core.html's
|
||||||
const f = e.target;
|
// bundle gets it too). On success it re-runs refreshState unless the form
|
||||||
if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return;
|
// opts out via `data-no-refresh` (its mutation arrives via an SSE event).
|
||||||
e.preventDefault();
|
bindAsyncForms(() => refreshState());
|
||||||
if (f.dataset.confirm && !confirm(f.dataset.confirm)) return;
|
|
||||||
if (f.dataset.prompt) {
|
|
||||||
const ans = prompt(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');
|
|
||||||
const original = btn ? btn.innerHTML : '';
|
|
||||||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner">◐</span>'; }
|
|
||||||
try {
|
|
||||||
const 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',
|
|
||||||
});
|
|
||||||
const ok = resp.ok || resp.type === 'opaqueredirect'
|
|
||||||
|| (resp.status >= 200 && resp.status < 400);
|
|
||||||
if (!ok) {
|
|
||||||
const text = await resp.text().catch(() => '');
|
|
||||||
alert('action failed: ' + resp.status + (text ? '\n\n' + text : ''));
|
|
||||||
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Re-enable the button — refreshState() rebuilds most lists but
|
|
||||||
// skips forms that didn't change (e.g. the spawn form), so without
|
|
||||||
// this the spinner sticks and the button can't be clicked again.
|
|
||||||
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
|
||||||
// Clear text inputs whose value was just submitted.
|
|
||||||
f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; });
|
|
||||||
// Forms whose endpoint already emits a DashboardEvent that
|
|
||||||
// updates the derived store can opt out of the post-submit
|
|
||||||
// /api/state refetch (the event delivers the new row faster
|
|
||||||
// than the snapshot poll anyway). Container-lifecycle forms
|
|
||||||
// still rely on the refresh since `ContainerView` isn't yet
|
|
||||||
// event-derivable.
|
|
||||||
if (!f.hasAttribute('data-no-refresh')) {
|
|
||||||
refreshState();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
alert('action failed: ' + err);
|
|
||||||
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// The live agent roster (`containersState`) + its snapshot-sync now
|
// The live agent roster (`containersState`) + its snapshot-sync now
|
||||||
// live in `./state.js` — it's the one piece of cross-domain state, read
|
// live in `./state.js` — it's the one piece of cross-domain state, read
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue