Shopping lists:
- Multiple lists per tenant: picker uses checkboxes instead of
radios. Relay schema migrated to composite PK (tenant_id,
card_uid) with a position column for display ordering.
- Picker rows are drag-and-drop reorderable (grip handle +
accent drop indicator). Order persists in localStorage and is
pushed to the relay as position integers — phone renders lists
in that order (ORDER BY position, title).
- Each picker row has a goto-arrow that closes the popup,
switches to the card's bucket if needed, and opens the card.
- Relay DELETE /api/shopping removes deactivated lists. Client
proxy forwards DELETE. Deactivating a list in the picker
immediately removes it from the relay.
- Phone UI renders multiple lists as stacked boxes with headers.
Section headings (# h1–h6) get 14px top padding for breathing
room. CSS selectors fixed to match div-based headings inside
the new .shopping-list-box containers.
- Help text updated for multi-list wording.
- pullOne preserves the synced position so a pull doesn't cause
a spurious re-push of the same order.
Test fixes:
- Line-numbers gutter height tolerance bumped 50%→60% — OS text
shaping can push the divergence past 50%.
- Find/replace flaky: replaced sleep(200) with waitFor on the
confirm dialog so slow renders don't miss the OK click.
- Shopping picker test updated: checkboxes instead of radios.
Bumps to 1.9.25.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
111 lines
4.1 KiB
JavaScript
111 lines
4.1 KiB
JavaScript
import { describe, it, afterEach, expect, $, $$, click, sleep, waitFor, closeAll, csrf } from './runner.js?v=4';
|
|
|
|
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 openSearchReplace() {
|
|
document.dispatchEvent(new KeyboardEvent('keydown', {
|
|
key: 'H', ctrlKey: true, shiftKey: true, bubbles: true,
|
|
}));
|
|
}
|
|
|
|
describe('Search: Find/Replace', () => {
|
|
afterEach(async () => {
|
|
await closeAll();
|
|
});
|
|
|
|
it('Ctrl+Shift+H opens search with replace row visible', async () => {
|
|
openSearchReplace();
|
|
await sleep(200);
|
|
const overlay = document.getElementById('search');
|
|
expect(overlay?.classList.contains('open')).toBe(true);
|
|
expect(document.getElementById('search-replace-row')).toBeVisible();
|
|
expect(document.getElementById('search-replace-input')).toExist();
|
|
expect(document.getElementById('search-replace-all')).toExist();
|
|
});
|
|
|
|
it('replace toggle shows/hides replace row', async () => {
|
|
openSearchReplace();
|
|
await sleep(200);
|
|
expect(document.getElementById('search-replace-row')).toBeVisible();
|
|
click('#search-replace-toggle');
|
|
await sleep(50);
|
|
expect(document.getElementById('search-replace-row')?.classList.contains('hidden')).toBe(true);
|
|
click('#search-replace-toggle');
|
|
await sleep(50);
|
|
expect(document.getElementById('search-replace-row')).toBeVisible();
|
|
});
|
|
|
|
it('replace all updates card bodies after confirmation', async () => {
|
|
const c1 = await makeCard('__bfr_repl_a__', 'hello world hello');
|
|
const c2 = await makeCard('__bfr_repl_b__', 'hello there');
|
|
await window._reloadAll?.();
|
|
await sleep(200);
|
|
|
|
openSearchReplace();
|
|
await sleep(200);
|
|
|
|
const searchInput = document.getElementById('search-input');
|
|
const replInput = document.getElementById('search-replace-input');
|
|
if (!searchInput || !replInput) return;
|
|
|
|
searchInput.value = 'hello';
|
|
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
await sleep(200);
|
|
|
|
replInput.value = 'goodbye';
|
|
replInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
await sleep(50);
|
|
|
|
const applyBtn = document.getElementById('search-replace-all');
|
|
expect(applyBtn.disabled).toBe(false);
|
|
click(applyBtn);
|
|
|
|
// The replace-all button opens a confirm dialog — wait for it
|
|
// to appear rather than relying on a fixed sleep, since the
|
|
// dialog creation is async and can lag on slow runs.
|
|
await waitFor(() => document.getElementById('dialog')?.classList.contains('open'), 2000);
|
|
const okBtn = document.getElementById('dialog-ok');
|
|
if (okBtn) click(okBtn);
|
|
|
|
// Poll until the replace has actually persisted
|
|
let updated1, updated2;
|
|
for (let i = 0; i < 40; i++) {
|
|
await sleep(100);
|
|
updated1 = await getCard(c1.id);
|
|
if (updated1?.body === 'goodbye world goodbye') break;
|
|
}
|
|
updated2 = await getCard(c2.id);
|
|
expect(updated1.body).toBe('goodbye world goodbye');
|
|
expect(updated2.body).toBe('goodbye there');
|
|
});
|
|
|
|
it('replace all button disabled without replace text', async () => {
|
|
await makeCard('__bfr_dis__', 'some text');
|
|
await window._reloadAll?.();
|
|
await sleep(200);
|
|
|
|
openSearchReplace();
|
|
await sleep(200);
|
|
|
|
const searchInput = document.getElementById('search-input');
|
|
if (!searchInput) return;
|
|
searchInput.value = 'some';
|
|
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
await sleep(100);
|
|
|
|
const applyBtn = document.getElementById('search-replace-all');
|
|
expect(applyBtn.disabled).toBe(true);
|
|
});
|
|
});
|