join: merge multi-selected cards into one (right-click + toolbar)

Right-click on a card that's part of a ≥2-card selection now offers
"Join N cards" at the top of the context menu, and the same action
sits next to "Group" in the bottom select-bar.

Title strategy:
* sequential ctrl-click selection → first selected wins
* bulk gesture (rubber-band) → longest title wins

The bulk vs sequential distinction is tracked via a new
`state.selectionWasBulk` flag set in board.js when the rubber-band
becomes active and cleared in clearSelection() and after a successful
join. ctrl-clicks alone don't touch it, so bulk + ctrl-click hybrid
selections stay bulk.

Body format: list of every original title as `## heading` lines, then
each card's body separated by `---`. The winner keeps its color, tags,
bucket, due_date, parent_id; only the body is rewritten. Losers are
deleted via DELETE /api/cards/{id}.

Undo: a single pushUndo entry restores the winner's original body and
recreates every loser via POST + due_date PATCH (since POST doesn't
take due_date). Ctrl+Z reverts the whole join.

Implementation:
* state.js: new selectionWasBulk boolean in state.
* board.js: rubber-band onMove sets state.selectionWasBulk = true on
  first activation; clearSelection clears it. New select-join click
  handler dynamic-imports joinSelectedCards from context-menu.js to
  keep the dependency lazy.
* context-menu.js: joinSelectedCards is now exported. Inserts
  data-action="join" into the menu when the right-clicked card is in
  a ≥2-card selection. Action handler dispatches to joinSelectedCards
  before falling through to the regular if-chain so it can early-return.
* index.php: new #select-join button in #select-bar with ph-arrows-merge
  icon, sits between Group and Bucket.

Tests (public/plugins/tests/card-join.test.js, registered in tests.js):
* visibility: shows for ≥2 selected with right-clicked card in the
  selection; hidden for solo selection; hidden when right-click target
  isn't selected.
* sequential winner: first ctrl-clicked card survives, others deleted.
* merged body lists every title at the top and contains every body.
* selection cleared after a successful join.
* bulk winner: longest title survives regardless of insertion order.
* undo: Ctrl+Z restores winner body + recreates losers.

undo.test.js race-fix:
* New waitForStateCard(id, predicate, timeout=2000) helper polls
  state.cards every 30ms until the in-memory state reflects the
  expected change. Replaces fragile fixed `sleep(300)` waits between
  chained user actions where the second action reads from state.cards
  before the first action's _reloadAll has landed.
* Multi-undo color test: between the two color changes, wait for
  state.cards to actually contain the pink color before doing the
  second rightClick. Without this the second action's contextmenu
  handler reads the stale card and pushes the wrong undo snapshot,
  collapsing both undo entries onto the original color.
* Due_date undo test: after PATCHing due_date directly via API, wait
  for state.cards to reflect it before opening the editor — the
  editor's snapshot for the undo entry reads from state.cards.find()
  and would otherwise capture due_date=null.

chore: bump version to 0.9.5

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 22:25:42 +02:00
1 parent 7f08dc1a0d
commit 9b4869812f
8 files changed
+379 -5

No files matched your search

+242
View File
@@ -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();
});
});
+1
View File
@@ -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',
+30 -3
View File
@@ -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');