Merge the per-field profile rejection surfaces

This commit is contained in:
Marc-André Bombeck committed 2026-09-10 01:06:59 +02:00
commit 6338da8cf6
21 files changed
+935 -137

No files matched your search

+8
View File
@@ -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.",
+8
View File
@@ -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.",
+8
View File
@@ -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.",
+8
View File
@@ -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.",
+8
View File
@@ -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.",
+8
View File
@@ -5355,6 +5355,14 @@
"profileSaved": "프로필을 저장했어요",
"profilePartiallySaved": "프로필을 저장했지만 {field} 항목은 업데이트하지 못했어요.",
"profileNothingSaved": "아무것도 저장하지 못했어요. {field} 항목을 고친 뒤 다시 시도해 주세요.",
"profileRejection": {
"tooBig": "이 항목이 받는 범위보다 큰 값이에요.",
"tooSmall": "이 항목이 받는 범위보다 작은 값이에요.",
"invalidValue": "이 항목이 받는 답변이 아니에요.",
"invalidFormat": "이 항목이 읽을 수 있는 형식이 아니에요.",
"invalidType": "이 항목이 저장할 수 있는 형식으로 오지 않았어요.",
"other": "이 값은 저장하지 못했어요. 확인한 뒤 다시 시도해 주세요."
},
"avatar": {
"title": "프로필 사진",
"description": "직접 운영하는 서버에 저장되는 사진을 올려요. 외부 아바타 서비스 대신 앱 전체에 표시돼요.",
+8
View File
@@ -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.",
@@ -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);
}
});
});
@@ -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([]);
+32
View File
@@ -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 () => {
@@ -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
* (`<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 {
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: <K extends keyof BaselineFieldValues>(
key: K,
next: BaselineFieldValues[K],
) => void;
heightAdapter: HeightUnitAdapter;
errors: BaselineFieldErrors;
}) {
const { t } = useTranslations();
return (
<fieldset className="bg-card border-border space-y-4 rounded-xl border p-4 md:p-6">
<legend className="sr-only">{t("onboarding.baseline.title")}</legend>
<FieldGroup
htmlFor={IDS.displayName}
label={t("onboarding.baseline.displayNameLabel")}
hint={t("onboarding.baseline.displayNameHint")}
error={errors.displayName}
>
<Input
id={IDS.displayName}
value={value.displayName}
onChange={(e) => 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
}
/>
</FieldGroup>
<div className="grid gap-4 sm:grid-cols-2">
<FieldGroup
htmlFor={IDS.heightCm}
label={
heightAdapter.usesFeetInches
? t("onboarding.baseline.heightLabelFtIn")
: t("onboarding.baseline.heightLabel")
}
error={errors.heightCm}
>
<HeightFieldControl
idPrefix={IDS.heightCm}
adapter={heightAdapter}
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
// sentinel to mean "no selection"; map back and forth so
// the form state ("") and the Select's value (undefined-
// adjacent) stay aligned. v1.4.25 W21 Fix-N (design-M1).
value={value.gender === "" ? undefined : value.gender}
onValueChange={(next) => onChange("gender", next)}
>
<SelectTrigger
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>
<SelectContent>
<SelectItem value="MALE">
{t("onboarding.baseline.genderMale")}
</SelectItem>
<SelectItem value="FEMALE">
{t("onboarding.baseline.genderFemale")}
</SelectItem>
<SelectItem value="OTHER">
{t("onboarding.baseline.genderOther")}
</SelectItem>
</SelectContent>
</Select>
</FieldGroup>
</div>
<FieldGroup
htmlFor={IDS.dateOfBirth}
label={t("onboarding.baseline.dateOfBirthLabel")}
hint={t("onboarding.baseline.dateOfBirthHint")}
error={errors.dateOfBirth}
>
<DateField
id={IDS.dateOfBirth}
value={value.dateOfBirth}
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: {},
};
}
+31 -98
View File
@@ -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<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
@@ -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() {
</p>
</header>
<fieldset className="bg-card border-border space-y-4 rounded-xl border p-4 md:p-6">
<legend className="sr-only">{t("onboarding.baseline.title")}</legend>
<FieldGroup
htmlFor="ob-baseline-display-name"
label={t("onboarding.baseline.displayNameLabel")}
hint={t("onboarding.baseline.displayNameHint")}
>
<Input
id="ob-baseline-display-name"
value={form.displayName}
onChange={(e) => patch("displayName", e.target.value)}
autoComplete="nickname"
maxLength={50}
placeholder={t("onboarding.baseline.displayNamePlaceholder")}
/>
</FieldGroup>
<div className="grid gap-4 sm:grid-cols-2">
<FieldGroup
htmlFor="ob-baseline-height"
label={
heightAdapter.usesFeetInches
? t("onboarding.baseline.heightLabelFtIn")
: t("onboarding.baseline.heightLabel")
}
>
<HeightFieldControl
idPrefix="ob-baseline-height"
adapter={heightAdapter}
value={form.height}
onChange={(next) => patch("height", next)}
autoComplete="off"
/>
</FieldGroup>
<FieldGroup
htmlFor="ob-baseline-gender"
label={t("onboarding.baseline.genderLabel")}
>
<Select
// The design system's Radix Select uses an empty-string
// sentinel to mean "no selection"; map back and forth so
// the form state ("") and the Select's value (undefined-
// adjacent) stay aligned. v1.4.25 W21 Fix-N (design-M1).
value={form.gender === "" ? undefined : form.gender}
onValueChange={(next) => patch("gender", next)}
>
<SelectTrigger
id="ob-baseline-gender"
className="w-full"
data-slot="onboarding-baseline-gender"
>
<SelectValue
placeholder={t("onboarding.baseline.genderNone")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="MALE">
{t("onboarding.baseline.genderMale")}
</SelectItem>
<SelectItem value="FEMALE">
{t("onboarding.baseline.genderFemale")}
</SelectItem>
<SelectItem value="OTHER">
{t("onboarding.baseline.genderOther")}
</SelectItem>
</SelectContent>
</Select>
</FieldGroup>
</div>
<FieldGroup
htmlFor="ob-baseline-dob"
label={t("onboarding.baseline.dateOfBirthLabel")}
hint={t("onboarding.baseline.dateOfBirthHint")}
>
<DateField
id="ob-baseline-dob"
value={form.dateOfBirth}
onChange={(value) => patch("dateOfBirth", value)}
max={new Date().toISOString().slice(0, 10)}
autoComplete="bday"
/>
</FieldGroup>
</fieldset>
<BaselineFields
value={form}
onChange={patch}
heightAdapter={heightAdapter}
errors={fieldErrors}
/>
<AnamnesisCard
value={anamnesis}
@@ -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
@@ -0,0 +1,83 @@
/**
* A refusal the person has already answered must not keep standing.
*
* The settings profile form paints one sentence per field the last save
* declined. It cleared them only on the next submit, so a corrected
* value sat under the reason it was refused until the person pressed
* save again — the onboarding baseline step drops the sentence while
* they type (`patch` in `baseline-form.tsx`) and this form did not.
*
* Two things have to hold, and both are checked here because either one
* alone passes on the defect: the state rule (the edited field's
* sentence goes, its siblings stay), and the wiring (every slot the form
* renders actually names itself when its input changes). A helper that
* nothing calls is exactly the shape this guard exists to catch.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { PROFILE_FIELD_LABEL_KEYS } from "@/lib/profile/rejected-fields";
import { clearRejectedField } from "../account-section-utils";
const SOURCE = readFileSync(resolve(__dirname, "../index.tsx"), "utf8");
/** The slots the form renders a refusal sentence into. */
function renderedSlots(): string[] {
return [
...new Set(
[...SOURCE.matchAll(/message=\{fieldErrors\.(\w+)\}/g)].map((m) => 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(),
);
});
});
@@ -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<string, string>,
slot: string,
): Record<string, string> {
if (!(slot in errors)) return errors;
const next = { ...errors };
delete next[slot];
return next;
}
@@ -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<Record<string, string>>({});
// 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}
/>
<FieldError id="email-error" message={fieldErrors.email} />
</div>
</div>
@@ -351,7 +377,14 @@ export function AccountSection() {
<NativeSelect
id="gender"
value={gender}
onChange={(e) => 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
}
>
<option value="">{t("settings.genderNone")}</option>
<option value="MALE">{t("settings.genderMale")}</option>
@@ -361,6 +394,7 @@ export function AccountSection() {
<p className="text-muted-foreground text-xs">
{t("settings.genderHint")}
</p>
<FieldError id="gender-error" message={fieldErrors.gender} />
</div>
<div className="space-y-2">
<Label htmlFor="height">
@@ -372,9 +406,15 @@ export function AccountSection() {
idPrefix="height"
adapter={heightAdapter}
value={height}
onChange={setHeight}
onChange={(next) => {
setHeight(next);
clearFieldError("heightCm");
}}
enterKeyHint="next"
invalid={Boolean(fieldErrors.heightCm)}
describedBy={fieldErrors.heightCm ? "height-error" : undefined}
/>
<FieldError id="height-error" message={fieldErrors.heightCm} />
</div>
</div>
@@ -390,12 +430,20 @@ export function AccountSection() {
<DateField
id="dob"
value={dateOfBirth}
onChange={setDateOfBirth}
onChange={(next) => {
setDateOfBirth(next);
clearFieldError("dateOfBirth");
}}
max={new Date().toISOString().slice(0, 10)}
aria-invalid={fieldErrors.dateOfBirth ? true : undefined}
aria-describedby={
fieldErrors.dateOfBirth ? "dob-error" : undefined
}
/>
<p className="text-muted-foreground text-xs">
{t("settings.dateOfBirthHint")}
</p>
<FieldError id="dob-error" message={fieldErrors.dateOfBirth} />
</div>
<div className="space-y-2">
<Label htmlFor="language-select">{t("settings.language")}</Label>
@@ -451,11 +499,19 @@ export function AccountSection() {
<Input
id="full-name"
value={fullName}
onChange={(e) => 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
}
/>
<FieldError id="full-name-error" message={fieldErrors.fullName} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
@@ -465,9 +521,20 @@ export function AccountSection() {
<Input
id="insurer"
value={insurerName}
onChange={(e) => 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
}
/>
<FieldError
id="insurer-error"
message={fieldErrors.insurerName}
/>
</div>
<div className="space-y-2">
@@ -477,17 +544,28 @@ export function AccountSection() {
<Input
id="insurance-number"
value={insuranceNumber}
onChange={(e) =>
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
}
/>
<p className="text-muted-foreground text-xs">
{t("settings.identity.insuranceNumberHint")}
</p>
<FieldError
id="insurance-number-error"
message={fieldErrors.insuranceNumber}
/>
</div>
</div>
</div>
+7
View File
@@ -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>
);
}
+50
View File
@@ -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<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.
*
* 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, 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;
}