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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 19:57:06 +01:00
1 parent 7001308636
commit 121b23f4a2
21 files changed
+57 -52

No files matched your search

+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -12536,7 +12536,8 @@
"save": "저장",
"savedToast": "사용자 지표를 저장했어요",
"updatedToast": "사용자 지표를 수정했어요",
"saveError": "사용자 지표를 저장하지 못했어요"
"saveError": "사용자 지표를 저장하지 못했어요",
"nameTaken": "이 이름의 사용자 지표가 이미 있어요."
},
"entry": {
"value": "값",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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ść",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -12536,7 +12536,8 @@
"save": "Сохранить",
"savedToast": "Свой показатель сохранён",
"updatedToast": "Свой показатель обновлён",
"saveError": "Не удалось сохранить свой показатель"
"saveError": "Не удалось сохранить свой показатель",
"nameTaken": "Свой показатель с таким названием уже существует."
},
"entry": {
"value": "Значение",
@@ -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);
}
@@ -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);
}
@@ -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"));
},
});
@@ -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);
@@ -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 });
}
@@ -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 });
}
+3 -6
View File
@@ -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);
}
@@ -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();
}
@@ -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 });
}
@@ -128,9 +128,10 @@ describe("<ManualWorkoutForm>", () => {
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, {
@@ -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);