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 GeschichteSidebar from './GeschichteSidebar.svelte';
|
||||||
import JourneyItemRow from './JourneyItemRow.svelte';
|
import JourneyItemRow from './JourneyItemRow.svelte';
|
||||||
import JourneyAddBar from './JourneyAddBar.svelte';
|
import JourneyAddBar from './JourneyAddBar.svelte';
|
||||||
|
import UnsavedWarningBanner from '$lib/shared/primitives/UnsavedWarningBanner.svelte';
|
||||||
|
|
||||||
type GeschichteView = components['schemas']['GeschichteView'];
|
type GeschichteView = components['schemas']['GeschichteView'];
|
||||||
type JourneyItemView = components['schemas']['JourneyItemView'];
|
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||||
@@ -224,19 +225,27 @@ async function handleMoveDown(index: number) {
|
|||||||
async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
|
async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
|
||||||
titleTouched = true;
|
titleTouched = true;
|
||||||
if (titleEmpty) return;
|
if (titleEmpty) return;
|
||||||
await onSubmit({
|
try {
|
||||||
title: title.trim(),
|
await onSubmit({
|
||||||
body,
|
title: title.trim(),
|
||||||
status: nextStatus,
|
body,
|
||||||
personIds: selectedPersons.map((p) => p.id!).filter(Boolean)
|
status: nextStatus,
|
||||||
});
|
personIds: selectedPersons.map((p) => p.id!).filter(Boolean)
|
||||||
unsaved.clearOnSuccess();
|
});
|
||||||
|
unsaved.clearOnSuccess();
|
||||||
|
} catch {
|
||||||
|
// onSubmit signalled failure — keep dirty flag so the banner stays
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Screen-reader live region for move announcements -->
|
<!-- Screen-reader live region for move announcements -->
|
||||||
<div aria-live="polite" aria-atomic="true" class="sr-only">{liveAnnounce}</div>
|
<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]">
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
|
||||||
<!-- Editor column -->
|
<!-- Editor column -->
|
||||||
<div bind:this={editorColEl} class="flex flex-col gap-4">
|
<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 { m } from '$lib/paraglide/messages.js';
|
||||||
import JourneyEditor from './JourneyEditor.svelte';
|
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) => ({
|
const docSummary = (id: string, title: string) => ({
|
||||||
id,
|
id,
|
||||||
title,
|
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', () => {
|
describe('JourneyEditor — person chips from GeschichteView', () => {
|
||||||
it('renders person names in the sidebar chips (PersonView carries no displayName)', async () => {
|
it('renders person names in the sidebar chips (PersonView carries no displayName)', async () => {
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ async function handleSubmit(payload: {
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const code = (await res.json().catch(() => ({})))?.code;
|
const code = (await res.json().catch(() => ({})))?.code;
|
||||||
errorMessage = getErrorMessage(code);
|
errorMessage = getErrorMessage(code);
|
||||||
return;
|
throw new Error('save failed');
|
||||||
}
|
}
|
||||||
goto(`/geschichten/${data.geschichte.id}`);
|
goto(`/geschichten/${data.geschichte.id}`);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user