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:
@@ -40,6 +40,7 @@ function handleInterludeCancel() {
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-add-document
|
||||
onclick={() => {
|
||||
showPicker = !showPicker;
|
||||
showInterludeForm = false;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user