fix(documents): filter inputs don't sync with URL on navigation (#482) #487
@@ -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).
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
@@ -212,6 +215,7 @@ $effect(() => {
|
||||
label={m.docs_filter_label_receivers()}
|
||||
bind:value={receiverId}
|
||||
initialName={initialReceiverName}
|
||||
resetKey={navKey}
|
||||
onchange={onSearch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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<string> {
|
||||
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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,8 @@ function makeData(overrides: Record<string, unknown> = {}) {
|
||||
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', () => {
|
||||
|
||||
Reference in New Issue
Block a user