fix(records): let the smoking history fields take keystrokes

The quit month only accepted text that was already a whole YYYY-MM
month and the start year clamped every keystroke into 1900..now, so
neither could be typed into. Both keep a draft while typing and commit a
valid value. Dependabot also stops proposing Node majors for the image.

Refs #1334
This commit is contained in:
Marc-André Bombeck committed 2026-11-04 16:54:14 +01:00
1 parent 25770912ce
commit 40200b55a7
3 files changed
+126 -18

No files matched your search

+14
View File
@@ -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
+46
View File
@@ -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");
});
});
@@ -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<string | null>(null);
return (
<Field id={id} label={label}>
<Input
@@ -290,21 +295,74 @@ function NumberField({
min={min}
max={max}
step={step}
value={value ?? ""}
value={draft ?? (value === null ? "" : String(value))}
disabled={disabled}
onChange={(event) => {
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)}
/>
</Field>
);
}
/**
* 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<string | null>(null);
return (
<Input
id={id}
placeholder="YYYY-MM"
inputMode="numeric"
maxLength={7}
value={draft ?? value ?? ""}
disabled={disabled}
onChange={(event) => {
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<T extends string>({
id,
label,
@@ -544,23 +602,13 @@ function SectionBody({
id={id("quit")}
label={t("records.life.fields.quitMonth")}
>
<Input
<QuitMonthInput
id={id("quit")}
placeholder="YYYY-MM"
inputMode="numeric"
maxLength={7}
value={v.quitMonth ?? ""}
value={v.quitMonth}
disabled={disabled}
onChange={(event) => {
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 })
}
/>
</Field>
) : null}