mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Gate the bottom-nav Meds slot on the medications module
The mobile bar pinned /medications unconditionally while the page renders nothing when the medications module is off — a visible tab over a blank screen. Run both fixed flanks through the one slot filter the Insights slot already uses (sharing coverage + module map, fail-closed until mounted), so a disabled module is hidden everywhere instead of relocated.
This commit is contained in:
1 parent
11aa3dc060
commit
c23e677b6e
3 files changed
+128
-34
No files matched your search
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Bottom-nav primary-slot module gate — the MOUNTED contract.
|
||||
*
|
||||
* `useMounted` is mocked true here, so the resolved module map applies
|
||||
* (the sibling `bottom-nav.test.tsx` pins the fail-closed pre-mount
|
||||
* markup). The Meds slot pins to `/medications`, whose page renders
|
||||
* nothing when the medications module is off — a visible tab over a
|
||||
* blank page — so the slot must honour the same per-user map the
|
||||
* Insights slot and the More hub already do.
|
||||
*
|
||||
* Watched red: with the `requiresModule: "medications"` entry removed
|
||||
* from PRIMARY_LEFT (the pre-fix ungated pin) the module-off test fails
|
||||
* with the tab still present. Verified red against the pre-fix bar.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/",
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}));
|
||||
|
||||
// Mounted=true: the SSR render below behaves like the hydrated bar.
|
||||
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
|
||||
|
||||
const mockUserRef = {
|
||||
value: { id: "u1", modules: {} } as {
|
||||
id: string;
|
||||
modules: Record<string, boolean>;
|
||||
} | null,
|
||||
};
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: mockUserRef.value,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { BottomNav } from "../bottom-nav";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<BottomNav />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<BottomNav> mounted module gate", () => {
|
||||
it("shows the Meds and Insights slots when their modules are on (default-on map)", () => {
|
||||
mockUserRef.value = { id: "u1", modules: {} };
|
||||
const html = render();
|
||||
for (const href of ["/", "/medications", "/insights"]) {
|
||||
expect(html).toContain(`href="${href}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops the Meds slot when the medications module is off", () => {
|
||||
mockUserRef.value = { id: "u1", modules: { medications: false } };
|
||||
const html = render();
|
||||
expect(html).not.toContain('href="/medications"');
|
||||
// Siblings unaffected.
|
||||
expect(html).toContain('href="/"');
|
||||
expect(html).toContain('href="/insights"');
|
||||
mockUserRef.value = { id: "u1", modules: {} };
|
||||
});
|
||||
});
|
||||
@@ -61,22 +61,21 @@ function render() {
|
||||
}
|
||||
|
||||
describe("<BottomNav> iOS-parity layout", () => {
|
||||
it("renders the always-on flanking primary anchors (Home, Meds)", () => {
|
||||
it("renders the always-on Home anchor", () => {
|
||||
const html = render();
|
||||
// Home + Meds are not module-gated, so they anchor the strip on first
|
||||
// paint. The Insights slot IS module-gated; v1.25.12 gates it behind
|
||||
// hydration (fail-closed pre-mount) so a disabled module can't flicker
|
||||
// in — its presence is asserted in the mounted/e2e path, not here.
|
||||
for (const href of ["/", "/medications"]) {
|
||||
expect(html).toContain(`href="${href}"`);
|
||||
}
|
||||
// Home is the one ungated primary slot, so it anchors the strip on
|
||||
// first paint. Meds and Insights are BOTH module-gated now and follow
|
||||
// the fail-closed-pre-mount contract; their mounted presence is pinned
|
||||
// in bottom-nav-module-gate.test.tsx.
|
||||
expect(html).toContain('href="/"');
|
||||
});
|
||||
|
||||
it("keeps the module-gated Insights slot OUT of the first-paint markup (v1.25.12)", () => {
|
||||
it("keeps the module-gated Meds and Insights slots OUT of the first-paint markup (v1.25.12)", () => {
|
||||
const html = render();
|
||||
// Pre-hydration the module map isn't settled; the Insights primary slot
|
||||
// therefore fails closed (absent) rather than flickering in.
|
||||
// Pre-hydration the module map isn't settled; a module-gated primary
|
||||
// slot therefore fails closed (absent) rather than flickering in.
|
||||
expect(html).not.toContain('href="/insights"');
|
||||
expect(html).not.toContain('href="/medications"');
|
||||
});
|
||||
|
||||
it("renders the center capture action as a dialog-opening button, not a link", () => {
|
||||
@@ -111,13 +110,13 @@ describe("<BottomNav> iOS-parity layout", () => {
|
||||
|
||||
it("each strip entry meets the 44 px tap-target floor (min-h-11 min-w-11)", () => {
|
||||
const html = render();
|
||||
// The flanking strip slots carry `min-h-11 min-w-11`. On first paint the
|
||||
// module-gated Insights slot is fail-closed (v1.25.12), so the strip
|
||||
// shows Home, Meds and More — three slots. We don't hard-pin the count
|
||||
// because the mounted render adds Insights back and a future entry would
|
||||
// also satisfy the contract.
|
||||
// The flanking strip slots carry `min-h-11 min-w-11`. On first paint
|
||||
// BOTH module-gated slots (Meds, Insights) are fail-closed (v1.25.12),
|
||||
// so the strip shows Home and More — two slots. We don't hard-pin the
|
||||
// count because the mounted render adds the gated slots back and a
|
||||
// future entry would also satisfy the contract.
|
||||
const matches = html.match(/min-h-11 min-w-11/g) ?? [];
|
||||
expect(matches.length).toBeGreaterThanOrEqual(3);
|
||||
expect(matches.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("the center capture FAB is a larger, elevated tap target", () => {
|
||||
@@ -171,9 +170,8 @@ describe("<BottomNav> iOS-parity layout", () => {
|
||||
mockUserRef.value = { id: "u1", modules: { insights: false } };
|
||||
const html = render();
|
||||
expect(html).not.toContain('href="/insights"');
|
||||
// Sibling primary slots are unaffected.
|
||||
// The ungated sibling anchor is unaffected.
|
||||
expect(html).toContain('href="/"');
|
||||
expect(html).toContain('href="/medications"');
|
||||
mockUserRef.value = { id: "u1", modules: {} };
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
mobileMoreHubDestinations,
|
||||
} from "@/components/layout/nav-model";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
@@ -55,9 +55,17 @@ interface NavLink {
|
||||
// slots, plus the shared utility tail) — so the two surfaces tell one story
|
||||
// instead of two hand-curated ones that drift, and the headline invariant
|
||||
// is a tested model function rather than inline bar logic.
|
||||
const PRIMARY_LEFT: ReadonlyArray<NavLink> = [
|
||||
// The Meds slot is module-gated exactly like the Insights slot below:
|
||||
// `/medications` renders nothing when the medications module is off, so a
|
||||
// pinned tab would be a visible control over a blank page.
|
||||
const PRIMARY_LEFT: ReadonlyArray<NavLink & { requiresModule?: ModuleKey }> = [
|
||||
{ href: "/", tKey: "nav.dashboard", icon: Home },
|
||||
{ href: "/medications", tKey: "nav.medications", icon: Pill },
|
||||
{
|
||||
href: "/medications",
|
||||
tKey: "nav.medications",
|
||||
icon: Pill,
|
||||
requiresModule: "medications",
|
||||
},
|
||||
];
|
||||
|
||||
// v1.18.0 — the Insights primary slot is module-gated. Insights is now a
|
||||
@@ -125,20 +133,35 @@ export function BottomNav() {
|
||||
// Insights never flickers in), then the real map applies: a missing key /
|
||||
// unloaded map keeps the slot, mirroring the gate + More-hub default-on
|
||||
// contract.
|
||||
const primaryRight = useMemo(
|
||||
() =>
|
||||
PRIMARY_RIGHT.filter(
|
||||
(item) =>
|
||||
// v1.36.0 — the fixed slot asks the shared destination model
|
||||
// whether sharing covers it, rather than carrying its own answer:
|
||||
// Insights is an AI surface and drops out under a switch.
|
||||
(!sharedRecord || isDestinationInSharedRecord(item.href, sections)) &&
|
||||
(!item.requiresModule ||
|
||||
(mounted && user?.modules?.[item.requiresModule] !== false)),
|
||||
),
|
||||
const primarySlotVisible = useCallback(
|
||||
(item: NavLink & { requiresModule?: ModuleKey }) => {
|
||||
// An ungated slot (Home) is pinned unconditionally — the bar always
|
||||
// has an anchor, whatever the module map or a record switch says.
|
||||
if (!item.requiresModule) return true;
|
||||
return (
|
||||
// v1.36.0 — the fixed slot asks the shared destination model
|
||||
// whether sharing covers it, rather than carrying its own answer:
|
||||
// Insights is an AI surface and drops out under a switch.
|
||||
(!sharedRecord || isDestinationInSharedRecord(item.href, sections)) &&
|
||||
mounted &&
|
||||
user?.modules?.[item.requiresModule] !== false
|
||||
);
|
||||
},
|
||||
[user?.modules, mounted, sharedRecord, sections],
|
||||
);
|
||||
|
||||
// Both fixed flanks run through the ONE slot filter, so a module-gated
|
||||
// tab (Meds left, Insights right) is dropped — not relocated — when the
|
||||
// account turned its module off, matching the More hub's exclusion.
|
||||
const primaryLeft = useMemo(
|
||||
() => PRIMARY_LEFT.filter(primarySlotVisible),
|
||||
[primarySlotVisible],
|
||||
);
|
||||
const primaryRight = useMemo(
|
||||
() => PRIMARY_RIGHT.filter(primarySlotVisible),
|
||||
[primarySlotVisible],
|
||||
);
|
||||
|
||||
function isActiveLink(href: string) {
|
||||
return isNavDestinationActive(href, pathname);
|
||||
}
|
||||
@@ -189,7 +212,7 @@ export function BottomNav() {
|
||||
className="bg-card border-border fixed bottom-0 left-0 z-50 min-h-16 w-full border-t pb-[env(safe-area-inset-bottom)] backdrop-blur-md md:hidden"
|
||||
>
|
||||
<div className="mx-auto flex h-16 max-w-lg items-stretch justify-around px-1">
|
||||
{PRIMARY_LEFT.map(renderPrimary)}
|
||||
{primaryLeft.map(renderPrimary)}
|
||||
|
||||
{/* Center capture action — a labeled button (NOT a link) that
|
||||
opens the capture picker. Elevated as a Floating-Action-
|
||||
|
||||
Reference in new issue
Block a user