From f1fc3dc1ce862912cd34e71a631cd9024fe961d0 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 18:45:03 +0200 Subject: [PATCH] feat(documents): thread undated filter through the search loader + i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/messages/de.json | 2 + frontend/messages/en.json | 2 + frontend/messages/es.json | 2 + frontend/src/lib/generated/api.ts | 3 + frontend/src/routes/documents/+page.server.ts | 5 + .../src/routes/documents/page.server.spec.ts | 100 ++++++++++++++++++ 6 files changed, 114 insertions(+) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 7358637a..f2524003 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -100,6 +100,8 @@ "docs_list_summary": "Zusammenfassung", "docs_list_unknown": "Unbekannt", "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", "doc_section_who_when": "Wer & Wann", "doc_section_description": "Beschreibung", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 77097540..33e7f222 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -100,6 +100,8 @@ "docs_list_summary": "Summary", "docs_list_unknown": "Unknown", "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", "doc_section_who_when": "Who & When", "doc_section_description": "Description", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 47906c0c..e8859767 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -100,6 +100,8 @@ "docs_list_summary": "Resumen", "docs_list_unknown": "Desconocido", "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", "doc_section_who_when": "Quién & Cuándo", "doc_section_description": "Descripción", diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 48b4b83d..9c901d56 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -5083,6 +5083,8 @@ export interface operations { dir?: string; /** @description Tag operator: AND (default) or OR */ tagOp?: string; + /** @description Restrict to undated documents (meta_date IS NULL) */ + undated?: boolean; /** @description Page number (0-indexed) */ page?: number; /** @description Page size (max 100) */ @@ -5184,6 +5186,7 @@ export interface operations { tagQ?: string; status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED"; tagOp?: string; + undated?: boolean; }; header?: never; path?: never; diff --git a/frontend/src/routes/documents/+page.server.ts b/frontend/src/routes/documents/+page.server.ts index 16186611..acd8f666 100644 --- a/frontend/src/routes/documents/+page.server.ts +++ b/frontend/src/routes/documents/+page.server.ts @@ -46,6 +46,8 @@ export async function load({ url, fetch }) { : 'desc'; const tagQ = url.searchParams.get('tagQ') || ''; 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 api = createApiClient(fetch); @@ -66,6 +68,7 @@ export async function load({ url, fetch }) { tag: tags.length ? tags : undefined, tagQ: tagQ && !tags.length ? tagQ : undefined, tagOp: tagOp === 'OR' ? 'OR' : undefined, + undated: undated || undefined, sort, dir: dir || undefined, page, @@ -94,6 +97,7 @@ export async function load({ url, fetch }) { dir, tagQ, tagOp, + undated, error: 'Daten konnten nicht geladen werden.' as string | null }; } @@ -124,6 +128,7 @@ export async function load({ url, fetch }) { dir, tagQ, tagOp, + undated, error: errorMessage }; } diff --git a/frontend/src/routes/documents/page.server.spec.ts b/frontend/src/routes/documents/page.server.spec.ts index 2ed33a86..69726562 100644 --- a/frontend/src/routes/documents/page.server.spec.ts +++ b/frontend/src/routes/documents/page.server.spec.ts @@ -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 () => { const item = { document: { id: 'd1' },