feat(journey-editor): build JourneyEditor orchestrator
Main editing surface for JOURNEY-type Geschichten. Manages sorted item list with optimistic add/remove/reorder (rollback on failure), drag-and-drop reorder via createBlockDragDrop, intro textarea, and sidebar via GeschichteSidebar. Publish requires at least one item + non-empty title. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
321
frontend/src/lib/geschichte/JourneyEditor.svelte
Normal file
321
frontend/src/lib/geschichte/JourneyEditor.svelte
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
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 { createUnsavedWarning } from '$lib/shared/hooks/useUnsavedWarning.svelte';
|
||||||
|
import GeschichteSidebar from './GeschichteSidebar.svelte';
|
||||||
|
import JourneyItemRow from './JourneyItemRow.svelte';
|
||||||
|
import JourneyAddBar from './JourneyAddBar.svelte';
|
||||||
|
|
||||||
|
type GeschichteView = components['schemas']['GeschichteView'];
|
||||||
|
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||||
|
type Person = components['schemas']['Person'];
|
||||||
|
type DocumentListItem = components['schemas']['DocumentListItem'];
|
||||||
|
type DocumentOption = Pick<
|
||||||
|
DocumentListItem,
|
||||||
|
'id' | 'title' | 'documentDate' | 'metaDatePrecision' | 'metaDateEnd'
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
geschichte: GeschichteView;
|
||||||
|
onSubmit: (payload: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
status: 'DRAFT' | 'PUBLISHED';
|
||||||
|
personIds: string[];
|
||||||
|
}) => Promise<void>;
|
||||||
|
submitting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { geschichte, onSubmit, submitting = false }: Props = $props();
|
||||||
|
|
||||||
|
const unsaved = createUnsavedWarning();
|
||||||
|
|
||||||
|
let title = $state(geschichte.title ?? '');
|
||||||
|
let body = $state(geschichte.body ?? '');
|
||||||
|
let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte.status ?? 'DRAFT');
|
||||||
|
let selectedPersons: Person[] = $state(geschichte.persons ? Array.from(geschichte.persons) : []);
|
||||||
|
let items: JourneyItemView[] = $state(
|
||||||
|
[...(geschichte.items ?? [])].sort((a, b) => a.position - b.position)
|
||||||
|
);
|
||||||
|
|
||||||
|
let titleTouched = $state(false);
|
||||||
|
let mutationError = $state('');
|
||||||
|
let liveAnnounce = $state('');
|
||||||
|
|
||||||
|
const titleEmpty = $derived(title.trim().length === 0);
|
||||||
|
const showTitleError = $derived(titleEmpty && titleTouched);
|
||||||
|
const isDraft = $derived(status === 'DRAFT');
|
||||||
|
const alreadyAddedIds = $derived(
|
||||||
|
new Set(items.filter((i) => i.document).map((i) => i.document!.id))
|
||||||
|
);
|
||||||
|
const canPublish = $derived(items.length > 0 && !titleEmpty);
|
||||||
|
const showPublishedEmptyWarning = $derived(status === 'PUBLISHED' && items.length === 0);
|
||||||
|
|
||||||
|
let listEl: HTMLElement | null = $state(null);
|
||||||
|
|
||||||
|
const dragDrop = createBlockDragDrop<JourneyItemView>({
|
||||||
|
getSortedBlocks: () => items,
|
||||||
|
onReorder: handleReorder
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
dragDrop.setListElement(listEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleReorder(itemIds: string[]) {
|
||||||
|
const prev = [...items];
|
||||||
|
items = itemIds.map((id) => items.find((i) => i.id === id)!);
|
||||||
|
mutationError = '';
|
||||||
|
try {
|
||||||
|
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/reorder`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ itemIds })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('reorder failed');
|
||||||
|
const updated: JourneyItemView[] = await res.json();
|
||||||
|
items = updated.sort((a, b) => a.position - b.position);
|
||||||
|
} catch {
|
||||||
|
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`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ documentId: doc.id })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('add document failed');
|
||||||
|
const newItem: JourneyItemView = await res.json();
|
||||||
|
items = [...items, newItem];
|
||||||
|
} catch {
|
||||||
|
items = prev;
|
||||||
|
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`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ note: text })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('add interlude failed');
|
||||||
|
const newItem: JourneyItemView = await res.json();
|
||||||
|
items = [...items, newItem];
|
||||||
|
} catch {
|
||||||
|
items = prev;
|
||||||
|
mutationError = m.journey_mutation_error_reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove(itemId: string) {
|
||||||
|
const prev = [...items];
|
||||||
|
items = items.filter((i) => i.id !== itemId);
|
||||||
|
mutationError = '';
|
||||||
|
try {
|
||||||
|
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/${itemId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('delete failed');
|
||||||
|
} catch {
|
||||||
|
items = prev;
|
||||||
|
mutationError = m.journey_mutation_error_reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNotePatch(itemId: string, note: string | null) {
|
||||||
|
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/${itemId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ note: note ?? undefined })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('note patch failed');
|
||||||
|
const updated: JourneyItemView = await res.json();
|
||||||
|
items = items.map((i) => (i.id === itemId ? updated : i));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMoveUp(index: number) {
|
||||||
|
if (index === 0) return;
|
||||||
|
const ids = items.map((i) => i.id);
|
||||||
|
[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]];
|
||||||
|
liveAnnounce = m.journey_item_moved();
|
||||||
|
await handleReorder(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMoveDown(index: number) {
|
||||||
|
if (index === items.length - 1) return;
|
||||||
|
const ids = items.map((i) => i.id);
|
||||||
|
[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]];
|
||||||
|
liveAnnounce = m.journey_item_moved();
|
||||||
|
await handleReorder(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
|
||||||
|
titleTouched = true;
|
||||||
|
if (titleEmpty) return;
|
||||||
|
await onSubmit({
|
||||||
|
title: title.trim(),
|
||||||
|
body,
|
||||||
|
status: nextStatus,
|
||||||
|
personIds: selectedPersons.map((p) => p.id!).filter(Boolean)
|
||||||
|
});
|
||||||
|
unsaved.clearOnSuccess();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Screen-reader live region for move announcements -->
|
||||||
|
<div aria-live="polite" aria-atomic="true" class="sr-only">{liveAnnounce}</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
|
||||||
|
<!-- Editor column -->
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<!-- Title -->
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={title}
|
||||||
|
oninput={() => unsaved.markDirty()}
|
||||||
|
onblur={() => (titleTouched = true)}
|
||||||
|
placeholder={m.geschichte_editor_title_placeholder()}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
{#if showTitleError}
|
||||||
|
<p id="journey-title-error" class="mt-1 text-sm text-danger" role="alert">
|
||||||
|
{m.geschichte_editor_title_required()}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Intro textarea -->
|
||||||
|
<div>
|
||||||
|
<textarea
|
||||||
|
bind:value={body}
|
||||||
|
oninput={() => unsaved.markDirty()}
|
||||||
|
placeholder={m.journey_intro_placeholder()}
|
||||||
|
rows={3}
|
||||||
|
class="block w-full resize-y rounded border border-line bg-surface px-3 py-2 font-serif text-base text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
></textarea>
|
||||||
|
<p class="mt-1 font-sans text-xs text-ink-3">{m.journey_intro_save_hint()}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Item list -->
|
||||||
|
{#if showPublishedEmptyWarning}
|
||||||
|
<p
|
||||||
|
class="rounded border border-amber-300 bg-amber-50 px-3 py-2 font-sans text-sm text-amber-800"
|
||||||
|
>
|
||||||
|
{m.journey_published_empty_warning()}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if mutationError}
|
||||||
|
<p
|
||||||
|
class="rounded border border-danger bg-danger/10 px-3 py-2 font-sans text-sm text-danger"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{mutationError}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
bind:this={listEl}
|
||||||
|
onpointermove={(e) => dragDrop.handlePointerMove(e)}
|
||||||
|
onpointerup={() => dragDrop.handlePointerUp()}
|
||||||
|
class="flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
{#each items as item, i (item.id)}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
data-block-wrapper
|
||||||
|
onpointerdown={(e) => dragDrop.handleGripDown(e, item.id)}
|
||||||
|
class="transition-all duration-150 {dragDrop.draggedBlockId === item.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-focus-ring/40' : ''}"
|
||||||
|
style={dragDrop.draggedBlockId === item.id
|
||||||
|
? `transform: translateY(${dragDrop.dragOffsetY}px) scale(1.02); opacity: 0.9;`
|
||||||
|
: ''}
|
||||||
|
>
|
||||||
|
{#if dragDrop.dropTargetIdx === i}
|
||||||
|
<div class="mb-1 h-1 rounded-full bg-accent transition-all"></div>
|
||||||
|
{/if}
|
||||||
|
<JourneyItemRow
|
||||||
|
item={item}
|
||||||
|
index={i}
|
||||||
|
total={items.length}
|
||||||
|
onMoveUp={() => handleMoveUp(i)}
|
||||||
|
onMoveDown={() => handleMoveDown(i)}
|
||||||
|
onRemove={() => handleRemove(item.id)}
|
||||||
|
onNotePatch={(note) => handleNotePatch(item.id, note)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<JourneyAddBar
|
||||||
|
alreadyAddedIds={alreadyAddedIds}
|
||||||
|
onAddDocument={handleAddDocument}
|
||||||
|
onAddInterlude={handleAddInterlude}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<GeschichteSidebar status={status} bind:selectedPersons={selectedPersons} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save bar -->
|
||||||
|
<div
|
||||||
|
class="sticky bottom-0 z-10 -mx-4 mt-6 flex flex-col gap-3 border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<p class="font-sans text-xs text-ink-3">
|
||||||
|
{isDraft ? m.geschichte_editor_save_hint_draft() : m.journey_save_hint_published()}
|
||||||
|
</p>
|
||||||
|
<div class="flex 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>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
261
frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts
Normal file
261
frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page, userEvent } from 'vitest/browser';
|
||||||
|
import JourneyEditor from './JourneyEditor.svelte';
|
||||||
|
|
||||||
|
const docSummary = (id: string, title: string) => ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
datePrecision: 'DAY' as const
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeGeschichte = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
id: 'g1',
|
||||||
|
title: 'Briefe der Familie Raddatz',
|
||||||
|
body: '',
|
||||||
|
status: 'DRAFT' as const,
|
||||||
|
type: 'JOURNEY' as const,
|
||||||
|
persons: [],
|
||||||
|
items: [],
|
||||||
|
createdAt: '2024-01-01T00:00:00',
|
||||||
|
updatedAt: '2024-01-01T00:00:00',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultProps = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
geschichte: makeGeschichte(),
|
||||||
|
onSubmit: vi.fn().mockResolvedValue(undefined),
|
||||||
|
submitting: false,
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockCsrfFetch(responseFactory: () => object) {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: vi.fn().mockResolvedValue(responseFactory())
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyEditor — empty state', () => {
|
||||||
|
it('renders title input and intro textarea', async () => {
|
||||||
|
render(JourneyEditor, defaultProps());
|
||||||
|
await expect.element(page.getByRole('textbox', { name: /Titel/ })).not.toBeInTheDocument(); // input has no aria-label
|
||||||
|
// title input has placeholder text
|
||||||
|
await expect.element(page.getByPlaceholder(/Titel/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publish button disabled when no items', async () => {
|
||||||
|
render(JourneyEditor, defaultProps());
|
||||||
|
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyEditor — items in position order', () => {
|
||||||
|
it('renders items sorted by position', async () => {
|
||||||
|
const items = [
|
||||||
|
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') },
|
||||||
|
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }
|
||||||
|
];
|
||||||
|
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||||
|
|
||||||
|
const titles = await page.getByText(/Brief [AB]/).all();
|
||||||
|
expect(titles.length).toBeGreaterThanOrEqual(2);
|
||||||
|
// Brief A should appear before Brief B (position 0 first)
|
||||||
|
const textContent = document.body.textContent ?? '';
|
||||||
|
expect(textContent.indexOf('Brief A')).toBeLessThan(textContent.indexOf('Brief B'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyEditor — publish disabled when title empty', () => {
|
||||||
|
it('publish stays disabled until title is non-empty', async () => {
|
||||||
|
render(
|
||||||
|
JourneyEditor,
|
||||||
|
defaultProps({
|
||||||
|
geschichte: makeGeschichte({
|
||||||
|
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
|
||||||
|
|
||||||
|
const titleInput = page.getByPlaceholder(/Titel/);
|
||||||
|
await userEvent.fill(titleInput, 'Meine Reise');
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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.getByRole('option', { name: /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.getByRole('textbox'), 'Reise nach Wien');
|
||||||
|
await userEvent.click(page.getByRole('button', { name: 'Hinzufügen' }));
|
||||||
|
|
||||||
|
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: 'Wirklich 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 does NOT mark dirty (isDirty stays false)', async () => {
|
||||||
|
const newItem = { id: 'i1', position: 0, note: 'Test' };
|
||||||
|
mockCsrfFetch(() => newItem);
|
||||||
|
|
||||||
|
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(JourneyEditor, defaultProps({ onSubmit }));
|
||||||
|
|
||||||
|
// Add interlude (no unsaved warning should interfere)
|
||||||
|
await userEvent.click(page.getByText('Zwischentext hinzufügen'));
|
||||||
|
await userEvent.fill(page.getByRole('textbox'), 'Test');
|
||||||
|
await userEvent.click(page.getByRole('button', { name: 'Hinzufügen' }));
|
||||||
|
|
||||||
|
// Saving (which requires non-empty title) — no unsaved warning dialog
|
||||||
|
await expect.element(page.getByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyEditor — duplicate document aria-disabled', () => {
|
||||||
|
it('already-added document appears as aria-disabled in picker', async () => {
|
||||||
|
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') }];
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: vi.fn().mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||||
|
|
||||||
|
await userEvent.click(page.getByText('Brief hinzufügen'));
|
||||||
|
await userEvent.fill(page.getByRole('combobox'), 'Karl');
|
||||||
|
await new Promise((r) => setTimeout(r, 350));
|
||||||
|
|
||||||
|
const option = page.getByRole('option', { name: /Brief von Karl/ });
|
||||||
|
await expect.element(option).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user