search: integrate find/replace into search overlay, add toolbar buttons

Moves board-wide find/replace from standalone overlay into the search
panel as a toggleable replace row. Adds editor toolbar find/replace
button. Ctrl+Shift+H now opens search in replace mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-12 12:53:11 +02:00
1 parent 838dfe2a6e
commit d1b26e625a
7 files changed
+159 -232

No files matched your search

+39 -69
View File
@@ -13,59 +13,37 @@ async function getCard(id) {
return all.find(c => c.id == id);
}
function openBoardFR() {
// Ctrl+Shift+H opens the board find/replace overlay
function openSearchReplace() {
document.dispatchEvent(new KeyboardEvent('keydown', {
key: 'H', ctrlKey: true, shiftKey: true, bubbles: true,
}));
}
describe('Board: Find/Replace', () => {
describe('Search: Find/Replace', () => {
afterEach(async () => {
document.getElementById('board-find-replace')?.remove();
await closeAll();
});
it('Ctrl+Shift+H opens the overlay', async () => {
openBoardFR();
// Wait for the overlay (it does an async api('api/cards') fetch)
for (let i = 0; i < 40; i++) {
if (document.getElementById('board-find-replace')) break;
await sleep(50);
}
expect(document.getElementById('board-find-replace')).toExist();
expect(document.getElementById('bfr-find')).toExist();
expect(document.getElementById('bfr-replace')).toExist();
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('typing in find shows matching cards', async () => {
const c1 = await makeCard('__bfr_match_a__', 'the quick brown fox');
const c2 = await makeCard('__bfr_match_b__', 'the quick red fox');
const c3 = await makeCard('__bfr_nomatch__', 'no match here');
await window._reloadAll?.();
it('replace toggle shows/hides replace row', async () => {
openSearchReplace();
await sleep(200);
openBoardFR();
for (let i = 0; i < 40; i++) {
if (document.getElementById('bfr-find')) break;
await sleep(50);
}
const findInp = document.getElementById('bfr-find');
if (!findInp) return;
findInp.value = 'quick';
findInp.dispatchEvent(new Event('input', { bubbles: true }));
await sleep(100);
const status = document.getElementById('bfr-status');
// Should find 'quick' in at least 2 cards (c1 + c2)
expect(status.textContent).toContain('2');
// The list should mention both matching card titles
const list = document.getElementById('bfr-list');
expect(list.textContent).toContain('__bfr_match_a__');
expect(list.textContent).toContain('__bfr_match_b__');
// Non-matching card should NOT be in the list
expect(list.textContent.includes('__bfr_nomatch__')).toBe(false);
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 () => {
@@ -74,29 +52,26 @@ describe('Board: Find/Replace', () => {
await window._reloadAll?.();
await sleep(200);
openBoardFR();
for (let i = 0; i < 40; i++) {
if (document.getElementById('bfr-find')) break;
await sleep(50);
}
openSearchReplace();
await sleep(200);
const findInp = document.getElementById('bfr-find');
const replInp = document.getElementById('bfr-replace');
if (!findInp || !replInp) return;
const searchInput = document.getElementById('search-input');
const replInput = document.getElementById('search-replace-input');
if (!searchInput || !replInput) return;
findInp.value = 'hello';
findInp.dispatchEvent(new Event('input', { bubbles: true }));
await sleep(50);
replInp.value = 'goodbye';
replInp.dispatchEvent(new Event('input', { bubbles: true }));
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('bfr-apply');
const applyBtn = document.getElementById('search-replace-all');
expect(applyBtn.disabled).toBe(false);
click(applyBtn);
await sleep(200);
// The confirmation dialog should appear
const dialog = document.getElementById('dialog');
if (dialog?.classList.contains('open')) {
const okBtn = document.getElementById('dialog-ok');
@@ -104,7 +79,6 @@ describe('Board: Find/Replace', () => {
await sleep(600);
}
// Verify card bodies were updated
const updated1 = await getCard(c1.id);
const updated2 = await getCard(c2.id);
expect(updated1.body).toBe('goodbye world goodbye');
@@ -116,20 +90,16 @@ describe('Board: Find/Replace', () => {
await window._reloadAll?.();
await sleep(200);
openBoardFR();
for (let i = 0; i < 40; i++) {
if (document.getElementById('bfr-find')) break;
await sleep(50);
}
openSearchReplace();
await sleep(200);
const findInp = document.getElementById('bfr-find');
if (!findInp) return;
findInp.value = 'some';
findInp.dispatchEvent(new Event('input', { bubbles: true }));
await sleep(50);
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('bfr-apply');
// Replace input is empty → button should be disabled
const applyBtn = document.getElementById('search-replace-all');
expect(applyBtn.disabled).toBe(true);
});
});