feat(transcription): drive @mention fetch through the dropdown search input
For issue #380 (AC-2, AC-3, AC-4 + NFR debounce). The search input is now the single fetch trigger. The dropdown's searchQuery reactivity calls onSearch on every change — whether sourced from the editor mirror or the user's own input. PersonMentionEditor debounces these calls at 150 ms, short-circuits on empty queries (no fetch, items cleared), and tears down pending timers on destroy. The Tiptap suggestion plugin's items() now returns [] — per-keystroke fetches in the editor are gone. The same /api/persons?q= endpoint is used; the difference is in when and how often the request fires. Adds a cancel() method to the debounce utility so destroyed editors don't leave trailing fetches alive (which previously polluted the test ledger and would have wasted bandwidth in production tab-close races). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,14 @@ $effect(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fire onSearch whenever the effective query changes — covers both the
|
||||||
|
// editor mirror and direct input edits. This is the only place onSearch
|
||||||
|
// fires; when the dropdown is unmounted, the effect is disposed and no
|
||||||
|
// further fetches occur.
|
||||||
|
$effect(() => {
|
||||||
|
onSearch(searchQuery);
|
||||||
|
});
|
||||||
|
|
||||||
// highlightedIndex must be both writable (keyboard handler mutates it) and
|
// highlightedIndex must be both writable (keyboard handler mutates it) and
|
||||||
// reset when `items` changes (so it never points past the end of a new list).
|
// reset when `items` changes (so it never points past the end of a new list).
|
||||||
// A pure $derived is read-only and cannot serve both needs, so $state + $effect
|
// A pure $derived is read-only and cannot serve both needs, so $state + $effect
|
||||||
@@ -161,9 +169,8 @@ function selectItem(item: Person) {
|
|||||||
class="min-h-[44px] w-full bg-transparent font-sans text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset"
|
class="min-h-[44px] w-full bg-transparent font-sans text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset"
|
||||||
placeholder={m.person_mention_search_prompt()}
|
placeholder={m.person_mention_search_prompt()}
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
oninput={(e) => {
|
oninput={() => {
|
||||||
userHasEdited = true;
|
userHasEdited = true;
|
||||||
onSearch(e.currentTarget.value);
|
|
||||||
}}
|
}}
|
||||||
onmousedown={(e) => e.stopPropagation()}
|
onmousedown={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ import { m } from '$lib/paraglide/messages.js';
|
|||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
import type { PersonMention } from '$lib/shared/types';
|
import type { PersonMention } from '$lib/shared/types';
|
||||||
import { deserialize, serialize } from '$lib/shared/discussion/mentionSerializer';
|
import { deserialize, serialize } from '$lib/shared/discussion/mentionSerializer';
|
||||||
|
import { debounce } from '$lib/shared/utils/debounce';
|
||||||
import MentionDropdown from './MentionDropdown.svelte';
|
import MentionDropdown from './MentionDropdown.svelte';
|
||||||
|
|
||||||
|
const SEARCH_DEBOUNCE_MS = 150;
|
||||||
|
const SEARCH_RESULT_LIMIT = 5;
|
||||||
|
|
||||||
type Person = components['schemas']['Person'];
|
type Person = components['schemas']['Person'];
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -33,6 +37,13 @@ let {
|
|||||||
|
|
||||||
let editorEl: HTMLDivElement;
|
let editorEl: HTMLDivElement;
|
||||||
let editor: Editor | null = null;
|
let editor: Editor | null = null;
|
||||||
|
// Hoisted so onDestroy can guarantee the imperatively-mounted dropdown is
|
||||||
|
// torn down even if Tiptap's suggestion plugin onExit didn't fire (e.g. when
|
||||||
|
// the host component is unmounted while the dropdown is still open).
|
||||||
|
let mountedDropdown: object | null = null;
|
||||||
|
// Hoisted so onDestroy can cancel any pending fetch — otherwise a trailing
|
||||||
|
// debounced search can fire after the editor is gone and pollute later tests.
|
||||||
|
let cancelPendingSearch: (() => void) | null = null;
|
||||||
|
|
||||||
// Single reactive state object shared with MentionDropdown. Mutating these
|
// Single reactive state object shared with MentionDropdown. Mutating these
|
||||||
// fields propagates to the mounted dropdown via Svelte's $state proxy —
|
// fields propagates to the mounted dropdown via Svelte's $state proxy —
|
||||||
@@ -167,7 +178,6 @@ onMount(() => {
|
|||||||
.run();
|
.run();
|
||||||
},
|
},
|
||||||
render() {
|
render() {
|
||||||
let component: object | null = null;
|
|
||||||
let exports: DropdownExports | null = null;
|
let exports: DropdownExports | null = null;
|
||||||
|
|
||||||
// Tiptap's SuggestionProps types `command` against the default
|
// Tiptap's SuggestionProps types `command` against the default
|
||||||
@@ -180,8 +190,33 @@ onMount(() => {
|
|||||||
clientRect?: (() => DOMRect | null) | null;
|
clientRect?: (() => DOMRect | null) | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const runSearch = async (query: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/persons?q=${encodeURIComponent(query)}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
dropdownState.items = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dropdownState.items = ((await res.json()) as Person[]).slice(
|
||||||
|
0,
|
||||||
|
SEARCH_RESULT_LIMIT
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
dropdownState.items = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const debouncedSearch = debounce(runSearch, SEARCH_DEBOUNCE_MS);
|
||||||
|
cancelPendingSearch = () => debouncedSearch.cancel();
|
||||||
|
const onSearch = (query: string) => {
|
||||||
|
if (query.trim() === '') {
|
||||||
|
debouncedSearch.cancel();
|
||||||
|
dropdownState.items = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debouncedSearch(query);
|
||||||
|
};
|
||||||
|
|
||||||
const updateState = (renderProps: LooseRenderProps) => {
|
const updateState = (renderProps: LooseRenderProps) => {
|
||||||
dropdownState.items = renderProps.items as Person[];
|
|
||||||
// AC-1: pass typed query as displayName, not person.displayName
|
// AC-1: pass typed query as displayName, not person.displayName
|
||||||
dropdownState.command = (item: Person) =>
|
dropdownState.command = (item: Person) =>
|
||||||
renderProps.command({
|
renderProps.command({
|
||||||
@@ -208,10 +243,10 @@ onMount(() => {
|
|||||||
get editorQuery() {
|
get editorQuery() {
|
||||||
return dropdownState.editorQuery;
|
return dropdownState.editorQuery;
|
||||||
},
|
},
|
||||||
onSearch: () => {}
|
onSearch
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
component = mounted as object;
|
mountedDropdown = mounted as object;
|
||||||
exports = mounted as unknown as DropdownExports;
|
exports = mounted as unknown as DropdownExports;
|
||||||
},
|
},
|
||||||
onUpdate(renderProps) {
|
onUpdate(renderProps) {
|
||||||
@@ -223,9 +258,9 @@ onMount(() => {
|
|||||||
return exports?.onKeyDown(event) ?? false;
|
return exports?.onKeyDown(event) ?? false;
|
||||||
},
|
},
|
||||||
onExit() {
|
onExit() {
|
||||||
if (component) {
|
if (mountedDropdown) {
|
||||||
unmount(component);
|
unmount(mountedDropdown);
|
||||||
component = null;
|
mountedDropdown = null;
|
||||||
exports = null;
|
exports = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,7 +303,15 @@ onMount(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
cancelPendingSearch?.();
|
||||||
editor?.destroy();
|
editor?.destroy();
|
||||||
|
// Tiptap suggestion onExit usually unmounts the dropdown, but if the host
|
||||||
|
// component is destroyed while a suggestion is active the dropdown can
|
||||||
|
// outlive the editor — clean it up explicitly.
|
||||||
|
if (mountedDropdown) {
|
||||||
|
unmount(mountedDropdown);
|
||||||
|
mountedDropdown = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keep the data-placeholder attribute in sync with actual emptiness so the
|
// Keep the data-placeholder attribute in sync with actual emptiness so the
|
||||||
|
|||||||
@@ -161,6 +161,64 @@ describe('PersonMentionEditor — typeahead', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── AC-2/3: search input drives the person fetch (debounced) ───────────────
|
||||||
|
|
||||||
|
describe('PersonMentionEditor — AC-2/3: search input drives fetch', () => {
|
||||||
|
it('editing the search input fires a debounced fetch with the new query', async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
renderHost();
|
||||||
|
|
||||||
|
// Open the dropdown so the search input is reachable.
|
||||||
|
await userEvent.type(page.getByRole('textbox'), '@');
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await expect.element(page.getByRole('searchbox')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchesBeforeSearch = fetchMock.mock.calls.length;
|
||||||
|
|
||||||
|
// `fill` simulates a single input event with the final value — sidesteps
|
||||||
|
// per-keystroke timing of userEvent.type so the test can deterministically
|
||||||
|
// assert that one input event collapses into one debounced fetch.
|
||||||
|
await page.getByRole('searchbox').fill('Walter');
|
||||||
|
|
||||||
|
await vi.waitFor(
|
||||||
|
() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('q=Walter'));
|
||||||
|
},
|
||||||
|
{ timeout: 1000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchesAfterSearch = fetchMock.mock.calls.length - fetchesBeforeSearch;
|
||||||
|
expect(fetchesAfterSearch).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearing the search input clears the list without firing a fetch', async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
renderHost();
|
||||||
|
|
||||||
|
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchesBeforeClear = fetchMock.mock.calls.length;
|
||||||
|
|
||||||
|
await userEvent.clear(page.getByRole('searchbox'));
|
||||||
|
|
||||||
|
// Wait beyond the debounce window to confirm no fetch was scheduled.
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls.length).toBe(fetchesBeforeClear);
|
||||||
|
await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── AC-1: search input prefilled with text typed after @ ───────────────────
|
// ─── AC-1: search input prefilled with text typed after @ ───────────────────
|
||||||
|
|
||||||
describe('PersonMentionEditor — AC-1: search input prefill', () => {
|
describe('PersonMentionEditor — AC-1: search input prefill', () => {
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* Returns a debounced version of fn that delays invocation until after
|
* Returns a debounced version of fn that delays invocation until after
|
||||||
* `delay` ms have elapsed since the last call.
|
* `delay` ms have elapsed since the last call. The returned function
|
||||||
|
* exposes a `cancel()` method that clears any pending invocation —
|
||||||
|
* essential when the host context (a destroyed component, an unmounted
|
||||||
|
* editor) shouldn't fire the trailing call.
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export function debounce<T extends (...args: any[]) => void>(fn: T, delay: number): T {
|
export function debounce<T extends (...args: any[]) => void>(
|
||||||
let timer: ReturnType<typeof setTimeout>;
|
fn: T,
|
||||||
return ((...args: Parameters<T>) => {
|
delay: number
|
||||||
clearTimeout(timer);
|
): T & { cancel: () => void } {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const wrapped = ((...args: Parameters<T>) => {
|
||||||
|
if (timer !== undefined) clearTimeout(timer);
|
||||||
timer = setTimeout(() => fn(...args), delay);
|
timer = setTimeout(() => fn(...args), delay);
|
||||||
}) as T;
|
}) as T & { cancel: () => void };
|
||||||
|
wrapped.cancel = () => {
|
||||||
|
if (timer !== undefined) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return wrapped;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user