diff --git a/frontend/src/lib/geschichte/JourneyEditor.svelte b/frontend/src/lib/geschichte/JourneyEditor.svelte new file mode 100644 index 00000000..ccc2a81a --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyEditor.svelte @@ -0,0 +1,321 @@ + + + +
{liveAnnounce}
+ +
+ +
+ +
+ 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} + + {/if} +
+ + +
+ +

{m.journey_intro_save_hint()}

+
+ + + {#if showPublishedEmptyWarning} +

+ {m.journey_published_empty_warning()} +

+ {/if} + + {#if mutationError} + + {/if} + + +
dragDrop.handlePointerMove(e)} + onpointerup={() => dragDrop.handlePointerUp()} + class="flex flex-col gap-2" + > + {#each items as item, i (item.id)} + +
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} +
+ {/if} + handleMoveUp(i)} + onMoveDown={() => handleMoveDown(i)} + onRemove={() => handleRemove(item.id)} + onNotePatch={(note) => handleNotePatch(item.id, note)} + /> +
+ {/each} +
+ + +
+ + + +
+ + +
+

+ {isDraft ? m.geschichte_editor_save_hint_draft() : m.journey_save_hint_published()} +

+
+ {#if isDraft} + + + {:else} + + + {/if} +
+
diff --git a/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts new file mode 100644 index 00000000..2fd455e8 --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts @@ -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 = {}) => ({ + 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 = {}) => ({ + 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'); + }); +});