mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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.
This commit is contained in:
1 parent
a15a148667
commit
d38ca7737e
2 files changed
+116
-2
No files matched your search
@@ -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:<type>`), 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<string, boolean> | 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("<MeasurementForm> with a type's module off", () => {
|
||||
it("does not open on that type, even when asked to", () => {
|
||||
auth.modules = { glucose: false };
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<MeasurementForm defaultType="BLOOD_GLUCOSE" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
auth.modules = undefined;
|
||||
expect(html).not.toContain("mg/dL");
|
||||
expect(html).toContain("mmHg");
|
||||
});
|
||||
});
|
||||
@@ -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:<type>` 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({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MEASUREMENT_TYPES.map((mt) => (
|
||||
{offeredTypes.map((mt) => (
|
||||
<SelectItem key={mt.value} value={mt.value}>
|
||||
{t(mt.labelKey)}
|
||||
</SelectItem>
|
||||
|
||||
Reference in new issue
Block a user