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) {