From d38ca7737e78a5c66115ed242a7ec07691ace1ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 12:24:47 +0200 Subject: [PATCH] fix(measurements): offer only measurement types whose module is on The measurement form, and with it the add menu's Measurement entry, listed sleep and glucose with those modules switched off. It now filters its types through the surface map's type ownership, which a test holds equal to the ownership the server gates measurement types on, and a default or last-used type that is switched off falls back to the first offered one. --- .../measurement-form-module-types.test.tsx | 93 +++++++++++++++++++ .../measurements/measurement-form.tsx | 25 ++++- 2 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 src/components/measurements/__tests__/measurement-form-module-types.test.tsx diff --git a/src/components/measurements/__tests__/measurement-form-module-types.test.tsx b/src/components/measurements/__tests__/measurement-form-module-types.test.tsx new file mode 100644 index 000000000..98a2ec7c9 --- /dev/null +++ b/src/components/measurements/__tests__/measurement-form-module-types.test.tsx @@ -0,0 +1,93 @@ +/** + * The measurement form offers only types whose module is on. + * + * The add menu's "Measurement" entry (and every other mount of the form) + * listed sleep and glucose with those modules switched off, so a person could + * file a reading into a domain whose pages, charts and reads are all hidden, + * or be refused by the route. The form now filters its types through the + * surface map's type ownership (`summary:`), which the test below holds + * equal to the measurement-scope ownership the server gates on. + */ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import type { MeasurementType } from "@/generated/prisma/client"; +import { moduleForMeasurementType } from "@/lib/modules/measurement-scope"; +import { surfaceModule } from "@/lib/modules/surface"; + +const auth = vi.hoisted(() => ({ + modules: undefined as Record | undefined, +})); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { + id: "u1", + role: "USER", + unitPreference: "metric", + glucoseUnit: null, + modules: auth.modules, + }, + isAuthenticated: true, + isLoading: false, + }), +})); +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({ + invalidateQueries: vi.fn(), + refetchQueries: vi.fn(), + }), +})); +vi.mock("@/lib/api/api-fetch", () => ({ + apiPost: vi.fn().mockResolvedValue({}), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { + MEASUREMENT_FORM_TYPE_VALUES, + MeasurementForm, + measurementFormTypes, +} from "../measurement-form"; + +describe("measurementFormTypes", () => { + it("drops sleep and glucose while their modules are off", () => { + const values = measurementFormTypes({ sleep: false, glucose: false }).map( + (t) => t.value, + ); + expect(values).not.toContain("SLEEP_DURATION"); + expect(values).not.toContain("BLOOD_GLUCOSE"); + expect(values).toContain("WEIGHT"); + }); + + it("offers every type while the module map is unknown or all on", () => { + expect(measurementFormTypes(undefined)).toHaveLength( + MEASUREMENT_FORM_TYPE_VALUES.length, + ); + expect(measurementFormTypes({ sleep: true, glucose: true })).toHaveLength( + MEASUREMENT_FORM_TYPE_VALUES.length, + ); + }); + + it("reads the same ownership the server gates measurement types on", () => { + let owned = 0; + for (const type of MEASUREMENT_FORM_TYPE_VALUES) { + const owner = moduleForMeasurementType(type as MeasurementType); + if (owner) owned += 1; + expect(surfaceModule(`summary:${type}`), type).toBe(owner ?? undefined); + } + expect(owned).toBeGreaterThan(0); + }); +}); + +describe(" with a type's module off", () => { + it("does not open on that type, even when asked to", () => { + auth.modules = { glucose: false }; + const html = renderToStaticMarkup( + + + , + ); + auth.modules = undefined; + expect(html).not.toContain("mg/dL"); + expect(html).toContain("mmHg"); + }); +}); diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index 00edb1d9a..092bc2e7a 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -28,6 +28,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { useAuth } from "@/hooks/use-auth"; import { resolveGlucoseUnit, toCanonicalMgdl } from "@/lib/glucose"; +import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface"; import { entryValueToCanonical, parseDecimalEntry, @@ -195,6 +196,20 @@ export const MEASUREMENT_FORM_TYPE_VALUES = MEASUREMENT_TYPES.map( (t) => t.value, ) as readonly string[]; +/** + * The types the form offers for a module map: a type a module owns + * (`summary:` in the surface map, the same ownership the server gates + * measurement types on) is left out while that module is off. An unknown map + * (the account still loading) offers every type. + */ +export function measurementFormTypes( + modules: SurfaceModuleMap | null | undefined, +): ReadonlyArray<(typeof MEASUREMENT_TYPES)[number]> { + return MEASUREMENT_TYPES.filter((mt) => + isSurfaceVisible(`summary:${mt.value}`, modules), + ); +} + // Legacy / Insights-internal tokens that predate the canonical enum. // Older empty-state CTAs and a handful of dashboard tiles still emit // these — translate them to the form's canonical value so the link @@ -307,12 +322,18 @@ export function MeasurementForm({ // seed from the last type the user actually saved rather than always // landing on BLOOD_PRESSURE. Lazy initializer so the localStorage read // happens once, at mount, not on every render. - const [type, setType] = useState( + const [selectedType, setType] = useState( () => normalizedDefault || getLastUsedMeasurementType(MEASUREMENT_FORM_TYPE_VALUES) || "BLOOD_PRESSURE", ); + // Only types whose module is on. A default, a deep link or the last-used + // type that is switched off falls back to the first offered type. + const offeredTypes = measurementFormTypes(user?.modules); + const type = offeredTypes.some((mt) => mt.value === selectedType) + ? selectedType + : (offeredTypes[0]?.value ?? "BLOOD_PRESSURE"); const [value, setValue] = useState(""); const [sysBp, setSysBp] = useState(""); const [diaBp, setDiaBp] = useState(""); @@ -515,7 +536,7 @@ export function MeasurementForm({ - {MEASUREMENT_TYPES.map((mt) => ( + {offeredTypes.map((mt) => ( {t(mt.labelKey)}