diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a4bc8c9..3dc9b4ba1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## [Unreleased] +### Fixed + +- **Typing into the smoking history.** "When you stopped" only took text + that was already a whole YYYY-MM month, so every keystroke was dropped + and only a paste got in. The start year clamped each keystroke into its + range, so the first digit turned into 1900. Both fields now keep what is + typed and commit it once it is a valid value, and leaving the month field + pads "2019-3" to "2019-03". (#1334) + +### Changed + +- Dependabot no longer proposes a new Node major for the image. The major + is tied to `engines`, `.nvmrc` and `.node-version` and moves by hand. + ## [1.47.0] — 2026-11-04 ### Added diff --git a/e2e/life-circumstances-typing.spec.ts b/e2e/life-circumstances-typing.spec.ts new file mode 100644 index 000000000..f0fc05069 --- /dev/null +++ b/e2e/life-circumstances-typing.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from "./setup/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * The tobacco section's typed fields take what a person types one key at a + * time. Through v1.47.0 neither did: "When you stopped" only accepted text + * that was already a whole YYYY-MM month, so every keystroke was thrown away + * and only a paste got in, and the start year clamped each keystroke into + * 1900..this year, so the first digit became 1900. + * + * The test types and leaves the fields without saving, so the shared + * account keeps whatever answers it had. + */ +test.describe("life circumstances typed fields", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("start year and quit month take keystrokes", async ({ page }) => { + await page.goto("/settings/anamnesis", { waitUntil: "domcontentloaded" }); + const section = page.locator('[data-slot="life-section-tobacco"]'); + await section.locator("summary").click(); + + await section.locator("#life-tobacco-status").selectOption("FORMER"); + + const start = section.locator("#life-tobacco-start"); + await start.fill(""); + await start.pressSequentially("1985"); + await expect(start).toHaveValue("1985"); + await start.blur(); + await expect(start).toHaveValue("1985"); + + const quit = section.locator("#life-tobacco-quit"); + await quit.fill(""); + await quit.pressSequentially("2019-03"); + await expect(quit).toHaveValue("2019-03"); + await quit.blur(); + await expect(quit).toHaveValue("2019-03"); + + // A single-digit month is padded when the field is left. + await quit.fill(""); + await quit.pressSequentially("2018-7"); + await expect(quit).toHaveValue("2018-7"); + await quit.blur(); + await expect(quit).toHaveValue("2018-07"); + }); +}); diff --git a/src/components/records/life-circumstances-manager.tsx b/src/components/records/life-circumstances-manager.tsx index 56db109be..e8a4ced02 100644 --- a/src/components/records/life-circumstances-manager.tsx +++ b/src/components/records/life-circumstances-manager.tsx @@ -281,6 +281,11 @@ function NumberField({ onChange: (value: number | null) => void; disabled: boolean; }) { + // What the person is typing. A year passes through "1", "19" and "198" on + // its way to "1985", so clamping each keystroke turned the first digit into + // the minimum and the field could not be typed into. A value is committed + // once it is in range, and leaving the field puts the committed one back. + const [draft, setDraft] = useState(null); return ( { const raw = event.target.value; + setDraft(raw); if (raw === "") return onChange(null); const parsed = Number(raw); - if (Number.isFinite(parsed)) { - onChange(Math.min(max, Math.max(min, parsed))); + if (Number.isFinite(parsed) && parsed >= min && parsed <= max) { + onChange(parsed); } }} + onBlur={() => setDraft(null)} /> ); } +/** + * A month typed as YYYY-MM. Only a complete month is committed, so the field + * keeps what is being typed on its own: holding it to the committed value + * threw away every keystroke until the text was a whole month, and only a + * paste got through. Leaving the field pads "2019-3" to "2019-03" and puts + * the committed month back over anything that is not one. + */ +function QuitMonthInput({ + id, + value, + onChange, + disabled, +}: { + id: string; + value: string | null; + onChange: (value: string | null) => void; + disabled: boolean; +}) { + const [draft, setDraft] = useState(null); + return ( + { + const raw = event.target.value; + setDraft(raw); + const month = completeMonth(raw); + if (month !== undefined) onChange(month); + }} + onBlur={() => { + if (draft !== null) { + const short = /^(\d{4})-([1-9])$/.exec(draft.trim()); + if (short) onChange(`${short[1]}-0${short[2]}`); + } + setDraft(null); + }} + /> + ); +} + +/** A whole month, null for an emptied field, undefined while still typing. */ +function completeMonth(raw: string): string | null | undefined { + const text = raw.trim(); + if (text === "") return null; + return /^\d{4}-(0[1-9]|1[0-2])$/.test(text) ? text : undefined; +} + function MultiField({ id, label, @@ -544,23 +602,13 @@ function SectionBody({ id={id("quit")} label={t("records.life.fields.quitMonth")} > - { - const raw = event.target.value.trim(); - patch("TOBACCO_DETAIL", { - quitMonth: /^\d{4}-(0[1-9]|1[0-2])$/.test(raw) - ? raw - : raw === "" - ? null - : v.quitMonth, - }); - }} + onChange={(quitMonth) => + patch("TOBACCO_DETAIL", { quitMonth }) + } /> ) : null}