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:
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