diff --git a/messages/de.json b/messages/de.json index eef3089fe..2c77763c1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index e75e342a8..f4d924b7e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/es.json b/messages/es.json index 91f6187d8..da24ec7e2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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.", diff --git a/messages/fr.json b/messages/fr.json index 23921c63d..f3f88618f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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.", diff --git a/messages/it.json b/messages/it.json index e17e31091..a805995dc 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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.", diff --git a/messages/ko.json b/messages/ko.json index 66d228404..c382284b8 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5353,6 +5353,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 7ec4d5cbc..b05aea094 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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.", 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 index 59ef5930a..5eaf113e5 100644 --- a/src/components/onboarding/baseline-fields.tsx +++ b/src/components/onboarding/baseline-fields.tsx @@ -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 + * (`-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: ( @@ -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} > @@ -82,6 +103,7 @@ export function BaselineFields({ ? t("onboarding.baseline.heightLabelFtIn") : t("onboarding.baseline.heightLabel") } + error={errors.heightCm} > onChange("height", next)} autoComplete="off" + invalid={Boolean(errors.heightCm)} + describedBy={errors.heightCm ? `${IDS.heightCm}-error` : undefined} />