feat(transcription): decouple @mention display text from person search (#380) #629
@@ -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
|
||||
// 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
|
||||
@@ -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"
|
||||
placeholder={m.person_mention_search_prompt()}
|
||||
bind:value={searchQuery}
|
||||
oninput={(e) => {
|
||||
oninput={() => {
|
||||
userHasEdited = true;
|
||||
onSearch(e.currentTarget.value);
|
||||
}}
|
||||
onmousedown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
@@ -7,8 +7,12 @@ import { m } from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { PersonMention } from '$lib/shared/types';
|
||||
import { deserialize, serialize } from '$lib/shared/discussion/mentionSerializer';
|
||||
import { debounce } from '$lib/shared/utils/debounce';
|
||||
import MentionDropdown from './MentionDropdown.svelte';
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 150;
|
||||
const SEARCH_RESULT_LIMIT = 5;
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
type Props = {
|
||||
@@ -33,6 +37,13 @@ let {
|
||||
|
||||
let editorEl: HTMLDivElement;
|
||||
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
|
||||
// fields propagates to the mounted dropdown via Svelte's $state proxy —
|
||||
@@ -167,7 +178,6 @@ onMount(() => {
|
||||
.run();
|
||||
},
|
||||
render() {
|
||||
let component: object | null = null;
|
||||
let exports: DropdownExports | null = null;
|
||||
|
||||
// Tiptap's SuggestionProps types `command` against the default
|
||||
@@ -180,8 +190,33 @@ onMount(() => {
|
||||
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) => {
|
||||
dropdownState.items = renderProps.items as Person[];
|
||||
// AC-1: pass typed query as displayName, not person.displayName
|
||||
dropdownState.command = (item: Person) =>
|
||||
renderProps.command({
|
||||
@@ -208,10 +243,10 @@ onMount(() => {
|
||||
get editorQuery() {
|
||||
return dropdownState.editorQuery;
|
||||
},
|
||||
onSearch: () => {}
|
||||
onSearch
|
||||
}
|
||||
});
|
||||
component = mounted as object;
|
||||
mountedDropdown = mounted as object;
|
||||
exports = mounted as unknown as DropdownExports;
|
||||
},
|
||||
onUpdate(renderProps) {
|
||||
@@ -223,9 +258,9 @@ onMount(() => {
|
||||
return exports?.onKeyDown(event) ?? false;
|
||||
},
|
||||
onExit() {
|
||||
if (component) {
|
||||
unmount(component);
|
||||
component = null;
|
||||
if (mountedDropdown) {
|
||||
unmount(mountedDropdown);
|
||||
mountedDropdown = null;
|
||||
exports = null;
|
||||
}
|
||||
}
|
||||
@@ -268,7 +303,15 @@ onMount(() => {
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
cancelPendingSearch?.();
|
||||
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
|
||||
|
||||
@@ -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 @ ───────────────────
|
||||
|
||||
describe('PersonMentionEditor — AC-1: search input prefill', () => {
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
/**
|
||||
* 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
|
||||
export function debounce<T extends (...args: any[]) => void>(fn: T, delay: number): T {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
return ((...args: Parameters<T>) => {
|
||||
clearTimeout(timer);
|
||||
export function debounce<T extends (...args: any[]) => void>(
|
||||
fn: T,
|
||||
delay: number
|
||||
): T & { cancel: () => void } {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const wrapped = ((...args: Parameters<T>) => {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
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