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
|