diff --git a/frontend/src/lib/person/PersonTypeahead.svelte b/frontend/src/lib/person/PersonTypeahead.svelte index 2114fe38..7622fb84 100644 --- a/frontend/src/lib/person/PersonTypeahead.svelte +++ b/frontend/src/lib/person/PersonTypeahead.svelte @@ -47,7 +47,6 @@ let { // searchTerm must be both prop-derived AND locally writable (user typing), so $state + // $effect is the correct pattern here — writable $derived is read-only and won't work. -// eslint-disable-next-line svelte/prefer-writable-derived let searchTerm = $state(initialName); // Sync display text when initialName changes OR when resetKey increments (navigation reset). diff --git a/frontend/src/routes/SearchFilterBar.svelte b/frontend/src/routes/SearchFilterBar.svelte index c2eb816d..9ad11f39 100644 --- a/frontend/src/routes/SearchFilterBar.svelte +++ b/frontend/src/routes/SearchFilterBar.svelte @@ -20,6 +20,7 @@ let { showAdvanced = $bindable(false), initialSenderName = '', initialReceiverName = '', + navKey = 0, isLoading = false, onSearch, onSearchImmediate, @@ -39,6 +40,7 @@ let { showAdvanced?: boolean; initialSenderName?: string; initialReceiverName?: string; + navKey?: number; isLoading?: boolean; onSearch: () => void; onSearchImmediate?: () => void; @@ -197,6 +199,7 @@ $effect(() => { label={m.docs_filter_label_sender()} bind:value={senderId} initialName={initialSenderName} + resetKey={navKey} onchange={onSearch} /> @@ -212,6 +215,7 @@ $effect(() => { label={m.docs_filter_label_receivers()} bind:value={receiverId} initialName={initialReceiverName} + resetKey={navKey} onchange={onSearch} /> diff --git a/frontend/src/routes/documents/+page.server.ts b/frontend/src/routes/documents/+page.server.ts index c969f88e..68e6c78a 100644 --- a/frontend/src/routes/documents/+page.server.ts +++ b/frontend/src/routes/documents/+page.server.ts @@ -3,6 +3,20 @@ import { createApiClient } from '$lib/shared/api.server'; import { getErrorMessage } from '$lib/shared/errors'; import type { components } from '$lib/generated/api'; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +async function resolvePersonName(id: string, fetch: typeof globalThis.fetch): Promise { + if (!UUID_RE.test(id)) return ''; + try { + const res = await fetch(`/api/persons/${id}`); + if (!res.ok) return ''; + const person = await res.json(); + return person.displayName ?? ''; + } catch { + return ''; + } +} + type DocumentSearchItem = components['schemas']['DocumentSearchItem']; const VALID_SORTS = ['DATE', 'TITLE', 'SENDER', 'RECEIVER', 'UPLOAD_DATE', 'RELEVANCE'] as const; @@ -34,25 +48,30 @@ export async function load({ url, fetch }) { const api = createApiClient(fetch); let result; + let initialSenderName = ''; + let initialReceiverName = ''; try { - result = await api.GET('/api/documents/search', { - params: { - query: { - q: q || undefined, - from: from || undefined, - to: to || undefined, - senderId: senderId || undefined, - receiverId: receiverId || undefined, - tag: tags.length ? tags : undefined, - tagQ: tagQ && !tags.length ? tagQ : undefined, - tagOp: tagOp === 'OR' ? 'OR' : undefined, - sort, - dir: dir || undefined, - page, - size: PAGE_SIZE + [result, [initialSenderName, initialReceiverName]] = await Promise.all([ + api.GET('/api/documents/search', { + params: { + query: { + q: q || undefined, + from: from || undefined, + to: to || undefined, + senderId: senderId || undefined, + receiverId: receiverId || undefined, + tag: tags.length ? tags : undefined, + tagQ: tagQ && !tags.length ? tagQ : undefined, + tagOp: tagOp === 'OR' ? 'OR' : undefined, + sort, + dir: dir || undefined, + page, + size: PAGE_SIZE + } } - } - }); + }), + Promise.all([resolvePersonName(senderId, fetch), resolvePersonName(receiverId, fetch)]) + ]); } catch { return { items: [] as DocumentSearchItem[], @@ -65,6 +84,8 @@ export async function load({ url, fetch }) { to, senderId, receiverId, + initialSenderName: '', + initialReceiverName: '', tags, sort, dir, @@ -94,6 +115,8 @@ export async function load({ url, fetch }) { to, senderId, receiverId, + initialSenderName, + initialReceiverName, tags, sort, dir, diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index 72fb84e8..d2f05de5 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -22,6 +22,9 @@ let from = $state(untrack(() => data.from || '')); let to = $state(untrack(() => data.to || '')); let senderId = $state(untrack(() => data.senderId || '')); let receiverId = $state(untrack(() => data.receiverId || '')); +let initialSenderName = $state(untrack(() => data.initialSenderName ?? '')); +let initialReceiverName = $state(untrack(() => data.initialReceiverName ?? '')); +let navKey = $state(0); let tagNames = $state<{ name: string; id?: string; color?: string; parentId?: string }[]>( untrack(() => (data.tags || []).map((name: string) => ({ name }))) ); @@ -207,12 +210,17 @@ async function editAllMatching() { // Keep local filter state in sync with server data after navigation completes. // Guard q: skip overwrite while the user is actively typing. +// navKey increments on every navigation so PersonTypeahead clears manually-typed +// terms even when initialSenderName/initialReceiverName stays '' across navigations. $effect(() => { if (!qFocused) q = data.q || ''; from = data.from || ''; to = data.to || ''; senderId = data.senderId || ''; receiverId = data.receiverId || ''; + initialSenderName = data.initialSenderName ?? ''; + initialReceiverName = data.initialReceiverName ?? ''; + untrack(() => navKey++); tagNames = (data.tags || []).map((name: string) => ({ name })); sort = data.sort || 'DATE'; dir = data.dir || 'desc'; @@ -247,6 +255,9 @@ $effect(() => { bind:dir={dir} bind:tagQ={tagQ} bind:tagOperator={tagOperator} + initialSenderName={initialSenderName} + initialReceiverName={initialReceiverName} + navKey={navKey} isLoading={navigating.to !== null} onSearch={handleTextSearch} onSearchImmediate={handleImmediateSearch} diff --git a/frontend/src/routes/documents/page.server.spec.ts b/frontend/src/routes/documents/page.server.spec.ts index ebf69be0..0d09ac69 100644 --- a/frontend/src/routes/documents/page.server.spec.ts +++ b/frontend/src/routes/documents/page.server.spec.ts @@ -167,3 +167,72 @@ describe('documents page load — network error fallback', () => { expect(result.items).toEqual([]); }); }); + +// ─── person name resolution ─────────────────────────────────────────────────── + +describe('documents page load — person name resolution', () => { + function makeSearchMock() { + const mockGet = vi.fn().mockResolvedValue({ + response: { ok: true, status: 200 }, + data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + } + + it('returns initialSenderName from person lookup when senderId is a valid UUID', async () => { + makeSearchMock(); + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ displayName: 'Max Mustermann' }) + }); + + const result = await load({ + url: makeUrl({ senderId: '11111111-1111-1111-1111-111111111111' }), + fetch: mockFetch as unknown as typeof fetch + }); + + expect(result.initialSenderName).toBe('Max Mustermann'); + }); + + it('returns initialReceiverName from person lookup when receiverId is a valid UUID', async () => { + makeSearchMock(); + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ displayName: 'Anna Musterfrau' }) + }); + + const result = await load({ + url: makeUrl({ receiverId: '22222222-2222-2222-2222-222222222222' }), + fetch: mockFetch as unknown as typeof fetch + }); + + expect(result.initialReceiverName).toBe('Anna Musterfrau'); + }); + + it('returns empty string when senderId is not a valid UUID', async () => { + makeSearchMock(); + const mockFetch = vi.fn(); + + const result = await load({ + url: makeUrl({ senderId: 'not-a-uuid' }), + fetch: mockFetch as unknown as typeof fetch + }); + + expect(result.initialSenderName).toBe(''); + expect(mockFetch).not.toHaveBeenCalledWith(expect.stringContaining('/api/persons/')); + }); + + it('returns empty string when person fetch returns 404', async () => { + makeSearchMock(); + const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 404 }); + + const result = await load({ + url: makeUrl({ senderId: '11111111-1111-1111-1111-111111111111' }), + fetch: mockFetch as unknown as typeof fetch + }); + + expect(result.initialSenderName).toBe(''); + }); +}); diff --git a/frontend/src/routes/documents/page.svelte.spec.ts b/frontend/src/routes/documents/page.svelte.spec.ts index 69eb9107..da97bcec 100644 --- a/frontend/src/routes/documents/page.svelte.spec.ts +++ b/frontend/src/routes/documents/page.svelte.spec.ts @@ -23,6 +23,8 @@ function makeData(overrides: Record = {}) { to: '', senderId: '', receiverId: '', + initialSenderName: '', + initialReceiverName: '', tags: [], sort: 'DATE', dir: 'desc', @@ -136,6 +138,22 @@ describe('documents page — URL building', () => { }); }); +// ─── Sender / receiver name display ────────────────────────────────────────── + +describe('documents page — sender/receiver display', () => { + it('pre-fills sender typeahead from initialSenderName when senderId filter is active', async () => { + render(Page, { + data: makeData({ + senderId: '11111111-1111-1111-1111-111111111111', + initialSenderName: 'Max Mustermann' + }) + }); + // Advanced filters are auto-shown when senderId is set + const inputs = page.getByPlaceholder('Namen tippen...'); + await expect.element(inputs.first()).toHaveValue('Max Mustermann'); + }); +}); + // ─── Timeline density widget wiring (#385) ──────────────────────────────────── describe('documents page — timeline density widget', () => {