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:
@@ -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}",
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
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