feat(geschichten): integrate DocumentFilterChip into list page
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m21s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 4m48s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 26s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m9s

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 dcc9a25fdc
commit 1de10986c3
2 changed files with 187 additions and 10 deletions

View File

@@ -3,6 +3,7 @@ import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte'; import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
import GeschichteListRow from '$lib/geschichte/GeschichteListRow.svelte'; import GeschichteListRow from '$lib/geschichte/GeschichteListRow.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();
@@ -10,11 +11,24 @@ 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); 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);
url.searchParams.delete('personId'); url.searchParams.delete('personId');
url.searchParams.delete('documentId');
for (const id of personIds) url.searchParams.append('personId', id); for (const id of personIds) url.searchParams.append('personId', id);
return url.pathname + url.search; return url.pathname + url.search;
} }
@@ -35,6 +49,10 @@ function addPerson(personId: string) {
function removePerson(personId: string) { function removePerson(personId: string) {
goto(rebuildUrl(selectedPersonIds.filter((id) => id !== personId))); goto(rebuildUrl(selectedPersonIds.filter((id) => id !== personId)));
} }
function removeDocument() {
goto(rebuildUrl(selectedPersonIds));
}
</script> </script>
<div class="mx-auto max-w-7xl px-4 py-8"> <div class="mx-auto max-w-7xl px-4 py-8">
@@ -76,6 +94,14 @@ function removePerson(personId: string) {
</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}
@@ -106,13 +132,7 @@ function removePerson(personId: string) {
<!-- Rows --> <!-- Rows -->
{#if data.geschichten.length === 0} {#if data.geschichten.length === 0}
<div class="px-4 py-12 text-center font-serif text-sm text-ink-3 italic"> <div class="px-4 py-12 text-center font-serif text-sm text-ink-3 italic">
{#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> <ul>

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');
@@ -113,6 +127,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({