diff --git a/frontend/src/routes/geschichten/+page.svelte b/frontend/src/routes/geschichten/+page.svelte index 7ced4ade..9f9b4b26 100644 --- a/frontend/src/routes/geschichten/+page.svelte +++ b/frontend/src/routes/geschichten/+page.svelte @@ -4,6 +4,7 @@ import { m } from '$lib/paraglide/messages.js'; import { plainExcerpt } from '$lib/shared/utils/extractText'; import { formatDate } from '$lib/shared/utils/date'; import PersonTypeahead from '$lib/person/PersonTypeahead.svelte'; +import DocumentFilterChip from './DocumentFilterChip.svelte'; import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); @@ -11,7 +12,19 @@ let { data }: { data: PageData } = $props(); let showPersonPicker = $state(false); const selectedPersonIds = $derived(data.personFilters.map((p) => p.id!)); -const hasFilters = $derived(data.personFilters.length > 0 || !!data.documentFilter); +const hasFilters = $derived(data.personFilters.length > 0 || data.documentFilter !== null); + +const emptyMessage = $derived.by(() => { + if (data.personFilters.length > 0) { + return m.geschichten_empty_for_persons({ + names: data.personFilters.map((p) => p.displayName).join(' & ') + }); + } + if (data.documentFilter) { + return m.geschichten_empty_for_document(); + } + return m.geschichten_empty_no_filter(); +}); function rebuildUrl(personIds: string[]) { const url = new URL(window.location.href); @@ -38,6 +51,10 @@ function removePerson(personId: string) { goto(rebuildUrl(selectedPersonIds.filter((id) => id !== personId))); } +function removeDocument() { + goto(rebuildUrl(selectedPersonIds)); +} + function authorName(g: { author?: { firstName?: string; lastName?: string; email: string } }) { const a = g.author; if (!a) return ''; @@ -88,6 +105,14 @@ function publishedAt(g: { publishedAt?: string }): string | null { {/each} + {#if data.documentFilter} + + {/if} + {#if data.geschichten.length === 0} - {#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} + {emptyMessage} {:else} diff --git a/frontend/src/routes/geschichten/page.svelte.spec.ts b/frontend/src/routes/geschichten/page.svelte.spec.ts index f5f7621e..98814a0f 100644 --- a/frontend/src/routes/geschichten/page.svelte.spec.ts +++ b/frontend/src/routes/geschichten/page.svelte.spec.ts @@ -33,6 +33,17 @@ function makeData(overrides: Partial = {}): PageData { } as unknown as PageData; } +function makeDocumentFilter(overrides: { id?: string; title?: string | null } = {}): { + id: string; + title: string | null; +} { + return { + id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + title: 'Brief an Oma', + ...overrides + }; +} + describe('geschichten page — multi-person filter chips', () => { it('renders one chip per person in personFilters', async () => { render(Page, { @@ -81,9 +92,12 @@ describe('geschichten page — multi-person filter chips', () => { }) }); - await page.getByRole('button', { name: /Anna A aus Filter entfernen/ }).click(); + const chipBtn = (await page + .getByRole('button', { name: /Anna A aus Filter entfernen/ }) + .element()) as HTMLElement; + chipBtn.click(); - expect(goto).toHaveBeenCalledOnce(); + await vi.waitFor(() => 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'); @@ -100,6 +114,149 @@ describe('geschichten page — multi-person filter chips', () => { await expect.element(page.getByRole('button', { name: /Person wählen/ })).toBeVisible(); }); + describe('document filter chip', () => { + it('renders the document chip when documentFilter is set', async () => { + render(Page, { + data: makeData({ documentFilter: makeDocumentFilter() as PageData['documentFilter'] }) + }); + + await expect.element(page.getByText(/Gefiltert nach Brief/)).toBeVisible(); + await expect.element(page.getByText(/Brief an Oma/)).toBeVisible(); + }); + + it('does not render the document chip when documentFilter is null', async () => { + render(Page, { data: makeData() }); + + await expect.element(page.getByText(/Gefiltert nach Brief/)).not.toBeInTheDocument(); + }); + + it('clicking the document chip remove button navigates without documentId', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + + window.history.replaceState( + {}, + '', + '/geschichten?documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + ); + + render(Page, { + data: makeData({ documentFilter: makeDocumentFilter() as PageData['documentFilter'] }) + }); + + const removeBtn = (await page + .getByRole('button', { name: /Brief an Oma aus Filter entfernen/ }) + .element()) as HTMLElement; + removeBtn.click(); + + await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce()); + const url = vi.mocked(goto).mock.calls[0][0] as string; + expect(url).not.toContain('documentId'); + }); + + it('document chip removal preserves active person filters', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + + window.history.replaceState( + {}, + '', + '/geschichten?personId=p1&documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + ); + + render(Page, { + data: makeData({ + personFilters: [person('p1', 'Anna A')] as PageData['personFilters'], + documentFilter: makeDocumentFilter() as PageData['documentFilter'] + }) + }); + + const removeBtn = (await page + .getByRole('button', { name: /Brief an Oma aus Filter entfernen/ }) + .element()) as HTMLElement; + removeBtn.click(); + + await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce()); + const url = vi.mocked(goto).mock.calls[0][0] as string; + expect(url).toContain('personId=p1'); + expect(url).not.toContain('documentId'); + }); + + it('marks the "All" pill as unpressed when document filter is active', async () => { + render(Page, { + data: makeData({ documentFilter: makeDocumentFilter() as PageData['documentFilter'] }) + }); + + await expect + .element(page.getByRole('button', { name: 'Alle' })) + .toHaveAttribute('aria-pressed', 'false'); + }); + }); + + describe('empty state precedence', () => { + it('shows geschichten_empty_for_document when only document filter is active', async () => { + render(Page, { + data: makeData({ + geschichten: [], + documentFilter: makeDocumentFilter() as PageData['documentFilter'] + }) + }); + + await expect.element(page.getByText('Noch keine Geschichten zu diesem Brief')).toBeVisible(); + }); + + it('shows geschichten_empty_for_persons when only person filter is active', async () => { + render(Page, { + data: makeData({ + geschichten: [], + personFilters: [person('a', 'Anna A')] as PageData['personFilters'] + }) + }); + + await expect.element(page.getByText(/Keine Geschichten für Anna A gefunden/)).toBeVisible(); + }); + + it('shows geschichten_empty_no_filter when no filter is active', async () => { + render(Page, { data: makeData({ geschichten: [] }) }); + + await expect + .element(page.getByText('Es gibt noch keine veröffentlichten Geschichten.')) + .toBeVisible(); + }); + + it('person-wins: shows persons message when both person and document filters are active', async () => { + render(Page, { + data: makeData({ + geschichten: [], + personFilters: [person('a', 'Anna A')] as PageData['personFilters'], + documentFilter: makeDocumentFilter() as PageData['documentFilter'] + }) + }); + + await expect.element(page.getByText(/Keine Geschichten für Anna A gefunden/)).toBeVisible(); + await expect + .element(page.getByText('Noch keine Geschichten zu diesem Brief')) + .not.toBeInTheDocument(); + }); + + it('chip renders alongside results (empty state not shown when results exist)', async () => { + render(Page, { + data: makeData({ + geschichten: [ + { id: 'g1', title: 'Lesereise Berlin', type: 'JOURNEY' } + ] as PageData['geschichten'], + documentFilter: makeDocumentFilter() as PageData['documentFilter'] + }) + }); + + await expect.element(page.getByText(/Gefiltert nach Brief/)).toBeVisible(); + await expect.element(page.getByText(/Lesereise Berlin/)).toBeVisible(); + await expect + .element(page.getByText('Noch keine Geschichten zu diesem Brief')) + .not.toBeInTheDocument(); + }); + }); + it('renders all filter pills with a 44px touch target (h-11)', async () => { render(Page, { data: makeData({