feat(documents): thread undated filter through the search loader + i18n

Parses ?undated strictly (=== 'true', mirroring the tagOp clamp), forwards
it as undated || undefined so the absent case drops out of the query, and
returns the flag in page data for the control to reflect. Adds the
docs_filter_undated_only toggle label and the explanatory
docs_range_excludes_undated empty-state copy in de/en/es. The badge reuses
the existing date_precision_unknown ("Datum unbekannt") key from #677.

OpenAPI types hand-edited for the new undated query param on /search and
/ids — CI must run `npm run generate:api` to confirm parity with the spec.

Refs #668
This commit is contained in:
Marcel
2026-05-27 18:45:03 +02:00
parent 268c31a49b
commit f1fc3dc1ce
6 changed files with 114 additions and 0 deletions

View File

@@ -100,6 +100,8 @@
"docs_list_summary": "Zusammenfassung", "docs_list_summary": "Zusammenfassung",
"docs_list_unknown": "Unbekannt", "docs_list_unknown": "Unbekannt",
"docs_group_undated": "Undatiert", "docs_group_undated": "Undatiert",
"docs_filter_undated_only": "Nur undatierte",
"docs_range_excludes_undated": "Ein Datumsfilter schließt undatierte Dokumente aus, da sie keinem Zeitraum zugeordnet werden können.",
"docs_group_unknown": "Unbekannt", "docs_group_unknown": "Unbekannt",
"doc_section_who_when": "Wer & Wann", "doc_section_who_when": "Wer & Wann",
"doc_section_description": "Beschreibung", "doc_section_description": "Beschreibung",

View File

@@ -100,6 +100,8 @@
"docs_list_summary": "Summary", "docs_list_summary": "Summary",
"docs_list_unknown": "Unknown", "docs_list_unknown": "Unknown",
"docs_group_undated": "Undated", "docs_group_undated": "Undated",
"docs_filter_undated_only": "Undated only",
"docs_range_excludes_undated": "A date range filter excludes undated documents, because they cannot belong to any time span.",
"docs_group_unknown": "Unknown", "docs_group_unknown": "Unknown",
"doc_section_who_when": "Who & When", "doc_section_who_when": "Who & When",
"doc_section_description": "Description", "doc_section_description": "Description",

View File

@@ -100,6 +100,8 @@
"docs_list_summary": "Resumen", "docs_list_summary": "Resumen",
"docs_list_unknown": "Desconocido", "docs_list_unknown": "Desconocido",
"docs_group_undated": "Sin fecha", "docs_group_undated": "Sin fecha",
"docs_filter_undated_only": "Solo sin fecha",
"docs_range_excludes_undated": "Un filtro de intervalo de fechas excluye los documentos sin fecha, ya que no pueden pertenecer a ningún periodo.",
"docs_group_unknown": "Desconocido", "docs_group_unknown": "Desconocido",
"doc_section_who_when": "Quién & Cuándo", "doc_section_who_when": "Quién & Cuándo",
"doc_section_description": "Descripción", "doc_section_description": "Descripción",

View File

@@ -5083,6 +5083,8 @@ export interface operations {
dir?: string; dir?: string;
/** @description Tag operator: AND (default) or OR */ /** @description Tag operator: AND (default) or OR */
tagOp?: string; tagOp?: string;
/** @description Restrict to undated documents (meta_date IS NULL) */
undated?: boolean;
/** @description Page number (0-indexed) */ /** @description Page number (0-indexed) */
page?: number; page?: number;
/** @description Page size (max 100) */ /** @description Page size (max 100) */
@@ -5184,6 +5186,7 @@ export interface operations {
tagQ?: string; tagQ?: string;
status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED"; status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
tagOp?: string; tagOp?: string;
undated?: boolean;
}; };
header?: never; header?: never;
path?: never; path?: never;

View File

@@ -46,6 +46,8 @@ export async function load({ url, fetch }) {
: 'desc'; : 'desc';
const tagQ = url.searchParams.get('tagQ') || ''; const tagQ = url.searchParams.get('tagQ') || '';
const tagOp = url.searchParams.get('tagOp') === 'OR' ? 'OR' : 'AND'; const tagOp = url.searchParams.get('tagOp') === 'OR' ? 'OR' : 'AND';
// Narrow the accepted truthy surface to exactly "true" (mirrors the tagOp clamp).
const undated = url.searchParams.get('undated') === 'true';
const page = Math.max(0, Number(url.searchParams.get('page') ?? '0') || 0); const page = Math.max(0, Number(url.searchParams.get('page') ?? '0') || 0);
const api = createApiClient(fetch); const api = createApiClient(fetch);
@@ -66,6 +68,7 @@ export async function load({ url, fetch }) {
tag: tags.length ? tags : undefined, tag: tags.length ? tags : undefined,
tagQ: tagQ && !tags.length ? tagQ : undefined, tagQ: tagQ && !tags.length ? tagQ : undefined,
tagOp: tagOp === 'OR' ? 'OR' : undefined, tagOp: tagOp === 'OR' ? 'OR' : undefined,
undated: undated || undefined,
sort, sort,
dir: dir || undefined, dir: dir || undefined,
page, page,
@@ -94,6 +97,7 @@ export async function load({ url, fetch }) {
dir, dir,
tagQ, tagQ,
tagOp, tagOp,
undated,
error: 'Daten konnten nicht geladen werden.' as string | null error: 'Daten konnten nicht geladen werden.' as string | null
}; };
} }
@@ -124,6 +128,7 @@ export async function load({ url, fetch }) {
dir, dir,
tagQ, tagQ,
tagOp, tagOp,
undated,
error: errorMessage error: errorMessage
}; };
} }

View File

@@ -100,6 +100,106 @@ describe('documents page load — search params', () => {
); );
}); });
it('forwards undated=true to the search API as a boolean true', async () => {
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
>);
await load({
url: makeUrl({ undated: 'true' }),
request: new Request('http://localhost/documents'),
fetch: vi.fn() as unknown as typeof fetch
});
expect(mockGet).toHaveBeenCalledWith(
'/api/documents/search',
expect.objectContaining({
params: expect.objectContaining({
query: expect.objectContaining({ undated: true })
})
})
);
});
it('omits undated from the query when the param is absent', async () => {
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
>);
await load({
url: makeUrl({}),
request: new Request('http://localhost/documents'),
fetch: vi.fn() as unknown as typeof fetch
});
const query = mockGet.mock.calls[0][1].params.query;
expect(query.undated).toBeUndefined();
});
it('treats any undated value other than the literal "true" as not-undated', async () => {
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
>);
const result = await load({
url: makeUrl({ undated: '1' }),
request: new Request('http://localhost/documents'),
fetch: vi.fn() as unknown as typeof fetch
});
expect(result.undated).toBe(false);
expect(mockGet.mock.calls[0][1].params.query.undated).toBeUndefined();
});
it('returns the undated flag in page data when enabled', async () => {
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
>);
const result = await load({
url: makeUrl({ undated: 'true' }),
request: new Request('http://localhost/documents'),
fetch: vi.fn() as unknown as typeof fetch
});
expect(result.undated).toBe(true);
});
it('does not carry page when toggling undated (page reset)', async () => {
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
>);
// A bare undated toggle URL carries no page param → loader requests page 0.
await load({
url: makeUrl({ undated: 'true' }),
request: new Request('http://localhost/documents'),
fetch: vi.fn() as unknown as typeof fetch
});
expect(mockGet.mock.calls[0][1].params.query.page).toBe(0);
});
it('returns items and total from the search result', async () => { it('returns items and total from the search result', async () => {
const item = { const item = {
document: { id: 'd1' }, document: { id: 'd1' },