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:
Marcel
2026-06-09 12:55:39 +02:00
parent 9a178210fa
commit a17eec537f
2 changed files with 582 additions and 0 deletions

View 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');
});
});