From eea0f2a53209776dad5254210513d25e3af3726f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 00:42:45 +0200 Subject: [PATCH] Clear a refused profile field as it is edited The settings profile form painted the server's refusal under the input and left it there until the next save, so a value the person had already corrected still read as refused. The onboarding baseline step drops that sentence while they type; the account card now does the same. Only the edited field's slot goes: the other fields the same save refused were not touched, so their sentences still stand. The guard derives both sets from the form itself, so a new rejection slot without a clear fails. Review finding L3. --- ...count-section-field-error-clearing.test.ts | 83 +++++++++++++++++++ .../account-section/account-section-utils.ts | 22 +++++ .../settings/account-section/index.tsx | 49 ++++++++--- 3 files changed, 144 insertions(+), 10 deletions(-) create mode 100644 src/components/settings/account-section/__tests__/account-section-field-error-clearing.test.ts diff --git a/src/components/settings/account-section/__tests__/account-section-field-error-clearing.test.ts b/src/components/settings/account-section/__tests__/account-section-field-error-clearing.test.ts new file mode 100644 index 000000000..9b48f313f --- /dev/null +++ b/src/components/settings/account-section/__tests__/account-section-field-error-clearing.test.ts @@ -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(), + ); + }); +}); diff --git a/src/components/settings/account-section/account-section-utils.ts b/src/components/settings/account-section/account-section-utils.ts index d37ab9683..ba90a1ed5 100644 --- a/src/components/settings/account-section/account-section-utils.ts +++ b/src/components/settings/account-section/account-section-utils.ts @@ -67,3 +67,25 @@ export function statusText( * uses the default label map, which points at the labels these inputs * already carry. */ + +/** + * Drop one field's refusal as the person edits that field. + * + * A refusal answers a value. Once the value changes it answers nothing, + * so it goes instead of sitting under an input that has already been + * corrected — the same rule the onboarding baseline step applies in its + * `patch` helper. Only the edited slot is dropped: the other fields the + * same save refused were not touched, so their sentences still stand. + * + * The map is returned unchanged when the slot holds nothing, so typing + * in a field that was never refused does not re-render the form. + */ +export function clearRejectedField( + errors: Record, + slot: string, +): Record { + if (!(slot in errors)) return errors; + const next = { ...errors }; + delete next[slot]; + return next; +} diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index cd3370dd2..21335c2b7 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -42,6 +42,7 @@ import { } from "@/lib/profile/rejected-fields"; import { FieldError } from "@/components/forms/field-error"; import { + clearRejectedField, resolveInitialTimezone, statusText, type StatusMessage, @@ -95,8 +96,17 @@ export function AccountSection() { // 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. + // input holding it. Cleared at the start of every save, and per field + // as that field is edited. const [fieldErrors, setFieldErrors] = useState>({}); + // The person is answering the objection; the objection goes away + // while they type rather than sitting under a value they already + // corrected. The slot is the schema name the server sent, which is + // what the sentence under the input is keyed by — the height boxes + // hold a `height` draft but name the `heightCm` slot. + function clearFieldError(slot: string) { + setFieldErrors((prev) => clearRejectedField(prev, slot)); + } // v1.23 — passkey + second-factor management moved to the dedicated // /settings/security hub so "how I secure my account" reads as one place. @@ -346,7 +356,10 @@ 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" @@ -364,7 +377,10 @@ export function AccountSection() { setGender(e.target.value)} + onChange={(e) => { + setGender(e.target.value); + clearFieldError("gender"); + }} aria-invalid={fieldErrors.gender ? true : undefined} aria-describedby={ fieldErrors.gender ? "gender-error" : undefined @@ -390,7 +406,10 @@ 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} @@ -411,7 +430,10 @@ export function AccountSection() { { + setDateOfBirth(next); + clearFieldError("dateOfBirth"); + }} max={new Date().toISOString().slice(0, 10)} aria-invalid={fieldErrors.dateOfBirth ? true : undefined} aria-describedby={ @@ -477,7 +499,10 @@ export function AccountSection() { setFullName(e.target.value)} + onChange={(e) => { + setFullName(e.target.value); + clearFieldError("fullName"); + }} placeholder={t("settings.identity.fullNamePlaceholder")} maxLength={120} autoComplete="name" @@ -496,7 +521,10 @@ export function AccountSection() { setInsurerName(e.target.value)} + onChange={(e) => { + setInsurerName(e.target.value); + clearFieldError("insurerName"); + }} placeholder={t("settings.identity.insurerPlaceholder")} maxLength={120} aria-invalid={fieldErrors.insurerName ? true : undefined} @@ -516,9 +544,10 @@ export function AccountSection() { - setInsuranceNumber(e.target.value.toUpperCase()) - } + onChange={(e) => { + setInsuranceNumber(e.target.value.toUpperCase()); + clearFieldError("insuranceNumber"); + }} placeholder="A123456780" maxLength={10} autoCapitalize="characters"