mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Show a refused profile field under the input it belongs to
The onboarding baseline step flashed a toast naming one refused field and walked on to the done screen, stamping the account as set up over a value the server never stored. The value the person typed was gone with the step. A refused field now writes its reason into that field's own error slot, marks the control invalid, and holds the step until the person corrects it. Every refused field is named, not only the first, and the reason comes from the validator code rather than the validator's own prose, in all seven shipped languages.
This commit is contained in:
1 parent
ae20ee3d9d
commit
36cbf48286
16 files changed
+438
-26
No files matched your search
@@ -5353,6 +5353,14 @@
|
||||
"profileSaved": "Profil gespeichert",
|
||||
"profilePartiallySaved": "Profil gespeichert, aber {field} konnte nicht aktualisiert werden.",
|
||||
"profileNothingSaved": "Es wurde nichts gespeichert. Bitte {field} korrigieren und erneut versuchen.",
|
||||
"profileRejection": {
|
||||
"tooBig": "Der Wert liegt über dem Bereich, den dieses Feld annimmt.",
|
||||
"tooSmall": "Der Wert liegt unter dem Bereich, den dieses Feld annimmt.",
|
||||
"invalidValue": "Das ist keine der Antworten, die dieses Feld annimmt.",
|
||||
"invalidFormat": "Das ist nicht in einer Form geschrieben, die dieses Feld lesen kann.",
|
||||
"invalidType": "Der Wert kam nicht in einer Form an, die dieses Feld speichern kann.",
|
||||
"other": "Dieser Wert wurde nicht angenommen. Bitte prüfen und erneut versuchen."
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Profilfoto",
|
||||
"description": "Lade ein Foto hoch, das auf deinem eigenen Server gespeichert wird. Es erscheint überall in der App anstelle eines externen Avatar-Dienstes.",
|
||||
|
||||
@@ -5353,6 +5353,14 @@
|
||||
"profileSaved": "Profile saved",
|
||||
"profilePartiallySaved": "Profile saved, but {field} could not be updated.",
|
||||
"profileNothingSaved": "Nothing was saved. Fix {field} and try again.",
|
||||
"profileRejection": {
|
||||
"tooBig": "That value is above the range this field accepts.",
|
||||
"tooSmall": "That value is below the range this field accepts.",
|
||||
"invalidValue": "That is not one of the answers this field accepts.",
|
||||
"invalidFormat": "That is not written in a form this field can read.",
|
||||
"invalidType": "That value did not arrive in a form this field can store.",
|
||||
"other": "This value was not accepted. Check it and try again."
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Profile photo",
|
||||
"description": "Upload a photo stored on your own server. It appears across the app in place of an external avatar service.",
|
||||
|
||||
@@ -5353,6 +5353,14 @@
|
||||
"profileSaved": "Perfil guardado",
|
||||
"profilePartiallySaved": "Perfil guardado, pero no se pudo actualizar {field}.",
|
||||
"profileNothingSaved": "No se guardó nada. Corrige {field} e inténtalo de nuevo.",
|
||||
"profileRejection": {
|
||||
"tooBig": "El valor está por encima del rango que acepta este campo.",
|
||||
"tooSmall": "El valor está por debajo del rango que acepta este campo.",
|
||||
"invalidValue": "Esa no es una de las respuestas que acepta este campo.",
|
||||
"invalidFormat": "Eso no está escrito en un formato que este campo pueda leer.",
|
||||
"invalidType": "El valor no llegó en un formato que este campo pueda guardar.",
|
||||
"other": "No se aceptó este valor. Revísalo e inténtalo de nuevo."
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Foto de perfil",
|
||||
"description": "Sube una foto que se almacena en tu propio servidor. Aparece en toda la aplicación en lugar de un servicio de avatares externo.",
|
||||
|
||||
@@ -5353,6 +5353,14 @@
|
||||
"profileSaved": "Profil enregistré",
|
||||
"profilePartiallySaved": "Profil enregistré, mais {field} n'a pas pu être mis à jour.",
|
||||
"profileNothingSaved": "Rien n'a été enregistré. Corrigez {field} et réessayez.",
|
||||
"profileRejection": {
|
||||
"tooBig": "La valeur dépasse la plage acceptée par ce champ.",
|
||||
"tooSmall": "La valeur est en dessous de la plage acceptée par ce champ.",
|
||||
"invalidValue": "Ce n'est pas une des réponses acceptées par ce champ.",
|
||||
"invalidFormat": "Ce n'est pas écrit dans un format que ce champ peut lire.",
|
||||
"invalidType": "La valeur n'est pas arrivée dans un format que ce champ peut enregistrer.",
|
||||
"other": "Cette valeur n'a pas été acceptée. Vérifiez-la et réessayez."
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Photo de profil",
|
||||
"description": "Téléverse une photo stockée sur ton propre serveur. Elle apparaît dans toute l'application à la place d'un service d'avatar externe.",
|
||||
|
||||
@@ -5353,6 +5353,14 @@
|
||||
"profileSaved": "Profilo salvato",
|
||||
"profilePartiallySaved": "Profilo salvato, ma non è stato possibile aggiornare {field}.",
|
||||
"profileNothingSaved": "Non è stato salvato nulla. Correggi {field} e riprova.",
|
||||
"profileRejection": {
|
||||
"tooBig": "Il valore supera l'intervallo accettato da questo campo.",
|
||||
"tooSmall": "Il valore è inferiore all'intervallo accettato da questo campo.",
|
||||
"invalidValue": "Questa non è una delle risposte accettate da questo campo.",
|
||||
"invalidFormat": "Non è scritto in un formato che questo campo possa leggere.",
|
||||
"invalidType": "Il valore non è arrivato in un formato che questo campo possa salvare.",
|
||||
"other": "Questo valore non è stato accettato. Controllalo e riprova."
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Foto del profilo",
|
||||
"description": "Carica una foto archiviata sul tuo server. Appare in tutta l'app al posto di un servizio di avatar esterno.",
|
||||
|
||||
@@ -5353,6 +5353,14 @@
|
||||
"profileSaved": "프로필을 저장했어요",
|
||||
"profilePartiallySaved": "프로필을 저장했지만 {field} 항목은 업데이트하지 못했어요.",
|
||||
"profileNothingSaved": "아무것도 저장하지 못했어요. {field} 항목을 고친 뒤 다시 시도해 주세요.",
|
||||
"profileRejection": {
|
||||
"tooBig": "이 항목이 받는 범위보다 큰 값이에요.",
|
||||
"tooSmall": "이 항목이 받는 범위보다 작은 값이에요.",
|
||||
"invalidValue": "이 항목이 받는 답변이 아니에요.",
|
||||
"invalidFormat": "이 항목이 읽을 수 있는 형식이 아니에요.",
|
||||
"invalidType": "이 항목이 저장할 수 있는 형식으로 오지 않았어요.",
|
||||
"other": "이 값은 저장하지 못했어요. 확인한 뒤 다시 시도해 주세요."
|
||||
},
|
||||
"avatar": {
|
||||
"title": "프로필 사진",
|
||||
"description": "직접 운영하는 서버에 저장되는 사진을 올려요. 외부 아바타 서비스 대신 앱 전체에 표시돼요.",
|
||||
|
||||
@@ -5353,6 +5353,14 @@
|
||||
"profileSaved": "Profil zapisany",
|
||||
"profilePartiallySaved": "Profil zapisany, ale nie udało się zaktualizować pola {field}.",
|
||||
"profileNothingSaved": "Nic nie zostało zapisane. Popraw {field} i spróbuj ponownie.",
|
||||
"profileRejection": {
|
||||
"tooBig": "Wartość jest powyżej zakresu, który przyjmuje to pole.",
|
||||
"tooSmall": "Wartość jest poniżej zakresu, który przyjmuje to pole.",
|
||||
"invalidValue": "To nie jest jedna z odpowiedzi, które przyjmuje to pole.",
|
||||
"invalidFormat": "To nie jest zapisane w formacie, który to pole może odczytać.",
|
||||
"invalidType": "Wartość nie dotarła w formacie, który to pole może zapisać.",
|
||||
"other": "Ta wartość nie została przyjęta. Sprawdź ją i spróbuj ponownie."
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Zdjęcie profilowe",
|
||||
"description": "Prześlij zdjęcie przechowywane na własnym serwerze. Pojawia się w całej aplikacji zamiast zewnętrznej usługi awatarów.",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* The error slot that belongs to one input.
|
||||
*
|
||||
* Every form that writes the profile shows a refused field the same
|
||||
* way, or the same rejection reads differently depending on where the
|
||||
* person happened to be standing. This is that one way: a destructive
|
||||
* sentence directly under the control, carrying an id the control
|
||||
* points `aria-describedby` at, so the reason is announced with the
|
||||
* field rather than only painted next to it.
|
||||
*
|
||||
* `role="alert"` is deliberate. The sentence appears in response to a
|
||||
* save the person just asked for, and a person who has already moved
|
||||
* focus past the field would otherwise never learn the value did not
|
||||
* land.
|
||||
*/
|
||||
export function FieldError({
|
||||
id,
|
||||
message,
|
||||
}: {
|
||||
/** Conventionally `<control id>-error`. */
|
||||
id: string;
|
||||
message?: ReactNode;
|
||||
}) {
|
||||
if (!message) return null;
|
||||
return (
|
||||
<p id={id} role="alert" className="text-destructive text-sm">
|
||||
{message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* A rejection the server names has to reach the input it belongs to.
|
||||
*
|
||||
* `PUT /api/auth/profile` writes field by field and reports the fields
|
||||
* it refused. The onboarding step read that report only far enough to
|
||||
* flash a toast and then walked the person to the next screen, so an
|
||||
* out-of-range height was gone by the time anyone could act on it and
|
||||
* the wizard counted the account as set up regardless.
|
||||
*
|
||||
* This runs the whole strip — build the body, PUT it over a stubbed
|
||||
* `fetch`, read the answer, render the real inputs with what came back
|
||||
* — because the gap was in the join, not in either end. The English
|
||||
* bundle is the shipped one, so a sentence that reads as a key or
|
||||
* leaks validator prose fails here.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { getCachedMessages } from "@/lib/i18n/load-locale";
|
||||
import { resolveKey } from "@/lib/i18n/resolve-key";
|
||||
import {
|
||||
EMPTY_HEIGHT_DRAFT,
|
||||
resolveHeightUnitAdapter,
|
||||
} from "@/lib/profile/height-unit-display";
|
||||
import { BaselineFields } from "../baseline-fields";
|
||||
import {
|
||||
baselineFieldLabelKeys,
|
||||
buildBaselineProfileBody,
|
||||
describeBaselineSaveOutcome,
|
||||
putBaselineProfile,
|
||||
} from "../baseline-form-utils";
|
||||
|
||||
/** A `t` backed by the shipped bundle — no stand-in strings. */
|
||||
function translator(locale: "en" | "de") {
|
||||
const bundle = getCachedMessages(locale);
|
||||
if (!bundle) throw new Error(`No cached bundle for ${locale}`);
|
||||
return (key: string, params?: Record<string, string | number>) => {
|
||||
const raw = resolveKey(bundle as Record<string, unknown>, key);
|
||||
if (raw === undefined) return key;
|
||||
return Object.entries(params ?? {}).reduce(
|
||||
(acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
|
||||
raw,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const FILLED_FORM = {
|
||||
displayName: "Robin",
|
||||
dateOfBirth: "1988-04-02",
|
||||
gender: "OTHER",
|
||||
};
|
||||
|
||||
function stubProfilePut(status: number, body: unknown) {
|
||||
vi.stubGlobal("fetch", () =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Render the step's inputs exactly as the step renders them. */
|
||||
function renderFields(errors: Record<string, string>) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<BaselineFields
|
||||
value={{
|
||||
displayName: FILLED_FORM.displayName,
|
||||
height: { ...EMPTY_HEIGHT_DRAFT, cm: "940" },
|
||||
dateOfBirth: FILLED_FORM.dateOfBirth,
|
||||
gender: FILLED_FORM.gender,
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
heightAdapter={resolveHeightUnitAdapter("metric")}
|
||||
errors={errors}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("a refused baseline field reaches its own input", () => {
|
||||
it("paints the reason in the height field's slot and holds the step", async () => {
|
||||
stubProfilePut(200, {
|
||||
data: {
|
||||
id: "u1",
|
||||
displayName: "Robin",
|
||||
rejectedFields: [{ path: "heightCm", code: "too_big" }],
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
|
||||
const en = translator("en");
|
||||
const outcome = describeBaselineSaveOutcome(
|
||||
await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, 940)),
|
||||
en,
|
||||
baselineFieldLabelKeys(false),
|
||||
);
|
||||
|
||||
// A field was refused: the wizard stays put and does not stamp the
|
||||
// account as finished behind the person's back.
|
||||
expect(outcome.advance).toBe(false);
|
||||
expect(outcome.fieldErrors).toEqual({
|
||||
heightCm: en("settings.profileRejection.tooBig"),
|
||||
});
|
||||
|
||||
// What the outcome produced is what the input renders — no second
|
||||
// mapping in between for a rename to fall through.
|
||||
const html = renderFields(outcome.fieldErrors);
|
||||
expect(html).toContain(en("settings.profileRejection.tooBig"));
|
||||
// Neither a bare key nor the validator's own vocabulary.
|
||||
expect(html).not.toContain("settings.profileRejection");
|
||||
expect(html).not.toContain("too_big");
|
||||
// Announced with the input, not merely painted beside it.
|
||||
expect(html).toContain('id="ob-baseline-height-error"');
|
||||
expect(html).toContain('aria-describedby="ob-baseline-height-error"');
|
||||
expect(html).toContain('aria-invalid="true"');
|
||||
});
|
||||
|
||||
it("leaves every accepted field's slot empty", async () => {
|
||||
stubProfilePut(200, { data: { id: "u1" }, error: null });
|
||||
|
||||
const outcome = describeBaselineSaveOutcome(
|
||||
await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, 178)),
|
||||
translator("en"),
|
||||
baselineFieldLabelKeys(false),
|
||||
);
|
||||
|
||||
expect(outcome.advance).toBe(true);
|
||||
expect(outcome.fieldErrors).toEqual({});
|
||||
// (`aria-invalid:` also appears inside the primitives' Tailwind
|
||||
// class strings, so the assertion is on the rendered attribute.)
|
||||
const html = renderFields({});
|
||||
expect(html).not.toContain('aria-invalid="true"');
|
||||
expect(html).not.toContain('role="alert"');
|
||||
});
|
||||
|
||||
it("names every refused field, not only the first", async () => {
|
||||
stubProfilePut(422, {
|
||||
data: null,
|
||||
error: 'Nothing was saved. Fix the "dateOfBirth" field and try again.',
|
||||
meta: { errorCode: "profile.update.nothingSaved" },
|
||||
details: {
|
||||
issues: [
|
||||
{ path: "dateOfBirth", code: "invalid_value" },
|
||||
{ path: "gender", code: "invalid_value" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const outcome = describeBaselineSaveOutcome(
|
||||
await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, null)),
|
||||
translator("de"),
|
||||
baselineFieldLabelKeys(false),
|
||||
);
|
||||
|
||||
expect(outcome.advance).toBe(false);
|
||||
expect(Object.keys(outcome.fieldErrors).sort()).toEqual([
|
||||
"dateOfBirth",
|
||||
"gender",
|
||||
]);
|
||||
// The person's language, not the validator's.
|
||||
const de = translator("de");
|
||||
expect(outcome.fieldErrors.gender).toBe(
|
||||
de("settings.profileRejection.invalidValue"),
|
||||
);
|
||||
expect(outcome.fieldErrors.gender).not.toContain("invalid_value");
|
||||
});
|
||||
});
|
||||
@@ -60,7 +60,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("onboarding baseline profile save", () => {
|
||||
it("names the rejected field and still lets the accepted ones stand", async () => {
|
||||
it("names the rejected field, keeps the accepted ones, and holds the step", async () => {
|
||||
const calls = stubProfilePut(200, {
|
||||
data: {
|
||||
id: "u1",
|
||||
@@ -88,14 +88,21 @@ describe("onboarding baseline profile save", () => {
|
||||
gender: "OTHER",
|
||||
});
|
||||
|
||||
// The accepted fields are saved and the wizard moves on...
|
||||
expect(outcome.advance).toBe(true);
|
||||
// ...but the declined one is said out loud, by the name this screen
|
||||
// put next to the input.
|
||||
// The accepted fields are saved, and the declined one is said out
|
||||
// loud by the name this screen put next to the input.
|
||||
expect(outcome.notice).toEqual({
|
||||
tone: "warning",
|
||||
message: "Profile saved, but Height (cm) could not be updated.",
|
||||
});
|
||||
// This assertion was `true` until the rejection reached the input
|
||||
// it belongs to. Advancing here posted `step: 4`, stamped the
|
||||
// account as set up and replaced the only screen that could show
|
||||
// the refused value — a toast on the way out was the whole notice
|
||||
// the person got. The step now waits for a correction.
|
||||
expect(outcome.advance).toBe(false);
|
||||
expect(outcome.fieldErrors.heightCm).toBe(
|
||||
"That value is above the range this field accepts.",
|
||||
);
|
||||
});
|
||||
|
||||
it("says nothing when every field was accepted", async () => {
|
||||
@@ -107,7 +114,7 @@ describe("onboarding baseline profile save", () => {
|
||||
baselineFieldLabelKeys(false),
|
||||
);
|
||||
|
||||
expect(outcome).toEqual({ advance: true, notice: null });
|
||||
expect(outcome).toEqual({ advance: true, notice: null, fieldErrors: {} });
|
||||
});
|
||||
|
||||
it("names the blocking field and holds the step when nothing was saved", async () => {
|
||||
|
||||
@@ -24,7 +24,12 @@ import type {
|
||||
* own against a server answer. The step's whole defect lived between
|
||||
* the response and these inputs — the server named a field it refused
|
||||
* and nothing on screen changed — so the seam a test needs is exactly
|
||||
* here: field values in, markup out.
|
||||
* here: field values in, field rejections in, markup out.
|
||||
*
|
||||
* Each field id doubles as the id of its error paragraph
|
||||
* (`<id>-error`), which is what the control points `aria-describedby`
|
||||
* at, so a rejection is announced with the input rather than only
|
||||
* painted next to it.
|
||||
*/
|
||||
|
||||
export interface BaselineFieldValues {
|
||||
@@ -34,6 +39,15 @@ export interface BaselineFieldValues {
|
||||
gender: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-field sentences keyed by the SCHEMA field name the server uses
|
||||
* (`heightCm`, not `height`) — the key the rejection arrives under, so
|
||||
* nothing has to be renamed between the wire and the slot.
|
||||
*/
|
||||
export type BaselineFieldErrors = Partial<
|
||||
Record<"displayName" | "heightCm" | "dateOfBirth" | "gender", string>
|
||||
>;
|
||||
|
||||
const IDS = {
|
||||
displayName: "ob-baseline-display-name",
|
||||
heightCm: "ob-baseline-height",
|
||||
@@ -45,6 +59,7 @@ export function BaselineFields({
|
||||
value,
|
||||
onChange,
|
||||
heightAdapter,
|
||||
errors,
|
||||
}: {
|
||||
value: BaselineFieldValues;
|
||||
onChange: <K extends keyof BaselineFieldValues>(
|
||||
@@ -52,6 +67,7 @@ export function BaselineFields({
|
||||
next: BaselineFieldValues[K],
|
||||
) => void;
|
||||
heightAdapter: HeightUnitAdapter;
|
||||
errors: BaselineFieldErrors;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
@@ -63,6 +79,7 @@ export function BaselineFields({
|
||||
htmlFor={IDS.displayName}
|
||||
label={t("onboarding.baseline.displayNameLabel")}
|
||||
hint={t("onboarding.baseline.displayNameHint")}
|
||||
error={errors.displayName}
|
||||
>
|
||||
<Input
|
||||
id={IDS.displayName}
|
||||
@@ -71,6 +88,10 @@ export function BaselineFields({
|
||||
autoComplete="nickname"
|
||||
maxLength={50}
|
||||
placeholder={t("onboarding.baseline.displayNamePlaceholder")}
|
||||
aria-invalid={errors.displayName ? true : undefined}
|
||||
aria-describedby={
|
||||
errors.displayName ? `${IDS.displayName}-error` : undefined
|
||||
}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
@@ -82,6 +103,7 @@ export function BaselineFields({
|
||||
? t("onboarding.baseline.heightLabelFtIn")
|
||||
: t("onboarding.baseline.heightLabel")
|
||||
}
|
||||
error={errors.heightCm}
|
||||
>
|
||||
<HeightFieldControl
|
||||
idPrefix={IDS.heightCm}
|
||||
@@ -89,11 +111,14 @@ export function BaselineFields({
|
||||
value={value.height}
|
||||
onChange={(next) => onChange("height", next)}
|
||||
autoComplete="off"
|
||||
invalid={Boolean(errors.heightCm)}
|
||||
describedBy={errors.heightCm ? `${IDS.heightCm}-error` : undefined}
|
||||
/>
|
||||
</FieldGroup>
|
||||
<FieldGroup
|
||||
htmlFor={IDS.gender}
|
||||
label={t("onboarding.baseline.genderLabel")}
|
||||
error={errors.gender}
|
||||
>
|
||||
<Select
|
||||
// The design system's Radix Select uses an empty-string
|
||||
@@ -107,6 +132,10 @@ export function BaselineFields({
|
||||
id={IDS.gender}
|
||||
className="w-full"
|
||||
data-slot="onboarding-baseline-gender"
|
||||
aria-invalid={errors.gender ? true : undefined}
|
||||
aria-describedby={
|
||||
errors.gender ? `${IDS.gender}-error` : undefined
|
||||
}
|
||||
>
|
||||
<SelectValue placeholder={t("onboarding.baseline.genderNone")} />
|
||||
</SelectTrigger>
|
||||
@@ -129,6 +158,7 @@ export function BaselineFields({
|
||||
htmlFor={IDS.dateOfBirth}
|
||||
label={t("onboarding.baseline.dateOfBirthLabel")}
|
||||
hint={t("onboarding.baseline.dateOfBirthHint")}
|
||||
error={errors.dateOfBirth}
|
||||
>
|
||||
<DateField
|
||||
id={IDS.dateOfBirth}
|
||||
@@ -136,6 +166,10 @@ export function BaselineFields({
|
||||
onChange={(next) => onChange("dateOfBirth", next)}
|
||||
max={new Date().toISOString().slice(0, 10)}
|
||||
autoComplete="bday"
|
||||
aria-invalid={errors.dateOfBirth ? true : undefined}
|
||||
aria-describedby={
|
||||
errors.dateOfBirth ? `${IDS.dateOfBirth}-error` : undefined
|
||||
}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</fieldset>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
describeRejectedProfileField,
|
||||
describeRejectedProfileFields,
|
||||
type RejectedProfileField,
|
||||
} from "@/lib/profile/rejected-fields";
|
||||
import { toProfileSex } from "@/lib/profile/sex";
|
||||
@@ -15,14 +16,19 @@ import { toProfileSex } from "@/lib/profile/sex";
|
||||
* screen with nothing said, and the person walked away believing it was
|
||||
* stored.
|
||||
*
|
||||
* The settings account form already answers this question (see
|
||||
* `handleSaveProfile` in `src/components/settings/account-section`):
|
||||
* name the field in the person's own words, keep validator prose off
|
||||
* the screen, and only claim a clean save when there is one. The two
|
||||
* screens share the field-naming helper and the two sentences, so the
|
||||
* same rejection reads the same wherever it happens; only the labels
|
||||
* differ, because each screen calls the fields what its own inputs
|
||||
* call them.
|
||||
* The settings account form answers the same question the same way
|
||||
* (see `handleSaveProfile` in `src/components/settings/account-section`):
|
||||
* name the field in the person's own words, put the reason in that
|
||||
* field's own slot, keep validator prose off the screen, and only claim
|
||||
* a clean save when there is one. The two screens share the
|
||||
* field-naming helper and the sentences, so the same rejection reads
|
||||
* the same wherever it happens; only the labels differ, because each
|
||||
* screen calls the fields what its own inputs call them.
|
||||
*
|
||||
* A refused field also stops the wizard. Walking on would stamp
|
||||
* `onboardingCompletedAt` over a step the person has not finished, and
|
||||
* the only screen that could have shown them the refused value is the
|
||||
* one they just left.
|
||||
*/
|
||||
|
||||
/** The parts of the profile response this step reads. */
|
||||
@@ -41,6 +47,12 @@ export interface BaselineSaveOutcome {
|
||||
advance: boolean;
|
||||
/** A ready-to-show sentence, already localized. Null on a clean save. */
|
||||
notice: { tone: "warning" | "error"; message: string } | null;
|
||||
/**
|
||||
* One localized reason per refused field, keyed by the schema field
|
||||
* name so the step can drop each into the slot under its own input.
|
||||
* Empty on a clean save and on a failure that named no field.
|
||||
*/
|
||||
fieldErrors: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,22 +137,25 @@ export function describeBaselineSaveOutcome(
|
||||
if (res.ok) {
|
||||
const rejected = res.body?.data?.rejectedFields;
|
||||
const field = describeRejectedProfileField(rejected, t, labelKeys);
|
||||
if (!field) return { advance: true, notice: null };
|
||||
if (!field) return { advance: true, notice: null, fieldErrors: {} };
|
||||
// Some fields landed, at least one did not. The wizard stops here:
|
||||
// the accepted values are stored and stay in the inputs, the
|
||||
// refused ones say why under their own control, and the person can
|
||||
// correct and press the same button again. Moving on would mark
|
||||
// the account set up over a value nobody ever saw refused.
|
||||
return {
|
||||
advance: true,
|
||||
advance: false,
|
||||
notice: {
|
||||
tone: "warning",
|
||||
message: t("settings.profilePartiallySaved", { field }),
|
||||
},
|
||||
fieldErrors: describeRejectedProfileFields(rejected, t),
|
||||
};
|
||||
}
|
||||
|
||||
const errorCode = res.body?.meta?.errorCode;
|
||||
const blockingField = describeRejectedProfileField(
|
||||
res.body?.details?.issues,
|
||||
t,
|
||||
labelKeys,
|
||||
);
|
||||
const issues = res.body?.details?.issues;
|
||||
const blockingField = describeRejectedProfileField(issues, t, labelKeys);
|
||||
|
||||
if (errorCode === "profile.update.nothingSaved" && blockingField) {
|
||||
return {
|
||||
@@ -149,12 +164,14 @@ export function describeBaselineSaveOutcome(
|
||||
tone: "error",
|
||||
message: t("settings.profileNothingSaved", { field: blockingField }),
|
||||
},
|
||||
fieldErrors: describeRejectedProfileFields(issues, t),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
advance: false,
|
||||
notice: { tone: "error", message: localizedCode(errorCode, t) },
|
||||
fieldErrors: {},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ import { toast } from "sonner";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BaselineFields } from "@/components/onboarding/baseline-fields";
|
||||
import {
|
||||
BaselineFields,
|
||||
type BaselineFieldErrors,
|
||||
} from "@/components/onboarding/baseline-fields";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import {
|
||||
EMPTY_HEIGHT_DRAFT,
|
||||
@@ -90,6 +93,10 @@ export function BaselineForm() {
|
||||
|
||||
const [form, setForm] = useState<BaselineFormState>(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
// Refusals from the last save, one sentence per field. Cleared on
|
||||
// every attempt so a slot never keeps a reason the server no longer
|
||||
// has, and cleared per field as it is edited.
|
||||
const [fieldErrors, setFieldErrors] = useState<BaselineFieldErrors>({});
|
||||
|
||||
// v1.17.1 — optional anamnesis (conditions + allergies). Persisted
|
||||
// through the existing encrypted self-context path
|
||||
@@ -127,11 +134,23 @@ export function BaselineForm() {
|
||||
value: BaselineFormState[K],
|
||||
) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
// The person is answering the objection; the objection goes away
|
||||
// while they type rather than sitting under a value they already
|
||||
// corrected. `height` is the draft's name here and `heightCm` the
|
||||
// schema's — the same field, so editing either box clears it.
|
||||
const slot = key === "height" ? "heightCm" : key;
|
||||
setFieldErrors((prev) => {
|
||||
if (!(slot in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[slot as keyof BaselineFieldErrors];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function advance(opts: { saveProfile: boolean }) {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
setFieldErrors({});
|
||||
try {
|
||||
if (opts.saveProfile) {
|
||||
const profileBody = buildBaselineProfileBody(
|
||||
@@ -150,9 +169,12 @@ export function BaselineForm() {
|
||||
show(outcome.notice.message);
|
||||
}
|
||||
if (!outcome.advance) {
|
||||
// Nothing was written. Staying on the step is the point —
|
||||
// the person has a named field to fix and the values they
|
||||
// typed are still in front of them.
|
||||
// A field was refused. Staying on the step is the point —
|
||||
// each refused input now says why under itself, the values
|
||||
// the person typed are still in front of them, and the
|
||||
// account is not stamped as set up over a value that never
|
||||
// landed.
|
||||
setFieldErrors(outcome.fieldErrors);
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
@@ -195,6 +217,7 @@ export function BaselineForm() {
|
||||
value={form}
|
||||
onChange={patch}
|
||||
heightAdapter={heightAdapter}
|
||||
errors={fieldErrors}
|
||||
/>
|
||||
|
||||
<AnamnesisCard
|
||||
|
||||
@@ -31,6 +31,10 @@ interface HeightFieldControlProps {
|
||||
onChange: (next: HeightDraft) => void;
|
||||
autoComplete?: string;
|
||||
enterKeyHint?: "next";
|
||||
/** Marks every box of the control as refused. */
|
||||
invalid?: boolean;
|
||||
/** Id of the error paragraph the control's boxes point at. */
|
||||
describedBy?: string;
|
||||
}
|
||||
|
||||
export function HeightFieldControl({
|
||||
@@ -40,6 +44,8 @@ export function HeightFieldControl({
|
||||
onChange,
|
||||
autoComplete,
|
||||
enterKeyHint,
|
||||
invalid,
|
||||
describedBy,
|
||||
}: HeightFieldControlProps) {
|
||||
const { t } = useTranslations();
|
||||
const { bounds } = adapter;
|
||||
@@ -58,6 +64,8 @@ export function HeightFieldControl({
|
||||
max={bounds.cm.max}
|
||||
step={0.1}
|
||||
autoComplete={autoComplete}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={describedBy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -77,6 +85,8 @@ export function HeightFieldControl({
|
||||
max={bounds.feet.max}
|
||||
step={1}
|
||||
autoComplete={autoComplete}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={describedBy}
|
||||
aria-label={t("common.heightFeet")}
|
||||
/>
|
||||
<span
|
||||
@@ -99,6 +109,8 @@ export function HeightFieldControl({
|
||||
max={bounds.inches.max}
|
||||
step={1}
|
||||
autoComplete={autoComplete}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={describedBy}
|
||||
aria-label={t("common.heightInches")}
|
||||
/>
|
||||
<span
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { FieldError } from "@/components/forms/field-error";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -10,6 +11,9 @@ import { cn } from "@/lib/utils";
|
||||
* is a no-op on rendered output.
|
||||
*
|
||||
* - `hint` renders below the control as muted fine print.
|
||||
* - `error` renders below the hint as the field's own error slot, with
|
||||
* the id `<htmlFor>-error` so the control can point
|
||||
* `aria-describedby` at it.
|
||||
* - `labelAccessory` sits right-aligned in the label row (e.g. a char counter);
|
||||
* when present the label row becomes a `justify-between` flex row.
|
||||
*/
|
||||
@@ -17,6 +21,7 @@ export function FieldGroup({
|
||||
htmlFor,
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
labelAccessory,
|
||||
className,
|
||||
labelClassName,
|
||||
@@ -25,6 +30,7 @@ export function FieldGroup({
|
||||
htmlFor: string;
|
||||
label: ReactNode;
|
||||
hint?: ReactNode;
|
||||
error?: ReactNode;
|
||||
labelAccessory?: ReactNode;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
@@ -46,6 +52,7 @@ export function FieldGroup({
|
||||
)}
|
||||
{children}
|
||||
{hint ? <p className="text-muted-foreground text-xs">{hint}</p> : null}
|
||||
<FieldError id={`${htmlFor}-error`} message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -60,3 +60,52 @@ export function describeRejectedProfileField(
|
||||
const labelKey = labelKeys[first.path];
|
||||
return labelKey ? t(labelKey) : first.path;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sentence a person reads under the input, keyed by the validator
|
||||
* code the server sent back. The code is the only reason that crosses
|
||||
* the wire — `message` is the validator's own prose and is deliberately
|
||||
* never shown — so each code gets one plain sentence saying what was
|
||||
* wrong with the value, in the person's language.
|
||||
*
|
||||
* A code with no entry here falls back to `other`, which says the field
|
||||
* was not accepted without inventing a reason. New Zod codes therefore
|
||||
* degrade to something honest instead of to silence.
|
||||
*/
|
||||
const REJECTION_REASON_KEYS: Record<string, string> = {
|
||||
too_big: "settings.profileRejection.tooBig",
|
||||
too_small: "settings.profileRejection.tooSmall",
|
||||
invalid_value: "settings.profileRejection.invalidValue",
|
||||
invalid_format: "settings.profileRejection.invalidFormat",
|
||||
invalid_type: "settings.profileRejection.invalidType",
|
||||
};
|
||||
|
||||
const REJECTION_FALLBACK_KEY = "settings.profileRejection.other";
|
||||
|
||||
/**
|
||||
* Turn a rejection list into one sentence per field, keyed by the
|
||||
* schema path so a form can drop each one into the slot under the
|
||||
* input it belongs to.
|
||||
*
|
||||
* Every refused field is carried, not just the first: a submission with
|
||||
* two bad values that only names one sends the person round the loop
|
||||
* twice. Where a field is refused for several reasons at once the first
|
||||
* one wins — the others are restatements of the same wrong value.
|
||||
*
|
||||
* `labelKeys` is accepted for symmetry with
|
||||
* `describeRejectedProfileField` and for the caller that wants to know
|
||||
* which fields it can actually render; a path the calling screen has no
|
||||
* input for still gets an entry, so nothing is dropped on the floor.
|
||||
*/
|
||||
export function describeRejectedProfileFields(
|
||||
fields: RejectedProfileField[] | undefined,
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const field of fields ?? []) {
|
||||
if (out[field.path]) continue;
|
||||
const key = REJECTION_REASON_KEYS[field.code] ?? REJECTION_FALLBACK_KEY;
|
||||
out[field.path] = t(key);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in new issue
Block a user