fix(geschichte): uniform onSubmit rejects-on-failure contract

The c3afd57e fix made the edit page's handleSubmit throw on !res.ok but
only JourneyEditor caught it. Now: GeschichteEditor.save() catches and
keeps its dirty state (no unhandled rejection -> no GlitchTip noise on a
failed STORY save); StoryCreate throws on failure so a failed STORY
create no longer silently disarms the unsaved guard; both handleSubmit
implementations catch network rejections, surface a message, and rethrow.
Contract documented on both editors' Props. GeschichteEditor also gets
the title maxlength=255. Spec: rejecting onSubmit is caught and the
editor stays usable.

Review round 3: Felix §2, Tobias S1, Nora (3), Markus (concern 1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-11 08:29:08 +02:00
parent 8995b6e922
commit b4fcbd7efc
4 changed files with 46 additions and 8 deletions

View File

@@ -14,6 +14,7 @@ type Person = components['schemas']['Person'];
interface Props { interface Props {
geschichte?: GeschichteView | null; geschichte?: GeschichteView | null;
initialPersons?: Person[]; initialPersons?: Person[];
/** Must reject when the save failed — the editor keeps its dirty state then. */
onSubmit: (payload: { onSubmit: (payload: {
title: string; title: string;
body: string; body: string;
@@ -106,13 +107,17 @@ function handleTitleInput() {
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)
dirty = false; });
dirty = false;
} catch {
// onSubmit signalled failure — keep dirty so the unsaved guard stays armed
}
} }
function isActive(name: string, attrs?: Record<string, unknown>): boolean { function isActive(name: string, attrs?: Record<string, unknown>): boolean {
@@ -135,6 +140,7 @@ function exec(action: () => void) {
<input <input
type="text" type="text"
bind:value={title} bind:value={title}
maxlength="255"
oninput={handleTitleInput} oninput={handleTitleInput}
onblur={handleTitleBlur} onblur={handleTitleBlur}
placeholder={m.geschichte_editor_title_placeholder()} placeholder={m.geschichte_editor_title_placeholder()}

View File

@@ -54,6 +54,22 @@ describe('GeschichteEditor — title-required guard', () => {
}); });
}); });
describe('GeschichteEditor — onSubmit rejects on failure', () => {
it('catches a rejecting onSubmit (no unhandled rejection) and stays editable', async () => {
// Contract: onSubmit rejects on failure. Without the catch in save(), this
// click would surface as an unhandled promise rejection and fail the run.
const onSubmit = vi.fn().mockRejectedValue(new Error('save failed'));
render(GeschichteEditor, { geschichte: draftFactory(), onSubmit });
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
// Editor still functional — a second save attempt goes through
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2));
});
});
describe('GeschichteEditor — save bar adapts to status', () => { describe('GeschichteEditor — save bar adapts to status', () => {
it('renders DRAFT mode buttons when no geschichte prop is supplied', async () => { it('renders DRAFT mode buttons when no geschichte prop is supplied', async () => {
render(GeschichteEditor, { onSubmit: vi.fn() }); render(GeschichteEditor, { onSubmit: vi.fn() });

View File

@@ -35,6 +35,14 @@ async function handleSubmit(payload: {
throw new Error('save failed'); throw new Error('save failed');
} }
goto(`/geschichten/${data.geschichte.id}`); goto(`/geschichten/${data.geschichte.id}`);
} catch (e) {
if (!errorMessage) {
console.error('Geschichte save failed', e);
errorMessage = getErrorMessage(undefined);
}
// Contract: onSubmit rejects on failure — both editors catch and keep
// their dirty state instead of disarming the unsaved-changes guard.
throw e;
} finally { } finally {
submitting = false; submitting = false;
} }

View File

@@ -31,10 +31,18 @@ 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('create failed');
} }
const created = await res.json(); const created = await res.json();
goto(`/geschichten/${created.id}`); goto(`/geschichten/${created.id}`);
} catch (e) {
if (!errorMessage) {
console.error('Story create failed', e);
errorMessage = getErrorMessage(undefined);
}
// Contract: onSubmit rejects on failure — GeschichteEditor catches and
// keeps its dirty state instead of disarming the unsaved-changes guard.
throw e;
} finally { } finally {
submitting = false; submitting = false;
} }