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:
1 parent
7f08dc1a0d
commit
9b4869812f
8 files changed
+379
-5
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
define('APP_NAME', 'hyprcrd');
|
||||
define('APP_VERSION', '0.9.4');
|
||||
define('APP_VERSION', '0.9.5');
|
||||
|
||||
// Database path — writable directory, outside webroot
|
||||
define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
|
||||
|
||||
@@ -2042,6 +2042,9 @@ if (str_starts_with($route, '/api/')) {
|
||||
<button id="select-group" class="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">
|
||||
<i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">Group</span>
|
||||
</button>
|
||||
<button id="select-join" class="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">
|
||||
<i class="ph ph-arrows-merge text-sm"></i><span class="font-pixel text-[10px]">Join</span>
|
||||
</button>
|
||||
<button id="select-bucket" class="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">
|
||||
<i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">Bucket</span>
|
||||
</button>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ? `<button class="ctx-item" data-action="join"><i class="ph ph-arrows-merge text-sm"></i><span class="font-pixel text-[10px]">Join ${selected.size} cards</span></button><div class="ctx-sep"></div>` : ''}
|
||||
<button class="ctx-item" data-action="open"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[10px]">Open</span></button>
|
||||
<button class="ctx-item" data-action="edit"><i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">Edit</span></button>
|
||||
<button class="ctx-item" data-action="duplicate"><i class="ph ph-copy text-sm"></i><span class="font-pixel text-[10px]">Duplicate</span></button>
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user