diff --git a/gerbil-manager-web/README.md b/gerbil-manager-web/README.md index 4e8df10..4359625 100644 --- a/gerbil-manager-web/README.md +++ b/gerbil-manager-web/README.md @@ -24,6 +24,14 @@ Erwartet die laufende GerbilManagerWebAPI unter `http://localhost:5179` `src/strings/de.ts` — keine Texte direkt in Komponenten hartkodieren. - API-Zugriffe laufen über `src/api/client.ts`. - Routen werden in `src/App.tsx` registriert, Seiten liegen in `src/pages/`. +- **Mutationen** (`useMutation` aus `src/hooks/useApi.ts`): `run()` wirft NIE, + sondern liefert ein `MutationOutcome` — `{ ok: true, value }` oder + `{ ok: false, error, cause }`. Aufrufer MÜSSEN verzweigen: + `const r = await m.run(x); if (r.ok) navigate(r.value.id)`. `m.error` treibt + die Inline-Anzeige; für Spezialfälle (z. B. HTTP 409) `r.cause` prüfen + (`r.cause instanceof ApiError && r.cause.status === 409`). So sind unbehandelte + Promise-Rejections an Aufrufstellen ausgeschlossen (siehe auch + `src/dev/unhandledRejectionGuard.ts`). ## Build diff --git a/gerbil-manager-web/src/hooks/useApi.ts b/gerbil-manager-web/src/hooks/useApi.ts index 01c635b..b0dc883 100644 --- a/gerbil-manager-web/src/hooks/useApi.ts +++ b/gerbil-manager-web/src/hooks/useApi.ts @@ -67,11 +67,25 @@ export function useApi(loader: () => Promise, deps: unknown[]): AsyncState return { data: resolved.data, loading, error: loading ? null : resolved.error, reload } } -/** Wraps a mutation (create/update/delete) with pending + error state. */ +/** + * Outcome of a mutation. Discriminated on `ok` so callers MUST branch before + * touching `value` — this makes error handling compile-time-visible and means + * `run()` NEVER throws (no more unhandled rejections from async event handlers). + * Works for void mutations too (delete): success is `{ ok: true, value: undefined }`. + */ +export type MutationOutcome = + | { ok: true; value: T } + | { ok: false; error: string; cause: unknown } + +/** + * Wraps a mutation (create/update/delete) with pending + error state. + * `run()` resolves to a MutationOutcome and never rejects; `error` also drives + * inline UI as before. Call sites: `const r = await m.run(x); if (r.ok) …`. + */ export function useMutation( mutator: (...args: TArgs) => Promise, ): { - run: (...args: TArgs) => Promise + run: (...args: TArgs) => Promise> pending: boolean error: string | null } { @@ -79,14 +93,16 @@ export function useMutation( const [error, setError] = useState(null) const run = useCallback( - async (...args: TArgs) => { + async (...args: TArgs): Promise> => { setPending(true) setError(null) try { - return await mutator(...args) + const value = await mutator(...args) + return { ok: true, value } } catch (err) { - setError(messageFor(err)) - throw err + const message = messageFor(err) + setError(message) + return { ok: false, error: message, cause: err } } finally { setPending(false) } diff --git a/gerbil-manager-web/src/pages/BeckenDetailPage.tsx b/gerbil-manager-web/src/pages/BeckenDetailPage.tsx index 452c37d..2ba666c 100644 --- a/gerbil-manager-web/src/pages/BeckenDetailPage.tsx +++ b/gerbil-manager-web/src/pages/BeckenDetailPage.tsx @@ -42,16 +42,14 @@ export default function BeckenDetailPage() { async function onDelete() { if (!window.confirm(t.delete.confirmMessage)) return setDeleteError(null) - try { - await removal.run() + const result = await removal.run() + if (result.ok) { navigate('/becken') - } catch (err) { + } else if (result.cause instanceof ApiError && result.cause.status === 409) { // Backend meldet Konflikt, wenn noch Tiere im Becken wohnen. - if (err instanceof ApiError && err.status === 409) { - setDeleteError(t.delete.conflict) - } else { - setDeleteError(removal.error ?? de.api.errors.unknown) - } + setDeleteError(t.delete.conflict) + } else { + setDeleteError(result.error) } } diff --git a/gerbil-manager-web/src/pages/BeckenFormPage.tsx b/gerbil-manager-web/src/pages/BeckenFormPage.tsx index 46702f7..f7df10c 100644 --- a/gerbil-manager-web/src/pages/BeckenFormPage.tsx +++ b/gerbil-manager-web/src/pages/BeckenFormPage.tsx @@ -44,8 +44,9 @@ export default function BeckenFormPage() { return } setErrors({}) - const saved = await mutation.run({ name: form.name.trim(), notes: nn(form.notes) }) - navigate(`/becken/${saved.id}`) + const result = await mutation.run({ name: form.name.trim(), notes: nn(form.notes) }) + if (result.ok) navigate(`/becken/${result.value.id}`) + // On failure mutation.error drives the inline alert; run() never throws. } if (isEdit && existing.loading) return

{de.common.loading}

diff --git a/gerbil-manager-web/src/pages/GerbilFormPage.tsx b/gerbil-manager-web/src/pages/GerbilFormPage.tsx index ed659a9..5600f67 100644 --- a/gerbil-manager-web/src/pages/GerbilFormPage.tsx +++ b/gerbil-manager-web/src/pages/GerbilFormPage.tsx @@ -166,13 +166,9 @@ export default function GerbilFormPage() { genotype: nn(form.genotype), notes: nn(form.notes), } - try { - const saved = await mutation.run(body) - navigate(`/rennmaeuse/${saved.id}`) - } catch { - // Error is surfaced via mutation.error; swallow so the rejected promise - // from this async submit handler doesn't become an unhandled rejection. - } + const result = await mutation.run(body) + if (result.ok) navigate(`/rennmaeuse/${result.value.id}`) + // On failure mutation.error drives the inline alert; run() never throws. } if (isEdit && existing.loading) return

{de.common.loading}

diff --git a/gerbil-manager-web/src/pages/KontaktDetailPage.tsx b/gerbil-manager-web/src/pages/KontaktDetailPage.tsx index 92490d9..d0b3103 100644 --- a/gerbil-manager-web/src/pages/KontaktDetailPage.tsx +++ b/gerbil-manager-web/src/pages/KontaktDetailPage.tsx @@ -45,16 +45,14 @@ export default function KontaktDetailPage() { async function onDelete() { if (!window.confirm(t.delete.confirmMessage)) return setDeleteError(null) - try { - await removal.run() + const result = await removal.run() + if (result.ok) { navigate('/kontakte') - } catch (err) { + } else if (result.cause instanceof ApiError && result.cause.status === 409) { // Backend meldet Konflikt, wenn der Kontakt noch referenziert wird. - if (err instanceof ApiError && err.status === 409) { - setDeleteError(t.delete.conflict) - } else { - setDeleteError(removal.error ?? de.api.errors.unknown) - } + setDeleteError(t.delete.conflict) + } else { + setDeleteError(result.error) } } diff --git a/gerbil-manager-web/src/pages/KontaktFormPage.tsx b/gerbil-manager-web/src/pages/KontaktFormPage.tsx index bfe4a94..3aebe83 100644 --- a/gerbil-manager-web/src/pages/KontaktFormPage.tsx +++ b/gerbil-manager-web/src/pages/KontaktFormPage.tsx @@ -52,12 +52,13 @@ export default function KontaktFormPage() { return } setErrors({}) - const saved = await mutation.run({ + const result = await mutation.run({ name: form.name.trim(), contactInfo: nn(form.contactInfo), notes: nn(form.notes), }) - navigate(`/kontakte/${saved.id}`) + if (result.ok) navigate(`/kontakte/${result.value.id}`) + // On failure mutation.error drives the inline alert; run() never throws. } if (isEdit && existing.loading) return

{de.common.loading}

diff --git a/gerbil-manager-web/src/pages/WurfFormPage.tsx b/gerbil-manager-web/src/pages/WurfFormPage.tsx index 423fe11..6ba21ba 100644 --- a/gerbil-manager-web/src/pages/WurfFormPage.tsx +++ b/gerbil-manager-web/src/pages/WurfFormPage.tsx @@ -116,13 +116,9 @@ export default function WurfFormPage() { expectedGoHomeDate: nn(form.expectedGoHomeDate), notes: nn(form.notes), } - try { - const saved = await mutation.run(body) - navigate(`/wuerfe/${saved.id}`) - } catch { - // Error surfaced via mutation.error; swallow to avoid an unhandled - // rejection escaping this async submit handler. - } + const result = await mutation.run(body) + if (result.ok) navigate(`/wuerfe/${result.value.id}`) + // On failure mutation.error drives the inline alert; run() never throws. } if (isEdit && existing.loading) return

{de.common.loading}