feat(geschichten): visible filter chip for the ?documentId list filter #803
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
PR #792 wired the headless
?documentId=<uuid>filter onGET /api/geschichten(commit98e3d924): the document-detail drawer links to/geschichten?documentId=<uuid>to surface Lesereisen that reference a letter, and the list route forwards the param toGeschichteService.list(). The loader already returns the active filter to the page (+page.server.tsreturns it asdocumentFilter— the raw UUID) — but no UI consumes it. A reader following the drawer link lands on a silently filtered list (Nielsen #1, visibility of system status).This issue was originally mis-referenced as #797 in the PR #792 description (round-3 review, Elicit blocker 2 follow-up); #797 is the reading-sheet surface issue. This is the issue that actually owns the filter UI.
Implementation notes
Title resolution (server-side, in the existing loader).
frontend/src/routes/geschichten/+page.server.tsalready fans out withPromise.all(it resolvespersonFiltersthe same way). Add a conditionalapi.GET('/api/documents/{id}')into that samePromise.allwhen a validdocumentIdis present —documentIdis known up front, so the title fetch runs in parallel with the list + person fetches and adds zero wall-clock latency. Do not introduce a serialawaitafter the list resolves. Return a minimal resolved shape —documentFilter: { id, title } | null— not the raw UUID string and not the wholeDocumententity. Data flows server-side via props; never client-sidefetch/onMount. ReusingGET /api/documents/{id}(confirmed atDocumentController.java:136) keeps the Geschichte domain free of Document-title concerns (no new backend endpoint, no denormalization).Breaking
PageDatashape change (not purely additive).documentFiltergoes fromstring | nullto{ id, title: string | null } | null.hasFilters(!!data.documentFilter) still works (a non-null object is truthy), and the chip renders on{#if data.documentFilter}(single optional region — no{#each}keying). The existingpage.svelte.spec.ts:26/page.svelte.test.ts:32makeData()factory referencesdocumentFilter: nulland ends withas unknown as PageData(so the type checker won't flag a stale shape —npm run checkstays green either way). Update it to return a real{ id, title }object in the same PR anyway, so the new empty-state/chip tests run against faithful data — the reason is runtime test fidelity, not the type gate.Display fallback chain for the chip label:
doc.title || doc.originalFilename || id.slice(0, 8). This matches the document detail page (documents/[id]/+page.svelte:272, which usesdoc.title || doc.originalFilename). The short-UUID fallback covers both "document is gone" and "document has no title". Resolve the label in a$derived chipLabelinsideDocumentFilterChip.svelte, not inline in markup. Do not copy the detail page's'Dokument'literal third fallback — here the short-UUID is the deliberate third fallback because it doubles as the "document gone" label. Always validate, then slice — never slice an unvalidated string.Fail closed and quietly on the title fetch. If
GET /api/documents/{id}returns non-ok, the loader must not callgetErrorMessage(...)or throwerror()— degrade to{ id, title: null }so the chip shows the short UUID. Treat 403 and 404 identically (don't reveal exists-but-forbidden vs. missing). Do not log the failed title fetch at WARN/ERROR, and never log the rawdocumentIdquery string — a gone/forbidden-document filter link would otherwise generate attacker-triggerable error-log noise (avoidable Loki noise); degrade silently. The list requestGET /api/geschichten?documentId=…carries the user's auth cookie viacreateApiClient(fetch), so filtered results already respect access rules — the authenticated fetch is the authorization boundary; do not add a bypass.UUID validation. Add a strict, fully-anchored
isUuid()guard as a local const in+page.server.ts(no existing helper; do not create avalidation/package for one regex):/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/. The chip renders only for a syntactically valid UUID, and the fallback label must reflect only the parsed/validated UUID — never echourl.searchParams.get('documentId')verbatim into the DOM or thetitle=attribute. (Svelte mustache escapes by default, so this is input hygiene, not an XSS fix.)Invalid-UUID behavior — decided (option B). The backend binds
documentIdas aUUID(GeschichteController.java:36), so a syntactically invalid value raisesMethodArgumentTypeMismatchException, whichGlobalExceptionHandler.handleTypeMismatch(GlobalExceptionHandler.java:51) maps to 400VALIDATION_ERROR(no stack trace, no raw message). The loader's existingthrow error(status, getErrorMessage(extractErrorCode(listResult.error)))already mapsVALIDATION_ERRORto a localized string (errors.ts:181) — so an invaliddocumentIdrenders a useful, localized error page that leaks nothing. We keep this; we do not strip-and-ignore. Distinct cases:documentId→ useful localized error page (no chip).Empty state — decided (add a new message + person-wins precedence). Today the empty branch (
+page.svelte:119-128) is a two-way{#if personFilters.length > 0}/{:else}with onlygeschichten_empty_for_personsandgeschichten_empty_no_filter; a Brief-only filter that matches nothing falls through to "no filter", contradicting the visible chip. Add ageschichten_empty_for_documentkey (de/en/es, below) and restructure the empty-state selection. Do not grow the inline{#if}chain — extract a single$derived emptyMessagein the script that resolves precedence once, and let the template read the one value (keeps business logic out of markup; makes precedence directly testable). Precedence (decided):geschichten_empty_for_persons(person-wins, even when a document filter is also active — the more specific multi-entity query)geschichten_empty_for_documentgeschichten_empty_no_filterThe combined case (both filters) is reachable only by manual URL or adding a person after the document-only drawer link, but the
$derivedresolves it deterministically. (Wire the$derivedto the existing pluralgeschichten_empty_for_persons; an orphan singulargeschichten_empty_for_personkey exists atde.json:1033but is pre-existing and out of scope — leave it alone.)Remove button — decided. Removes only the
documentId, preserving any active person filters.rebuildUrl()(+page.svelte:16-22) already deletesdocumentIdon every rebuild, so the handler isgoto(rebuildUrl(selectedPersonIds))— consistent with the existingremovePersonbehavior. MirrorremovePersonexactly; do not write a bespokeremoveDocumentthat re-implements the same delete.Component extraction. Extract a
DocumentFilterChip.svelte(a ~25-line nameable region takingtitle: string | null,id: string, and anonremovecallback) so the component spec can render it in isolation. The existing inline person chips (+page.svelte:78-91) do not need to move.UX / chip design (domain term: "Brief")
The existing person chips are
uppercase tracking-wider text-xsonbg-ink— that works for short names but must not be copied verbatim for a document title (mixed-case, often long, e.g. "Brief an die Großmutter, Weihnachten 1923").min-h-11. Static prefix label "Gefiltert nach Brief:" in chrome style (font-sans text-xs uppercase tracking-wider); the title in normal case,font-serif italic, not uppercased. Usemin-h-11(not a fixedh-11) so a single-line chip baseline matches the "Alle" and "+ Person wählen" pills in the same flex row onsm+, while the chip is allowed to grow to two lines on mobile (see truncation below).line-clamp-2 sm:truncate sm:max-w-[16rem]: the title wraps to at most two lines on small screens (so the full title is readable on a phone — the read-path audience), and truncates to a single16rem-capped line onsm+ where thetitle=hover tooltip works. Keep the full title in atitle=attribute for the desktop hover. Rationale (decided):title=is hover-only and never fires for touch users, so a fixedtruncateeverywhere would make a long Brief title unrecoverable in place on phones; clamping costs one responsive class and closes the gap.aria-live="polite"(or ensure the surrounding filter region announces) so a screen-reader user who triggered the Brief filter from the drawer hears that it is now active — visibility of system status for AT.×is its own<button>withmin-h-[44px] min-w-[44px](≥44px touch target, WCAG 2.2 §2.5.8 — critical for the 60+ audience),aria-labelviageschichten_filter_remove_document_chip({ title }), andfocus-visible:ring-2 focus-visible:ring-focus-ring(established pattern on the existing pills at+page.svelte:60,73). Don't bundle label+remove into one full-width button (misclick risk on the long label).text-primary-fgonbg-ink(same AA-passing token pair as the person chips); don't introduce a new color.font-serifis Tinos, which ships a true italic — confirm during proofshot it's a real italic, not synthesized oblique; if the italic serif reads too light onbg-inkat small size, fall back to non-italic serif (do not bold — bolding changes the semantic weight).flex flex-wrap gap-2container (+page.svelte:68); (2) the Tinos italic renders as a true italic at small size; (3) onsm+ the single-line prefix+title zone baseline aligns to the sibling pills (min-h-11); on mobile a two-line wrapped chip may be taller than 44px, which is expected.i18n
Add keys in
messages/{de,en,es}.json, Sie-free register matching the existinggeschichten_filter_*/geschichten_empty_*keys (messages/de.json:1028-1035);{title}interpolation matches the existinggeschichten_filter_remove_chip({name}):geschichten_filter_document_chip: deGefiltert nach Brief:· enFiltered by letter:· esFiltrado por carta:geschichten_filter_remove_document_chip({ title }): deBrief {title} aus Filter entfernen· enRemove letter {title} from filter· esQuitar la carta {title} del filtrogeschichten_empty_for_document: deNoch keine Geschichten zu diesem Brief· enNo stories reference this letter yet· esAún no hay historias sobre esta cartaTest strategy
Loader unit tests — there is currently no
+page.server.test.tsforgeschichten; this AC creates the first one. Importloaddirectly, mock only the api client at the$lib/shared/api.serverboundary (the persona-filter resolution is a working template — same module, same fan-out). Don't spin up a browser. The riskiest logic lives here:documentIdis a valid UUID and the document existsawait expect(load(...)).resolves.toMatchObject({ documentFilter: { id, title: null } })(assert it resolves, never rejects — explicit.resolves, not a loose "does not throw")originalFilenamegetErrorMessage/throwon a forbidden title fetch, locking the no-oracle guaranteedocumentId→ list request surfaces 400VALIDATION_ERRORas a thrown localized error (option B)mockApi.GETresolutions keyed by path) so a title-fetch failure provably does not corrupt thegeschichtenlist result — assert the list array is still populated when the title fetch returns 404Empty-state precedence tests (component or page level) — the restructured
$derived emptyMessageis new behavior; lock every branch. Prefer asserting the rendered message (getByText) over the$derivedvalue directly:geschichten_empty_for_documentgeschichten_empty_for_personsgeschichten_empty_no_filtergeschichten_empty_for_persons(person-wins)Component spec (
DocumentFilterChip.svelte.spec.ts,vitest-browser-svelte,vi.mock('$app/navigation')):documentFilter: null→ chip absent)onremovewhen the remove button is clicked — drive viagetByRole('button', { name: <aria-label> })+ click; assert the spy fired once, not internal statetitle=attribute equals the validated value, not a raw query string (reflected-input hygiene survives refactors)Run targeted files locally only (
--project=clientfor the.svelte.spec.ts, node for the loader test); leave the full sweep to CI. No E2E needed (presentation detail on an existing journey).Acceptance criteria
/geschichten?documentId=<uuid>is active and the document resolves, the list shows a visible filter chip above the results: "Gefiltert nach Brief: ", title resolved server-side viaGET /api/documents/{id}.title || originalFilename || short-UUID; a gone/forbidden document (404/403) degrades quietly to the short UUID — no thrown error, no leaked backend message, no WARN/ERROR log of the raw query string; 403 and 404 are indistinguishable in the UI.<button>, witharia-labeland visible focus ring) removes onlydocumentIdand preserves active person filters (goto(rebuildUrl(selectedPersonIds))).title=attribute) reflects only the parsed UUID, never the raw query string.documentIdrenders an error page with a useful, localized message (option B — not stripped/ignored).$derivedwith person-wins precedence: person filter (even if a document filter is also active) →geschichten_empty_for_persons; else document filter →geschichten_empty_for_document; else →geschichten_empty_no_filter. All four branches plus the non-empty case are test-locked.font-serif italic); responsive truncationline-clamp-2 sm:truncate sm:max-w-[16rem](wraps to ≤2 lines on mobile so the full title is readable on a phone; single truncated line withtitle=hover onsm+); prefix label in chrome style; chipmin-h-11(single-line baseline aligns to sibling pills onsm+, may grow to two lines on mobile); chip region hasaria-live="polite"; no horizontal overflow at 320px (verified by proofshot covering 2-line wrap + italic + baseline).geschichten_filter_document_chip,geschichten_filter_remove_document_chip, andgeschichten_empty_for_documentadded in de/en/es, Sie-free register.DocumentFilterChip.svelteextracted; component spec: chip renders with filter, absent without, remove firesonremove, andtitle=equals the validated value.+page.server.test.tscovers: resolve, 404 fallback (.resolves), null-title fallback, 403==404 no-oracle, invalid-UUID error, independent list/title mocks.page.svelte.spec.ts:26/page.svelte.test.ts:32makeData()updated to return a realdocumentFilterobject shape (same PR) for runtime test fidelity.Out of scope / non-impacts
DocumentFilterChip.svelteis a component under the existing/geschichtenroute, not a new+page.svelte), no newErrorCode/Permission, no backend controller/service change → no PlantUML orCLAUDE.mddoc-currency triggers fire, no ADR. ("Brief" is an existing document-domain term — no new glossary entry.)GET /api/documents/{id}only on filtered loads, parallel in the existingPromise.all— zero added latency).Refs
98e3d924— backend + loader),frontend/src/routes/geschichten/+page.server.ts(active filter already indataasdocumentFilter), document-detail drawer link (DocumentMetadataDrawer.svelte:246).Implementation complete ✅
Branch:
feat/issue-803-geschichten-document-filter-chipCommits
5a87e4d6i18n keys — addedgeschichten_filter_document_chip,geschichten_filter_remove_document_chip,geschichten_empty_for_documentto de/en/esba2481aeLoader — UUID-validates?documentId=, fetches title fromGET /api/documents/{id}in parallel with persons fetch, returns{ id, title } | null; 403/404 fail-closed to{ id, title: null }; invalid UUID still reaches the list API (→ backend 400, option B)4d8b8f15DocumentFilterChip.svelte — two-zone chip with prefix label,font-serif italictitle,line-clamp-2 sm:truncate,≥44pxtouch target remove button,aria-live="polite"; 7 browser specsb2b3eb0b+page.svelte integration — adds chip to filter bar,$derived.by()emptyMessagewith person-wins precedence,removeDocumentnavigation helper; 16 browser specs + 11 server specs all greenTest coverage (34 tests, all passing)
page.server.test.ts(11): title resolution, null-title fallback, 403/404 fail-closed, invalid-UUID gating, option-B passthroughDocumentFilterChip.svelte.spec.ts(7): rendering, fallback to short UUID, remove callback, aria-label, touch targetpage.svelte.spec.ts(16): person chips, All-pill state, document chip show/hide, chip removal navigation, empty-state precedence (person-wins)