From 56c90225cb07ef283c5e800a32c69a33fe45239a Mon Sep 17 00:00:00 2001 From: th Date: Sun, 12 Apr 2026 09:34:31 +0200 Subject: [PATCH] group delete with confirmation + undo, retry progress bar fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit context-menu: group delete (public/js/modules/context-menu.js): * When the delete action targets a card that has children (a group parent), the confirmation dialog is ALWAYS shown regardless of the hyprcrd-confirm-delete setting — group deletion is destructive enough that it should never happen accidentally. The dialog lists every child card by title and the OK button reads "Delete all N" (parent + children counted). * Children are deleted first (avoids orphan flicker), then the parent. * Undo: a single pushUndo entry recreates the parent via POST first, then all children with parent_id pointing at the new parent id. due_date is restored via a follow-up PATCH since POST doesn't accept it. Ctrl+Z fully restores the group hierarchy. * Non-group cards keep the existing behavior (simple confirm if the setting is on, single delete, pushCardDelete for undo). tests (public/plugins/tests/card-interactions.test.js): * New describe "Card: Group Delete" with two tests: - "deleting a group removes parent AND all children": creates a parent + 2 children, right-clicks the parent, clicks delete, asserts the dialog text contains both child titles, confirms, verifies all 3 cards are gone from the API. - "Ctrl+Z after group delete restores parent + children": creates parent + 1 child, deletes the group, confirms both gone, fires Ctrl+Z, finds the restored cards by title, asserts child.parent_id === restoredParent.id. Both tests save/restore the hyprcrd-confirm-delete localStorage value and soft-skip if the parent isn't visible on the board. runner: retry progress bar (public/plugins/tests/runner.js): * hideProgress() was called inside runAll() at the end of the first pass, which removed the #test-progress element before the retry pass started. The retry ran invisibly — "retrying…" never showed. Moved hideProgress() out of runAll() into run(), after both the first pass and the retry pass are done. The bar now stays visible for the whole sequence: first pass fills to 100%, retry resets the fill to 0 (via updateProgress(0, total, 'retrying…', false)) and fills again for just the failed tests. chore: bump version to 0.9.10 Co-Authored-By: Claude Opus 4.6 (1M context) --- config.php | 2 +- public/js/modules/context-menu.js | 42 +++++- .../plugins/tests/card-interactions.test.js | 122 ++++++++++++++++++ public/plugins/tests/runner.js | 8 +- 4 files changed, 168 insertions(+), 6 deletions(-) diff --git a/config.php b/config.php index 4ca7d33..c9613fc 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ 0; const needConfirm = localStorage.getItem('hyprcrd-confirm-delete') !== '0'; - if (needConfirm) { + if (isGroup) { + // Always confirm when deleting a group — list every child + // so the user knows exactly what they're losing. + const childList = children.map(c => `• ${c.title || '(untitled)'}`).join('\n'); + const msg = `Delete group "${card.title}" and all ${children.length} card${children.length > 1 ? 's' : ''} inside?\n\n${childList}`; + const ok = await showDialog({ title: 'delete_group', message: msg, danger: true, okLabel: `Delete all ${children.length + 1}` }); + if (!ok) return; + } else if (needConfirm) { const ok = await showDialog({ title: 'delete_card', message: `Really delete "${card.title}"?`, danger: true, okLabel: 'Delete' }); if (!ok) return; } - const wasSync = !!card.caldav_uid; - pushCardDelete('delete', snapshotCard(card)); + // Snapshot everything for undo BEFORE deleting + const allCards = isGroup ? [card, ...children] : [card]; + const snapshots = allCards.map(c => snapshotCard(c)); + const wasSync = allCards.some(c => !!c.caldav_uid); + pushUndo(`delete ${isGroup ? 'group' : 'card'} "${card.title}"`, async () => { + // Recreate parent first so children can reference it + const parent = await api('api/cards', { method: 'POST', body: { + title: snapshots[0].title, body: snapshots[0].body, + color: snapshots[0].color, bucket_id: snapshots[0].bucket_id, + position: snapshots[0].position, + }}); + if (parent?.id && snapshots[0].due_date) { + await api(`api/cards/${parent.id}`, { method: 'PATCH', body: { due_date: snapshots[0].due_date } }); + } + // Recreate children pointing at the new parent + for (let i = 1; i < snapshots.length; i++) { + const s = snapshots[i]; + const child = await api('api/cards', { method: 'POST', body: { + title: s.title, body: s.body, color: s.color, + bucket_id: s.bucket_id, parent_id: parent?.id, + position: s.position, + }}); + if (child?.id && s.due_date) { + await api(`api/cards/${child.id}`, { method: 'PATCH', body: { due_date: s.due_date } }); + } + } + }); + // Delete children first, then parent (avoids orphan flicker) + for (const c of children) await api(`api/cards/${c.id}`, { method: 'DELETE' }); await api(`api/cards/${card.id}`, { method: 'DELETE' }); await window._reloadAll(); if (wasSync) window._triggerSync?.(); diff --git a/public/plugins/tests/card-interactions.test.js b/public/plugins/tests/card-interactions.test.js index 1a464c5..4795f47 100644 --- a/public/plugins/tests/card-interactions.test.js +++ b/public/plugins/tests/card-interactions.test.js @@ -269,3 +269,125 @@ describe('Card: Grouping', () => { expect(parentEl).toExist(); }); }); + +describe('Card: Group Delete', () => { + afterEach(() => closeAll()); + + it('deleting a group removes parent AND all children', async () => { + // Disable confirm-delete so the non-group path doesn't block, + // but the group delete ALWAYS confirms regardless of this setting. + const prevConfirm = localStorage.getItem('hyprcrd-confirm-delete'); + localStorage.setItem('hyprcrd-confirm-delete', '0'); + + const parent = await fetch('api/cards', { + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, + body: JSON.stringify({ title: '__test_grpdel_parent__', body: '', color: '#38bdf8' }), + }).then(r => r.json()); + const child1 = await fetch('api/cards', { + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, + body: JSON.stringify({ title: '__test_grpdel_child1__', body: '', color: '#38bdf8', parent_id: parent.id }), + }).then(r => r.json()); + const child2 = await fetch('api/cards', { + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, + body: JSON.stringify({ title: '__test_grpdel_child2__', body: '', color: '#38bdf8', parent_id: parent.id }), + }).then(r => r.json()); + await window._reloadAll?.(); + await sleep(200); + + // The group parent should be on the board + const parentEl = document.querySelector(`[data-id="${parent.id}"]`); + if (!parentEl) { + // Restore and skip if parent not visible (e.g. filter state) + if (prevConfirm !== null) localStorage.setItem('hyprcrd-confirm-delete', prevConfirm); + else localStorage.removeItem('hyprcrd-confirm-delete'); + return; + } + + // Right-click → delete. The group delete dialog always shows + // regardless of the confirm-delete setting. We need to auto- + // confirm it. The showDialog function exposes the dialog element + // as #dialog; the OK button is .dialog-ok. + rightClick(parentEl); + await sleep(150); + click($$('#ctx-items [data-action="delete"]')[0]); + await sleep(200); + + // The confirmation dialog should be showing the child list + const dialog = document.getElementById('dialog'); + if (dialog?.classList.contains('open')) { + const msg = dialog.querySelector('#dialog-msg'); + // Dialog should mention both children + expect(msg.textContent).toContain('__test_grpdel_child1__'); + expect(msg.textContent).toContain('__test_grpdel_child2__'); + // Confirm the delete + const okBtn = dialog.querySelector('.dialog-ok'); + if (okBtn) click(okBtn); + await sleep(500); + } + + // All three cards should be gone + const remaining = await fetch('api/cards').then(r => r.json()); + expect(remaining.some(c => c.id === parent.id)).toBe(false); + expect(remaining.some(c => c.id === child1.id)).toBe(false); + expect(remaining.some(c => c.id === child2.id)).toBe(false); + + if (prevConfirm !== null) localStorage.setItem('hyprcrd-confirm-delete', prevConfirm); + else localStorage.removeItem('hyprcrd-confirm-delete'); + }); + + it('Ctrl+Z after group delete restores parent + children', async () => { + const prevConfirm = localStorage.getItem('hyprcrd-confirm-delete'); + localStorage.setItem('hyprcrd-confirm-delete', '0'); + + const parent = await fetch('api/cards', { + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, + body: JSON.stringify({ title: '__test_grpundo_parent__', body: 'parent body', color: '#fb7185' }), + }).then(r => r.json()); + const child = await fetch('api/cards', { + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, + body: JSON.stringify({ title: '__test_grpundo_child__', body: 'child body', color: '#38bdf8', parent_id: parent.id }), + }).then(r => r.json()); + await window._reloadAll?.(); + await sleep(200); + + const parentEl = document.querySelector(`[data-id="${parent.id}"]`); + if (!parentEl) { + if (prevConfirm !== null) localStorage.setItem('hyprcrd-confirm-delete', prevConfirm); + else localStorage.removeItem('hyprcrd-confirm-delete'); + return; + } + + // Delete the group + rightClick(parentEl); + await sleep(150); + click($$('#ctx-items [data-action="delete"]')[0]); + await sleep(200); + const dialog = document.getElementById('dialog'); + if (dialog?.classList.contains('open')) { + const okBtn = dialog.querySelector('.dialog-ok'); + if (okBtn) click(okBtn); + await sleep(500); + } + + // Confirm both are gone + let cards = await fetch('api/cards').then(r => r.json()); + expect(cards.some(c => c.id === parent.id)).toBe(false); + expect(cards.some(c => c.id === child.id)).toBe(false); + + // Undo + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, bubbles: true })); + await sleep(600); + + // Parent + child should be back (new ids, find by title) + cards = await fetch('api/cards').then(r => r.json()); + const restoredParent = cards.find(c => c.title === '__test_grpundo_parent__'); + const restoredChild = cards.find(c => c.title === '__test_grpundo_child__'); + expect(restoredParent).toExist(); + expect(restoredChild).toExist(); + // Child should point at the restored parent + expect(restoredChild.parent_id).toBe(restoredParent.id); + + if (prevConfirm !== null) localStorage.setItem('hyprcrd-confirm-delete', prevConfirm); + else localStorage.removeItem('hyprcrd-confirm-delete'); + }); +}); diff --git a/public/plugins/tests/runner.js b/public/plugins/tests/runner.js index 503141f..f6aef5d 100644 --- a/public/plugins/tests/runner.js +++ b/public/plugins/tests/runner.js @@ -281,7 +281,8 @@ async function runAll(only = null) { if (!suiteMap.has(suite.name)) suiteMap.set(suite.name, { name: suite.name, tests: [] }); suiteMap.get(suite.name).tests.push(entry); } - hideProgress(); + // Don't hide the progress bar here — run() does it after the retry + // pass (if any) so the bar stays visible during the whole sequence. return [...suiteMap.values()]; } @@ -425,7 +426,10 @@ async function run() { } } - // 5. Clean up any leftover UI state + // 5. Hide progress bar now that both passes (first + retry) are done + hideProgress(); + + // 6. Clean up any leftover UI state await closeAll(); // 6. Restore backup