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
+693
-48
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
define('APP_NAME', 'hyprcrd');
|
||||
define('APP_VERSION', '0.9.2');
|
||||
define('APP_VERSION', '0.9.3');
|
||||
|
||||
// Database path — writable directory, outside webroot
|
||||
define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
|
||||
|
||||
+54
-11
@@ -426,21 +426,25 @@ body::after {
|
||||
pointer-events: none; z-index: 10;
|
||||
}
|
||||
|
||||
/* Drop indicators — thick glowing bar */
|
||||
.card-drop-before::before,
|
||||
.card-drop-after::after {
|
||||
content: '';
|
||||
/* Card drop cursor — single absolutely-positioned vertical line that
|
||||
lives inside #board (which is `position: relative`) and gets
|
||||
repositioned by the dragover handlers in `board.js`. Replaces the
|
||||
old `.card-drop-before::before` / `.card-drop-after::after` pseudo-
|
||||
elements so the indicator is a real DOM node in the gap between cards
|
||||
instead of a sliver glued to one card. The "before first card"
|
||||
case works automatically: the first card's left-25% zone positions
|
||||
the cursor at `card.offsetLeft - 6`, which sits in #board's left
|
||||
padding and stays visible. */
|
||||
#card-drop-cursor {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 4px;
|
||||
background: var(--t-accent);
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 12px var(--t-accent), 0 0 4px var(--t-accent);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
display: none;
|
||||
}
|
||||
.card-drop-before::before { left: -6px; }
|
||||
.card-drop-after::after { right: -6px; }
|
||||
|
||||
/* Group fan preview */
|
||||
.group-fan {
|
||||
@@ -665,8 +669,47 @@ body.detail-image-fs-open { overflow: hidden; }
|
||||
}
|
||||
.bucket-add.drag-over i,
|
||||
.bucket-add.drag-over span { color: inherit !important; }
|
||||
.bucket-drop-left { border-left: 3px solid var(--t-accent) !important; }
|
||||
.bucket-drop-right { border-right: 3px solid var(--t-accent) !important; }
|
||||
/* Bucket reorder drop cursor — a single absolutely-positioned vertical
|
||||
line that lives inside #bucket-bar and gets repositioned by the
|
||||
dragover handler in `public/js/modules/buckets.js`. Showing the cursor
|
||||
*between* tiles makes the insert point unambiguous next to the
|
||||
super-bucket nest gesture (center 50% → nest, edges 25% → reorder). */
|
||||
#bucket-drop-cursor {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 3px;
|
||||
background: var(--t-accent);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--t-accent) 70%, transparent);
|
||||
border-radius: 1px;
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
display: none;
|
||||
}
|
||||
/* Drop-into-center highlight: target tile gets an outline that says
|
||||
"release here to nest". Distinct from the insertion cursor so the
|
||||
user can tell reorder vs. nest at a glance. */
|
||||
.bucket-tab.bucket-nest-target {
|
||||
outline: 2px dashed var(--t-accent) !important;
|
||||
outline-offset: 1px;
|
||||
background: color-mix(in srgb, var(--t-accent) 12%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Super-bucket dropdown popover. Positioned via inline styles by
|
||||
openSuperDropdown() in buckets.js. Lives on document.body (not
|
||||
inside #bucket-bar) so it can escape the bar's overflow:auto. */
|
||||
#bucket-super-popover {
|
||||
z-index: 220;
|
||||
background: var(--t-surface);
|
||||
border: 1px solid var(--t-border);
|
||||
border-radius: var(--ui-radius-modal);
|
||||
box-shadow: var(--ui-shadow-modal, 0 10px 30px rgba(0,0,0,0.4));
|
||||
padding: 4px;
|
||||
min-width: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* Tag chips */
|
||||
.tag-chip {
|
||||
|
||||
+39
-2
@@ -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>
|
||||
|
||||
+83
-10
@@ -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;
|
||||
|
||||
|
||||
+239
-24
@@ -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 = `
|
||||
<button class="${tab(state.activeBucket === 'all')}" data-bucket="all" title="all"><i class="ph ph-squares-four text-sm"></i></button>
|
||||
<button class="${tab(state.activeBucket === 'current')}" data-bucket="current" title="current"><i class="ph ph-lightning text-sm"></i></button>
|
||||
<button class="${tab(state.activeBucket === 'favorites')}" data-bucket="favorites" title="favorites"><i class="ph ph-star text-sm"></i></button>
|
||||
<button class="${tab(state.activeBucket === 'journal')}" data-bucket="journal" title="journal"><i class="ph ph-notebook text-sm"></i></button>
|
||||
<button class="${tab(state.activeBucket === 'none')}" data-bucket="none" title="unsorted"><i class="ph ph-tray text-sm"></i></button>
|
||||
${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 `<button class="${tab(groupActive)} bucket-super" data-bucket-super="${b.id}" data-super-shown="${shown.id}" draggable="true" data-bucket-drag="${b.id}" style="border-top:3px solid ${c};${groupActive ? `background:${c};color:var(--t-bg);border-color:${c}` : ''}"><i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">${esc(shown.name)}</span><i class="ph ph-caret-down text-[10px] ml-0.5 opacity-70"></i></button>`;
|
||||
}
|
||||
const c = b.color || '#64748b';
|
||||
const active = state.activeBucket == b.id;
|
||||
return `<button class="${tab(active)}" data-bucket="${b.id}" draggable="true" data-bucket-drag="${b.id}" style="border-top:3px solid ${c};${active ? `background:${c};color:var(--t-bg);border-color:${c}` : ''}"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">${esc(b.name)}</span></button>`;
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/** 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 `<button class="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 w-full" data-bucket="${m.id}" draggable="true" data-bucket-drag="${m.id}" style="border-left:3px solid ${c};${active ? `background:${c};color:var(--t-bg);border-color:${c}` : ''}"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px] flex-1 text-left">${esc(m.name)}</span>${isParent ? '<i class="ph ph-house text-[10px] opacity-60" title="parent"></i>' : ''}</button>`;
|
||||
};
|
||||
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 } })));
|
||||
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?.();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user