fix(journey-editor): unsaved-warning banner + save throws on failure

JourneyEditor now renders UnsavedWarningBanner when showUnsavedWarning
is true. save() wraps onSubmit in try/catch so clearOnSuccess only fires
on success. edit/+page.svelte handleSubmit throws instead of returning
on non-ok responses so JourneyEditor sees the failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-10 19:36:18 +02:00
parent 74b94ccd84
commit c3afd57e19
3 changed files with 83 additions and 8 deletions

View File

@@ -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(