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:
@@ -5,34 +5,26 @@ import { Editor } from '@tiptap/core';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte';
|
||||
import DocumentMultiSelect from '$lib/document/DocumentMultiSelect.svelte';
|
||||
import GeschichteSidebar from '$lib/geschichte/GeschichteSidebar.svelte';
|
||||
import { toPersonOption, type PersonOption } from '$lib/person/personOption';
|
||||
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
type GeschichteView = components['schemas']['GeschichteView'];
|
||||
type Person = components['schemas']['Person'];
|
||||
type Document = components['schemas']['Document'];
|
||||
|
||||
interface Props {
|
||||
geschichte?: Geschichte | null;
|
||||
geschichte?: GeschichteView | null;
|
||||
initialPersons?: Person[];
|
||||
initialDocuments?: Document[];
|
||||
/** Must reject when the save failed — the editor keeps its dirty state then. */
|
||||
onSubmit: (payload: {
|
||||
title: string;
|
||||
body: string;
|
||||
status: 'DRAFT' | 'PUBLISHED';
|
||||
personIds: string[];
|
||||
documentIds: string[];
|
||||
}) => Promise<void>;
|
||||
submitting?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
geschichte = null,
|
||||
initialPersons = [],
|
||||
initialDocuments = [],
|
||||
onSubmit,
|
||||
submitting = false
|
||||
}: Props = $props();
|
||||
let { geschichte = null, initialPersons = [], onSubmit, submitting = false }: Props = $props();
|
||||
|
||||
// Initial-state snapshot from incoming props. The editor owns these values
|
||||
// after mount; the parent should re-mount the component with a different
|
||||
@@ -41,11 +33,8 @@ let {
|
||||
let title = $state(geschichte?.title ?? '');
|
||||
let body = $state(geschichte?.body ?? '');
|
||||
let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte?.status ?? 'DRAFT');
|
||||
let selectedPersons: Person[] = $state(
|
||||
geschichte?.persons ? Array.from(geschichte.persons) : initialPersons
|
||||
);
|
||||
let selectedDocuments: Document[] = $state(
|
||||
geschichte?.documents ? Array.from(geschichte.documents) : initialDocuments
|
||||
let selectedPersons: PersonOption[] = $state(
|
||||
geschichte?.persons ? Array.from(geschichte.persons).map(toPersonOption) : initialPersons
|
||||
);
|
||||
|
||||
let dirty = $state(false);
|
||||
@@ -118,14 +107,17 @@ function handleTitleInput() {
|
||||
async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
|
||||
titleTouched = true;
|
||||
if (titleEmpty) return;
|
||||
await onSubmit({
|
||||
title: title.trim(),
|
||||
body,
|
||||
status: nextStatus,
|
||||
personIds: selectedPersons.map((p) => p.id!).filter(Boolean),
|
||||
documentIds: selectedDocuments.map((d) => d.id!).filter(Boolean)
|
||||
});
|
||||
dirty = false;
|
||||
try {
|
||||
await onSubmit({
|
||||
title: title.trim(),
|
||||
body,
|
||||
status: nextStatus,
|
||||
personIds: selectedPersons.map((p) => p.id!).filter(Boolean)
|
||||
});
|
||||
dirty = false;
|
||||
} catch {
|
||||
// onSubmit signalled failure — keep dirty so the unsaved guard stays armed
|
||||
}
|
||||
}
|
||||
|
||||
function isActive(name: string, attrs?: Record<string, unknown>): boolean {
|
||||
@@ -148,6 +140,7 @@ function exec(action: () => void) {
|
||||
<input
|
||||
type="text"
|
||||
bind:value={title}
|
||||
maxlength="255"
|
||||
oninput={handleTitleInput}
|
||||
onblur={handleTitleBlur}
|
||||
placeholder={m.geschichte_editor_title_placeholder()}
|
||||
@@ -241,43 +234,12 @@ function exec(action: () => void) {
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="flex flex-col gap-6">
|
||||
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||
<h2 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">Status</h2>
|
||||
<p class="mb-3">
|
||||
<span
|
||||
class="inline-flex items-center rounded px-2 py-1 font-sans text-xs font-bold tracking-widest uppercase {isDraft
|
||||
? 'bg-muted text-ink-2'
|
||||
: 'bg-accent-bg text-ink'}"
|
||||
>
|
||||
{isDraft
|
||||
? m.geschichte_editor_status_draft()
|
||||
: m.geschichte_editor_status_published()}
|
||||
</span>
|
||||
</p>
|
||||
<p class="font-sans text-xs text-ink-3">
|
||||
{isDraft
|
||||
? m.geschichte_editor_status_draft_hint()
|
||||
: m.geschichte_editor_status_published_hint()}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.geschichte_editor_personen_heading()}
|
||||
</h2>
|
||||
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_personen_hint()}</p>
|
||||
<PersonMultiSelect bind:selectedPersons={selectedPersons} />
|
||||
</section>
|
||||
|
||||
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.geschichte_editor_dokumente_heading()}
|
||||
</h2>
|
||||
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_dokumente_hint()}</p>
|
||||
<DocumentMultiSelect bind:selectedDocuments={selectedDocuments} />
|
||||
</section>
|
||||
</aside>
|
||||
<GeschichteSidebar
|
||||
status={status}
|
||||
bind:selectedPersons={selectedPersons}
|
||||
geschichteId={geschichte?.id}
|
||||
items={geschichte?.items ?? []}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Save bar -->
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 GeschichteEditor from './GeschichteEditor.svelte';
|
||||
|
||||
const personFactory = (id: string, displayName: string) => ({
|
||||
@@ -8,19 +9,9 @@ const personFactory = (id: string, displayName: string) => ({
|
||||
firstName: displayName.split(' ')[0],
|
||||
lastName: displayName.split(' ').slice(1).join(' ') || displayName,
|
||||
displayName,
|
||||
personType: 'PERSON' as const
|
||||
});
|
||||
|
||||
const docFactory = (id: string, title: string, date = '1882-01-01') => ({
|
||||
id,
|
||||
title,
|
||||
documentDate: date,
|
||||
originalFilename: `${title}.pdf`,
|
||||
status: 'UPLOADED' as const,
|
||||
metadataComplete: false,
|
||||
scriptType: 'UNKNOWN' as const,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00'
|
||||
personType: 'PERSON' as const,
|
||||
familyMember: false,
|
||||
provisional: false
|
||||
});
|
||||
|
||||
const draftFactory = (overrides: Record<string, unknown> = {}) => ({
|
||||
@@ -28,8 +19,9 @@ const draftFactory = (overrides: Record<string, unknown> = {}) => ({
|
||||
title: 'Existing draft',
|
||||
body: '<p>Hello world</p>',
|
||||
status: 'DRAFT' as const,
|
||||
type: 'STORY' as const,
|
||||
persons: [],
|
||||
documents: [],
|
||||
items: [],
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
...overrides
|
||||
@@ -63,6 +55,22 @@ describe('GeschichteEditor — title-required guard', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GeschichteEditor — onSubmit rejects on failure', () => {
|
||||
it('catches a rejecting onSubmit (no unhandled rejection) and stays editable', async () => {
|
||||
// Contract: onSubmit rejects on failure. Without the catch in save(), this
|
||||
// click would surface as an unhandled promise rejection and fail the run.
|
||||
const onSubmit = vi.fn().mockRejectedValue(new Error('save failed'));
|
||||
render(GeschichteEditor, { geschichte: draftFactory(), onSubmit });
|
||||
|
||||
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
|
||||
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
||||
|
||||
// Editor still functional — a second save attempt goes through
|
||||
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
|
||||
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GeschichteEditor — save bar adapts to status', () => {
|
||||
it('renders DRAFT mode buttons when no geschichte prop is supplied', async () => {
|
||||
render(GeschichteEditor, { onSubmit: vi.fn() });
|
||||
@@ -93,14 +101,6 @@ describe('GeschichteEditor — pre-fill', () => {
|
||||
await expect.element(page.getByText('Franz Raddatz')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders initial documents as chips', async () => {
|
||||
render(GeschichteEditor, {
|
||||
initialDocuments: [docFactory('d1', 'Brief von Eugenie')],
|
||||
onSubmit: vi.fn()
|
||||
});
|
||||
await expect.element(page.getByText(/Brief von Eugenie/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('populates the title input from a geschichte prop', async () => {
|
||||
render(GeschichteEditor, {
|
||||
geschichte: draftFactory({ title: 'My existing story' }),
|
||||
@@ -154,11 +154,10 @@ describe('GeschichteEditor — onSubmit payload', () => {
|
||||
expect(onSubmit.mock.calls[0][0].status).toBe('PUBLISHED');
|
||||
});
|
||||
|
||||
it('passes the personIds and documentIds from initial props through onSubmit', async () => {
|
||||
it('passes personIds from initial props through onSubmit', async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
render(GeschichteEditor, {
|
||||
initialPersons: [personFactory('p1', 'Franz Raddatz')],
|
||||
initialDocuments: [docFactory('d1', 'Brief A')],
|
||||
onSubmit
|
||||
});
|
||||
|
||||
@@ -171,6 +170,38 @@ describe('GeschichteEditor — onSubmit payload', () => {
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
const payload = onSubmit.mock.calls[0][0];
|
||||
expect(payload.personIds).toEqual(['p1']);
|
||||
expect(payload.documentIds).toEqual(['d1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GeschichteEditor — story document panel (#795)', () => {
|
||||
it('shows the document panel with the story items when editing an existing story', async () => {
|
||||
render(GeschichteEditor, {
|
||||
geschichte: draftFactory({
|
||||
items: [
|
||||
{
|
||||
id: 'i1',
|
||||
position: 10,
|
||||
document: {
|
||||
id: 'd1',
|
||||
title: 'Brief von Eugenie',
|
||||
datePrecision: 'DAY' as const,
|
||||
receiverCount: 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}),
|
||||
onSubmit: vi.fn().mockResolvedValue(undefined)
|
||||
});
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('heading', { name: m.geschichte_documents_heading() }))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Brief von Eugenie')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the document panel when no geschichte is set (creation flow)', async () => {
|
||||
render(GeschichteEditor, { onSubmit: vi.fn() });
|
||||
|
||||
expect(document.body.textContent).not.toContain(m.geschichte_documents_heading());
|
||||
});
|
||||
});
|
||||
|
||||
85
frontend/src/lib/geschichte/GeschichteListRow.svelte
Normal file
85
frontend/src/lib/geschichte/GeschichteListRow.svelte
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { plainExcerpt } from '$lib/shared/utils/extractText';
|
||||
import { getInitials, personAvatarColor } from '$lib/person/personFormat';
|
||||
import { formatAuthorName, formatPublishedAt } from './utils';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type GeschichteRow = Pick<
|
||||
components['schemas']['GeschichteSummary'],
|
||||
'id' | 'title' | 'body' | 'type' | 'author' | 'publishedAt'
|
||||
>;
|
||||
|
||||
let { geschichte }: { geschichte: GeschichteRow } = $props();
|
||||
|
||||
const isJourney = $derived(geschichte.type === 'JOURNEY');
|
||||
|
||||
const publishedAt = $derived(formatPublishedAt(geschichte.publishedAt, 'short'));
|
||||
|
||||
const authorName = $derived(formatAuthorName(geschichte.author));
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/geschichten/{geschichte.id}"
|
||||
class="group flex min-h-[44px] transition-colors hover:bg-canvas/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<!-- Meta column (desktop) -->
|
||||
<div class="hidden w-40 shrink-0 flex-col items-start gap-1 border-r border-line-2 p-3 sm:flex">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-full font-sans text-[9px] font-bold text-white"
|
||||
style="background-color: {personAvatarColor(authorName)}"
|
||||
>
|
||||
{getInitials(authorName)}
|
||||
</span>
|
||||
<span class="font-sans text-sm leading-tight font-semibold text-ink">{authorName}</span>
|
||||
{#if publishedAt}
|
||||
<span class="font-sans text-sm text-ink-3">{publishedAt}</span>
|
||||
{/if}
|
||||
{#if isJourney}
|
||||
<span
|
||||
data-testid="journey-badge"
|
||||
class="inline-flex items-center rounded-sm border border-journey-border bg-journey-tint px-1.5 py-px font-sans text-xs font-bold tracking-wide text-journey uppercase"
|
||||
>
|
||||
{m.journey_badge_list()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Content column -->
|
||||
<div class="min-w-0 flex-1 p-3 sm:px-4">
|
||||
<!-- Compact meta line (mobile only) -->
|
||||
<div class="mb-1 flex items-center gap-1.5 sm:hidden">
|
||||
<!-- 7px initials render as smudge at this size — a plain color dot reads better -->
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style="background-color: {personAvatarColor(authorName)}"
|
||||
></span>
|
||||
<span class="font-sans text-sm font-semibold text-ink">{authorName}</span>
|
||||
{#if publishedAt}
|
||||
<span class="ml-auto font-sans text-sm text-ink-3">{publishedAt}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mb-1 flex items-center gap-1.5">
|
||||
<h2 class="font-serif text-lg leading-snug text-ink group-hover:underline">
|
||||
{geschichte.title}
|
||||
</h2>
|
||||
{#if isJourney}
|
||||
<span
|
||||
data-testid="journey-badge-mobile"
|
||||
class="inline-flex shrink-0 items-center rounded-sm border border-journey-border bg-journey-tint px-1.5 py-px font-sans text-xs font-bold tracking-wide text-journey uppercase sm:hidden"
|
||||
>
|
||||
{m.journey_badge_list()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if geschichte.body}
|
||||
<!-- plaintext for JOURNEY, sanitised-HTML→text for STORY; never {@html} -->
|
||||
<p class="line-clamp-2 font-sans text-sm leading-relaxed text-ink-3">
|
||||
{plainExcerpt(geschichte.body, 150)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
94
frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts
Normal file
94
frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
const { default: GeschichteListRow } = await import('./GeschichteListRow.svelte');
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const baseRow = (overrides = {}) => ({
|
||||
id: 'g1',
|
||||
title: 'Die Reise nach Berlin',
|
||||
body: '<p>Im Jahr 1923...</p>',
|
||||
type: 'STORY' as 'STORY' | 'JOURNEY',
|
||||
status: 'PUBLISHED' as 'PUBLISHED' | 'DRAFT',
|
||||
author: { firstName: 'Anna', lastName: 'Schmidt' },
|
||||
publishedAt: '2026-04-15T10:00:00Z',
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('GeschichteListRow', () => {
|
||||
it('renders the title', async () => {
|
||||
render(GeschichteListRow, { props: { geschichte: baseRow() } });
|
||||
await expect
|
||||
.element(page.getByRole('heading', { level: 2 }))
|
||||
.toHaveTextContent('Die Reise nach Berlin');
|
||||
});
|
||||
|
||||
it('row text sizes suit the full-width list: title text-lg, excerpt/meta text-sm (#802)', async () => {
|
||||
render(GeschichteListRow, { props: { geschichte: baseRow() } });
|
||||
|
||||
const title = document.querySelector('h2');
|
||||
expect(title!.className).toContain('text-lg');
|
||||
expect(title!.className).not.toContain('text-[15px]');
|
||||
|
||||
const excerpt = document.querySelector('p');
|
||||
expect(excerpt!.className).toContain('text-sm');
|
||||
expect(excerpt!.className).not.toContain('text-xs');
|
||||
|
||||
const meta = Array.from(document.querySelectorAll('span')).filter((s) =>
|
||||
s.textContent?.includes('Anna Schmidt')
|
||||
);
|
||||
expect(meta.length).toBeGreaterThan(0);
|
||||
for (const span of meta) {
|
||||
expect(span.className).toContain('text-sm');
|
||||
}
|
||||
});
|
||||
|
||||
it('desktop meta column is wide enough for text-sm names (w-40, #802)', async () => {
|
||||
render(GeschichteListRow, { props: { geschichte: baseRow() } });
|
||||
|
||||
const metaColumn = document.querySelector('[class*="border-r"]');
|
||||
expect(metaColumn).not.toBeNull();
|
||||
expect(metaColumn!.className).toContain('w-40');
|
||||
expect(metaColumn!.className).not.toContain('w-28');
|
||||
});
|
||||
|
||||
it('shows no badge for STORY type', async () => {
|
||||
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'STORY' }) } });
|
||||
expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows no badge when type is undefined', async () => {
|
||||
render(GeschichteListRow, {
|
||||
props: { geschichte: baseRow({ type: undefined as unknown as 'STORY' | 'JOURNEY' }) }
|
||||
});
|
||||
expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows REISE badge for JOURNEY type', async () => {
|
||||
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } });
|
||||
const badge = document.querySelector('[data-testid="journey-badge"]');
|
||||
expect(badge).not.toBeNull();
|
||||
expect(badge?.textContent?.trim()).toBe('REISE');
|
||||
});
|
||||
|
||||
it('badge is a plain <span>, not a nested interactive element', async () => {
|
||||
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } });
|
||||
const badge = document.querySelector('[data-testid="journey-badge"]');
|
||||
expect(badge?.tagName.toLowerCase()).toBe('span');
|
||||
});
|
||||
|
||||
it('badge uses the 12px label size — text-xs is the visible-text floor', async () => {
|
||||
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } });
|
||||
const badge = document.querySelector('[data-testid="journey-badge"]');
|
||||
expect(badge!.className).toContain('text-xs');
|
||||
// 10px was below the house floor for the 60+ audience (round-3 review)
|
||||
expect(badge!.className).not.toContain('text-[10px]');
|
||||
});
|
||||
|
||||
it('renders author name in meta line', async () => {
|
||||
render(GeschichteListRow, { props: { geschichte: baseRow() } });
|
||||
expect(document.body.textContent).toContain('Anna Schmidt');
|
||||
});
|
||||
});
|
||||
82
frontend/src/lib/geschichte/GeschichteSidebar.svelte
Normal file
82
frontend/src/lib/geschichte/GeschichteSidebar.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte';
|
||||
import type { PersonOption } from '$lib/person/personOption';
|
||||
import StoryDocumentPanel from './StoryDocumentPanel.svelte';
|
||||
|
||||
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||
|
||||
interface Props {
|
||||
status: 'DRAFT' | 'PUBLISHED';
|
||||
selectedPersons: PersonOption[];
|
||||
/** When set, the story document panel is rendered (STORY edit only). */
|
||||
geschichteId?: string;
|
||||
items?: JourneyItemView[];
|
||||
}
|
||||
|
||||
let {
|
||||
status,
|
||||
selectedPersons = $bindable(),
|
||||
geschichteId = undefined,
|
||||
items = []
|
||||
}: Props = $props();
|
||||
|
||||
const isDraft = $derived(status === 'DRAFT');
|
||||
</script>
|
||||
|
||||
<aside class="flex flex-col gap-6">
|
||||
<!-- Status section -->
|
||||
<details open class="sm:contents">
|
||||
<summary
|
||||
class="flex min-h-[44px] cursor-pointer items-center px-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:hidden"
|
||||
>
|
||||
{m.geschichte_sidebar_status()}
|
||||
</summary>
|
||||
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||
<!-- hidden below sm: the <summary> already shows this label there -->
|
||||
<h2
|
||||
class="mb-1 hidden font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:block"
|
||||
>
|
||||
{m.geschichte_sidebar_status()}
|
||||
</h2>
|
||||
<p class="mb-3">
|
||||
<span
|
||||
class="inline-flex items-center rounded px-2 py-1 font-sans text-xs font-bold tracking-widest uppercase {isDraft
|
||||
? 'bg-muted text-ink-2'
|
||||
: 'bg-accent-bg text-ink'}"
|
||||
>
|
||||
{isDraft ? m.geschichte_editor_status_draft() : m.geschichte_editor_status_published()}
|
||||
</span>
|
||||
</p>
|
||||
<p class="font-sans text-xs text-ink-3">
|
||||
{isDraft
|
||||
? m.geschichte_editor_status_draft_hint()
|
||||
: m.geschichte_editor_status_published_hint()}
|
||||
</p>
|
||||
</section>
|
||||
</details>
|
||||
|
||||
<!-- Persons section -->
|
||||
<details open class="sm:contents">
|
||||
<summary
|
||||
class="flex min-h-[44px] cursor-pointer items-center px-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:hidden"
|
||||
>
|
||||
{m.geschichte_editor_personen_heading()}
|
||||
</summary>
|
||||
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||
<h2
|
||||
class="mb-2 hidden font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:block"
|
||||
>
|
||||
{m.geschichte_editor_personen_heading()}
|
||||
</h2>
|
||||
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_personen_hint()}</p>
|
||||
<PersonMultiSelect bind:selectedPersons={selectedPersons} />
|
||||
</section>
|
||||
</details>
|
||||
|
||||
<!-- Documents section — STORY edit only; journeys manage items in the editor column -->
|
||||
{#if geschichteId}
|
||||
<StoryDocumentPanel geschichteId={geschichteId} items={items} />
|
||||
{/if}
|
||||
</aside>
|
||||
40
frontend/src/lib/geschichte/GeschichteSidebar.svelte.spec.ts
Normal file
40
frontend/src/lib/geschichte/GeschichteSidebar.svelte.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import GeschichteSidebar from './GeschichteSidebar.svelte';
|
||||
|
||||
const item = {
|
||||
id: 'i1',
|
||||
position: 10,
|
||||
document: {
|
||||
id: 'd1',
|
||||
title: 'Brief von Eugenie',
|
||||
datePrecision: 'DAY' as const,
|
||||
receiverCount: 0
|
||||
}
|
||||
};
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('GeschichteSidebar — document panel contract (#795)', () => {
|
||||
it('renders the document panel when geschichteId and items are provided', async () => {
|
||||
render(GeschichteSidebar, {
|
||||
status: 'DRAFT',
|
||||
selectedPersons: [],
|
||||
geschichteId: 'g1',
|
||||
items: [item]
|
||||
});
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('heading', { name: m.geschichte_documents_heading() }))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Brief von Eugenie')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the document panel without geschichteId', async () => {
|
||||
render(GeschichteSidebar, { status: 'DRAFT', selectedPersons: [] });
|
||||
|
||||
expect(document.body.textContent).not.toContain(m.geschichte_documents_heading());
|
||||
});
|
||||
});
|
||||
@@ -3,11 +3,12 @@ import { m } from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { plainExcerpt } from '$lib/shared/utils/extractText';
|
||||
import { formatDate } from '$lib/shared/utils/date';
|
||||
import { formatAuthorName } from './utils';
|
||||
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
type GeschichteSummary = components['schemas']['GeschichteSummary'];
|
||||
|
||||
interface Props {
|
||||
geschichten: Geschichte[];
|
||||
geschichten: GeschichteSummary[];
|
||||
personId: string;
|
||||
personName: string;
|
||||
canWrite: boolean;
|
||||
@@ -18,16 +19,13 @@ let { geschichten, personId, personName, canWrite }: Props = $props();
|
||||
const visible = $derived(geschichten.slice(0, 3));
|
||||
const hasOverflow = $derived(geschichten.length >= 3);
|
||||
|
||||
function formatPublishedDate(g: Geschichte): string | null {
|
||||
function formatPublishedDate(g: GeschichteSummary): string | null {
|
||||
if (!g.publishedAt) return null;
|
||||
return formatDate(g.publishedAt.slice(0, 10), 'short');
|
||||
}
|
||||
|
||||
function authorName(g: Geschichte): string {
|
||||
const a = g.author;
|
||||
if (!a) return '';
|
||||
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
|
||||
return full || a.email || '';
|
||||
function authorName(g: GeschichteSummary): string {
|
||||
return formatAuthorName(g.author);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,19 +3,19 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import GeschichtenCard from './GeschichtenCard.svelte';
|
||||
|
||||
const makeStory = (id: string, title: string, body: string | null = '<p>Body</p>') => ({
|
||||
const makeStory = (id: string, title: string, body: string | undefined = '<p>Body</p>') => ({
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
status: 'PUBLISHED' as const,
|
||||
type: 'STORY' as const,
|
||||
publishedAt: '2024-04-01T12:00:00',
|
||||
createdAt: '2024-03-01T12:00:00',
|
||||
updatedAt: '2024-04-01T12:00:00',
|
||||
persons: [],
|
||||
documents: [],
|
||||
items: [],
|
||||
author: {
|
||||
id: 'u1',
|
||||
email: 'marcel@example.com',
|
||||
firstName: 'Marcel',
|
||||
lastName: 'Raddatz',
|
||||
enabled: true,
|
||||
@@ -120,6 +120,16 @@ describe('GeschichtenCard', () => {
|
||||
expect(link.getAttribute('href')).toBe('/geschichten?personId=p1');
|
||||
});
|
||||
|
||||
it('JOURNEY type does not bleed a REISE badge into the person-sidebar card', async () => {
|
||||
render(GeschichtenCard, {
|
||||
geschichten: [{ ...makeStory('g1', 'Reise Berlin'), type: 'JOURNEY' as const }],
|
||||
personId: 'p1',
|
||||
personName: 'Franz',
|
||||
canWrite: false
|
||||
});
|
||||
expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a plain-text excerpt without HTML markup', async () => {
|
||||
render(GeschichtenCard, {
|
||||
geschichten: [
|
||||
|
||||
@@ -2,20 +2,29 @@ import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import GeschichtenCard from './GeschichtenCard.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type GeschichteSummary = components['schemas']['GeschichteSummary'];
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const makeGeschichte = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'g1',
|
||||
title: 'Reise nach Berlin',
|
||||
body: '<p>Brief text</p>',
|
||||
publishedAt: '2026-04-15T10:00:00Z',
|
||||
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@b' } as unknown,
|
||||
...overrides
|
||||
});
|
||||
const makeGeschichte = (overrides: Record<string, unknown> = {}): GeschichteSummary =>
|
||||
({
|
||||
id: 'g1',
|
||||
title: 'Reise nach Berlin',
|
||||
body: '<p>Brief text</p>',
|
||||
status: 'PUBLISHED' as const,
|
||||
type: 'STORY' as const,
|
||||
publishedAt: '2026-04-15T10:00:00Z',
|
||||
author: {
|
||||
firstName: 'Anna',
|
||||
lastName: 'Schmidt'
|
||||
},
|
||||
...overrides
|
||||
}) as GeschichteSummary;
|
||||
|
||||
const baseProps = (overrides: Record<string, unknown> = {}) => ({
|
||||
geschichten: [] as ReturnType<typeof makeGeschichte>[],
|
||||
geschichten: [] as GeschichteSummary[],
|
||||
personId: 'p-1',
|
||||
personName: 'Anna Schmidt',
|
||||
canWrite: false,
|
||||
@@ -93,17 +102,17 @@ describe('GeschichtenCard', () => {
|
||||
await expect.element(page.getByText(/Anna Schmidt/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('falls back to author email when no name', async () => {
|
||||
it('falls back to [Unbekannt] when no name', async () => {
|
||||
render(GeschichtenCard, {
|
||||
props: baseProps({
|
||||
geschichten: [
|
||||
makeGeschichte({
|
||||
author: { firstName: undefined, lastName: undefined, email: 'fallback@x' }
|
||||
author: { firstName: undefined, lastName: undefined }
|
||||
})
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
await expect.element(page.getByText(/fallback@x/)).toBeVisible();
|
||||
await expect.element(page.getByText('[Unbekannt]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
126
frontend/src/lib/geschichte/JourneyAddBar.svelte
Normal file
126
frontend/src/lib/geschichte/JourneyAddBar.svelte
Normal file
@@ -0,0 +1,126 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import DocumentPickerDropdown from '$lib/document/DocumentPickerDropdown.svelte';
|
||||
import type { DocumentOption } from '$lib/document/documentTypeahead';
|
||||
|
||||
interface Props {
|
||||
alreadyAddedIds?: Set<string>;
|
||||
onAddDocument: (doc: DocumentOption) => void;
|
||||
onAddInterlude: (text: string) => void;
|
||||
}
|
||||
|
||||
let { alreadyAddedIds = new Set(), onAddDocument, onAddInterlude }: Props = $props();
|
||||
|
||||
let showPicker = $state(false);
|
||||
let showInterludeForm = $state(false);
|
||||
let interludeDraft = $state('');
|
||||
let rootEl: HTMLElement | null = $state(null);
|
||||
|
||||
const canConfirmInterlude = $derived(interludeDraft.trim().length > 0);
|
||||
|
||||
async function togglePicker() {
|
||||
showPicker = !showPicker;
|
||||
showInterludeForm = false;
|
||||
if (showPicker) {
|
||||
// Keyboard users need a perceivable result of activating the toggle.
|
||||
await tick();
|
||||
rootEl?.querySelector<HTMLInputElement>('#journey-add-picker input')?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleInterludeForm() {
|
||||
showInterludeForm = !showInterludeForm;
|
||||
showPicker = false;
|
||||
if (showInterludeForm) {
|
||||
await tick();
|
||||
rootEl?.querySelector<HTMLTextAreaElement>('#journey-add-interlude textarea')?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDocumentSelect(doc: DocumentOption) {
|
||||
showPicker = false;
|
||||
onAddDocument(doc);
|
||||
}
|
||||
|
||||
function handleInterludeConfirm() {
|
||||
if (!canConfirmInterlude) return;
|
||||
const text = interludeDraft.trim();
|
||||
interludeDraft = '';
|
||||
showInterludeForm = false;
|
||||
onAddInterlude(text);
|
||||
}
|
||||
|
||||
function handleInterludeCancel() {
|
||||
interludeDraft = '';
|
||||
showInterludeForm = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={rootEl} class="flex flex-col gap-3">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-add-document
|
||||
onclick={togglePicker}
|
||||
aria-expanded={showPicker}
|
||||
aria-controls={showPicker ? 'journey-add-picker' : undefined}
|
||||
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.journey_add_document()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleInterludeForm}
|
||||
aria-expanded={showInterludeForm}
|
||||
aria-controls={showInterludeForm ? 'journey-add-interlude' : undefined}
|
||||
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.journey_add_interlude()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showPicker}
|
||||
<div id="journey-add-picker">
|
||||
<DocumentPickerDropdown
|
||||
alreadyAddedIds={alreadyAddedIds}
|
||||
onSelect={handleDocumentSelect}
|
||||
placeholder={m.journey_add_document()}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showInterludeForm}
|
||||
<div id="journey-add-interlude" class="flex flex-col gap-2">
|
||||
<textarea
|
||||
bind:value={interludeDraft}
|
||||
placeholder={m.journey_interlude_placeholder()}
|
||||
rows={3}
|
||||
maxlength={2000}
|
||||
class="block w-full resize-y rounded border border-line bg-surface px-3 py-2 font-sans text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
></textarea>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleInterludeConfirm}
|
||||
disabled={!canConfirmInterlude}
|
||||
class={[
|
||||
'inline-flex h-11 items-center rounded px-4 font-sans text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring',
|
||||
canConfirmInterlude
|
||||
? 'bg-primary text-primary-fg hover:opacity-90'
|
||||
: 'cursor-not-allowed bg-primary/40 text-primary-fg/60'
|
||||
].join(' ')}
|
||||
>
|
||||
{m.journey_add_interlude_confirm()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleInterludeCancel}
|
||||
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.journey_remove_confirm_cancel()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
72
frontend/src/lib/geschichte/JourneyAddBar.svelte.spec.ts
Normal file
72
frontend/src/lib/geschichte/JourneyAddBar.svelte.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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 JourneyAddBar from './JourneyAddBar.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('JourneyAddBar — interlude flow', () => {
|
||||
it('interlude confirm button is natively disabled when text is empty (WCAG 4.1.2)', async () => {
|
||||
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
|
||||
|
||||
await userEvent.click(page.getByText(m.journey_add_interlude()));
|
||||
|
||||
const confirmBtn = page.getByRole('button', {
|
||||
name: m.journey_add_interlude_confirm(),
|
||||
exact: true
|
||||
});
|
||||
await expect.element(confirmBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('confirm becomes enabled after typing text', async () => {
|
||||
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
|
||||
|
||||
await userEvent.click(page.getByText(m.journey_add_interlude()));
|
||||
await userEvent.fill(page.getByRole('textbox'), 'Eine schöne Reise');
|
||||
|
||||
const confirmBtn = page.getByRole('button', {
|
||||
name: m.journey_add_interlude_confirm(),
|
||||
exact: true
|
||||
});
|
||||
await expect.element(confirmBtn).toBeEnabled();
|
||||
});
|
||||
|
||||
it('calls onAddInterlude with text on confirm', async () => {
|
||||
const onAddInterlude = vi.fn();
|
||||
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude });
|
||||
|
||||
await userEvent.click(page.getByText(m.journey_add_interlude()));
|
||||
await userEvent.fill(page.getByRole('textbox'), 'Reise nach Wien');
|
||||
await userEvent.click(
|
||||
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
|
||||
);
|
||||
|
||||
expect(onAddInterlude).toHaveBeenCalledWith('Reise nach Wien');
|
||||
});
|
||||
|
||||
it('limits the interlude textarea to 2000 characters', async () => {
|
||||
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
|
||||
|
||||
await userEvent.click(page.getByText(m.journey_add_interlude()));
|
||||
|
||||
await expect.element(page.getByRole('textbox')).toHaveAttribute('maxlength', '2000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyAddBar — document picker', () => {
|
||||
it('reveals picker when "Brief hinzufügen" is clicked', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [] }) })
|
||||
);
|
||||
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
|
||||
|
||||
await userEvent.click(page.getByText(m.journey_add_document()));
|
||||
|
||||
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
408
frontend/src/lib/geschichte/JourneyEditor.svelte
Normal file
408
frontend/src/lib/geschichte/JourneyEditor.svelte
Normal file
@@ -0,0 +1,408 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { csrfFetch } from '$lib/shared/cookies';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import { createBlockDragDrop } from '$lib/shared/hooks/useBlockDragDrop.svelte';
|
||||
import { toPersonOption, type PersonOption } from '$lib/person/personOption';
|
||||
import { createUnsavedWarning } from '$lib/shared/hooks/useUnsavedWarning.svelte';
|
||||
import type { DocumentOption } from '$lib/document/documentTypeahead';
|
||||
import GeschichteSidebar from './GeschichteSidebar.svelte';
|
||||
import JourneyItemRow from './JourneyItemRow.svelte';
|
||||
import JourneyAddBar from './JourneyAddBar.svelte';
|
||||
import UnsavedWarningBanner from '$lib/shared/primitives/UnsavedWarningBanner.svelte';
|
||||
|
||||
type GeschichteView = components['schemas']['GeschichteView'];
|
||||
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||
|
||||
interface Props {
|
||||
geschichte: GeschichteView;
|
||||
/** Must reject when the save failed — the editor keeps its dirty state then. */
|
||||
onSubmit: (payload: {
|
||||
title: string;
|
||||
body: string;
|
||||
status: 'DRAFT' | 'PUBLISHED';
|
||||
personIds: string[];
|
||||
}) => Promise<void>;
|
||||
submitting?: boolean;
|
||||
}
|
||||
|
||||
let { geschichte, onSubmit, submitting = false }: Props = $props();
|
||||
|
||||
const unsaved = createUnsavedWarning();
|
||||
|
||||
let title = $state(geschichte.title ?? '');
|
||||
|
||||
let body = $state(geschichte.body ?? '');
|
||||
let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte.status ?? 'DRAFT');
|
||||
let selectedPersons: PersonOption[] = $state(
|
||||
geschichte.persons ? Array.from(geschichte.persons).map(toPersonOption) : []
|
||||
);
|
||||
let items: JourneyItemView[] = $state(
|
||||
[...(geschichte.items ?? [])].sort((a, b) => a.position - b.position)
|
||||
);
|
||||
|
||||
let titleTouched = $state(false);
|
||||
let mutationError = $state('');
|
||||
let pendingRemoveIds: string[] = $state([]);
|
||||
let liveAnnounce = $state('');
|
||||
let announceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function scheduleAnnounceReset() {
|
||||
if (announceTimer) clearTimeout(announceTimer);
|
||||
announceTimer = setTimeout(() => {
|
||||
liveAnnounce = '';
|
||||
announceTimer = null;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
const titleEmpty = $derived(title.trim().length === 0);
|
||||
const showTitleError = $derived(titleEmpty && titleTouched);
|
||||
const isDraft = $derived(status === 'DRAFT');
|
||||
const alreadyAddedIds = $derived(
|
||||
new Set(items.filter((i) => i.document).map((i) => i.document!.id))
|
||||
);
|
||||
const canPublish = $derived(items.length > 0 && !titleEmpty);
|
||||
const showPublishedEmptyWarning = $derived(status === 'PUBLISHED' && items.length === 0);
|
||||
|
||||
// Skip the initial run so mounting with pre-existing persons doesn't mark dirty.
|
||||
let _personEffectMounted = false;
|
||||
$effect(() => {
|
||||
void selectedPersons.length;
|
||||
if (!_personEffectMounted) {
|
||||
_personEffectMounted = true;
|
||||
return;
|
||||
}
|
||||
unsaved.markDirty();
|
||||
});
|
||||
|
||||
let listEl: HTMLElement | null = $state(null);
|
||||
let editorColEl: HTMLElement | null = $state(null);
|
||||
|
||||
const dragDrop = createBlockDragDrop<JourneyItemView>({
|
||||
getSortedBlocks: () => items,
|
||||
onReorder: handleReorder
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
dragDrop.setListElement(listEl);
|
||||
});
|
||||
|
||||
/** Maps a failed mutation response to a user-facing message via its backend error code. */
|
||||
async function failureMessage(res: Response): Promise<string> {
|
||||
const code = (await res.json().catch(() => ({})))?.code;
|
||||
return code ? getErrorMessage(code) : m.journey_mutation_error_reload();
|
||||
}
|
||||
|
||||
/** Moves keyboard focus to a control inside the row of the given item. */
|
||||
async function focusRowControl(itemId: string, selector: string) {
|
||||
await tick();
|
||||
editorColEl?.querySelector<HTMLElement>(`[data-block-id="${itemId}"] ${selector}`)?.focus();
|
||||
}
|
||||
|
||||
async function handleReorder(itemIds: string[]) {
|
||||
const prev = [...items];
|
||||
items = itemIds.map((id) => items.find((i) => i.id === id)!);
|
||||
mutationError = '';
|
||||
try {
|
||||
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/reorder`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ itemIds })
|
||||
});
|
||||
if (!res.ok) {
|
||||
items = prev;
|
||||
mutationError = await failureMessage(res);
|
||||
return;
|
||||
}
|
||||
const updated: JourneyItemView[] = await res.json();
|
||||
items = updated.sort((a, b) => a.position - b.position);
|
||||
} catch (e) {
|
||||
console.error('Journey reorder failed', e);
|
||||
items = prev;
|
||||
mutationError = m.journey_mutation_error_reload();
|
||||
}
|
||||
}
|
||||
|
||||
/** Pessimistic append shared by both add paths — items update only on API success. */
|
||||
async function appendItem(body: { documentId?: string; note?: string }) {
|
||||
mutationError = '';
|
||||
try {
|
||||
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) {
|
||||
mutationError = await failureMessage(res);
|
||||
return;
|
||||
}
|
||||
const newItem: JourneyItemView = await res.json();
|
||||
items = [...items, newItem];
|
||||
// Move-up is disabled on the first row — fall back to the remove button then.
|
||||
await focusRowControl(newItem.id, '[data-move-up]:not([disabled]), [data-remove-btn]');
|
||||
} catch (e) {
|
||||
console.error('Journey item append failed', e);
|
||||
mutationError = m.journey_mutation_error_reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddDocument(doc: DocumentOption) {
|
||||
await appendItem({ documentId: doc.id });
|
||||
}
|
||||
|
||||
async function handleAddInterlude(text: string) {
|
||||
await appendItem({ note: text });
|
||||
}
|
||||
|
||||
async function handleRemove(itemId: string) {
|
||||
const idx = items.findIndex((i) => i.id === itemId);
|
||||
mutationError = '';
|
||||
pendingRemoveIds = [...pendingRemoveIds, itemId];
|
||||
liveAnnounce = m.journey_item_pending_remove();
|
||||
scheduleAnnounceReset();
|
||||
try {
|
||||
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/${itemId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!res.ok) {
|
||||
mutationError = await failureMessage(res);
|
||||
return;
|
||||
}
|
||||
items = items.filter((i) => i.id !== itemId);
|
||||
await tick();
|
||||
if (items.length === 0 || idx <= 0) {
|
||||
editorColEl?.querySelector<HTMLElement>('[data-add-document]')?.focus();
|
||||
} else {
|
||||
await focusRowControl(items[idx - 1].id, '[data-remove-btn]');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Journey item remove failed', e);
|
||||
mutationError = m.journey_mutation_error_reload();
|
||||
} finally {
|
||||
pendingRemoveIds = pendingRemoveIds.filter((id) => id !== itemId);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNotePatch(itemId: string, note: string | null) {
|
||||
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/${itemId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ note: note })
|
||||
});
|
||||
// Carry the backend error code's message so the row can show the specific
|
||||
// reason (e.g. JOURNEY_NOTE_TOO_LONG) instead of a generic alert.
|
||||
if (!res.ok) throw new Error(await failureMessage(res));
|
||||
const updated: JourneyItemView = await res.json();
|
||||
items = items.map((i) => (i.id === itemId ? updated : i));
|
||||
}
|
||||
|
||||
async function handleMoveUp(index: number) {
|
||||
if (index === 0) return;
|
||||
const total = items.length;
|
||||
const ids = items.map((i) => i.id);
|
||||
[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]];
|
||||
await handleReorder(ids);
|
||||
// Announce only after the server confirmed (or rejected) the reorder —
|
||||
// announcing beforehand would claim success for a move that rolled back.
|
||||
liveAnnounce = mutationError
|
||||
? mutationError
|
||||
: m.journey_item_moved({ position: index + 1, total, newPosition: index });
|
||||
scheduleAnnounceReset();
|
||||
}
|
||||
|
||||
async function handleMoveDown(index: number) {
|
||||
if (index === items.length - 1) return;
|
||||
const total = items.length;
|
||||
const ids = items.map((i) => i.id);
|
||||
[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]];
|
||||
await handleReorder(ids);
|
||||
liveAnnounce = mutationError
|
||||
? mutationError
|
||||
: m.journey_item_moved({ position: index + 1, total, newPosition: index + 2 });
|
||||
scheduleAnnounceReset();
|
||||
}
|
||||
|
||||
async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
|
||||
titleTouched = true;
|
||||
if (titleEmpty) return;
|
||||
try {
|
||||
await onSubmit({
|
||||
title: title.trim(),
|
||||
body,
|
||||
status: nextStatus,
|
||||
personIds: selectedPersons.map((p) => p.id!).filter(Boolean)
|
||||
});
|
||||
unsaved.clearOnSuccess();
|
||||
} catch {
|
||||
// onSubmit signalled failure — keep dirty flag so the banner stays
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Screen-reader live region for move announcements -->
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only">{liveAnnounce}</div>
|
||||
|
||||
{#if unsaved.showUnsavedWarning}
|
||||
<UnsavedWarningBanner onDiscard={unsaved.discard} />
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
<!-- Editor column -->
|
||||
<div bind:this={editorColEl} class="flex flex-col gap-4">
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={title}
|
||||
maxlength="255"
|
||||
oninput={() => unsaved.markDirty()}
|
||||
onblur={() => (titleTouched = true)}
|
||||
placeholder={m.geschichte_editor_title_placeholder()}
|
||||
aria-label={m.journey_title_aria_label()}
|
||||
aria-invalid={showTitleError}
|
||||
aria-describedby={showTitleError ? 'journey-title-error' : undefined}
|
||||
class="block w-full rounded border {showTitleError
|
||||
? 'border-danger'
|
||||
: 'border-line'} bg-surface px-3 py-3 font-serif text-2xl font-bold text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
{#if showTitleError}
|
||||
<p id="journey-title-error" class="mt-1 text-sm text-danger" role="alert">
|
||||
{m.geschichte_editor_title_required()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Intro textarea -->
|
||||
<div>
|
||||
<textarea
|
||||
bind:value={body}
|
||||
maxlength="4000"
|
||||
oninput={() => unsaved.markDirty()}
|
||||
placeholder={m.journey_intro_placeholder()}
|
||||
aria-label={m.journey_intro_aria_label()}
|
||||
rows={3}
|
||||
class="block w-full resize-y rounded border border-line bg-surface px-3 py-2 font-serif text-base text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
></textarea>
|
||||
<p class="mt-1 font-sans text-xs text-ink-3">{m.journey_intro_save_hint()}</p>
|
||||
</div>
|
||||
|
||||
<!-- Item list -->
|
||||
{#if showPublishedEmptyWarning}
|
||||
<p
|
||||
class="rounded border border-[var(--color-warning-border)] bg-[var(--color-warning-bg)] px-3 py-2 font-sans text-sm text-[var(--color-warning-text)]"
|
||||
>
|
||||
{m.journey_published_empty_warning()}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if mutationError}
|
||||
<p
|
||||
class="rounded border border-danger bg-danger/10 px-3 py-2 font-sans text-sm text-danger"
|
||||
role="alert"
|
||||
>
|
||||
{mutationError}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if items.length === 0}
|
||||
<p class="font-sans text-sm text-ink-3">{m.journey_empty_state()}</p>
|
||||
{:else}
|
||||
<!-- pointer events managed by createBlockDragDrop; keyboard reorder available via move-up/down buttons on each item -->
|
||||
<ol
|
||||
bind:this={listEl}
|
||||
onpointermove={(e) => dragDrop.handlePointerMove(e)}
|
||||
onpointerup={() => dragDrop.handlePointerUp()}
|
||||
class="m-0 flex list-none flex-col gap-2 p-0"
|
||||
>
|
||||
{#each items as item, i (item.id)}
|
||||
<!-- pointerdown initiates drag; the drag handle button inside is the semantic interactive element -->
|
||||
<li
|
||||
data-block-wrapper
|
||||
onpointerdown={(e) => dragDrop.handleGripDown(e, item.id)}
|
||||
class="transition-all duration-150 {dragDrop.draggedBlockId === item.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-focus-ring/40' : ''}"
|
||||
style={dragDrop.draggedBlockId === item.id
|
||||
? `transform: translateY(${dragDrop.dragOffsetY}px) scale(1.02); opacity: 0.9;`
|
||||
: ''}
|
||||
>
|
||||
{#if dragDrop.dropTargetIdx === i}
|
||||
<div class="mb-1 h-1 rounded-full bg-accent transition-all"></div>
|
||||
{/if}
|
||||
<JourneyItemRow
|
||||
item={item}
|
||||
index={i}
|
||||
total={items.length}
|
||||
pendingRemove={pendingRemoveIds.includes(item.id)}
|
||||
onMoveUp={() => handleMoveUp(i)}
|
||||
onMoveDown={() => handleMoveDown(i)}
|
||||
onRemove={() => handleRemove(item.id)}
|
||||
onNotePatch={(note) => handleNotePatch(item.id, note)}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
|
||||
<JourneyAddBar
|
||||
alreadyAddedIds={alreadyAddedIds}
|
||||
onAddDocument={handleAddDocument}
|
||||
onAddInterlude={handleAddInterlude}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<GeschichteSidebar status={status} bind:selectedPersons={selectedPersons} />
|
||||
</div>
|
||||
|
||||
<!-- Save bar -->
|
||||
<div
|
||||
class="sticky bottom-0 z-10 -mx-4 mt-6 flex flex-col gap-3 border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<p class="font-sans text-xs text-ink-3">
|
||||
{isDraft ? m.geschichte_editor_save_hint_draft() : m.journey_save_hint_published()}
|
||||
</p>
|
||||
<div class="flex flex-col items-start gap-1 sm:items-end">
|
||||
<div class="flex gap-2">
|
||||
{#if isDraft}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => save('DRAFT')}
|
||||
disabled={submitting || titleEmpty}
|
||||
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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{m.geschichte_editor_save_draft()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => save('PUBLISHED')}
|
||||
disabled={submitting || !canPublish}
|
||||
title={canPublish ? undefined : m.journey_publish_disabled_title()}
|
||||
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:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{m.geschichte_editor_publish()}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => save('DRAFT')}
|
||||
disabled={submitting}
|
||||
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-[var(--color-warning-text)] hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{m.geschichte_editor_unpublish()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => save('PUBLISHED')}
|
||||
disabled={submitting || titleEmpty}
|
||||
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:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{m.geschichte_editor_save()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isDraft && !canPublish}
|
||||
<p class="font-sans text-xs text-ink-3">{m.journey_publish_disabled_hint()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
813
frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts
Normal file
813
frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts
Normal file
@@ -0,0 +1,813 @@
|
||||
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 JourneyEditor from './JourneyEditor.svelte';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
|
||||
const docSummary = (id: string, title: string) => ({
|
||||
id,
|
||||
title,
|
||||
datePrecision: 'DAY' as const,
|
||||
receiverCount: 0
|
||||
});
|
||||
|
||||
/** DocumentListItem fixture as returned by the picker search endpoint. */
|
||||
const makeSearchResultItem = (id: string, title: string) => ({
|
||||
id,
|
||||
title,
|
||||
documentDate: '1880-01-01',
|
||||
metaDatePrecision: 'DAY',
|
||||
originalFilename: 'brief.pdf',
|
||||
receivers: [],
|
||||
tags: [],
|
||||
completionPercentage: 0,
|
||||
contributors: [],
|
||||
matchData: {
|
||||
titleOffsets: [],
|
||||
senderMatched: false,
|
||||
matchedReceiverIds: [],
|
||||
matchedTagIds: [],
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
},
|
||||
status: 'UPLOADED',
|
||||
metadataComplete: false,
|
||||
scriptType: 'UNKNOWN',
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00'
|
||||
});
|
||||
|
||||
const makeGeschichte = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'g1',
|
||||
title: 'Briefe der Familie Raddatz',
|
||||
body: '',
|
||||
status: 'DRAFT' as const,
|
||||
type: 'JOURNEY' as const,
|
||||
persons: [],
|
||||
items: [],
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
...overrides
|
||||
});
|
||||
|
||||
const defaultProps = (overrides: Record<string, unknown> = {}) => ({
|
||||
geschichte: makeGeschichte(),
|
||||
onSubmit: vi.fn().mockResolvedValue(undefined),
|
||||
submitting: false,
|
||||
...overrides
|
||||
});
|
||||
|
||||
function mockCsrfFetch(responseFactory: () => object) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(responseFactory())
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('JourneyEditor — empty state', () => {
|
||||
it('renders title input and intro textarea', async () => {
|
||||
render(JourneyEditor, defaultProps());
|
||||
await expect.element(page.getByPlaceholder(/Titel/)).toBeInTheDocument();
|
||||
await expect.element(page.getByPlaceholder(/Einleitung/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('labels the title input and intro textarea for screen readers', async () => {
|
||||
render(JourneyEditor, defaultProps());
|
||||
await expect
|
||||
.element(page.getByRole('textbox', { name: m.journey_title_aria_label() }))
|
||||
.toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByRole('textbox', { name: m.journey_intro_aria_label() }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state message when items list is empty', async () => {
|
||||
render(JourneyEditor, defaultProps());
|
||||
await expect.element(page.getByText(m.journey_empty_state())).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — items in position order', () => {
|
||||
it('renders items sorted by position', async () => {
|
||||
const items = [
|
||||
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') },
|
||||
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }
|
||||
];
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
// Brief A (position 0) must appear before Brief B (position 1) in DOM order
|
||||
const briefA = page.getByText('Brief A').element();
|
||||
const briefB = page.getByText('Brief B').element();
|
||||
expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — publish surface', () => {
|
||||
it('publish button disabled when no items', async () => {
|
||||
render(JourneyEditor, defaultProps());
|
||||
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows a visible hint while publishing is disabled', async () => {
|
||||
render(JourneyEditor, defaultProps());
|
||||
await expect.element(page.getByText(m.journey_publish_disabled_hint())).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('publish stays disabled until title is non-empty', async () => {
|
||||
render(
|
||||
JourneyEditor,
|
||||
defaultProps({
|
||||
geschichte: makeGeschichte({
|
||||
title: '',
|
||||
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
|
||||
|
||||
const titleInput = page.getByPlaceholder(/Titel/);
|
||||
await userEvent.fill(titleInput, 'Meine Reise');
|
||||
|
||||
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('adding an item enables the publish button (canPublish becomes true)', async () => {
|
||||
const newItem = { id: 'i1', position: 0, note: 'Test' };
|
||||
mockCsrfFetch(() => newItem);
|
||||
|
||||
render(JourneyEditor, defaultProps());
|
||||
|
||||
// Publish should be disabled before adding item
|
||||
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
|
||||
|
||||
// Add interlude
|
||||
await userEvent.click(page.getByText(m.journey_add_interlude()));
|
||||
await userEvent.fill(page.getByPlaceholder(m.journey_interlude_placeholder()), 'Test');
|
||||
await userEvent.click(
|
||||
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
|
||||
);
|
||||
|
||||
// After item add, publish becomes enabled — item was added and state is correct
|
||||
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('clicking Veröffentlichen calls onSubmit with status PUBLISHED and the trimmed title', async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
JourneyEditor,
|
||||
defaultProps({
|
||||
onSubmit,
|
||||
geschichte: makeGeschichte({
|
||||
title: ' Meine Reise ',
|
||||
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
await userEvent.click(page.getByRole('button', { name: /Veröffentlichen/ }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'PUBLISHED', title: 'Meine Reise' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('unpublish button calls onSubmit with status DRAFT in PUBLISHED state', async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
JourneyEditor,
|
||||
defaultProps({
|
||||
onSubmit,
|
||||
geschichte: makeGeschichte({
|
||||
status: 'PUBLISHED',
|
||||
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_unpublish() }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ status: 'DRAFT' }));
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the published-empty warning banner when PUBLISHED with 0 items', async () => {
|
||||
render(
|
||||
JourneyEditor,
|
||||
defaultProps({ geschichte: makeGeschichte({ status: 'PUBLISHED', items: [] }) })
|
||||
);
|
||||
|
||||
await expect.element(page.getByText(m.journey_published_empty_warning())).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — add document', () => {
|
||||
it('calls POST with documentId when document selected from picker', async () => {
|
||||
const newItem = { id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') };
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
// picker search results
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ items: [makeSearchResultItem('d1', 'Brief von Karl')] })
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
// POST /items
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(newItem)
|
||||
})
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps());
|
||||
|
||||
await userEvent.click(page.getByText(m.journey_add_document()));
|
||||
await userEvent.fill(page.getByRole('combobox'), 'Karl');
|
||||
// dropdown option appears after the typeahead debounce
|
||||
await expect.element(page.getByText(/Brief von Karl ·/)).toBeInTheDocument();
|
||||
await userEvent.click(page.getByText(/Brief von Karl ·/));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/items'),
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — add interlude', () => {
|
||||
it('calls POST with note on interlude confirm', async () => {
|
||||
const newItem = { id: 'i1', position: 0, note: 'Reise nach Wien' };
|
||||
mockCsrfFetch(() => newItem);
|
||||
|
||||
render(JourneyEditor, defaultProps());
|
||||
|
||||
await userEvent.click(page.getByText(m.journey_add_interlude()));
|
||||
await userEvent.fill(
|
||||
page.getByPlaceholder(m.journey_interlude_placeholder()),
|
||||
'Reise nach Wien'
|
||||
);
|
||||
await userEvent.click(
|
||||
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/items'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ note: 'Reise nach Wien' })
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('moves keyboard focus into the new row after the interlude is added', async () => {
|
||||
const newItem = { id: 'i1', position: 0, note: 'Reise nach Wien' };
|
||||
mockCsrfFetch(() => newItem);
|
||||
|
||||
render(JourneyEditor, defaultProps());
|
||||
|
||||
await userEvent.click(page.getByText(m.journey_add_interlude()));
|
||||
await userEvent.fill(
|
||||
page.getByPlaceholder(m.journey_interlude_placeholder()),
|
||||
'Reise nach Wien'
|
||||
);
|
||||
await userEvent.click(
|
||||
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement?.closest('[data-block-id="i1"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — mutation error code routing', () => {
|
||||
it('shows the specific i18n message when POST /items fails with a backend error code', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: vi.fn().mockResolvedValue({ code: 'JOURNEY_DOCUMENT_ALREADY_ADDED' })
|
||||
})
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps());
|
||||
|
||||
await userEvent.click(page.getByText(m.journey_add_interlude()));
|
||||
await userEvent.fill(
|
||||
page.getByPlaceholder(m.journey_interlude_placeholder()),
|
||||
'Reise nach Wien'
|
||||
);
|
||||
await userEvent.click(
|
||||
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
|
||||
);
|
||||
|
||||
await expect
|
||||
.element(page.getByText(m.error_journey_document_already_added()))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(page.getByText(m.journey_mutation_error_reload())).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — remove with pending state', () => {
|
||||
it('keeps the row in the DOM with pending treatment while the DELETE is in flight', async () => {
|
||||
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
|
||||
let resolveFetch!: (value: unknown) => void;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation(() => new Promise((resolve) => (resolveFetch = resolve)))
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
|
||||
);
|
||||
|
||||
// Row still present, marked as pending (text appears in the row AND the live region,
|
||||
// so scope the query to the row instead of using a page-wide locator)
|
||||
await expect.element(page.getByText('Brief A')).toBeInTheDocument();
|
||||
await vi.waitFor(() => {
|
||||
const row = document.querySelector('[data-block-id="i1"]');
|
||||
expect(row).toBeTruthy();
|
||||
expect(row!.textContent).toContain(m.journey_item_pending_remove());
|
||||
expect(row!.className).toContain('opacity-60');
|
||||
});
|
||||
|
||||
resolveFetch({ ok: true });
|
||||
await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the row and shows an error alert on failed DELETE (non-ok response)', async () => {
|
||||
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) })
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
// Click remove (no note → direct remove)
|
||||
await userEvent.click(
|
||||
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
|
||||
);
|
||||
|
||||
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Brief A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('removes the row on successful DELETE', async () => {
|
||||
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
|
||||
);
|
||||
|
||||
await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('focuses a sensible target after a successful remove (not body)', async () => {
|
||||
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
|
||||
);
|
||||
|
||||
await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement).not.toBe(document.body);
|
||||
expect(document.activeElement?.hasAttribute('data-add-document')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — reorder via move buttons', () => {
|
||||
it('move-up calls PUT reorder with swapped IDs', async () => {
|
||||
const items = [
|
||||
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
|
||||
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
|
||||
];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue([
|
||||
{ id: 'i2', position: 0, document: docSummary('d2', 'Brief B') },
|
||||
{ id: 'i1', position: 1, document: docSummary('d1', 'Brief A') }
|
||||
])
|
||||
})
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/items/reorder'),
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ itemIds: ['i2', 'i1'] })
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('move-down calls PUT reorder with swapped IDs', async () => {
|
||||
const items = [
|
||||
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
|
||||
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
|
||||
];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue([
|
||||
{ id: 'i2', position: 0, document: docSummary('d2', 'Brief B') },
|
||||
{ id: 'i1', position: 1, document: docSummary('d1', 'Brief A') }
|
||||
])
|
||||
})
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(page.getByRole('button', { name: /Brief A.*nach unten verschieben/ }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/items/reorder'),
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ itemIds: ['i2', 'i1'] })
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the server-confirmed order after a successful reorder', async () => {
|
||||
const items = [
|
||||
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
|
||||
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
|
||||
];
|
||||
// Server response deliberately NOT pre-sorted — pins items = updated.sort(...)
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue([
|
||||
{ id: 'i1', position: 20, document: docSummary('d1', 'Brief A') },
|
||||
{ id: 'i2', position: 10, document: docSummary('d2', 'Brief B') }
|
||||
])
|
||||
})
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(page.getByRole('button', { name: /Brief A.*nach unten verschieben/ }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const briefB = page.getByText('Brief B').element();
|
||||
const briefA = page.getByText('Brief A').element();
|
||||
expect(
|
||||
briefB.compareDocumentPosition(briefA) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('restores the original DOM order and shows an alert on failed reorder (non-ok)', async () => {
|
||||
const items = [
|
||||
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
|
||||
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
|
||||
];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) })
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
|
||||
|
||||
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||
const briefA = page.getByText('Brief A').element();
|
||||
const briefB = page.getByText('Brief B').element();
|
||||
expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it('restores the original DOM order and shows an alert when the reorder request rejects', async () => {
|
||||
const items = [
|
||||
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
|
||||
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
|
||||
];
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network down')));
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
|
||||
|
||||
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||
const briefA = page.getByText('Brief A').element();
|
||||
const briefB = page.getByText('Brief B').element();
|
||||
expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(consoleError).toHaveBeenCalled();
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — live announce region', () => {
|
||||
it('announces the move only after the reorder resolved, then clears', async () => {
|
||||
const items = [
|
||||
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
|
||||
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
|
||||
];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue([
|
||||
{ id: 'i2', position: 0, document: docSummary('d2', 'Brief B') },
|
||||
{ id: 'i1', position: 1, document: docSummary('d1', 'Brief A') }
|
||||
])
|
||||
})
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
|
||||
|
||||
const liveRegion = document.querySelector('[aria-live="polite"]');
|
||||
await vi.waitFor(() => {
|
||||
expect((liveRegion?.textContent ?? '').trim().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect((liveRegion?.textContent ?? '').trim()).toBe('');
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('announces the error text instead of a success message when the move fails', async () => {
|
||||
const items = [
|
||||
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
|
||||
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
|
||||
];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) })
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
|
||||
|
||||
const liveRegion = document.querySelector('[aria-live="polite"]');
|
||||
await vi.waitFor(() => {
|
||||
expect((liveRegion?.textContent ?? '').trim()).toBe(m.journey_mutation_error_reload());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — note patch body', () => {
|
||||
it('sends {"note":null} when note textarea is cleared and blurred', async () => {
|
||||
const items = [
|
||||
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A'), note: 'old note' }
|
||||
];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') })
|
||||
})
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
|
||||
await userEvent.clear(textarea);
|
||||
await textarea.element().dispatchEvent(new FocusEvent('blur'));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/items/i1'),
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ note: null })
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — duplicate document aria-disabled', () => {
|
||||
it('already-added document appears as aria-disabled in picker', async () => {
|
||||
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') }];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ items: [makeSearchResultItem('d1', 'Brief von Karl')] })
|
||||
})
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(page.getByText(m.journey_add_document()));
|
||||
await userEvent.fill(page.getByRole('combobox'), 'Karl');
|
||||
|
||||
// The dropdown item includes the date ("Brief von Karl · …"), the list item does not
|
||||
await expect.element(page.getByText(/Brief von Karl ·/)).toBeInTheDocument();
|
||||
const option = page
|
||||
.getByText(/Brief von Karl ·/)
|
||||
.element()
|
||||
.closest('li')!;
|
||||
expect(option.getAttribute('aria-disabled')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — unsaved warning banner', () => {
|
||||
function triggerNavigationAttempt() {
|
||||
const calls = vi.mocked(beforeNavigate).mock.calls;
|
||||
if (calls.length === 0) return;
|
||||
const [callback] = calls[calls.length - 1];
|
||||
const cancel = vi.fn();
|
||||
(callback as (nav: { cancel: () => void; to: { url: URL } | null }) => void)({
|
||||
cancel,
|
||||
to: { url: new URL('http://localhost/geschichten') }
|
||||
});
|
||||
return cancel;
|
||||
}
|
||||
|
||||
it('banner is absent before any edit or navigation attempt', async () => {
|
||||
render(JourneyEditor, defaultProps());
|
||||
expect(page.getByText(m.admin_unsaved_warning()).query()).toBeNull();
|
||||
});
|
||||
|
||||
it('banner appears when dirty and a navigation is attempted', async () => {
|
||||
render(JourneyEditor, defaultProps());
|
||||
|
||||
// Mark dirty by editing the title
|
||||
const titleInput = page.getByPlaceholder(/Titel/);
|
||||
await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||
|
||||
// Simulate the user trying to navigate away
|
||||
const cancel = triggerNavigationAttempt();
|
||||
expect(cancel).toHaveBeenCalled();
|
||||
|
||||
await expect.element(page.getByText(m.admin_unsaved_warning())).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('banner stays after a failed save (clearOnSuccess not called when onSubmit throws)', async () => {
|
||||
const onSubmit = vi.fn().mockRejectedValue(new Error('server error'));
|
||||
render(
|
||||
JourneyEditor,
|
||||
defaultProps({
|
||||
onSubmit,
|
||||
geschichte: makeGeschichte({
|
||||
title: 'Titel',
|
||||
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
// Mark dirty
|
||||
const titleInput = page.getByPlaceholder(/Titel/);
|
||||
await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||
|
||||
// Trigger navigation → banner appears
|
||||
triggerNavigationAttempt();
|
||||
await expect.element(page.getByText(m.admin_unsaved_warning())).toBeInTheDocument();
|
||||
|
||||
// Attempt save — onSubmit throws
|
||||
await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_save_draft() }));
|
||||
|
||||
// Banner must still be visible (isDirty was not cleared)
|
||||
await vi.waitFor(() => {
|
||||
expect(page.getByText(m.admin_unsaved_warning()).query()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('successful save clears the unsaved warning (navigation unblocked after onSubmit resolves)', async () => {
|
||||
// Regression guard for clearOnSuccess(): without it, a curator who edits the
|
||||
// title and saves successfully stays trapped — the page goto() gets cancelled
|
||||
// by the still-armed guard and the banner appears after a *successful* save.
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
render(JourneyEditor, defaultProps({ onSubmit }));
|
||||
|
||||
// Mark dirty
|
||||
const titleInput = page.getByPlaceholder(/Titel/);
|
||||
await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||
|
||||
// Dirty state blocks navigation
|
||||
expect(triggerNavigationAttempt()).toHaveBeenCalled();
|
||||
|
||||
// Save succeeds
|
||||
await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_save_draft() }));
|
||||
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
|
||||
// Guard is disarmed again — navigation passes and no banner shows
|
||||
await vi.waitFor(() => {
|
||||
expect(triggerNavigationAttempt()).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(page.getByText(m.admin_unsaved_warning()).query()).toBeNull();
|
||||
});
|
||||
|
||||
it('item add does not arm the unsaved-changes guard (items persist immediately)', async () => {
|
||||
mockCsrfFetch(() => ({ id: 'i-new', position: 10, note: 'Zwischentext' }));
|
||||
render(JourneyEditor, defaultProps());
|
||||
|
||||
await userEvent.click(page.getByText(m.journey_add_interlude()));
|
||||
await userEvent.fill(page.getByPlaceholder(m.journey_interlude_placeholder()), 'Zwischentext');
|
||||
await userEvent.click(
|
||||
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
|
||||
);
|
||||
// The new interlude row renders its note textarea once the POST resolved
|
||||
await expect
|
||||
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ }))
|
||||
.toBeInTheDocument();
|
||||
|
||||
// The item was persisted by its own POST — navigating away loses nothing
|
||||
expect(triggerNavigationAttempt()).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — selectedPersons marks dirty', () => {
|
||||
function getNavCallback() {
|
||||
const calls = vi.mocked(beforeNavigate).mock.calls;
|
||||
const [callback] = calls[calls.length - 1];
|
||||
return (cancel = vi.fn()) => {
|
||||
(callback as (nav: { cancel: () => void; to: { url: URL } | null }) => void)({
|
||||
cancel,
|
||||
to: { url: new URL('http://localhost/geschichten') }
|
||||
});
|
||||
return cancel;
|
||||
};
|
||||
}
|
||||
|
||||
it('removing a person chip marks the editor dirty', async () => {
|
||||
render(
|
||||
JourneyEditor,
|
||||
defaultProps({
|
||||
geschichte: makeGeschichte({
|
||||
persons: [{ id: 'p1', firstName: 'Anna', lastName: 'Schmidt' }]
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
// Confirm navigation is NOT blocked initially (clean state)
|
||||
const triggerNav = getNavCallback();
|
||||
expect(triggerNav()).not.toHaveBeenCalled();
|
||||
|
||||
// Remove the person chip (aria-label = m.comp_multiselect_remove() = "Entfernen")
|
||||
await userEvent.click(page.getByRole('button', { name: m.comp_multiselect_remove() }));
|
||||
|
||||
// After person removal, navigation should be blocked
|
||||
await vi.waitFor(() => {
|
||||
const cancel = triggerNav();
|
||||
expect(cancel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — person chips from GeschichteView', () => {
|
||||
it('renders person names in the sidebar chips (PersonView carries no displayName)', async () => {
|
||||
render(
|
||||
JourneyEditor,
|
||||
defaultProps({
|
||||
geschichte: makeGeschichte({
|
||||
persons: [{ id: 'p1', firstName: 'Anna', lastName: 'Schmidt' }]
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
18
frontend/src/lib/geschichte/JourneyInterlude.svelte
Normal file
18
frontend/src/lib/geschichte/JourneyInterlude.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Props {
|
||||
note: string;
|
||||
}
|
||||
|
||||
let { note }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="note"
|
||||
aria-label={m.journey_interlude_aria_label()}
|
||||
class="my-4 rounded-r-sm border-l-2 border-journey-border bg-journey-tint py-2 pr-3 pl-3"
|
||||
>
|
||||
<!-- plaintext — do NOT use {@html} here -->
|
||||
<p class="text-base leading-relaxed text-ink italic">{note}</p>
|
||||
</div>
|
||||
64
frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts
Normal file
64
frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
const { default: JourneyInterlude } = await import('./JourneyInterlude.svelte');
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__xss_interlude?: number;
|
||||
}
|
||||
}
|
||||
|
||||
describe('JourneyInterlude', () => {
|
||||
it('renders the note text as plaintext', async () => {
|
||||
render(JourneyInterlude, { props: { note: 'Eine kurze Pause auf der Reise.' } });
|
||||
|
||||
await expect.element(page.getByText('Eine kurze Pause auf der Reise.')).toBeVisible();
|
||||
});
|
||||
|
||||
it('has aria-label from i18n (journey_interlude_aria_label)', async () => {
|
||||
render(JourneyInterlude, { props: { note: 'Notiz' } });
|
||||
|
||||
const el = document.querySelector(`[aria-label="${m.journey_interlude_aria_label()}"]`);
|
||||
expect(el).not.toBeNull();
|
||||
});
|
||||
|
||||
it('has role="note" so the aria-label is announced by screen readers', async () => {
|
||||
render(JourneyInterlude, { props: { note: 'Notiz' } });
|
||||
|
||||
const el = document.querySelector('[role="note"]');
|
||||
expect(el).not.toBeNull();
|
||||
expect(el?.getAttribute('aria-label')).toBe(m.journey_interlude_aria_label());
|
||||
});
|
||||
|
||||
it('uses mode-aware journey tokens, not raw orange utilities (#801)', async () => {
|
||||
render(JourneyInterlude, { props: { note: 'Notiz' } });
|
||||
|
||||
const block = document.querySelector('[role="note"]');
|
||||
expect(block!.className).toContain('bg-journey-tint');
|
||||
expect(block!.className).toContain('border-journey-border');
|
||||
expect(block!.className).not.toContain('bg-orange-50');
|
||||
});
|
||||
|
||||
it('note text uses readable body size (text-base, #800)', async () => {
|
||||
render(JourneyInterlude, { props: { note: 'Notiz' } });
|
||||
|
||||
const text = document.querySelector('[role="note"] p');
|
||||
expect(text!.className).toContain('text-base');
|
||||
expect(text!.className).not.toContain('text-xs');
|
||||
});
|
||||
|
||||
it('XSS: note is rendered as plaintext — injected payload does not execute', async () => {
|
||||
// Interlude uses Svelte text interpolation ({note}), NOT {@html}.
|
||||
render(JourneyInterlude, {
|
||||
props: { note: '<img src=x onerror="window.__xss_interlude=1">' }
|
||||
});
|
||||
|
||||
expect(window.__xss_interlude).toBeUndefined();
|
||||
expect(document.body.textContent).toContain('<img src=x onerror=');
|
||||
});
|
||||
});
|
||||
66
frontend/src/lib/geschichte/JourneyItemCard.svelte
Normal file
66
frontend/src/lib/geschichte/JourneyItemCard.svelte
Normal file
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/shared/utils/date';
|
||||
import { formatDocumentMetaLine } from './utils';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||
|
||||
interface Props {
|
||||
item: JourneyItemView;
|
||||
}
|
||||
|
||||
let { item }: Props = $props();
|
||||
|
||||
// Safe: JourneyReader filters out items where document === null before rendering this component.
|
||||
const doc = $derived(item.document!);
|
||||
const formattedDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'short') : null);
|
||||
const metaLine = $derived(formatDocumentMetaLine(doc));
|
||||
const openAriaLabel = $derived(
|
||||
formattedDate
|
||||
? m.journey_item_open_aria({ date: formattedDate })
|
||||
: m.journey_item_open_aria_undated()
|
||||
);
|
||||
const hasNote = $derived(item.note != null && item.note.trim().length > 0);
|
||||
</script>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="rounded-sm border border-line bg-surface p-3">
|
||||
<!-- plaintext — do NOT use {@html} here -->
|
||||
<p class="mb-0.5 font-serif text-base leading-snug text-ink">{doc.title}</p>
|
||||
{#if metaLine}
|
||||
<p class="mb-2 text-sm text-ink-3">{metaLine}</p>
|
||||
{/if}
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
aria-label={openAriaLabel}
|
||||
class="-my-2 inline-flex min-h-[44px] items-center gap-1 text-sm font-semibold text-ink hover:text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<svg class="h-3 w-3 shrink-0" viewBox="0 0 10 12" fill="none">
|
||||
<rect x="1" y="1" width="8" height="10" rx="1" stroke="currentColor" stroke-width="1" />
|
||||
<path
|
||||
d="M3 4h4M3 6.5h4M3 9h2"
|
||||
stroke="currentColor"
|
||||
stroke-width=".7"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{m.journey_item_open()}
|
||||
<svg class="h-3 w-3 shrink-0" viewBox="0 0 10 10" fill="none">
|
||||
<path
|
||||
d="M4 2l4 3-4 3"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{#if hasNote}
|
||||
<!-- plaintext — do NOT use {@html} here -->
|
||||
<div class="mt-3 rounded-r-sm border-l-2 border-brand-mint bg-muted py-1.5 pr-2 pl-3">
|
||||
<p class="text-base leading-relaxed text-ink-2 italic">{item.note}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
188
frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts
Normal file
188
frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
const { default: JourneyItemCard } = await import('./JourneyItemCard.svelte');
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__xss_note?: number;
|
||||
}
|
||||
}
|
||||
|
||||
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||
|
||||
const baseItem = (overrides: Partial<JourneyItemView> = {}): JourneyItemView => ({
|
||||
id: 'item1',
|
||||
position: 0,
|
||||
document: {
|
||||
id: 'd1',
|
||||
title: 'Brief an Helene',
|
||||
documentDate: '1923-05-15',
|
||||
datePrecision: 'DAY',
|
||||
receiverCount: 0
|
||||
},
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('JourneyItemCard', () => {
|
||||
it('renders the document title', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||
|
||||
await expect.element(page.getByText('Brief an Helene')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the document date in the meta line when documentDate is present', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||
|
||||
await expect.element(page.getByText(/1923/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('"Brief öffnen" link points to /documents/:id', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||
|
||||
const link = page.getByRole('link', { name: /öffnen/i });
|
||||
await expect.element(link).toBeInTheDocument();
|
||||
const el = await link.element();
|
||||
expect(el.getAttribute('href')).toContain('/documents/d1');
|
||||
});
|
||||
|
||||
it('"Brief öffnen" link meets the 44px touch-target floor', async () => {
|
||||
// Primary tap action of the phone read path — WCAG 2.5.5 / house rule.
|
||||
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||
|
||||
const link = page.getByRole('link', { name: /öffnen/i });
|
||||
await expect.element(link).toBeInTheDocument();
|
||||
const height = link.element().getBoundingClientRect().height;
|
||||
expect(height).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
|
||||
it('"Brief öffnen" link has dated aria-label when documentDate is present', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||
|
||||
const link = page.getByRole('link', { name: /1923/i });
|
||||
await expect.element(link).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('"Brief öffnen" link has undated aria-label when documentDate is absent', async () => {
|
||||
render(JourneyItemCard, {
|
||||
props: {
|
||||
item: baseItem({
|
||||
document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'UNKNOWN', receiverCount: 0 }
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
const link = page.getByRole('link', { name: m.journey_item_open_aria_undated() });
|
||||
await expect.element(link).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits date from meta line when documentDate is absent', async () => {
|
||||
render(JourneyItemCard, {
|
||||
props: {
|
||||
item: baseItem({
|
||||
document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'UNKNOWN', receiverCount: 0 }
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText(/1923/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders sender→receiver in meta line when both are present', async () => {
|
||||
render(JourneyItemCard, {
|
||||
props: {
|
||||
item: baseItem({
|
||||
document: {
|
||||
id: 'd1',
|
||||
title: 'Brief an Helene',
|
||||
documentDate: '1923-05-15',
|
||||
datePrecision: 'DAY',
|
||||
senderName: 'Franz Raddatz',
|
||||
receiverName: 'Emma Müller',
|
||||
receiverCount: 1
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText(/Franz Raddatz/)).toBeVisible();
|
||||
await expect.element(page.getByText(/Emma Müller/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders note as annotation block when note is present', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } });
|
||||
|
||||
await expect.element(page.getByText('Ein wichtiger Brief')).toBeVisible();
|
||||
});
|
||||
|
||||
it('annotation block uses the brand mint accent border (#798)', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } });
|
||||
|
||||
const note = document.querySelector('[class*="border-l-2"]');
|
||||
expect(note).not.toBeNull();
|
||||
expect(note!.className).toContain('border-brand-mint');
|
||||
});
|
||||
|
||||
it('card uses the surface token, not bg-white, so dark mode remaps it', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||
|
||||
const card = document.querySelector('[class*="border-line"]');
|
||||
expect(card).not.toBeNull();
|
||||
expect(card!.className).toContain('bg-surface');
|
||||
expect(card!.className).not.toContain('bg-white');
|
||||
});
|
||||
|
||||
it('annotation block is tinted with bg-muted to stand off the white card', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } });
|
||||
|
||||
const note = document.querySelector('[class*="border-l-2"]');
|
||||
expect(note!.className).toContain('bg-muted');
|
||||
});
|
||||
|
||||
it('reading text sizes meet the accessibility floor (#800)', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } });
|
||||
|
||||
const title = page.getByText('Brief an Helene');
|
||||
expect((await title.element()).className).toContain('text-base');
|
||||
|
||||
const link = await page.getByRole('link', { name: /öffnen/i }).element();
|
||||
expect(link.className).toContain('text-sm');
|
||||
expect(link.className).not.toContain('text-xs');
|
||||
|
||||
const noteText = document.querySelector('[class*="border-l-2"] p');
|
||||
expect(noteText!.className).toContain('text-base');
|
||||
expect(noteText!.className).not.toContain('text-xs');
|
||||
});
|
||||
|
||||
it('omits annotation block when note is blank or whitespace', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem({ note: ' ' }) } });
|
||||
|
||||
await expect.element(page.getByText(/ {3}/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits annotation block when note is absent', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem({ note: undefined }) } });
|
||||
|
||||
const notes = document.querySelectorAll('[class*="border-mint"]');
|
||||
expect(notes.length).toBe(0);
|
||||
});
|
||||
|
||||
it('XSS: note is rendered as plaintext — injected payload does not execute', async () => {
|
||||
// Note uses Svelte text interpolation ({item.note}), NOT {@html}.
|
||||
render(JourneyItemCard, {
|
||||
props: {
|
||||
item: baseItem({
|
||||
note: '<img src=x onerror="window.__xss_note=1">'
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
expect(window.__xss_note).toBeUndefined();
|
||||
expect(document.body.textContent).toContain('<img src=x onerror=');
|
||||
});
|
||||
});
|
||||
268
frontend/src/lib/geschichte/JourneyItemRow.svelte
Normal file
268
frontend/src/lib/geschichte/JourneyItemRow.svelte
Normal file
@@ -0,0 +1,268 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDocumentMetaLine } from './utils';
|
||||
|
||||
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||
|
||||
interface Props {
|
||||
item: JourneyItemView;
|
||||
index: number;
|
||||
total: number;
|
||||
pendingRemove?: boolean;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
onRemove: () => void;
|
||||
onNotePatch: (note: string | null) => Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
item,
|
||||
index,
|
||||
total,
|
||||
pendingRemove = false,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onRemove,
|
||||
onNotePatch
|
||||
}: Props = $props();
|
||||
|
||||
const isInterlude = $derived(!item.document);
|
||||
const itemTitle = $derived(item.document?.title ?? m.journey_interlude_label());
|
||||
// Spec LE-2 "Briefmeta": date · von X an Y disambiguates near-identical titles.
|
||||
const metaLine = $derived(item.document ? formatDocumentMetaLine(item.document) : '');
|
||||
const needsConfirmOnRemove = $derived(!!item.note);
|
||||
|
||||
let rootEl: HTMLElement | null = $state(null);
|
||||
let showNote = $state(!!item.note);
|
||||
let noteDraft = $state(item.note ?? '');
|
||||
let noteSaving = $state(false);
|
||||
let noteError = $state('');
|
||||
let showRemoveConfirm = $state(false);
|
||||
|
||||
async function handleNoteBlur() {
|
||||
if (noteSaving) return;
|
||||
const normalizedDraft = noteDraft.trim().length === 0 ? null : noteDraft;
|
||||
// '' and undefined both mean "no note" — never PATCH a no-op.
|
||||
if (normalizedDraft === (item.note ?? null)) {
|
||||
// Opened "Notiz hinzufügen" and blurred without typing → collapse again.
|
||||
if (!isInterlude && normalizedDraft === null) showNote = false;
|
||||
return;
|
||||
}
|
||||
if (isInterlude && normalizedDraft === null) {
|
||||
// Interludes must keep a note — restore the draft so the UI doesn't show
|
||||
// an emptied text that the server still holds.
|
||||
noteDraft = item.note ?? '';
|
||||
return;
|
||||
}
|
||||
|
||||
noteSaving = true;
|
||||
noteError = '';
|
||||
try {
|
||||
await onNotePatch(normalizedDraft);
|
||||
// Clearing an existing note collapses the textarea after the PATCH lands.
|
||||
if (normalizedDraft === null) showNote = false;
|
||||
} catch (e) {
|
||||
noteError = e instanceof Error && e.message ? e.message : m.journey_note_error();
|
||||
} finally {
|
||||
noteSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNoteRemove() {
|
||||
const prevDraft = noteDraft;
|
||||
const prevShowNote = showNote;
|
||||
noteDraft = '';
|
||||
showNote = false;
|
||||
noteError = '';
|
||||
try {
|
||||
await onNotePatch(null);
|
||||
} catch (e) {
|
||||
noteDraft = prevDraft;
|
||||
showNote = prevShowNote;
|
||||
noteError = e instanceof Error && e.message ? e.message : m.journey_note_error();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNoteOpen() {
|
||||
showNote = true;
|
||||
// Spec LE-3: focus moves into the revealed textarea.
|
||||
await tick();
|
||||
rootEl?.querySelector<HTMLTextAreaElement>('textarea')?.focus();
|
||||
}
|
||||
|
||||
async function handleRemoveClick() {
|
||||
if (needsConfirmOnRemove) {
|
||||
showRemoveConfirm = true;
|
||||
await tick();
|
||||
rootEl?.querySelector<HTMLElement>('[data-remove-confirm-cancel]')?.focus();
|
||||
} else {
|
||||
onRemove();
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoveConfirm() {
|
||||
showRemoveConfirm = false;
|
||||
onRemove();
|
||||
}
|
||||
|
||||
async function handleRemoveCancel() {
|
||||
showRemoveConfirm = false;
|
||||
await tick();
|
||||
rootEl?.querySelector<HTMLElement>('[data-remove-btn]')?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={rootEl}
|
||||
data-block-id={item.id}
|
||||
class={[
|
||||
'flex min-w-0 flex-col rounded border transition-colors',
|
||||
pendingRemove ? 'opacity-60' : '',
|
||||
isInterlude
|
||||
? 'border-l-4 border-line border-l-interlude-border bg-interlude-bg'
|
||||
: 'border-line bg-surface'
|
||||
].join(' ')}
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-1 px-2 py-1">
|
||||
<!-- Drag handle (desktop, pointer-only — keyboard users reorder via the move buttons) -->
|
||||
<button
|
||||
type="button"
|
||||
data-drag-handle
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
class="hidden shrink-0 cursor-grab items-center justify-center self-center text-ink-3 transition-colors hover:text-ink active:cursor-grabbing md:flex"
|
||||
style="min-height: 44px; min-width: 44px;"
|
||||
>
|
||||
⠿
|
||||
</button>
|
||||
|
||||
<!-- Move up/down (mobile + always visible) -->
|
||||
<div class="flex shrink-0 flex-col self-start">
|
||||
<button
|
||||
type="button"
|
||||
data-move-up
|
||||
onclick={onMoveUp}
|
||||
disabled={index === 0}
|
||||
aria-label={m.journey_move_up({ title: itemTitle })}
|
||||
class="flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-20"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onMoveDown}
|
||||
disabled={index === total - 1}
|
||||
aria-label={m.journey_move_down({ title: itemTitle })}
|
||||
class="flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-20"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content (title + note inline) -->
|
||||
<div class="min-w-0 flex-1 py-1 break-words">
|
||||
{#if isInterlude}
|
||||
<span class="font-sans text-xs font-bold tracking-widest text-interlude-label uppercase">
|
||||
{m.journey_interlude_label()}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="font-sans text-xs text-ink-3">{index + 1}.</span>
|
||||
<span class="ml-1 font-serif text-sm text-ink">{item.document!.title}</span>
|
||||
{#if metaLine}
|
||||
<p class="mt-0.5 font-sans text-xs text-ink-3">{metaLine}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if showNote}
|
||||
<div class="mt-2">
|
||||
<textarea
|
||||
aria-label={m.journey_note_aria_label({ title: itemTitle })}
|
||||
bind:value={noteDraft}
|
||||
onblur={handleNoteBlur}
|
||||
maxlength={2000}
|
||||
rows={2}
|
||||
class="block w-full resize-y rounded border border-line bg-transparent px-2 py-1.5 font-sans text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
></textarea>
|
||||
<div class="mt-1 flex items-center justify-between gap-2">
|
||||
<p class="font-sans text-xs text-ink-3">{m.journey_note_save_hint()}</p>
|
||||
{#if !isInterlude}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleNoteRemove}
|
||||
class="inline-flex min-h-[44px] items-center font-sans text-xs text-ink-3 underline hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.journey_note_remove()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if noteError}
|
||||
<p class="mt-1 font-sans text-xs text-danger" role="alert">{noteError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if !isInterlude}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleNoteOpen}
|
||||
aria-expanded={showNote}
|
||||
class="mt-0.5 inline-flex min-h-[44px] items-center font-sans text-xs text-ink-3 underline hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.journey_note_add()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Remove button / confirm / pending -->
|
||||
<div class="shrink-0 self-start">
|
||||
{#if pendingRemove}
|
||||
<span class="inline-flex min-h-[44px] items-center font-sans text-xs text-ink-3 italic">
|
||||
{m.journey_item_pending_remove()}
|
||||
</span>
|
||||
{:else if showRemoveConfirm}
|
||||
<div role="group" aria-label={m.journey_remove_confirm()} class="flex items-center gap-2">
|
||||
<span class="font-sans text-xs text-ink-2">{m.journey_remove_confirm()}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleRemoveConfirm}
|
||||
onkeydown={(e) => e.key === 'Escape' && handleRemoveCancel()}
|
||||
class="inline-flex min-h-[44px] items-center rounded bg-danger px-3 font-sans text-xs font-medium text-white hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.journey_remove_confirm_yes()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-remove-confirm-cancel
|
||||
onclick={handleRemoveCancel}
|
||||
onkeydown={(e) => e.key === 'Escape' && handleRemoveCancel()}
|
||||
class="inline-flex min-h-[44px] items-center rounded border border-line px-3 font-sans text-xs font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.journey_remove_confirm_cancel()}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
data-remove-btn
|
||||
onclick={handleRemoveClick}
|
||||
aria-label={m.journey_remove_item_aria({ title: itemTitle })}
|
||||
class="-m-1 inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded p-3 text-ink-3 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
354
frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts
Normal file
354
frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
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 JourneyItemRow from './JourneyItemRow.svelte';
|
||||
|
||||
const docItem = (overrides: Partial<{ note: string }> = {}) => ({
|
||||
id: 'item-1',
|
||||
position: 0,
|
||||
document: {
|
||||
id: 'doc-1',
|
||||
title: 'Brief von Karl',
|
||||
datePrecision: 'DAY' as const,
|
||||
receiverCount: 0
|
||||
},
|
||||
...overrides
|
||||
});
|
||||
|
||||
const interludeItem = (note = 'Reise nach Wien') => ({
|
||||
id: 'item-2',
|
||||
position: 1,
|
||||
note
|
||||
});
|
||||
|
||||
const defaultProps = (overrides = {}) => ({
|
||||
index: 0,
|
||||
total: 3,
|
||||
onMoveUp: vi.fn(),
|
||||
onMoveDown: vi.fn(),
|
||||
onRemove: vi.fn(),
|
||||
onNotePatch: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('JourneyItemRow — interlude label', () => {
|
||||
it('shows "Zwischentext" (not the add-button label) on interlude rows', async () => {
|
||||
render(JourneyItemRow, { item: interludeItem(), ...defaultProps() });
|
||||
|
||||
await expect.element(page.getByText(m.journey_interlude_label())).toBeInTheDocument();
|
||||
await expect.element(page.getByText(m.journey_add_interlude())).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses "Zwischentext" in the move button aria-labels', async () => {
|
||||
render(JourneyItemRow, { item: interludeItem(), ...defaultProps({ index: 1 }) });
|
||||
|
||||
await expect
|
||||
.element(
|
||||
page.getByRole('button', {
|
||||
name: m.journey_move_up({ title: m.journey_interlude_label() })
|
||||
})
|
||||
)
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyItemRow — note textarea', () => {
|
||||
it('opens note textarea on "Notiz hinzufügen" click', async () => {
|
||||
render(JourneyItemRow, { item: docItem(), ...defaultProps() });
|
||||
|
||||
await userEvent.click(page.getByText(m.journey_note_add()));
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('blur without typing does not call onNotePatch and collapses the textarea', async () => {
|
||||
// '' (untouched draft) and undefined (no note) both mean "no note" — a
|
||||
// spurious PATCH {note: null} must not fire, and the empty textarea closes.
|
||||
const onNotePatch = vi.fn().mockResolvedValue(undefined);
|
||||
render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) });
|
||||
|
||||
await userEvent.click(page.getByText(m.journey_note_add()));
|
||||
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ });
|
||||
await textarea.element().dispatchEvent(new FocusEvent('blur'));
|
||||
|
||||
expect(onNotePatch).not.toHaveBeenCalled();
|
||||
await expect.element(page.getByText(m.journey_note_add())).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('moves focus into the textarea when "Notiz hinzufügen" opens it', async () => {
|
||||
render(JourneyItemRow, { item: docItem(), ...defaultProps() });
|
||||
|
||||
const toggle = page.getByText(m.journey_note_add());
|
||||
expect(toggle.element().getAttribute('aria-expanded')).toBe('false');
|
||||
await userEvent.click(toggle);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const textarea = page
|
||||
.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ })
|
||||
.element();
|
||||
expect(document.activeElement).toBe(textarea);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onNotePatch on textarea blur with non-empty value', async () => {
|
||||
const onNotePatch = vi.fn().mockResolvedValue(undefined);
|
||||
render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) });
|
||||
|
||||
await userEvent.click(page.getByText(m.journey_note_add()));
|
||||
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ });
|
||||
await userEvent.fill(textarea, 'Eine neue Notiz');
|
||||
await textarea.element().dispatchEvent(new FocusEvent('blur'));
|
||||
|
||||
expect(onNotePatch).toHaveBeenCalledWith('Eine neue Notiz');
|
||||
});
|
||||
|
||||
it('limits the note textarea to 2000 characters', async () => {
|
||||
render(JourneyItemRow, { item: docItem({ note: 'Notiz' }), ...defaultProps() });
|
||||
|
||||
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
|
||||
await expect.element(textarea).toHaveAttribute('maxlength', '2000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyItemRow — note error state', () => {
|
||||
it('shows role=alert error message when onNotePatch rejects', async () => {
|
||||
const onNotePatch = vi.fn().mockRejectedValue(new Error('server error'));
|
||||
render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) });
|
||||
|
||||
await userEvent.click(page.getByText(m.journey_note_add()));
|
||||
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ });
|
||||
await userEvent.fill(textarea, 'Eine Notiz');
|
||||
await textarea.element().dispatchEvent(new FocusEvent('blur'));
|
||||
|
||||
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyItemRow — note remove error state', () => {
|
||||
it('restores note and shows error when onNotePatch rejects during remove', async () => {
|
||||
const onNotePatch = vi.fn().mockRejectedValue(new Error('server error'));
|
||||
render(JourneyItemRow, {
|
||||
item: docItem({ note: 'keep me' }),
|
||||
...defaultProps({ onNotePatch })
|
||||
});
|
||||
|
||||
await userEvent.click(page.getByText(m.journey_note_remove()));
|
||||
|
||||
// textarea should be visible again (showNote restored)
|
||||
await expect
|
||||
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ }))
|
||||
.toBeInTheDocument();
|
||||
// error alert should be shown
|
||||
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyItemRow — interlude rules', () => {
|
||||
it('does not show "Notiz entfernen" for interlude items', async () => {
|
||||
render(JourneyItemRow, { item: interludeItem(), ...defaultProps() });
|
||||
|
||||
// Note section should be visible (interlude always shows note)
|
||||
await expect
|
||||
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ }))
|
||||
.toBeInTheDocument();
|
||||
// But "Notiz entfernen" must be absent
|
||||
await expect.element(page.getByText(m.journey_note_remove())).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('blocks saving empty text on interlude note blur', async () => {
|
||||
const onNotePatch = vi.fn().mockResolvedValue(undefined);
|
||||
render(JourneyItemRow, {
|
||||
item: interludeItem('original text'),
|
||||
...defaultProps({ onNotePatch })
|
||||
});
|
||||
|
||||
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
|
||||
await userEvent.clear(textarea);
|
||||
await textarea.element().dispatchEvent(new FocusEvent('blur'));
|
||||
|
||||
expect(onNotePatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores the original note text after a blocked empty-clear blur', async () => {
|
||||
render(JourneyItemRow, {
|
||||
item: interludeItem('original text'),
|
||||
...defaultProps()
|
||||
});
|
||||
|
||||
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
|
||||
await userEvent.clear(textarea);
|
||||
await textarea.element().dispatchEvent(new FocusEvent('blur'));
|
||||
|
||||
await expect.element(textarea).toHaveValue('original text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyItemRow — remove confirm', () => {
|
||||
it('shows inline confirm when removing a document item that has a note', async () => {
|
||||
render(JourneyItemRow, {
|
||||
item: docItem({ note: 'Wichtige Notiz' }),
|
||||
...defaultProps()
|
||||
});
|
||||
|
||||
// Click remove (x button)
|
||||
await userEvent.click(
|
||||
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
||||
);
|
||||
|
||||
await expect.element(page.getByText(m.journey_remove_confirm())).toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: m.journey_remove_confirm_yes() }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking Bestätigen invokes onRemove (destructive path)', async () => {
|
||||
const onRemove = vi.fn();
|
||||
render(JourneyItemRow, {
|
||||
item: docItem({ note: 'Wichtige Notiz' }),
|
||||
...defaultProps({ onRemove })
|
||||
});
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
||||
);
|
||||
await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_yes() }));
|
||||
|
||||
expect(onRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('confirm cancel restores remove button without calling onRemove', async () => {
|
||||
const onRemove = vi.fn();
|
||||
render(JourneyItemRow, {
|
||||
item: docItem({ note: 'Notiz' }),
|
||||
...defaultProps({ onRemove })
|
||||
});
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
||||
);
|
||||
await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_cancel() }));
|
||||
|
||||
expect(onRemove).not.toHaveBeenCalled();
|
||||
// The remove button should be back
|
||||
await expect
|
||||
.element(
|
||||
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
||||
)
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('confirm cancel returns keyboard focus to the row remove button', async () => {
|
||||
render(JourneyItemRow, {
|
||||
item: docItem({ note: 'Notiz' }),
|
||||
...defaultProps()
|
||||
});
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
||||
);
|
||||
await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_cancel() }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const removeBtn = page
|
||||
.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
||||
.element();
|
||||
expect(document.activeElement).toBe(removeBtn);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyItemRow — remove confirm a11y', () => {
|
||||
it('confirm area is wrapped in role=group with an accessible label', async () => {
|
||||
render(JourneyItemRow, {
|
||||
item: docItem({ note: 'Wichtige Notiz' }),
|
||||
...defaultProps()
|
||||
});
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
||||
);
|
||||
|
||||
const group = document.querySelector('[role="group"]');
|
||||
expect(group).toBeTruthy();
|
||||
expect(group!.getAttribute('aria-label')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keyboard focus moves to Cancel button when confirm appears', async () => {
|
||||
render(JourneyItemRow, {
|
||||
item: docItem({ note: 'Wichtige Notiz' }),
|
||||
...defaultProps()
|
||||
});
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const cancelBtn = page
|
||||
.getByRole('button', { name: m.journey_remove_confirm_cancel() })
|
||||
.element();
|
||||
expect(document.activeElement).toBe(cancelBtn);
|
||||
});
|
||||
});
|
||||
|
||||
it('pressing Escape while confirm is open hides confirm and refocuses remove button', async () => {
|
||||
render(JourneyItemRow, {
|
||||
item: docItem({ note: 'Wichtige Notiz' }),
|
||||
...defaultProps()
|
||||
});
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
const cancelBtn = page
|
||||
.getByRole('button', { name: m.journey_remove_confirm_cancel() })
|
||||
.element();
|
||||
expect(document.activeElement).toBe(cancelBtn);
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{Escape}');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const removeBtn = page
|
||||
.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
||||
.element();
|
||||
expect(document.activeElement).toBe(removeBtn);
|
||||
});
|
||||
await expect.element(page.getByText(m.journey_remove_confirm())).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyItemRow — pending remove state', () => {
|
||||
it('renders dimmed with the pending text and without a remove button', async () => {
|
||||
render(JourneyItemRow, {
|
||||
item: docItem(),
|
||||
...defaultProps({ pendingRemove: true })
|
||||
});
|
||||
|
||||
await expect.element(page.getByText(m.journey_item_pending_remove())).toBeInTheDocument();
|
||||
await expect
|
||||
.element(
|
||||
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
||||
)
|
||||
.not.toBeInTheDocument();
|
||||
|
||||
const root = document.querySelector('[data-block-id="item-1"]')!;
|
||||
expect(root.className).toContain('opacity-60');
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyItemRow — drag handle', () => {
|
||||
it('is pointer-only: removed from tab order and hidden from the accessibility tree', async () => {
|
||||
render(JourneyItemRow, { item: docItem(), ...defaultProps() });
|
||||
|
||||
const handle = document.querySelector('[data-drag-handle]')!;
|
||||
expect(handle.getAttribute('tabindex')).toBe('-1');
|
||||
expect(handle.getAttribute('aria-hidden')).toBe('true');
|
||||
});
|
||||
});
|
||||
53
frontend/src/lib/geschichte/JourneyReader.svelte
Normal file
53
frontend/src/lib/geschichte/JourneyReader.svelte
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import JourneyItemCard from './JourneyItemCard.svelte';
|
||||
import JourneyInterlude from './JourneyInterlude.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type GeschichteView = components['schemas']['GeschichteView'];
|
||||
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||
|
||||
interface Props {
|
||||
geschichte: GeschichteView;
|
||||
}
|
||||
|
||||
let { geschichte: g }: Props = $props();
|
||||
|
||||
// Render intro only when body is a non-empty, non-whitespace string.
|
||||
const introText = $derived(g.body?.trim() ? g.body : null);
|
||||
|
||||
// Omit items that have neither a document nor a non-blank note (dangling deleted-document guard).
|
||||
const validItems = $derived(
|
||||
g.items.filter(
|
||||
(item: JourneyItemView) =>
|
||||
item.document != null || (item.note != null && item.note.trim().length > 0)
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if introText}
|
||||
<!-- plaintext — do NOT use {@html} here -->
|
||||
<p
|
||||
class="mb-6 border-b border-dashed border-line-2 pb-4 font-serif text-lg leading-relaxed text-ink-2 italic"
|
||||
>
|
||||
{introText}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if validItems.length === 0}
|
||||
<p class="font-sans text-sm text-ink-3" data-testid="journey-empty-state">
|
||||
{m.journey_empty_state()}
|
||||
</p>
|
||||
{:else}
|
||||
<ol class="flex list-none flex-col">
|
||||
{#each validItems as item (item.id)}
|
||||
<li>
|
||||
{#if item.document != null}
|
||||
<JourneyItemCard item={item} />
|
||||
{:else}
|
||||
<JourneyInterlude note={item.note!} />
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
174
frontend/src/lib/geschichte/JourneyReader.svelte.spec.ts
Normal file
174
frontend/src/lib/geschichte/JourneyReader.svelte.spec.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
const { default: JourneyReader } = await import('./JourneyReader.svelte');
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__xss_journey?: number;
|
||||
}
|
||||
}
|
||||
|
||||
type GeschichteView = components['schemas']['GeschichteView'];
|
||||
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||
|
||||
const baseGeschichte = (overrides: Partial<GeschichteView> = {}): GeschichteView => ({
|
||||
id: 'g1',
|
||||
title: 'Lesereise Berlin',
|
||||
body: null as unknown as undefined,
|
||||
type: 'JOURNEY',
|
||||
status: 'PUBLISHED',
|
||||
persons: [],
|
||||
items: [],
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
...overrides
|
||||
});
|
||||
|
||||
const docItem = (id: string, title: string, position: number, note?: string): JourneyItemView => ({
|
||||
id,
|
||||
position,
|
||||
document: {
|
||||
id: `d${id}`,
|
||||
title,
|
||||
datePrecision: 'DAY',
|
||||
documentDate: '1923-05-15',
|
||||
receiverCount: 0
|
||||
},
|
||||
note
|
||||
});
|
||||
|
||||
const interludeItem = (id: string, note: string, position: number): JourneyItemView => ({
|
||||
id,
|
||||
position,
|
||||
document: undefined,
|
||||
note
|
||||
});
|
||||
|
||||
const ctx = () => new Map([[CONFIRM_KEY, createConfirmService()]]);
|
||||
|
||||
describe('JourneyReader', () => {
|
||||
it('renders intro paragraph when body is non-empty', async () => {
|
||||
render(JourneyReader, {
|
||||
context: ctx(),
|
||||
props: { geschichte: baseGeschichte({ body: 'Eine Reise durch die Geschichte.' }) }
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('Eine Reise durch die Geschichte.')).toBeVisible();
|
||||
});
|
||||
|
||||
it('intro paragraph uses readable body size (text-lg, #800)', async () => {
|
||||
render(JourneyReader, {
|
||||
context: ctx(),
|
||||
props: { geschichte: baseGeschichte({ body: 'Eine Reise durch die Geschichte.' }) }
|
||||
});
|
||||
|
||||
const intro = document.querySelector('p');
|
||||
expect(intro!.className).toContain('text-lg');
|
||||
expect(intro!.className).not.toContain('text-sm');
|
||||
});
|
||||
|
||||
it('omits intro paragraph when body is null', async () => {
|
||||
render(JourneyReader, {
|
||||
context: ctx(),
|
||||
props: { geschichte: baseGeschichte({ body: undefined }) }
|
||||
});
|
||||
|
||||
// Only empty state should render
|
||||
await expect.element(page.getByTestId('journey-empty-state')).toBeVisible();
|
||||
});
|
||||
|
||||
it('omits intro paragraph when body is only whitespace', async () => {
|
||||
render(JourneyReader, {
|
||||
context: ctx(),
|
||||
props: { geschichte: baseGeschichte({ body: ' ' }) }
|
||||
});
|
||||
|
||||
// Whitespace-only body must NOT produce a visible intro paragraph.
|
||||
// The only rendered content should be the empty-state message.
|
||||
await expect.element(page.getByTestId('journey-empty-state')).toBeVisible();
|
||||
const paragraphs = document.querySelectorAll('p:not([data-testid])');
|
||||
expect(paragraphs.length).toBe(0);
|
||||
});
|
||||
|
||||
it('renders empty-state message when items array is empty', async () => {
|
||||
render(JourneyReader, {
|
||||
context: ctx(),
|
||||
props: { geschichte: baseGeschichte({ items: [] }) }
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders both intro and empty-state when body is set but items is empty', async () => {
|
||||
render(JourneyReader, {
|
||||
context: ctx(),
|
||||
props: {
|
||||
geschichte: baseGeschichte({ body: 'Eine Einleitung.', items: [] })
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('Eine Einleitung.')).toBeVisible();
|
||||
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders document items (JourneyItemCard)', async () => {
|
||||
render(JourneyReader, {
|
||||
context: ctx(),
|
||||
props: {
|
||||
geschichte: baseGeschichte({ items: [docItem('item1', 'Brief an Helene', 0)] })
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('Brief an Helene')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders interlude items (JourneyInterlude)', async () => {
|
||||
render(JourneyReader, {
|
||||
context: ctx(),
|
||||
props: {
|
||||
geschichte: baseGeschichte({ items: [interludeItem('inter1', 'Eine Pause.', 0)] })
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('Eine Pause.')).toBeVisible();
|
||||
});
|
||||
|
||||
it('omits items where document is null AND note is blank (dangling-item rule)', async () => {
|
||||
render(JourneyReader, {
|
||||
context: ctx(),
|
||||
props: {
|
||||
geschichte: baseGeschichte({
|
||||
items: [
|
||||
{ id: 'dangling', position: 0, document: undefined, note: ' ' },
|
||||
docItem('item2', 'Echter Brief', 1)
|
||||
]
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('Echter Brief')).toBeVisible();
|
||||
// Empty-state must NOT render when valid items exist
|
||||
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('XSS: Journey body is rendered as plaintext — injected payload does not execute', async () => {
|
||||
// JourneyReader uses Svelte text interpolation, NOT {@html}.
|
||||
render(JourneyReader, {
|
||||
context: ctx(),
|
||||
props: {
|
||||
geschichte: baseGeschichte({
|
||||
body: '<img src=x onerror="window.__xss_journey=1">'
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
expect(window.__xss_journey).toBeUndefined();
|
||||
expect(document.body.textContent).toContain('<img src=x onerror=');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
# geschichte (frontend)
|
||||
|
||||
UI for family stories: the rich-text editor, story cards, and story list view.
|
||||
UI for family stories (Geschichte) and reading journeys (Lesereise): the rich-text editor, story/journey readers, type badge, and list rows.
|
||||
|
||||
## What this domain owns
|
||||
|
||||
Components: `GeschichteEditor.svelte`, `GeschichtenCard.svelte`.
|
||||
Components: `GeschichteEditor.svelte`, `GeschichteSidebar.svelte`, `StoryDocumentPanel.svelte`, `JourneyEditor.svelte`, `JourneyItemRow.svelte`, `JourneyAddBar.svelte`, `GeschichtenCard.svelte`, `GeschichteListRow.svelte`, `StoryReader.svelte`, `JourneyReader.svelte`, `JourneyItemCard.svelte`, `JourneyInterlude.svelte`.
|
||||
Utilities: `utils.ts`.
|
||||
|
||||
## What this domain does NOT own
|
||||
|
||||
@@ -14,14 +15,44 @@ Components: `GeschichteEditor.svelte`, `GeschichtenCard.svelte`.
|
||||
|
||||
## Key components
|
||||
|
||||
| Component | Used in | Notes |
|
||||
| ------------------------- | -------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| `GeschichteEditor.svelte` | `/geschichten/new`, `/geschichten/[id]/edit` | Rich-text editor with person/document @-mentions and inline embeds |
|
||||
| `GeschichtenCard.svelte` | `/geschichten` (list), dashboard | Story preview card with cover image and publish status |
|
||||
| Component | Used in | Notes |
|
||||
| --------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `GeschichteEditor.svelte` | `/geschichten/new`, `/geschichten/[id]/edit` | Rich-text editor (TipTap) for STORY type; delegates sidebar to `GeschichteSidebar` |
|
||||
| `GeschichteSidebar.svelte` | `GeschichteEditor`, `JourneyEditor` | Status badge + PersonMultiSelect sidebar; `<details>` mobile collapsibles with 44px touch targets |
|
||||
| `StoryDocumentPanel.svelte` | `GeschichteSidebar` (STORY edit only) | Sidebar document list for stories: picker add (POST), optimistic remove (DELETE), deleted-doc placeholders |
|
||||
| `JourneyEditor.svelte` | `/geschichten/[id]/edit` (JOURNEY branch) | Curator editing surface: title, intro textarea, ordered item list with drag/reorder, add bar, save/publish |
|
||||
| `JourneyItemRow.svelte` | `JourneyEditor.svelte` | Item row: drag handle, move-up/down, note textarea (PATCH on blur), inline remove confirm |
|
||||
| `JourneyAddBar.svelte` | `JourneyEditor.svelte` | Two add buttons: document picker (`DocumentPickerDropdown`) and interlude draft form |
|
||||
| `GeschichtenCard.svelte` | Person-detail sidebar | Sidebar card showing the 3 most recent stories linked to a person — NOT the list page |
|
||||
| `GeschichteListRow.svelte` | `/geschichten` (list) | Editorial list row: meta column (avatar, author, date, REISE badge), title + excerpt content column |
|
||||
| `StoryReader.svelte` | `/geschichten/[id]` (STORY branch) | Renders sanitised rich-text body, persons section, documents section, and author actions |
|
||||
| `JourneyReader.svelte` | `/geschichten/[id]` (JOURNEY branch) | Renders intro paragraph, ordered items list, empty-state; delegates to ItemCard/Interlude |
|
||||
| `JourneyItemCard.svelte` | `JourneyReader.svelte` | Card per document item: title, meta line (date · von X an Y), "Brief öffnen →" link, mint-border note |
|
||||
| `JourneyInterlude.svelte` | `JourneyReader.svelte` | Left-accent interlude box between letters (mode-aware tokens); `aria-label="Kuratorennotiz"` |
|
||||
|
||||
## utils.ts
|
||||
|
||||
`formatAuthorName(author)` — joins `firstName + lastName`, falls back to the localized `person_unknown` key (for list/summary shapes; email is not exposed).
|
||||
`formatAuthorDisplayName(author)` — returns `displayName`, localizing the server's `[Unbekannt]` fallback (for detail `AuthorView` shape).
|
||||
`formatDocumentMetaLine(doc)` — `"12.07.1938 · von Franz an Emma"`; shared by `JourneyItemCard`, `JourneyItemRow`, and the story doc-reference cards.
|
||||
`formatPublishedAt(publishedAt, style)` — wraps `formatDate` with null check; `style` is `'short'` (list) or `'long'` (detail).
|
||||
|
||||
## Public list is PUBLISHED-only
|
||||
|
||||
`GET /api/geschichten` constrains `status=PUBLISHED`, so DRAFT journeys never appear in the list.
|
||||
The REISE badge is only ever seen for published journeys.
|
||||
Empty-state and draft-preview paths are reachable ONLY via the **detail route** (`/geschichten/[id]`), not the list.
|
||||
Wire empty-state E2E tests through the detail route, not by expecting a draft journey in the list.
|
||||
|
||||
## TypeSelector route component
|
||||
|
||||
`TypeSelector.svelte` lives in `src/routes/geschichten/new/` (single-use route UI).
|
||||
It is NOT in `$lib/geschichte/` — route-specific, not reused elsewhere.
|
||||
`StoryCreate.svelte` (also in `new/`) wraps `GeschichteEditor` so tree-shaking excludes TipTap from the JOURNEY placeholder path.
|
||||
|
||||
## Audience note
|
||||
|
||||
The `/geschichten` route primarily serves readers (younger family members on mobile). Cards must have ≥ 44 px touch targets. Status must not rely on color alone.
|
||||
The `/geschichten` route primarily serves readers (younger family members on mobile). Cards must have ≥ 44 px touch targets. Status must not rely on color alone. JourneyReader mobile layout is Critical; TypeSelector is Minor.
|
||||
|
||||
## Cross-domain imports
|
||||
|
||||
|
||||
221
frontend/src/lib/geschichte/StoryDocumentPanel.svelte
Normal file
221
frontend/src/lib/geschichte/StoryDocumentPanel.svelte
Normal file
@@ -0,0 +1,221 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { csrfFetch } from '$lib/shared/cookies';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import DocumentPickerDropdown from '$lib/document/DocumentPickerDropdown.svelte';
|
||||
import type { DocumentOption } from '$lib/document/documentTypeahead';
|
||||
|
||||
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||
|
||||
interface Props {
|
||||
geschichteId: string;
|
||||
items?: JourneyItemView[];
|
||||
}
|
||||
|
||||
let { geschichteId, items: initialItems = [] }: Props = $props();
|
||||
|
||||
const uid = $props.id();
|
||||
const pickerInputId = `story-doc-picker-${uid}`;
|
||||
|
||||
// Initial-state snapshot — the panel owns the list after mount and updates
|
||||
// it from API responses; the parent re-mounts to reset (same contract as
|
||||
// GeschichteEditor/JourneyEditor).
|
||||
// svelte-ignore state_referenced_locally
|
||||
let items: JourneyItemView[] = $state([...initialItems].sort((a, b) => a.position - b.position));
|
||||
let errorMessage = $state('');
|
||||
let liveAnnounce = $state('');
|
||||
let announceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let sectionEl: HTMLElement | null = $state(null);
|
||||
|
||||
$effect(() => () => {
|
||||
if (announceTimer) clearTimeout(announceTimer);
|
||||
});
|
||||
|
||||
const alreadyAddedIds = $derived(
|
||||
new Set(items.filter((i) => i.document).map((i) => i.document!.id))
|
||||
);
|
||||
|
||||
function announce(message: string) {
|
||||
liveAnnounce = message;
|
||||
if (announceTimer) clearTimeout(announceTimer);
|
||||
announceTimer = setTimeout(() => {
|
||||
liveAnnounce = '';
|
||||
announceTimer = null;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function itemTitle(item: JourneyItemView): string {
|
||||
return item.document?.title ?? m.geschichte_documents_deleted_placeholder();
|
||||
}
|
||||
|
||||
/** Maps a failed mutation to a user-facing message — story wording for the
|
||||
* two journey-flavored 409s, whose generic messages say "Lesereise". */
|
||||
async function failureMessage(res: Response): Promise<string> {
|
||||
const code = (await res.json().catch(() => ({})))?.code;
|
||||
if (code === 'JOURNEY_AT_CAPACITY') return m.geschichte_documents_capacity();
|
||||
if (code === 'JOURNEY_DOCUMENT_ALREADY_ADDED') return m.geschichte_documents_duplicate();
|
||||
return code ? getErrorMessage(code) : m.journey_mutation_error_reload();
|
||||
}
|
||||
|
||||
/** Pessimistic append — the list updates only with the server's response. */
|
||||
async function handleAdd(doc: DocumentOption) {
|
||||
errorMessage = '';
|
||||
try {
|
||||
const res = await csrfFetch(`/api/geschichten/${geschichteId}/items`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ documentId: doc.id })
|
||||
});
|
||||
if (!res.ok) {
|
||||
errorMessage = await failureMessage(res);
|
||||
return;
|
||||
}
|
||||
const newItem: JourneyItemView = await res.json();
|
||||
items = [...items, newItem];
|
||||
announce(m.geschichte_documents_added_announce({ title: itemTitle(newItem) }));
|
||||
} catch (e) {
|
||||
console.error('Story document add failed', e);
|
||||
errorMessage = m.journey_mutation_error_reload();
|
||||
}
|
||||
}
|
||||
|
||||
/** The removed row's button leaves the DOM — without this, focus drops to
|
||||
* <body> and a keyboard user is teleported to page top. */
|
||||
async function moveFocusAfterRemove(removedIdx: number) {
|
||||
await tick();
|
||||
if (items.length === 0) {
|
||||
sectionEl?.querySelector<HTMLElement>(`#${pickerInputId}`)?.focus();
|
||||
return;
|
||||
}
|
||||
const target = items[Math.max(removedIdx - 1, 0)];
|
||||
sectionEl
|
||||
?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(target.id)}"] [data-remove-btn]`)
|
||||
?.focus();
|
||||
}
|
||||
|
||||
/** Optimistic removal with snapshot-and-rollback. */
|
||||
async function handleRemove(item: JourneyItemView) {
|
||||
const idx = items.findIndex((i) => i.id === item.id);
|
||||
const prev = [...items];
|
||||
errorMessage = '';
|
||||
items = items.filter((i) => i.id !== item.id);
|
||||
await moveFocusAfterRemove(idx);
|
||||
try {
|
||||
const res = await csrfFetch(`/api/geschichten/${geschichteId}/items/${item.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!res.ok) {
|
||||
items = prev;
|
||||
await tick();
|
||||
sectionEl
|
||||
?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(item.id)}"] [data-remove-btn]`)
|
||||
?.focus();
|
||||
errorMessage = await failureMessage(res);
|
||||
return;
|
||||
}
|
||||
announce(m.geschichte_documents_removed_announce({ title: itemTitle(item) }));
|
||||
} catch (e) {
|
||||
console.error('Story document remove failed', e);
|
||||
items = prev;
|
||||
await tick();
|
||||
sectionEl
|
||||
?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(item.id)}"] [data-remove-btn]`)
|
||||
?.focus();
|
||||
errorMessage = m.journey_mutation_error_reload();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Screen-reader live region for add/remove confirmations -->
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only">{liveAnnounce}</div>
|
||||
|
||||
<details open class="sm:contents">
|
||||
<summary
|
||||
class="flex min-h-[44px] cursor-pointer items-center px-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:hidden"
|
||||
>
|
||||
{m.geschichte_documents_heading()}
|
||||
</summary>
|
||||
<section bind:this={sectionEl} class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||
<h2
|
||||
class="mb-2 hidden font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:block"
|
||||
>
|
||||
{m.geschichte_documents_heading()}
|
||||
</h2>
|
||||
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_documents_hint()}</p>
|
||||
|
||||
{#if errorMessage}
|
||||
<p
|
||||
role="alert"
|
||||
class="mb-3 flex items-start gap-2 rounded border border-danger bg-danger/10 px-3 py-2 font-sans text-sm text-danger"
|
||||
>
|
||||
<svg
|
||||
class="mt-0.5 h-4 w-4 flex-none"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
{errorMessage}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if items.length === 0}
|
||||
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_documents_empty()}</p>
|
||||
{:else}
|
||||
<ul class="m-0 mb-3 flex list-none flex-col p-0">
|
||||
{#each items as item (item.id)}
|
||||
<li
|
||||
data-item-id={item.id}
|
||||
class="flex items-center justify-between gap-2 border-b border-line/60 last:border-b-0"
|
||||
>
|
||||
{#if item.document}
|
||||
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink">
|
||||
{item.document.title}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink-3 italic">
|
||||
{m.geschichte_documents_deleted_placeholder()}
|
||||
</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
data-remove-btn
|
||||
onclick={() => handleRemove(item)}
|
||||
aria-label={m.geschichte_documents_remove_label({ title: itemTitle(item) })}
|
||||
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded text-ink-3 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<label for={pickerInputId} class="mb-1 block font-sans text-xs font-medium text-ink-2">
|
||||
{m.geschichte_documents_picker_label()}
|
||||
</label>
|
||||
<DocumentPickerDropdown
|
||||
inputId={pickerInputId}
|
||||
alreadyAddedIds={alreadyAddedIds}
|
||||
placeholder={m.geschichte_documents_picker_placeholder()}
|
||||
onSelect={handleAdd}
|
||||
/>
|
||||
</section>
|
||||
</details>
|
||||
446
frontend/src/lib/geschichte/StoryDocumentPanel.svelte.spec.ts
Normal file
446
frontend/src/lib/geschichte/StoryDocumentPanel.svelte.spec.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
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 StoryDocumentPanel from './StoryDocumentPanel.svelte';
|
||||
|
||||
const docSummary = (id: string, title: string) => ({
|
||||
id,
|
||||
title,
|
||||
datePrecision: 'DAY' as const,
|
||||
receiverCount: 0
|
||||
});
|
||||
|
||||
const makeItem = (
|
||||
id: string,
|
||||
position: number,
|
||||
document?: ReturnType<typeof docSummary>,
|
||||
note?: string
|
||||
) => ({ id, position, document, note });
|
||||
|
||||
/** DocumentListItem fixture as returned by the picker search endpoint. */
|
||||
const makeSearchResultItem = (id: string, title: string) => ({
|
||||
id,
|
||||
title,
|
||||
documentDate: '1880-01-01',
|
||||
metaDatePrecision: 'DAY',
|
||||
originalFilename: 'brief.pdf',
|
||||
receivers: [],
|
||||
tags: [],
|
||||
completionPercentage: 0,
|
||||
contributors: [],
|
||||
matchData: {
|
||||
titleOffsets: [],
|
||||
senderMatched: false,
|
||||
matchedReceiverIds: [],
|
||||
matchedTagIds: [],
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
},
|
||||
status: 'UPLOADED',
|
||||
metadataComplete: false,
|
||||
scriptType: 'UNKNOWN',
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00'
|
||||
});
|
||||
|
||||
type MutationResponse = { ok: boolean; status?: number; body?: object };
|
||||
|
||||
/**
|
||||
* Routes the picker's GET search to `searchItems` and every mutation
|
||||
* (POST/DELETE) to `mutation` — the panel talks to both endpoints.
|
||||
*/
|
||||
function stubFetch(searchItems: object[], mutation: MutationResponse = { ok: true, body: {} }) {
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
if (method === 'GET') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ items: searchItems })
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: mutation.ok,
|
||||
status: mutation.status ?? (mutation.ok ? 200 : 500),
|
||||
json: () => Promise.resolve(mutation.body ?? {})
|
||||
});
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
const defaultProps = (overrides: Record<string, unknown> = {}) => ({
|
||||
geschichteId: 'g1',
|
||||
items: [],
|
||||
...overrides
|
||||
});
|
||||
|
||||
async function addViaPicker(title: RegExp) {
|
||||
await userEvent.fill(page.getByRole('combobox'), 'Brief');
|
||||
await expect.element(page.getByText(title)).toBeInTheDocument();
|
||||
await userEvent.click(page.getByText(title));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('StoryDocumentPanel — rendering', () => {
|
||||
it('renders linked documents sorted by position', async () => {
|
||||
render(
|
||||
StoryDocumentPanel,
|
||||
defaultProps({
|
||||
items: [
|
||||
makeItem('i3', 30, docSummary('d3', 'Dritter Brief')),
|
||||
makeItem('i1', 10, docSummary('d1', 'Erster Brief'))
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
const rows = Array.from(document.querySelectorAll('li')).map((li) => li.textContent ?? '');
|
||||
expect(rows[0]).toContain('Erster Brief');
|
||||
expect(rows[1]).toContain('Dritter Brief');
|
||||
});
|
||||
|
||||
it('shows the empty state when no items are linked', async () => {
|
||||
render(StoryDocumentPanel, defaultProps());
|
||||
await expect.element(page.getByText(m.geschichte_documents_empty())).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a deleted-document item as placeholder row that is still removable', async () => {
|
||||
render(StoryDocumentPanel, defaultProps({ items: [makeItem('i1', 10, undefined)] }));
|
||||
|
||||
await expect
|
||||
.element(page.getByText(m.geschichte_documents_deleted_placeholder()))
|
||||
.toBeInTheDocument();
|
||||
await expect
|
||||
.element(
|
||||
page.getByRole('button', {
|
||||
name: m.geschichte_documents_remove_label({
|
||||
title: m.geschichte_documents_deleted_placeholder()
|
||||
})
|
||||
})
|
||||
)
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('wires a visible label to the picker input', async () => {
|
||||
render(StoryDocumentPanel, defaultProps());
|
||||
const input = page.getByRole('combobox').element() as HTMLInputElement;
|
||||
const label = document.querySelector(`label[for="${input.id}"]`);
|
||||
expect(label?.textContent).toContain(m.geschichte_documents_picker_label());
|
||||
});
|
||||
});
|
||||
|
||||
describe('StoryDocumentPanel — add', () => {
|
||||
it('POSTs to the items endpoint and appends the created item', async () => {
|
||||
const fetchMock = stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
|
||||
ok: true,
|
||||
body: makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))
|
||||
});
|
||||
render(StoryDocumentPanel, defaultProps());
|
||||
|
||||
await addViaPicker(/Brief von Eugenie/i);
|
||||
|
||||
const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST');
|
||||
expect(post?.[0]).toBe('/api/geschichten/g1/items');
|
||||
expect(JSON.parse(String(post?.[1]?.body))).toEqual({ documentId: 'd1' });
|
||||
const rows = Array.from(document.querySelectorAll('li')).map((li) => li.textContent ?? '');
|
||||
expect(rows.some((r) => r.includes('Brief von Eugenie'))).toBe(true);
|
||||
});
|
||||
|
||||
it('marks an already-linked document as not selectable in the dropdown', async () => {
|
||||
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')]);
|
||||
render(
|
||||
StoryDocumentPanel,
|
||||
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||
);
|
||||
|
||||
await userEvent.fill(page.getByRole('combobox'), 'Brief');
|
||||
await expect.element(page.getByRole('option')).toBeInTheDocument();
|
||||
|
||||
const option = document.querySelector('[role="listbox"] [role="option"]');
|
||||
expect(option?.getAttribute('aria-disabled')).toBe('true');
|
||||
});
|
||||
|
||||
it('renders the story-worded duplicate error on a 409 JOURNEY_DOCUMENT_ALREADY_ADDED', async () => {
|
||||
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
|
||||
ok: false,
|
||||
status: 409,
|
||||
body: { code: 'JOURNEY_DOCUMENT_ALREADY_ADDED' }
|
||||
});
|
||||
render(StoryDocumentPanel, defaultProps());
|
||||
|
||||
await addViaPicker(/Brief von Eugenie/i);
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('alert'))
|
||||
.toHaveTextContent(m.geschichte_documents_duplicate());
|
||||
});
|
||||
|
||||
it('renders the story-worded capacity error on a 409 JOURNEY_AT_CAPACITY', async () => {
|
||||
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
|
||||
ok: false,
|
||||
status: 409,
|
||||
body: { code: 'JOURNEY_AT_CAPACITY' }
|
||||
});
|
||||
render(StoryDocumentPanel, defaultProps());
|
||||
|
||||
await addViaPicker(/Brief von Eugenie/i);
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('alert'))
|
||||
.toHaveTextContent(m.geschichte_documents_capacity());
|
||||
});
|
||||
|
||||
it('announces a successful add via the polite live region', async () => {
|
||||
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
|
||||
ok: true,
|
||||
body: makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))
|
||||
});
|
||||
render(StoryDocumentPanel, defaultProps());
|
||||
|
||||
await addViaPicker(/Brief von Eugenie/i);
|
||||
|
||||
const liveRegion = document.querySelector('[aria-live="polite"]');
|
||||
expect(liveRegion?.textContent).toBe(
|
||||
m.geschichte_documents_added_announce({ title: 'Brief von Eugenie' })
|
||||
);
|
||||
});
|
||||
|
||||
it('routes a 403 response through getErrorMessage on POST', async () => {
|
||||
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
|
||||
ok: false,
|
||||
status: 403,
|
||||
body: { code: 'FORBIDDEN' }
|
||||
});
|
||||
render(StoryDocumentPanel, defaultProps());
|
||||
|
||||
await addViaPicker(/Brief von Eugenie/i);
|
||||
|
||||
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||
const alertText = page.getByRole('alert').element().textContent ?? '';
|
||||
expect(alertText).not.toBe('');
|
||||
expect(alertText).not.toContain('FORBIDDEN');
|
||||
});
|
||||
|
||||
it('shows the generic reload message when POST throws a network error', async () => {
|
||||
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')]);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if ((init?.method ?? 'GET').toUpperCase() === 'GET') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({ items: [makeSearchResultItem('d1', 'Brief von Eugenie')] })
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error('Network error'));
|
||||
})
|
||||
);
|
||||
render(StoryDocumentPanel, defaultProps());
|
||||
|
||||
await addViaPicker(/Brief von Eugenie/i);
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('alert'))
|
||||
.toHaveTextContent(m.journey_mutation_error_reload());
|
||||
});
|
||||
|
||||
it('attaches X-XSRF-TOKEN header from cookie on POST', async () => {
|
||||
document.cookie = 'XSRF-TOKEN=test-csrf-token';
|
||||
const fetchMock = stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
|
||||
ok: true,
|
||||
body: makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))
|
||||
});
|
||||
render(StoryDocumentPanel, defaultProps());
|
||||
|
||||
await addViaPicker(/Brief von Eugenie/i);
|
||||
|
||||
const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST');
|
||||
const headers = post?.[1]?.headers as Headers;
|
||||
expect(headers.get('X-XSRF-TOKEN')).toBe('test-csrf-token');
|
||||
document.cookie = 'XSRF-TOKEN=; Max-Age=0';
|
||||
});
|
||||
});
|
||||
|
||||
describe('StoryDocumentPanel — remove', () => {
|
||||
it('DELETEs the item endpoint and removes the row', async () => {
|
||||
const fetchMock = stubFetch([], { ok: true, body: {} });
|
||||
render(
|
||||
StoryDocumentPanel,
|
||||
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', {
|
||||
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||
})
|
||||
);
|
||||
|
||||
const del = fetchMock.mock.calls.find(([, init]) => init?.method === 'DELETE');
|
||||
expect(del?.[0]).toBe('/api/geschichten/g1/items/i1');
|
||||
expect(document.querySelectorAll('li').length).toBe(0);
|
||||
await expect.element(page.getByText(m.geschichte_documents_empty())).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('restores the row and shows an error when the DELETE fails', async () => {
|
||||
stubFetch([], { ok: false, status: 500, body: {} });
|
||||
render(
|
||||
StoryDocumentPanel,
|
||||
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', {
|
||||
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||
})
|
||||
);
|
||||
|
||||
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||
const rows = Array.from(document.querySelectorAll('li')).map((li) => li.textContent ?? '');
|
||||
expect(rows.some((r) => r.includes('Brief von Eugenie'))).toBe(true);
|
||||
});
|
||||
|
||||
it('moves focus to the previous row remove button instead of dropping to body', async () => {
|
||||
stubFetch([], { ok: true, body: {} });
|
||||
render(
|
||||
StoryDocumentPanel,
|
||||
defaultProps({
|
||||
items: [
|
||||
makeItem('i1', 10, docSummary('d1', 'Erster Brief')),
|
||||
makeItem('i2', 20, docSummary('d2', 'Zweiter Brief'))
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', {
|
||||
name: m.geschichte_documents_remove_label({ title: 'Zweiter Brief' })
|
||||
})
|
||||
);
|
||||
|
||||
expect(document.activeElement).not.toBe(document.body);
|
||||
expect(document.activeElement?.getAttribute('aria-label')).toBe(
|
||||
m.geschichte_documents_remove_label({ title: 'Erster Brief' })
|
||||
);
|
||||
});
|
||||
|
||||
it('moves focus to the picker input when the last item is removed', async () => {
|
||||
stubFetch([], { ok: true, body: {} });
|
||||
render(
|
||||
StoryDocumentPanel,
|
||||
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', {
|
||||
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||
})
|
||||
);
|
||||
|
||||
const input = page.getByRole('combobox').element();
|
||||
expect(document.activeElement).toBe(input);
|
||||
});
|
||||
|
||||
it('announces a successful remove via the polite live region', async () => {
|
||||
stubFetch([], { ok: true, body: {} });
|
||||
render(
|
||||
StoryDocumentPanel,
|
||||
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', {
|
||||
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||
})
|
||||
);
|
||||
|
||||
const liveRegion = document.querySelector('[aria-live="polite"]');
|
||||
expect(liveRegion?.textContent).toBe(
|
||||
m.geschichte_documents_removed_announce({ title: 'Brief von Eugenie' })
|
||||
);
|
||||
});
|
||||
|
||||
it('returns focus to the item remove button when DELETE fails with !res.ok', async () => {
|
||||
stubFetch([], { ok: false, status: 500, body: {} });
|
||||
render(
|
||||
StoryDocumentPanel,
|
||||
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', {
|
||||
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||
})
|
||||
);
|
||||
|
||||
expect(document.activeElement?.getAttribute('aria-label')).toBe(
|
||||
m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||
);
|
||||
});
|
||||
|
||||
it('returns focus to the item remove button when DELETE throws a network error', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => Promise.reject(new Error('Network error')))
|
||||
);
|
||||
render(
|
||||
StoryDocumentPanel,
|
||||
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', {
|
||||
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||
})
|
||||
);
|
||||
|
||||
expect(document.activeElement?.getAttribute('aria-label')).toBe(
|
||||
m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the generic reload message when DELETE throws a network error', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => Promise.reject(new Error('Network error')))
|
||||
);
|
||||
render(
|
||||
StoryDocumentPanel,
|
||||
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', {
|
||||
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||
})
|
||||
);
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('alert'))
|
||||
.toHaveTextContent(m.journey_mutation_error_reload());
|
||||
});
|
||||
|
||||
it('attaches X-XSRF-TOKEN header from cookie on DELETE', async () => {
|
||||
document.cookie = 'XSRF-TOKEN=test-csrf-token';
|
||||
const fetchMock = stubFetch([], { ok: true, body: {} });
|
||||
render(
|
||||
StoryDocumentPanel,
|
||||
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', {
|
||||
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||
})
|
||||
);
|
||||
|
||||
const del = fetchMock.mock.calls.find(([, init]) => init?.method === 'DELETE');
|
||||
const headers = del?.[1]?.headers as Headers;
|
||||
expect(headers.get('X-XSRF-TOKEN')).toBe('test-csrf-token');
|
||||
document.cookie = 'XSRF-TOKEN=; Max-Age=0';
|
||||
});
|
||||
});
|
||||
123
frontend/src/lib/geschichte/StoryReader.svelte
Normal file
123
frontend/src/lib/geschichte/StoryReader.svelte
Normal file
@@ -0,0 +1,123 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { safeHtml } from '$lib/shared/utils/sanitize';
|
||||
import { getInitials, personAvatarColor } from '$lib/person/personFormat';
|
||||
import { formatDocumentMetaLine } from './utils';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type GeschichteView = components['schemas']['GeschichteView'];
|
||||
|
||||
interface Props {
|
||||
geschichte: GeschichteView;
|
||||
}
|
||||
|
||||
let { geschichte: g }: Props = $props();
|
||||
|
||||
const sanitized = $derived(safeHtml(g.body));
|
||||
|
||||
const documentItems = $derived(g.items.filter((i) => i.document));
|
||||
|
||||
function personName(p: { firstName?: string; lastName?: string }): string {
|
||||
return [p.firstName, p.lastName].filter(Boolean).join(' ').trim();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--
|
||||
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.
|
||||
|
||||
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}
|
||||
</div>
|
||||
|
||||
<!-- 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}"
|
||||
style="display: inline-flex; min-height: 44px"
|
||||
class="inline-flex min-h-[44px] items-center gap-2 rounded-full border border-line bg-surface px-3 py-1.5 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-sans text-[8px] font-bold text-white"
|
||||
style="background-color: {personAvatarColor(p.id)}"
|
||||
>
|
||||
{getInitials(personName(p))}
|
||||
</span>
|
||||
{personName(p)}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Dokumente (JourneyItems) -->
|
||||
{#if documentItems.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 documentItems as item (item.id)}
|
||||
<li>
|
||||
<a
|
||||
href="/documents/{item.document!.id}"
|
||||
class="flex items-start gap-3 rounded-sm border border-line bg-surface p-3 transition-shadow hover:shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded bg-muted"
|
||||
>
|
||||
<svg class="h-4 w-4 text-ink-3" viewBox="0 0 10 12" fill="none">
|
||||
<rect
|
||||
x="1"
|
||||
y="1"
|
||||
width="8"
|
||||
height="10"
|
||||
rx="1"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
/>
|
||||
<path
|
||||
d="M3 4h4M3 6.5h4M3 9h2"
|
||||
stroke="currentColor"
|
||||
stroke-width=".8"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block font-sans text-sm leading-snug font-semibold text-ink">
|
||||
{item.document!.title}
|
||||
</span>
|
||||
{#if formatDocumentMetaLine(item.document!)}
|
||||
<span class="block font-sans text-xs text-ink-3">
|
||||
{formatDocumentMetaLine(item.document!)}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</a>
|
||||
{#if item.note}
|
||||
<!-- plaintext — do NOT use {@html} here -->
|
||||
<p class="mt-1 font-sans text-sm text-ink-3">{item.note}</p>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
134
frontend/src/lib/geschichte/StoryReader.svelte.spec.ts
Normal file
134
frontend/src/lib/geschichte/StoryReader.svelte.spec.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
const { default: StoryReader } = await import('./StoryReader.svelte');
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
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>',
|
||||
type: 'STORY',
|
||||
status: 'PUBLISHED',
|
||||
author: { id: 'u1', displayName: 'Anna Schmidt' },
|
||||
persons: [],
|
||||
items: [],
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('StoryReader', () => {
|
||||
it('renders body HTML content', async () => {
|
||||
render(StoryReader, { props: { geschichte: baseGeschichte() } });
|
||||
|
||||
await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('omits persons section when persons array is empty', async () => {
|
||||
render(StoryReader, { props: { geschichte: baseGeschichte({ persons: [] }) } });
|
||||
|
||||
await expect.element(page.getByText(/Personen in dieser Geschichte/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders persons section with firstName + lastName joined', async () => {
|
||||
render(StoryReader, {
|
||||
props: {
|
||||
geschichte: baseGeschichte({
|
||||
persons: [
|
||||
{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' },
|
||||
{ id: 'p2', firstName: 'Karl', lastName: 'Müller' }
|
||||
]
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('Personen in dieser Geschichte')).toBeVisible();
|
||||
await expect.element(page.getByText('Helene Schmidt')).toBeVisible();
|
||||
await expect.element(page.getByText('Karl Müller')).toBeVisible();
|
||||
});
|
||||
|
||||
it('omits documents section when no items have documents', async () => {
|
||||
render(StoryReader, { props: { geschichte: baseGeschichte({ items: [] }) } });
|
||||
|
||||
await expect.element(page.getByText('Erwähnte Dokumente')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders document reference cards with title and link for items with documents', async () => {
|
||||
render(StoryReader, {
|
||||
props: {
|
||||
geschichte: baseGeschichte({
|
||||
items: [
|
||||
{
|
||||
id: 'i1',
|
||||
position: 0,
|
||||
document: {
|
||||
id: 'd1',
|
||||
title: 'Brief vom 12. Juli 1938',
|
||||
documentDate: '1938-07-12',
|
||||
senderName: 'Franz Raddatz',
|
||||
receiverName: 'Emma Müller',
|
||||
datePrecision: 'DAY',
|
||||
receiverCount: 1
|
||||
} as unknown as NonNullable<components['schemas']['JourneyItemView']['document']>,
|
||||
note: 'Wichtiger Brief'
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('Erwähnte Dokumente')).toBeVisible();
|
||||
await expect.element(page.getByText('Brief vom 12. Juli 1938')).toBeVisible();
|
||||
await expect.element(page.getByText(/von Franz Raddatz an Emma Müller/)).toBeVisible();
|
||||
await expect.element(page.getByText('Wichtiger Brief')).toBeVisible();
|
||||
|
||||
const link = document.querySelector<HTMLAnchorElement>('a[href="/documents/d1"]');
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
|
||||
it('person chip link meets 44px touch-target minimum height', async () => {
|
||||
render(StoryReader, {
|
||||
props: {
|
||||
geschichte: baseGeschichte({
|
||||
persons: [{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }]
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
const link = document.querySelector<HTMLAnchorElement>('a[href^="/persons/"]');
|
||||
const rect = link?.getBoundingClientRect();
|
||||
expect(rect?.height).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
|
||||
it('person chip shows avatar initials', async () => {
|
||||
render(StoryReader, {
|
||||
props: {
|
||||
geschichte: baseGeschichte({
|
||||
persons: [{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }]
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
const chip = document.querySelector<HTMLAnchorElement>('a[href="/persons/p1"]');
|
||||
expect(chip?.textContent).toContain('HS');
|
||||
});
|
||||
|
||||
it('XSS: Story body is sanitised — injected payload does not execute', async () => {
|
||||
// StoryReader uses {@html safeHtml(g.body)} — DOMPurify must strip the payload.
|
||||
render(StoryReader, {
|
||||
props: {
|
||||
geschichte: baseGeschichte({
|
||||
body: '<img src=x onerror="(window as any).__xss_story=1">'
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
expect((window as { __xss_story?: number }).__xss_story).toBeUndefined();
|
||||
});
|
||||
});
|
||||
65
frontend/src/lib/geschichte/utils.test.ts
Normal file
65
frontend/src/lib/geschichte/utils.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatAuthorName, formatAuthorDisplayName, formatPublishedAt } from './utils';
|
||||
|
||||
describe('formatAuthorName', () => {
|
||||
it('joins firstName and lastName with a space', () => {
|
||||
expect(formatAuthorName({ firstName: 'Anna', lastName: 'Schmidt' })).toBe('Anna Schmidt');
|
||||
});
|
||||
|
||||
it('returns firstName alone when lastName is absent', () => {
|
||||
expect(formatAuthorName({ firstName: 'Anna' })).toBe('Anna');
|
||||
});
|
||||
|
||||
it('returns lastName alone when firstName is absent', () => {
|
||||
expect(formatAuthorName({ lastName: 'Schmidt' })).toBe('Schmidt');
|
||||
});
|
||||
|
||||
it('falls back to [Unbekannt] when both names are absent', () => {
|
||||
expect(formatAuthorName({})).toBe('[Unbekannt]');
|
||||
});
|
||||
|
||||
it('returns empty string for null input', () => {
|
||||
expect(formatAuthorName(null)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for undefined input', () => {
|
||||
expect(formatAuthorName(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAuthorDisplayName', () => {
|
||||
it('returns displayName when present', () => {
|
||||
expect(formatAuthorDisplayName({ displayName: 'Anna Schmidt' })).toBe('Anna Schmidt');
|
||||
});
|
||||
|
||||
it('returns empty string for null input', () => {
|
||||
expect(formatAuthorDisplayName(null)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for undefined input', () => {
|
||||
expect(formatAuthorDisplayName(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPublishedAt', () => {
|
||||
it('returns null for null input', () => {
|
||||
expect(formatPublishedAt(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for undefined input', () => {
|
||||
expect(formatPublishedAt(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('formats an ISO datetime string to a localised date', () => {
|
||||
const result = formatPublishedAt('2026-04-15T10:00:00Z', 'short');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toContain('2026');
|
||||
});
|
||||
|
||||
it('slices to date-only before formatting (no TZ off-by-one)', () => {
|
||||
// Both dates should format identically regardless of timezone offset
|
||||
const a = formatPublishedAt('2026-04-15T00:00:00Z', 'short');
|
||||
const b = formatPublishedAt('2026-04-15T23:59:59Z', 'short');
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
39
frontend/src/lib/geschichte/utils.ts
Normal file
39
frontend/src/lib/geschichte/utils.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { formatDate } from '$lib/shared/utils/date';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { joinNameOrUnknown, unknownPersonName } from '$lib/person/personFormat';
|
||||
|
||||
type AuthorSummary = { firstName?: string; lastName?: string };
|
||||
type DocumentMeta = { documentDate?: string; senderName?: string; receiverName?: string };
|
||||
type AuthorView = { displayName: string };
|
||||
|
||||
export function formatAuthorName(author: AuthorSummary | null | undefined): string {
|
||||
if (!author) return '';
|
||||
// Email is no longer exposed — names or the localized fallback only.
|
||||
return joinNameOrUnknown(author.firstName, author.lastName);
|
||||
}
|
||||
|
||||
export function formatAuthorDisplayName(author: AuthorView | null | undefined): string {
|
||||
if (!author) return '';
|
||||
// The server-side fallback is the literal '[Unbekannt]' — localize it here.
|
||||
return author.displayName === '[Unbekannt]' ? unknownPersonName() : author.displayName;
|
||||
}
|
||||
|
||||
export function formatPublishedAt(
|
||||
publishedAt: string | null | undefined,
|
||||
style: 'short' | 'long' = 'short'
|
||||
): string | null {
|
||||
if (!publishedAt) return null;
|
||||
return formatDate(publishedAt.slice(0, 10), style);
|
||||
}
|
||||
|
||||
/** "12.07.1938 · von Franz an Emma" — shared by JourneyItemCard and the story doc-reference cards. */
|
||||
export function formatDocumentMetaLine(doc: DocumentMeta): string {
|
||||
const parts: string[] = [];
|
||||
if (doc.documentDate) parts.push(formatDate(doc.documentDate, 'short'));
|
||||
if (doc.senderName && doc.receiverName) {
|
||||
parts.push(m.journey_item_meta_from_to({ sender: doc.senderName, receiver: doc.receiverName }));
|
||||
} else if (doc.senderName) {
|
||||
parts.push(doc.senderName);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
Reference in New Issue
Block a user