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
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:
@@ -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>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user