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