diff --git a/docs/organisation/systems/app-experience.json b/docs/organisation/systems/app-experience.json index 094c56139b..2cfa472d0f 100644 --- a/docs/organisation/systems/app-experience.json +++ b/docs/organisation/systems/app-experience.json @@ -341,6 +341,7 @@ "tests/services-tap-floor.test.ts", "tests/settings-*", "tests/shared-*", + "tests/side-menu-leave-guard.dom.test.tsx", "tests/sidebar-*", "tests/signed-*", "tests/smart-search-intent.test.ts", diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index eadd8378cc..a75b744383 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -1,6 +1,5 @@ "use client"; -import dynamic from "next/dynamic"; import { useEffect, useRef, @@ -61,6 +60,7 @@ import { ClinicalMenuWorkAreas, clinicalMenuTabId, } from "@/components/clinical-dashboard/clinical-menu-drawer"; +import { LazyWorkSideCounts } from "@/components/work-frame/lazy-work-side-counts"; import { useWorkSideCounts } from "@/components/work-frame/work-frame-store"; import { useNewWorkMode } from "@/components/work-mode-launch/work-mode-launch-provider"; import { needsYouBellVisibleForAuth } from "@/lib/needs-you/homes"; @@ -68,18 +68,6 @@ import type { ModeMenuSideId } from "@/lib/phone-mode-groups"; import { useAuthSession } from "@/lib/supabase/client"; import type { WorkAreaId } from "@/lib/work-frame/areas"; -// The counts reader is loaded straight from its own file. Going through -// work-frame/lazy-work-side-nav would pull in the two-pane menu, which imports -// this file back and makes an import cycle. -const LazyCountsReader = dynamic( - () => import("@/components/work-frame/work-side-counts-reader").then((module) => module.WorkSideCountsReader), - { ssr: false }, -); - -function LazyWorkSideCounts({ active }: { readonly active: boolean }) { - return active ? : null; -} - export type SidebarIdentity = { displayName: string; initials: string; diff --git a/src/components/work-frame/lazy-work-side-counts.tsx b/src/components/work-frame/lazy-work-side-counts.tsx new file mode 100644 index 0000000000..ec2d0fe9c1 --- /dev/null +++ b/src/components/work-frame/lazy-work-side-counts.tsx @@ -0,0 +1,15 @@ +"use client"; + +import dynamic from "next/dynamic"; + +// Its own leaf module: the clinical sidebar imports it, so it must not pull in the +// side menus (two-pane-side-menu imports the clinical sidebar back). +const LazyCounts = dynamic( + () => import("@/components/work-frame/work-side-counts-reader").then((module) => module.WorkSideCountsReader), + { ssr: false }, +); + +/** The side menu and rail's counts, read only while one of them is on screen. */ +export function LazyWorkSideCounts({ active }: { readonly active: boolean }) { + return active ? : null; +} diff --git a/src/components/work-frame/lazy-work-side-nav.tsx b/src/components/work-frame/lazy-work-side-nav.tsx index 478a398ecd..f789f0b325 100644 --- a/src/components/work-frame/lazy-work-side-nav.tsx +++ b/src/components/work-frame/lazy-work-side-nav.tsx @@ -3,6 +3,8 @@ import dynamic from "next/dynamic"; import { useState, type ComponentProps } from "react"; +import { LazyWorkSideCounts } from "@/components/work-frame/lazy-work-side-counts"; + const loadWorkSideMenu = () => import("@/components/work-frame/work-side-menu").then((module) => module.WorkSideMenu); const LazyMenu = dynamic(loadWorkSideMenu, { ssr: false }); @@ -10,11 +12,6 @@ const loadTwoPaneSideMenu = () => import("@/components/work-frame/two-pane-side-menu").then((module) => module.TwoPaneSideMenu); const LazyTwoPane = dynamic(loadTwoPaneSideMenu, { ssr: false }); -const LazyCounts = dynamic( - () => import("@/components/work-frame/work-side-counts-reader").then((module) => module.WorkSideCountsReader), - { ssr: false }, -); - /** Warms the side menu's chunk when the menu button is about to be pressed. */ export function prefetchWorkSideMenu() { void loadWorkSideMenu(); @@ -42,10 +39,7 @@ export function LazyTwoPaneSideMenu(props: ComponentProps) { return opened ? : null; } -/** The side menu and rail's counts, read only while one of them is on screen. */ -export function LazyWorkSideCounts({ active }: { readonly active: boolean }) { - return active ? : null; -} +export { LazyWorkSideCounts }; /** * The two-pane side menu with its own counts reader, for a page that has no diff --git a/src/components/work-frame/two-pane-side-menu.tsx b/src/components/work-frame/two-pane-side-menu.tsx index 27c5f281be..c372f6dae3 100644 --- a/src/components/work-frame/two-pane-side-menu.tsx +++ b/src/components/work-frame/two-pane-side-menu.tsx @@ -239,8 +239,8 @@ export function TwoPaneSideMenu({ const visibleAreas = workAvailable ? WORK_SIDE_AREAS.filter((entry) => routeVisible(entry.href)) : []; const openReminders = () => { + if (!runReminders()) return; close(); - runReminders(); }; const needle = find.trim().toLowerCase(); diff --git a/src/components/work-frame/two-pane-side-strip.tsx b/src/components/work-frame/two-pane-side-strip.tsx index 837804a4e0..c87409a4dc 100644 --- a/src/components/work-frame/two-pane-side-strip.tsx +++ b/src/components/work-frame/two-pane-side-strip.tsx @@ -30,6 +30,7 @@ import { appModeIcons } from "@/lib/app-mode-icons"; import { BRAND_NAME } from "@/lib/brand"; import type { ModeMenuSideId } from "@/lib/phone-mode-groups"; import type { ThemePreference } from "@/lib/theme"; +import { confirmShellNavigation } from "@/lib/navigation-guards"; /** * The strip shared by the two-pane side menu and its tablet rail. On a phone the strip lives inside the menu. From 768 px the same @@ -61,11 +62,17 @@ export function useThemeCycle() { } /** Opens Reminders. Only a page that offers it can open it; anywhere else goes to My Day first. */ -export function useOpenReminders(): () => void { +export function useOpenReminders(): () => boolean { const router = useRouter(); return () => { - if (!workFrameActionHandler("my-day-reminders")) router.push("/my-day"); + const handled = workFrameActionHandler("my-day-reminders"); + if (!handled) { + // An unsaved page (the safety plan) may keep the reader where they are. + if (!confirmShellNavigation()) return false; + router.push("/my-day"); + } requestWorkFrameAction("my-day-reminders"); + return true; }; } diff --git a/src/components/work-frame/work-side-menu.tsx b/src/components/work-frame/work-side-menu.tsx index ec64cde378..8195b875a4 100644 --- a/src/components/work-frame/work-side-menu.tsx +++ b/src/components/work-frame/work-side-menu.tsx @@ -19,6 +19,7 @@ import { appModeIcons } from "@/lib/app-mode-icons"; import { BRAND_NAME } from "@/lib/brand"; import type { ModeMenuSideId } from "@/lib/phone-mode-groups"; import { WORK_AREAS, type WorkAreaId } from "@/lib/work-frame/areas"; +import { confirmShellNavigation } from "@/lib/navigation-guards"; import { WORK_SIDE_AREAS, WORK_SIDE_NOTIFICATIONS_HREF, @@ -83,9 +84,11 @@ export function WorkSideMenu({ const close = () => onOpenChange(false); const openReminders = () => { - close(); // Only a page that offers Reminders can open it; anywhere else goes to My Day first. - if (!workFrameActionHandler("my-day-reminders")) router.push("/my-day"); + const handled = workFrameActionHandler("my-day-reminders"); + if (!handled && !confirmShellNavigation()) return; + close(); + if (!handled) router.push("/my-day"); requestWorkFrameAction("my-day-reminders"); }; diff --git a/tests/side-menu-leave-guard.dom.test.tsx b/tests/side-menu-leave-guard.dom.test.tsx new file mode 100644 index 0000000000..cbd7e6d9d9 --- /dev/null +++ b/tests/side-menu-leave-guard.dom.test.tsx @@ -0,0 +1,34 @@ +/** @vitest-environment jsdom */ + +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useOpenReminders } from "@/components/work-frame/two-pane-side-strip"; +import { registerNavigationGuard } from "@/lib/navigation-guards"; + +const router = vi.hoisted(() => ({ push: vi.fn() })); + +vi.mock("next/navigation", () => ({ + useRouter: () => router, + usePathname: () => "/psychiatry/safety-plan", +})); + +afterEach(() => { + router.push.mockReset(); +}); + +describe("side menu Reminders shortcut", () => { + it("stays on a page with unsaved work when the reader says stay", () => { + const remove = registerNavigationGuard(() => false); + const { result } = renderHook(() => useOpenReminders()); + act(() => result.current()); + expect(router.push).not.toHaveBeenCalled(); + remove(); + }); + + it("goes to My Day when nothing holds the reader", () => { + const { result } = renderHook(() => useOpenReminders()); + act(() => result.current()); + expect(router.push).toHaveBeenCalledWith("/my-day"); + }); +});