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