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
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:
@@ -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 {
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if data.documentFilter}
|
||||
<DocumentFilterChip
|
||||
id={data.documentFilter.id}
|
||||
title={data.documentFilter.title}
|
||||
onremove={removeDocument}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={showPersonPicker}
|
||||
@@ -118,13 +143,7 @@ function publishedAt(g: { publishedAt?: string }): string | null {
|
||||
<!-- Card list -->
|
||||
{#if data.geschichten.length === 0}
|
||||
<div class="rounded border border-line bg-surface p-6 text-center font-sans text-sm text-ink-3">
|
||||
{#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}
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="flex flex-col gap-4">
|
||||
|
||||
@@ -33,6 +33,17 @@ function makeData(overrides: Partial<PageData> = {}): 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({
|
||||
|
||||
Reference in New Issue
Block a user