From 96d023a7cb9082e0765df9efeb24b1840f17794d Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 3 May 2026 08:37:28 +0200 Subject: [PATCH] feat(geschichten): chip-row UI for multi-person AND filter The /geschichten list page now renders one removable chip per active person filter and lets users add more via the existing typeahead. The URL uses repeated ?personId= params (matching the documents tag filter), which the regenerated API client passes straight through to the backend's new array-bound endpoint. New translation keys cover the chip remove aria-label, the AND hint shown while picking, and the multi-person empty state. --- frontend/messages/de.json | 3 + frontend/messages/en.json | 3 + frontend/messages/es.json | 3 + frontend/src/lib/generated/api.ts | 4 +- .../src/routes/geschichten/+page.server.ts | 19 ++-- frontend/src/routes/geschichten/+page.svelte | 73 ++++++++----- .../routes/geschichten/page.svelte.spec.ts | 102 ++++++++++++++++++ 7 files changed, 171 insertions(+), 36 deletions(-) create mode 100644 frontend/src/routes/geschichten/page.svelte.spec.ts diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 932b8f24..28fb6f67 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -929,7 +929,10 @@ "geschichten_filter_all_pill": "Alle", "geschichten_filter_choose_person": "Person wählen", "geschichten_filter_aria_label": "Person filtern", + "geschichten_filter_remove_chip": "{name} aus Filter entfernen", + "geschichten_filter_and_hint": "Es werden nur Geschichten gezeigt, in denen alle ausgewählten Personen vorkommen.", "geschichten_empty_for_person": "Keine Geschichten für {name} gefunden.", + "geschichten_empty_for_persons": "Keine Geschichten für {names} gefunden.", "geschichten_empty_no_filter": "Es gibt noch keine veröffentlichten Geschichten.", "geschichten_back_to_index": "Zurück zu Geschichten", "geschichten_published_on": "veröffentlicht am {date}", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 2bcb443d..1d811734 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -929,7 +929,10 @@ "geschichten_filter_all_pill": "All", "geschichten_filter_choose_person": "Choose person", "geschichten_filter_aria_label": "Filter by person", + "geschichten_filter_remove_chip": "Remove {name} from filter", + "geschichten_filter_and_hint": "Only stories that include every selected person are shown.", "geschichten_empty_for_person": "No stories found for {name}.", + "geschichten_empty_for_persons": "No stories found for {names}.", "geschichten_empty_no_filter": "There are no published stories yet.", "geschichten_back_to_index": "Back to stories", "geschichten_published_on": "published on {date}", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 1777de77..06b805e8 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -929,7 +929,10 @@ "geschichten_filter_all_pill": "Todas", "geschichten_filter_choose_person": "Elegir persona", "geschichten_filter_aria_label": "Filtrar por persona", + "geschichten_filter_remove_chip": "Quitar {name} del filtro", + "geschichten_filter_and_hint": "Solo se muestran las historias que incluyen a todas las personas seleccionadas.", "geschichten_empty_for_person": "No hay historias para {name}.", + "geschichten_empty_for_persons": "No hay historias para {names}.", "geschichten_empty_no_filter": "Aún no hay historias publicadas.", "geschichten_back_to_index": "Volver a Historias", "geschichten_published_on": "publicada el {date}", diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 33f5060f..32c1c2e6 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -2140,9 +2140,9 @@ export interface components { deathYear?: number; familyMember?: boolean; notes?: string; + alias?: string; /** Format: int64 */ documentCount?: number; - alias?: string; }; InferredRelationshipWithPersonDTO: { person: components["schemas"]["PersonNodeDTO"]; @@ -3339,7 +3339,7 @@ export interface operations { parameters: { query?: { status?: "DRAFT" | "PUBLISHED"; - personId?: string; + personId?: string[]; documentId?: string; limit?: number; }; diff --git a/frontend/src/routes/geschichten/+page.server.ts b/frontend/src/routes/geschichten/+page.server.ts index 66e3fdf1..54072df6 100644 --- a/frontend/src/routes/geschichten/+page.server.ts +++ b/frontend/src/routes/geschichten/+page.server.ts @@ -1,26 +1,27 @@ import { error } from '@sveltejs/kit'; import { createApiClient } from '$lib/api.server'; import { getErrorMessage } from '$lib/errors'; +import type { components } from '$lib/generated/api'; import type { PageServerLoad } from './$types'; +type Person = components['schemas']['Person']; + export const load: PageServerLoad = async ({ url, fetch }) => { const api = createApiClient(fetch); - const personId = url.searchParams.get('personId') ?? undefined; + const personIds = url.searchParams.getAll('personId'); const documentId = url.searchParams.get('documentId') ?? undefined; - const [listResult, personResult] = await Promise.all([ + const [listResult, ...personResults] = await Promise.all([ api.GET('/api/geschichten', { params: { query: { status: 'PUBLISHED', - personId, + personId: personIds.length ? personIds : undefined, documentId } } }), - personId - ? api.GET('/api/persons/{id}', { params: { path: { id: personId } } }) - : Promise.resolve(null) + ...personIds.map((id) => api.GET('/api/persons/{id}', { params: { path: { id } } })) ]); if (!listResult.response.ok) { @@ -28,9 +29,13 @@ export const load: PageServerLoad = async ({ url, fetch }) => { throw error(listResult.response.status, getErrorMessage(code)); } + const personFilters = personResults + .filter((r) => r && r.response.ok && r.data) + .map((r) => r!.data!) as Person[]; + return { geschichten: listResult.data ?? [], - personFilter: personResult && personResult.response.ok ? personResult.data! : null, + personFilters, documentFilter: documentId ?? null }; }; diff --git a/frontend/src/routes/geschichten/+page.svelte b/frontend/src/routes/geschichten/+page.svelte index feb1f876..dd6240da 100644 --- a/frontend/src/routes/geschichten/+page.svelte +++ b/frontend/src/routes/geschichten/+page.svelte @@ -10,22 +10,32 @@ let { data }: { data: PageData } = $props(); let showPersonPicker = $state(false); -const filterName = $derived(data.personFilter?.displayName ?? ''); +const selectedPersonIds = $derived(data.personFilters.map((p) => p.id!)); +const hasFilters = $derived(data.personFilters.length > 0 || !!data.documentFilter); -function clearFilter() { +function rebuildUrl(personIds: string[]) { const url = new URL(window.location.href); url.searchParams.delete('personId'); url.searchParams.delete('documentId'); - goto(url.pathname + url.search, { replaceState: true }); + for (const id of personIds) url.searchParams.append('personId', id); + return url.pathname + url.search; } -function pickPerson(personId: string) { - if (!personId) return; - const url = new URL(window.location.href); - url.searchParams.set('personId', personId); - url.searchParams.delete('documentId'); +function clearAll() { + goto(rebuildUrl([]), { replaceState: true }); +} + +function addPerson(personId: string) { + if (!personId || selectedPersonIds.includes(personId)) { + showPersonPicker = false; + return; + } showPersonPicker = false; - goto(url.pathname + url.search); + goto(rebuildUrl([...selectedPersonIds, personId])); +} + +function removePerson(personId: string) { + goto(rebuildUrl(selectedPersonIds.filter((id) => id !== personId))); } function authorName(g: { author?: { firstName?: string; lastName?: string; email: string } }) { @@ -58,33 +68,34 @@ function publishedAt(g: { publishedAt?: string }): string | null {
- {#if data.personFilter} + {#each data.personFilters as p (p.id)} - {:else} - - {/if} + {/each} + +
{#if showPersonPicker} @@ -93,16 +104,24 @@ function publishedAt(g: { publishedAt?: string }): string | null { name="filter-person" label={m.geschichten_filter_choose_person()} compact - onchange={pickPerson} + autofocus + onchange={addPerson} /> + {#if selectedPersonIds.length > 1} +

+ {m.geschichten_filter_and_hint()} +

+ {/if} {/if} {#if data.geschichten.length === 0}
- {#if data.personFilter} - {m.geschichten_empty_for_person({ name: filterName })} + {#if data.personFilters.length > 0} + {m.geschichten_empty_for_persons({ + names: data.personFilters.map((p) => p.displayName).join(' & ') + })} {:else} {m.geschichten_empty_no_filter()} {/if} diff --git a/frontend/src/routes/geschichten/page.svelte.spec.ts b/frontend/src/routes/geschichten/page.svelte.spec.ts new file mode 100644 index 00000000..f1c2d104 --- /dev/null +++ b/frontend/src/routes/geschichten/page.svelte.spec.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); +vi.mock('$app/state', () => ({ navigating: { to: null } })); + +import Page from './+page.svelte'; +import type { PageData } from './$types'; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +function person(id: string, displayName: string) { + return { + id, + firstName: displayName.split(' ')[0] ?? displayName, + lastName: displayName.split(' ').slice(1).join(' ') || 'X', + displayName, + personType: 'PERSON' + }; +} + +function makeData(overrides: Partial = {}): PageData { + return { + geschichten: [], + personFilters: [], + documentFilter: null, + canBlogWrite: false, + ...overrides + } as unknown as PageData; +} + +describe('geschichten page — multi-person filter chips', () => { + it('renders one chip per person in personFilters', async () => { + render(Page, { + data: makeData({ + personFilters: [person('a', 'Anna A'), person('b', 'Bertha B')] as PageData['personFilters'] + }) + }); + + await expect + .element(page.getByRole('button', { name: /Anna A aus Filter entfernen/ })) + .toBeVisible(); + await expect + .element(page.getByRole('button', { name: /Bertha B aus Filter entfernen/ })) + .toBeVisible(); + }); + + it('renders the "All" pill in pressed state when no filters are active', async () => { + render(Page, { data: makeData() }); + await expect + .element(page.getByRole('button', { name: 'Alle' })) + .toHaveAttribute('aria-pressed', 'true'); + }); + + it('renders the "All" pill in unpressed state when at least one filter is active', async () => { + render(Page, { + data: makeData({ + personFilters: [person('a', 'Anna A')] as PageData['personFilters'] + }) + }); + await expect + .element(page.getByRole('button', { name: 'Alle' })) + .toHaveAttribute('aria-pressed', 'false'); + }); + + it('clicking × on a chip removes only that person from the URL', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + + // Seed window.location so the chip-removal logic builds the new URL deterministically. + const originalHref = window.location.href; + window.history.replaceState({}, '', '/geschichten?personId=a&personId=b'); + + render(Page, { + data: makeData({ + personFilters: [person('a', 'Anna A'), person('b', 'Bertha B')] as PageData['personFilters'] + }) + }); + + await page.getByRole('button', { name: /Anna A aus Filter entfernen/ }).click(); + + expect(goto).toHaveBeenCalledOnce(); + const url = vi.mocked(goto).mock.calls[0][0] as string; + expect(url).toContain('personId=b'); + expect(url).not.toContain('personId=a'); + + window.history.replaceState({}, '', originalHref); + }); + + it('shows the "+ Person wählen" button even when filters are already active', async () => { + render(Page, { + data: makeData({ + personFilters: [person('a', 'Anna A')] as PageData['personFilters'] + }) + }); + await expect.element(page.getByRole('button', { name: /Person wählen/ })).toBeVisible(); + }); +});