From cb569b6c9d61c80d819257d42223b148a0754c87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 15:09:29 +0100 Subject: [PATCH] fix(sheets): stop two capture forms asking before anything was entered The symptom form and the dashboard's medication intake asked "Discard?" on every close, untouched. The sheet's fallback check read the input Radix keeps for the intensity slider (value "0" before anyone moves it) and the dose the form fills in from the preselected medication as typed input. The check now skips a slider's own input and counts a slider moved off unanswered instead, and compares a field against the value its form says it filled in (`data-seed`). A controlled input's defaultValue follows its value, so it cannot tell the two apart. The dose field names the medication's dose, one line more for the form's baseline. --- .../baselines/source-function-lines.json | 2 +- .../dashboard/medication-intake-quick-add.tsx | 1 + .../sheet-dirty-check.browser.test.tsx | 81 +++++++++++++++++++ .../ui/__tests__/sheet-dirty-check.test.ts | 1 + src/components/ui/responsive-sheet.tsx | 22 +++-- 5 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 src/components/ui/__tests__/sheet-dirty-check.browser.test.tsx diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 595676ba3..1add8f02f 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -84,7 +84,7 @@ "components/cycle/log-day-sheet.tsx :: LogDaySheet": 760, "components/daily/today-hero.tsx :: TodayHero": 488, "components/dashboard/dashboard-header.tsx :: DashboardHeader": 215, - "components/dashboard/medication-intake-quick-add.tsx :: MedicationIntakeQuickAdd": 383, + "components/dashboard/medication-intake-quick-add.tsx :: MedicationIntakeQuickAdd": 384, "components/day/day-layer.tsx :: DayLayer": 358, "components/day/day-view.tsx :: DayView": 309, "components/documents/document-ai-section.tsx :: DocumentAiSection": 262, diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx index 0c662b618..1b57d4fdf 100644 --- a/src/components/dashboard/medication-intake-quick-add.tsx +++ b/src/components/dashboard/medication-intake-quick-add.tsx @@ -451,6 +451,7 @@ export function MedicationIntakeQuickAdd({ id="medication-intake-dose" data-testid="medication-intake-quick-add-dose" value={dose} + data-seed={selectedMedication?.dose ?? ""} onChange={(e) => setDoseOverride(e.target.value)} maxLength={50} placeholder={ diff --git a/src/components/ui/__tests__/sheet-dirty-check.browser.test.tsx b/src/components/ui/__tests__/sheet-dirty-check.browser.test.tsx new file mode 100644 index 000000000..e786b6756 --- /dev/null +++ b/src/components/ui/__tests__/sheet-dirty-check.browser.test.tsx @@ -0,0 +1,81 @@ +/** + * The sheet's fallback dirty check on two real capture forms, rendered in + * Chromium: untouched, neither may ask "Discard?" when the sheet closes, and + * each reads as edited once something is changed. + * + * - The symptom form's intensity slider sits in a `
`, so Radix adds + * an input of its own for it, which carries a value before anyone moves + * the slider. + * - The medication dose starts as the preselected medication's own dose. + * + * All data invented. + */ +import { describe, expect, it } from "vitest"; +import { userEvent } from "vitest/browser"; + +import { SymptomEntryForm } from "@/components/symptoms/symptom-entry-form"; +import { MedicationIntakeQuickAdd } from "@/components/dashboard/medication-intake-quick-add"; +import { queryKeys } from "@/lib/query-keys"; + +import { stubFetch } from "../../../../tests/browser/fetch-stub"; +import { + renderWithApp, + testQueryClient, +} from "../../../../tests/browser/render"; +import { sheetBodyHasUnsavedInput } from "../responsive-sheet"; + +const bodyOf = (element: Element) => element as HTMLElement; + +describe("the sheet's dirty check on capture forms", () => { + it("reads an untouched symptom form as clean, a moved slider as input", async () => { + stubFetch().on("GET", /\/api\/symptoms\/definitions/, { + data: { definitions: [] }, + }); + const { screen } = await renderWithApp( +
+ +
, + ); + const slider = screen.getByRole("slider"); + await expect.element(slider).toBeInTheDocument(); + const body = bodyOf(screen.getByTestId("body").element()); + + expect(sheetBodyHasUnsavedInput(body)).toBe(false); + + await userEvent.click(slider); + await userEvent.keyboard("{ArrowRight}"); + expect(sheetBodyHasUnsavedInput(body)).toBe(true); + }); + + it("reads the preselected medication's dose as clean, an edited dose as input", async () => { + const queryClient = testQueryClient(); + queryClient.setQueryData(queryKeys.medications(), [ + { + id: "med-1", + name: "Example tablet", + dose: "500 mg", + active: true, + intakeActionable: true, + schedules: [], + lastTakenAt: null, + }, + ]); + stubFetch().on("GET", "/api/settings/reminder-thresholds", { + data: { lateMinutes: 60, missedMinutes: 240, lowStockRunwayDays: null }, + }); + const { screen } = await renderWithApp( +
+ +
, + { queryClient }, + ); + const dose = screen.getByTestId("medication-intake-quick-add-dose"); + await expect.element(dose).toHaveValue("500 mg"); + const body = bodyOf(screen.getByTestId("body").element()); + + expect(sheetBodyHasUnsavedInput(body)).toBe(false); + + await dose.fill("250 mg"); + expect(sheetBodyHasUnsavedInput(body)).toBe(true); + }); +}); diff --git a/src/components/ui/__tests__/sheet-dirty-check.test.ts b/src/components/ui/__tests__/sheet-dirty-check.test.ts index 80868167b..ffaeeff63 100644 --- a/src/components/ui/__tests__/sheet-dirty-check.test.ts +++ b/src/components/ui/__tests__/sheet-dirty-check.test.ts @@ -28,6 +28,7 @@ function fakeBody(fixture: { const fields = (fixture.fields ?? []).map((field) => ({ ...field, value: field.value ?? "", + dataset: {}, closest: () => (field.inDateOrTimeField ? {} : null), })); return { diff --git a/src/components/ui/responsive-sheet.tsx b/src/components/ui/responsive-sheet.tsx index ac634acf6..2733ae7a6 100644 --- a/src/components/ui/responsive-sheet.tsx +++ b/src/components/ui/responsive-sheet.tsx @@ -169,9 +169,12 @@ export function keepKeyboardDownOnOpen(event: Event): void { /** * The fallback dirty check for an add form that keeps its state inside the * sheet body: any text field with a value, a checkbox or radio moved off its - * default, a chosen `role="radio"` (the mood faces) or a Select that is open. - * The date and time fields are skipped, because the forms fill them with - * "now". An edit form starts filled and has to say for itself whether + * default, a slider moved off unanswered, a chosen `role="radio"` (the mood + * faces) or a Select that is open. The date and time fields are skipped, + * because the forms fill them with "now", and so is the input a slider keeps + * for its form. A field the form fills itself names that value in + * `data-seed` (a controlled input's `defaultValue` follows its value, so it + * cannot say). An edit form starts filled and has to say for itself whether * anything changed. */ export function sheetBodyHasUnsavedInput(body: HTMLElement | null): boolean { @@ -181,7 +184,11 @@ export function sheetBodyHasUnsavedInput(body: HTMLElement | null): boolean { ); for (const field of fields) { if (field.disabled || field.type === "hidden") continue; - if (field.closest('[data-slot="date-field"], [data-slot="time-field"]')) { + if ( + field.closest( + '[data-slot="date-field"], [data-slot="time-field"], [data-slot="slider"]', + ) + ) { continue; } if (field.type === "checkbox" || field.type === "radio") { @@ -189,7 +196,12 @@ export function sheetBodyHasUnsavedInput(body: HTMLElement | null): boolean { if (box.checked !== box.defaultChecked) return true; continue; } - if (field.value.trim() !== "") return true; + if (field.value.trim() !== (field.dataset.seed ?? "").trim()) return true; + } + if ( + body.querySelector('[data-slot="slider-field"]:not([data-unset])') !== null + ) { + return true; } // Mood faces start with none chosen, so any chosen one is input. if (body.querySelector('[role="radio"][aria-checked="true"]') !== null) {