diff --git a/docs/frontend-architecture.md b/docs/frontend-architecture.md index c82ee760..5dfdc92b 100644 --- a/docs/frontend-architecture.md +++ b/docs/frontend-architecture.md @@ -2,7 +2,7 @@ ## Route ownership -- `GlobalSearchShell` owns shared navigation, responsive chrome and URL-backed mode/query/filter state. +- `GlobalSearchShell` owns shared navigation, responsive chrome and URL-backed mode/query/filter state. `search-route-ownership` is the pure routing boundary that decides whether a submitted search stays in a route-owned workflow or renders `ClinicalDashboard`. - `ClinicalDashboard` owns submitted Answer, Documents and Prescribing workflows. - `/documents/search` renders live `/api/search` results for submitted queries; `/documents/[id]` is the canonical viewer. - `/documents/source*` are compatibility redirects. Fixture document journeys live only below `/mockups/document-search/**`. diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 4c5afcce..60a5fc80 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -47,6 +47,7 @@ import { isLocalNoAuthMode } from "@/lib/client-env"; import { documentsSearchHref } from "@/lib/document-flow-routes"; import { differentialsMobileCompareAddonSlotId, modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { readSearchNavigationContext, type SearchNavigationOptions } from "@/lib/search-navigation-context"; +import { shouldRenderClinicalDashboard, shouldRenderDashboardSearch } from "@/lib/search-route-ownership"; import type { SearchScopeFilters } from "@/lib/search-scope"; import { useAuthSession } from "@/lib/supabase/client"; import type { ClinicalQueryMode } from "@/lib/types"; @@ -118,35 +119,23 @@ function GlobalSearchShellClient(props: GlobalSearchShellProps) { : initialSearchMode; const requestedQuery = (searchParams.get("q") ?? searchParams.get("query") ?? "").trim(); const hasSubmittedModeSearch = searchParams.get("run") === "1" && requestedQuery.length > 0; - const isHomeRoute = pathname === "/"; - const isDocumentSearchMockupRoute = pathname.startsWith("/mockups/document-search"); - const shouldRenderDashboardSearch = - hasSubmittedModeSearch && - resolvedSearchMode !== "services" && - resolvedSearchMode !== "forms" && - resolvedSearchMode !== "favourites" && - resolvedSearchMode !== "differentials" && - resolvedSearchMode !== "dsm" && - resolvedSearchMode !== "specifiers" && - resolvedSearchMode !== "formulation" && - // Therapy Compass owns its route with an in-tool search; a run-enabled link - // (/therapy-compass?q=…&run=1) must keep rendering the tool, not the dashboard. - resolvedSearchMode !== "therapy-compass" && - !isDocumentSearchMockupRoute; - const isMedicationDetailRoute = /^\/medications\/[^/]+$/.test(pathname); - const shouldRenderClinicalDashboard = !isMedicationDetailRoute && (isHomeRoute || shouldRenderDashboardSearch); + const rendersClinicalDashboard = shouldRenderClinicalDashboard({ + hasSubmittedSearch: hasSubmittedModeSearch, + mode: resolvedSearchMode, + pathname, + }); // Wrap both render paths so the patient-considerations profile is shared // between the prescribing workspace (ClinicalDashboard) and the medication // detail pages (standalone shell), backed by sessionStorage across navigation. return ( - {shouldRenderClinicalDashboard ? ( + {rendersClinicalDashboard ? ( ) : ( @@ -237,7 +226,6 @@ function GlobalStandaloneSearchShellClient({ const auth = useAuthSession(); const sidebarIdentity = useMemo(() => deriveSidebarIdentity(auth.session?.user.email), [auth.session?.user.email]); const hasSubmittedModeSearch = requestedRun && requestedQuery.length > 0; - const isDocumentSearchMockupRoute = pathname.startsWith("/mockups/document-search"); const isDocumentCommandSearchView = pathname === "/documents/search" && requestedQuery.length > 0; const useCompactBottomSearch = hasSubmittedModeSearch || isDocumentCommandSearchView; const differentialsCompareAddonActive = @@ -246,22 +234,14 @@ function GlobalStandaloneSearchShellClient({ // standalone routes; the shell must not swap them to the dashboard. On the // home route the dashboard always renders, so these exclusions only apply // to the standalone pages. - const shouldRenderDashboardSearch = - hasSubmittedModeSearch && - resolvedSearchMode !== "services" && - resolvedSearchMode !== "forms" && - resolvedSearchMode !== "favourites" && - resolvedSearchMode !== "differentials" && - resolvedSearchMode !== "dsm" && - resolvedSearchMode !== "specifiers" && - resolvedSearchMode !== "formulation" && - // Therapy Compass owns its route with an in-tool search; a run-enabled link - // (/therapy-compass?q=…&run=1) must keep rendering the tool, not the dashboard. - resolvedSearchMode !== "therapy-compass" && - !isDocumentSearchMockupRoute; + const rendersDashboardSearch = shouldRenderDashboardSearch({ + hasSubmittedSearch: hasSubmittedModeSearch, + mode: resolvedSearchMode, + pathname, + }); const isStandaloneModeHome = !hasSubmittedModeSearch && - !shouldRenderDashboardSearch && + !rendersDashboardSearch && ((searchMode === "services" && pathname === "/services") || (searchMode === "forms" && pathname === "/forms") || (searchMode === "favourites" && pathname === "/favourites") || diff --git a/src/lib/search-route-ownership.ts b/src/lib/search-route-ownership.ts new file mode 100644 index 00000000..ebbb0bfb --- /dev/null +++ b/src/lib/search-route-ownership.ts @@ -0,0 +1,47 @@ +import type { AppModeId } from "@/lib/app-modes"; + +/** + * Decides whether a submitted shared-composer search belongs to the dashboard + * or to the route that is already mounted. This is deliberately a pure routing + * boundary: it does not parse URLs, fetch data, or depend on React state. + */ +const routeOwnedSubmittedSearchModes = new Set([ + "services", + "forms", + "favourites", + "differentials", + "dsm", + "specifiers", + "formulation", + "therapy-compass", +]); + +export function shouldRenderDashboardSearch({ + hasSubmittedSearch, + mode, + pathname, +}: { + hasSubmittedSearch: boolean; + mode: AppModeId; + pathname: string; +}) { + return ( + hasSubmittedSearch && !routeOwnedSubmittedSearchModes.has(mode) && !pathname.startsWith("/mockups/document-search") + ); +} + +export function shouldRenderClinicalDashboard({ + hasSubmittedSearch, + mode, + pathname, +}: { + hasSubmittedSearch: boolean; + mode: AppModeId; + pathname: string; +}) { + const isMedicationDetailRoute = /^\/medications\/[^/]+$/.test(pathname); + return ( + !isMedicationDetailRoute && + (pathname === "/" || shouldRenderDashboardSearch({ hasSubmittedSearch, mode, pathname })) + ); +} diff --git a/tests/search-route-ownership.test.ts b/tests/search-route-ownership.test.ts new file mode 100644 index 00000000..aab3f214 --- /dev/null +++ b/tests/search-route-ownership.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { shouldRenderClinicalDashboard, shouldRenderDashboardSearch } from "@/lib/search-route-ownership"; + +describe("shared-search route ownership", () => { + it("keeps submitted searches in route-owned mode workflows", () => { + for (const mode of [ + "services", + "forms", + "favourites", + "differentials", + "dsm", + "specifiers", + "formulation", + "therapy-compass", + ] as const) { + expect(shouldRenderDashboardSearch({ hasSubmittedSearch: true, mode, pathname: `/${mode}` })).toBe(false); + } + }); + + it("routes dashboard-owned submitted workflows to ClinicalDashboard", () => { + expect(shouldRenderDashboardSearch({ hasSubmittedSearch: true, mode: "answer", pathname: "/" })).toBe(true); + expect( + shouldRenderDashboardSearch({ hasSubmittedSearch: true, mode: "documents", pathname: "/documents/search" }), + ).toBe(true); + expect(shouldRenderClinicalDashboard({ hasSubmittedSearch: false, mode: "answer", pathname: "/" })).toBe(true); + }); + + it("never replaces an explicit medication detail or document-search mockup", () => { + expect( + shouldRenderClinicalDashboard({ + hasSubmittedSearch: true, + mode: "prescribing", + pathname: "/medications/acamprosate", + }), + ).toBe(false); + expect( + shouldRenderDashboardSearch({ + hasSubmittedSearch: true, + mode: "documents", + pathname: "/mockups/document-search/search", + }), + ).toBe(false); + }); +}); diff --git a/tests/therapy-compass-mode-wiring.test.ts b/tests/therapy-compass-mode-wiring.test.ts index 7aa80983..15598952 100644 --- a/tests/therapy-compass-mode-wiring.test.ts +++ b/tests/therapy-compass-mode-wiring.test.ts @@ -2,6 +2,8 @@ import { existsSync, readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; +import { shouldRenderDashboardSearch } from "@/lib/search-route-ownership"; + // Guards the two production-mode wiring invariants for Therapy Compass. Both were // real breakages caught in review when the mockup was promoted to a live mode. @@ -9,10 +11,6 @@ const loaderSrc = readFileSync( new URL("../src/components/therapy-compass/data/use-therapy-data.ts", import.meta.url), "utf8", ); -const shellSrc = readFileSync( - new URL("../src/components/clinical-dashboard/global-search-shell.tsx", import.meta.url), - "utf8", -); const dataDir = new URL("../public/therapy-compass-data/", import.meta.url); describe("Therapy Compass production-mode wiring", () => { @@ -28,11 +26,11 @@ describe("Therapy Compass production-mode wiring", () => { } }); - it("excludes therapy-compass from the shell dashboard-search so run-enabled links keep the tool", () => { - // Both shouldRenderDashboardSearch blocks (standalone + non-standalone client) must exclude the mode, - // otherwise /therapy-compass?q=…&run=1 renders ClinicalDashboard over TherapyCompassPage. - const occurrences = shellSrc.match(/resolvedSearchMode !== "therapy-compass"/g) ?? []; - expect(occurrences.length).toBeGreaterThanOrEqual(2); + it("keeps therapy-compass route-owned when the shared composer has a submitted query", () => { + // Otherwise /therapy-compass?q=…&run=1 renders ClinicalDashboard over TherapyCompassPage. + expect( + shouldRenderDashboardSearch({ hasSubmittedSearch: true, mode: "therapy-compass", pathname: "/therapy-compass" }), + ).toBe(false); }); it("honors run-enabled deep links by seeding the in-tool search instead of landing on Home", () => {