"use client" /** * Fills the DS shell's slots with this app's content (ADR 0003). * * The shell in `@exxatdesignux/ui` renders sidebar chrome; the spots that need * Exxat content — the notification bell, the product lock-up, the switcher rows * — are declared as slots and filled here, once, above the shell. Adding a spot * is a change to this file rather than a change inside the shell, which is what * lets the shell keep updating through a version bump. * * When a consumer app does not fill a slot the shell renders that spot empty, * so this file is additive rather than required. */ import * as React from "react" import { AppShellSlotsProvider, type AppShellData, type AppShellRoutePolicy, type AppShellSlots, type NavRowActiveOverride, type ScopeSwitcherMenuSlotProps, type SecondaryPanelSpec, type ShellNav, type SidebarDrillInPanelSpec, type ShellProductEntry, type ShellScope, } from "@exxatdesignux/ui/components/shell" import { collectNavUrls } from "@exxatdesignux/ui/lib/nav-active" import { customProductSlugFromSuffix, primaryNavLinksForSlug, } from "@exxatdesignux/product-framework" import { useProduct, type Product } from "@/contexts/product-context" import { expandSwitcherProducts, resolveActiveSwitcherEntry, type SwitcherProductEntry, } from "@/lib/product-switcher-catalog" import { ScopeSwitcherMenuBody, useScopeSwitcher, type ScopeSwitcherBag, } from "@/components/scope-switcher-menu" import { NotificationBell } from "@/components/notification-bell" import { ExxatProductLogo, ExxatProductMark } from "@/components/exxat-product-logo" import { ProductSwitcherMenuItems } from "@/components/product-switcher-menu-items" import { requestOpenCommandMenu } from "@/components/command-menu" import { useAskLeo } from "@/components/ask-leo-context" import { HomeLayoutMenu } from "@/components/product-home/home-layout-menu" import { LibrarySearchModeMenu } from "@/components/library-search-mode-menu" import { SignInFlowMenuItem } from "@/components/sign-in-flow-menu-item" import { ThemeModeMenu } from "@/components/theme-mode-menu" import { useLogOut } from "@/hooks/use-log-out" import { useAskLeoDockedPanelOpen } from "@/hooks/use-ask-leo-docked-panel-open" import { isSidebarHiddenPath } from "@/lib/focus-workflow" import { LibrarySecondaryNav } from "@/components/library-secondary-nav" import { LearningActivitiesSecondaryNav } from "@/components/learning-activities-secondary-nav" import { PanelBridgesProvider } from "@/components/panel-bridges" import { LeoSidebarDrillInPanel } from "@/components/sidebar/leo-sidebar-drill-in-panel" import { DesignSystemSidebarDrillInPanel, } from "@/components/sidebar/design-system-sidebar-drill-in-panel" import { isLibraryPrimaryListNavActive, isLibrarySecondaryPanelRoute, LIBRARY_PRIMARY_LIST_NAV_KEY, shouldLibrarySecondaryPanelBeOpen, } from "@/lib/library-nav" import { isLearningActivitiesCourseDetailPath, isLearningActivitiesSecondaryPanelRoute, shouldLearningActivitiesSecondaryPanelBeOpen, } from "@/lib/learning-activities-nav" import { getPrimaryNavForProduct, getPrimaryNavLayoutForProduct, getSecondaryNavForProduct, NAV_BY_PRODUCT, NAV_DOCUMENTS, NAV_DOCUMENTS_LABEL, NAV_QUICK_ACTIONS, NAV_SECONDARY, NAV_USER, } from "@/lib/mock/navigation" import { productSlug } from "@/stores/app-store" /** * Narrow this app's scope bag down to the fields the shell's trigger draws, and * carry the bag itself through `menuContext` so the menu body below runs on the * *same* state instance rather than a second copy of it. */ function useShellScope(product: Product): ShellScope { const bag = useScopeSwitcher(product) return React.useMemo( () => ({ status: bag.status, fixed: bag.fixed, parent: bag.parent, child: bag.child, label: bag.label, ariaLabel: bag.ariaLabel, choosePrompt: bag.config.choosePrompt, emptyIconClass: `fa-light fa-${bag.config.childIcon}`, onOpenChange: bag.onOpenChange, menuContext: bag, }), [bag], ) } function ScopeSwitcherMenuSlot({ scope }: ScopeSwitcherMenuSlotProps) { // The cast is safe because `useShellScope` above is the only writer of // `menuContext`, and both sides live in this app. return } /** * Name the product the switcher trigger shows. * * The catalogue lives in the app because it holds the built-in Exxat product rows * and the tenant-brand rules for hiding and disambiguating them. The shell only * needs the winner. */ function useActiveProduct(): ShellProductEntry { const { product, customProducts, activeCustomIndex, hiddenProducts } = useProduct() const products = React.useMemo( () => expandSwitcherProducts(customProducts, hiddenProducts), [customProducts, hiddenProducts], ) const isCurrentProduct = React.useCallback( (entry: SwitcherProductEntry) => entry.id === product && (entry.customIndex === undefined || entry.customIndex === activeCustomIndex), [activeCustomIndex, product], ) const current = resolveActiveSwitcherEntry( products, product, activeCustomIndex, customProducts, isCurrentProduct, ) return React.useMemo( () => ({ id: current.id, label: current.label, customIndex: current.customIndex }), [current.id, current.label, current.customIndex], ) } /** * This workspace keeps most products' settings at a shared route and scopes only * One — Sites to its own product root, so the destination comes from the nav * registry rather than the product slug. */ function useWorkspaceSettingsHref(): string { const { product } = useProduct() const [workspaceSettings] = getSecondaryNavForProduct(product) return workspaceSettings.url } /** * Every href the sidebar can expose in any product. * * The shell picks the highlighted row by longest prefix, which is only correct * when it can see the more specific hrefs it should lose to. Built-in products * are known at module load; tenant products hydrate at runtime and are merged in * {@link useNav}. */ const BUILTIN_PRODUCT_SETTINGS_URLS = ( [ "exxat-prism", "exxat-design-os", "exxat-one-schools", "exxat-one-sites", "exxat-curriculum-mapping", "exxat-compliance", "exxat-surveys", "exxat-exam-management", "exxat-accreditation", "exxat-student-success", ] as const ).map(p => `/${productSlug(p)}/settings`) const STATIC_NAV_URLS = collectNavUrls([ ...Object.values(NAV_BY_PRODUCT).flat(), ...NAV_DOCUMENTS, ...NAV_SECONDARY, ...BUILTIN_PRODUCT_SETTINGS_URLS.map(url => ({ url })), { url: "/settings/profile" }, { url: "/settings/organization" }, ]) /** * Library's primary row stays selected across every scope the secondary rail * sets, which the shell's generic rules cannot derive: the row's href sits a * level below its parent's hub path, so the hub rule rejects it. Only the rows * named here are answered; everything else falls through to those rules. */ const rowActive: NavRowActiveOverride = ({ parentKey, childKey }, pathname) => { if (parentKey === "library" && childKey === LIBRARY_PRIMARY_LIST_NAV_KEY) { return isLibraryPrimaryListNavActive(pathname) } return undefined } function useNav(): ShellNav { const { product, customProducts, activeCustomIndex } = useProduct() const knownUrls = React.useMemo(() => { // Tenant nav is not in `STATIC_NAV_URLS` because the registry hydrates after // module load. The registered tree is a stub (dashboard, library, // placements) rather than the full IA the sidebar draws, so merge both. const extras: string[] = [] for (let i = 0; i < customProducts.length; i++) { extras.push(...collectNavUrls(getPrimaryNavForProduct("exxat-custom", customProducts, i))) } for (const cp of customProducts) { const registered = primaryNavLinksForSlug(customProductSlugFromSuffix(cp.suffix)) if (registered?.length) extras.push(...collectNavUrls(registered)) } if (extras.length === 0) return STATIC_NAV_URLS return Array.from(new Set([...STATIC_NAV_URLS, ...extras])) }, [customProducts]) return React.useMemo( () => ({ primary: getPrimaryNavForProduct(product, customProducts, activeCustomIndex), primaryLayout: getPrimaryNavLayoutForProduct(product, customProducts, activeCustomIndex), quickActions: NAV_QUICK_ACTIONS, documents: NAV_DOCUMENTS, documentsLabel: NAV_DOCUMENTS_LABEL, secondary: getSecondaryNavForProduct(product), user: NAV_USER, knownUrls, rowActive, }), [product, customProducts, activeCustomIndex, knownUrls], ) } const SHELL_DATA: AppShellData = { useScope: useShellScope, useActiveProduct, useWorkspaceSettingsHref, useNav, } // Order is the rendered order of the identity menu's preference groups. const USER_MENU_PREFERENCES = [ThemeModeMenu, LibrarySearchModeMenu, HomeLayoutMenu] const USER_MENU_CONSOLE_ITEMS = [SignInFlowMenuItem] /** * The nested scope rails this app has, in priority order. * * Library separates `flyoutRoute` from `autoOpen` because the two sets differ: * the flyout toggle reaches for the rail from the `All questions` list hub, while * every scoped library route below it keeps the rail mounted. */ const SECONDARY_PANELS: SecondaryPanelSpec[] = [ { id: "library", title: "Library", Body: LibrarySecondaryNav, flyoutRoute: isLibrarySecondaryPanelRoute, autoOpen: shouldLibrarySecondaryPanelBeOpen, }, { id: "learning-activities", title: "Learning activities", Body: LearningActivitiesSecondaryNav, flyoutRoute: isLearningActivitiesSecondaryPanelRoute, autoOpen: shouldLearningActivitiesSecondaryPanelBeOpen, }, ] /** * Drill-ins this app draws itself rather than as a row list. * * Ask Leo's is a search field, a New chat button, and a persisted recents list; * Design OS's is a collapsible tier tree that draws its own heading, which is why * it takes the section title off the shell's drill-in header. */ const DRILL_IN_PANELS: SidebarDrillInPanelSpec[] = [ { rowKey: "ask-leo", Body: LeoSidebarDrillInPanel }, { rowKey: "design-system", Body: DesignSystemSidebarDrillInPanel, hidesSectionTitle: true, }, ] // Module scope, not a `useMemo`: every entry is a static component reference, // so the object never needs to change identity and the shell never re-renders // because of it. const SHELL_SLOTS: AppShellSlots = { Notifications: NotificationBell, ProductLogo: ExxatProductLogo, ProductMark: ExxatProductMark, ProductSwitcherItems: ProductSwitcherMenuItems, ScopeSwitcherMenu: ScopeSwitcherMenuSlot, userMenuPreferences: USER_MENU_PREFERENCES, userMenuConsoleItems: USER_MENU_CONSOLE_ITEMS, secondaryPanels: SECONDARY_PANELS, drillInPanels: DRILL_IN_PANELS, } // Focus workflows are a fixed property of this app's routes, so the policy // object never changes identity. const SHELL_ROUTES: AppShellRoutePolicy = { hidesSidebar: isSidebarHiddenPath, // A course record wants the canvas, so the primary rail drops to icons on the // way in. Every other destination keeps whatever state the rail was left in. mainSidebarOnPanelClose: pathname => isLearningActivitiesCourseDetailPath(pathname) ? "collapse" : "leave", } export function AppShellWiring({ children }: { children: React.ReactNode }) { const { open: askLeoOpen, setOpen: setAskLeoOpen } = useAskLeo() const askLeoDockedOpen = useAskLeoDockedPanelOpen() const logOut = useLogOut() const on = React.useMemo( () => ({ openCommandMenu: requestOpenCommandMenu, askLeoOpen, askLeoDockedOpen, setAskLeoOpen, logOut, }), [askLeoOpen, askLeoDockedOpen, setAskLeoOpen, logOut], ) return ( {children} ) }