feat(lesereisen): implement lesereisen
All checks were successful
CI / Unit & Component Tests (push) Successful in 4m34s
CI / OCR Service Tests (push) Successful in 27s
CI / Backend Unit Tests (push) Successful in 5m1s
CI / fail2ban Regex (push) Successful in 47s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m11s
All checks were successful
CI / Unit & Component Tests (push) Successful in 4m34s
CI / OCR Service Tests (push) Successful in 27s
CI / Backend Unit Tests (push) Successful in 5m1s
CI / fail2ban Regex (push) Successful in 47s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m11s
This commit was merged in pull request #787.
This commit is contained in:
@@ -11,7 +11,7 @@ type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||
type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO'];
|
||||
type PersonSummaryDTO = components['schemas']['PersonSummaryDTO'];
|
||||
type DocumentListItem = components['schemas']['DocumentListItem'];
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
type GeschichteSummary = components['schemas']['GeschichteSummary'];
|
||||
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
||||
|
||||
function settled<T>(res: PromiseSettledResult<unknown> | undefined): T | null {
|
||||
@@ -57,9 +57,9 @@ export async function load({ fetch, parent }) {
|
||||
const topPersons = settled<{ items: PersonSummaryDTO[] }>(topPersonsRes)?.items ?? [];
|
||||
const searchData = settled<{ items: DocumentListItem[] }>(recentDocsRes);
|
||||
const recentDocs = searchData?.items ?? [];
|
||||
const recentStories = settled<Geschichte[]>(recentStoriesRes) ?? [];
|
||||
const recentStories = settled<GeschichteSummary[]>(recentStoriesRes) ?? [];
|
||||
const tagTree = settled<TagTreeNodeDTO[]>(tagTreeRes) ?? [];
|
||||
const drafts = settled<Geschichte[]>(draftsRes) ?? [];
|
||||
const drafts = settled<GeschichteSummary[]>(draftsRes) ?? [];
|
||||
|
||||
return {
|
||||
isReader: true as const,
|
||||
@@ -179,9 +179,9 @@ export async function load({ fetch, parent }) {
|
||||
readerStats: null,
|
||||
topPersons: [] as PersonSummaryDTO[],
|
||||
recentDocs: [] as DocumentListItem[],
|
||||
recentStories: [] as Geschichte[],
|
||||
recentStories: [] as GeschichteSummary[],
|
||||
tagTree: [] as TagTreeNodeDTO[],
|
||||
drafts: [] as Geschichte[],
|
||||
drafts: [] as GeschichteSummary[],
|
||||
error: 'Daten konnten nicht geladen werden.' as string | null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,6 +47,10 @@ describe('SearchFilterBar – AND/OR tag operator toggle', () => {
|
||||
async function openAdvanced() {
|
||||
const filterBtn = page.getByRole('button', { name: 'Filter', exact: true });
|
||||
await filterBtn.click();
|
||||
// Wait for slide transition to finish before interacting with contents —
|
||||
// clicking during the transition triggers track_reactivity_loss in Svelte 5 async.js
|
||||
// (same guard as the undated-only describe below; this block flaked in CI run 2208).
|
||||
await expect.element(page.getByTestId('undated-only-toggle')).toBeVisible();
|
||||
}
|
||||
|
||||
it('hides AND/OR toggle when fewer than 2 tags are selected', async () => {
|
||||
@@ -132,6 +136,9 @@ describe('SearchFilterBar – undated-only toggle (#668)', () => {
|
||||
async function openAdvanced() {
|
||||
const filterBtn = page.getByRole('button', { name: 'Filter', exact: true });
|
||||
await filterBtn.click();
|
||||
// Wait for slide transition to finish before interacting with contents —
|
||||
// clicking during the transition triggers track_reactivity_loss in Svelte 5 async.js
|
||||
await expect.element(page.getByTestId('undated-only-toggle')).toBeVisible();
|
||||
}
|
||||
|
||||
it('renders the "Nur undatierte" toggle in the advanced row', async () => {
|
||||
|
||||
@@ -6,21 +6,27 @@ import type { PageServerLoad } from './$types';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export const load: PageServerLoad = async ({ url, fetch }) => {
|
||||
const api = createApiClient(fetch);
|
||||
const personIds = url.searchParams.getAll('personId');
|
||||
const documentId = url.searchParams.get('documentId') ?? undefined;
|
||||
const rawDocumentId = url.searchParams.get('documentId');
|
||||
const documentId = rawDocumentId && UUID_PATTERN.test(rawDocumentId) ? rawDocumentId : null;
|
||||
|
||||
const [listResult, ...personResults] = await Promise.all([
|
||||
const [listResult, docResult, ...personResults] = await Promise.all([
|
||||
api.GET('/api/geschichten', {
|
||||
params: {
|
||||
query: {
|
||||
status: 'PUBLISHED',
|
||||
personId: personIds.length ? personIds : undefined,
|
||||
documentId
|
||||
documentId: documentId ?? undefined
|
||||
}
|
||||
}
|
||||
}),
|
||||
documentId
|
||||
? api.GET('/api/documents/{id}', { params: { path: { id: documentId } } })
|
||||
: Promise.resolve(null),
|
||||
...personIds.map((id) => api.GET('/api/persons/{id}', { params: { path: { id } } }))
|
||||
]);
|
||||
|
||||
@@ -32,9 +38,22 @@ export const load: PageServerLoad = async ({ url, fetch }) => {
|
||||
.filter((r) => r && r.response.ok && r.data)
|
||||
.map((r) => r!.data!) as Person[];
|
||||
|
||||
let documentFilter: { id: string; title: string | null } | null = null;
|
||||
if (documentId) {
|
||||
if (docResult && docResult.response.ok && docResult.data) {
|
||||
const doc = docResult.data;
|
||||
documentFilter = {
|
||||
id: documentId,
|
||||
title: doc.title ?? doc.originalFilename ?? null
|
||||
};
|
||||
} else {
|
||||
documentFilter = { id: documentId, title: null };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
geschichten: listResult.data ?? [],
|
||||
personFilters,
|
||||
documentFilter: documentId ?? null
|
||||
documentFilter
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { plainExcerpt } from '$lib/shared/utils/extractText';
|
||||
import { formatDate } from '$lib/shared/utils/date';
|
||||
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
|
||||
import GeschichteListRow from '$lib/geschichte/GeschichteListRow.svelte';
|
||||
import DocumentFilterChip from './DocumentFilterChip.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
@@ -11,18 +11,32 @@ let { data }: { data: PageData } = $props();
|
||||
let showPersonPicker = $state(false);
|
||||
|
||||
const selectedPersonIds = $derived(data.personFilters.map((p) => p.id!));
|
||||
const hasFilters = $derived(data.personFilters.length > 0 || !!data.documentFilter);
|
||||
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[]) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('personId');
|
||||
url.searchParams.delete('documentId');
|
||||
for (const id of personIds) url.searchParams.append('personId', id);
|
||||
return url.pathname + url.search;
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
goto(rebuildUrl([]), { replaceState: true });
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('personId');
|
||||
url.searchParams.delete('documentId');
|
||||
goto(url.pathname + url.search, { replaceState: true });
|
||||
}
|
||||
|
||||
function addPerson(personId: string) {
|
||||
@@ -38,22 +52,16 @@ function removePerson(personId: string) {
|
||||
goto(rebuildUrl(selectedPersonIds.filter((id) => id !== personId)));
|
||||
}
|
||||
|
||||
function authorName(g: { author?: { firstName?: string; lastName?: string; email: string } }) {
|
||||
const a = g.author;
|
||||
if (!a) return '';
|
||||
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
|
||||
return full || a.email || '';
|
||||
}
|
||||
|
||||
function publishedAt(g: { publishedAt?: string }): string | null {
|
||||
if (!g.publishedAt) return null;
|
||||
return formatDate(g.publishedAt.slice(0, 10), 'short');
|
||||
function removeDocument() {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('documentId');
|
||||
goto(url.pathname + url.search);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||
<header class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="font-serif text-3xl font-bold text-ink">{m.geschichten_index_title()}</h1>
|
||||
<div class="mx-auto max-w-7xl px-4 py-8">
|
||||
<header class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="font-serif text-2xl text-ink">{m.geschichten_index_title()}</h1>
|
||||
{#if data.canBlogWrite}
|
||||
<a
|
||||
href="/geschichten/new"
|
||||
@@ -64,86 +72,81 @@ function publishedAt(g: { publishedAt?: string }): string | null {
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- Filter pills -->
|
||||
<div class="mb-6 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={!hasFilters}
|
||||
onclick={clearAll}
|
||||
class="inline-flex h-11 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>
|
||||
|
||||
{#each data.personFilters as p (p.id)}
|
||||
<!-- Editorial list card: filter pills + rows share one surface -->
|
||||
<div class="overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
|
||||
<!-- Filter pills -->
|
||||
<div class="flex flex-wrap items-center gap-2 border-b border-line-2 px-3 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed="true"
|
||||
aria-label={m.geschichten_filter_remove_chip({ name: p.displayName })}
|
||||
onclick={() => removePerson(p.id!)}
|
||||
class="inline-flex h-11 items-center gap-2 rounded-full bg-ink px-3 font-sans text-xs font-bold tracking-wider text-primary-fg uppercase"
|
||||
aria-pressed={!hasFilters}
|
||||
onclick={clearAll}
|
||||
class="inline-flex h-11 items-center rounded-full border border-line px-3 font-sans text-xs font-semibold tracking-wider text-ink-2 uppercase hover:bg-muted aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-fg"
|
||||
>
|
||||
{p.displayName}
|
||||
<span aria-hidden="true">×</span>
|
||||
{m.geschichten_filter_all_pill()}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={showPersonPicker}
|
||||
onclick={() => (showPersonPicker = !showPersonPicker)}
|
||||
class="inline-flex h-11 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}
|
||||
<div class="mb-4">
|
||||
<PersonTypeahead
|
||||
name="filter-person"
|
||||
label={m.geschichten_filter_choose_person()}
|
||||
compact
|
||||
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.personFilters.length > 0}
|
||||
{m.geschichten_empty_for_persons({
|
||||
names: data.personFilters.map((p) => p.displayName).join(' & ')
|
||||
})}
|
||||
{:else}
|
||||
{m.geschichten_empty_no_filter()}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="flex flex-col gap-4">
|
||||
{#each data.geschichten as g (g.id)}
|
||||
<li
|
||||
class="rounded border border-line bg-surface p-5 shadow-sm transition-shadow hover:shadow-md"
|
||||
{#each data.personFilters as p (p.id)}
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed="true"
|
||||
aria-label={m.geschichten_filter_remove_chip({ name: p.displayName })}
|
||||
onclick={() => removePerson(p.id!)}
|
||||
class="inline-flex h-11 items-center gap-1.5 rounded-full border border-primary bg-primary px-3 font-sans text-xs font-semibold tracking-wider text-primary-fg uppercase"
|
||||
>
|
||||
<a href="/geschichten/{g.id}" class="block">
|
||||
<h2 class="mb-1 font-serif text-xl font-bold text-ink">{g.title}</h2>
|
||||
<p class="mb-3 font-sans text-xs text-ink-3">
|
||||
{authorName(g)}
|
||||
{#if publishedAt(g)}· {m.geschichten_published_on({ date: publishedAt(g)! })}{/if}
|
||||
</p>
|
||||
{#if g.body}
|
||||
<p class="font-serif text-base text-ink-2">{plainExcerpt(g.body, 150)}</p>
|
||||
{/if}
|
||||
</a>
|
||||
</li>
|
||||
{p.displayName}
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if data.documentFilter}
|
||||
<DocumentFilterChip
|
||||
id={data.documentFilter.id}
|
||||
title={data.documentFilter.title}
|
||||
onremove={removeDocument}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={showPersonPicker}
|
||||
onclick={() => (showPersonPicker = !showPersonPicker)}
|
||||
class="inline-flex h-11 items-center rounded-full border border-dashed border-line px-3 font-sans text-xs font-semibold text-ink-3 hover:bg-muted"
|
||||
>
|
||||
+ {m.geschichten_filter_choose_person()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showPersonPicker}
|
||||
<div class="border-b border-line-2 px-3 py-3">
|
||||
<PersonTypeahead
|
||||
name="filter-person"
|
||||
label={m.geschichten_filter_choose_person()}
|
||||
compact
|
||||
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}
|
||||
|
||||
<!-- Rows -->
|
||||
{#if data.geschichten.length === 0}
|
||||
<div class="px-4 py-12 text-center font-serif text-sm text-ink-3 italic">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each data.geschichten as g (g.id)}
|
||||
<li class="border-b border-line-2 last:border-b-0">
|
||||
<!-- plaintext for JOURNEY, sanitised-HTML→text for STORY; never {@html} -->
|
||||
<GeschichteListRow geschichte={g} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
35
frontend/src/routes/geschichten/DocumentFilterChip.svelte
Normal file
35
frontend/src/routes/geschichten/DocumentFilterChip.svelte
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
id,
|
||||
title,
|
||||
onremove
|
||||
}: {
|
||||
id: string;
|
||||
title: string | null;
|
||||
onremove: () => void;
|
||||
} = $props();
|
||||
|
||||
const chipLabel = $derived(title ?? id.slice(0, 8));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="inline-flex min-h-11 items-center gap-1.5 rounded-full border border-primary bg-primary px-3 text-primary-fg"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="font-sans text-xs tracking-wider whitespace-nowrap uppercase">
|
||||
{m.geschichten_filter_document_chip()}
|
||||
</span>
|
||||
<span class="line-clamp-2 font-serif italic sm:max-w-[16rem] sm:truncate" title={chipLabel}>
|
||||
{chipLabel}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onremove}
|
||||
aria-label={m.geschichten_filter_remove_document_chip({ title: chipLabel })}
|
||||
class="ml-0.5 flex min-h-[44px] min-w-[44px] items-center justify-center rounded-full focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,87 @@
|
||||
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() }));
|
||||
|
||||
import DocumentFilterChip from './DocumentFilterChip.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const VALID_UUID = '11111111-2222-3333-4444-555555555555';
|
||||
|
||||
describe('DocumentFilterChip', () => {
|
||||
it('renders the resolved document title inside the chip', async () => {
|
||||
render(DocumentFilterChip, {
|
||||
props: {
|
||||
id: VALID_UUID,
|
||||
title: 'Brief an Oma',
|
||||
onremove: vi.fn()
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText(/Brief an Oma/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the prefix label', async () => {
|
||||
render(DocumentFilterChip, {
|
||||
props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() }
|
||||
});
|
||||
|
||||
await expect.element(page.getByText(/Gefiltert nach Brief/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('falls back to short UUID when title is null', async () => {
|
||||
render(DocumentFilterChip, {
|
||||
props: { id: VALID_UUID, title: null, onremove: vi.fn() }
|
||||
});
|
||||
|
||||
await expect.element(page.getByText(/11111111/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('fires onremove when the remove button is clicked', async () => {
|
||||
const onremove = vi.fn();
|
||||
render(DocumentFilterChip, {
|
||||
props: { id: VALID_UUID, title: 'Brief an Oma', onremove }
|
||||
});
|
||||
|
||||
const btn = (await page
|
||||
.getByRole('button', { name: /Brief an Oma aus Filter entfernen/ })
|
||||
.element()) as HTMLElement;
|
||||
btn.click();
|
||||
|
||||
await vi.waitFor(() => expect(onremove).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it('remove button aria-label references the resolved title', async () => {
|
||||
render(DocumentFilterChip, {
|
||||
props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() }
|
||||
});
|
||||
|
||||
const btn = page.getByRole('button', { name: /Brief an Oma aus Filter entfernen/ });
|
||||
await expect.element(btn).toBeVisible();
|
||||
});
|
||||
|
||||
it('title= attribute equals the validated id, not a raw query string', async () => {
|
||||
render(DocumentFilterChip, {
|
||||
props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() }
|
||||
});
|
||||
|
||||
const chip = document.querySelector('[title]');
|
||||
expect(chip?.getAttribute('title')).toBe('Brief an Oma');
|
||||
});
|
||||
|
||||
it('remove button has a minimum 44px touch target', async () => {
|
||||
render(DocumentFilterChip, {
|
||||
props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() }
|
||||
});
|
||||
|
||||
const btn = (await page
|
||||
.getByRole('button', { name: /Brief an Oma aus Filter entfernen/ })
|
||||
.element()) as HTMLElement;
|
||||
expect(btn.className).toMatch(/min-h-\[44px\]|min-h-11/);
|
||||
});
|
||||
});
|
||||
@@ -1,33 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { safeHtml } from '$lib/shared/utils/sanitize';
|
||||
import { formatDate } from '$lib/shared/utils/date';
|
||||
import { formatAuthorDisplayName } from '$lib/geschichte/utils';
|
||||
import { getInitials, personAvatarColor } from '$lib/person/personFormat';
|
||||
import { getConfirmService } from '$lib/shared/services/confirm.svelte';
|
||||
import BackButton from '$lib/shared/primitives/BackButton.svelte';
|
||||
import { csrfFetch } from '$lib/shared/cookies';
|
||||
import { parseBackendError, getErrorMessage } from '$lib/shared/errors';
|
||||
import BackButton from '$lib/shared/primitives/BackButton.svelte';
|
||||
import StoryReader from '$lib/geschichte/StoryReader.svelte';
|
||||
import JourneyReader from '$lib/geschichte/JourneyReader.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const g = $derived(data.geschichte);
|
||||
const sanitized = $derived(safeHtml(g.body));
|
||||
const isJourney = $derived(g.type === 'JOURNEY');
|
||||
|
||||
const publishedAt = $derived.by(() => {
|
||||
if (!g.publishedAt) return null;
|
||||
return formatDate(g.publishedAt.slice(0, 10), 'long');
|
||||
});
|
||||
|
||||
function authorName(): string {
|
||||
const a = g.author;
|
||||
if (!a) return '';
|
||||
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
|
||||
return full || a.email || '';
|
||||
}
|
||||
const authorName = $derived(formatAuthorDisplayName(g.author));
|
||||
|
||||
const confirm = getConfirmService();
|
||||
|
||||
let deleteError = $state<string | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
deleteError = null;
|
||||
const ok = await confirm.confirm({
|
||||
title: m.geschichte_delete_confirm_title(),
|
||||
body: m.geschichte_delete_confirm_body(),
|
||||
@@ -39,105 +41,92 @@ async function handleDelete() {
|
||||
const res = await csrfFetch(`/api/geschichten/${g.id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
goto('/geschichten');
|
||||
} else {
|
||||
const err = await parseBackendError(res);
|
||||
deleteError = getErrorMessage(err?.code);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-3xl px-4 py-8">
|
||||
<div class="mx-auto max-w-7xl px-4 py-8">
|
||||
<div class="mb-6">
|
||||
<BackButton />
|
||||
</div>
|
||||
|
||||
<article aria-labelledby="geschichte-title">
|
||||
<header class="mb-6">
|
||||
<h1 id="geschichte-title" class="mb-3 font-serif text-4xl font-bold text-ink">
|
||||
{g.title}
|
||||
</h1>
|
||||
<p class="font-sans text-sm text-ink-3">
|
||||
{authorName()}
|
||||
{#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if}
|
||||
</p>
|
||||
</header>
|
||||
<article
|
||||
aria-labelledby="geschichte-title"
|
||||
class="rounded-sm border border-line bg-sheet px-5 py-6 shadow-sm sm:px-10 sm:py-10"
|
||||
>
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<header class="mb-6">
|
||||
{#if isJourney}
|
||||
<span
|
||||
class="mb-2 inline-flex rounded-sm border border-journey-border bg-journey-tint px-2 py-px text-xs font-bold tracking-widest text-journey uppercase"
|
||||
>
|
||||
{m.journey_badge_detail()}
|
||||
</span>
|
||||
{/if}
|
||||
<h1 id="geschichte-title" class="mb-4 font-serif text-3xl leading-tight text-ink">
|
||||
{g.title}
|
||||
</h1>
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-line-2 pb-4">
|
||||
{#if authorName}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full font-sans text-xs font-bold text-white"
|
||||
style="background-color: {personAvatarColor(authorName)}"
|
||||
>
|
||||
{getInitials(authorName)}
|
||||
</span>
|
||||
{/if}
|
||||
<div>
|
||||
{#if authorName}
|
||||
<p class="font-sans text-sm leading-tight font-semibold text-ink">{authorName}</p>
|
||||
{/if}
|
||||
{#if publishedAt}
|
||||
<p class="font-sans text-xs text-ink-3">
|
||||
{#if isJourney}
|
||||
{m.journey_compiled_on({ date: publishedAt })}
|
||||
{:else}
|
||||
{m.geschichten_published_on({ date: publishedAt })}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if data.canBlogWrite}
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<a
|
||||
href="/geschichten/{g.id}/edit"
|
||||
class="inline-flex h-11 items-center rounded border border-line bg-surface px-3 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.btn_edit()}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleDelete}
|
||||
class="inline-flex h-11 items-center rounded font-sans text-sm font-medium text-danger hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.btn_delete()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!--
|
||||
Body styles are explicit (no `prose`) so the text uses the full max-w-3xl
|
||||
parent width — Tailwind Typography's default `max-w-prose` clamps to ~65ch
|
||||
and produces a much narrower column inside an already narrow page, which
|
||||
Leonie flagged as unreadable for the senior-author persona.
|
||||
{#if deleteError}
|
||||
<p
|
||||
role="alert"
|
||||
class="mb-4 rounded border border-danger/30 bg-danger/10 px-4 py-3 font-sans text-sm text-danger"
|
||||
>
|
||||
{deleteError}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
Sanitised via safeHtml() (DOMPurify) on render — matches backend OWASP allow-list.
|
||||
-->
|
||||
<div
|
||||
class="font-serif text-lg leading-relaxed text-ink [&_h2]:mt-8 [&_h2]:mb-3 [&_h2]:text-2xl [&_h2]:font-bold [&_h3]:mt-6 [&_h3]:mb-2 [&_h3]:text-xl [&_h3]:font-bold [&_li]:mb-1 [&_ol]:mb-4 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mb-4 [&_ul]:mb-4 [&_ul]:list-disc [&_ul]:pl-6"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html sanitized}
|
||||
{#if isJourney}
|
||||
<JourneyReader geschichte={g} />
|
||||
{:else}
|
||||
<StoryReader geschichte={g} />
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Personen -->
|
||||
{#if g.persons && g.persons.length > 0}
|
||||
<section class="mt-10 border-t border-line pt-6">
|
||||
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
|
||||
{m.geschichten_persons_section()}
|
||||
</h2>
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
{#each g.persons as p (p.id)}
|
||||
<li>
|
||||
<a
|
||||
href="/persons/{p.id}"
|
||||
class="inline-flex items-center rounded-full bg-muted px-3 py-1 font-sans text-sm text-ink hover:bg-accent-bg"
|
||||
>
|
||||
{p.displayName}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Dokumente -->
|
||||
{#if g.documents && g.documents.length > 0}
|
||||
<section class="mt-8 border-t border-line pt-6">
|
||||
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
|
||||
{m.geschichten_documents_section()}
|
||||
</h2>
|
||||
<ul class="flex flex-col gap-2">
|
||||
{#each g.documents as d (d.id)}
|
||||
<li>
|
||||
<a
|
||||
href="/documents/{d.id}"
|
||||
class="block rounded border border-line bg-surface px-4 py-3 font-serif text-base text-ink hover:bg-muted"
|
||||
>
|
||||
{d.title}
|
||||
{#if d.documentDate}
|
||||
<span class="ml-2 font-sans text-xs text-ink-3">
|
||||
{formatDate(d.documentDate, 'short')}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Author actions -->
|
||||
{#if data.canBlogWrite}
|
||||
<div class="mt-10 flex items-center gap-3 border-t border-line pt-6">
|
||||
<a
|
||||
href="/geschichten/{g.id}/edit"
|
||||
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.btn_edit()}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleDelete}
|
||||
class="inline-flex h-11 items-center rounded font-sans text-sm font-medium text-danger hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.btn_delete()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import GeschichteEditor from '$lib/geschichte/GeschichteEditor.svelte';
|
||||
import JourneyEditor from '$lib/geschichte/JourneyEditor.svelte';
|
||||
import BackButton from '$lib/shared/primitives/BackButton.svelte';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import { csrfFetch } from '$lib/shared/cookies';
|
||||
@@ -12,12 +13,13 @@ let { data }: { data: PageData } = $props();
|
||||
let submitting = $state(false);
|
||||
let errorMessage: string | null = $state(null);
|
||||
|
||||
const isJourney = $derived(data.geschichte.type === 'JOURNEY');
|
||||
|
||||
async function handleSubmit(payload: {
|
||||
title: string;
|
||||
body: string;
|
||||
status: 'DRAFT' | 'PUBLISHED';
|
||||
personIds: string[];
|
||||
documentIds: string[];
|
||||
}) {
|
||||
submitting = true;
|
||||
errorMessage = null;
|
||||
@@ -30,9 +32,17 @@ async function handleSubmit(payload: {
|
||||
if (!res.ok) {
|
||||
const code = (await res.json().catch(() => ({})))?.code;
|
||||
errorMessage = getErrorMessage(code);
|
||||
return;
|
||||
throw new Error('save failed');
|
||||
}
|
||||
goto(`/geschichten/${data.geschichte.id}`);
|
||||
} catch (e) {
|
||||
if (!errorMessage) {
|
||||
console.error('Geschichte save failed', e);
|
||||
errorMessage = getErrorMessage(undefined);
|
||||
}
|
||||
// Contract: onSubmit rejects on failure — both editors catch and keep
|
||||
// their dirty state instead of disarming the unsaved-changes guard.
|
||||
throw e;
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
@@ -45,7 +55,8 @@ async function handleSubmit(payload: {
|
||||
</div>
|
||||
|
||||
<h1 class="mb-6 font-serif text-3xl font-bold text-ink">
|
||||
{m.btn_edit()}: {data.geschichte.title}
|
||||
{isJourney ? m.journey_edit_title_journey() : m.journey_edit_title_story()}:
|
||||
{data.geschichte.title}
|
||||
</h1>
|
||||
|
||||
{#if errorMessage}
|
||||
@@ -57,5 +68,13 @@ async function handleSubmit(payload: {
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<GeschichteEditor geschichte={data.geschichte} onSubmit={handleSubmit} submitting={submitting} />
|
||||
{#if isJourney}
|
||||
<JourneyEditor geschichte={data.geschichte} onSubmit={handleSubmit} submitting={submitting} />
|
||||
{:else}
|
||||
<GeschichteEditor
|
||||
geschichte={data.geschichte}
|
||||
onSubmit={handleSubmit}
|
||||
submitting={submitting}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
vi.mock('$app/navigation', () => ({
|
||||
beforeNavigate: () => {},
|
||||
@@ -21,13 +22,20 @@ const { default: GeschichtenEditPage } = await import('./+page.svelte');
|
||||
afterEach(cleanup);
|
||||
|
||||
const baseData = (overrides: Record<string, unknown> = {}) => ({
|
||||
user: undefined,
|
||||
canWrite: true,
|
||||
canAnnotate: false,
|
||||
canBlogWrite: true,
|
||||
geschichte: {
|
||||
id: 'g1',
|
||||
title: 'Die Reise nach Berlin',
|
||||
body: '<p>Im Jahr 1923...</p>',
|
||||
status: 'PUBLISHED' as 'DRAFT' | 'PUBLISHED',
|
||||
type: 'STORY' as 'STORY' | 'JOURNEY',
|
||||
persons: [],
|
||||
documents: []
|
||||
items: [],
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00'
|
||||
},
|
||||
...overrides
|
||||
});
|
||||
@@ -60,4 +68,50 @@ describe('geschichten/[id]/edit page', () => {
|
||||
const inputs = document.querySelectorAll('input, textarea, [contenteditable]');
|
||||
expect(inputs.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders the JourneyEditor (add-bar, no TipTap toolbar) for JOURNEY-type geschichten', async () => {
|
||||
render(GeschichtenEditPage, {
|
||||
props: {
|
||||
data: baseData({
|
||||
geschichte: {
|
||||
id: 'g1',
|
||||
title: 'Die Reise nach Berlin',
|
||||
body: '',
|
||||
status: 'DRAFT' as const,
|
||||
type: 'JOURNEY' as const,
|
||||
persons: [],
|
||||
items: [],
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00'
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText(m.journey_add_document())).toBeVisible();
|
||||
expect(document.querySelector('[role="toolbar"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the GeschichteEditor (TipTap toolbar, no add-bar) for STORY-type geschichten', async () => {
|
||||
render(GeschichtenEditPage, {
|
||||
props: {
|
||||
data: baseData({
|
||||
geschichte: {
|
||||
id: 'g1',
|
||||
title: 'Die Reise nach Berlin',
|
||||
body: '<p>Im Jahr 1923...</p>',
|
||||
status: 'DRAFT' as const,
|
||||
type: 'STORY' as const,
|
||||
persons: [],
|
||||
items: [],
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00'
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByRole('toolbar')).toBeVisible();
|
||||
await expect.element(page.getByText(m.journey_add_document())).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,29 +1,58 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
|
||||
vi.mock('$app/navigation', () => ({
|
||||
beforeNavigate: () => {},
|
||||
afterNavigate: () => {},
|
||||
goto: vi.fn(),
|
||||
invalidate: vi.fn(),
|
||||
invalidateAll: vi.fn(),
|
||||
preloadCode: vi.fn(),
|
||||
preloadData: vi.fn(),
|
||||
pushState: vi.fn(),
|
||||
replaceState: vi.fn(),
|
||||
disableScrollHandling: vi.fn(),
|
||||
onNavigate: () => () => {}
|
||||
}));
|
||||
|
||||
vi.mock('$lib/shared/cookies', () => ({
|
||||
csrfFetch: vi.fn()
|
||||
}));
|
||||
|
||||
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
|
||||
import { csrfFetch } from '$lib/shared/cookies';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
const { default: GeschichtePage } = await import('./+page.svelte');
|
||||
|
||||
afterEach(cleanup);
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const baseGeschichte = (overrides: Record<string, unknown> = {}) => ({
|
||||
type GeschichteView = components['schemas']['GeschichteView'];
|
||||
|
||||
const baseGeschichte = (overrides: Partial<GeschichteView> = {}): GeschichteView => ({
|
||||
id: 'g1',
|
||||
title: 'Die Reise nach Berlin',
|
||||
body: '<p>Im Jahr 1923 fuhr Helene...</p>',
|
||||
publishedAt: '2026-04-15T10:00:00Z' as string | null,
|
||||
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'anna@example.com' } as {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email: string;
|
||||
} | null,
|
||||
persons: [] as { id: string; displayName: string }[],
|
||||
documents: [] as { id: string; title: string; documentDate?: string | null }[],
|
||||
type: 'STORY',
|
||||
status: 'PUBLISHED',
|
||||
author: { id: 'u1', displayName: 'Anna Schmidt' },
|
||||
persons: [],
|
||||
items: [],
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
publishedAt: '2026-04-15T10:00:00Z',
|
||||
...overrides
|
||||
});
|
||||
|
||||
const baseData = (overrides: Record<string, unknown> = {}) => ({
|
||||
user: undefined,
|
||||
canWrite: false,
|
||||
canAnnotate: false,
|
||||
geschichte: baseGeschichte(),
|
||||
canBlogWrite: false,
|
||||
...overrides
|
||||
@@ -41,6 +70,46 @@ describe('geschichten/[id] page', () => {
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
it('spans the directory width with a centered reading column inside the sheet (#799)', async () => {
|
||||
render(GeschichtePage, {
|
||||
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
||||
props: { data: baseData() }
|
||||
});
|
||||
|
||||
const outer = document.querySelector('[class*="mx-auto"]');
|
||||
expect(outer!.className).toContain('max-w-7xl');
|
||||
|
||||
const column = document.querySelector('article [class*="max-w-3xl"]');
|
||||
expect(column).not.toBeNull();
|
||||
expect(column!.className).toContain('mx-auto');
|
||||
});
|
||||
|
||||
it('renders the article on a reading-sheet surface card (#797)', async () => {
|
||||
render(GeschichtePage, {
|
||||
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
||||
props: { data: baseData() }
|
||||
});
|
||||
|
||||
const article = document.querySelector('article');
|
||||
expect(article).not.toBeNull();
|
||||
// bg-sheet sits between the sand canvas and the white cards inside the article
|
||||
for (const cls of ['bg-sheet', 'border-line', 'rounded-sm', 'shadow-sm']) {
|
||||
expect(article!.className).toContain(cls);
|
||||
}
|
||||
});
|
||||
|
||||
it('journey badge uses mode-aware journey tokens, not raw orange utilities (#801)', async () => {
|
||||
render(GeschichtePage, {
|
||||
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
||||
props: { data: baseData({ geschichte: baseGeschichte({ type: 'JOURNEY' }) }) }
|
||||
});
|
||||
|
||||
const badge = document.querySelector('h1')!.parentElement!.querySelector('span');
|
||||
expect(badge!.className).toContain('bg-journey-tint');
|
||||
expect(badge!.className).toContain('text-journey');
|
||||
expect(badge!.className).not.toContain('bg-orange-50');
|
||||
});
|
||||
|
||||
it('renders the author full name from firstName + lastName', async () => {
|
||||
render(GeschichtePage, {
|
||||
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
||||
@@ -50,14 +119,12 @@ describe('geschichten/[id] page', () => {
|
||||
await expect.element(page.getByText(/Anna Schmidt/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('falls back to author email when no name is set', async () => {
|
||||
it('renders the server-computed author displayName verbatim', async () => {
|
||||
render(GeschichtePage, {
|
||||
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
||||
props: {
|
||||
data: baseData({
|
||||
geschichte: baseGeschichte({
|
||||
author: { firstName: undefined, lastName: undefined, email: 'fallback@example.com' }
|
||||
})
|
||||
geschichte: baseGeschichte({ author: { id: 'u2', displayName: 'fallback@example.com' } })
|
||||
})
|
||||
}
|
||||
});
|
||||
@@ -65,10 +132,10 @@ describe('geschichten/[id] page', () => {
|
||||
await expect.element(page.getByText(/fallback@example.com/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders an empty author when author is null', async () => {
|
||||
it('renders an empty author when author is absent', async () => {
|
||||
render(GeschichtePage, {
|
||||
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
||||
props: { data: baseData({ geschichte: baseGeschichte({ author: null }) }) }
|
||||
props: { data: baseData({ geschichte: baseGeschichte({ author: undefined }) }) }
|
||||
});
|
||||
|
||||
await expect.element(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||
@@ -86,7 +153,9 @@ describe('geschichten/[id] page', () => {
|
||||
it('omits the publishedAt suffix when publishedAt is null', async () => {
|
||||
render(GeschichtePage, {
|
||||
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
||||
props: { data: baseData({ geschichte: baseGeschichte({ publishedAt: null }) }) }
|
||||
props: {
|
||||
data: baseData({ geschichte: baseGeschichte({ publishedAt: undefined }) })
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText(/veröffentlicht am/i)).not.toBeInTheDocument();
|
||||
@@ -108,8 +177,8 @@ describe('geschichten/[id] page', () => {
|
||||
data: baseData({
|
||||
geschichte: baseGeschichte({
|
||||
persons: [
|
||||
{ id: 'p1', displayName: 'Helene Schmidt' },
|
||||
{ id: 'p2', displayName: 'Karl Müller' }
|
||||
{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' },
|
||||
{ id: 'p2', firstName: 'Karl', lastName: 'Müller' }
|
||||
]
|
||||
})
|
||||
})
|
||||
@@ -130,13 +199,20 @@ describe('geschichten/[id] page', () => {
|
||||
await expect.element(page.getByText('Erwähnte Dokumente')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the documents section when there are linked documents', async () => {
|
||||
it('renders the documents section when there are linked journey items', async () => {
|
||||
render(GeschichtePage, {
|
||||
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
||||
props: {
|
||||
data: baseData({
|
||||
geschichte: baseGeschichte({
|
||||
documents: [{ id: 'd1', title: 'Brief 1923', documentDate: '1923-04-15' }]
|
||||
items: [
|
||||
{
|
||||
id: 'item1',
|
||||
position: 0,
|
||||
document: { id: 'd1', title: 'Brief 1923', datePrecision: 'DAY', receiverCount: 0 },
|
||||
note: 'Brief aus 1923'
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -144,6 +220,27 @@ describe('geschichten/[id] page', () => {
|
||||
|
||||
await expect.element(page.getByText('Erwähnte Dokumente')).toBeVisible();
|
||||
await expect.element(page.getByText('Brief 1923')).toBeVisible();
|
||||
await expect.element(page.getByText('Brief aus 1923')).toBeVisible();
|
||||
expect(document.querySelector('a[href="/documents/d1"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('JOURNEY shows "zusammengestellt am" instead of "veröffentlicht am"', async () => {
|
||||
render(GeschichtePage, {
|
||||
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
||||
props: { data: baseData({ geschichte: baseGeschichte({ type: 'JOURNEY' }) }) }
|
||||
});
|
||||
|
||||
await expect.element(page.getByText(/zusammengestellt am/i)).toBeVisible();
|
||||
await expect.element(page.getByText(/veröffentlicht am/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the author avatar initials in the meta bar', async () => {
|
||||
render(GeschichtePage, {
|
||||
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
||||
props: { data: baseData() }
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('AS', { exact: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders edit and delete actions when canBlogWrite is true', async () => {
|
||||
@@ -167,4 +264,77 @@ describe('geschichten/[id] page', () => {
|
||||
await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument();
|
||||
await expect.element(page.getByRole('button', { name: /löschen/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('STORY with items:[] renders rich-text body and no empty-state message', async () => {
|
||||
render(GeschichtePage, {
|
||||
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
||||
props: { data: baseData({ geschichte: baseGeschichte({ type: 'STORY', items: [] }) }) }
|
||||
});
|
||||
|
||||
await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible();
|
||||
await expect.element(page.getByText(/Diese Lesereise ist noch leer/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('type:undefined + non-empty body renders StoryReader and no empty-state', async () => {
|
||||
render(GeschichtePage, {
|
||||
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
||||
props: {
|
||||
data: baseData({
|
||||
geschichte: baseGeschichte({
|
||||
type: undefined as unknown as 'STORY' | 'JOURNEY',
|
||||
items: []
|
||||
})
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible();
|
||||
await expect.element(page.getByText(/Diese Lesereise ist noch leer/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('delete success: navigates to /geschichten after confirmed DELETE returns ok', async () => {
|
||||
vi.mocked(csrfFetch).mockResolvedValue(new Response(null, { status: 200 }));
|
||||
const confirmService = createConfirmService();
|
||||
render(GeschichtePage, {
|
||||
context: new Map([[CONFIRM_KEY, confirmService]]),
|
||||
props: { data: baseData({ canBlogWrite: true }) }
|
||||
});
|
||||
|
||||
// Trigger delete — opens confirm dialog
|
||||
const deleteBtn = page.getByRole('button', { name: /löschen/i });
|
||||
await userEvent.click(deleteBtn);
|
||||
|
||||
// Settle the confirmation dialog
|
||||
confirmService.settle(true);
|
||||
|
||||
// Wait for the async delete to complete, then check goto was called
|
||||
await vi.waitFor(() => {
|
||||
expect(vi.mocked(goto)).toHaveBeenCalledWith('/geschichten');
|
||||
});
|
||||
});
|
||||
|
||||
it('delete failure: shows error message when DELETE returns non-ok', async () => {
|
||||
vi.mocked(csrfFetch).mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: 'FORBIDDEN' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
);
|
||||
const confirmService = createConfirmService();
|
||||
render(GeschichtePage, {
|
||||
context: new Map([[CONFIRM_KEY, confirmService]]),
|
||||
props: { data: baseData({ canBlogWrite: true }) }
|
||||
});
|
||||
|
||||
// Trigger delete — opens confirm dialog
|
||||
const deleteBtn = page.getByRole('button', { name: /löschen/i });
|
||||
await userEvent.click(deleteBtn);
|
||||
|
||||
// Settle the confirmation dialog
|
||||
confirmService.settle(true);
|
||||
|
||||
// Wait for the error to appear inline
|
||||
await expect.element(page.getByRole('alert')).toBeVisible();
|
||||
expect(vi.mocked(goto)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,24 +10,21 @@ export const load: PageServerLoad = async ({ url, fetch, parent }) => {
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const personId = url.searchParams.get('personId');
|
||||
const documentId = url.searchParams.get('documentId');
|
||||
|
||||
const [personResult, documentResult] = await Promise.all([
|
||||
personId
|
||||
? api.GET('/api/persons/{id}', { params: { path: { id: personId } } })
|
||||
: Promise.resolve(null),
|
||||
documentId
|
||||
? api.GET('/api/documents/{id}', { params: { path: { id: documentId } } })
|
||||
: Promise.resolve(null)
|
||||
]);
|
||||
const personResult = personId
|
||||
? await api.GET('/api/persons/{id}', { params: { path: { id: personId } } })
|
||||
: null;
|
||||
|
||||
// Silently ignore 404/403 to avoid leaking entity existence on unknown IDs.
|
||||
const initialPersons =
|
||||
personResult && personResult.response.ok && personResult.data ? [personResult.data] : [];
|
||||
const initialDocuments =
|
||||
documentResult && documentResult.response.ok && documentResult.data
|
||||
? [documentResult.data]
|
||||
: [];
|
||||
|
||||
return { initialPersons, initialDocuments };
|
||||
// Validate ?type against the known union — prevents unexpected strings from reaching the API.
|
||||
// Security note: strict equality rejects encoded variants (e.g. STORY%00JOURNEY) and
|
||||
// only the FIRST value is returned by searchParams.get() on repeated params.
|
||||
const rawType = url.searchParams.get('type');
|
||||
const selectedType: 'STORY' | 'JOURNEY' | null =
|
||||
rawType === 'STORY' || rawType === 'JOURNEY' ? rawType : null;
|
||||
|
||||
return { initialPersons, selectedType };
|
||||
};
|
||||
|
||||
@@ -1,43 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import GeschichteEditor from '$lib/geschichte/GeschichteEditor.svelte';
|
||||
import BackButton from '$lib/shared/primitives/BackButton.svelte';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import { csrfFetch } from '$lib/shared/cookies';
|
||||
import TypeSelector from './TypeSelector.svelte';
|
||||
import StoryCreate from './StoryCreate.svelte';
|
||||
import JourneyCreate from './JourneyCreate.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let submitting = $state(false);
|
||||
let errorMessage: string | null = $state(null);
|
||||
|
||||
async function handleSubmit(payload: {
|
||||
title: string;
|
||||
body: string;
|
||||
status: 'DRAFT' | 'PUBLISHED';
|
||||
personIds: string[];
|
||||
documentIds: string[];
|
||||
}) {
|
||||
submitting = true;
|
||||
errorMessage = null;
|
||||
try {
|
||||
const res = await csrfFetch('/api/geschichten', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const code = (await res.json().catch(() => ({})))?.code;
|
||||
errorMessage = getErrorMessage(code);
|
||||
return;
|
||||
}
|
||||
const created = await res.json();
|
||||
goto(`/geschichten/${created.id}`);
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-5xl px-4 py-8">
|
||||
@@ -47,19 +17,11 @@ async function handleSubmit(payload: {
|
||||
|
||||
<h1 class="mb-6 font-serif text-3xl font-bold text-ink">{m.geschichten_new_button()}</h1>
|
||||
|
||||
{#if errorMessage}
|
||||
<div
|
||||
class="mb-4 rounded border border-danger bg-danger/10 p-3 text-sm text-danger"
|
||||
role="alert"
|
||||
>
|
||||
{errorMessage}
|
||||
</div>
|
||||
{#if data.selectedType === 'STORY'}
|
||||
<StoryCreate initialPersons={data.initialPersons} />
|
||||
{:else if data.selectedType === 'JOURNEY'}
|
||||
<JourneyCreate />
|
||||
{:else}
|
||||
<TypeSelector onweiter={(type) => goto(`/geschichten/new?type=${type}`)} />
|
||||
{/if}
|
||||
|
||||
<GeschichteEditor
|
||||
initialPersons={data.initialPersons}
|
||||
initialDocuments={data.initialDocuments}
|
||||
onSubmit={handleSubmit}
|
||||
submitting={submitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
90
frontend/src/routes/geschichten/new/JourneyCreate.svelte
Normal file
90
frontend/src/routes/geschichten/new/JourneyCreate.svelte
Normal file
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import { csrfFetch } from '$lib/shared/cookies';
|
||||
|
||||
let title = $state('');
|
||||
let titleTouched = $state(false);
|
||||
let submitting = $state(false);
|
||||
let errorMessage: string | null = $state(null);
|
||||
|
||||
const titleEmpty = $derived(title.trim().length === 0);
|
||||
const showTitleError = $derived(titleEmpty && titleTouched);
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
titleTouched = true;
|
||||
if (titleEmpty) return;
|
||||
|
||||
submitting = true;
|
||||
errorMessage = null;
|
||||
try {
|
||||
const res = await csrfFetch('/api/geschichten', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: title.trim(),
|
||||
type: 'JOURNEY',
|
||||
status: 'DRAFT',
|
||||
body: '',
|
||||
personIds: []
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const code = (await res.json().catch(() => ({})))?.code;
|
||||
errorMessage = getErrorMessage(code);
|
||||
return;
|
||||
}
|
||||
const created = await res.json();
|
||||
goto(`/geschichten/${created.id}/edit`);
|
||||
} catch (e) {
|
||||
console.error('JourneyCreate submit failed', e);
|
||||
errorMessage = getErrorMessage(undefined);
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={handleSubmit} class="max-w-lg">
|
||||
{#if errorMessage}
|
||||
<div
|
||||
class="mb-4 rounded border border-danger bg-danger/10 p-3 text-sm text-danger"
|
||||
role="alert"
|
||||
>
|
||||
{errorMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={title}
|
||||
maxlength="255"
|
||||
onblur={() => (titleTouched = true)}
|
||||
placeholder={m.geschichte_editor_title_placeholder()}
|
||||
aria-label={m.journey_title_aria_label()}
|
||||
aria-invalid={showTitleError}
|
||||
class="block w-full rounded border px-3 py-2 font-serif text-lg text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {showTitleError
|
||||
? 'border-danger'
|
||||
: 'border-line'}"
|
||||
/>
|
||||
{#if showTitleError}
|
||||
<p class="mt-1 font-sans text-xs text-danger">{m.geschichte_editor_title_required()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-50"
|
||||
>
|
||||
{m.journey_create_submit()}
|
||||
</button>
|
||||
<a href="/geschichten/new" class="font-sans text-sm text-ink-3 underline hover:text-ink">
|
||||
{m.journey_placeholder_back()}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,89 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
|
||||
vi.mock('$app/navigation', () => ({
|
||||
goto: vi.fn()
|
||||
}));
|
||||
|
||||
const { default: JourneyCreate } = await import('./JourneyCreate.svelte');
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('JourneyCreate — failure path', () => {
|
||||
it('renders the mapped error message when POST /api/geschichten fails with a code', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: vi.fn().mockResolvedValue({ code: 'VALIDATION_ERROR' })
|
||||
})
|
||||
);
|
||||
|
||||
render(JourneyCreate, {});
|
||||
|
||||
await userEvent.fill(
|
||||
page.getByRole('textbox', { name: m.journey_title_aria_label() }),
|
||||
'Meine Lesereise'
|
||||
);
|
||||
await userEvent.click(page.getByRole('button', { name: m.journey_create_submit() }));
|
||||
|
||||
const alert = page.getByRole('alert');
|
||||
await expect.element(alert).toBeInTheDocument();
|
||||
await expect.element(alert).toHaveTextContent(getErrorMessage('VALIDATION_ERROR'));
|
||||
});
|
||||
|
||||
it('navigates to the edit page on success', async () => {
|
||||
const { goto } = await import('$app/navigation');
|
||||
vi.mocked(goto).mockClear();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ id: 'g-new' })
|
||||
})
|
||||
);
|
||||
|
||||
render(JourneyCreate, {});
|
||||
|
||||
await userEvent.fill(
|
||||
page.getByRole('textbox', { name: m.journey_title_aria_label() }),
|
||||
'Meine Lesereise'
|
||||
);
|
||||
await userEvent.click(page.getByRole('button', { name: m.journey_create_submit() }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(goto).toHaveBeenCalledWith('/geschichten/g-new/edit');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an error alert when the network request rejects (no crash)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network down')));
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
render(JourneyCreate, {});
|
||||
|
||||
await userEvent.fill(
|
||||
page.getByRole('textbox', { name: m.journey_title_aria_label() }),
|
||||
'Meine Lesereise'
|
||||
);
|
||||
await userEvent.click(page.getByRole('button', { name: m.journey_create_submit() }));
|
||||
|
||||
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('has an accessible label on the title input', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
render(JourneyCreate, {});
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('textbox', { name: m.journey_title_aria_label() }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
58
frontend/src/routes/geschichten/new/StoryCreate.svelte
Normal file
58
frontend/src/routes/geschichten/new/StoryCreate.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import GeschichteEditor from '$lib/geschichte/GeschichteEditor.svelte';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import { csrfFetch } from '$lib/shared/cookies';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
interface Props {
|
||||
initialPersons: components['schemas']['Person'][];
|
||||
}
|
||||
|
||||
let { initialPersons }: Props = $props();
|
||||
|
||||
let submitting = $state(false);
|
||||
let errorMessage: string | null = $state(null);
|
||||
|
||||
async function handleSubmit(payload: {
|
||||
title: string;
|
||||
body: string;
|
||||
status: 'DRAFT' | 'PUBLISHED';
|
||||
personIds: string[];
|
||||
}) {
|
||||
submitting = true;
|
||||
errorMessage = null;
|
||||
try {
|
||||
const res = await csrfFetch('/api/geschichten', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...payload, type: 'STORY' })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const code = (await res.json().catch(() => ({})))?.code;
|
||||
errorMessage = getErrorMessage(code);
|
||||
throw new Error('create failed');
|
||||
}
|
||||
const created = await res.json();
|
||||
goto(`/geschichten/${created.id}`);
|
||||
} catch (e) {
|
||||
if (!errorMessage) {
|
||||
console.error('Story create failed', e);
|
||||
errorMessage = getErrorMessage(undefined);
|
||||
}
|
||||
// Contract: onSubmit rejects on failure — GeschichteEditor catches and
|
||||
// keeps its dirty state instead of disarming the unsaved-changes guard.
|
||||
throw e;
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if errorMessage}
|
||||
<div class="mb-4 rounded border border-danger bg-danger/10 p-3 text-sm text-danger" role="alert">
|
||||
{errorMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<GeschichteEditor initialPersons={initialPersons} onSubmit={handleSubmit} submitting={submitting} />
|
||||
@@ -0,0 +1,16 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
|
||||
import StoryCreate from './StoryCreate.svelte';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('StoryCreate — document panel guard (#795)', () => {
|
||||
it('renders without the document panel — documents attach after the first save', async () => {
|
||||
render(StoryCreate, { initialPersons: [] });
|
||||
|
||||
expect(document.body.textContent).not.toContain(m.geschichte_documents_heading());
|
||||
});
|
||||
});
|
||||
97
frontend/src/routes/geschichten/new/TypeSelector.svelte
Normal file
97
frontend/src/routes/geschichten/new/TypeSelector.svelte
Normal file
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { radioGroupNav } from '$lib/shared/actions/radioGroupNav';
|
||||
|
||||
type GeschichteType = 'STORY' | 'JOURNEY';
|
||||
|
||||
const TYPES: GeschichteType[] = ['STORY', 'JOURNEY'];
|
||||
|
||||
interface Props {
|
||||
onweiter: (type: GeschichteType) => void;
|
||||
}
|
||||
|
||||
let { onweiter }: Props = $props();
|
||||
|
||||
let selected = $state<GeschichteType | null>(null);
|
||||
let announcement = $state('');
|
||||
|
||||
// Roving-tabindex holder: falls back to the first card so keyboard nav can start
|
||||
// even when nothing is selected (all cards at tabindex=-1 would be a keyboard dead-spot).
|
||||
const rovingFocusType = $derived(selected ?? TYPES[0]);
|
||||
|
||||
function select(type: GeschichteType) {
|
||||
selected = type;
|
||||
announcement = '';
|
||||
}
|
||||
|
||||
function handleWeiter() {
|
||||
if (!selected) {
|
||||
announcement = m.journey_selector_aria_live_hint();
|
||||
return;
|
||||
}
|
||||
onweiter(selected);
|
||||
}
|
||||
|
||||
const titles: Record<GeschichteType, () => string> = {
|
||||
STORY: m.journey_selector_story_title,
|
||||
JOURNEY: m.journey_selector_journey_title
|
||||
};
|
||||
|
||||
const descs: Record<GeschichteType, () => string> = {
|
||||
STORY: m.journey_selector_story_desc,
|
||||
JOURNEY: m.journey_selector_journey_desc
|
||||
};
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<p id="type-selector-label" class="mb-4 font-sans text-base font-medium text-ink">
|
||||
{m.journey_selector_question()}
|
||||
</p>
|
||||
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-labelledby="type-selector-label"
|
||||
class="grid grid-cols-1 gap-4 sm:grid-cols-2"
|
||||
use:radioGroupNav={(v) => {
|
||||
if (TYPES.includes(v as GeschichteType)) select(v as GeschichteType);
|
||||
}}
|
||||
>
|
||||
{#each TYPES as type (type)}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
value={type}
|
||||
aria-checked={selected === type}
|
||||
tabindex={type === rovingFocusType ? 0 : -1}
|
||||
onclick={() => select(type)}
|
||||
class="min-h-[64px] cursor-pointer rounded border px-4 py-3 text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {selected === type
|
||||
? 'border-primary bg-primary text-primary-fg'
|
||||
: 'border-line bg-surface text-ink hover:border-primary/50'}"
|
||||
>
|
||||
<span class="block font-sans text-sm font-bold">{titles[type]()}</span>
|
||||
<span class="mt-1 block font-sans text-xs text-current opacity-70">{descs[type]()}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only">{announcement}</div>
|
||||
|
||||
{#if !selected}
|
||||
<p id="type-hint" class="mt-3 font-sans text-xs text-ink-3" aria-hidden="true">
|
||||
{m.journey_selector_aria_live_hint()}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-disabled={selected == null ? 'true' : 'false'}
|
||||
aria-describedby={selected == null ? 'type-hint' : undefined}
|
||||
tabindex="0"
|
||||
onclick={handleWeiter}
|
||||
class="mt-6 inline-flex h-11 items-center rounded px-6 font-sans text-sm font-medium transition-opacity focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {selected == null
|
||||
? 'cursor-not-allowed bg-primary text-primary-fg opacity-50'
|
||||
: 'bg-primary text-primary-fg hover:opacity-90'}"
|
||||
>
|
||||
{m.journey_selector_next_btn()}
|
||||
</button>
|
||||
</div>
|
||||
123
frontend/src/routes/geschichten/new/TypeSelector.svelte.spec.ts
Normal file
123
frontend/src/routes/geschichten/new/TypeSelector.svelte.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
|
||||
vi.mock('$app/navigation', () => ({
|
||||
goto: vi.fn()
|
||||
}));
|
||||
|
||||
const { default: TypeSelector } = await import('./TypeSelector.svelte');
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('TypeSelector', () => {
|
||||
it('renders both type cards', async () => {
|
||||
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||
|
||||
await expect.element(page.getByRole('radio', { name: /Geschichte/i })).toBeVisible();
|
||||
await expect.element(page.getByRole('radio', { name: /Lesereise/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it('radiogroup is correctly labelled', async () => {
|
||||
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||
|
||||
const group = document.querySelector('[role="radiogroup"]');
|
||||
const labelledBy = group?.getAttribute('aria-labelledby');
|
||||
const labelEl = labelledBy ? document.getElementById(labelledBy) : null;
|
||||
expect(labelEl?.textContent?.trim().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('Weiter button has aria-disabled=true when nothing is selected', async () => {
|
||||
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||
|
||||
const weiter = document.querySelector('button[type="button"]:not([role="radio"])');
|
||||
expect(weiter?.getAttribute('aria-disabled')).toBe('true');
|
||||
});
|
||||
|
||||
it('no card is aria-checked when nothing is selected', async () => {
|
||||
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||
|
||||
const radios = Array.from(document.querySelectorAll('[role="radio"]'));
|
||||
const anyChecked = radios.some((r) => r.getAttribute('aria-checked') === 'true');
|
||||
expect(anyChecked).toBe(false);
|
||||
});
|
||||
|
||||
it('with no selection: first card has tabindex=0, second has tabindex=-1', async () => {
|
||||
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||
|
||||
const radios = Array.from(document.querySelectorAll('[role="radio"]'));
|
||||
expect(radios[0]?.getAttribute('tabindex')).toBe('0');
|
||||
expect(radios[1]?.getAttribute('tabindex')).toBe('-1');
|
||||
});
|
||||
|
||||
it('clicking STORY card sets aria-checked=true and enables Weiter', async () => {
|
||||
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||
|
||||
const storyCard = page.getByRole('radio', { name: /Geschichte/i });
|
||||
await userEvent.click(storyCard);
|
||||
|
||||
await expect.element(storyCard).toHaveAttribute('aria-checked', 'true');
|
||||
const weiter = document.querySelector('button[type="button"]:not([role="radio"])');
|
||||
expect(weiter?.getAttribute('aria-disabled')).toBe('false');
|
||||
});
|
||||
|
||||
it('clicking JOURNEY card sets aria-checked=true', async () => {
|
||||
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||
|
||||
const journeyCard = page.getByRole('radio', { name: /Lesereise/i });
|
||||
await userEvent.click(journeyCard);
|
||||
|
||||
await expect.element(journeyCard).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('clicking Weiter after selection calls onweiter with the selected type', async () => {
|
||||
const onweiter = vi.fn();
|
||||
render(TypeSelector, { props: { onweiter } });
|
||||
|
||||
await userEvent.click(page.getByRole('radio', { name: /Geschichte/i }));
|
||||
const weiter = page.getByRole('button', { name: /Weiter/i });
|
||||
await userEvent.click(weiter);
|
||||
|
||||
expect(onweiter).toHaveBeenCalledWith('STORY');
|
||||
});
|
||||
|
||||
it('clicking Weiter without selection does NOT call onweiter', async () => {
|
||||
const onweiter = vi.fn();
|
||||
render(TypeSelector, { props: { onweiter } });
|
||||
|
||||
// aria-disabled="true" prevents Playwright actionability — dispatch via DOM to test handler behaviour
|
||||
const weiter = document.querySelector<HTMLButtonElement>('button[aria-disabled="true"]');
|
||||
weiter?.click();
|
||||
|
||||
expect(onweiter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('instructional text is visible when no type is selected', async () => {
|
||||
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||
|
||||
await expect.element(page.getByText(/Bitte wähle einen Typ/i)).toBeVisible();
|
||||
});
|
||||
|
||||
it('ArrowRight moves focus and selection from STORY to JOURNEY', async () => {
|
||||
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||
|
||||
const storyCard = page.getByRole('radio', { name: /Geschichte/i });
|
||||
await userEvent.click(storyCard); // click focuses the card; .focus() is not on vitest-browser Locator
|
||||
await userEvent.keyboard('{ArrowRight}');
|
||||
|
||||
const journeyCard = page.getByRole('radio', { name: /Lesereise/i });
|
||||
await expect.element(journeyCard).toHaveAttribute('aria-checked', 'true');
|
||||
await expect.element(storyCard).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
it('ArrowLeft wraps from STORY back to JOURNEY', async () => {
|
||||
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||
|
||||
const storyCard = page.getByRole('radio', { name: /Geschichte/i });
|
||||
await userEvent.click(storyCard); // click focuses the card; .focus() is not on vitest-browser Locator
|
||||
await userEvent.keyboard('{ArrowLeft}');
|
||||
|
||||
const journeyCard = page.getByRole('radio', { name: /Lesereise/i });
|
||||
await expect.element(journeyCard).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
});
|
||||
77
frontend/src/routes/geschichten/new/page.server.test.ts
Normal file
77
frontend/src/routes/geschichten/new/page.server.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: { API_INTERNAL_URL: 'http://backend:8080' }
|
||||
}));
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({
|
||||
createApiClient: vi.fn(() => ({
|
||||
GET: vi.fn().mockResolvedValue({ response: { ok: false }, data: null })
|
||||
}))
|
||||
}));
|
||||
|
||||
import { load } from './+page.server';
|
||||
|
||||
function makeEvent(search: string, canBlogWrite = true) {
|
||||
return {
|
||||
url: new URL(`http://localhost/geschichten/new${search}`),
|
||||
request: new Request(`http://localhost/geschichten/new${search}`),
|
||||
fetch: vi.fn(),
|
||||
parent: vi.fn().mockResolvedValue({ canBlogWrite })
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe('geschichten/new load — selectedType validation (security regression)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns selectedType: STORY for ?type=STORY', async () => {
|
||||
const result = await load(makeEvent('?type=STORY'));
|
||||
expect(result.selectedType).toBe('STORY');
|
||||
});
|
||||
|
||||
it('returns selectedType: JOURNEY for ?type=JOURNEY', async () => {
|
||||
const result = await load(makeEvent('?type=JOURNEY'));
|
||||
expect(result.selectedType).toBe('JOURNEY');
|
||||
});
|
||||
|
||||
it('returns selectedType: null when ?type param is absent', async () => {
|
||||
const result = await load(makeEvent(''));
|
||||
expect(result.selectedType).toBeNull();
|
||||
});
|
||||
|
||||
it('returns selectedType: null for invalid ?type param (security regression)', async () => {
|
||||
const result = await load(makeEvent('?type=ADMIN'));
|
||||
expect(result.selectedType).toBeNull();
|
||||
});
|
||||
|
||||
it('returns selectedType: null for ?type=STORY%00JOURNEY (null-byte encoded — strict equality rejects it)', async () => {
|
||||
// Strict equality rejects encoded variants; .includes/.startsWith would not.
|
||||
const result = await load(makeEvent('?type=STORY%00JOURNEY'));
|
||||
expect(result.selectedType).toBeNull();
|
||||
});
|
||||
|
||||
it('returns selectedType: STORY for repeated ?type=STORY&type=JOURNEY (first-value semantics — intentional)', async () => {
|
||||
// url.searchParams.get() returns the first value; this is intentional and documented.
|
||||
const result = await load(makeEvent('?type=STORY&type=JOURNEY'));
|
||||
expect(result.selectedType).toBe('STORY');
|
||||
});
|
||||
|
||||
it('returns BOTH selectedType: STORY AND initialPersons when ?type=STORY&personId=p1 (no coupling)', async () => {
|
||||
const { createApiClient } = await import('$lib/shared/api.server');
|
||||
vi.mocked(createApiClient).mockReturnValue({
|
||||
GET: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ response: { ok: true }, data: { id: 'p1', displayName: 'Anna' } })
|
||||
} as never);
|
||||
|
||||
const result = await load(makeEvent('?type=STORY&personId=p1'));
|
||||
expect(result.selectedType).toBe('STORY');
|
||||
expect(result.initialPersons).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('redirects non-BLOG_WRITE users to /geschichten', async () => {
|
||||
await expect(load(makeEvent('', false))).rejects.toMatchObject({ location: '/geschichten' });
|
||||
});
|
||||
});
|
||||
@@ -20,51 +20,87 @@ const { default: GeschichtenNewPage } = await import('./+page.svelte');
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const baseData = {
|
||||
const baseData = (overrides: Record<string, unknown> = {}) => ({
|
||||
initialPersons: [] as { id: string; displayName: string }[],
|
||||
initialDocuments: [] as { id: string; title: string }[]
|
||||
};
|
||||
selectedType: 'STORY' as 'STORY' | 'JOURNEY' | null,
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('geschichten/new page', () => {
|
||||
it('renders the page heading', async () => {
|
||||
render(GeschichtenNewPage, { props: { data: baseData } });
|
||||
render(GeschichtenNewPage, { props: { data: baseData() } });
|
||||
|
||||
await expect.element(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders a button (BackButton component)', async () => {
|
||||
render(GeschichtenNewPage, { props: { data: baseData } });
|
||||
render(GeschichtenNewPage, { props: { data: baseData() } });
|
||||
|
||||
const buttons = document.querySelectorAll('button');
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not render an error banner by default', async () => {
|
||||
render(GeschichtenNewPage, { props: { data: baseData } });
|
||||
render(GeschichtenNewPage, { props: { data: baseData() } });
|
||||
|
||||
expect(document.querySelector('[role="alert"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the GeschichteEditor child component', async () => {
|
||||
render(GeschichtenNewPage, { props: { data: baseData } });
|
||||
it('renders the GeschichteEditor when selectedType is STORY', async () => {
|
||||
render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'STORY' }) } });
|
||||
|
||||
// Editor renders inputs/textarea — verify at least one form input is present
|
||||
const inputs = document.querySelectorAll('input, textarea');
|
||||
expect(inputs.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('passes initialPersons and initialDocuments through to the editor', async () => {
|
||||
it('passes initialPersons through to the editor', async () => {
|
||||
render(GeschichtenNewPage, {
|
||||
props: {
|
||||
data: {
|
||||
initialPersons: [{ id: 'p1', displayName: 'Anna Schmidt' }],
|
||||
initialDocuments: [{ id: 'd1', title: 'Brief 1923' }]
|
||||
}
|
||||
data: baseData({
|
||||
selectedType: 'STORY',
|
||||
initialPersons: [{ id: 'p1', displayName: 'Anna Schmidt' }]
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
// Both should appear somewhere in the rendered editor
|
||||
await expect.element(page.getByText('Anna Schmidt')).toBeVisible();
|
||||
await expect.element(page.getByText('Brief 1923')).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows TypeSelector radiogroup when selectedType is null', async () => {
|
||||
render(GeschichtenNewPage, { props: { data: baseData({ selectedType: null }) } });
|
||||
|
||||
await expect.element(page.getByRole('radiogroup')).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows JourneyCreate form when selectedType is JOURNEY', async () => {
|
||||
render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'JOURNEY' }) } });
|
||||
|
||||
await expect.element(page.getByRole('button', { name: /Lesereise erstellen/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it('JOURNEY create form offers a return-to-selection link', async () => {
|
||||
render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'JOURNEY' }) } });
|
||||
|
||||
const backLink = page.getByRole('link', { name: /andere Auswahl/i });
|
||||
await expect.element(backLink).toBeVisible();
|
||||
await expect.element(backLink).toHaveAttribute('href', '/geschichten/new');
|
||||
});
|
||||
|
||||
it('TypeSelector Weiter calls goto with ?type=STORY on STORY selection', async () => {
|
||||
const { goto } = await import('$app/navigation');
|
||||
vi.mocked(goto).mockClear();
|
||||
|
||||
render(GeschichtenNewPage, { props: { data: baseData({ selectedType: null }) } });
|
||||
|
||||
// Select STORY
|
||||
const storyCard = page.getByRole('radio', { name: /Geschichte/i });
|
||||
await storyCard.click();
|
||||
|
||||
// Click Weiter
|
||||
const weiter = page.getByRole('button', { name: /Weiter/i });
|
||||
await weiter.click();
|
||||
|
||||
expect(goto).toHaveBeenCalledWith('/geschichten/new?type=STORY');
|
||||
});
|
||||
});
|
||||
|
||||
193
frontend/src/routes/geschichten/page.server.test.ts
Normal file
193
frontend/src/routes/geschichten/page.server.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({
|
||||
createApiClient: vi.fn(),
|
||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
||||
}));
|
||||
|
||||
import { load } from './+page.server';
|
||||
import { createApiClient } from '$lib/shared/api.server';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const VALID_UUID = '11111111-2222-3333-4444-555555555555';
|
||||
|
||||
function makeUrl(params: Record<string, string | string[]> = {}) {
|
||||
const url = new URL('http://localhost/geschichten');
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => url.searchParams.append(key, v));
|
||||
} else {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function callLoad(url: URL) {
|
||||
return load({
|
||||
url,
|
||||
request: new Request('http://localhost/geschichten'),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
}
|
||||
|
||||
function mockApi(
|
||||
opts: {
|
||||
listData?: unknown[];
|
||||
docOk?: boolean;
|
||||
docData?: Record<string, unknown> | null;
|
||||
} = {}
|
||||
) {
|
||||
const {
|
||||
listData = [],
|
||||
docOk = true,
|
||||
docData = { id: VALID_UUID, title: 'Brief an Oma', originalFilename: 'brief.jpg' }
|
||||
} = opts;
|
||||
|
||||
const mockGet = vi.fn().mockImplementation((path: string) => {
|
||||
if (path === '/api/documents/{id}') {
|
||||
return Promise.resolve({
|
||||
response: { ok: docOk, status: docOk ? 200 : 404 },
|
||||
data: docOk ? docData : undefined
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
response: { ok: true, status: 200 },
|
||||
data: listData
|
||||
});
|
||||
});
|
||||
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
return mockGet;
|
||||
}
|
||||
|
||||
describe('geschichten page load — documentFilter title resolution', () => {
|
||||
it('resolves document title when documentId is a valid UUID and document exists', async () => {
|
||||
mockApi({ docData: { id: VALID_UUID, title: 'Brief an Oma', originalFilename: 'brief.jpg' } });
|
||||
|
||||
const result = await callLoad(makeUrl({ documentId: VALID_UUID }));
|
||||
|
||||
expect(result.documentFilter).toEqual({ id: VALID_UUID, title: 'Brief an Oma' });
|
||||
});
|
||||
|
||||
it('falls back to originalFilename when document title is null', async () => {
|
||||
mockApi({ docData: { id: VALID_UUID, title: null, originalFilename: 'scan_001.jpg' } });
|
||||
|
||||
const result = await callLoad(makeUrl({ documentId: VALID_UUID }));
|
||||
|
||||
expect(result.documentFilter).toEqual({ id: VALID_UUID, title: 'scan_001.jpg' });
|
||||
});
|
||||
|
||||
it('preserves an empty-string title rather than falling back to filename', async () => {
|
||||
mockApi({ docData: { id: VALID_UUID, title: '', originalFilename: 'scan_001.jpg' } });
|
||||
|
||||
const result = await callLoad(makeUrl({ documentId: VALID_UUID }));
|
||||
|
||||
expect(result.documentFilter).toEqual({ id: VALID_UUID, title: '' });
|
||||
});
|
||||
|
||||
it('degrades to {id, title: null} on 404 without throwing (resolves, never rejects)', async () => {
|
||||
// Explicit .resolves locks the no-throw guarantee — if error() were called, this would reject
|
||||
mockApi({ docOk: false });
|
||||
|
||||
await expect(callLoad(makeUrl({ documentId: VALID_UUID }))).resolves.toMatchObject({
|
||||
documentFilter: { id: VALID_UUID, title: null }
|
||||
});
|
||||
});
|
||||
|
||||
it('treats 403 identically to 404 — no oracle, loader still resolves', async () => {
|
||||
// Permanent regression test: loader must not call getErrorMessage/throw on a forbidden title fetch.
|
||||
// If it did, this assertion would fail with a rejection instead of a resolution.
|
||||
const mockGet = vi.fn().mockImplementation((path: string) => {
|
||||
if (path === '/api/documents/{id}') {
|
||||
return Promise.resolve({ response: { ok: false, status: 403 }, data: undefined });
|
||||
}
|
||||
return Promise.resolve({ response: { ok: true, status: 200 }, data: [] });
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
await expect(callLoad(makeUrl({ documentId: VALID_UUID }))).resolves.toMatchObject({
|
||||
documentFilter: { id: VALID_UUID, title: null }
|
||||
});
|
||||
});
|
||||
|
||||
it('list still populates when title fetch returns 404 (independent results)', async () => {
|
||||
mockApi({
|
||||
listData: [{ id: 'g1', title: 'Some Story' }],
|
||||
docOk: false
|
||||
});
|
||||
|
||||
const result = await callLoad(makeUrl({ documentId: VALID_UUID }));
|
||||
|
||||
expect(result.geschichten).toHaveLength(1);
|
||||
expect(result.documentFilter).toEqual({ id: VALID_UUID, title: null });
|
||||
});
|
||||
|
||||
it('returns null documentFilter when documentId is syntactically invalid', async () => {
|
||||
mockApi();
|
||||
|
||||
const result = await callLoad(makeUrl({ documentId: 'not-a-uuid' }));
|
||||
|
||||
expect(result.documentFilter).toBeNull();
|
||||
});
|
||||
|
||||
it('does not fetch document title when documentId is invalid', async () => {
|
||||
const mockGet = mockApi();
|
||||
|
||||
await callLoad(makeUrl({ documentId: 'not-a-uuid' }));
|
||||
|
||||
expect(mockGet).not.toHaveBeenCalledWith('/api/documents/{id}', expect.anything());
|
||||
});
|
||||
|
||||
it('returns null documentFilter when documentId is absent', async () => {
|
||||
mockApi();
|
||||
|
||||
const result = await callLoad(makeUrl());
|
||||
|
||||
expect(result.documentFilter).toBeNull();
|
||||
});
|
||||
|
||||
it('passes valid documentId to the geschichten API', async () => {
|
||||
const mockGet = mockApi();
|
||||
|
||||
await callLoad(makeUrl({ documentId: VALID_UUID }));
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/api/geschichten',
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
query: expect.objectContaining({ documentId: VALID_UUID })
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('omits documentId from the list API when the value is not a valid UUID', async () => {
|
||||
const mockGet = mockApi();
|
||||
|
||||
await callLoad(makeUrl({ documentId: 'not-a-uuid' }));
|
||||
|
||||
const listCall = mockGet.mock.calls.find((c) => c[0] === '/api/geschichten');
|
||||
expect(listCall?.[1]?.params?.query?.documentId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps forwarding personId filters alongside documentId', async () => {
|
||||
const mockGet = mockApi();
|
||||
|
||||
await callLoad(makeUrl({ documentId: VALID_UUID, personId: [VALID_UUID] }));
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/api/geschichten',
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
query: expect.objectContaining({ documentId: VALID_UUID, personId: [VALID_UUID] })
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,17 @@ function makeData(overrides: Partial<PageData> = {}): 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', () => {
|
||||
it('renders one chip per person in personFilters', async () => {
|
||||
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;
|
||||
expect(url).toContain('personId=b');
|
||||
expect(url).not.toContain('personId=a');
|
||||
@@ -91,6 +105,19 @@ describe('geschichten page — multi-person filter chips', () => {
|
||||
window.history.replaceState({}, '', originalHref);
|
||||
});
|
||||
|
||||
it('JOURNEY row in the list shows the REISE badge (integration: page passes type through)', async () => {
|
||||
render(Page, {
|
||||
data: makeData({
|
||||
geschichten: [
|
||||
{ id: 'g1', title: 'Lesereise Berlin', type: 'JOURNEY' }
|
||||
] as PageData['geschichten']
|
||||
})
|
||||
});
|
||||
|
||||
const badge = document.querySelector('[data-testid="journey-badge"]');
|
||||
expect(badge).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows the "+ Person wählen" button even when filters are already active', async () => {
|
||||
render(Page, {
|
||||
data: makeData({
|
||||
@@ -100,6 +127,207 @@ describe('geschichten page — multi-person filter chips', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
it('removing a person chip preserves an active document filter in the URL', async () => {
|
||||
const { goto } = await import('$app/navigation');
|
||||
vi.mocked(goto).mockClear();
|
||||
|
||||
window.history.replaceState(
|
||||
{},
|
||||
'',
|
||||
'/geschichten?personId=a&documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
);
|
||||
|
||||
render(Page, {
|
||||
data: makeData({
|
||||
personFilters: [person('a', 'Anna A')] as PageData['personFilters'],
|
||||
documentFilter: makeDocumentFilter() as PageData['documentFilter']
|
||||
})
|
||||
});
|
||||
|
||||
const chipBtn = (await page
|
||||
.getByRole('button', { name: /Anna A aus Filter entfernen/ })
|
||||
.element()) as HTMLElement;
|
||||
chipBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce());
|
||||
const url = vi.mocked(goto).mock.calls[0][0] as string;
|
||||
expect(url).not.toContain('personId=a');
|
||||
expect(url).toContain('documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee');
|
||||
|
||||
window.history.replaceState({}, '', '/');
|
||||
});
|
||||
|
||||
it('clearAll removes both person and document filters from the URL', async () => {
|
||||
const { goto } = await import('$app/navigation');
|
||||
vi.mocked(goto).mockClear();
|
||||
|
||||
window.history.replaceState(
|
||||
{},
|
||||
'',
|
||||
'/geschichten?personId=a&documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
);
|
||||
|
||||
render(Page, {
|
||||
data: makeData({
|
||||
personFilters: [person('a', 'Anna A')] as PageData['personFilters'],
|
||||
documentFilter: makeDocumentFilter() as PageData['documentFilter']
|
||||
})
|
||||
});
|
||||
|
||||
const allBtn = (await page.getByRole('button', { name: 'Alle' }).element()) as HTMLElement;
|
||||
allBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce());
|
||||
const url = vi.mocked(goto).mock.calls[0][0] as string;
|
||||
expect(url).not.toContain('personId');
|
||||
expect(url).not.toContain('documentId');
|
||||
|
||||
window.history.replaceState({}, '', '/');
|
||||
});
|
||||
|
||||
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 () => {
|
||||
render(Page, {
|
||||
data: makeData({
|
||||
|
||||
@@ -26,7 +26,7 @@ const baseData = (overrides: Record<string, unknown> = {}) => ({
|
||||
title: string;
|
||||
body?: string;
|
||||
publishedAt?: string;
|
||||
author?: { firstName?: string; lastName?: string; email: string };
|
||||
author?: { firstName?: string; lastName?: string };
|
||||
}>,
|
||||
personFilters: [] as { id?: string; displayName: string }[],
|
||||
documentFilter: null,
|
||||
@@ -35,6 +35,14 @@ const baseData = (overrides: Record<string, unknown> = {}) => ({
|
||||
});
|
||||
|
||||
describe('geschichten/+ page', () => {
|
||||
it('uses the same directory width as Dokumente/Personen overviews (max-w-7xl)', async () => {
|
||||
render(GeschichtenListPage, { props: { data: baseData() } });
|
||||
|
||||
const container = document.querySelector('[class*="mx-auto"]');
|
||||
expect(container).not.toBeNull();
|
||||
expect(container!.className).toContain('max-w-7xl');
|
||||
});
|
||||
|
||||
it('renders the page heading', async () => {
|
||||
render(GeschichtenListPage, { props: { data: baseData() } });
|
||||
|
||||
@@ -127,7 +135,7 @@ describe('geschichten/+ page', () => {
|
||||
title: 'Reise nach Berlin',
|
||||
body: '<p>Im Jahr 1923...</p>',
|
||||
publishedAt: '2026-04-15T10:00:00Z',
|
||||
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' }
|
||||
author: { firstName: 'Anna', lastName: 'Schmidt' }
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -139,7 +147,7 @@ describe('geschichten/+ page', () => {
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
it('authorName falls back to email when first/last names are missing', async () => {
|
||||
it('authorName falls back to [Unbekannt] when first/last names are missing', async () => {
|
||||
render(GeschichtenListPage, {
|
||||
props: {
|
||||
data: baseData({
|
||||
@@ -147,14 +155,14 @@ describe('geschichten/+ page', () => {
|
||||
{
|
||||
id: 'g1',
|
||||
title: 'Anonym',
|
||||
author: { email: 'anon@example.com' }
|
||||
author: {}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain('anon@example.com');
|
||||
expect(document.body.textContent).toContain('[Unbekannt]');
|
||||
});
|
||||
|
||||
it('authorName renders empty when author is undefined', async () => {
|
||||
@@ -178,7 +186,7 @@ describe('geschichten/+ page', () => {
|
||||
{
|
||||
id: 'g1',
|
||||
title: 'Draft',
|
||||
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' }
|
||||
author: { firstName: 'Anna', lastName: 'Schmidt' }
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -188,8 +196,9 @@ describe('geschichten/+ page', () => {
|
||||
// No "·" separator before date when no publishedAt
|
||||
const titleHeading = document.querySelector('h2');
|
||||
const card = titleHeading?.closest('li');
|
||||
// The middle paragraph (author line) should not contain "·"
|
||||
expect(card?.textContent).toContain('Anna Schmidt');
|
||||
// "·" separator must be absent when there is no publishedAt date
|
||||
expect(card?.textContent).not.toContain('·');
|
||||
});
|
||||
|
||||
it('omits the body excerpt when body is empty', async () => {
|
||||
@@ -201,7 +210,7 @@ describe('geschichten/+ page', () => {
|
||||
id: 'g1',
|
||||
title: 'No Body',
|
||||
body: '',
|
||||
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' }
|
||||
author: { firstName: 'Anna', lastName: 'Schmidt' }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
--color-overlay: var(--c-overlay);
|
||||
--color-muted: var(--c-muted);
|
||||
|
||||
/* Reading sheet — article panel between canvas and the white cards it contains */
|
||||
--color-sheet: var(--c-sheet);
|
||||
|
||||
/* Borders */
|
||||
--color-line: var(--c-line);
|
||||
--color-line-2: var(--c-line-2);
|
||||
@@ -77,6 +80,21 @@
|
||||
--color-warning: #b45309;
|
||||
--color-warning-fg: #ffffff;
|
||||
|
||||
/* Warning surface — amber banner (bg/border/text), mode-aware */
|
||||
--color-warning-bg: var(--c-warning-bg);
|
||||
--color-warning-border: var(--c-warning-border);
|
||||
--color-warning-text: var(--c-warning-text);
|
||||
|
||||
/* Journey / Lesereise — orange semantic tokens (badge, interlude block, annotation) */
|
||||
--color-journey-tint: var(--c-journey-bg);
|
||||
--color-journey: var(--c-journey-text);
|
||||
--color-journey-border: var(--c-journey-border);
|
||||
|
||||
/* Interlude row — neutral surface with left accent border; ZWISCHENTEXT label */
|
||||
--color-interlude-bg: var(--c-interlude-bg);
|
||||
--color-interlude-border: var(--c-interlude-border);
|
||||
--color-interlude-label: var(--c-interlude-label);
|
||||
|
||||
/* Static brand tokens (not themed) */
|
||||
--color-brand-navy: var(--palette-navy);
|
||||
--color-brand-mint: var(--palette-mint);
|
||||
@@ -91,6 +109,7 @@
|
||||
--c-surface: #ffffff;
|
||||
--c-overlay: #ffffff;
|
||||
--c-muted: #f5f4ef;
|
||||
--c-sheet: #fafaf7; /* between canvas and surface — spec .g-article value */
|
||||
|
||||
--c-line: #e4e2d7;
|
||||
--c-line-2: #eeede8;
|
||||
@@ -128,6 +147,22 @@
|
||||
/* Parchment — warm near-white for example blocks (light mode: cream #FAF8F1) */
|
||||
--c-parchment: #faf8f1;
|
||||
|
||||
/* Journey / Lesereise — orange semantic tokens
|
||||
Text #7A3F0E on bg #FEF0E6 ≈ 7.4:1 — WCAG AAA ✓ (text-xs requires 4.5:1 normal-text) */
|
||||
--c-journey-bg: #fef0e6;
|
||||
--c-journey-text: #7a3f0e;
|
||||
--c-journey-border: #f0c99a;
|
||||
|
||||
/* Interlude (Zwischentext) — neutral warm surface with left accent border */
|
||||
--c-interlude-bg: #f5f4f0;
|
||||
--c-interlude-border: #a1dcd8;
|
||||
--c-interlude-label: #4b5563;
|
||||
|
||||
/* Warning surface — amber banner; text #92400E on #FFFBEB ≈ 7.7:1 — WCAG AAA ✓ */
|
||||
--c-warning-bg: #fffbeb;
|
||||
--c-warning-border: #fcd34d;
|
||||
--c-warning-text: #92400e;
|
||||
|
||||
/* Tag color tokens — decorative dot colors on tag chips */
|
||||
--c-tag-sage: #5a8a6a;
|
||||
--c-tag-sienna: #a0522d;
|
||||
@@ -182,6 +217,7 @@
|
||||
--c-surface: #011526;
|
||||
--c-overlay: #011e38;
|
||||
--c-muted: #011a30;
|
||||
--c-sheet: #011222; /* between canvas and surface */
|
||||
|
||||
--c-line: #0d3358;
|
||||
--c-line-2: #092843;
|
||||
@@ -246,6 +282,23 @@
|
||||
/* Stammbaum gutter stripe (issue #689) — 14% mint on dark canvas for
|
||||
visibility parity with the 8% light-mode token. Decorative carve-out. */
|
||||
--c-gutter-stripe: rgba(161, 220, 216, 0.14);
|
||||
|
||||
/* Journey / Lesereise — muted warm tint on dark navy; text #E8862A on
|
||||
#3A2A1A ≈ 5.2:1 — WCAG AA ✓ (text-xs requires 4.5:1 normal-text) */
|
||||
--c-journey-bg: #3a2a1a;
|
||||
--c-journey-text: #e8862a;
|
||||
--c-journey-border: #7a4a1e;
|
||||
|
||||
/* Interlude (Zwischentext) — KEEP IN SYNC with :root[data-theme='dark'] */
|
||||
--c-interlude-bg: #151c22;
|
||||
--c-interlude-border: #00c7b1;
|
||||
--c-interlude-label: #8b97a5;
|
||||
|
||||
/* Warning surface — muted amber on dark; text #FBD38D on #2A2113 ≈ 9.5:1 — WCAG AAA ✓
|
||||
KEEP IN SYNC with :root[data-theme='dark'] */
|
||||
--c-warning-bg: #2a2113;
|
||||
--c-warning-border: #6d5417;
|
||||
--c-warning-text: #fbd38d;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +311,7 @@
|
||||
--c-surface: #011526;
|
||||
--c-overlay: #011e38;
|
||||
--c-muted: #011a30;
|
||||
--c-sheet: #011222; /* between canvas and surface */
|
||||
|
||||
--c-line: #0d3358;
|
||||
--c-line-2: #092843;
|
||||
@@ -321,6 +375,21 @@
|
||||
|
||||
/* Stammbaum gutter stripe (issue #689) — KEEP IN SYNC with the @media block. */
|
||||
--c-gutter-stripe: rgba(161, 220, 216, 0.14);
|
||||
|
||||
/* Journey / Lesereise — KEEP IN SYNC with the @media block above */
|
||||
--c-journey-bg: #3a2a1a;
|
||||
--c-journey-text: #e8862a;
|
||||
--c-journey-border: #7a4a1e;
|
||||
|
||||
/* Interlude (Zwischentext) — KEEP IN SYNC with the @media block above */
|
||||
--c-interlude-bg: #151c22;
|
||||
--c-interlude-border: #00c7b1;
|
||||
--c-interlude-label: #8b97a5;
|
||||
|
||||
/* Warning surface — KEEP IN SYNC with the @media block above */
|
||||
--c-warning-bg: #2a2113;
|
||||
--c-warning-border: #6d5417;
|
||||
--c-warning-text: #fbd38d;
|
||||
}
|
||||
|
||||
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */
|
||||
|
||||
Reference in New Issue
Block a user