fix(dashboard): catch mood-radio and open-Select changes in the quick-entry dirty-check

The confirm-on-dismiss guard walked only `input` / `textarea`, so a user who
changed just the mood `role=radio` selector or was mid-pick in a Radix Select
(measurement type / medication) lost the change on a backdrop dismiss with no
confirm. Treat any `[role=radio][aria-checked=true]` (mood starts unselected,
so a checked radio is a deliberate choice) and any open Select trigger
(`[role=combobox][data-state=open]`) as unsaved input. Keep the date/time-picker
exclusion, and do not flag a closed Select on its default value so a pristine
sheet still dismisses without the prompt.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 18:38:14 +02:00
1 parent f3ac9942ee
commit 60462b0f12
2 files changed
+165 -1

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
/**
* v1.11.5 — `sheetBodyHasUnsavedInput` dirty-check coverage.
*
* The quick-entry sheets confirm-on-dismiss only when the body holds unsaved
* input. The original walk read `input` / `textarea` only, so a user who
* changed just the mood `role="radio"` selector or opened a Radix Select
* (type / medication) could lose the change on a backdrop dismiss with no
* confirm. These cases lock the extended detection in — and pin the
* over-trigger guard so a pristine sheet (no radio chosen, every Select on its
* default and closed) still reads clean.
*
* The repo runs vitest in the Node environment (no jsdom / happy-dom). The
* function only ever touches `document.querySelector` once (the sheet body) and
* then `body.querySelectorAll` / `body.querySelector` against three fixed
* selectors, so we drive it with a hand-rolled fake `document` that answers
* exactly those selectors — no DOM engine, matching the repo's node-only test
* convention (cf. the SSR-string tests elsewhere).
*/
import { afterEach, describe, expect, it } from "vitest";
import { sheetBodyHasUnsavedInput } from "../page";
const BODY_SELECTOR = '[data-slot="responsive-sheet-body"]';
const MOOD_RADIO_SELECTOR = '[role="radio"][aria-checked="true"]';
const OPEN_SELECT_SELECTOR = '[role="combobox"][data-state="open"]';
interface FakeField {
type: string;
value?: string;
disabled?: boolean;
checked?: boolean;
defaultChecked?: boolean;
}
interface SheetFixture {
fields?: FakeField[];
moodRadioSelected?: boolean;
openSelect?: boolean;
}
/** Install a fake `document` whose queries answer the given fixture. */
function installFakeDocument(fixture: SheetFixture | null): void {
const body = fixture
? {
querySelectorAll: (sel: string) =>
sel === "input, textarea" ? (fixture.fields ?? []) : [],
querySelector: (sel: string) => {
if (sel === MOOD_RADIO_SELECTOR)
return fixture.moodRadioSelected ? {} : null;
if (sel === OPEN_SELECT_SELECTOR)
return fixture.openSelect ? {} : null;
return null;
},
}
: null;
(globalThis as { document?: unknown }).document = {
querySelector: (sel: string) => (sel === BODY_SELECTOR ? body : null),
};
}
afterEach(() => {
delete (globalThis as { document?: unknown }).document;
});
describe("sheetBodyHasUnsavedInput — mood radio", () => {
it("is clean when no mood radio is selected (pristine)", () => {
installFakeDocument({ moodRadioSelected: false });
expect(sheetBodyHasUnsavedInput()).toBe(false);
});
it("is dirty when a mood radio is selected", () => {
installFakeDocument({ moodRadioSelected: true });
expect(sheetBodyHasUnsavedInput()).toBe(true);
});
});
describe("sheetBodyHasUnsavedInput — Radix Select", () => {
it("is clean for a closed Select on its default value (no over-trigger)", () => {
// A measurement-type / medication Select mounts with a non-placeholder
// DEFAULT and is closed — a pristine sheet must NOT prompt the confirm.
installFakeDocument({ openSelect: false });
expect(sheetBodyHasUnsavedInput()).toBe(false);
});
it("is dirty while a Select dropdown is open (mid-interaction)", () => {
installFakeDocument({ openSelect: true });
expect(sheetBodyHasUnsavedInput()).toBe(true);
});
});
describe("sheetBodyHasUnsavedInput — text fields still honoured", () => {
it("is clean for an empty pristine sheet", () => {
installFakeDocument({
fields: [
{ type: "text", value: "" },
{ type: "textarea", value: "" },
{ type: "datetime-local", value: "2026-06-04T08:00" },
],
});
expect(sheetBodyHasUnsavedInput()).toBe(false);
});
it("is dirty when a text input carries a value", () => {
installFakeDocument({ fields: [{ type: "text", value: "75.5" }] });
expect(sheetBodyHasUnsavedInput()).toBe(true);
});
it("ignores a prefilled date/time picker (v1.11.4 H1)", () => {
installFakeDocument({
fields: [{ type: "datetime-local", value: "2026-06-04T08:00" }],
});
expect(sheetBodyHasUnsavedInput()).toBe(false);
});
it("ignores a disabled field carrying a value", () => {
installFakeDocument({
fields: [{ type: "text", value: "ignored", disabled: true }],
});
expect(sheetBodyHasUnsavedInput()).toBe(false);
});
it("is dirty for a native checkbox toggled off its default", () => {
installFakeDocument({
fields: [{ type: "checkbox", checked: true, defaultChecked: false }],
});
expect(sheetBodyHasUnsavedInput()).toBe(true);
});
});
describe("sheetBodyHasUnsavedInput — no sheet body", () => {
it("is clean when no sheet body is mounted", () => {
installFakeDocument(null);
expect(sheetBodyHasUnsavedInput()).toBe(false);
});
it("is clean when document is undefined (SSR)", () => {
delete (globalThis as { document?: unknown }).document;
expect(sheetBodyHasUnsavedInput()).toBe(false);
});
});
+25 -1
View File
@@ -88,7 +88,7 @@ const PREFILLED_PICKER_TYPES = new Set([
"month",
"week",
]);
function sheetBodyHasUnsavedInput(): boolean {
export function sheetBodyHasUnsavedInput(): boolean {
if (typeof document === "undefined") return false;
const body = document.querySelector<HTMLElement>(
'[data-slot="responsive-sheet-body"]',
@@ -109,6 +109,30 @@ function sheetBodyHasUnsavedInput(): boolean {
}
if (field.value.trim() !== "") return true;
}
// v1.11.5 — the input/textarea walk misses two non-text controls a user can
// change before dismissing: the mood `role="radio"` selector and a Radix
// Select (measurement type / medication). Losing either silently on a
// backdrop-dismiss is the same data-loss class the H1 confirm-on-dirty guard
// was meant to close.
//
// Mood radios start with nothing selected (`mood = ""`), so ANY
// `aria-checked="true"` is a deliberate user choice — flag it.
if (
body.querySelector('[role="radio"][aria-checked="true"]') !== null
) {
return true;
}
// Radix Select trigger renders `role="combobox"`. A trigger with
// `data-state="open"` is mid-interaction (the dropdown is up) — the user is
// actively choosing, so a dismiss should confirm rather than drop the pick.
// We deliberately do NOT treat a merely non-placeholder value as dirty: the
// type / medication selects mount with a non-placeholder DEFAULT, and
// flagging that would over-trigger the confirm on a pristine sheet.
if (
body.querySelector('[role="combobox"][data-state="open"]') !== null
) {
return true;
}
return false;
}