diff --git a/frontend/src/lib/geschichte/JourneyEditor.svelte b/frontend/src/lib/geschichte/JourneyEditor.svelte index 5c418a1e..8060776f 100644 --- a/frontend/src/lib/geschichte/JourneyEditor.svelte +++ b/frontend/src/lib/geschichte/JourneyEditor.svelte @@ -11,6 +11,7 @@ import type { DocumentOption } from '$lib/document/documentTypeahead'; import GeschichteSidebar from './GeschichteSidebar.svelte'; import JourneyItemRow from './JourneyItemRow.svelte'; import JourneyAddBar from './JourneyAddBar.svelte'; +import UnsavedWarningBanner from '$lib/shared/primitives/UnsavedWarningBanner.svelte'; type GeschichteView = components['schemas']['GeschichteView']; type JourneyItemView = components['schemas']['JourneyItemView']; @@ -224,19 +225,27 @@ async function handleMoveDown(index: number) { 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(); + try { + await onSubmit({ + title: title.trim(), + body, + status: nextStatus, + personIds: selectedPersons.map((p) => p.id!).filter(Boolean) + }); + unsaved.clearOnSuccess(); + } catch { + // onSubmit signalled failure — keep dirty flag so the banner stays + } }
{liveAnnounce}
+{#if unsaved.showUnsavedWarning} + +{/if} +
diff --git a/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts index db181eca..cd69e985 100644 --- a/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts +++ b/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts @@ -4,6 +4,9 @@ import { page, userEvent } from 'vitest/browser'; import { m } from '$lib/paraglide/messages.js'; import JourneyEditor from './JourneyEditor.svelte'; +vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() })); +import { beforeNavigate } from '$app/navigation'; + const docSummary = (id: string, title: string) => ({ id, title, @@ -612,6 +615,69 @@ describe('JourneyEditor — duplicate document aria-disabled', () => { }); }); +describe('JourneyEditor — unsaved warning banner', () => { + function triggerNavigationAttempt() { + const calls = vi.mocked(beforeNavigate).mock.calls; + if (calls.length === 0) return; + const [callback] = calls[calls.length - 1]; + const cancel = vi.fn(); + (callback as (nav: { cancel: () => void; to: { url: URL } | null }) => void)({ + cancel, + to: { url: new URL('http://localhost/geschichten') } + }); + return cancel; + } + + it('banner is absent before any edit or navigation attempt', async () => { + render(JourneyEditor, defaultProps()); + expect(document.querySelector('[class*="amber"]')).toBeNull(); + }); + + it('banner appears when dirty and a navigation is attempted', async () => { + render(JourneyEditor, defaultProps()); + + // Mark dirty by editing the title + const titleInput = page.getByPlaceholder(/Titel/); + await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true })); + + // Simulate the user trying to navigate away + const cancel = triggerNavigationAttempt(); + expect(cancel).toHaveBeenCalled(); + + await expect.element(page.getByText(m.admin_unsaved_warning())).toBeInTheDocument(); + }); + + it('banner stays after a failed save (clearOnSuccess not called when onSubmit throws)', async () => { + const onSubmit = vi.fn().mockRejectedValue(new Error('server error')); + render( + JourneyEditor, + defaultProps({ + onSubmit, + geschichte: makeGeschichte({ + title: 'Titel', + items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }] + }) + }) + ); + + // Mark dirty + const titleInput = page.getByPlaceholder(/Titel/); + await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true })); + + // Trigger navigation → banner appears + triggerNavigationAttempt(); + await expect.element(page.getByText(m.admin_unsaved_warning())).toBeInTheDocument(); + + // Attempt save — onSubmit throws + await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_save_draft() })); + + // Banner must still be visible (isDirty was not cleared) + await vi.waitFor(() => { + expect(document.querySelector('[class*="amber"]')).toBeTruthy(); + }); + }); +}); + describe('JourneyEditor — person chips from GeschichteView', () => { it('renders person names in the sidebar chips (PersonView carries no displayName)', async () => { render( diff --git a/frontend/src/routes/geschichten/[id]/edit/+page.svelte b/frontend/src/routes/geschichten/[id]/edit/+page.svelte index 472b5238..3b77c83c 100644 --- a/frontend/src/routes/geschichten/[id]/edit/+page.svelte +++ b/frontend/src/routes/geschichten/[id]/edit/+page.svelte @@ -32,7 +32,7 @@ async function handleSubmit(payload: { if (!res.ok) { const code = (await res.json().catch(() => ({})))?.code; errorMessage = getErrorMessage(code); - return; + throw new Error('save failed'); } goto(`/geschichten/${data.geschichte.id}`); } finally {