Clicking the super-bucket tab now activates the last-shown child directly. Only the caret arrow on the right opens the dropdown popover. Caret has its own hit area with accent hover highlight. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
290 lines
12 KiB
JavaScript
290 lines
12 KiB
JavaScript
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js?v=4';
|
|
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 caret 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;
|
|
const caret = superTile.querySelector('[data-super-caret]');
|
|
expect(caret).toExist();
|
|
click(caret);
|
|
await sleep(100);
|
|
|
|
const popover = document.getElementById('bucket-super-popover');
|
|
expect(popover).toExist();
|
|
const entries = popover.querySelectorAll('[data-bucket]');
|
|
expect(entries.length).toBe(3);
|
|
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;
|
|
const caret = superTile.querySelector('[data-super-caret]');
|
|
click(caret);
|
|
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 activates the shown bucket', async () => {
|
|
const parent = await makeBucket('__sb_direct_parent__');
|
|
const child = await makeBucket('__sb_direct_child__');
|
|
await patchBucket(child.id, { parent_id: parent.id });
|
|
await window._reloadAll?.();
|
|
await sleep(200);
|
|
|
|
state.activeBucket = 'all';
|
|
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
|
|
if (!superTile) return;
|
|
click(superTile);
|
|
await sleep(150);
|
|
|
|
const shownId = superTile.dataset.superShown;
|
|
expect(String(state.activeBucket)).toBe(String(shownId));
|
|
expect(document.getElementById('bucket-super-popover')).toBe(null);
|
|
});
|
|
|
|
it('clicking the caret 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;
|
|
const caret = superTile.querySelector('[data-super-caret]');
|
|
click(caret);
|
|
await sleep(100);
|
|
expect(document.getElementById('bucket-super-popover')).toExist();
|
|
click(caret);
|
|
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);
|
|
});
|
|
});
|