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_choose_person": "Person wählen",
"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_persons": "Keine Geschichten für {names} gefunden.",
"geschichten_empty_no_filter": "Es gibt noch keine veröffentlichten Geschichten.",
"geschichten_back_to_index": "Zurück zu Geschichten",
"geschichten_published_on": "veröffentlicht am {date}",

View File

@@ -929,7 +929,10 @@
"geschichten_filter_all_pill": "All",
"geschichten_filter_choose_person": "Choose 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_persons": "No stories found for {names}.",
"geschichten_empty_no_filter": "There are no published stories yet.",
"geschichten_back_to_index": "Back to stories",
"geschichten_published_on": "published on {date}",

View File

@@ -929,7 +929,10 @@
"geschichten_filter_all_pill": "Todas",
"geschichten_filter_choose_person": "Elegir 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_persons": "No hay historias para {names}.",
"geschichten_empty_no_filter": "Aún no hay historias publicadas.",
"geschichten_back_to_index": "Volver a Historias",
"geschichten_published_on": "publicada el {date}",

View File

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

View File

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

View File

@@ -10,22 +10,32 @@ let { data }: { data: PageData } = $props();
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);
url.searchParams.delete('personId');
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) {
if (!personId) return;
const url = new URL(window.location.href);
url.searchParams.set('personId', personId);
url.searchParams.delete('documentId');
function clearAll() {
goto(rebuildUrl([]), { replaceState: true });
}
function addPerson(personId: string) {
if (!personId || selectedPersonIds.includes(personId)) {
showPersonPicker = false;
return;
}
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 } }) {
@@ -58,33 +68,34 @@ function publishedAt(g: { publishedAt?: string }): string | null {
<div class="mb-6 flex flex-wrap items-center gap-2">
<button
type="button"
aria-pressed={!data.personFilter}
onclick={clearFilter}
aria-pressed={!hasFilters}
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"
>
{m.geschichten_filter_all_pill()}
</button>
{#if data.personFilter}
{#each data.personFilters as p (p.id)}
<button
type="button"
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"
>
{filterName}
{p.displayName}
<span aria-hidden="true">×</span>
</button>
{:else}
<button
type="button"
aria-label={m.geschichten_filter_aria_label()}
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>
{/if}
{/each}
<button
type="button"
aria-expanded={showPersonPicker}
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>
</div>
{#if showPersonPicker}
@@ -93,16 +104,24 @@ function publishedAt(g: { publishedAt?: string }): string | null {
name="filter-person"
label={m.geschichten_filter_choose_person()}
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>
{/if}
<!-- 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.personFilter}
{m.geschichten_empty_for_person({ name: filterName })}
{#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}

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();
});
});