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
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue