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:
thandClaude Opus 4.6 committed 2026-04-11 21:55:03 +02:00
1 parent 2059e50ce3
commit 08f73dfd19
8 files changed
+696 -51

No files matched your search

+39 -2
View File
@@ -355,7 +355,33 @@ if (str_starts_with($route, '/api/')) {
$b->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/')) {
</div>
</div>
<div id="bucket-row" class="flex items-center px-6 py-1.5 relative">
<div class="flex items-center gap-1 overflow-x-auto flex-1" id="bucket-bar"></div>
<div class="flex items-center gap-1 overflow-x-auto flex-1 relative" id="bucket-bar"></div>
<button id="board-split-toggle" class="toolbar-toggle" title="split preview">
<i class="ph ph-split-horizontal text-sm"></i>
</button>