mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge the per-field profile rejection surfaces
This commit is contained in:
commit
6338da8cf6
21 files changed
+935
-137
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -5355,6 +5355,14 @@
|
||||
"profileSaved": "프로필을 저장했어요",
|
||||
"profilePartiallySaved": "프로필을 저장했지만 {field} 항목은 업데이트하지 못했어요.",
|
||||
"profileNothingSaved": "아무것도 저장하지 못했어요. {field} 항목을 고친 뒤 다시 시도해 주세요.",
|
||||
"profileRejection": {
|
||||
"tooBig": "이 항목이 받는 범위보다 큰 값이에요.",
|
||||
"tooSmall": "이 항목이 받는 범위보다 작은 값이에요.",
|
||||
"invalidValue": "이 항목이 받는 답변이 아니에요.",
|
||||
"invalidFormat": "이 항목이 읽을 수 있는 형식이 아니에요.",
|
||||
"invalidType": "이 항목이 저장할 수 있는 형식으로 오지 않았어요.",
|
||||
"other": "이 값은 저장하지 못했어요. 확인한 뒤 다시 시도해 주세요."
|
||||
},
|
||||
"avatar": {
|
||||
"title": "프로필 사진",
|
||||
"description": "직접 운영하는 서버에 저장되는 사진을 올려요. 외부 아바타 서비스 대신 앱 전체에 표시돼요.",
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+83
@@ -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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { FieldError } from "@/components/forms/field-error";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -10,6 +11,9 @@ import { cn } from "@/lib/utils";
|
||||
* is a no-op on rendered output.
|
||||
*
|
||||
* - `hint` renders below the control as muted fine print.
|
||||
* - `error` renders below the hint as the field's own error slot, with
|
||||
* the id `<htmlFor>-error` so the control can point
|
||||
* `aria-describedby` at it.
|
||||
* - `labelAccessory` sits right-aligned in the label row (e.g. a char counter);
|
||||
* when present the label row becomes a `justify-between` flex row.
|
||||
*/
|
||||
@@ -17,6 +21,7 @@ export function FieldGroup({
|
||||
htmlFor,
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
labelAccessory,
|
||||
className,
|
||||
labelClassName,
|
||||
@@ -25,6 +30,7 @@ export function FieldGroup({
|
||||
htmlFor: string;
|
||||
label: ReactNode;
|
||||
hint?: ReactNode;
|
||||
error?: ReactNode;
|
||||
labelAccessory?: ReactNode;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
@@ -46,6 +52,7 @@ export function FieldGroup({
|
||||
)}
|
||||
{children}
|
||||
{hint ? <p className="text-muted-foreground text-xs">{hint}</p> : null}
|
||||
<FieldError id={`${htmlFor}-error`} message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -60,3 +60,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;
|
||||
}
|
||||
Reference in new issue
Block a user