From 08f73dfd19c5ffa1fe65ef3bfdd96129a46b9c26 Mon Sep 17 00:00:00 2001 From: th Date: Sat, 11 Apr 2026 21:55:03 +0200 Subject: [PATCH] super-buckets, dnd insert cursor for cards + buckets, drag-state survives polls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= and data-super-shown=. 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 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) --- config.php | 2 +- public/css/style.css | 65 ++++- public/index.php | 41 +++- public/js/modules/board.js | 93 ++++++- public/js/modules/buckets.js | 269 ++++++++++++++++++--- public/plugins/tests/super-buckets.test.js | 269 +++++++++++++++++++++ public/plugins/tests/tests.js | 1 + src/Database.php | 7 + 8 files changed, 696 insertions(+), 51 deletions(-) create mode 100644 public/plugins/tests/super-buckets.test.js diff --git a/config.php b/config.php index a5da473..4b587d2 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ execute(['id' => $m[1]]); $existing = $b->fetch(); $isCollab = $existing && !empty($existing['collab_uid']); - $allowed = $isCollab ? ['position'] : ['name', 'position', 'color']; + $allowed = $isCollab ? ['position'] : ['name', 'position', 'color', 'parent_id']; + // Super-bucket depth + cycle enforcement: a bucket may only become a + // child if (a) it has no children itself, (b) the prospective parent + // is top-level (parent_id IS NULL), and (c) self-parent is forbidden. + if (!$isCollab && array_key_exists('parent_id', $input)) { + $newParent = $input['parent_id']; + if ($newParent !== null && (int)$newParent !== 0) { + $newParent = (int)$newParent; + if ($newParent === (int)$m[1]) { + http_response_code(400); echo json_encode(['error' => 'cannot parent to self']); exit; + } + $h = $db->prepare('SELECT COUNT(*) FROM buckets WHERE parent_id = :id'); + $h->execute(['id' => $m[1]]); + if ((int)$h->fetchColumn() > 0) { + http_response_code(400); echo json_encode(['error' => 'this bucket has children — flatten it first']); exit; + } + $p = $db->prepare('SELECT parent_id FROM buckets WHERE id = :id'); + $p->execute(['id' => $newParent]); + $parentRow = $p->fetch(); + if (!$parentRow) { http_response_code(400); echo json_encode(['error' => 'parent not found']); exit; } + if ($parentRow['parent_id'] !== null) { + http_response_code(400); echo json_encode(['error' => 'cannot nest under a child bucket']); exit; + } + } else { + $input['parent_id'] = null; + } + } $fields = []; $params = ['id' => $m[1]]; foreach ($allowed as $f) { if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $f === 'color' ? validColor($input[$f]) : $input[$f]; } } if ($fields) $db->prepare('UPDATE buckets SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params); @@ -374,6 +400,9 @@ if (str_starts_with($route, '/api/')) { exit; } $db->prepare('UPDATE cards SET bucket_id = NULL WHERE bucket_id = :id')->execute(['id' => $m[1]]); + // If this was a super-bucket parent, un-nest its children back to top-level + // instead of cascade-deleting them. + $db->prepare('UPDATE buckets SET parent_id = NULL WHERE parent_id = :id')->execute(['id' => $m[1]]); $db->prepare('DELETE FROM buckets WHERE id = :id')->execute(['id' => $m[1]]); echo json_encode(['ok' => true]); exit; } @@ -654,6 +683,14 @@ if (str_starts_with($route, '/api/')) { ]); $bucketMap[$oldId] = $db->lastInsertId(); } + // Second pass: resolve parent_id (old id → new id) now that all + // buckets have been inserted and the map is complete. + foreach ($data['buckets'] as $b) { + if (!empty($b['parent_id']) && isset($bucketMap[$b['id']]) && isset($bucketMap[$b['parent_id']])) { + $db->prepare('UPDATE buckets SET parent_id = :p WHERE id = :id') + ->execute(['p' => $bucketMap[$b['parent_id']], 'id' => $bucketMap[$b['id']]]); + } + } // Import cards (preserve all fields including CalDAV sync state) $cardMap = []; // old id → new id @@ -1218,7 +1255,7 @@ if (str_starts_with($route, '/api/')) {
-
+
diff --git a/public/js/modules/board.js b/public/js/modules/board.js index 9c0b7b3..8f69138 100644 --- a/public/js/modules/board.js +++ b/public/js/modules/board.js @@ -23,6 +23,12 @@ const boardPreviewTags = $('board-preview-tags'); const boardPreviewMeta = $('board-preview-meta'); let activeDragEl = null; let dragGhost = null; +// Set of card ids currently being dragged. Module-scoped so a poll-driven +// re-render (caldav/collab) can re-apply the `.card-dragging` ghost class +// to the matching DOM nodes after `board.innerHTML = …` wipes them. Without +// this the dragged card would visually pop back to its normal state every +// time a background poll completed mid-drag. +const draggingCardIds = new Set(); // ── Multi-select ── const selectedIds = state.selectedIds; @@ -614,6 +620,16 @@ export function renderBoard() { bindCardEvents(); + // Re-apply the dragging ghost to any card whose drag is still in + // flight. Polls (caldav, collab) call _reloadAll → renderBoard mid- + // drag, which would otherwise wipe the visual ghost state without + // the underlying drag operation noticing. + if (draggingCardIds.size) { + draggingCardIds.forEach(did => { + board.querySelector(`[data-id="${did}"]`)?.classList.add('card-dragging'); + }); + } + // Restore selection state selectedIds.forEach(id => { const el = board.querySelector(`[data-id="${id}"]`); @@ -843,6 +859,36 @@ function hideFan() { // ── Card event binding ── function bindCardEvents() { + // Single insertion-cursor element that lives inside #board (which is + // `relative`) and gets repositioned by the card-level dragover handlers + // below. Replaces the previous `.card-drop-before::before` / + // `.card-drop-after::after` pseudo-elements so the cursor is a real + // element in the gap rather than a sliver glued to the side of a tile. + let cardDropCursor = board.querySelector('#card-drop-cursor'); + if (!cardDropCursor) { + cardDropCursor = document.createElement('div'); + cardDropCursor.id = 'card-drop-cursor'; + board.appendChild(cardDropCursor); + } + function showCardDropCursor(targetEl, before) { + // offsetLeft / offsetTop / offsetHeight are relative to the + // offsetParent (#board) so the cursor positions correctly even + // when #board is scrolled vertically. + const x = before + ? targetEl.offsetLeft - 6 + : targetEl.offsetLeft + targetEl.offsetWidth + 2; + cardDropCursor.style.left = x + 'px'; + cardDropCursor.style.top = (targetEl.offsetTop + 8) + 'px'; + cardDropCursor.style.height = (targetEl.offsetHeight - 16) + 'px'; + cardDropCursor.style.display = 'block'; + } + function hideCardDropCursor() { + cardDropCursor.style.display = 'none'; + } + // Expose so the global dragend handler (defined outside this function) + // can hide the cursor too. + board._hideCardDropCursor = hideCardDropCursor; + board.querySelectorAll('[data-group]').forEach(badge => { badge.addEventListener('click', e => { e.stopPropagation(); state.activeGroup = badge.dataset.group; renderBoard(); }); }); @@ -932,8 +978,11 @@ function bindCardEvents() { const isMulti = selectedIds.has(String(id)) && selectedIds.size > 1; const dragIds = isMulti ? [...selectedIds] : [String(id)]; - // Ghost all dragged cards + // Ghost all dragged cards + remember the ids so a poll-driven + // re-render can re-apply the ghost state. + draggingCardIds.clear(); dragIds.forEach(did => { + draggingCardIds.add(String(did)); board.querySelector(`[data-id="${did}"]`)?.classList.add('card-dragging'); }); @@ -979,20 +1028,36 @@ function bindCardEvents() { el.addEventListener('dragend', () => { // Remove ghost class from all dragged cards board.querySelectorAll('.card-dragging').forEach(c => c.classList.remove('card-dragging')); + draggingCardIds.clear(); activeDragEl = null; if (dragGhost) { dragGhost.remove(); dragGhost = null; } - board.querySelectorAll('.card-drop-before,.card-drop-after,.card-drop-group').forEach(x => - x.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group')); + board.querySelectorAll('.card-drop-group').forEach(x => x.classList.remove('card-drop-group')); + hideCardDropCursor(); }); el.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const rect = el.getBoundingClientRect(); const edge = rect.width * 0.25, x = e.clientX - rect.left; - el.classList.toggle('card-drop-before', x < edge); - el.classList.toggle('card-drop-after', x > rect.width - edge); - el.classList.toggle('card-drop-group', x >= edge && x <= rect.width - edge); + // Three zones (matching the bucket fix): left 25% / right 25% + // → insertion cursor in the gap, center 50% → group highlight + // on the target card. + const isBefore = x < edge; + const isAfter = x > rect.width - edge; + const isGroup = !isBefore && !isAfter; + el.classList.toggle('card-drop-group', isGroup); + if (isGroup) { + hideCardDropCursor(); + } else { + showCardDropCursor(el, isBefore); + } + }); + el.addEventListener('dragleave', () => { + el.classList.remove('card-drop-group'); + // Don't hide the cursor here — the next card's dragover will + // reposition it. Hiding on dragleave makes the cursor flicker + // as the mouse moves across adjacent cards. Final cleanup + // happens on dragend. }); - el.addEventListener('dragleave', () => el.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group')); el.addEventListener('drop', e => handleCardDrop(e, el, id)); }); } @@ -1001,9 +1066,17 @@ function bindCardEvents() { async function handleCardDrop(e, el, targetId) { e.preventDefault(); - const wasGroup = el.classList.contains('card-drop-group'); - const wasBefore = el.classList.contains('card-drop-before'); - el.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group'); + // Recompute the drop zone from the mouse position rather than reading + // it back from CSS classes — the new cursor-based dragover doesn't + // tag the target with `.card-drop-before` / `.card-drop-after` anymore, + // only `.card-drop-group` for the center hover state. + const rect = el.getBoundingClientRect(); + const edge = rect.width * 0.25; + const x = e.clientX - rect.left; + const wasGroup = !(x < edge) && !(x > rect.width - edge); + const wasBefore = x < edge; + el.classList.remove('card-drop-group'); + board.querySelector('#card-drop-cursor')?.style.setProperty('display', 'none'); const dragId = e.dataTransfer.getData('text/plain'); if (!dragId || dragId === targetId) return; diff --git a/public/js/modules/buckets.js b/public/js/modules/buckets.js index fa24cbe..352cb0b 100644 --- a/public/js/modules/buckets.js +++ b/public/js/modules/buckets.js @@ -20,9 +20,16 @@ document.addEventListener('click', hideBucketCtx); async function showBucketCtx(x, y, bucket) { ctxBucketId = bucket.id; - const idx = state.buckets.findIndex(b => b.id == bucket.id); + // Move-left / move-right operates within the bucket's siblings — + // top-level peers if the bucket is top-level, dropdown peers if it's + // a super-bucket child. This keeps reordering scoped to where the + // user actually sees the bucket in the UI. + const siblings = state.buckets + .filter(b => (b.parent_id || null) === (bucket.parent_id || null)) + .sort((a, b) => (a.position || 0) - (b.position || 0)); + const idx = siblings.findIndex(b => b.id == bucket.id); const canLeft = idx > 0; - const canRight = idx < state.buckets.length - 1; + const canRight = idx >= 0 && idx < siblings.length - 1; const hasCards = state.cards.some(c => c.bucket_id == bucket.id); const isCollab = !!bucket.collab_uid; @@ -98,11 +105,12 @@ async function showBucketCtx(x, y, bucket) { } else if (a === 'move-left' || a === 'move-right') { hideBucketCtx(); - const swapIdx = a === 'move-left' ? idx - 1 : idx + 1; - const other = state.buckets[swapIdx]; + const other = siblings[a === 'move-left' ? idx - 1 : idx + 1]; if (!other) return; - await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { position: swapIdx } }); - await api(`api/buckets/${other.id}`, { method: 'PATCH', body: { position: idx } }); + // Swap positions of two siblings — works for both top-level + // and dropdown reordering since `siblings` is scoped above. + await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { position: other.position ?? 0 } }); + await api(`api/buckets/${other.id}`, { method: 'PATCH', body: { position: bucket.position ?? 0 } }); await loadBuckets(); window._renderBoard(); } else if (a === 'delete-keep') { @@ -222,13 +230,42 @@ function renderBar() { if (!bar) return; const tab = (active) => `bucket-tab ${active ? 'active' : ''} flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors shrink-0`; + // Top-level buckets only — children are rendered inside their parent's + // dropdown popover, not directly in the bar. + const topLevel = state.buckets.filter(b => !b.parent_id); + const childrenOf = (parentId) => state.buckets + .filter(b => b.parent_id == parentId) + .sort((a, b) => (a.position || 0) - (b.position || 0)); + + // Resolve which child / parent to *show* on a super-bucket tile. The + // active bucket wins; otherwise fall back to the parent itself. + const tileFor = (parent) => { + const kids = childrenOf(parent.id); + if (!kids.length) return null; // not a super-bucket + const activeChild = kids.find(c => c.id == state.activeBucket); + if (activeChild) return activeChild; + if (state.activeBucket == parent.id) return parent; + return parent; + }; + bar.innerHTML = ` - ${state.buckets.map(b => { + ${topLevel.map(b => { + const kids = childrenOf(b.id); + const isSuper = kids.length > 0; + if (isSuper) { + // Super-bucket tile: shows the active member's name + a stack icon. + // The shown member's id goes on `data-super-shown` so we can + // highlight + recolor without taking over the dropdown click. + const shown = tileFor(b); + const c = shown.color || '#64748b'; + const groupActive = state.activeBucket == b.id || kids.some(k => k.id == state.activeBucket); + return ``; + } const c = b.color || '#64748b'; const active = state.activeBucket == b.id; return ``; @@ -239,11 +276,152 @@ function renderBar() { let draggingBucketId = null; - bar.querySelectorAll('.bucket-tab').forEach(t => { - const bid = t.dataset.bucket; - const isUser = bid !== 'all' && bid !== 'none'; + // Single insertion-cursor element shared by all bucket tabs. Lives + // inside #bucket-bar (which is `position: relative` so absolute + // children align to its scroll-aware coord space) and gets repositioned + // by the dragover handlers below. The cursor sits *between* tiles, + // not as a border on the target tile, so it stays unambiguous when + // we add the super-bucket nest gesture later. + let dropCursor = bar.querySelector('#bucket-drop-cursor'); + if (!dropCursor) { + dropCursor = document.createElement('div'); + dropCursor.id = 'bucket-drop-cursor'; + bar.appendChild(dropCursor); + } + function showDropCursor(targetTab, before) { + // Position the cursor at the gap on the chosen side of the target. + // offsetLeft / offsetWidth are relative to the offsetParent + // (#bucket-bar) so they survive horizontal scroll. + const x = before + ? targetTab.offsetLeft - 4 + : targetTab.offsetLeft + targetTab.offsetWidth + 1; + dropCursor.style.left = x + 'px'; + dropCursor.style.display = 'block'; + } + function hideDropCursor() { + dropCursor.style.display = 'none'; + } - t.addEventListener('click', () => { state.activeBucket = bid; state.activeGroup = null; renderBar(); window._renderBoard(); }); + /** Can `draggedId` legally become a child of `targetId`? + * Rules: 1-level deep only, no self-parenting, target must be a real + * user bucket and currently top-level, dragged must not have children. */ + function canNestInto(draggedId, targetId) { + if (!Number.isFinite(draggedId) || !Number.isFinite(targetId)) return false; + if (draggedId === targetId) return false; + const target = state.buckets.find(b => b.id === targetId); + if (!target || target.parent_id) return false; + const dragged = state.buckets.find(b => b.id === draggedId); + if (!dragged) return false; + // Already a child of the same target → no-op nest + if (dragged.parent_id === targetId) return false; + // Dragged has its own children → would create level-2 + if (state.buckets.some(b => b.parent_id === draggedId)) return false; + return true; + } + + /** Open a popover under a super-bucket tile, listing parent + every + * child. Each entry is a real `.bucket-tab[data-bucket=...]` element + * so the collab plugin's MutationObserver still picks them up and + * decorates them with the unread dot / users-three icon. */ + function openSuperDropdown(anchorTab, parentId) { + // Toggle: if a popover is already open for this parent, close it. + const existing = document.getElementById('bucket-super-popover'); + if (existing) { + const wasFor = existing.dataset.parentId; + existing.remove(); + if (wasFor === String(parentId)) return; + } + const parent = state.buckets.find(b => b.id === parentId); + if (!parent) return; + const kids = state.buckets + .filter(b => b.parent_id === parentId) + .sort((a, b) => (a.position || 0) - (b.position || 0)); + const members = [parent, ...kids]; + + const pop = document.createElement('div'); + pop.id = 'bucket-super-popover'; + pop.dataset.parentId = String(parentId); + // Anchor under the tile, clamped to the viewport. + const rect = anchorTab.getBoundingClientRect(); + pop.style.position = 'fixed'; + pop.style.top = (rect.bottom + 4) + 'px'; + pop.style.left = rect.left + 'px'; + + const renderEntry = (m) => { + const c = m.color || '#64748b'; + const active = state.activeBucket == m.id; + const isParent = m.id === parentId; + return ``; + }; + pop.innerHTML = members.map(renderEntry).join(''); + // Append inside #bucket-bar (not document.body) so the collab + // plugin's MutationObserver on #bucket-bar still picks up these + // .bucket-tab entries and decorates collab children with the + // unread dot + users-three icon. Position is `fixed` anyway so + // the bar's overflow:auto can't clip the popover. + bar.appendChild(pop); + + // Wire each entry: click selects, contextmenu opens bucket ctx, + // drag starts a re-parent / re-order operation against the bar. + pop.querySelectorAll('.bucket-tab[data-bucket]').forEach(entry => { + const ebid = entry.dataset.bucket; + entry.addEventListener('click', () => { + state.activeBucket = ebid; + state.activeGroup = null; + pop.remove(); + renderBar(); + window._renderBoard(); + }); + entry.addEventListener('contextmenu', e => { + e.preventDefault(); e.stopPropagation(); + const bk = state.buckets.find(b => b.id == ebid); + if (bk) showBucketCtx(e.clientX, e.clientY, bk); + }); + entry.addEventListener('dragstart', e => { + draggingBucketId = ebid; + e.dataTransfer.setData('application/x-bucket', ebid); + e.dataTransfer.effectAllowed = 'move'; + entry.style.opacity = '0.3'; + }); + entry.addEventListener('dragend', () => { + entry.style.opacity = ''; + draggingBucketId = null; + hideDropCursor(); + bar.querySelectorAll('.bucket-nest-target').forEach(x => x.classList.remove('bucket-nest-target')); + }); + }); + + // Click outside the popover closes it. + setTimeout(() => { + const onDocClick = (e) => { + if (!pop.contains(e.target) && !anchorTab.contains(e.target)) { + pop.remove(); + document.removeEventListener('click', onDocClick); + } + }; + document.addEventListener('click', onDocClick); + }, 0); + } + + bar.querySelectorAll('.bucket-tab').forEach(t => { + // Super-bucket tile uses `data-bucket-super`; regular tiles use + // `data-bucket`. The id we resolve here is the parent's id for a + // super-tile (so drag/contextmenu/etc. operate on the parent). + const isSuperTile = t.classList.contains('bucket-super'); + const bid = isSuperTile ? t.dataset.bucketSuper : t.dataset.bucket; + const SPECIAL = ['all', 'current', 'favorites', 'journal', 'none']; + const isUser = isSuperTile || !SPECIAL.includes(bid); + + if (isSuperTile) { + // Click opens the dropdown popover instead of activating the + // parent directly. The dropdown lists parent + every child. + t.addEventListener('click', (e) => { + e.stopPropagation(); + openSuperDropdown(t, parseInt(bid, 10)); + }); + } else { + t.addEventListener('click', () => { state.activeBucket = bid; state.activeGroup = null; renderBar(); window._renderBoard(); }); + } if (isUser) { t.addEventListener('contextmenu', e => { @@ -262,18 +440,31 @@ function renderBar() { t.addEventListener('dragend', () => { t.style.opacity = ''; draggingBucketId = null; - bar.querySelectorAll('.bucket-drop-left,.bucket-drop-right').forEach(x => x.classList.remove('bucket-drop-left', 'bucket-drop-right')); + hideDropCursor(); + bar.querySelectorAll('.bucket-nest-target').forEach(x => x.classList.remove('bucket-nest-target')); }); } t.addEventListener('dragover', e => { e.preventDefault(); if (draggingBucketId && isUser && draggingBucketId !== bid) { - // Bucket reorder indicator + // Three drop zones along the tile's width: + // left 25% → insert before + // right 25% → insert after + // center 50% → nest into this bucket const rect = t.getBoundingClientRect(); - const left = e.clientX < rect.left + rect.width / 2; - t.classList.toggle('bucket-drop-left', left); - t.classList.toggle('bucket-drop-right', !left); + const x = e.clientX - rect.left; + const isLeft = x < rect.width * 0.25; + const isRight = x > rect.width * 0.75; + const isCenter = !isLeft && !isRight; + if (isCenter && canNestInto(parseInt(draggingBucketId, 10), parseInt(bid, 10))) { + hideDropCursor(); + bar.querySelectorAll('.bucket-nest-target').forEach(x => x.classList.remove('bucket-nest-target')); + t.classList.add('bucket-nest-target'); + } else { + bar.querySelectorAll('.bucket-nest-target').forEach(x => x.classList.remove('bucket-nest-target')); + showDropCursor(t, isLeft); + } t.classList.remove('drag-over'); } else if (!draggingBucketId) { // Card drop on bucket @@ -281,23 +472,44 @@ function renderBar() { } }); t.addEventListener('dragleave', () => { - t.classList.remove('drag-over', 'bucket-drop-left', 'bucket-drop-right'); + t.classList.remove('drag-over'); }); t.addEventListener('drop', async e => { e.preventDefault(); - t.classList.remove('drag-over', 'bucket-drop-left', 'bucket-drop-right'); + t.classList.remove('drag-over', 'bucket-nest-target'); + hideDropCursor(); - // Bucket reorder + // Bucket reorder / nest const dragBid = e.dataTransfer.getData('application/x-bucket'); if (dragBid && isUser && dragBid !== bid) { const rect = t.getBoundingClientRect(); - const before = e.clientX < rect.left + rect.width / 2; - const ids = state.buckets.map(b => String(b.id)).filter(i => i !== dragBid); - const targetIdx = ids.indexOf(String(bid)); - ids.splice(before ? targetIdx : targetIdx + 1, 0, dragBid); - await Promise.all(ids.map((id, i) => api(`api/buckets/${id}`, { method: 'PATCH', body: { position: i } }))); - await loadBuckets(); - window._renderBoard(); + const x = e.clientX - rect.left; + const isLeft = x < rect.width * 0.25; + const isRight = x > rect.width * 0.75; + const isCenter = !isLeft && !isRight; + if (isCenter && canNestInto(parseInt(dragBid, 10), parseInt(bid, 10))) { + // Nest: dragged becomes a child of target. Backend + // refuses cycles / level-2 with a 400 — bubble that + // up via alert so the user knows. + const res = await api(`api/buckets/${dragBid}`, { method: 'PATCH', body: { parent_id: parseInt(bid, 10) } }); + if (res?.error) { alert(res.error); return; } + await loadBuckets(); + renderBar(); + window._renderBoard(); + return; + } + // Reorder among top-level buckets only. + const before = isLeft || (!isRight && x < rect.width / 2); + const topIds = state.buckets.filter(b => !b.parent_id).map(b => String(b.id)).filter(i => i !== dragBid); + const targetIdx = topIds.indexOf(String(bid)); + if (targetIdx >= 0) { + topIds.splice(before ? targetIdx : targetIdx + 1, 0, dragBid); + // The dragged bucket also gets un-nested if it was a child. + await api(`api/buckets/${dragBid}`, { method: 'PATCH', body: { parent_id: null } }); + await Promise.all(topIds.map((id, i) => api(`api/buckets/${id}`, { method: 'PATCH', body: { position: i } }))); + await loadBuckets(); + window._renderBoard(); + } return; } @@ -314,7 +526,10 @@ function renderBar() { window._renderBoard(); return; } - const bucketId = bid === 'none' ? null : (['all','current','journal'].includes(bid) ? undefined : bid); + // Card drop on a super-bucket: card goes into the *currently + // shown* member of that group, not the parent itself. + const targetBid = isSuperTile ? (t.dataset.superShown || bid) : bid; + const bucketId = targetBid === 'none' ? null : (['all','current','journal'].includes(targetBid) ? undefined : targetBid); if (bucketId === undefined) return; await Promise.all(ids.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: bucketId, parent_id: null } }))); window._clearSelection?.(); diff --git a/public/plugins/tests/super-buckets.test.js b/public/plugins/tests/super-buckets.test.js new file mode 100644 index 0000000..5cba72d --- /dev/null +++ b/public/plugins/tests/super-buckets.test.js @@ -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=; 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); + }); +}); diff --git a/public/plugins/tests/tests.js b/public/plugins/tests/tests.js index 34798bb..613bb69 100644 --- a/public/plugins/tests/tests.js +++ b/public/plugins/tests/tests.js @@ -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', diff --git a/src/Database.php b/src/Database.php index 91aed10..1988828 100644 --- a/src/Database.php +++ b/src/Database.php @@ -144,5 +144,12 @@ class Database self::$pdo->exec('ALTER TABLE buckets ADD COLUMN collab_uid TEXT DEFAULT NULL'); self::$pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_buckets_collab_uid ON buckets(collab_uid) WHERE collab_uid IS NOT NULL'); } + // Super-buckets: 1-level nesting. parent_id = NULL for top-level + // (regular bucket OR super-bucket parent), or another bucket id for + // a child. Depth is enforced in the API layer (no grandchildren). + if (!in_array('parent_id', $bcolNames)) { + self::$pdo->exec('ALTER TABLE buckets ADD COLUMN parent_id INTEGER DEFAULT NULL REFERENCES buckets(id)'); + self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_buckets_parent_id ON buckets(parent_id)'); + } } }