feat(geschichten): chip-row UI for multi-person AND filter

The /geschichten list page now renders one removable chip per active
person filter and lets users add more via the existing typeahead. The
URL uses repeated ?personId= params (matching the documents tag
filter), which the regenerated API client passes straight through to
the backend's new array-bound endpoint. New translation keys cover the
chip remove aria-label, the AND hint shown while picking, and the
multi-person empty state.
This commit is contained in:
Marcel
2026-05-03 08:37:28 +02:00
parent 0802889ea9
commit 96d023a7cb
7 changed files with 171 additions and 36 deletions

View File

@@ -929,7 +929,10 @@
"geschichten_filter_all_pill": "Alle", "geschichten_filter_all_pill": "Alle",
"geschichten_filter_choose_person": "Person wählen", "geschichten_filter_choose_person": "Person wählen",
"geschichten_filter_aria_label": "Person filtern", "geschichten_filter_aria_label": "Person filtern",
"geschichten_filter_remove_chip": "{name} aus Filter entfernen",
"geschichten_filter_and_hint": "Es werden nur Geschichten gezeigt, in denen alle ausgewählten Personen vorkommen.",
"geschichten_empty_for_person": "Keine Geschichten für {name} gefunden.", "geschichten_empty_for_person": "Keine Geschichten für {name} gefunden.",
"geschichten_empty_for_persons": "Keine Geschichten für {names} gefunden.",
"geschichten_empty_no_filter": "Es gibt noch keine veröffentlichten Geschichten.", "geschichten_empty_no_filter": "Es gibt noch keine veröffentlichten Geschichten.",
"geschichten_back_to_index": "Zurück zu Geschichten", "geschichten_back_to_index": "Zurück zu Geschichten",
"geschichten_published_on": "veröffentlicht am {date}", "geschichten_published_on": "veröffentlicht am {date}",

View File

@@ -929,7 +929,10 @@
"geschichten_filter_all_pill": "All", "geschichten_filter_all_pill": "All",
"geschichten_filter_choose_person": "Choose person", "geschichten_filter_choose_person": "Choose person",
"geschichten_filter_aria_label": "Filter by person", "geschichten_filter_aria_label": "Filter by person",
"geschichten_filter_remove_chip": "Remove {name} from filter",
"geschichten_filter_and_hint": "Only stories that include every selected person are shown.",
"geschichten_empty_for_person": "No stories found for {name}.", "geschichten_empty_for_person": "No stories found for {name}.",
"geschichten_empty_for_persons": "No stories found for {names}.",
"geschichten_empty_no_filter": "There are no published stories yet.", "geschichten_empty_no_filter": "There are no published stories yet.",
"geschichten_back_to_index": "Back to stories", "geschichten_back_to_index": "Back to stories",
"geschichten_published_on": "published on {date}", "geschichten_published_on": "published on {date}",

View File

@@ -929,7 +929,10 @@
"geschichten_filter_all_pill": "Todas", "geschichten_filter_all_pill": "Todas",
"geschichten_filter_choose_person": "Elegir persona", "geschichten_filter_choose_person": "Elegir persona",
"geschichten_filter_aria_label": "Filtrar por persona", "geschichten_filter_aria_label": "Filtrar por persona",
"geschichten_filter_remove_chip": "Quitar {name} del filtro",
"geschichten_filter_and_hint": "Solo se muestran las historias que incluyen a todas las personas seleccionadas.",
"geschichten_empty_for_person": "No hay historias para {name}.", "geschichten_empty_for_person": "No hay historias para {name}.",
"geschichten_empty_for_persons": "No hay historias para {names}.",
"geschichten_empty_no_filter": "Aún no hay historias publicadas.", "geschichten_empty_no_filter": "Aún no hay historias publicadas.",
"geschichten_back_to_index": "Volver a Historias", "geschichten_back_to_index": "Volver a Historias",
"geschichten_published_on": "publicada el {date}", "geschichten_published_on": "publicada el {date}",

View File

@@ -2140,9 +2140,9 @@ export interface components {
deathYear?: number; deathYear?: number;
familyMember?: boolean; familyMember?: boolean;
notes?: string; notes?: string;
alias?: string;
/** Format: int64 */ /** Format: int64 */
documentCount?: number; documentCount?: number;
alias?: string;
}; };
InferredRelationshipWithPersonDTO: { InferredRelationshipWithPersonDTO: {
person: components["schemas"]["PersonNodeDTO"]; person: components["schemas"]["PersonNodeDTO"];
@@ -3339,7 +3339,7 @@ export interface operations {
parameters: { parameters: {
query?: { query?: {
status?: "DRAFT" | "PUBLISHED"; status?: "DRAFT" | "PUBLISHED";
personId?: string; personId?: string[];
documentId?: string; documentId?: string;
limit?: number; limit?: number;
}; };

View File

@@ -1,26 +1,27 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server'; import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors'; import { getErrorMessage } from '$lib/errors';
import type { components } from '$lib/generated/api';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
type Person = components['schemas']['Person'];
export const load: PageServerLoad = async ({ url, fetch }) => { export const load: PageServerLoad = async ({ url, fetch }) => {
const api = createApiClient(fetch); const api = createApiClient(fetch);
const personId = url.searchParams.get('personId') ?? undefined; const personIds = url.searchParams.getAll('personId');
const documentId = url.searchParams.get('documentId') ?? undefined; const documentId = url.searchParams.get('documentId') ?? undefined;
const [listResult, personResult] = await Promise.all([ const [listResult, ...personResults] = await Promise.all([
api.GET('/api/geschichten', { api.GET('/api/geschichten', {
params: { params: {
query: { query: {
status: 'PUBLISHED', status: 'PUBLISHED',
personId, personId: personIds.length ? personIds : undefined,
documentId documentId
} }
} }
}), }),
personId ...personIds.map((id) => api.GET('/api/persons/{id}', { params: { path: { id } } }))
? api.GET('/api/persons/{id}', { params: { path: { id: personId } } })
: Promise.resolve(null)
]); ]);
if (!listResult.response.ok) { if (!listResult.response.ok) {
@@ -28,9 +29,13 @@ export const load: PageServerLoad = async ({ url, fetch }) => {
throw error(listResult.response.status, getErrorMessage(code)); throw error(listResult.response.status, getErrorMessage(code));
} }
const personFilters = personResults
.filter((r) => r && r.response.ok && r.data)
.map((r) => r!.data!) as Person[];
return { return {
geschichten: listResult.data ?? [], geschichten: listResult.data ?? [],
personFilter: personResult && personResult.response.ok ? personResult.data! : null, personFilters,
documentFilter: documentId ?? null documentFilter: documentId ?? null
}; };
}; };

View File

@@ -10,22 +10,32 @@ let { data }: { data: PageData } = $props();
let showPersonPicker = $state(false); let showPersonPicker = $state(false);
const filterName = $derived(data.personFilter?.displayName ?? ''); const selectedPersonIds = $derived(data.personFilters.map((p) => p.id!));
const hasFilters = $derived(data.personFilters.length > 0 || !!data.documentFilter);
function clearFilter() { 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'); url.searchParams.delete('documentId');
goto(url.pathname + url.search, { replaceState: true }); for (const id of personIds) url.searchParams.append('personId', id);
return url.pathname + url.search;
} }
function pickPerson(personId: string) { function clearAll() {
if (!personId) return; goto(rebuildUrl([]), { replaceState: true });
const url = new URL(window.location.href); }
url.searchParams.set('personId', personId);
url.searchParams.delete('documentId'); function addPerson(personId: string) {
if (!personId || selectedPersonIds.includes(personId)) {
showPersonPicker = false;
return;
}
showPersonPicker = false; showPersonPicker = false;
goto(url.pathname + url.search); goto(rebuildUrl([...selectedPersonIds, personId]));
}
function removePerson(personId: string) {
goto(rebuildUrl(selectedPersonIds.filter((id) => id !== personId)));
} }
function authorName(g: { author?: { firstName?: string; lastName?: string; email: string } }) { function authorName(g: { author?: { firstName?: string; lastName?: string; email: string } }) {
@@ -58,33 +68,34 @@ function publishedAt(g: { publishedAt?: string }): string | null {
<div class="mb-6 flex flex-wrap items-center gap-2"> <div class="mb-6 flex flex-wrap items-center gap-2">
<button <button
type="button" type="button"
aria-pressed={!data.personFilter} aria-pressed={!hasFilters}
onclick={clearFilter} onclick={clearAll}
class="inline-flex h-9 items-center rounded-full border border-line px-3 font-sans text-xs font-bold tracking-wider text-ink-2 uppercase hover:bg-muted aria-pressed:bg-ink aria-pressed:text-primary-fg" class="inline-flex h-9 items-center rounded-full border border-line px-3 font-sans text-xs font-bold tracking-wider text-ink-2 uppercase hover:bg-muted aria-pressed:bg-ink aria-pressed:text-primary-fg"
> >
{m.geschichten_filter_all_pill()} {m.geschichten_filter_all_pill()}
</button> </button>
{#if data.personFilter} {#each data.personFilters as p (p.id)}
<button <button
type="button" type="button"
aria-pressed="true" aria-pressed="true"
onclick={clearFilter} aria-label={m.geschichten_filter_remove_chip({ name: p.displayName })}
onclick={() => removePerson(p.id!)}
class="inline-flex h-9 items-center gap-2 rounded-full bg-ink px-3 font-sans text-xs font-bold tracking-wider text-primary-fg uppercase" class="inline-flex h-9 items-center gap-2 rounded-full bg-ink px-3 font-sans text-xs font-bold tracking-wider text-primary-fg uppercase"
> >
{filterName} {p.displayName}
<span aria-hidden="true">×</span> <span aria-hidden="true">×</span>
</button> </button>
{:else} {/each}
<button
type="button" <button
aria-label={m.geschichten_filter_aria_label()} type="button"
onclick={() => (showPersonPicker = !showPersonPicker)} aria-expanded={showPersonPicker}
class="inline-flex h-9 items-center rounded-full border border-line px-3 font-sans text-xs font-bold tracking-wider text-ink-2 uppercase hover:bg-muted" onclick={() => (showPersonPicker = !showPersonPicker)}
> class="inline-flex h-9 items-center rounded-full border border-line px-3 font-sans text-xs font-bold tracking-wider text-ink-2 uppercase hover:bg-muted"
+ {m.geschichten_filter_choose_person()} >
</button> + {m.geschichten_filter_choose_person()}
{/if} </button>
</div> </div>
{#if showPersonPicker} {#if showPersonPicker}
@@ -93,16 +104,24 @@ function publishedAt(g: { publishedAt?: string }): string | null {
name="filter-person" name="filter-person"
label={m.geschichten_filter_choose_person()} label={m.geschichten_filter_choose_person()}
compact compact
onchange={pickPerson} autofocus
onchange={addPerson}
/> />
{#if selectedPersonIds.length > 1}
<p class="mt-1 font-sans text-xs text-ink-3">
{m.geschichten_filter_and_hint()}
</p>
{/if}
</div> </div>
{/if} {/if}
<!-- 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.personFilter} {#if data.personFilters.length > 0}
{m.geschichten_empty_for_person({ name: filterName })} {m.geschichten_empty_for_persons({
names: data.personFilters.map((p) => p.displayName).join(' & ')
})}
{:else} {:else}
{m.geschichten_empty_no_filter()} {m.geschichten_empty_no_filter()}
{/if} {/if}

View File

@@ -0,0 +1,102 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$app/state', () => ({ navigating: { to: null } }));
import Page from './+page.svelte';
import type { PageData } from './$types';
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
function person(id: string, displayName: string) {
return {
id,
firstName: displayName.split(' ')[0] ?? displayName,
lastName: displayName.split(' ').slice(1).join(' ') || 'X',
displayName,
personType: 'PERSON'
};
}
function makeData(overrides: Partial<PageData> = {}): PageData {
return {
geschichten: [],
personFilters: [],
documentFilter: null,
canBlogWrite: false,
...overrides
} as unknown as PageData;
}
describe('geschichten page — multi-person filter chips', () => {
it('renders one chip per person in personFilters', async () => {
render(Page, {
data: makeData({
personFilters: [person('a', 'Anna A'), person('b', 'Bertha B')] as PageData['personFilters']
})
});
await expect
.element(page.getByRole('button', { name: /Anna A aus Filter entfernen/ }))
.toBeVisible();
await expect
.element(page.getByRole('button', { name: /Bertha B aus Filter entfernen/ }))
.toBeVisible();
});
it('renders the "All" pill in pressed state when no filters are active', async () => {
render(Page, { data: makeData() });
await expect
.element(page.getByRole('button', { name: 'Alle' }))
.toHaveAttribute('aria-pressed', 'true');
});
it('renders the "All" pill in unpressed state when at least one filter is active', async () => {
render(Page, {
data: makeData({
personFilters: [person('a', 'Anna A')] as PageData['personFilters']
})
});
await expect
.element(page.getByRole('button', { name: 'Alle' }))
.toHaveAttribute('aria-pressed', 'false');
});
it('clicking × on a chip removes only that person from the URL', async () => {
const { goto } = await import('$app/navigation');
vi.mocked(goto).mockClear();
// Seed window.location so the chip-removal logic builds the new URL deterministically.
const originalHref = window.location.href;
window.history.replaceState({}, '', '/geschichten?personId=a&personId=b');
render(Page, {
data: makeData({
personFilters: [person('a', 'Anna A'), person('b', 'Bertha B')] as PageData['personFilters']
})
});
await page.getByRole('button', { name: /Anna A aus Filter entfernen/ }).click();
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');
window.history.replaceState({}, '', originalHref);
});
it('shows the "+ Person wählen" button even when filters are already active', async () => {
render(Page, {
data: makeData({
personFilters: [person('a', 'Anna A')] as PageData['personFilters']
})
});
await expect.element(page.getByRole('button', { name: /Person wählen/ })).toBeVisible();
});
});