mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
1 parent
4c858e7600
commit
eea0f2a532
3 files changed
+144
-10
No files matched your search
+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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user