From 05369377b02700d388950d449d1bcd4c2b48b0c3 Mon Sep 17 00:00:00 2001 From: Louis King Date: Sun, 19 Jul 2026 20:56:50 +0100 Subject: [PATCH 1/2] feat(ui): show spinner on modal action buttons while in-flight Adds a small DaisyUI loading-spinner to the primary action button and disables both action buttons (Save/Delete + Cancel) while the request is in flight. Prevents double-submit on slow operations like route create/update, and prevents closing a modal mid-request which leaves modalState in a confusing state. Two patterns in the SPA, kept idiomatic to each: - channels.js + routes.js (lit-html state-driven): thread a 'saving' boolean from modalState into the modal renderers, which add ?disabled + a leading when set. Handlers set the flag and re-render before await; clear and re-render in the catch. - node-detail.js (native + imperative listeners): add an id to the Save button so the handler can toggle .disabled and swap .innerHTML on the button element directly, restored in a finally. Covers all six modal action buttons: channel add/edit + delete, route add/edit + delete, node-tag edit + delete. --- .../web/static/js/spa/pages/channels.js | 22 ++++++++++++++----- .../web/static/js/spa/pages/node-detail.js | 21 +++++++++++++++++- .../web/static/js/spa/pages/routes.js | 22 ++++++++++++++----- 3 files changed, 52 insertions(+), 13 deletions(-) diff --git a/src/meshcore_hub/web/static/js/spa/pages/channels.js b/src/meshcore_hub/web/static/js/spa/pages/channels.js index f51f6f0..d1ec654 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/channels.js +++ b/src/meshcore_hub/web/static/js/spa/pages/channels.js @@ -63,7 +63,7 @@ function renderAddButton(onAdd) { `; } -function renderChannelModal({ channel, isEdit, onSave, onCancel }) { +function renderChannelModal({ channel, isEdit, onSave, onCancel, saving }) { const title = isEdit ? t('channels.edit_channel') : t('channels.add_channel'); return html` @@ -98,8 +98,8 @@ function renderChannelModal({ channel, isEdit, onSave, onCancel }) { @@ -107,14 +107,14 @@ function renderChannelModal({ channel, isEdit, onSave, onCancel }) { `; } -function renderDeleteModal({ channel, onConfirm, onCancel }) { +function renderDeleteModal({ channel, onConfirm, onCancel, saving }) { return html` @@ -186,12 +186,14 @@ export async function render(container, params, router) { isEdit: modalState.type === 'edit', onSave: handleSave, onCancel: () => { modalState = null; renderPage(channelsList); }, + saving: !!modalState.saving, }); } else if (modalState?.type === 'delete') { modalHtml = renderDeleteModal({ channel: modalState.channel, onConfirm: handleDeleteConfirm, onCancel: () => { modalState = null; renderPage(channelsList); }, + saving: !!modalState.saving, }); } @@ -258,6 +260,8 @@ export async function render(container, params, router) { } } + modalState = { ...modalState, saving: true }; + renderPage(channels); try { if (isEdit) { await apiPut(`/api/v1/channels/${modalState.channel.id}`, body); @@ -267,16 +271,22 @@ export async function render(container, params, router) { modalState = null; await refresh(); } catch (e) { + modalState = { ...modalState, saving: false }; + renderPage(channels); alert(e.message || 'Failed to save channel'); } } async function handleDeleteConfirm() { + modalState = { ...modalState, saving: true }; + renderPage(channels); try { await apiDelete(`/api/v1/channels/${modalState.channel.id}`); modalState = null; await refresh(); } catch (e) { + modalState = { ...modalState, saving: false }; + renderPage(channels); alert(e.message || 'Failed to delete channel'); } } diff --git a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js b/src/meshcore_hub/web/static/js/spa/pages/node-detail.js index f3ed26e..bfaed26 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js +++ b/src/meshcore_hub/web/static/js/spa/pages/node-detail.js @@ -62,7 +62,7 @@ function renderEditTagModal() { @@ -496,6 +496,12 @@ ${canEditTags ? renderEditTagModal() : nothing}`, container); } if (errorLabel) { errorLabel.textContent = ''; errorLabel.classList.add('hidden'); } + const submitBtn = container.querySelector('#tagEditSubmit'); + const cancelBtn = container.querySelector('#tagEditCancel'); + const orig = submitBtn.innerHTML; + submitBtn.disabled = true; + cancelBtn.disabled = true; + submitBtn.innerHTML = ` ${orig}`; try { await apiPut('/api/v1/nodes/' + node.public_key + '/tags/' + encodeURIComponent(key), { value, value_type: valueType }); container.querySelector('#tagEditModal').close(); @@ -503,6 +509,10 @@ ${canEditTags ? renderEditTagModal() : nothing}`, container); router.navigate('/nodes/' + node.public_key, true); } catch (err) { if (errorLabel) { errorLabel.textContent = err.message; errorLabel.classList.remove('hidden'); } + } finally { + submitBtn.disabled = false; + cancelBtn.disabled = false; + submitBtn.innerHTML = orig; } }, { signal }); } @@ -532,6 +542,11 @@ ${canEditTags ? renderEditTagModal() : nothing}`, container); deleteConfirm.addEventListener('click', async () => { const modal = container.querySelector('#tagDeleteModal'); const key = modal._tagKey; + const cancelBtn = container.querySelector('#tagDeleteCancel'); + const orig = deleteConfirm.innerHTML; + deleteConfirm.disabled = true; + cancelBtn.disabled = true; + deleteConfirm.innerHTML = ` ${orig}`; try { await apiDelete('/api/v1/nodes/' + node.public_key + '/tags/' + encodeURIComponent(key)); modal.close(); @@ -540,6 +555,10 @@ ${canEditTags ? renderEditTagModal() : nothing}`, container); } catch (err) { modal.close(); showFlash('error', err.message); + } finally { + deleteConfirm.disabled = false; + cancelBtn.disabled = false; + deleteConfirm.innerHTML = orig; } }, { signal }); } diff --git a/src/meshcore_hub/web/static/js/spa/pages/routes.js b/src/meshcore_hub/web/static/js/spa/pages/routes.js index 7d1dac1..02d2125 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/routes.js +++ b/src/meshcore_hub/web/static/js/spa/pages/routes.js @@ -262,7 +262,7 @@ function renderNodeSearchResult(node, onSelect) { `; } -function renderRouteModal({ modalState, onSave, onCancel }) { +function renderRouteModal({ modalState, onSave, onCancel, saving }) { const route = modalState.route; const isEdit = modalState.isEdit; const title = isEdit ? t('routes.edit_route') : t('routes.add_route'); @@ -434,8 +434,8 @@ function renderRouteModal({ modalState, onSave, onCancel }) { @@ -443,7 +443,7 @@ function renderRouteModal({ modalState, onSave, onCancel }) { `; } -function renderDeleteModal({ route, onConfirm, onCancel }) { +function renderDeleteModal({ route, onConfirm, onCancel, saving }) { const arrow = route.reversible !== false ? '\u2194' : '\u2192'; const label = `${route.from_label} ${arrow} ${route.to_label}`; return html` @@ -451,8 +451,8 @@ function renderDeleteModal({ route, onConfirm, onCancel }) {

${t('routes.delete_route')}

${t('routes.delete_confirm', { label })}

@@ -569,12 +569,14 @@ export async function render(container, params, router) { modalState, onSave: handleSave, onCancel: () => { modalState = null; renderPage(routesList); }, + saving: !!modalState.saving, }); } else if (modalState?.type === 'delete') { modalHtml = renderDeleteModal({ route: modalState.route, onConfirm: handleDeleteConfirm, onCancel: () => { modalState = null; renderPage(routesList); }, + saving: !!modalState.saving, }); } @@ -814,6 +816,8 @@ export async function render(container, params, router) { body.clear_threshold = parseInt(clearVal, 10); } + modalState = { ...modalState, saving: true }; + renderPage(routes); try { if (isEdit) { await apiPut(`/api/v1/routes/${modalState.route.id}`, body); @@ -825,16 +829,22 @@ export async function render(container, params, router) { modalState = null; await refresh(); } catch (e) { + modalState = { ...modalState, saving: false }; + renderPage(routes); alert(e.message || 'Failed to save route'); } } async function handleDeleteConfirm() { + modalState = { ...modalState, saving: true }; + renderPage(routes); try { await apiDelete(`/api/v1/routes/${modalState.route.id}`); modalState = null; await refresh(); } catch (e) { + modalState = { ...modalState, saving: false }; + renderPage(routes); alert(e.message || 'Failed to delete route'); } } From b86c514847c48027b214e7dd520e26a469d32705 Mon Sep 17 00:00:00 2001 From: Louis King Date: Sun, 19 Jul 2026 20:56:56 +0100 Subject: [PATCH 2/2] fix(routes): sort list by From then To Routes list was sorted only by from_label; routes sharing the same From appeared in backend-returned order, which looked random. Add to_label as a secondary tiebreaker in the localeCompare comparator. Case-sensitivity preserved (no behavior change for the primary key). --- src/meshcore_hub/web/static/js/spa/pages/routes.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/meshcore_hub/web/static/js/spa/pages/routes.js b/src/meshcore_hub/web/static/js/spa/pages/routes.js index 02d2125..2a386f8 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/routes.js +++ b/src/meshcore_hub/web/static/js/spa/pages/routes.js @@ -548,9 +548,10 @@ export async function render(container, params, router) { for (const vis of VISIBILITY_ORDER) { const group = groups.get(vis); if (!group || group.length === 0) continue; - group.sort((a, b) => - (a.from_label || '').localeCompare(b.from_label || '') - ); + group.sort((a, b) => { + const cmp = (a.from_label || '').localeCompare(b.from_label || ''); + return cmp !== 0 ? cmp : (a.to_label || '').localeCompare(b.to_label || ''); + }); groupedSections.push(html`

${t(`routes.visibility_${vis}`)}