diff --git a/messages/de.json b/messages/de.json index 9a3d0cb6c..6256bc25b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5355,6 +5355,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.", diff --git a/messages/en.json b/messages/en.json index ae324a483..3ccc18318 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5355,6 +5355,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.", diff --git a/messages/es.json b/messages/es.json index 3fb6c1d8c..7ba0835c3 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5355,6 +5355,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.", diff --git a/messages/fr.json b/messages/fr.json index 08a2b848f..047d580a1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5355,6 +5355,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.", diff --git a/messages/it.json b/messages/it.json index bb61e24ba..c8b4e49d4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5355,6 +5355,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.", diff --git a/messages/ko.json b/messages/ko.json index 37af1bca3..4eb23051d 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5355,6 +5355,14 @@ "profileSaved": "프로필을 저장했어요", "profilePartiallySaved": "프로필을 저장했지만 {field} 항목은 업데이트하지 못했어요.", "profileNothingSaved": "아무것도 저장하지 못했어요. {field} 항목을 고친 뒤 다시 시도해 주세요.", + "profileRejection": { + "tooBig": "이 항목이 받는 범위보다 큰 값이에요.", + "tooSmall": "이 항목이 받는 범위보다 작은 값이에요.", + "invalidValue": "이 항목이 받는 답변이 아니에요.", + "invalidFormat": "이 항목이 읽을 수 있는 형식이 아니에요.", + "invalidType": "이 항목이 저장할 수 있는 형식으로 오지 않았어요.", + "other": "이 값은 저장하지 못했어요. 확인한 뒤 다시 시도해 주세요." + }, "avatar": { "title": "프로필 사진", "description": "직접 운영하는 서버에 저장되는 사진을 올려요. 외부 아바타 서비스 대신 앱 전체에 표시돼요.", diff --git a/messages/pl.json b/messages/pl.json index da7723c8b..ae08e819e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5355,6 +5355,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.", diff --git a/src/__tests__/profile-rejection-surface-guard.test.ts b/src/__tests__/profile-rejection-surface-guard.test.ts new file mode 100644 index 000000000..7bed465fd --- /dev/null +++ b/src/__tests__/profile-rejection-surface-guard.test.ts @@ -0,0 +1,135 @@ +/** + * Every client that writes the profile has to read the answer. + * + * `applyProfileUpdate` writes field by field: a body where some fields + * validate and some do not is answered with 200 and a `rejectedFields` + * list naming what was dropped. A client that treats any 200 as a clean + * save discards the person's correction and says nothing — which is + * exactly what the onboarding baseline step did, for as long as the + * route has behaved this way. + * + * That is a whole-class defect, not one screen's bug, so the guard is + * on the class: enumerate every surface that writes to either profile + * route and require each one either to read `rejectedFields`, or to + * send a body the partial arm cannot reach. + * + * Its limit, written down so nobody mistakes it for a proof: it matches + * a URL literal next to a mutating method. A future submitter that + * builds its path by concatenation, or routes through a helper that + * takes the path as an argument, would not be seen. The set below is + * frozen so that adding one has to be a deliberate edit here. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +/** The two routes that answer with `rejectedFields`. */ +const PROFILE_ROUTES = ["/api/auth/profile", "/api/user/profile"]; + +function sourceFiles(): string[] { + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.startsWith("app/api/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .sort(); +} + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +/** + * A file counts as a submitter when a profile-route literal and a + * mutating method literal both appear within one request expression — + * approximated as the 400 characters following the URL, which covers + * the `fetch(url, { method, headers, body })` shape every caller in the + * tree uses. + */ +function submitters(): string[] { + const found: string[] = []; + for (const rel of sourceFiles()) { + const text = read(rel); + for (const route of PROFILE_ROUTES) { + let at = text.indexOf(`"${route}"`); + while (at !== -1) { + const window = text.slice(at, at + 400); + if (/method:\s*"(PUT|PATCH)"/.test(window)) { + found.push(rel); + at = -1; + break; + } + at = text.indexOf(`"${route}"`, at + 1); + } + if (found[found.length - 1] === rel) break; + } + } + return [...new Set(found)].sort(); +} + +/** + * Submitters whose body carries exactly ONE field. The partial arm + * needs at least one field to accept alongside at least one to refuse, + * so a one-field body is answered either with a clean 200 or with the + * 422 `profile.update.nothingSaved` — never with `rejectedFields`. + * These already surface the 422 as a save error. + * + * The claim is not taken on trust: the body literal is counted below. + */ +const SINGLE_FIELD_SUBMITTERS = [ + "components/settings/date-format-select.tsx", + "components/settings/mood-reminder-card.tsx", + "components/settings/time-format-select.tsx", +]; + +/** Submitters that send several fields and must read the answer. */ +const MULTI_FIELD_SUBMITTERS = [ + "components/onboarding/baseline-form-utils.ts", + "components/settings/account-section/index.tsx", +]; + +describe("every profile submitter answers a rejected field", () => { + it("finds the submitters at all", () => { + // A guard that matches nothing is green for the wrong reason. + expect(submitters().length).toBeGreaterThan(0); + }); + + it("keeps the submitter set frozen", () => { + expect(submitters()).toEqual( + [...SINGLE_FIELD_SUBMITTERS, ...MULTI_FIELD_SUBMITTERS].sort(), + ); + }); + + it.each(MULTI_FIELD_SUBMITTERS)("%s reads rejectedFields", (rel) => { + const text = read(rel); + expect(text).toMatch(/rejectedFields/); + // And renders them per field, rather than only counting them. The + // matcher demands a CALL: an import of the helper next to a body + // that never calls it is the shape this whole guard exists to + // catch, and matching the bare name would pass on it. + const calls = [...text.matchAll(/describeRejectedProfileFields\s*\(/g)]; + expect(calls.length).toBeGreaterThan(0); + }); + + it.each(SINGLE_FIELD_SUBMITTERS)("%s sends exactly one field", (rel) => { + const text = read(rel); + const bodies = [ + ...text.matchAll(/body:\s*JSON\.stringify\(\{([^}]*)\}\)/g), + ]; + expect(bodies.length).toBeGreaterThan(0); + for (const [, inner] of bodies) { + const keys = inner + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + // One key, so there is never a valid sibling to save alongside an + // invalid field — the arm that answers with `rejectedFields` + // cannot be reached from here. + expect(keys).toHaveLength(1); + } + }); +}); diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts index b42229280..a90ff2dbb 100644 --- a/src/__tests__/unit-preference-display-guard.test.ts +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -56,9 +56,19 @@ const TARGET_SURFACES = [ * the same failure the target block guards against — a converted field * over a canonical save silently rewrites the stored height. */ +/** + * One entry per surface. A surface is a group of files because the + * onboarding step keeps the preference and the adapter where the save + * happens and renders the control from its own inputs file — the three + * conditions below are asserted across the group, not per file, so + * splitting a form does not read as an un-wire. + */ const HEIGHT_SURFACES = [ - join(SRC, "components", "onboarding", "baseline-form.tsx"), - join(SRC, "components", "settings", "account-section", "index.tsx"), + [ + join(SRC, "components", "onboarding", "baseline-form.tsx"), + join(SRC, "components", "onboarding", "baseline-fields.tsx"), + ], + [join(SRC, "components", "settings", "account-section", "index.tsx")], ]; const HEIGHT_CONTROL = join( @@ -153,21 +163,25 @@ describe("unit-preference display guard", () => { // from one form and it falls back to centimetres while its sibling // shows feet and inches. const offenders: string[] = []; - for (const file of HEIGHT_SURFACES) { - const src = readFileSync(file, "utf8"); + for (const files of HEIGHT_SURFACES) { + const surface = files.join(" + "); + const src = files.map((file) => readFileSync(file, "utf8")).join("\n"); if (!src.includes("useUnitDisplay()")) { - offenders.push(`${file}: no preference hook`); + offenders.push(`${surface}: no preference hook`); } if (!src.includes("resolveHeightUnitAdapter")) { - offenders.push(`${file}: no height adapter`); + offenders.push(`${surface}: no height adapter`); } - if (!src.includes("HeightFieldControl")) { - offenders.push(`${file}: hand-rolled height input`); + // Word-bounded: a renamed local wrapper whose name merely starts + // with the control's satisfies a substring check while rendering + // something else entirely. + if (!/\bHeightFieldControl\b/.test(src)) { + offenders.push(`${surface}: hand-rolled height input`); } // The canonical centimetre guardrails must come from the // adapter's inward-rounded bounds, never from a literal. if (/min=\{50\}|max=\{300\}/.test(src)) { - offenders.push(`${file}: hardcoded centimetre guardrail`); + offenders.push(`${surface}: hardcoded centimetre guardrail`); } } expect(offenders).toEqual([]); diff --git a/src/components/forms/field-error.tsx b/src/components/forms/field-error.tsx new file mode 100644 index 000000000..5a720a786 --- /dev/null +++ b/src/components/forms/field-error.tsx @@ -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 `-error`. */ + id: string; + message?: ReactNode; +}) { + if (!message) return null; + return ( + + ); +} diff --git a/src/components/onboarding/__tests__/baseline-fields-rejection-slots.test.tsx b/src/components/onboarding/__tests__/baseline-fields-rejection-slots.test.tsx new file mode 100644 index 000000000..57aec24f9 --- /dev/null +++ b/src/components/onboarding/__tests__/baseline-fields-rejection-slots.test.tsx @@ -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) => { + const raw = resolveKey(bundle as Record, 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) { + return renderToStaticMarkup( + + + , + ); +} + +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"); + }); +}); diff --git a/src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts b/src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts index 198ed664f..8094ddbe0 100644 --- a/src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts +++ b/src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts @@ -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 () => { diff --git a/src/components/onboarding/baseline-fields.tsx b/src/components/onboarding/baseline-fields.tsx new file mode 100644 index 000000000..5eaf113e5 --- /dev/null +++ b/src/components/onboarding/baseline-fields.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { Input } from "@/components/ui/input"; +import { DateField } from "@/components/ui/date-field"; +import { FieldGroup } from "@/components/ui/field-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { HeightFieldControl } from "@/components/profile/height-field-control"; +import { useTranslations } from "@/lib/i18n/context"; +import type { + HeightDraft, + HeightUnitAdapter, +} from "@/lib/profile/height-unit-display"; + +/** + * The four profile inputs of the onboarding baseline step. + * + * Split out of `baseline-form.tsx` so the markup can be rendered on its + * 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, field rejections in, markup out. + * + * Each field id doubles as the id of its error paragraph + * (`-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 { + displayName: string; + height: HeightDraft; + dateOfBirth: string; + 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", + dateOfBirth: "ob-baseline-dob", + gender: "ob-baseline-gender", +} as const; + +export function BaselineFields({ + value, + onChange, + heightAdapter, + errors, +}: { + value: BaselineFieldValues; + onChange: ( + key: K, + next: BaselineFieldValues[K], + ) => void; + heightAdapter: HeightUnitAdapter; + errors: BaselineFieldErrors; +}) { + const { t } = useTranslations(); + + return ( +
+ {t("onboarding.baseline.title")} + + + onChange("displayName", e.target.value)} + autoComplete="nickname" + maxLength={50} + placeholder={t("onboarding.baseline.displayNamePlaceholder")} + aria-invalid={errors.displayName ? true : undefined} + aria-describedby={ + errors.displayName ? `${IDS.displayName}-error` : undefined + } + /> + + +
+ + onChange("height", next)} + autoComplete="off" + invalid={Boolean(errors.heightCm)} + describedBy={errors.heightCm ? `${IDS.heightCm}-error` : undefined} + /> + + + + +
+ + + 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 + } + /> + +
+ ); +} diff --git a/src/components/onboarding/baseline-form-utils.ts b/src/components/onboarding/baseline-form-utils.ts index 634d5ffc9..c51a95722 100644 --- a/src/components/onboarding/baseline-form-utils.ts +++ b/src/components/onboarding/baseline-form-utils.ts @@ -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; } /** @@ -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: {}, }; } diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx index 7576be392..6bb195692 100644 --- a/src/components/onboarding/baseline-form.tsx +++ b/src/components/onboarding/baseline-form.tsx @@ -8,17 +8,10 @@ import { toast } from "sonner"; import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { DateField } from "@/components/ui/date-field"; -import { FieldGroup } from "@/components/ui/field-group"; -import { Input } from "@/components/ui/input"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { HeightFieldControl } from "@/components/profile/height-field-control"; + BaselineFields, + type BaselineFieldErrors, +} from "@/components/onboarding/baseline-fields"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { EMPTY_HEIGHT_DRAFT, @@ -100,6 +93,10 @@ export function BaselineForm() { const [form, setForm] = useState(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({}); // v1.17.1 — optional anamnesis (conditions + allergies). Persisted // through the existing encrypted self-context path @@ -137,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( @@ -160,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; } @@ -201,91 +213,12 @@ export function BaselineForm() {

-
- {t("onboarding.baseline.title")} - - - patch("displayName", e.target.value)} - autoComplete="nickname" - maxLength={50} - placeholder={t("onboarding.baseline.displayNamePlaceholder")} - /> - - -
- - patch("height", next)} - autoComplete="off" - /> - - - - -
- - - patch("dateOfBirth", value)} - max={new Date().toISOString().slice(0, 10)} - autoComplete="bday" - /> - -
+ 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")} /> m[1]), + ), + ].sort(); +} + +/** The slots an input clears as it is edited. */ +function clearedSlots(): string[] { + return [ + ...new Set( + [...SOURCE.matchAll(/clearFieldError\("(\w+)"\)/g)].map((m) => m[1]), + ), + ].sort(); +} + +describe("a rejected profile field clears as it is edited", () => { + it("drops the edited field's sentence and keeps the others", () => { + const refused = { + heightCm: "That value is above the range this field accepts.", + gender: "That value is not one this field accepts.", + }; + + const afterHeightEdit = clearRejectedField(refused, "heightCm"); + expect(afterHeightEdit.heightCm).toBeUndefined(); + // The gender value was not touched, so its refusal still answers + // the value that is still in the box. + expect(afterHeightEdit.gender).toBe(refused.gender); + + // Editing the other field clears only its own slot in turn. + const afterBothEdits = clearRejectedField(afterHeightEdit, "gender"); + expect(afterBothEdits).toEqual({}); + // The original map is never mutated — it is React state. + expect(refused.heightCm).toBeDefined(); + }); + + it("leaves a map with nothing in that slot untouched", () => { + const refused = { gender: "That value is not one this field accepts." }; + // Same reference: typing in a field that was never refused must not + // hand React a new object every keystroke. + expect(clearRejectedField(refused, "email")).toBe(refused); + }); + + it("clears every slot the form can paint", () => { + // A guard that matches nothing is green for the wrong reason. + expect(renderedSlots().length).toBeGreaterThan(0); + expect(clearedSlots()).toEqual(renderedSlots()); + }); + + it("paints a slot for every field the server can refuse", () => { + // The label map is the set of paths `applyProfileUpdate` names back + // to this screen; a path with no slot has nowhere to land. + expect(renderedSlots()).toEqual( + Object.keys(PROFILE_FIELD_LABEL_KEYS).sort(), + ); + }); +}); diff --git a/src/components/settings/account-section/account-section-utils.ts b/src/components/settings/account-section/account-section-utils.ts index d37ab9683..ba90a1ed5 100644 --- a/src/components/settings/account-section/account-section-utils.ts +++ b/src/components/settings/account-section/account-section-utils.ts @@ -67,3 +67,25 @@ export function statusText( * uses the default label map, which points at the labels these inputs * already carry. */ + +/** + * Drop one field's refusal as the person edits that field. + * + * A refusal answers a value. Once the value changes it answers nothing, + * so it goes instead of sitting under an input that has already been + * corrected — the same rule the onboarding baseline step applies in its + * `patch` helper. Only the edited slot is dropped: the other fields the + * same save refused were not touched, so their sentences still stand. + * + * The map is returned unchanged when the slot holds nothing, so typing + * in a field that was never refused does not re-render the form. + */ +export function clearRejectedField( + errors: Record, + slot: string, +): Record { + if (!(slot in errors)) return errors; + const next = { ...errors }; + delete next[slot]; + return next; +} diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index 1dd03c3f4..21335c2b7 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -37,9 +37,12 @@ import { detectBrowserTimezone } from "@/lib/tz/format"; import { apiFetchRaw } from "@/lib/api/api-fetch"; import { describeRejectedProfileField, + describeRejectedProfileFields, type RejectedProfileField, } from "@/lib/profile/rejected-fields"; +import { FieldError } from "@/components/forms/field-error"; import { + clearRejectedField, resolveInitialTimezone, statusText, type StatusMessage, @@ -90,6 +93,20 @@ export function AccountSection() { const [saveMsgType, setSaveMsgType] = useState< "success" | "warning" | "error" | null >(null); + // One sentence per field the last save refused, keyed by the schema + // name the server sent. The banner above the button says a save was + // partial; these say which value was not stored and why, under the + // input holding it. Cleared at the start of every save, and per field + // as that field is edited. + const [fieldErrors, setFieldErrors] = useState>({}); + // The person is answering the objection; the objection goes away + // while they type rather than sitting under a value they already + // corrected. The slot is the schema name the server sent, which is + // what the sentence under the input is keyed by — the height boxes + // hold a `height` draft but name the `heightCm` slot. + function clearFieldError(slot: string) { + setFieldErrors((prev) => clearRejectedField(prev, slot)); + } // v1.23 — passkey + second-factor management moved to the dedicated // /settings/security hub so "how I secure my account" reads as one place. @@ -172,6 +189,7 @@ export function AccountSection() { setSaving(true); setSaveMsg(null); setSaveMsgType(null); + setFieldErrors({}); const savedProfile = { email: email.trim(), height, @@ -234,6 +252,7 @@ export function AccountSection() { params: { field: describeRejectedProfileField(rejected, t) ?? "" }, }); setSaveMsgType("warning"); + setFieldErrors(describeRejectedProfileFields(rejected, t)); await refetch(); setSeededUserId(null); } else { @@ -272,6 +291,7 @@ export function AccountSection() { } | null; const errorCode = json?.meta?.errorCode; const field = describeRejectedProfileField(json?.details?.issues, t); + setFieldErrors(describeRejectedProfileFields(json?.details?.issues, t)); if (errorCode === "profile.update.nothingSaved" && field) { setSaveMsg({ key: "settings.profileNothingSaved", params: { field } }); } else if (errorCode) { @@ -336,12 +356,18 @@ export function AccountSection() { id="email" type="email" value={email} - onChange={(e) => setEmail(e.target.value)} + onChange={(e) => { + setEmail(e.target.value); + clearFieldError("email"); + }} placeholder={t("auth.emailPlaceholder")} maxLength={320} autoComplete="email" enterKeyHint="next" + aria-invalid={fieldErrors.email ? true : undefined} + aria-describedby={fieldErrors.email ? "email-error" : undefined} /> + @@ -351,7 +377,14 @@ export function AccountSection() { setGender(e.target.value)} + onChange={(e) => { + setGender(e.target.value); + clearFieldError("gender"); + }} + aria-invalid={fieldErrors.gender ? true : undefined} + aria-describedby={ + fieldErrors.gender ? "gender-error" : undefined + } > @@ -361,6 +394,7 @@ export function AccountSection() {

{t("settings.genderHint")}

+
@@ -390,12 +430,20 @@ export function AccountSection() { { + setDateOfBirth(next); + clearFieldError("dateOfBirth"); + }} max={new Date().toISOString().slice(0, 10)} + aria-invalid={fieldErrors.dateOfBirth ? true : undefined} + aria-describedby={ + fieldErrors.dateOfBirth ? "dob-error" : undefined + } />

{t("settings.dateOfBirthHint")}

+
@@ -451,11 +499,19 @@ export function AccountSection() { setFullName(e.target.value)} + onChange={(e) => { + setFullName(e.target.value); + clearFieldError("fullName"); + }} placeholder={t("settings.identity.fullNamePlaceholder")} maxLength={120} autoComplete="name" + aria-invalid={fieldErrors.fullName ? true : undefined} + aria-describedby={ + fieldErrors.fullName ? "full-name-error" : undefined + } /> +
@@ -465,9 +521,20 @@ export function AccountSection() { setInsurerName(e.target.value)} + onChange={(e) => { + setInsurerName(e.target.value); + clearFieldError("insurerName"); + }} placeholder={t("settings.identity.insurerPlaceholder")} maxLength={120} + aria-invalid={fieldErrors.insurerName ? true : undefined} + aria-describedby={ + fieldErrors.insurerName ? "insurer-error" : undefined + } + /> +
@@ -477,17 +544,28 @@ export function AccountSection() { - setInsuranceNumber(e.target.value.toUpperCase()) - } + onChange={(e) => { + setInsuranceNumber(e.target.value.toUpperCase()); + clearFieldError("insuranceNumber"); + }} placeholder="A123456780" maxLength={10} autoCapitalize="characters" spellCheck={false} + aria-invalid={fieldErrors.insuranceNumber ? true : undefined} + aria-describedby={ + fieldErrors.insuranceNumber + ? "insurance-number-error" + : undefined + } />

{t("settings.identity.insuranceNumberHint")}

+
diff --git a/src/components/ui/field-group.tsx b/src/components/ui/field-group.tsx index 60aca6399..8040c6f62 100644 --- a/src/components/ui/field-group.tsx +++ b/src/components/ui/field-group.tsx @@ -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 `-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 ?

{hint}

: null} + ); } diff --git a/src/lib/profile/rejected-fields.ts b/src/lib/profile/rejected-fields.ts index 12cd82a20..da0a3e604 100644 --- a/src/lib/profile/rejected-fields.ts +++ b/src/lib/profile/rejected-fields.ts @@ -60,3 +60,53 @@ 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 = { + 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. + * + * No label map is needed: the sentence sits under the input, which + * already carries the label, and the key is the schema path so the + * calling screen can place it without a rename. A path the screen has + * no input for still gets an entry rather than being dropped, so the + * caller can decide what to do with it. + */ +export function describeRejectedProfileFields( + fields: RejectedProfileField[] | undefined, + t: (key: string, params?: Record) => string, +): Record { + const out: Record = {}; + 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; +}