refactor(persons): update all callers for the paged /api/persons response
GET /api/persons now returns PersonSearchResult { items, … } instead of a bare
list. Update every caller: the dashboard top-persons path reads .items; the
unused full-list fetches in documents/new and documents/[id]/edit are dropped
(both pages use the self-fetching PersonTypeahead); the raw-fetch consumers
(PersonTypeahead, PersonMultiSelect, PersonMentionEditor) read body.items and
pass review=true so search still spans the whole directory. Specs updated to
the new envelope shape.
Refs #667
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -34,9 +34,10 @@ function handleInput() {
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
|
||||
const res = await fetch(`/api/persons?review=true&q=${encodeURIComponent(searchTerm)}`);
|
||||
if (res.ok) {
|
||||
const all: Person[] = await res.json();
|
||||
const body = await res.json();
|
||||
const all: Person[] = body.items ?? [];
|
||||
results = all.filter((p) => !selectedPersons.some((s) => s.id === p.id));
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -34,11 +34,12 @@ const PERSONS = [
|
||||
];
|
||||
|
||||
function mockFetch(persons = PERSONS) {
|
||||
// /api/persons now returns a paged { items } envelope.
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(persons)
|
||||
json: vi.fn().mockResolvedValue({ items: persons })
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,8 +79,12 @@ const typeahead = createTypeahead<Person>({
|
||||
return res.ok ? filter(await res.json()) : [];
|
||||
}
|
||||
if (term.length < 1) return [];
|
||||
const res = await fetch(`/api/persons?q=${encodeURIComponent(term)}`);
|
||||
return res.ok ? filter(await res.json()) : [];
|
||||
// review=true so the typeahead searches the whole directory (incl. provisional /
|
||||
// zero-document persons), not just the clean reader subset.
|
||||
const res = await fetch(`/api/persons?review=true&q=${encodeURIComponent(term)}`);
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
return filter(body.items ?? []);
|
||||
},
|
||||
debounceMs: 300
|
||||
});
|
||||
|
||||
@@ -24,12 +24,18 @@ const PERSONS = [
|
||||
];
|
||||
|
||||
function mockFetchWithPersons(persons = PERSONS) {
|
||||
// The directory endpoint (/api/persons?…) now returns a paged { items } envelope; the
|
||||
// correspondents endpoint still returns a bare array. Branch the mock on the URL.
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(persons)
|
||||
})
|
||||
vi.fn().mockImplementation((url: string) =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: vi
|
||||
.fn()
|
||||
.mockResolvedValue(url.includes('/correspondents') ? persons : { items: persons })
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -266,7 +272,9 @@ describe('PersonTypeahead – correspondent mode', () => {
|
||||
await waitForDebounce();
|
||||
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Anna'));
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/persons?review=true&q=Anna')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -197,16 +197,16 @@ onMount(() => {
|
||||
// Defensive client-side cap — server-side enforcement is tracked
|
||||
// separately. Markus on PR #629.
|
||||
const res = await fetch(
|
||||
`/api/persons?q=${encodeURIComponent(query)}&limit=${SEARCH_RESULT_LIMIT}`
|
||||
`/api/persons?review=true&q=${encodeURIComponent(query)}&size=${SEARCH_RESULT_LIMIT}`
|
||||
);
|
||||
if (id !== requestId) return;
|
||||
if (!res.ok) {
|
||||
dropdownState.items = [];
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as Person[];
|
||||
const body = (await res.json()) as { items?: Person[] };
|
||||
if (id !== requestId) return;
|
||||
dropdownState.items = data.slice(0, SEARCH_RESULT_LIMIT);
|
||||
dropdownState.items = (body.items ?? []).slice(0, SEARCH_RESULT_LIMIT);
|
||||
} catch {
|
||||
if (id !== requestId) return;
|
||||
dropdownState.items = [];
|
||||
|
||||
@@ -53,14 +53,14 @@ const ANNA: Person = {
|
||||
function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) })
|
||||
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: persons }) })
|
||||
);
|
||||
}
|
||||
|
||||
function mockFetchEmpty() {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
|
||||
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [] }) })
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,28 +132,30 @@ describe('PersonMentionEditor — typeahead', () => {
|
||||
it('hits /api/persons?q= with the typed query', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
|
||||
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
renderHost();
|
||||
|
||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug'));
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/persons?review=true&q=Aug')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('appends &limit=5 to the fetch URL (defensive client-side cap, Markus on PR #629)', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
|
||||
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
renderHost();
|
||||
|
||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('limit=5'));
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('size=5'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -206,7 +208,7 @@ 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]) });
|
||||
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
renderHost();
|
||||
|
||||
@@ -243,7 +245,7 @@ describe('PersonMentionEditor — AC-2/3: search input drives fetch', () => {
|
||||
// Sara on PR #629 round 3.
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
|
||||
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
renderHost();
|
||||
|
||||
@@ -270,7 +272,7 @@ describe('PersonMentionEditor — AC-2/3: search input drives fetch', () => {
|
||||
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]) });
|
||||
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
renderHost();
|
||||
|
||||
@@ -323,10 +325,12 @@ describe('PersonMentionEditor — whitespace-only query', () => {
|
||||
|
||||
describe('PersonMentionEditor — stale-response race', () => {
|
||||
it('discards a stale response that resolves after the search has been cleared', async () => {
|
||||
let resolveFetch!: (v: { ok: boolean; json: () => Promise<Person[]> }) => void;
|
||||
const pendingResponse = new Promise<{ ok: boolean; json: () => Promise<Person[]> }>((r) => {
|
||||
resolveFetch = r;
|
||||
});
|
||||
let resolveFetch!: (v: { ok: boolean; json: () => Promise<{ items: Person[] }> }) => void;
|
||||
const pendingResponse = new Promise<{ ok: boolean; json: () => Promise<{ items: Person[] }> }>(
|
||||
(r) => {
|
||||
resolveFetch = r;
|
||||
}
|
||||
);
|
||||
const fetchMock = vi.fn().mockReturnValue(pendingResponse);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
renderHost();
|
||||
@@ -334,7 +338,9 @@ describe('PersonMentionEditor — stale-response race', () => {
|
||||
// Open the dropdown and let the debounce fire so a fetch is in flight.
|
||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug'));
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/persons?review=true&q=Aug')
|
||||
);
|
||||
});
|
||||
|
||||
// Clear the search input *before* the fetch resolves.
|
||||
@@ -342,7 +348,7 @@ describe('PersonMentionEditor — stale-response race', () => {
|
||||
await expect.element(page.getByRole('searchbox')).toHaveValue('');
|
||||
|
||||
// The stale fetch now resolves with persons. The dropdown must stay empty.
|
||||
resolveFetch({ ok: true, json: () => Promise.resolve([AUGUSTE]) });
|
||||
resolveFetch({ ok: true, json: () => Promise.resolve({ items: [AUGUSTE] }) });
|
||||
// Flush pending Svelte reactivity so any (non-)update from the stale
|
||||
// fetch resolution has landed before we assert. expect.element already
|
||||
// polls, so no fixed-timeout fallback is needed. Sara on PR #629 round 4.
|
||||
|
||||
Reference in New Issue
Block a user