Files
hyprcrd/public/plugins/tests/super-buckets.test.js
T
thandClaude Opus 4.6 08f73dfd19 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>
2026-04-11 21:55:03 +02:00

270 lines
11 KiB
JavaScript

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);
});
});