super-buckets, dnd insert cursor for cards + buckets, drag-state survives polls
Super-buckets: a bucket can act as a dropdown of nested children, 1-level deep, created by dragging one bucket onto the center of another. The parent shows up as a single tile in the bar with a stack icon and the active member's name; clicking opens a popover that lists parent + all children. Selecting an entry sets state.activeBucket. Implementation: * src/Database.php — ALTER TABLE buckets ADD COLUMN parent_id + index. * public/index.php — PATCH /api/buckets accepts parent_id with depth / cycle / collab guards. DELETE on a parent un-nests its children rather than cascade-deleting. The /api/import handler does a second pass to remap parent_id through the new bucket id map so backup + restore round-trips the hierarchy. * public/js/modules/buckets.js renderBar() — top-level buckets only; buckets with ≥1 child render as a `.bucket-super` tile with data-bucket-super=<parent.id> and data-super-shown=<active member>. Three drop zones per tile: left/right 25% → reorder via insert cursor, center 50% → nest (PATCH parent_id) with .bucket-nest-target outline. canNestInto() mirrors the backend rules client-side. openSuperDropdown() renders a popover under the tile with parent + children as real .bucket-tab[data-bucket=…] entries (so the collab plugin's MutationObserver still picks them up); appended inside #bucket-bar but `position:fixed` so the bar's overflow:auto can't clip it. showBucketCtx() now scopes move-left/right to siblings (top-level peers OR same parent_id) so the action works in both the bar and the dropdown. Drag-and-drop insertion cursor for cards (matches the bucket fix): * public/js/modules/board.js bindCardEvents() — single #card-drop-cursor element inside #board, repositioned by per-card dragover. Three zones (left/right 25% → cursor in gap, center 50% → card-drop-group highlight). dragleave does NOT hide the cursor (was flickering as the mouse moved between adjacent cards); only dragend clears it. handleCardDrop recomputes the drop zone from e.clientX since the cursor approach no longer tags the target with .card-drop-before / .card-drop-after. * "Vor der ersten Card" works automatically: the first card's left 25% positions the cursor at offsetLeft - 6, which sits inside #board's px-6 padding and stays visible. * public/css/style.css — old .card-drop-before::before / .card-drop-after::after pseudo-elements removed in favour of the #card-drop-cursor rule. Drag ghost survives background polls: * board.js draggingCardIds: Set<string> at module scope. dragstart fills it; dragend clears. renderBoard() re-applies the .card-dragging class to every member of the set after every render so caldav / collab polls calling _reloadAll mid-drag don't visually pop the source card back to its normal state. Tests (public/plugins/tests/super-buckets.test.js): * API parent_id: nest, un-nest, self-parent rejected, level-2 rejected (bucket with own children, target is itself a child), DELETE un-nests, export → import preserves parent_id. * Bar rendering: super-tile DOM markers, label = parent name when no child active, label = active child name when selected, popover opens with parent + children entries, click child sets activeBucket, click super-tile twice toggles popover closed, plain bucket stays plain. * Drop cursor element exists. * Both describe blocks reset state.activeBucket = 'all' in afterEach — the rendering tests set it to a child id and the leak was breaking downstream Card: Due Date & Done / Card: Grouping tests by filtering every newly-created card with bucket_id=null off the board. chore: bump version to 0.9.3 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2059e50ce3
commit
08f73dfd19
8 files changed
+696
-51
No files matched your search
@@ -0,0 +1,269 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js?v=2';
|
||||
import { state } from '../../js/modules/state.js';
|
||||
|
||||
// Helper: create a bucket with the given name and return its row.
|
||||
async function makeBucket(name) {
|
||||
return fetch('api/buckets', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ name }),
|
||||
}).then(r => r.json());
|
||||
}
|
||||
|
||||
async function patchBucket(id, body) {
|
||||
const res = await fetch(`api/buckets/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
async function deleteBucket(id) {
|
||||
return fetch(`api/buckets/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrf() },
|
||||
}).then(r => r.json());
|
||||
}
|
||||
|
||||
async function getBuckets() {
|
||||
return fetch('api/buckets').then(r => r.json());
|
||||
}
|
||||
|
||||
describe('Super-buckets: API parent_id', () => {
|
||||
afterEach(async () => {
|
||||
// These tests don't touch state.activeBucket but the bar-rendering
|
||||
// suite below does — keep cleanup symmetric so reordering tests
|
||||
// doesn't introduce a regression.
|
||||
state.activeBucket = 'all';
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it('PATCH parent_id nests one bucket under another', async () => {
|
||||
const parent = await makeBucket('__sb_parent_a__');
|
||||
const child = await makeBucket('__sb_child_a__');
|
||||
const res = await patchBucket(child.id, { parent_id: parent.id });
|
||||
expect(res.status).toBe(200);
|
||||
const all = await getBuckets();
|
||||
const found = all.find(b => b.id === child.id);
|
||||
expect(found.parent_id).toBe(parent.id);
|
||||
});
|
||||
|
||||
it('PATCH parent_id = null un-nests', async () => {
|
||||
const parent = await makeBucket('__sb_parent_b__');
|
||||
const child = await makeBucket('__sb_child_b__');
|
||||
await patchBucket(child.id, { parent_id: parent.id });
|
||||
const res = await patchBucket(child.id, { parent_id: null });
|
||||
expect(res.status).toBe(200);
|
||||
const all = await getBuckets();
|
||||
const found = all.find(b => b.id === child.id);
|
||||
expect(found.parent_id).toBeFalsy();
|
||||
});
|
||||
|
||||
it('refuses self as parent', async () => {
|
||||
const b = await makeBucket('__sb_self__');
|
||||
const res = await patchBucket(b.id, { parent_id: b.id });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain('self');
|
||||
});
|
||||
|
||||
it('refuses level 2 — bucket with own children cannot become a child', async () => {
|
||||
const grandparent = await makeBucket('__sb_grand__');
|
||||
const middle = await makeBucket('__sb_middle__');
|
||||
const leaf = await makeBucket('__sb_leaf__');
|
||||
// middle becomes a parent of leaf
|
||||
await patchBucket(leaf.id, { parent_id: middle.id });
|
||||
// Now try to make middle a child of grandparent — would create level 2
|
||||
const res = await patchBucket(middle.id, { parent_id: grandparent.id });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error.toLowerCase()).toContain('children');
|
||||
});
|
||||
|
||||
it('refuses nesting under a bucket that is itself a child', async () => {
|
||||
const parent = await makeBucket('__sb_parent_c__');
|
||||
const child = await makeBucket('__sb_child_c__');
|
||||
const other = await makeBucket('__sb_other_c__');
|
||||
await patchBucket(child.id, { parent_id: parent.id });
|
||||
// Try to nest `other` under `child` — child is already nested
|
||||
const res = await patchBucket(other.id, { parent_id: child.id });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error.toLowerCase()).toContain('child');
|
||||
});
|
||||
|
||||
it('DELETE on a parent un-nests its children back to top-level', async () => {
|
||||
const parent = await makeBucket('__sb_parent_d__');
|
||||
const c1 = await makeBucket('__sb_child_d1__');
|
||||
const c2 = await makeBucket('__sb_child_d2__');
|
||||
await patchBucket(c1.id, { parent_id: parent.id });
|
||||
await patchBucket(c2.id, { parent_id: parent.id });
|
||||
await deleteBucket(parent.id);
|
||||
const all = await getBuckets();
|
||||
const f1 = all.find(b => b.id === c1.id);
|
||||
const f2 = all.find(b => b.id === c2.id);
|
||||
expect(f1).toExist();
|
||||
expect(f2).toExist();
|
||||
expect(f1.parent_id).toBeFalsy();
|
||||
expect(f2.parent_id).toBeFalsy();
|
||||
});
|
||||
|
||||
it('export → import preserves parent_id', async () => {
|
||||
const parent = await makeBucket('__sb_parent_e__');
|
||||
const child = await makeBucket('__sb_child_e__');
|
||||
await patchBucket(child.id, { parent_id: parent.id });
|
||||
|
||||
// Round-trip through export → replace-import
|
||||
const blob = await fetch('api/export').then(r => r.blob());
|
||||
const form = new FormData();
|
||||
form.append('file', blob, 'roundtrip.zip');
|
||||
await fetch('api/import', { method: 'POST', body: form, headers: { 'X-CSRF-Token': csrf() } });
|
||||
|
||||
const all = await getBuckets();
|
||||
const p = all.find(b => b.name === '__sb_parent_e__');
|
||||
const c = all.find(b => b.name === '__sb_child_e__');
|
||||
expect(p).toExist();
|
||||
expect(c).toExist();
|
||||
expect(c.parent_id).toBe(p.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Super-buckets: Bar rendering', () => {
|
||||
afterEach(async () => {
|
||||
// Reset activeBucket — several tests in this suite set it to a
|
||||
// child id that wouldn't otherwise survive the run. Without this
|
||||
// reset, downstream tests that create cards with bucket_id=null
|
||||
// can't find them on the board (the activeBucket filter excludes
|
||||
// them) and start failing in nondeterministic order.
|
||||
state.activeBucket = 'all';
|
||||
document.getElementById('bucket-super-popover')?.remove();
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it('a parent with one child renders a single super-tile in the bar', async () => {
|
||||
const parent = await makeBucket('__sb_render_parent__');
|
||||
const child = await makeBucket('__sb_render_child__');
|
||||
await patchBucket(child.id, { parent_id: parent.id });
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
// The super-tile carries data-bucket-super=<parent.id>; the child is
|
||||
// not rendered directly in the bar.
|
||||
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
|
||||
expect(superTile).toExist();
|
||||
const childTile = document.querySelector(`#bucket-bar [data-bucket="${child.id}"]:not(#bucket-super-popover [data-bucket])`);
|
||||
expect(childTile).toBe(null);
|
||||
});
|
||||
|
||||
it('super-tile shows the parent name when no child is active', async () => {
|
||||
const parent = await makeBucket('__sb_label_parent__');
|
||||
const child = await makeBucket('__sb_label_child__');
|
||||
await patchBucket(child.id, { parent_id: parent.id });
|
||||
// Reset active filter so the parent is the default shown member.
|
||||
state.activeBucket = 'all';
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
|
||||
if (!superTile) return;
|
||||
expect(superTile.textContent).toContain('__sb_label_parent__');
|
||||
});
|
||||
|
||||
it('super-tile relabels to the active child name when a child is selected', async () => {
|
||||
const parent = await makeBucket('__sb_active_parent__');
|
||||
const child = await makeBucket('__sb_active_child__');
|
||||
await patchBucket(child.id, { parent_id: parent.id });
|
||||
state.activeBucket = String(child.id);
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
|
||||
if (!superTile) return;
|
||||
expect(superTile.textContent).toContain('__sb_active_child__');
|
||||
});
|
||||
|
||||
it('clicking the super-tile opens a popover with parent + child entries', async () => {
|
||||
const parent = await makeBucket('__sb_pop_parent__');
|
||||
const child1 = await makeBucket('__sb_pop_child1__');
|
||||
const child2 = await makeBucket('__sb_pop_child2__');
|
||||
await patchBucket(child1.id, { parent_id: parent.id });
|
||||
await patchBucket(child2.id, { parent_id: parent.id });
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
|
||||
if (!superTile) return;
|
||||
click(superTile);
|
||||
await sleep(100);
|
||||
|
||||
const popover = document.getElementById('bucket-super-popover');
|
||||
expect(popover).toExist();
|
||||
// Parent + 2 children = 3 entries
|
||||
const entries = popover.querySelectorAll('[data-bucket]');
|
||||
expect(entries.length).toBe(3);
|
||||
// All three names appear
|
||||
const text = popover.textContent;
|
||||
expect(text).toContain('__sb_pop_parent__');
|
||||
expect(text).toContain('__sb_pop_child1__');
|
||||
expect(text).toContain('__sb_pop_child2__');
|
||||
});
|
||||
|
||||
it('clicking a child entry in the popover sets activeBucket', async () => {
|
||||
const parent = await makeBucket('__sb_act_parent__');
|
||||
const child = await makeBucket('__sb_act_child__');
|
||||
await patchBucket(child.id, { parent_id: parent.id });
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
|
||||
if (!superTile) return;
|
||||
click(superTile);
|
||||
await sleep(100);
|
||||
|
||||
const childEntry = document.querySelector(`#bucket-super-popover [data-bucket="${child.id}"]`);
|
||||
expect(childEntry).toExist();
|
||||
click(childEntry);
|
||||
await sleep(150);
|
||||
|
||||
expect(String(state.activeBucket)).toBe(String(child.id));
|
||||
});
|
||||
|
||||
it('clicking the super-tile twice toggles the popover closed', async () => {
|
||||
const parent = await makeBucket('__sb_toggle_parent__');
|
||||
const child = await makeBucket('__sb_toggle_child__');
|
||||
await patchBucket(child.id, { parent_id: parent.id });
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
|
||||
if (!superTile) return;
|
||||
click(superTile);
|
||||
await sleep(100);
|
||||
expect(document.getElementById('bucket-super-popover')).toExist();
|
||||
click(superTile);
|
||||
await sleep(100);
|
||||
expect(document.getElementById('bucket-super-popover')).toBe(null);
|
||||
});
|
||||
|
||||
it('regular bucket (no children) keeps its plain data-bucket tile', async () => {
|
||||
const plain = await makeBucket('__sb_plain__');
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
const tile = document.querySelector(`#bucket-bar [data-bucket="${plain.id}"]`);
|
||||
expect(tile).toExist();
|
||||
// Plain tile is NOT a super-tile
|
||||
expect(tile.classList.contains('bucket-super')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Super-buckets: Drop cursor', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('#bucket-drop-cursor element exists in the bar', async () => {
|
||||
// Force a fresh render so the cursor element is appended
|
||||
await window._reloadAll?.();
|
||||
await sleep(100);
|
||||
const cursor = document.querySelector('#bucket-bar #bucket-drop-cursor');
|
||||
expect(cursor).toExist();
|
||||
// Hidden by default
|
||||
expect(cursor.style.display === 'none' || cursor.style.display === '').toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ const FILES = [
|
||||
'./toolbar.test.js',
|
||||
'./buckets.test.js',
|
||||
'./bucket-context.test.js',
|
||||
'./super-buckets.test.js',
|
||||
'./context-menu.test.js',
|
||||
'./card-interactions.test.js',
|
||||
'./filtering.test.js',
|
||||
|
||||
Reference in new issue
Block a user