feat(#2443): asyncBtn — shared reusable component, replace ad-hoc disable/spinner patterns

add `asyncBtn(btn, fn)` to `@hive/shared/forms.js` as the single
reusable component for async button actions:
  1. double-click guard: returns immediately if btn is already disabled
  2. saves btn.innerHTML, replaces with spinner while in-flight
  3. restores btn on resolve or reject via finally

wire it into all ad-hoc disable/spinner/restore patterns:
  - common.js: bindAsyncForms uses asyncBtn internally
  - core.js: 'clear perms' button
  - permissions.js: clearStaleAgent
  - schedules.js: saveSchedule submit, editSchedule submit
  - app.js: buildAnswerForm, buildInboxMarkAllRow

fireScheduleNow in schedules.js is left with its existing childNode
save/restore because it shows a custom result flash on the button
content after a successful fire-now (the auto-restore of asyncBtn
would overwrite it); the surrounding themedConfirm dialog already
acts as a natural double-click barrier before the fetch.

saveAll in permissions.js is also left as-is: it uses a custom
'queued ✓' success label + a 900ms delay before re-fetch; the
btn.dataset.busy flag is its own double-submit guard.
This commit is contained in:
iris 2026-07-20 19:51:03 +02:00
commit 4b45c5cd3d
7 changed files with 143 additions and 119 deletions

View file

@ -3,6 +3,7 @@
// actions (send / login/* / dashboard rebuild).
import { create as termCreate, linkify as termLinkify } from '@hive/shared/terminal.js';
import { asyncBtn } from '@hive/shared/forms.js';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
@ -1051,29 +1052,28 @@ window.marked = marked;
const ta = el('textarea', { rows: '2', placeholder: 'answer as operator…' });
const btn = el('button', { type: 'button' }, 'send answer');
const status = el('span', { class: 'answer-status' });
btn.addEventListener('click', async () => {
btn.addEventListener('click', () => {
const answer = ta.value.trim();
if (!answer) { status.textContent = 'answer required'; return; }
if (!dashboardBase) { status.textContent = 'dashboard url unknown'; return; }
btn.disabled = true;
status.textContent = 'sending…';
try {
const resp = await fetch(dashboardBase + 'api/answer-question/' + id, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'answer=' + encodeURIComponent(answer),
});
if (resp.ok) {
status.textContent = 'answered ✓';
refreshLooseEnds();
} else {
status.textContent = 'failed: ' + (await resp.text());
btn.disabled = false;
asyncBtn(btn, async () => {
try {
const resp = await fetch(dashboardBase + 'api/answer-question/' + id, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'answer=' + encodeURIComponent(answer),
});
if (resp.ok) {
status.textContent = 'answered ✓';
refreshLooseEnds();
} else {
status.textContent = 'failed: ' + (await resp.text());
}
} catch (err) {
status.textContent = 'failed: ' + err;
}
} catch (err) {
status.textContent = 'failed: ' + err;
btn.disabled = false;
}
});
});
wrap.append(ta, btn, status);
return wrap;
@ -1094,38 +1094,31 @@ window.marked = marked;
+ 'history shown here is the most-recent-N regardless of state, '
+ 'so the list itself stays visible.',
}, '✓ mark all read');
btn.addEventListener('click', async () => {
if (!dashboardBase) {
status.textContent = 'dashboard url unknown';
return;
}
if (!label) {
status.textContent = 'agent label unknown';
return;
}
btn.addEventListener('click', () => {
if (!dashboardBase) { status.textContent = 'dashboard url unknown'; return; }
if (!label) { status.textContent = 'agent label unknown'; return; }
if (!window.confirm(
`mark every queued message for ${label} as read? `
+ `the message history shown stays; only the unread queue is drained.`
)) return;
btn.disabled = true;
status.textContent = 'clearing…';
try {
const resp = await fetch(
dashboardBase + 'api/agent/' + encodeURIComponent(label) + '/mark-all-read',
{ method: 'POST' });
if (resp.ok) {
const data = await resp.json().catch(() => ({}));
const n = Number(data.marked) || 0;
status.textContent = '✓ marked ' + n + ' as read';
if (typeof onCleared === 'function') onCleared();
} else {
status.textContent = 'failed: http ' + resp.status;
btn.disabled = false;
asyncBtn(btn, async () => {
try {
const resp = await fetch(
dashboardBase + 'api/agent/' + encodeURIComponent(label) + '/mark-all-read',
{ method: 'POST' });
if (resp.ok) {
const data = await resp.json().catch(() => ({}));
const n = Number(data.marked) || 0;
status.textContent = '✓ marked ' + n + ' as read';
if (typeof onCleared === 'function') onCleared();
} else {
status.textContent = 'failed: http ' + resp.status;
}
} catch (err) {
status.textContent = 'failed: ' + err;
}
} catch (err) {
status.textContent = 'failed: ' + err;
btn.disabled = false;
}
});
});
return el('div', { class: 'inbox-mark-all-row' }, btn, status);
}