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:
@@ -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()}
|
||||||
|
|||||||
@@ -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() });
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user