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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 00:42:45 +02:00
1 parent 4c858e7600
commit eea0f2a532
3 files changed
+144 -10

No files matched your search

@@ -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;
}
@@ -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<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.
@@ -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() {
<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
@@ -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() {
<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={
@@ -477,7 +499,10 @@ 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"
@@ -496,7 +521,10 @@ 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}
@@ -516,9 +544,10 @@ 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"