feat(geschichten): integrate DocumentFilterChip into list page
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m1s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 4m30s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m10s

Add DocumentFilterChip to the filter bar, extract emptyMessage as $derived.by() with person-wins precedence, and add removeDocument navigation helper. Update tests: add document-filter chip and empty-state-precedence suites, fix person-chip click test to use native element.click() + vi.waitFor() for reliable Svelte 5 onclick triggering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-12 09:27:20 +02:00
parent 4d8b8f15ad
commit b2b3eb0b1c
2 changed files with 186 additions and 10 deletions

View File

@@ -4,6 +4,7 @@ import { m } from '$lib/paraglide/messages.js';
import { plainExcerpt } from '$lib/shared/utils/extractText'; import { plainExcerpt } from '$lib/shared/utils/extractText';
import { formatDate } from '$lib/shared/utils/date'; import { formatDate } from '$lib/shared/utils/date';
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte'; import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
import DocumentFilterChip from './DocumentFilterChip.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@@ -11,7 +12,19 @@ let { data }: { data: PageData } = $props();
let showPersonPicker = $state(false); let showPersonPicker = $state(false);
const selectedPersonIds = $derived(data.personFilters.map((p) => p.id!)); 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[]) { function rebuildUrl(personIds: string[]) {
const url = new URL(window.location.href); const url = new URL(window.location.href);
@@ -38,6 +51,10 @@ function removePerson(personId: string) {
goto(rebuildUrl(selectedPersonIds.filter((id) => id !== personId))); goto(rebuildUrl(selectedPersonIds.filter((id) => id !== personId)));
} }
function removeDocument() {
goto(rebuildUrl(selectedPersonIds));
}
function authorName(g: { author?: { firstName?: string; lastName?: string; email: string } }) { function authorName(g: { author?: { firstName?: string; lastName?: string; email: string } }) {
const a = g.author; const a = g.author;
if (!a) return ''; if (!a) return '';
@@ -88,6 +105,14 @@ function publishedAt(g: { publishedAt?: string }): string | null {
</button> </button>
{/each} {/each}
{#if data.documentFilter}
<DocumentFilterChip
id={data.documentFilter.id}
title={data.documentFilter.title}
onremove={removeDocument}
/>
{/if}
<button <button
type="button" type="button"
aria-expanded={showPersonPicker} aria-expanded={showPersonPicker}
@@ -118,13 +143,7 @@ function publishedAt(g: { publishedAt?: string }): string | null {
<!-- Card list --> <!-- Card list -->
{#if data.geschichten.length === 0} {#if data.geschichten.length === 0}
<div class="rounded border border-line bg-surface p-6 text-center font-sans text-sm text-ink-3"> <div class="rounded border border-line bg-surface p-6 text-center font-sans text-sm text-ink-3">
{#if data.personFilters.length > 0} {emptyMessage}
{m.geschichten_empty_for_persons({
names: data.personFilters.map((p) => p.displayName).join(' & ')
})}
{:else}
{m.geschichten_empty_no_filter()}
{/if}
</div> </div>
{:else} {:else}
<ul class="flex flex-col gap-4"> <ul class="flex flex-col gap-4">

View File

@@ -33,6 +33,17 @@ function makeData(overrides: Partial<PageData> = {}): PageData {
} as unknown as 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', () => { describe('geschichten page — multi-person filter chips', () => {
it('renders one chip per person in personFilters', async () => { it('renders one chip per person in personFilters', async () => {
render(Page, { 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; const url = vi.mocked(goto).mock.calls[0][0] as string;
expect(url).toContain('personId=b'); expect(url).toContain('personId=b');
expect(url).not.toContain('personId=a'); 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(); 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 () => { it('renders all filter pills with a 44px touch target (h-11)', async () => {
render(Page, { render(Page, {
data: makeData({ data: makeData({