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:
@@ -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
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Screen-reader live region for move announcements -->
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only">{liveAnnounce}</div>
|
||||
|
||||
{#if unsaved.showUnsavedWarning}
|
||||
<UnsavedWarningBanner onDiscard={unsaved.discard} />
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
<!-- Editor column -->
|
||||
<div bind:this={editorColEl} class="flex flex-col gap-4">
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user