From 121b23f4a2aaff6ec8c724464e8f0b071d38b4cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 19:57:06 +0100 Subject: [PATCH] fix(forms): show the form's own sentence when a save is refused The custom metric, allergy, vaccination, booster, visit, address-book, workout and quick-add dose forms printed the server's error string as it came: English in every locale, and for a network failure the browser's own TypeError text. They now resolve the error code through localizedApiError like the other entry forms, so a known refusal reads in the person's language and anything else falls back to the form's "could not save" sentence. A custom metric whose name is taken keeps its specific message, now translated. The workout form's browser test expected the server prose and now expects the form's sentence. --- messages/de.json | 3 ++- messages/en.json | 3 ++- messages/es.json | 3 ++- messages/fr.json | 3 ++- messages/it.json | 3 ++- messages/ko.json | 3 ++- messages/nl.json | 3 ++- messages/pl.json | 3 ++- messages/pt-BR.json | 3 ++- messages/ru.json | 3 ++- .../custom-metrics/custom-metric-entry-form.tsx | 9 +++------ src/components/custom-metrics/custom-metric-form.tsx | 11 ++++++----- .../custom-metrics/custom-metric-history-list.tsx | 9 +++------ .../dashboard/medication-intake-quick-add.tsx | 11 +++++++---- src/components/encounters/encounter-sheet.tsx | 4 ++-- src/components/practitioners/practitioner-sheet.tsx | 4 ++-- src/components/records/allergy-form.tsx | 9 +++------ src/components/vaccinations/booster-mint-prompt.tsx | 8 ++++---- src/components/vaccinations/vaccination-sheet.tsx | 4 ++-- .../__tests__/manual-workout-form.browser.test.tsx | 3 ++- src/components/workouts/manual-workout-form.tsx | 7 +++---- 21 files changed, 57 insertions(+), 52 deletions(-) diff --git a/messages/de.json b/messages/de.json index 38a6915f3..c1908fddc 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12536,7 +12536,8 @@ "save": "Speichern", "savedToast": "Eigene Metrik gespeichert", "updatedToast": "Eigene Metrik aktualisiert", - "saveError": "Eigene Metrik konnte nicht gespeichert werden" + "saveError": "Eigene Metrik konnte nicht gespeichert werden", + "nameTaken": "Es gibt bereits eine eigene Metrik mit diesem Namen." }, "entry": { "value": "Wert", diff --git a/messages/en.json b/messages/en.json index f237a82cf..ca52adbf4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12536,7 +12536,8 @@ "save": "Save", "savedToast": "Custom metric saved", "updatedToast": "Custom metric updated", - "saveError": "Could not save the custom metric" + "saveError": "Could not save the custom metric", + "nameTaken": "A custom metric with this name already exists." }, "entry": { "value": "Value", diff --git a/messages/es.json b/messages/es.json index 78b9b5535..17dde5112 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12536,7 +12536,8 @@ "save": "Guardar", "savedToast": "Métrica personalizada guardada", "updatedToast": "Métrica personalizada actualizada", - "saveError": "No se pudo guardar la métrica personalizada" + "saveError": "No se pudo guardar la métrica personalizada", + "nameTaken": "Ya existe una métrica personalizada con este nombre." }, "entry": { "value": "Valor", diff --git a/messages/fr.json b/messages/fr.json index ca9d45fbf..c73ad4505 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12536,7 +12536,8 @@ "save": "Enregistrer", "savedToast": "Mesure personnalisée enregistrée", "updatedToast": "Mesure personnalisée mise à jour", - "saveError": "Impossible d'enregistrer la mesure personnalisée" + "saveError": "Impossible d'enregistrer la mesure personnalisée", + "nameTaken": "Une mesure personnalisée portant ce nom existe déjà." }, "entry": { "value": "Valeur", diff --git a/messages/it.json b/messages/it.json index 50bc0e36f..7883cfb66 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12536,7 +12536,8 @@ "save": "Salva", "savedToast": "Metrica personalizzata salvata", "updatedToast": "Metrica personalizzata aggiornata", - "saveError": "Impossibile salvare la metrica personalizzata" + "saveError": "Impossibile salvare la metrica personalizzata", + "nameTaken": "Esiste già una metrica personalizzata con questo nome." }, "entry": { "value": "Valore", diff --git a/messages/ko.json b/messages/ko.json index 7fc9fdf61..23255f05e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12536,7 +12536,8 @@ "save": "저장", "savedToast": "사용자 지표를 저장했어요", "updatedToast": "사용자 지표를 수정했어요", - "saveError": "사용자 지표를 저장하지 못했어요" + "saveError": "사용자 지표를 저장하지 못했어요", + "nameTaken": "이 이름의 사용자 지표가 이미 있어요." }, "entry": { "value": "값", diff --git a/messages/nl.json b/messages/nl.json index 0e5b745b5..6387a747e 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -12536,7 +12536,8 @@ "save": "Opslaan", "savedToast": "Eigen meting opgeslagen", "updatedToast": "Eigen meting bijgewerkt", - "saveError": "De eigen meting kon niet worden opgeslagen" + "saveError": "De eigen meting kon niet worden opgeslagen", + "nameTaken": "Er bestaat al een eigen meting met deze naam." }, "entry": { "value": "Waarde", diff --git a/messages/pl.json b/messages/pl.json index c46749950..505248df9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12536,7 +12536,8 @@ "save": "Zapisz", "savedToast": "Zapisano metrykę własną", "updatedToast": "Zaktualizowano metrykę własną", - "saveError": "Nie udało się zapisać metryki własnej" + "saveError": "Nie udało się zapisać metryki własnej", + "nameTaken": "Metryka własna o tej nazwie już istnieje." }, "entry": { "value": "Wartość", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index cbe230a62..512eb208f 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -12536,7 +12536,8 @@ "save": "Salvar", "savedToast": "Métrica personalizada salva", "updatedToast": "Métrica personalizada atualizada", - "saveError": "Não foi possível salvar a métrica personalizada" + "saveError": "Não foi possível salvar a métrica personalizada", + "nameTaken": "Já existe uma métrica personalizada com este nome." }, "entry": { "value": "Valor", diff --git a/messages/ru.json b/messages/ru.json index 4fabe3cfa..478b17ba2 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -12536,7 +12536,8 @@ "save": "Сохранить", "savedToast": "Свой показатель сохранён", "updatedToast": "Свой показатель обновлён", - "saveError": "Не удалось сохранить свой показатель" + "saveError": "Не удалось сохранить свой показатель", + "nameTaken": "Свой показатель с таким названием уже существует." }, "entry": { "value": "Значение", diff --git a/src/components/custom-metrics/custom-metric-entry-form.tsx b/src/components/custom-metrics/custom-metric-entry-form.tsx index b8236d3cf..01dbc2212 100644 --- a/src/components/custom-metrics/custom-metric-entry-form.tsx +++ b/src/components/custom-metrics/custom-metric-entry-form.tsx @@ -11,7 +11,8 @@ import { DateTimeField } from "@/components/ui/date-time-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; -import { ApiError, apiPost } from "@/lib/api/api-fetch"; +import { apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -99,11 +100,7 @@ export function CustomMetricEntryForm({ }); onSuccess?.(); } catch (err) { - const message = - err instanceof ApiError - ? err.message - : t("customMetrics.entry.saveError"); - setError(message); + setError(localizedApiError(err, t, "customMetrics.entry.saveError")); } finally { setSubmitting(false); } diff --git a/src/components/custom-metrics/custom-metric-form.tsx b/src/components/custom-metrics/custom-metric-form.tsx index 5c59f4e1e..ee599f7b1 100644 --- a/src/components/custom-metrics/custom-metric-form.tsx +++ b/src/components/custom-metrics/custom-metric-form.tsx @@ -11,6 +11,7 @@ import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { ApiError, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import type { CustomMetricDto } from "./types"; @@ -122,11 +123,11 @@ export function CustomMetricForm({ ); onSuccess?.(saved); } catch (err) { - const message = - err instanceof ApiError - ? err.message - : t("customMetrics.form.saveError"); - setError(message); + setError( + err instanceof ApiError && err.status === 409 + ? t("customMetrics.form.nameTaken") + : localizedApiError(err, t, "customMetrics.form.saveError"), + ); } finally { setSubmitting(false); } diff --git a/src/components/custom-metrics/custom-metric-history-list.tsx b/src/components/custom-metrics/custom-metric-history-list.tsx index 5e2b60dfa..01930fea3 100644 --- a/src/components/custom-metrics/custom-metric-history-list.tsx +++ b/src/components/custom-metrics/custom-metric-history-list.tsx @@ -22,7 +22,8 @@ import { } from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; import { useTableSort } from "@/hooks/use-table-sort"; -import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { formatDateShort } from "@/lib/format"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -140,11 +141,7 @@ export function CustomMetricHistoryList({ closeEdit(); }, onError: (err) => { - setEditError( - err instanceof ApiError - ? err.message - : t("customMetrics.entry.editError"), - ); + setEditError(localizedApiError(err, t, "customMetrics.entry.editError")); }, }); diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx index 38bd99fcf..1c63d9ae6 100644 --- a/src/components/dashboard/medication-intake-quick-add.tsx +++ b/src/components/dashboard/medication-intake-quick-add.tsx @@ -27,7 +27,8 @@ import { } from "@/lib/medications/default-medication"; import { useAuth } from "@/hooks/use-auth"; import { useActiveRecordName } from "@/hooks/use-record-capabilities"; -import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { apiGet, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { intakeToastOptions, runUndoIntake, @@ -270,9 +271,11 @@ export function MedicationIntakeQuickAdd({ void invalidateMedicationReads(queryClient); } catch (err) { setError( - err instanceof ApiError && err.message - ? err.message - : t("dashboard.medicationIntakeQuickAdd.saveError"), + localizedApiError( + err, + t, + "dashboard.medicationIntakeQuickAdd.saveError", + ), ); } finally { setLoading(false); diff --git a/src/components/encounters/encounter-sheet.tsx b/src/components/encounters/encounter-sheet.tsx index 4c0eefcd0..378636604 100644 --- a/src/components/encounters/encounter-sheet.tsx +++ b/src/components/encounters/encounter-sheet.tsx @@ -49,6 +49,7 @@ import { import { Button } from "@/components/ui/button"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { useEncounterMutations, type Encounter } from "@/hooks/use-encounters"; import { @@ -128,8 +129,7 @@ export function EncounterSheet({ onSaved?.(saved); onOpenChange(false); } catch (err) { - const message = - err instanceof Error ? err.message : t("encounters.saveFailed"); + const message = localizedApiError(err, t, "encounters.saveFailed"); setError(message); showErrorToast(message, { cause: err }); } diff --git a/src/components/practitioners/practitioner-sheet.tsx b/src/components/practitioners/practitioner-sheet.tsx index f9654f691..54118fa5a 100644 --- a/src/components/practitioners/practitioner-sheet.tsx +++ b/src/components/practitioners/practitioner-sheet.tsx @@ -20,6 +20,7 @@ import { FieldGroup } from "@/components/ui/field-group"; import { Input } from "@/components/ui/input"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Textarea } from "@/components/ui/textarea"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { usePractitionerMutations, @@ -104,8 +105,7 @@ export function PractitionerSheet({ onSaved?.(saved); onOpenChange(false); } catch (err) { - const message = - err instanceof Error ? err.message : t("practitioners.saveFailed"); + const message = localizedApiError(err, t, "practitioners.saveFailed"); setError(message); showErrorToast(message, { cause: err }); } diff --git a/src/components/records/allergy-form.tsx b/src/components/records/allergy-form.tsx index 341c01b3d..1270ce001 100644 --- a/src/components/records/allergy-form.tsx +++ b/src/components/records/allergy-form.tsx @@ -17,7 +17,8 @@ import { SelectValue, } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; -import { ApiError, apiPost, apiPut } from "@/lib/api/api-fetch"; +import { apiPost, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { allergyCategoryEnum, allergySeverityEnum, @@ -113,11 +114,7 @@ export function AllergyForm({ ); onSuccess?.(saved); } catch (err) { - const message = - err instanceof ApiError - ? err.message - : t("records.allergies.form.saveError"); - setError(message); + setError(localizedApiError(err, t, "records.allergies.form.saveError")); } finally { setSubmitting(false); } diff --git a/src/components/vaccinations/booster-mint-prompt.tsx b/src/components/vaccinations/booster-mint-prompt.tsx index 49707c3ef..3f1d38390 100644 --- a/src/components/vaccinations/booster-mint-prompt.tsx +++ b/src/components/vaccinations/booster-mint-prompt.tsx @@ -33,6 +33,7 @@ import { import { FieldGroup } from "@/components/ui/field-group"; import { Input } from "@/components/ui/input"; import { NativeSelect } from "@/components/ui/native-select"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { customLookupOf, @@ -105,10 +106,9 @@ export function BoosterMintPrompt({ }); toastWrittenOutcome("success", t("vaccinations.booster.planned")); } catch (err) { - showErrorToast( - err instanceof Error ? err.message : t("vaccinations.booster.failed"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "vaccinations.booster.failed"), { + cause: err, + }); } finally { onClose(); } diff --git a/src/components/vaccinations/vaccination-sheet.tsx b/src/components/vaccinations/vaccination-sheet.tsx index 40a8cce42..7bc0cbb6a 100644 --- a/src/components/vaccinations/vaccination-sheet.tsx +++ b/src/components/vaccinations/vaccination-sheet.tsx @@ -36,6 +36,7 @@ import { import { Button } from "@/components/ui/button"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { RelatedRecordsBlock } from "@/components/links/related-records"; import type { RelatedLink } from "@/lib/links/related"; @@ -99,8 +100,7 @@ export function VaccinationSheet({ onCreated?.(created); } } catch (err) { - const message = - err instanceof Error ? err.message : t("vaccinations.saveFailed"); + const message = localizedApiError(err, t, "vaccinations.saveFailed"); setError(message); showErrorToast(message, { cause: err }); } diff --git a/src/components/workouts/__tests__/manual-workout-form.browser.test.tsx b/src/components/workouts/__tests__/manual-workout-form.browser.test.tsx index 305a63e6d..de5105900 100644 --- a/src/components/workouts/__tests__/manual-workout-form.browser.test.tsx +++ b/src/components/workouts/__tests__/manual-workout-form.browser.test.tsx @@ -128,9 +128,10 @@ describe("", () => { await pickSport("Running"); await screen.getByLabelText("Minutes").fill("30"); await save.click(); + // The form's own sentence, never the server's English prose. await expect .element(screen.getByRole("alert")) - .toHaveTextContent("Service unavailable"); + .toHaveTextContent("The workout could not be saved"); expect(onSuccess).not.toHaveBeenCalled(); api.on("POST", BATCH, { diff --git a/src/components/workouts/manual-workout-form.tsx b/src/components/workouts/manual-workout-form.tsx index b10aafc1d..363d200c6 100644 --- a/src/components/workouts/manual-workout-form.tsx +++ b/src/components/workouts/manual-workout-form.tsx @@ -21,7 +21,8 @@ import { } from "@/components/ui/select"; import { useAuth } from "@/hooks/use-auth"; import { useUnitDisplay } from "@/hooks/use-unit-display"; -import { ApiError, apiPost } from "@/lib/api/api-fetch"; +import { apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { getQuantityTransform } from "@/lib/measurements/display-transform"; import { @@ -232,9 +233,7 @@ export function ManualWorkoutForm({ onSuccess?.(); } catch (err) { setSaveError( - err instanceof ApiError && err.message - ? err.message - : t("insights.workouts.manual.saveError"), + localizedApiError(err, t, "insights.workouts.manual.saveError"), ); } finally { setSaving(false);