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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-12 15:09:29 +01:00
1 parent 218986ea89
commit cb569b6c9d
5 files changed
+101 -6

No files matched your search

@@ -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,
@@ -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={
@@ -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 `<form>`, 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(
<div data-testid="body">
<SymptomEntryForm />
</div>,
);
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(
<div data-testid="body">
<MedicationIntakeQuickAdd />
</div>,
{ 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);
});
});
@@ -28,6 +28,7 @@ function fakeBody(fixture: {
const fields = (fixture.fields ?? []).map((field) => ({
...field,
value: field.value ?? "",
dataset: {},
closest: () => (field.inDateOrTimeField ? {} : null),
}));
return {
+17 -5
View File
@@ -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) {