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 {
geschichte?: GeschichteView | null;
initialPersons?: Person[];
/** Must reject when the save failed — the editor keeps its dirty state then. */
onSubmit: (payload: {
title: string;
body: string;
@@ -106,13 +107,17 @@ function handleTitleInput() {
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)
});
dirty = false;
try {
await onSubmit({
title: title.trim(),
body,
status: nextStatus,
personIds: selectedPersons.map((p) => p.id!).filter(Boolean)
});
dirty = false;
} catch {
// onSubmit signalled failure — keep dirty so the unsaved guard stays armed
}
}
function isActive(name: string, attrs?: Record<string, unknown>): boolean {
@@ -135,6 +140,7 @@ function exec(action: () => void) {
<input
type="text"
bind:value={title}
maxlength="255"
oninput={handleTitleInput}
onblur={handleTitleBlur}
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', () => {
it('renders DRAFT mode buttons when no geschichte prop is supplied', async () => {
render(GeschichteEditor, { onSubmit: vi.fn() });

View File

@@ -35,6 +35,14 @@ async function handleSubmit(payload: {
throw new Error('save failed');
}
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 {
submitting = false;
}

View File

@@ -31,10 +31,18 @@ async function handleSubmit(payload: {
if (!res.ok) {
const code = (await res.json().catch(() => ({})))?.code;
errorMessage = getErrorMessage(code);
return;
throw new Error('create failed');
}
const created = await res.json();
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 {
submitting = false;
}