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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
102
frontend/src/routes/geschichten/page.svelte.spec.ts
Normal file
102
frontend/src/routes/geschichten/page.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user