diff --git a/config.php b/config.php index e2a345a..3f56901 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ Group + diff --git a/public/js/modules/board.js b/public/js/modules/board.js index 5095699..d5cf790 100644 --- a/public/js/modules/board.js +++ b/public/js/modules/board.js @@ -66,6 +66,7 @@ function toggleSelect(id) { function clearSelection() { selectedIds.clear(); + state.selectionWasBulk = false; board.querySelectorAll('.card-selected').forEach(el => { el.classList.remove('card-selected'); el.querySelector('.card-select-check')?.remove(); @@ -117,6 +118,16 @@ $('select-group')?.addEventListener('click', async () => { await window._reloadAll(); }); +$('select-join')?.addEventListener('click', async () => { + if (selectedIds.size < 2) return; + // Dynamic import keeps the board → context-menu edge lazy and avoids + // circular import surprises at module load time. joinSelectedCards + // reads state.selectedIds + state.selectionWasBulk and handles its + // own reload + selection clear. + const { joinSelectedCards } = await import('./context-menu.js'); + await joinSelectedCards(); +}); + $('select-bucket')?.addEventListener('click', () => { if (!selectedIds.size) return; // Remove existing picker @@ -1193,6 +1204,9 @@ board.addEventListener('mousedown', e => { if (!boxActive) { boxActive = true; if (!additive) clearSelection(); + // Mark the selection as bulk so the join action knows to use + // the longest-title strategy instead of first-clicked. + state.selectionWasBulk = true; boxEl = document.createElement('div'); boxEl.className = 'box-select'; board.appendChild(boxEl); diff --git a/public/js/modules/context-menu.js b/public/js/modules/context-menu.js index ac99d40..23423b7 100644 --- a/public/js/modules/context-menu.js +++ b/public/js/modules/context-menu.js @@ -3,7 +3,7 @@ import { $, esc, api } from './util.js'; import { state, parseTags, getChildren, isFavorite, toggleFavorite, CARD_COLORS } from './state.js'; import { showDialog } from './dialog.js'; -import { pushCardEdit, pushCardDelete, pushBatch, snapshotCard } from './undo.js'; +import { pushCardEdit, pushCardDelete, pushBatch, pushUndo, snapshotCard } from './undo.js'; import { stripMd } from './markdown.js'; const menu = $('ctx-menu'); @@ -32,12 +32,89 @@ menu.addEventListener('mouseenter', () => { document.addEventListener('click', hideContextMenu); document.addEventListener('contextmenu', e => { if (!menu.contains(e.target)) hideContextMenu(); }); +/** + * Merge every currently-selected card into a single card. Title strategy: + * if the selection involved a bulk gesture (rubber-band) we use the + * longest title — there's no meaningful "first" in that case. Otherwise + * the first ctrl-clicked card wins (state.selectedIds is a Set, which + * preserves insertion order). + * + * Body format: list of all titles as `# heading` lines at the top, then + * each card's body separated by `---`. The winner keeps its color, tags, + * bucket, due_date, parent_id; the others are deleted. + * + * The whole operation is bundled into a single undo entry that recreates + * the deleted cards verbatim and restores the winner's original body. + */ +export async function joinSelectedCards() { + const ids = [...state.selectedIds].map(String); + const cards = ids + .map(id => state.cards.find(c => String(c.id) === id)) + .filter(Boolean); + if (cards.length < 2) return; + + // We need bodies for the merge. /api/cards GET excludes body for + // performance, so fetch the full rows for the selected ids in one shot. + const fullRes = await fetch(`api/cards?ids=${cards.map(c => c.id).join(',')}`).then(r => r.json()); + const fullById = new Map(fullRes.map(c => [String(c.id), c])); + const full = cards.map(c => fullById.get(String(c.id)) || c); + + // Pick winner + let winner; + if (state.selectionWasBulk) { + winner = full.reduce((a, b) => (b.title || '').length > (a.title || '').length ? b : a); + } else { + winner = full[0]; // first in Set iteration order = first selected + } + const losers = full.filter(c => c.id !== winner.id); + + // Build merged body: title list, then bodies separated by `---`. + // Order: winner first (its body stays at the top after the title list), + // then losers in their selection order so the result is deterministic. + const ordered = [winner, ...losers]; + const titleList = ordered.map(c => `## ${c.title || ''}`).join('\n'); + const bodyChunks = ordered.map(c => (c.body || '').trim()).filter(Boolean); + const mergedBody = titleList + '\n\n---\n\n' + bodyChunks.join('\n\n---\n\n'); + + // Snapshot for undo BEFORE we mutate anything + const winnerPrev = { title: winner.title, body: winner.body || '' }; + const loserSnapshots = losers.map(c => snapshotCard(c)); + + // Apply: PATCH winner with the merged body, DELETE losers. + pushUndo(`join ${ordered.length} cards`, async () => { + await api(`api/cards/${winner.id}`, { method: 'PATCH', body: winnerPrev }); + for (const snap of loserSnapshots) { + const created = await api('api/cards', { method: 'POST', body: { + title: snap.title, body: snap.body, color: snap.color, + bucket_id: snap.bucket_id, parent_id: snap.parent_id, position: snap.position, + }}); + if (created?.id && snap.due_date) { + await api(`api/cards/${created.id}`, { method: 'PATCH', body: { due_date: snap.due_date } }); + } + } + }); + + await api(`api/cards/${winner.id}`, { method: 'PATCH', body: { body: mergedBody } }); + for (const c of losers) { + await api(`api/cards/${c.id}`, { method: 'DELETE' }); + } + + state.selectedIds.clear(); + state.selectionWasBulk = false; + await window._reloadAll(); +} + export function showContextMenu(x, y, card) { const children = getChildren(card.id); const isParent = children.length > 0; const isChild = !!card.parent_id; + // Multi-select join: only offered when the right-clicked card is part + // of a selection of ≥2 cards. The action merges them into one. + const selected = state.selectedIds; + const inMultiSelect = selected.size >= 2 && selected.has(String(card.id)); items.innerHTML = ` + ${inMultiSelect ? `
` : ''} @@ -176,6 +253,11 @@ export function showContextMenu(x, y, card) { items.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); const a = btn.dataset.action; + if (a === 'join') { + hideContextMenu(); + await joinSelectedCards(); + return; + } if (a === 'open') { hideContextMenu(); window._openDetail(card.id); } else if (a === 'edit') { hideContextMenu(); window._openModal(card); } else if (a === 'duplicate') { diff --git a/public/js/modules/state.js b/public/js/modules/state.js index 6b15c7c..faa0318 100644 --- a/public/js/modules/state.js +++ b/public/js/modules/state.js @@ -23,6 +23,11 @@ export const state = { // Multi-select selectedIds: new Set(), + // True when at least one bulk gesture (rubber-band) participated in + // building the current selection. Used by the join/merge action to + // pick the title strategy: false → first selected wins, true → + // longest title wins (since "first" isn't meaningful for bulk). + selectionWasBulk: false, // Detail / editor editingId: null, diff --git a/public/plugins/tests/card-join.test.js b/public/plugins/tests/card-join.test.js new file mode 100644 index 0000000..76bf0fd --- /dev/null +++ b/public/plugins/tests/card-join.test.js @@ -0,0 +1,242 @@ +import { describe, it, afterEach, expect, $, $$, click, rightClick, sleep, closeAll, csrf } from './runner.js?v=2'; +import { state } from '../../js/modules/state.js'; + +async function makeCard(title, body = '') { + return fetch('api/cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, + body: JSON.stringify({ title, body, color: '#38bdf8' }), + }).then(r => r.json()); +} + +async function getCard(id) { + const all = await fetch(`api/cards?ids=${id}`).then(r => r.json()); + return all.find(c => c.id == id); +} + +function selectIds(ids, { bulk = false } = {}) { + state.selectedIds.clear(); + state.selectionWasBulk = bulk; + for (const id of ids) state.selectedIds.add(String(id)); +} + +async function clickJoin(rightClickTargetEl) { + rightClick(rightClickTargetEl); + await sleep(150); + const joinBtn = $$('#ctx-items [data-action="join"]')[0]; + if (!joinBtn) return false; + click(joinBtn); + await sleep(400); + return true; +} + +describe('Card: Join — context menu visibility', () => { + afterEach(async () => { + state.selectedIds.clear(); + state.selectionWasBulk = false; + await closeAll(); + }); + + it('shows Join action when ≥2 cards are selected and right-clicked card is one of them', async () => { + const a = await makeCard('__join_vis_a__'); + const b = await makeCard('__join_vis_b__'); + await window._reloadAll?.(); + await sleep(200); + + const aEl = document.querySelector(`[data-id="${a.id}"]`); + if (!aEl) return; + selectIds([a.id, b.id]); + + rightClick(aEl); + await sleep(150); + const joinBtn = $$('#ctx-items [data-action="join"]')[0]; + expect(joinBtn).toExist(); + expect(joinBtn.textContent.toLowerCase()).toContain('join'); + expect(joinBtn.textContent).toContain('2'); + }); + + it('does NOT show Join with only one card selected', async () => { + const a = await makeCard('__join_vis_solo__'); + await window._reloadAll?.(); + await sleep(200); + + const aEl = document.querySelector(`[data-id="${a.id}"]`); + if (!aEl) return; + selectIds([a.id]); + + rightClick(aEl); + await sleep(150); + const joinBtn = $$('#ctx-items [data-action="join"]')[0]; + expect(joinBtn).toBe(undefined); + }); + + it('does NOT show Join when right-clicked card is not part of the selection', async () => { + const a = await makeCard('__join_vis_outside_a__'); + const b = await makeCard('__join_vis_outside_b__'); + const c = await makeCard('__join_vis_outside_c__'); + await window._reloadAll?.(); + await sleep(200); + + const cEl = document.querySelector(`[data-id="${c.id}"]`); + if (!cEl) return; + selectIds([a.id, b.id]); + + rightClick(cEl); + await sleep(150); + const joinBtn = $$('#ctx-items [data-action="join"]')[0]; + expect(joinBtn).toBe(undefined); + }); +}); + +describe('Card: Join — sequential selection (first wins)', () => { + afterEach(async () => { + state.selectedIds.clear(); + state.selectionWasBulk = false; + await closeAll(); + }); + + it('first ctrl-clicked card becomes the winner; others are deleted', async () => { + const first = await makeCard('__join_seq_first__', 'body of first'); + const second = await makeCard('__join_seq_second__', 'body of second'); + const third = await makeCard('__join_seq_third__', 'body of third'); + await window._reloadAll?.(); + await sleep(200); + + const firstEl = document.querySelector(`[data-id="${first.id}"]`); + if (!firstEl) return; + selectIds([first.id, second.id, third.id], { bulk: false }); + + const ok = await clickJoin(firstEl); + if (!ok) return; + + // Winner survives + const survivor = await getCard(first.id); + expect(survivor).toExist(); + expect(survivor.title).toBe('__join_seq_first__'); + // Losers deleted + expect(await getCard(second.id)).toBe(undefined); + expect(await getCard(third.id)).toBe(undefined); + }); + + it('merged body lists every original title at the top', async () => { + const a = await makeCard('__join_titles_a__', 'aaa'); + const b = await makeCard('__join_titles_b__', 'bbb'); + const c = await makeCard('__join_titles_c__', 'ccc'); + await window._reloadAll?.(); + await sleep(200); + + const aEl = document.querySelector(`[data-id="${a.id}"]`); + if (!aEl) return; + selectIds([a.id, b.id, c.id], { bulk: false }); + + const ok = await clickJoin(aEl); + if (!ok) return; + + const survivor = await getCard(a.id); + expect(survivor.body).toContain('__join_titles_a__'); + expect(survivor.body).toContain('__join_titles_b__'); + expect(survivor.body).toContain('__join_titles_c__'); + // All three bodies preserved too + expect(survivor.body).toContain('aaa'); + expect(survivor.body).toContain('bbb'); + expect(survivor.body).toContain('ccc'); + // Title list comes before the body content + const titlePos = survivor.body.indexOf('__join_titles_b__'); + const bodyPos = survivor.body.indexOf('bbb'); + expect(titlePos).toBeLessThan(bodyPos); + }); + + it('selection is cleared after a successful join', async () => { + const a = await makeCard('__join_clearsel_a__'); + const b = await makeCard('__join_clearsel_b__'); + await window._reloadAll?.(); + await sleep(200); + + const aEl = document.querySelector(`[data-id="${a.id}"]`); + if (!aEl) return; + selectIds([a.id, b.id], { bulk: false }); + + const ok = await clickJoin(aEl); + if (!ok) return; + + expect(state.selectedIds.size).toBe(0); + expect(state.selectionWasBulk).toBe(false); + }); +}); + +describe('Card: Join — bulk selection (longest title wins)', () => { + afterEach(async () => { + state.selectedIds.clear(); + state.selectionWasBulk = false; + await closeAll(); + }); + + it('longest-title card becomes the winner regardless of insertion order', async () => { + const short = await makeCard('__jbulk_x__', 'x body'); + const longer = await makeCard('__jbulk_loooooong_winner_title__', 'l body'); + const medium = await makeCard('__jbulk_med_xx__', 'm body'); + await window._reloadAll?.(); + await sleep(200); + + const shortEl = document.querySelector(`[data-id="${short.id}"]`); + if (!shortEl) return; + // Insert in order short, longer, medium — but mark as bulk so the + // join code uses longest-title strategy. + selectIds([short.id, longer.id, medium.id], { bulk: true }); + + const ok = await clickJoin(shortEl); + if (!ok) return; + + // The longest-titled card should be the survivor + const survivor = await getCard(longer.id); + expect(survivor).toExist(); + expect(survivor.title).toBe('__jbulk_loooooong_winner_title__'); + // The other two are deleted + expect(await getCard(short.id)).toBe(undefined); + expect(await getCard(medium.id)).toBe(undefined); + // Survivor body still has all titles + expect(survivor.body).toContain('__jbulk_x__'); + expect(survivor.body).toContain('__jbulk_med_xx__'); + }); +}); + +describe('Card: Join — undo', () => { + afterEach(async () => { + state.selectedIds.clear(); + state.selectionWasBulk = false; + await closeAll(); + }); + + it('Ctrl+Z reverts a join: winner body restored, losers recreated', async () => { + const winner = await makeCard('__join_undo_winner__', 'original winner body'); + const loser = await makeCard('__join_undo_loser__', 'original loser body'); + await window._reloadAll?.(); + await sleep(200); + + const winnerEl = document.querySelector(`[data-id="${winner.id}"]`); + if (!winnerEl) return; + selectIds([winner.id, loser.id], { bulk: false }); + + const ok = await clickJoin(winnerEl); + if (!ok) return; + + // Sanity: loser is gone, winner has merged body + expect(await getCard(loser.id)).toBe(undefined); + let survivor = await getCard(winner.id); + expect(survivor.body).toContain('__join_undo_loser__'); + + // Undo via global Ctrl+Z + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, bubbles: true })); + await sleep(400); + + // Winner body restored to original + survivor = await getCard(winner.id); + expect(survivor.body).toBe('original winner body'); + + // A loser card was recreated with the same title + body + // (its id will differ — find by title since the original is gone) + const all = await fetch('api/cards').then(r => r.json()); + const restored = all.find(c => c.title === '__join_undo_loser__'); + expect(restored).toExist(); + }); +}); diff --git a/public/plugins/tests/tests.js b/public/plugins/tests/tests.js index 613bb69..111cd90 100644 --- a/public/plugins/tests/tests.js +++ b/public/plugins/tests/tests.js @@ -16,6 +16,7 @@ const FILES = [ './super-buckets.test.js', './context-menu.test.js', './card-interactions.test.js', + './card-join.test.js', './filtering.test.js', './sorting.test.js', './state-functions.test.js', diff --git a/public/plugins/tests/undo.test.js b/public/plugins/tests/undo.test.js index da4dc60..cf4db8d 100644 --- a/public/plugins/tests/undo.test.js +++ b/public/plugins/tests/undo.test.js @@ -1,9 +1,28 @@ import { describe, it, afterEach, expect, $, $$, click, type, rightClick, pressKey, sleep, closeAll, csrf } from './runner.js?v=2'; +import { state } from '../../js/modules/state.js'; function ctrlZ() { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, bubbles: true })); } +/** + * Poll `state.cards` until the card with the given id satisfies the + * predicate. Lets a test wait for an async click handler's _reloadAll + * to land in the in-memory state before triggering the next user + * action that reads from it. Use this in place of fixed `sleep(300)` + * waits between chained actions — fixed sleeps race against handler + * speed when the run executes faster than the original tuning. + */ +async function waitForStateCard(id, predicate, timeout = 2000) { + const start = Date.now(); + while (Date.now() - start < timeout) { + const card = state.cards.find(c => c.id == id); + if (card && predicate(card)) return card; + await sleep(30); + } + throw new Error(`waitForStateCard: card ${id} did not satisfy predicate within ${timeout}ms`); +} + describe('Undo: Color Change', () => { afterEach(() => closeAll()); @@ -112,7 +131,10 @@ describe('Undo: Editor Save', () => { body: JSON.stringify({ due_date: '2099-06-15' }), }); await window._reloadAll?.(); - await sleep(200); + // Make sure state.cards has the new due_date before opening the + // editor — the editor's snapshot for the undo entry reads from + // state.cards.find() and would otherwise capture due_date=null. + await waitForStateCard(res.id, c => c.due_date === '2099-06-15'); // Edit and change due date via editor const cardEl = document.querySelector(`[data-id="${res.id}"]`); @@ -287,7 +309,12 @@ describe('Undo: Multiple Undos', () => { click($$('#ctx-items [data-action="color"]')[0]); await sleep(100); click(document.querySelector('#ctx-color-grid [data-ctx-color="#fb7185"]')); - await sleep(300); + // Wait for state.cards to reflect the new color before doing the + // *second* rightClick — otherwise the second action's contextmenu + // handler reads the stale card (#38bdf8) from state.cards and + // pushes the wrong undo snapshot, collapsing both undo entries + // onto the original color. + await waitForStateCard(res.id, c => c.color === '#fb7185'); // Change 2: color to green const cardEl2 = document.querySelector(`[data-id="${res.id}"]`); @@ -296,7 +323,7 @@ describe('Undo: Multiple Undos', () => { click($$('#ctx-items [data-action="color"]')[0]); await sleep(100); click(document.querySelector('#ctx-color-grid [data-ctx-color="#4ade80"]')); - await sleep(300); + await waitForStateCard(res.id, c => c.color === '#4ade80'); let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id); expect(card.color).toBe('#4ade80');