mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user