fix(journey): editor review round — labels, errors, pending state, a11y, tests

Addresses the remaining #792 review blockers and concerns in the journey
editor cluster:

- Interlude rows show 'Zwischentext' (dedicated key), not the add-button text
- All four mutation handlers route the backend ErrorCode through
  getErrorMessage (a 409 duplicate no longer says 'bitte Seite neu laden')
  and console.error their failures so client-side errors leave a trace
- Remove implements the spec'd pending state: row stays dimmed with an
  aria-live 'wird entfernt…' until the DELETE resolves; failure keeps the row
- Move announcements fire after the reorder resolves (no false 'verschoben')
- Touch targets ≥44px (remove ×, note links, create submit); focus moves to
  the new row after add, to a sensible neighbor after remove, back to × on
  confirm-cancel; drag handle is pointer-only; title/intro get aria-labels;
  publish-disabled reason is a visible hint, not a title tooltip
- Amber warning styles use new --color-warning-* tokens with dark remaps
- Blocked interlude-clear restores the draft instead of showing phantom text
- useBlockDragDrop moves to $lib/shared/hooks — geschichte no longer imports
  another domain's internals
- Test hardening: reorder-failure rollback (non-ok + reject), publish/
  unpublish/empty-warning surface, destructive confirm path, maxlength
  assertions, JourneyCreate failure path, edit-page STORY/JOURNEY branch,
  fixture factory, m.* assertions, all fixed sleeps replaced with polling

67 component tests green across 6 spec files; transcription consumer of the
moved hook re-verified (30 green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-10 07:55:12 +02:00
parent 7977d22d0b
commit f10b0cb73e
13 changed files with 843 additions and 268 deletions

View File

@@ -5,7 +5,7 @@ import OcrTrigger from '$lib/ocr/OcrTrigger.svelte';
import TranscribeCoachEmptyState from '$lib/shared/help/TranscribeCoachEmptyState.svelte';
import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types';
import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte';
import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte';
import { createBlockDragDrop } from '$lib/shared/hooks/useBlockDragDrop.svelte';
import { csrfFetch } from '$lib/shared/cookies';
type Props = {

View File

@@ -40,6 +40,7 @@ function handleInterludeCancel() {
<div class="flex flex-wrap gap-2">
<button
type="button"
data-add-document
onclick={() => {
showPicker = !showPicker;
showInterludeForm = false;

View File

@@ -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 JourneyAddBar from './JourneyAddBar.svelte';
afterEach(() => {
@@ -12,19 +13,25 @@ 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('Zwischentext hinzufügen'));
await userEvent.click(page.getByText(m.journey_add_interlude()));
const confirmBtn = page.getByRole('button', { name: 'Hinzufügen', exact: true });
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('Zwischentext hinzufügen'));
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: 'Hinzufügen', exact: true });
const confirmBtn = page.getByRole('button', {
name: m.journey_add_interlude_confirm(),
exact: true
});
await expect.element(confirmBtn).toBeEnabled();
});
@@ -32,12 +39,22 @@ describe('JourneyAddBar — interlude flow', () => {
const onAddInterlude = vi.fn();
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude });
await userEvent.click(page.getByText('Zwischentext hinzufügen'));
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: 'Hinzufügen', exact: true }));
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', () => {
@@ -48,7 +65,7 @@ describe('JourneyAddBar — document picker', () => {
);
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
await userEvent.click(page.getByText('Brief hinzufügen'));
await userEvent.click(page.getByText(m.journey_add_document()));
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
});

View File

@@ -1,8 +1,10 @@
<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 { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte';
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';
@@ -40,6 +42,7 @@ let items: JourneyItemView[] = $state(
let titleTouched = $state(false);
let mutationError = $state('');
let pendingRemoveIds: string[] = $state([]);
let liveAnnounce = $state('');
let announceTimer: ReturnType<typeof setTimeout> | null = null;
@@ -61,6 +64,7 @@ const canPublish = $derived(items.length > 0 && !titleEmpty);
const showPublishedEmptyWarning = $derived(status === 'PUBLISHED' && items.length === 0);
let listEl: HTMLElement | null = $state(null);
let editorColEl: HTMLElement | null = $state(null);
const dragDrop = createBlockDragDrop<JourneyItemView>({
getSortedBlocks: () => items,
@@ -71,6 +75,18 @@ $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)!);
@@ -81,17 +97,21 @@ async function handleReorder(itemIds: string[]) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemIds })
});
if (!res.ok) throw new Error('reorder failed');
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 {
} catch (e) {
console.error('Journey reorder failed', e);
items = prev;
mutationError = m.journey_mutation_error_reload();
}
}
async function handleAddDocument(doc: DocumentOption) {
const prev = [...items];
mutationError = '';
try {
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items`, {
@@ -99,17 +119,21 @@ async function handleAddDocument(doc: DocumentOption) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ documentId: doc.id })
});
if (!res.ok) throw new Error('add document failed');
if (!res.ok) {
mutationError = await failureMessage(res);
return;
}
const newItem: JourneyItemView = await res.json();
items = [...items, newItem];
} catch {
items = prev; // prev === items here (add is pessimistic); kept for symmetry with optimistic handlers
// 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 add document failed', e);
mutationError = m.journey_mutation_error_reload();
}
}
async function handleAddInterlude(text: string) {
const prev = [...items];
mutationError = '';
try {
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items`, {
@@ -117,27 +141,46 @@ async function handleAddInterlude(text: string) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note: text })
});
if (!res.ok) throw new Error('add interlude failed');
if (!res.ok) {
mutationError = await failureMessage(res);
return;
}
const newItem: JourneyItemView = await res.json();
items = [...items, newItem];
} catch {
items = prev; // prev === items here (add is pessimistic); kept for symmetry with optimistic handlers
// 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 add interlude failed', e);
mutationError = m.journey_mutation_error_reload();
}
}
async function handleRemove(itemId: string) {
const prev = [...items];
items = items.filter((i) => i.id !== itemId);
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) throw new Error('delete failed');
} catch {
items = prev;
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);
}
}
@@ -154,27 +197,27 @@ async function handleNotePatch(itemId: string, note: string | null) {
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]];
liveAnnounce = m.journey_item_moved({
position: index + 1,
total: items.length,
newPosition: index
});
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]];
liveAnnounce = m.journey_item_moved({
position: index + 1,
total: items.length,
newPosition: index + 2
});
await handleReorder(ids);
liveAnnounce = mutationError
? mutationError
: m.journey_item_moved({ position: index + 1, total, newPosition: index + 2 });
scheduleAnnounceReset();
}
@@ -196,7 +239,7 @@ async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
<!-- Editor column -->
<div class="flex flex-col gap-4">
<div bind:this={editorColEl} class="flex flex-col gap-4">
<!-- Title -->
<div>
<input
@@ -205,6 +248,7 @@ async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
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 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"
@@ -222,6 +266,7 @@ async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
bind:value={body}
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>
@@ -231,7 +276,7 @@ async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
<!-- Item list -->
{#if showPublishedEmptyWarning}
<p
class="rounded border border-amber-300 bg-amber-50 px-3 py-2 font-sans text-sm text-amber-800"
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>
@@ -275,6 +320,7 @@ async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
item={item}
index={i}
total={items.length}
pendingRemove={pendingRemoveIds.includes(item.id)}
onMoveUp={() => handleMoveUp(i)}
onMoveDown={() => handleMoveDown(i)}
onRemove={() => handleRemove(item.id)}
@@ -302,42 +348,47 @@ async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
<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 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-amber-700 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>
<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>

View File

@@ -1,12 +1,40 @@
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';
const docSummary = (id: string, title: string) => ({
id,
title,
datePrecision: 'DAY' as const
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> = {}) => ({
@@ -51,14 +79,19 @@ describe('JourneyEditor — empty state', () => {
await expect.element(page.getByPlaceholder(/Einleitung/)).toBeInTheDocument();
});
it('publish button disabled when no items', async () => {
it('labels the title input and intro textarea for screen readers', async () => {
render(JourneyEditor, defaultProps());
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
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('Diese Lesereise ist noch leer.')).toBeInTheDocument();
await expect.element(page.getByText(m.journey_empty_state())).toBeInTheDocument();
});
});
@@ -77,7 +110,17 @@ describe('JourneyEditor — items in position order', () => {
});
});
describe('JourneyEditor — publish disabled when title empty', () => {
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,
@@ -96,107 +139,6 @@ describe('JourneyEditor — publish disabled when title empty', () => {
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled();
});
});
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') };
mockCsrfFetch(() => newItem);
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValueOnce({
// picker search results
ok: true,
json: vi.fn().mockResolvedValue({
items: [
{
id: 'd1',
title: 'Brief von Karl',
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'
}
]
})
})
.mockResolvedValueOnce({
// POST /items
ok: true,
json: vi.fn().mockResolvedValue(newItem)
})
);
render(JourneyEditor, defaultProps());
await userEvent.click(page.getByText('Brief hinzufügen'));
await userEvent.fill(page.getByRole('combobox'), 'Karl');
await new Promise((r) => setTimeout(r, 350)); // wait debounce
await userEvent.click(page.getByText(/Brief von Karl/));
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('Zwischentext hinzufügen'));
await userEvent.fill(page.getByPlaceholder('Zwischentext eingeben…'), 'Reise nach Wien');
await userEvent.click(page.getByRole('button', { name: 'Hinzufügen', exact: true }));
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items'),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ note: 'Reise nach Wien' })
})
);
});
});
describe('JourneyEditor — remove with rollback', () => {
it('restores item 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: 'Eintrag entfernen' }));
await new Promise((r) => setTimeout(r, 50));
// Item should be restored after rollback
await expect.element(page.getByText('Brief A')).toBeInTheDocument();
});
it('item-add enables publish button (isDirty stays false, canPublish becomes true)', async () => {
const newItem = { id: 'i1', position: 0, note: 'Test' };
@@ -208,14 +150,248 @@ describe('JourneyEditor — remove with rollback', () => {
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
// Add interlude
await userEvent.click(page.getByText('Zwischentext hinzufügen'));
await userEvent.fill(page.getByPlaceholder('Zwischentext eingeben…'), 'Test');
await userEvent.click(page.getByRole('button', { name: 'Hinzufügen', exact: true }));
await new Promise((r) => setTimeout(r, 50));
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() }));
// 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() }));
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() }));
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() }));
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', () => {
@@ -238,15 +414,16 @@ describe('JourneyEditor — reorder via move buttons', () => {
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
await new Promise((r) => setTimeout(r, 50)); // handleMoveUp → handleReorder → csrfFetch: two await levels
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items/reorder'),
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ itemIds: ['i2', 'i1'] })
})
);
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 () => {
@@ -268,20 +445,61 @@ describe('JourneyEditor — reorder via move buttons', () => {
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief A.*nach unten verschieben/ }));
await new Promise((r) => setTimeout(r, 50)); // handleMoveDown → handleReorder → csrfFetch: two await levels
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items/reorder'),
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ itemIds: ['i2', 'i1'] })
})
await vi.waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items/reorder'),
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ itemIds: ['i2', 'i1'] })
})
);
});
});
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('clears the live announce region 500ms after a move operation', async () => {
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') }
@@ -300,13 +518,38 @@ describe('JourneyEditor — live announce region', () => {
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
await new Promise((r) => setTimeout(r, 50)); // wait for csrfFetch
const liveRegion = document.querySelector('[aria-live="polite"]');
expect((liveRegion?.textContent ?? '').trim().length).toBeGreaterThan(0);
await vi.waitFor(() => {
expect((liveRegion?.textContent ?? '').trim().length).toBeGreaterThan(0);
});
await new Promise((r) => setTimeout(r, 650)); // 500ms clear timeout + buffer
expect((liveRegion?.textContent ?? '').trim()).toBe('');
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());
});
});
});
@@ -330,15 +573,16 @@ describe('JourneyEditor — note patch body', () => {
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
await userEvent.clear(textarea);
await textarea.element().dispatchEvent(new FocusEvent('blur'));
await new Promise((r) => setTimeout(r, 50));
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items/i1'),
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({ note: null })
})
);
await vi.waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items/i1'),
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({ note: null })
})
);
});
});
});
@@ -349,44 +593,17 @@ describe('JourneyEditor — duplicate document aria-disabled', () => {
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({
items: [
{
id: 'd1',
title: 'Brief von Karl',
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'
}
]
})
json: vi.fn().mockResolvedValue({ items: [makeSearchResultItem('d1', 'Brief von Karl')] })
})
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByText('Brief hinzufügen'));
await userEvent.click(page.getByText(m.journey_add_document()));
await userEvent.fill(page.getByRole('combobox'), 'Karl');
await new Promise((r) => setTimeout(r, 350));
// 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()

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { tick } from 'svelte';
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
@@ -8,18 +9,29 @@ 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, onMoveUp, onMoveDown, onRemove, onNotePatch }: Props = $props();
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_add_interlude());
const itemTitle = $derived(item.document?.title ?? m.journey_interlude_label());
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);
@@ -29,7 +41,12 @@ let showRemoveConfirm = $state(false);
async function handleNoteBlur() {
if (noteSaving) return;
if (noteDraft === item.note) return;
if (isInterlude && noteDraft.trim().length === 0) return;
if (isInterlude && noteDraft.trim().length === 0) {
// 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 = '';
@@ -64,23 +81,37 @@ function handleRemoveClick() {
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-[var(--color-interlude-border)] bg-[var(--color-interlude-bg)]'
: 'border-line bg-surface'
].join(' ')}
>
<div class="flex min-w-0 items-start gap-1 px-2 py-2">
<!-- Drag handle (desktop) -->
<!-- Drag handle (desktop, pointer-only — keyboard users reorder via the move buttons) -->
<button
type="button"
data-drag-handle
aria-label={m.journey_drag_aria_label({ title: itemTitle })}
tabindex="-1"
aria-hidden="true"
class="hidden shrink-0 cursor-grab items-center justify-center text-ink-3 transition-colors hover:text-ink active:cursor-grabbing md:flex"
style="min-height: 44px; min-width: 44px;"
>
@@ -91,6 +122,7 @@ function handleRemoveClick() {
<div class="flex shrink-0 flex-col">
<button
type="button"
data-move-up
onclick={onMoveUp}
disabled={index === 0}
aria-label={m.journey_move_up({ title: itemTitle })}
@@ -120,7 +152,7 @@ function handleRemoveClick() {
class="font-sans text-xs font-bold tracking-widest uppercase"
style="color: var(--color-interlude-label);"
>
{m.journey_add_interlude()}
{m.journey_interlude_label()}
</span>
{:else}
<span class="font-sans text-xs text-ink-3">{index + 1}.</span>
@@ -128,21 +160,25 @@ function handleRemoveClick() {
{/if}
</div>
<!-- Remove button / confirm -->
<!-- Remove button / confirm / pending -->
<div class="shrink-0">
{#if showRemoveConfirm}
{#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 class="flex items-center gap-2">
<span class="font-sans text-xs text-ink-2">{m.journey_remove_confirm()}</span>
<button
type="button"
onclick={onRemove}
onclick={handleRemoveConfirm}
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"
onclick={() => (showRemoveConfirm = false)}
onclick={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()}
@@ -151,9 +187,10 @@ function handleRemoveClick() {
{:else}
<button
type="button"
data-remove-btn
onclick={handleRemoveClick}
aria-label={m.journey_remove_item_aria()}
class="-m-1 rounded p-3 text-ink-3 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
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"
@@ -186,7 +223,7 @@ function handleRemoveClick() {
<button
type="button"
onclick={handleNoteRemove}
class="font-sans text-xs text-ink-3 underline hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
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>
@@ -203,7 +240,7 @@ function handleRemoveClick() {
onclick={() => {
showNote = true;
}}
class="font-sans text-xs text-ink-3 underline hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
class="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>

View File

@@ -1,12 +1,18 @@
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 },
document: {
id: 'doc-1',
title: 'Brief von Karl',
datePrecision: 'DAY' as const,
receiverCount: 0
},
...overrides
});
@@ -28,11 +34,32 @@ const defaultProps = (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('Notiz hinzufügen'));
await userEvent.click(page.getByText(m.journey_note_add()));
await expect
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ }))
@@ -43,13 +70,20 @@ describe('JourneyItemRow — note textarea', () => {
const onNotePatch = vi.fn().mockResolvedValue(undefined);
render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) });
await userEvent.click(page.getByText('Notiz hinzufügen'));
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', () => {
@@ -57,7 +91,7 @@ describe('JourneyItemRow — note error state', () => {
const onNotePatch = vi.fn().mockRejectedValue(new Error('server error'));
render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) });
await userEvent.click(page.getByText('Notiz hinzufügen'));
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'));
@@ -74,8 +108,7 @@ describe('JourneyItemRow — note remove error state', () => {
...defaultProps({ onNotePatch })
});
await userEvent.click(page.getByText('Notiz entfernen'));
await new Promise((r) => setTimeout(r, 50));
await userEvent.click(page.getByText(m.journey_note_remove()));
// textarea should be visible again (showNote restored)
await expect
@@ -95,7 +128,7 @@ describe('JourneyItemRow — interlude rules', () => {
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ }))
.toBeInTheDocument();
// But "Notiz entfernen" must be absent
await expect.element(page.getByText('Notiz entfernen')).not.toBeInTheDocument();
await expect.element(page.getByText(m.journey_note_remove())).not.toBeInTheDocument();
});
it('blocks saving empty text on interlude note blur', async () => {
@@ -111,6 +144,19 @@ describe('JourneyItemRow — interlude rules', () => {
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', () => {
@@ -121,10 +167,25 @@ describe('JourneyItemRow — remove confirm', () => {
});
// Click remove (x button)
await userEvent.click(page.getByRole('button', { name: 'Eintrag entfernen' }));
await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() }));
await expect.element(page.getByText('Wirklich entfernen?')).toBeInTheDocument();
await expect.element(page.getByRole('button', { name: 'Bestätigen' })).toBeInTheDocument();
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() }));
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 () => {
@@ -134,13 +195,55 @@ describe('JourneyItemRow — remove confirm', () => {
...defaultProps({ onRemove })
});
await userEvent.click(page.getByRole('button', { name: 'Eintrag entfernen' }));
await userEvent.click(page.getByRole('button', { name: 'Abbrechen' }));
await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() }));
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: 'Eintrag entfernen' }))
.element(page.getByRole('button', { name: m.journey_remove_item_aria() }))
.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() }));
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() }).element();
expect(document.activeElement).toBe(removeBtn);
});
});
});
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() }))
.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');
});
});

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
vi.mock('$app/navigation', () => ({
beforeNavigate: () => {},
@@ -21,13 +22,20 @@ const { default: GeschichtenEditPage } = await import('./+page.svelte');
afterEach(cleanup);
const baseData = (overrides: Record<string, unknown> = {}) => ({
user: undefined,
canWrite: true,
canAnnotate: false,
canBlogWrite: true,
geschichte: {
id: 'g1',
title: 'Die Reise nach Berlin',
body: '<p>Im Jahr 1923...</p>',
status: 'PUBLISHED' as 'DRAFT' | 'PUBLISHED',
type: 'STORY' as 'STORY' | 'JOURNEY',
persons: [],
documents: []
items: [],
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
},
...overrides
});
@@ -60,4 +68,50 @@ describe('geschichten/[id]/edit page', () => {
const inputs = document.querySelectorAll('input, textarea, [contenteditable]');
expect(inputs.length).toBeGreaterThan(0);
});
it('renders the JourneyEditor (add-bar, no TipTap toolbar) for JOURNEY-type geschichten', async () => {
render(GeschichtenEditPage, {
props: {
data: baseData({
geschichte: {
id: 'g1',
title: 'Die Reise nach Berlin',
body: '',
status: 'DRAFT' as const,
type: 'JOURNEY' as const,
persons: [],
items: [],
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
}
})
}
});
await expect.element(page.getByText(m.journey_add_document())).toBeVisible();
expect(document.querySelector('[role="toolbar"]')).toBeNull();
});
it('renders the GeschichteEditor (TipTap toolbar, no add-bar) for STORY-type geschichten', async () => {
render(GeschichtenEditPage, {
props: {
data: baseData({
geschichte: {
id: 'g1',
title: 'Die Reise nach Berlin',
body: '<p>Im Jahr 1923...</p>',
status: 'DRAFT' as const,
type: 'STORY' as const,
persons: [],
items: [],
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
}
})
}
});
await expect.element(page.getByRole('toolbar')).toBeVisible();
await expect.element(page.getByText(m.journey_add_document())).not.toBeInTheDocument();
});
});

View File

@@ -60,6 +60,7 @@ async function handleSubmit(e: SubmitEvent) {
bind:value={title}
onblur={() => (titleTouched = true)}
placeholder={m.geschichte_editor_title_placeholder()}
aria-label={m.journey_title_aria_label()}
aria-invalid={showTitleError}
class="block w-full rounded border px-3 py-2 font-serif text-lg text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {showTitleError
? 'border-danger'
@@ -74,7 +75,7 @@ async function handleSubmit(e: SubmitEvent) {
<button
type="submit"
disabled={submitting}
class="rounded bg-brand-navy px-4 py-2 font-sans text-sm font-medium text-white hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-50"
class="inline-flex h-11 items-center rounded bg-brand-navy px-4 font-sans text-sm font-medium text-white hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-50"
>
{m.journey_create_submit()}
</button>

View File

@@ -0,0 +1,73 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import { getErrorMessage } from '$lib/shared/errors';
vi.mock('$app/navigation', () => ({
goto: vi.fn()
}));
const { default: JourneyCreate } = await import('./JourneyCreate.svelte');
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('JourneyCreate — failure path', () => {
it('renders the mapped error message when POST /api/geschichten fails with a code', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
json: vi.fn().mockResolvedValue({ code: 'VALIDATION_ERROR' })
})
);
render(JourneyCreate, {});
await userEvent.fill(
page.getByRole('textbox', { name: m.journey_title_aria_label() }),
'Meine Lesereise'
);
await userEvent.click(page.getByRole('button', { name: m.journey_create_submit() }));
const alert = page.getByRole('alert');
await expect.element(alert).toBeInTheDocument();
await expect.element(alert).toHaveTextContent(getErrorMessage('VALIDATION_ERROR'));
});
it('navigates to the edit page on success', async () => {
const { goto } = await import('$app/navigation');
vi.mocked(goto).mockClear();
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ id: 'g-new' })
})
);
render(JourneyCreate, {});
await userEvent.fill(
page.getByRole('textbox', { name: m.journey_title_aria_label() }),
'Meine Lesereise'
);
await userEvent.click(page.getByRole('button', { name: m.journey_create_submit() }));
await vi.waitFor(() => {
expect(goto).toHaveBeenCalledWith('/geschichten/g-new/edit');
});
});
it('has an accessible label on the title input', async () => {
vi.stubGlobal('fetch', vi.fn());
render(JourneyCreate, {});
await expect
.element(page.getByRole('textbox', { name: m.journey_title_aria_label() }))
.toBeInTheDocument();
});
});

View File

@@ -77,6 +77,11 @@
--color-warning: #b45309;
--color-warning-fg: #ffffff;
/* Warning surface — amber banner (bg/border/text), mode-aware */
--color-warning-bg: var(--c-warning-bg);
--color-warning-border: var(--c-warning-border);
--color-warning-text: var(--c-warning-text);
/* Journey / Lesereise — orange semantic tokens (badge, interlude block, annotation) */
--color-journey-tint: var(--c-journey-bg);
--color-journey: var(--c-journey-text);
@@ -149,6 +154,11 @@
--c-interlude-border: #a1dcd8;
--c-interlude-label: #4b5563;
/* Warning surface — amber banner; text #92400E on #FFFBEB ≈ 7.7:1 — WCAG AAA ✓ */
--c-warning-bg: #fffbeb;
--c-warning-border: #fcd34d;
--c-warning-text: #92400e;
/* Tag color tokens — decorative dot colors on tag chips */
--c-tag-sage: #5a8a6a;
--c-tag-sienna: #a0522d;
@@ -278,6 +288,12 @@
--c-interlude-bg: #151c22;
--c-interlude-border: #00c7b1;
--c-interlude-label: #8b97a5;
/* Warning surface — muted amber on dark; text #FBD38D on #2A2113 ≈ 9.5:1 — WCAG AAA ✓
KEEP IN SYNC with :root[data-theme='dark'] */
--c-warning-bg: #2a2113;
--c-warning-border: #6d5417;
--c-warning-text: #fbd38d;
}
}
@@ -363,6 +379,11 @@
--c-interlude-bg: #151c22;
--c-interlude-border: #00c7b1;
--c-interlude-label: #8b97a5;
/* Warning surface — KEEP IN SYNC with the @media block above */
--c-warning-bg: #2a2113;
--c-warning-border: #6d5417;
--c-warning-text: #fbd38d;
}
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */