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:
parent
6d281e4606
commit
4b45c5cd3d
7 changed files with 143 additions and 119 deletions
34
frontend/packages/shared/src/forms.js
Normal file
34
frontend/packages/shared/src/forms.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// Shared async-button primitive. Used by both the dashboard and the
|
||||
// per-agent UI for any button that triggers a network action.
|
||||
//
|
||||
// `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.) before the re-throw, or handle it inside `fn` without
|
||||
// re-throwing.
|
||||
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>';
|
||||
fn().finally(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = orig;
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue