group delete with confirmation + undo, retry progress bar fix
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
c16659c2ad
commit
56c90225cb
4 files changed
+168
-6
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
define('APP_NAME', 'hyprcrd');
|
||||
define('APP_VERSION', '0.9.9');
|
||||
define('APP_VERSION', '0.9.10');
|
||||
|
||||
// Database path — writable directory, outside webroot
|
||||
define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
|
||||
|
||||
@@ -354,13 +354,49 @@ export function showContextMenu(x, y, card) {
|
||||
else if (a === 'ungroup') { hideContextMenu(); pushCardEdit('ungroup', card.id, { parent_id: card.parent_id }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); }
|
||||
else if (a === 'delete') {
|
||||
hideContextMenu();
|
||||
const children = getChildren(card.id);
|
||||
const isGroup = children.length > 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?.();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user