diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a1a7d415..70c00008 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -64,6 +64,7 @@ jobs: npm run test:run -- src/testing/tests/FinchAppLayout.test.tsx npm run test:run -- src/testing/tests/FinchHeader.test.tsx npm run test:run -- src/testing/tests/FinchMainContent.test.tsx + npm run test:run -- src/testing/tests/FinchPageTabs.test.tsx npm run test:run -- src/testing/tests/FinchSidebar.test.tsx npm run test:run -- src/testing/tests/GoogleDoc.test.tsx npm run test:run -- src/testing/tests/Header.test.tsx @@ -89,6 +90,7 @@ jobs: npm run test:run -- src/testing/tests/TiledComponents.test.tsx npm run test:run -- src/testing/tests/TiledLinePlotMaker.test.tsx npm run test:run -- src/testing/tests/Widget.test.tsx + npm run test:run -- src/testing/tests/useActiveRoute.test.tsx echo "Running ophyd sim test suites..." npm run test:run -- src/testing/tests/BeamstopModel.test.tsx npm run test:run -- src/testing/tests/createFallbackTransport.test.ts @@ -214,7 +216,7 @@ jobs: `- ${testStepStatus} **Buttons** - Button, ButtonIconOnly, ButtonWithIcon, ButtonCopyToClipboard`, `- ${testStepStatus} **Inputs** - InputCheckBox, InputNumber, InputEnumBoxRounded, InputStringBoxRounded`, `- ${testStepStatus} **Plots** - PlotlyHeatmap, PlotlyHeatmapTiled, PlotlyScatter, Histogram, ColormapPicker`, - `- ${testStepStatus} **Layout** - Sidebar, SidebarItem, FinchSidebar, FinchAppLayout, FinchHeader, FinchMainContent, Header, Main, Bento, Paper, Widget`, + `- ${testStepStatus} **Layout** - Sidebar, SidebarItem, FinchSidebar, FinchAppLayout, FinchHeader, FinchMainContent, FinchPageTabs, useActiveRoute, Header, Main, Bento, Paper, Widget`, `- ${testStepStatus} **Tiled** - TiledComponents, TiledLinePlotMaker`, `- ${testStepStatus} **Devices** - Camera, Shutter, DeviceControllerBox, DeviceControllerBoxSimple, TableDeviceController, ControllerAbsoluteMove, ControllerRelativeMove, Hexapod, BeamEnergy`, `- ${testStepStatus} **Services** - QueueServer, SignalMonitorPlots, Experiment`, diff --git a/README.md b/README.md index 2806e2f6..d7abc15c 100644 --- a/README.md +++ b/README.md @@ -184,12 +184,72 @@ export default App; ``` Each route entry supports: -- `path` — the URL path (e.g. `"/controls"`) +- `path` — the URL path, static only (e.g. `"/controls"`). The leading slash is optional - `label` — text shown in the sidebar navigation -- `element` — the React component to render for that route +- `element` — the React component to render for that route (omit when `tabs` is set) +- `tabs` — optional tabs shown in a strip above the page, each with its own URL (omit `element` when set) - `icon` — optional React element shown next to the label in the sidebar - `isBackgroundTransparent` — when `true`, renders the page with a transparent background and white text as a default (good for separate components on the same page) - `classNameContainer` — additional CSS classes for the page container +- `showPageTitle` — whether this route's label appears in the header (defaults to `true`) + +Route paths must be static. The sidebar links to each `path` directly, so a dynamic segment such as `/runs/:uid` would render a link to that literal text. + +The header shows the active route's label after the app title, on every route. Pass `showPageTitle` to the layout to turn that off everywhere, and set `showPageTitle` on a route to override the layout either way. That is how you hide the label on a landing page whose app title already names it. + +### Page tabs + +A route can declare `tabs` instead of `element`. Finch draws a tab strip above the page and gives each tab its own URL beneath the route path, so tabs are deep-linkable and survive a refresh. Visiting the bare route path redirects to the first tab. + +```tsx +const routes: RouteItem[] = [ + { + path: '/explorer', + label: 'Explorer', + icon: , + tabs: [ + { path: 'live', label: 'Live', element: }, // -> /explorer/live + { path: 'replay', label: 'Replay', element: }, // -> /explorer/replay + ], + }, +]; +``` + +Each tab takes `path`, `label`, and `element`, plus optional `isBackgroundTransparent` and `classNameContainer`. Both fall back to the parent route's values, and `classNameContainer` is merged on top of the route's rather than replacing it. + +A leading slash on a tab `path` is optional and ignored, so `'live'` and `'/live'` both land on `/explorer/live`. Trailing slashes are ignored too, repeated slashes collapse, and the route's own `path` works the same way. Case is ignored when matching, so someone who types `/Explorer/Live` lands on that same page. + +The root route can declare tabs, and they sit under a reserved `-` segment: + +```tsx +const routes: RouteItem[] = [ + { + path: '/', + label: 'Home', + tabs: [ + { path: 'live', label: 'Live', element: }, // -> /-/live + { path: 'replay', label: 'Replay', element: }, // -> /-/replay + ], + }, +]; +``` + +Visiting `/` redirects to `/-/live`. The `-` keeps the root's tabs out of the top level, where `/live` would shadow a real `/live` route and leave the sidebar with no entry highlighted. It also keeps the tab catch-all scoped, so an unknown `/-/…` URL falls back to the first tab while the rest of your URLs are untouched. Leave `-` to the root's tabs. + +Finch rejects a couple of configurations with an error rather than failing quietly. + +A tab `path` cannot be empty or a bare `/`. Every tab needs its own segment beneath the route, so give it something like `path: 'overview'`. + +No two pages can land on the same URL. Routes and tabs share one URL space, so a route at `/explorer/live` collides with the Live tab of `/explorer` exactly as two `live` tabs collide with each other. Because slashes are trimmed and case is ignored, `'live'`, `'/live'` and `'Live'` all mean `/explorer/live`, so this is an error: + +```tsx +tabs: [ + { path: 'live', label: 'Live', element: }, + { path: '/live', label: 'Replay', element: }, // error: same URL as Live +]; +``` + +Without the check the Replay tab would be dead weight: both tabs would link to `/explorer/live`, both would draw as active, and only `` would ever render. ## Alternative Installation - Clone This Repo diff --git a/src/app/App.tsx b/src/app/App.tsx index 8f226cdb..a0e1af67 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -7,7 +7,7 @@ import AllComponentsPage from './pages/AllComponentsPage'; import TestPage from './pages/TestPage'; import Documentation from './pages/Documentation'; -import FinchAppLayout from '@/components/FinchAppLayout'; +import FinchAppLayout from '@/components/FinchAppLayout/FinchAppLayout'; import { RouteItem } from '@/types/navigationRouterTypes'; diff --git a/src/components/FinchAppLayout.tsx b/src/components/FinchAppLayout/FinchAppLayout.tsx similarity index 63% rename from src/components/FinchAppLayout.tsx rename to src/components/FinchAppLayout/FinchAppLayout.tsx index 147fa9b9..65000883 100644 --- a/src/components/FinchAppLayout.tsx +++ b/src/components/FinchAppLayout/FinchAppLayout.tsx @@ -1,6 +1,7 @@ -import FinchHeader from '@/components/FinchHeader'; -import FinchMainContent from '@/components/FinchMainContent'; -import FinchSidebar from '@/components/FinchSidebar'; +import FinchHeader from './FinchHeader'; +import FinchMainContent from './FinchMainContent'; +import FinchSidebar from './FinchSidebar'; +import { useActiveRoute } from './hooks/useActiveRoute'; import { cn } from '@/lib/utils'; import { RouteItem } from '@/types/navigationRouterTypes'; @@ -10,8 +11,15 @@ export type FinchAppLayoutProps = { routes: RouteItem[]; /** Title text displayed in the header. */ headerTitle?: string; + /** + * Whether the active route's label appears in the header, for every route. + * A route's own `showPageTitle` overrides this. Defaults to `true`. + */ + showPageTitle?: boolean; /** Additional CSS classes applied to the header title element. */ classNameHeaderTitle?: string; + /** Additional CSS classes applied to the header page title element. */ + classNameHeaderPageTitle?: string; /** URL of the logo image displayed in the header. Ignored when `headerLogoIcon` is provided. */ headerLogoUrl?: string; /** @@ -21,8 +29,16 @@ export type FinchAppLayoutProps = { headerLogoIcon?: React.ReactElement; /** Additional CSS classes applied to the outer main content area. */ classNameMainContent?: string; + /** Additional CSS classes applied to the scrolling main content area that holds the page padding. */ + classNameMainContentScrollContainer?: string; /** Additional CSS classes applied to the inner main content area. */ classNameMainContentInnerContainer?: string; + /** Additional CSS classes applied to the page tab strip. */ + classNamePageTabs?: string; + /** Additional CSS classes applied to the active page tab. */ + classNamePageTabsActive?: string; + /** Additional CSS classes applied to inactive page tabs. */ + classNamePageTabsInactive?: string; /** Additional CSS classes applied to the header element. */ classNameHeader?: string; /** Additional CSS classes applied to the sidebar element. */ @@ -39,12 +55,18 @@ export type FinchAppLayoutProps = { export default function FinchAppLayout({ routes, headerTitle, + showPageTitle, headerLogoUrl, headerLogoIcon, classNameMainContent, + classNameMainContentScrollContainer, classNameMainContentInnerContainer, + classNamePageTabs, + classNamePageTabsActive, + classNamePageTabsInactive, classNameHeader, classNameHeaderTitle, + classNameHeaderPageTitle, classNameSidebar, classNameSidebarActiveLink, classNameSidebarInactiveLink, @@ -52,6 +74,10 @@ export default function FinchAppLayout({ className, ...props }: FinchAppLayoutProps) { + const activeRoute = useActiveRoute(routes); + const isPageTitleShown = activeRoute?.showPageTitle ?? showPageTitle ?? true; + const pageTitle = isPageTitleShown ? activeRoute?.label : undefined; + return (
); diff --git a/src/components/FinchHeader.tsx b/src/components/FinchAppLayout/FinchHeader.tsx similarity index 72% rename from src/components/FinchHeader.tsx rename to src/components/FinchAppLayout/FinchHeader.tsx index a058d5c8..d0dce3e2 100644 --- a/src/components/FinchHeader.tsx +++ b/src/components/FinchAppLayout/FinchHeader.tsx @@ -3,6 +3,8 @@ import { cn } from '@/lib/utils'; export type FinchHeaderProps = { /** Title text displayed in the header. */ title?: string; + /** Name of the active page, rendered after the title and a divider. */ + pageTitle?: string; /** URL of the logo image displayed in the header. Ignored when `logoIcon` is provided. */ logoUrl?: string; /** @@ -16,6 +18,8 @@ export type FinchHeaderProps = { classNameImage?: string; /** Additional CSS classes applied to the title element. */ classNameTitle?: string; + /** Additional CSS classes applied to the page title element. */ + classNamePageTitle?: string; /** Arbitrary JSX rendered on the right side of the header. */ rightSlot?: React.ReactNode; }; @@ -28,11 +32,13 @@ export type FinchHeaderProps = { */ export default function FinchHeader({ title = 'BEAMLINE APP', + pageTitle, logoUrl = 'https://img.icons8.com/?size=100&id=11743&format=png&color=000000', logoIcon, className, classNameImage, classNameTitle, + classNamePageTitle, rightSlot, ...props }: FinchHeaderProps) { @@ -50,6 +56,19 @@ export default function FinchHeader({

{title}

+ {pageTitle && ( + <> + + + {pageTitle} + + + )} {rightSlot} diff --git a/src/components/FinchAppLayout/FinchMainContent.tsx b/src/components/FinchAppLayout/FinchMainContent.tsx new file mode 100644 index 00000000..8f07bafd --- /dev/null +++ b/src/components/FinchAppLayout/FinchMainContent.tsx @@ -0,0 +1,77 @@ +import { useRoutes } from 'react-router'; +import FinchPageTabs from './FinchPageTabs'; +import { useActiveRoute } from './hooks/useActiveRoute'; +import { buildPageRoutes } from './utils/pageRoutes'; +import { cn } from '@/lib/utils'; + +import { RouteItem, RouteTab } from '@/types/navigationRouterTypes'; + +export type FinchMainContentProps = { + /** Route definitions used to render the matched page component via React Router. */ + routes: RouteItem[]; + /** Additional CSS classes applied to the main outer element. */ + className?: string; + /** Additional CSS classes applied to the scrolling element that holds the page padding. */ + classNameScrollContainer?: string; + /** Additional CSS classes applied to the inner element directly rendering the route element. */ + classNameInnerContainer?: string; + /** Additional CSS classes applied to the page tab strip. */ + classNamePageTabs?: string; + /** Additional CSS classes applied to the active page tab. */ + classNamePageTabsActive?: string; + /** Additional CSS classes applied to inactive page tabs. */ + classNamePageTabsInactive?: string; +}; +export default function FinchMainContent({ + routes, + className, + classNameScrollContainer, + classNameInnerContainer, + classNamePageTabs, + classNamePageTabsActive, + classNamePageTabsInactive, + ...props +}: FinchMainContentProps) { + const activeRoute = useActiveRoute(routes); + + const page = (route: RouteItem, tab?: RouteTab) => { + const item = tab ?? route; + const isBackgroundTransparent = + tab?.isBackgroundTransparent ?? route.isBackgroundTransparent; + return ( +
+ {item.element} +
+ ); + }; + + const pages = useRoutes(buildPageRoutes(routes, page)); + + return ( +
+ {activeRoute?.tabs?.length ? ( + + ) : null} +
+ {pages} +
+
+ ); +} diff --git a/src/components/FinchAppLayout/FinchPageTabs.tsx b/src/components/FinchAppLayout/FinchPageTabs.tsx new file mode 100644 index 00000000..d4345c0a --- /dev/null +++ b/src/components/FinchAppLayout/FinchPageTabs.tsx @@ -0,0 +1,59 @@ +import { NavLink } from 'react-router'; +import { toTabPath } from './utils/pageRoutes'; +import { cn } from '@/lib/utils'; + +import { RouteTab } from '@/types/navigationRouterTypes'; + +export type FinchPageTabsProps = { + /** Path of the route these tabs belong to, used to build each tab link. */ + basePath: string; + /** Tab definitions rendered as links, in order. */ + tabs: Pick[]; + /** Additional CSS classes applied to the root nav element. */ + className?: string; + /** Additional CSS classes applied to the active tab link. */ + classNameActiveTab?: string; + /** Additional CSS classes applied to inactive tab links. */ + classNameInactiveTab?: string; +}; + +const tabStyles = 'self-end px-4 py-3 text-sm font-medium border-b-2 transition-colors'; + +/** Strip of tab links rendered above a page whose route declares `tabs`. */ +export default function FinchPageTabs({ + basePath, + tabs, + className, + classNameActiveTab, + classNameInactiveTab, + ...props +}: FinchPageTabsProps) { + return ( + + ); +} diff --git a/src/components/FinchAppLayout/FinchSidebar.tsx b/src/components/FinchAppLayout/FinchSidebar.tsx new file mode 100644 index 00000000..a7db94a6 --- /dev/null +++ b/src/components/FinchAppLayout/FinchSidebar.tsx @@ -0,0 +1,57 @@ +import { Link } from 'react-router'; +import { useActiveRoute } from './hooks/useActiveRoute'; +import { toRoutePath } from './utils/pageRoutes'; +import { cn } from '@/lib/utils'; + +import { RouteItem } from '@/types/navigationRouterTypes'; + +export type FinchSidebarProps = { + /** Route definitions used to render the sidebar navigation links. */ + routes: RouteItem[]; + /** Additional CSS classes applied to the root aside element. */ + className?: string; + /** Additional CSS classes applied to the active navigation link. */ + classNameActiveLink?: string; + /** Additional CSS classes applied to inactive navigation links. */ + classNameInactiveLink?: string; +}; +export default function FinchSidebar({ + routes, + className, + classNameActiveLink, + classNameInactiveLink, + ...props +}: FinchSidebarProps) { + const activeRoute = useActiveRoute(routes); + const navStyles = cn( + 'flex flex-col items-center justify-center h-20 aspect-square rounded-lg text-white hover:bg-sky-800 cursor-pointer', + classNameInactiveLink, + ); + return ( + + ); +} diff --git a/src/components/FinchAppLayout/hooks/useActiveRoute.ts b/src/components/FinchAppLayout/hooks/useActiveRoute.ts new file mode 100644 index 00000000..619cebc0 --- /dev/null +++ b/src/components/FinchAppLayout/hooks/useActiveRoute.ts @@ -0,0 +1,18 @@ +import { useMemo } from 'react'; +import { matchRoutes, useLocation } from 'react-router'; + +import { buildPageRoutes } from '../utils/pageRoutes'; +import { RouteItem } from '@/types/navigationRouterTypes'; + +/** + * Returns the route matching the current location, if any. + * + * Ranking runs against the same config `FinchMainContent` renders, so the answer + * always agrees with the page on screen. The config is rebuilt only when `routes` + * changes, since three components call this hook on every navigation. + */ +export function useActiveRoute(routes: RouteItem[]) { + const location = useLocation(); + const pageRoutes = useMemo(() => buildPageRoutes(routes), [routes]); + return matchRoutes(pageRoutes, location)?.[0]?.route.handle as RouteItem | undefined; +} diff --git a/src/components/FinchAppLayout/utils/pageRoutes.tsx b/src/components/FinchAppLayout/utils/pageRoutes.tsx new file mode 100644 index 00000000..63a0fd39 --- /dev/null +++ b/src/components/FinchAppLayout/utils/pageRoutes.tsx @@ -0,0 +1,169 @@ +import { Navigate } from 'react-router'; + +import type { RouteObject } from 'react-router'; +import { RouteItem, RouteTab } from '@/types/navigationRouterTypes'; + +/** Renders the page body for a route, or for one of its tabs. */ +type PageRenderer = (route: RouteItem, tab?: RouteTab) => React.ReactNode; + +function trimSlashes(path: string) { + return path.replace(/\/+/g, '/').replace(/^\/|\/$/g, ''); +} + +/** + * Normalizes a route path to one leading slash and no trailing one. + * + * `"data"`, `"/data"` and `"/data/"` all become `"/data"`, and repeated slashes + * collapse, so a route behaves the same however its path was written. The root path + * stays `"/"`. + */ +export function toRoutePath(path: string) { + return `/${trimSlashes(path)}`; +} + +/** + * Prefix that the root route's tabs sit behind. + * + * The root has no segment of its own, so its tabs hide behind a reserved `-` rather + * than taking top-level urls, where they would shadow other routes. + */ +function toTabPrefix(routePath: string) { + return routePath === '/' ? '-/' : ''; +} + +/** The path a tab registers beneath its route. */ +function toTabChildPath(tab: Pick, routePath: string) { + const segment = trimSlashes(tab.path); + if (!segment) { + throw new Error( + `Tab "${tab.label}" needs a path segment beneath "${routePath}", such as "overview".`, + ); + } + return `${toTabPrefix(routePath)}${segment}`; +} + +/** The catch-all a tabbed route registers for urls none of its tabs claim. */ +function toTabFallbackPath(routePath: string) { + return `${toTabPrefix(routePath)}*`; +} + +/** Joins a path a route registers beneath itself onto the route's own path. */ +function toChildUrl(routePath: string, childPath: string) { + return toRoutePath(`${routePath}/${childPath}`); +} + +/** + * Builds the url of a tab beneath its route, ignoring stray slashes on either path. + * + * The tab link, the redirect and the registered route all join the same + * `toTabChildPath`, so they cannot point at different urls. + */ +export function toTabPath({ + basePath, + tab, +}: { + basePath: string; + tab: Pick; +}) { + const routePath = toRoutePath(basePath); + return toChildUrl(routePath, toTabChildPath(tab, routePath)); +} + +/** A url a route config occupies, and how to name it in an error. */ +type Page = { url: string; name: string }; + +/** + * The first page that lands on a url an earlier page already took. + * + * Keys ignore case because React Router does: `/data` and `/Data` are one url, and only + * the page declared first would ever render. + */ +function findCollision(pages: Page[]) { + const seen = new Map(); + for (const page of pages) { + const key = page.url.toLowerCase(); + const earlier = seen.get(key); + if (earlier) { + return { earlier, later: page }; + } + seen.set(key, page); + } +} + +/** + * Every url a route occupies: its own path, one per tab, and the tab fallback. + * + * Routes and tabs share one url space, so they are collected together and checked + * against each other. + */ +function toPages(route: RouteItem): Page[] { + const path = toRoutePath(route.path); + const page = { url: path, name: `the route "${route.label}"` }; + if (!route.tabs?.length) { + return [page]; + } + return [ + page, + ...route.tabs.map((tab) => ({ + url: toTabPath({ basePath: path, tab }), + name: `the "${tab.label}" tab of "${path}"`, + })), + { + url: toChildUrl(path, toTabFallbackPath(path)), + name: `the fallback of "${path}"`, + }, + ]; +} + +/** + * Builds the React Router config for a set of Finch routes. + * + * A route with `tabs` becomes a parent route whose children are the tabs, plus an + * index and a catch-all that both redirect to the first tab. `useRoutes` renders + * this config and `matchRoutes` ranks against it, so the tab strip and the page on + * screen can never disagree about which route is active. + * + * Throws when a tab path holds no segment, and when two pages resolve to the same + * url. Urls that differ only by case are the same url, since routes match without case. + * + * Omit `renderPage` to build the config for matching only. + */ +export function buildPageRoutes(routes: RouteItem[], renderPage?: PageRenderer): RouteObject[] { + const collision = findCollision(routes.flatMap(toPages)); + if (collision) { + const { earlier, later } = collision; + const caseNote = + earlier.url === later.url + ? '' + : ` Urls ignore case, so "${later.url}" is the same url.`; + throw new Error( + `Two pages both resolve to "${earlier.url}": ${earlier.name} and ${later.name}.` + + `${caseNote} Give each its own path.`, + ); + } + + return routes.map((route) => { + const path = toRoutePath(route.path); + const tabs = route.tabs; + if (!tabs?.length) { + return { path, handle: route, element: renderPage?.(route) }; + } + + const redirectToFirstTab = ( + + ); + + return { + path, + handle: route, + children: [ + { index: true, element: redirectToFirstTab }, + ...tabs.map((tab) => ({ + path: toTabChildPath(tab, path), + element: renderPage?.(route, tab), + })), + { path: toTabFallbackPath(path), element: redirectToFirstTab }, + ], + }; + }); +} diff --git a/src/components/FinchMainContent.tsx b/src/components/FinchMainContent.tsx deleted file mode 100644 index 7d431105..00000000 --- a/src/components/FinchMainContent.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Routes, Route } from 'react-router'; -import { cn } from '@/lib/utils'; - -import { RouteItem } from '@/types/navigationRouterTypes'; - -export type FinchMainContentProps = { - /** Route definitions used to render the matched page component via React Router. */ - routes: RouteItem[]; - /** Additional CSS classes applied to the main outer element. */ - className?: string; - /** Additional CSS classes applied to the inner element directly rendering the route element. */ - classNameInnerContainer?: string; -}; -export default function FinchMainContent({ - routes, - className, - classNameInnerContainer, - ...props -}: FinchMainContentProps) { - return ( -
- - {routes.map((route) => ( - - {route.element} - - } - /> - ))} - -
- ); -} diff --git a/src/components/FinchSidebar.tsx b/src/components/FinchSidebar.tsx deleted file mode 100644 index e2c3fed9..00000000 --- a/src/components/FinchSidebar.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { NavLink } from 'react-router'; -import { cn } from '@/lib/utils'; - -import { RouteItem } from '@/types/navigationRouterTypes'; - -export type FinchSidebarProps = { - /** Route definitions used to render the sidebar navigation links. */ - routes: RouteItem[]; - /** Additional CSS classes applied to the root aside element. */ - className?: string; - /** Additional CSS classes applied to the active navigation link. */ - classNameActiveLink?: string; - /** Additional CSS classes applied to inactive navigation links. */ - classNameInactiveLink?: string; -}; -export default function FinchSidebar({ - routes, - className, - classNameActiveLink, - classNameInactiveLink, - ...props -}: FinchSidebarProps) { - const navStyles = cn( - 'flex flex-col items-center justify-center h-20 aspect-square rounded-lg text-white hover:bg-sky-800 cursor-pointer', - classNameInactiveLink, - ); - return ( - - ); -} diff --git a/src/index.ts b/src/index.ts index 85224410..8e87723f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,30 +28,33 @@ export type { TiledHeatmapSelectorProps } from './features/TiledHeatmapSelector' export { default as Beamstop } from './features/Beamstop'; export type { BeamstopProps } from './features/Beamstop'; -export { default as FinchAppLayout } from './components/FinchAppLayout'; -export type { FinchAppLayoutProps } from './components/FinchAppLayout'; +export { default as FinchAppLayout } from './components/FinchAppLayout/FinchAppLayout'; +export type { FinchAppLayoutProps } from './components/FinchAppLayout/FinchAppLayout'; -export { default as FinchHeader } from './components/FinchHeader'; -export type { FinchHeaderProps } from './components/FinchHeader'; +export { default as FinchHeader } from './components/FinchAppLayout/FinchHeader'; +export type { FinchHeaderProps } from './components/FinchAppLayout/FinchHeader'; -export { default as FinchMainContent } from './components/FinchMainContent'; -export type { FinchMainContentProps } from './components/FinchMainContent'; +export { default as FinchMainContent } from './components/FinchAppLayout/FinchMainContent'; +export type { FinchMainContentProps } from './components/FinchAppLayout/FinchMainContent'; -export { default as FinchSidebar } from './components/FinchSidebar'; -export type { FinchSidebarProps } from './components/FinchSidebar'; +export { default as FinchPageTabs } from './components/FinchAppLayout/FinchPageTabs'; +export type { FinchPageTabsProps } from './components/FinchAppLayout/FinchPageTabs'; + +export { default as FinchSidebar } from './components/FinchAppLayout/FinchSidebar'; +export type { FinchSidebarProps } from './components/FinchAppLayout/FinchSidebar'; // @deprecated — use Finch* equivalents -export { default as HubAppLayout } from './components/FinchAppLayout'; -export type { FinchAppLayoutProps as HubAppLayoutProps } from './components/FinchAppLayout'; +export { default as HubAppLayout } from './components/FinchAppLayout/FinchAppLayout'; +export type { FinchAppLayoutProps as HubAppLayoutProps } from './components/FinchAppLayout/FinchAppLayout'; -export { default as HubHeader } from './components/FinchHeader'; -export type { FinchHeaderProps as HubHeaderProps } from './components/FinchHeader'; +export { default as HubHeader } from './components/FinchAppLayout/FinchHeader'; +export type { FinchHeaderProps as HubHeaderProps } from './components/FinchAppLayout/FinchHeader'; -export { default as HubMainContent } from './components/FinchMainContent'; -export type { FinchMainContentProps as HubMainContentProps } from './components/FinchMainContent'; +export { default as HubMainContent } from './components/FinchAppLayout/FinchMainContent'; +export type { FinchMainContentProps as HubMainContentProps } from './components/FinchAppLayout/FinchMainContent'; -export { default as HubSidebar } from './components/FinchSidebar'; -export type { FinchSidebarProps as HubSidebarProps } from './components/FinchSidebar'; +export { default as HubSidebar } from './components/FinchAppLayout/FinchSidebar'; +export type { FinchSidebarProps as HubSidebarProps } from './components/FinchAppLayout/FinchSidebar'; export { default as ContainerQServer } from './components/QServer/ContainerQServer'; export type { ContainerQServerProps } from './components/QServer/ContainerQServer'; @@ -305,7 +308,7 @@ export type { } from './api/qServer/types'; //TYPES -export type { RouteItem } from './types/navigationRouterTypes'; +export type { RouteItem, RouteTab } from './types/navigationRouterTypes'; export type { Device, Devices } from './types/deviceControllerTypes'; //CONTEXT PROVIDERS diff --git a/src/stories/FinchAppLayout.stories.tsx b/src/stories/FinchAppLayout.stories.tsx index 08c0fb94..af1c0a5c 100644 --- a/src/stories/FinchAppLayout.stories.tsx +++ b/src/stories/FinchAppLayout.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react'; -import FinchAppLayout from '@/components/FinchAppLayout'; +import FinchAppLayout from '@/components/FinchAppLayout/FinchAppLayout'; import Paper from '@/components/Paper'; -import { House, Joystick, StackPlus, ImageSquare } from '@phosphor-icons/react'; +import { House, Joystick, StackPlus, ImageSquare, ChartScatter } from '@phosphor-icons/react'; import { RouteItem } from '@/types/navigationRouterTypes'; import { MemoryRouter } from 'react-router'; @@ -13,10 +13,22 @@ import { MemoryRouter } from 'react-router'; * Each RouteItem defines a navigation tab/page with the following structure: * * RouteItem { - * element: React.ReactNode - The component/content to render when this route is active - * path: string - The URL path for this route (e.g., "/", "/control", "/data") - * label: string - The display text shown in the sidebar navigation tab - * icon: React.ReactNode - The icon displayed next to the label in the sidebar + * path: string - The URL path for this route (e.g., "/", "/control"), static only + * label: string - The display text shown in the sidebar navigation tab + * element?: React.ReactNode - The component/content to render when this route is active + * tabs?: RouteTab[] - Tabs shown in a strip above the page, each with its own URL + * icon?: React.ReactNode - The icon displayed next to the label in the sidebar + * isBackgroundTransparent?: boolean - Renders the page transparent with white text (default: false) + * classNameContainer?: string - Additional CSS classes for the page container + * showPageTitle?: boolean - Shows this route's label in the header (default: true) + * } + * + * A route declares either `element` or `tabs`, never both. + * + * RouteTab { + * path: string - Path segment under the route path (e.g. "live" under "/explorer") + * label: string - The display text shown on the tab + * element: React.ReactNode - The component/content to render when this tab is active * } * * Example: @@ -32,6 +44,15 @@ import { MemoryRouter } from 'react-router'; * path: "/control", * label: "Control", * icon: + * }, + * { + * path: "/explorer", + * label: "Explorer", + * icon: , + * tabs: [ + * { element: , path: "live", label: "Live" }, + * { element: , path: "replay", label: "Replay" } + * ] * } * ]; * @@ -39,6 +60,8 @@ import { MemoryRouter } from 'react-router'; * 1. Create sidebar navigation tabs based on the label and icon * 2. Handle routing between different paths * 3. Render the corresponding element when a route is selected + * 4. Draw a tab strip above the page for any route that declares tabs + * 5. Show the active route label in the header, after the app title */ const Page1 = () => { @@ -85,6 +108,34 @@ const routes: RouteItem[] = [ { element: , path: '/data', label: 'Data', icon: }, ]; +type ExplorerTabProps = { + name: string; +}; + +const ExplorerTab = ({ name }: ExplorerTabProps) => { + return ( + +

{name}

+

Each tab has its own URL under /explorer.

+
+ ); +}; + +const routesWithTabs: RouteItem[] = [ + ...routes, + { + path: '/explorer', + label: 'Explorer', + icon: , + tabs: [ + { element: , path: 'live', label: 'Live' }, + { element: , path: 'explore', label: 'Explore' }, + { element: , path: 'replay', label: 'Replay' }, + { element: , path: 'run', label: 'Run' }, + ], + }, +]; + const meta = { title: 'Layout Components/FinchAppLayout', component: FinchAppLayout, @@ -101,15 +152,53 @@ Each RouteItem defines a navigation tab/page with the following structure: \`\`\`typescript RouteItem { - element: React.ReactNode // The component/content to render when this route is active - path: string // The URL path for this route (e.g., "/", "/control", "/data") + element?: React.ReactNode // The component/content to render when this route is active + path: string // The URL path, static only (e.g., "/", "/control", "/data") label: string // The display text shown in the sidebar navigation tab icon: React.ReactNode // The icon displayed next to the label in the sidebar + tabs?: RouteTab[] // Tabs shown in a strip above the page (omit \`element\` when set) isBackgroundTransparent?: boolean // If true, page background is transparent (default: false) classNameContainer?: string // Additional CSS classes applied to the page container + showPageTitle?: boolean // Whether the header shows this route's label (default: true) +} +\`\`\` + +A route declares either \`element\` or \`tabs\`, never both. + +Route paths must be static. The sidebar links to each \`path\` directly, so a dynamic +segment such as \`/runs/:uid\` would render a link to that literal text. + +### Page Tabs + +A route can declare \`tabs\` instead of an \`element\`. Finch then draws a tab strip +above the page and gives each tab its own URL beneath the route path, so tabs are +deep-linkable and survive a refresh. Visiting the bare route path redirects to the +first tab. + +\`\`\`typescript +RouteTab { + element: React.ReactNode // The component/content to render when this tab is active + path: string // Path segment under the route path (e.g. "live" under "/explorer") + label: string // The display text shown on the tab + isBackgroundTransparent?: boolean // If true, this tab's background is transparent + classNameContainer?: string // Additional CSS classes applied to this tab's container } \`\`\` +\`\`\`typescript +const routes: RouteItem[] = [ + { + path: '/explorer', + label: 'Explorer', + icon: , + tabs: [ + { element: , path: 'live', label: 'Live' }, // -> /explorer/live + { element: , path: 'replay', label: 'Replay' }, // -> /explorer/replay + ], + }, +]; +\`\`\` + ### Basic Example: \`\`\`typescript const routes: RouteItem[] = [ @@ -130,7 +219,7 @@ const routes: RouteItem[] = [ ### Full App Example (from App.tsx): \`\`\`typescript -import FinchAppLayout from '@/components/FinchAppLayout'; +import FinchAppLayout from '@/components/FinchAppLayout/FinchAppLayout'; import { RouteItem } from '@/types/navigationRouterTypes'; import { House, Table, TestTube, Question } from '@phosphor-icons/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -189,6 +278,8 @@ function App() { 2. **Handles routing** between different paths using React Router 3. **Renders the corresponding element** when a route is selected 4. **Applies per-route styling** via \`isBackgroundTransparent\` or \`classNameContainer\` +5. **Draws a tab strip** above the page for any route that declares \`tabs\` +6. **Shows the active route label** in the header, after the app title (set \`showPageTitle\` on the layout to turn it off everywhere, or on a route to override that) The component uses React Router internally to manage navigation between different pages/views. `, @@ -196,8 +287,8 @@ The component uses React Router internally to manage navigation between differen }, }, decorators: [ - (Story, { args }) => ( - + (Story, { args, parameters }) => ( + ), @@ -223,6 +314,25 @@ export const CustomTitle: Story = { }, }; +export const WithPageTabs: Story = { + parameters: { initialPath: '/explorer' }, + args: { + routes: routesWithTabs, + headerTitle: 'Finch Dev Mode', + className: 'w-full h-full', + }, +}; + +export const WithoutPageTitle: Story = { + parameters: { initialPath: '/control' }, + args: { + routes: routes, + headerTitle: 'Finch Dev Mode', + showPageTitle: false, + className: 'w-full h-full', + }, +}; + export const CustomClasses: Story = { args: { routes: routes, @@ -232,6 +342,7 @@ export const CustomClasses: Story = { classNameSidebarActiveLink: 'bg-red-500', classNameSidebarInactiveLink: 'text-red-500 hover:bg-purple-300 hover:text-slate-900', classNameMainContent: 'bg-red-300', + classNameMainContentScrollContainer: 'p-2', classNameHeader: 'bg-red-200', classNameHeaderTitle: 'text-red-900', className: 'w-full h-full', diff --git a/src/testing/tests/FinchAppLayout.test.tsx b/src/testing/tests/FinchAppLayout.test.tsx index fe77f536..d32227b5 100644 --- a/src/testing/tests/FinchAppLayout.test.tsx +++ b/src/testing/tests/FinchAppLayout.test.tsx @@ -1,7 +1,9 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { MemoryRouter } from 'react-router'; -import FinchAppLayout from '../../components/FinchAppLayout'; +import FinchAppLayout, { + FinchAppLayoutProps, +} from '../../components/FinchAppLayout/FinchAppLayout'; import { RouteItem } from '../../types/navigationRouterTypes'; const mockRoutes: RouteItem[] = [ @@ -9,10 +11,12 @@ const mockRoutes: RouteItem[] = [ { path: '/settings', label: 'Settings', element:
Settings Page
}, ]; -function renderLayout(props = {}) { +type RenderLayoutOptions = Partial & { path?: string }; + +function renderLayout({ routes = mockRoutes, path = '/home', ...props }: RenderLayoutOptions = {}) { return render( - - + + , ); } @@ -24,9 +28,10 @@ describe('FinchAppLayout Component', () => { }); it('renders sidebar navigation links for each route', () => { - renderLayout(); - expect(screen.getByText('Home')).toBeInTheDocument(); - expect(screen.getByText('Settings')).toBeInTheDocument(); + const { container } = renderLayout(); + const sidebar = within(container.querySelector('aside') as HTMLElement); + expect(sidebar.getByText('Home')).toBeInTheDocument(); + expect(sidebar.getByText('Settings')).toBeInTheDocument(); }); it('renders the default header title', () => { @@ -76,19 +81,86 @@ describe('FinchAppLayout Component', () => { expect(container.firstChild).toHaveClass('my-root-class'); }); + it('renders the active route label as the header page title', () => { + const { container } = renderLayout(); + expect(container.querySelector('header')).toHaveTextContent('Home'); + }); + + it('renders no header page title for a route that opts out', () => { + const optedOut: RouteItem[] = [ + { path: '/home', label: 'Home', element:
, showPageTitle: false }, + ]; + const { container } = renderLayout({ routes: optedOut }); + expect(container.querySelector('header')).not.toHaveTextContent('Home'); + }); + + it('renders the header page title at the root route like any other route', () => { + const rootRoutes: RouteItem[] = [{ path: '/', label: 'Overview', element:
}]; + const { container } = renderLayout({ routes: rootRoutes, path: '/' }); + expect(container.querySelector('header')).toHaveTextContent('Overview'); + }); + + it('renders no header page title when the layout opts out', () => { + const { container } = renderLayout({ showPageTitle: false }); + expect(container.querySelector('header')).not.toHaveTextContent('Home'); + }); + + it('renders the header page title for a route that opts in while the layout opts out', () => { + const optedIn: RouteItem[] = [ + { path: '/home', label: 'Home', element:
, showPageTitle: true }, + ]; + const { container } = renderLayout({ routes: optedIn, showPageTitle: false }); + expect(container.querySelector('header')).toHaveTextContent('Home'); + }); + + it('applies classNameHeaderPageTitle to the header page title', () => { + renderLayout({ classNameHeaderPageTitle: 'text-red-500' }); + expect(screen.getByText('Home', { selector: 'header span' })).toHaveClass('text-red-500'); + }); + + it('keeps a tabbed route active when a dynamic sibling could also match', () => { + const withDynamicSibling: RouteItem[] = [ + { + path: '/data', + label: 'Data', + tabs: [{ path: 'details', label: 'Details', element:
Details Tab
}], + }, + { path: '/data/:id', label: 'Data Item', element:
Item Page
}, + ]; + const { container } = renderLayout({ + routes: withDynamicSibling, + path: '/data/details', + }); + expect(screen.getByText('Data', { selector: 'header span' })).toBeInTheDocument(); + expect(container.querySelector('nav')).toBeInTheDocument(); + }); + it('renders sidebar links for all routes', () => { const moreRoutes: RouteItem[] = [ { path: '/a', label: 'Alpha', element:
}, { path: '/b', label: 'Beta', element:
}, { path: '/c', label: 'Gamma', element:
}, ]; - render( - - - , - ); - expect(screen.getByText('Alpha')).toBeInTheDocument(); - expect(screen.getByText('Beta')).toBeInTheDocument(); - expect(screen.getByText('Gamma')).toBeInTheDocument(); + const { container } = renderLayout({ routes: moreRoutes, path: '/a' }); + const sidebar = within(container.querySelector('aside') as HTMLElement); + expect(sidebar.getByText('Alpha')).toBeInTheDocument(); + expect(sidebar.getByText('Beta')).toBeInTheDocument(); + expect(sidebar.getByText('Gamma')).toBeInTheDocument(); + }); + + it('applies classNamePageTabs to the tab strip', () => { + const tabbedRoutes: RouteItem[] = [ + { + path: '/explorer', + label: 'Explorer', + tabs: [{ path: 'live', label: 'Live', element:
Live Tab
}], + }, + ]; + const { container } = renderLayout({ + routes: tabbedRoutes, + path: '/explorer/live', + classNamePageTabs: 'my-tabs-class', + }); + expect(container.querySelector('nav')).toHaveClass('my-tabs-class'); }); }); diff --git a/src/testing/tests/FinchHeader.test.tsx b/src/testing/tests/FinchHeader.test.tsx index f1712f71..9c41898b 100644 --- a/src/testing/tests/FinchHeader.test.tsx +++ b/src/testing/tests/FinchHeader.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; -import FinchHeader from '../../components/FinchHeader'; +import FinchHeader from '../../components/FinchAppLayout/FinchHeader'; describe('FinchHeader Component', () => { it('renders without crashing', () => { @@ -57,4 +57,21 @@ describe('FinchHeader Component', () => { const { container } = render(); expect(container.querySelector('img')).toHaveClass('rounded-full'); }); + + it('renders the page title after the title', () => { + render(); + const title = screen.getByText('My Beamline'); + const pageTitle = screen.getByText('Explorer'); + expect(title.compareDocumentPosition(pageTitle)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + }); + + it('renders no page title when pageTitle is not provided', () => { + render(); + expect(screen.queryByText('Explorer')).not.toBeInTheDocument(); + }); + + it('applies classNamePageTitle to the page title element', () => { + render(); + expect(screen.getByText('Explorer')).toHaveClass('text-red-500'); + }); }); diff --git a/src/testing/tests/FinchMainContent.test.tsx b/src/testing/tests/FinchMainContent.test.tsx index c9f09497..e82104ff 100644 --- a/src/testing/tests/FinchMainContent.test.tsx +++ b/src/testing/tests/FinchMainContent.test.tsx @@ -1,7 +1,9 @@ import { render, screen } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { MemoryRouter } from 'react-router'; -import FinchMainContent from '../../components/FinchMainContent'; +import FinchMainContent, { + FinchMainContentProps, +} from '../../components/FinchAppLayout/FinchMainContent'; import { RouteItem } from '../../types/navigationRouterTypes'; const mockRoutes: RouteItem[] = [ @@ -9,10 +11,39 @@ const mockRoutes: RouteItem[] = [ { path: '/settings', label: 'Settings', element:
Settings Page
}, ]; -function renderContent(initialPath = '/home', props = {}) { +const tabbedRoutes: RouteItem[] = [ + { path: '/home', label: 'Home', element:
Home Page
}, + { + path: '/explorer', + label: 'Explorer', + tabs: [ + { path: 'live', label: 'Live', element:
Live Tab
}, + { path: 'explore', label: 'Explore', element:
Explore Tab
}, + ], + }, +]; + +const slashedTabRoutes: RouteItem[] = [ + { + path: '/test', + label: 'Test', + tabs: [ + { path: '/testing', label: 'Test Page', element:
Test Page Body
}, + { path: 'docs', label: 'Docs', element:
Docs Body
}, + ], + }, +]; + +type RenderContentOptions = Partial & { path?: string }; + +function renderContent({ + routes = mockRoutes, + path = '/home', + ...props +}: RenderContentOptions = {}) { return render( - - + + , ); } @@ -24,28 +55,28 @@ describe('FinchMainContent Component', () => { }); it('renders the matched route element', () => { - renderContent('/home'); + renderContent({ path: '/home' }); expect(screen.getByText('Home Page')).toBeInTheDocument(); }); it('renders a different matched route element', () => { - renderContent('/settings'); + renderContent({ path: '/settings' }); expect(screen.getByText('Settings Page')).toBeInTheDocument(); }); it('does not render a non-active route element', () => { - renderContent('/home'); + renderContent({ path: '/home' }); expect(screen.queryByText('Settings Page')).not.toBeInTheDocument(); }); it('renders nothing for an unmatched path', () => { - renderContent('/unknown'); + renderContent({ path: '/unknown' }); expect(screen.queryByText('Home Page')).not.toBeInTheDocument(); expect(screen.queryByText('Settings Page')).not.toBeInTheDocument(); }); it('applies a custom className to the main element', () => { - const { container } = renderContent('/home', { className: 'my-main-class' }); + const { container } = renderContent({ className: 'my-main-class' }); expect(container.querySelector('main')).toHaveClass('my-main-class'); }); @@ -53,4 +84,183 @@ describe('FinchMainContent Component', () => { const { container } = renderContent(); expect(container.querySelector('main')).toBeInTheDocument(); }); + + it('lets classNameScrollContainer replace the default page padding', () => { + const { container } = renderContent({ classNameScrollContainer: 'p-0' }); + const scrollContainer = container.querySelector('main > div'); + expect(scrollContainer).toHaveClass('p-0'); + expect(scrollContainer).not.toHaveClass('p-8'); + }); + + it('renders a tab strip on a route that declares tabs', () => { + renderContent({ routes: tabbedRoutes, path: '/explorer/live' }); + expect(screen.getByText('Live')).toBeInTheDocument(); + expect(screen.getByText('Explore')).toBeInTheDocument(); + }); + + it('renders no tab strip on a route without tabs', () => { + const { container } = renderContent({ routes: tabbedRoutes }); + expect(container.querySelector('nav')).not.toBeInTheDocument(); + }); + + it('renders only the active tab element', () => { + renderContent({ routes: tabbedRoutes, path: '/explorer/live' }); + expect(screen.getByText('Live Tab')).toBeInTheDocument(); + expect(screen.queryByText('Explore Tab')).not.toBeInTheDocument(); + }); + + it('redirects the bare route path to its first tab', () => { + renderContent({ routes: tabbedRoutes, path: '/explorer' }); + expect(screen.getByText('Live Tab')).toBeInTheDocument(); + }); + + it('redirects an unknown subpath to the first tab', () => { + renderContent({ routes: tabbedRoutes, path: '/explorer/typo' }); + expect(screen.getByText('Live Tab')).toBeInTheDocument(); + }); + + it('applies the parent route transparent background to its tabs', () => { + const transparentRoute: RouteItem[] = [ + { + path: '/explorer', + label: 'Explorer', + isBackgroundTransparent: true, + tabs: [{ path: 'live', label: 'Live', element:
Live Tab
}], + }, + ]; + const { container } = renderContent({ + routes: transparentRoute, + path: '/explorer/live', + }); + expect(container.querySelector('section')).toHaveClass('bg-transparent'); + }); + + it('lets a tab override the parent route background setting', () => { + const mixedRoute: RouteItem[] = [ + { + path: '/explorer', + label: 'Explorer', + isBackgroundTransparent: true, + tabs: [ + { + path: 'live', + label: 'Live', + element:
Live Tab
, + isBackgroundTransparent: false, + }, + ], + }, + ]; + const { container } = renderContent({ routes: mixedRoute, path: '/explorer/live' }); + expect(container.querySelector('section')).toHaveClass('bg-white'); + }); + + it('merges the parent route container classes into its tabs', () => { + const styledRoute: RouteItem[] = [ + { + path: '/explorer', + label: 'Explorer', + classNameContainer: 'bg-slate-50', + tabs: [ + { + path: 'live', + label: 'Live', + element:
Live Tab
, + classNameContainer: 'p-4', + }, + ], + }, + ]; + const { container } = renderContent({ routes: styledRoute, path: '/explorer/live' }); + expect(container.querySelector('section')).toHaveClass('bg-slate-50', 'p-4'); + }); + + it('renders nothing rather than crashing for a route with an empty tabs array', () => { + const emptyTabs: RouteItem[] = [{ path: '/explorer', label: 'Explorer', tabs: [] }]; + const { container } = renderContent({ routes: emptyTabs, path: '/explorer' }); + expect(container.querySelector('main')).toBeInTheDocument(); + expect(container.querySelector('nav')).not.toBeInTheDocument(); + }); + + it('redirects a tabbed root route to its first tab beneath the dash segment', () => { + const rootTabbed: RouteItem[] = [ + { + path: '/', + label: 'Home', + tabs: [ + { path: 'live', label: 'Live', element:
Live Tab
}, + { path: 'replay', label: 'Replay', element:
Replay Tab
}, + ], + }, + ]; + renderContent({ routes: rootTabbed, path: '/' }); + expect(screen.getByText('Live Tab')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Live' })).toHaveAttribute('href', '/-/live'); + }); + + it('renders the tab page when the tab path carries a leading slash', () => { + renderContent({ routes: slashedTabRoutes, path: '/test/testing' }); + expect(screen.getByText('Test Page Body')).toBeInTheDocument(); + expect(screen.queryByText('Docs Body')).not.toBeInTheDocument(); + }); + + it('links a leading-slash tab beneath its route rather than to the site root', () => { + renderContent({ routes: slashedTabRoutes, path: '/test/testing' }); + expect(screen.getByRole('link', { name: 'Test Page' })).toHaveAttribute( + 'href', + '/test/testing', + ); + }); + + it('redirects the bare route path to a first tab that carries a leading slash', () => { + renderContent({ routes: slashedTabRoutes, path: '/test' }); + expect(screen.getByText('Test Page Body')).toBeInTheDocument(); + }); + + it('renders a static route that outranks a tabbed route sharing its prefix', () => { + const overlapping: RouteItem[] = [ + { + path: '/data', + label: 'Data', + tabs: [{ path: 'live', label: 'Live', element:
Live Tab
}], + }, + { path: '/data/details', label: 'Details', element:
Details Page
}, + ]; + const { container } = renderContent({ routes: overlapping, path: '/data/details' }); + expect(screen.getByText('Details Page')).toBeInTheDocument(); + expect(container.querySelector('nav')).not.toBeInTheDocument(); + }); + + it('renders the tab strip when a root route precedes the tabbed route', () => { + const withRootRoute: RouteItem[] = [ + { path: '/', label: 'Home', element:
Root Page
}, + { + path: '/explorer', + label: 'Explorer', + tabs: [{ path: 'live', label: 'Live', element:
Live Tab
}], + }, + ]; + renderContent({ routes: withRootRoute, path: '/explorer/live' }); + expect(screen.getByText('Live')).toBeInTheDocument(); + }); + + it('applies classNamePageTabs to the tab strip', () => { + const { container } = renderContent({ + routes: tabbedRoutes, + path: '/explorer/live', + classNamePageTabs: 'my-tabs-class', + }); + expect(container.querySelector('nav')).toHaveClass('my-tabs-class'); + }); + + it('applies classNamePageTabsActive and classNamePageTabsInactive to the right tabs', () => { + renderContent({ + routes: tabbedRoutes, + path: '/explorer/live', + classNamePageTabsActive: 'active-tab-class', + classNamePageTabsInactive: 'inactive-tab-class', + }); + expect(screen.getByText('Live').closest('a')).toHaveClass('active-tab-class'); + expect(screen.getByText('Explore').closest('a')).toHaveClass('inactive-tab-class'); + }); }); diff --git a/src/testing/tests/FinchPageTabs.test.tsx b/src/testing/tests/FinchPageTabs.test.tsx new file mode 100644 index 00000000..43ee5a6d --- /dev/null +++ b/src/testing/tests/FinchPageTabs.test.tsx @@ -0,0 +1,97 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { MemoryRouter } from 'react-router'; +import FinchPageTabs from '../../components/FinchAppLayout/FinchPageTabs'; +import { RouteTab } from '../../types/navigationRouterTypes'; + +const mockTabs: RouteTab[] = [ + { path: 'live', label: 'Live', element:
}, + { path: 'explore', label: 'Explore', element:
}, + { path: 'replay', label: 'Replay', element:
}, +]; + +function renderTabs(initialPath = '/explorer/live', props = {}) { + return render( + + + , + ); +} + +describe('FinchPageTabs Component', () => { + it('renders a link for each tab', () => { + renderTabs(); + expect(screen.getByText('Live')).toBeInTheDocument(); + expect(screen.getByText('Explore')).toBeInTheDocument(); + expect(screen.getByText('Replay')).toBeInTheDocument(); + }); + + it('links each tab to its path beneath the base path', () => { + renderTabs(); + const links = screen.getAllByRole('link'); + expect(links[0]).toHaveAttribute('href', '/explorer/live'); + expect(links[1]).toHaveAttribute('href', '/explorer/explore'); + expect(links[2]).toHaveAttribute('href', '/explorer/replay'); + }); + + it('applies the active class to the tab matching the current path', () => { + renderTabs('/explorer/explore', { classNameActiveTab: 'active-test-class' }); + expect(screen.getByText('Explore').closest('a')).toHaveClass('active-test-class'); + }); + + it('does not apply the active class to the other tabs', () => { + renderTabs('/explorer/explore', { classNameActiveTab: 'active-test-class' }); + expect(screen.getByText('Live').closest('a')).not.toHaveClass('active-test-class'); + }); + + it('applies classNameInactiveTab only to inactive tabs', () => { + renderTabs('/explorer/explore', { classNameInactiveTab: 'inactive-test-class' }); + expect(screen.getByText('Live').closest('a')).toHaveClass('inactive-test-class'); + expect(screen.getByText('Explore').closest('a')).not.toHaveClass('inactive-test-class'); + }); + + it('applies a custom className to the nav element', () => { + const { container } = renderTabs('/explorer/live', { className: 'my-tabs-class' }); + expect(container.querySelector('nav')).toHaveClass('my-tabs-class'); + }); + + it('renders a nav as the root element', () => { + const { container } = renderTabs(); + expect(container.querySelector('nav')).toBeInTheDocument(); + }); + + it('links a root route tab beneath the reserved dash segment', () => { + renderTabs('/-/live', { basePath: '/' }); + expect(screen.getByText('Live').closest('a')).toHaveAttribute('href', '/-/live'); + }); + + it('marks only the exact tab active when another tab nests beneath it', () => { + const nestedTabs: RouteTab[] = [ + { path: 'live', label: 'Live', element:
}, + { path: 'live/detail', label: 'Detail', element:
}, + ]; + renderTabs('/explorer/live/detail', { + tabs: nestedTabs, + classNameActiveTab: 'active-test-class', + }); + expect(screen.getByText('Detail').closest('a')).toHaveClass('active-test-class'); + expect(screen.getByText('Live').closest('a')).not.toHaveClass('active-test-class'); + }); + + it('links a tab to the same url whether or not its path carries a leading slash', () => { + const slashedTabs: RouteTab[] = mockTabs.map((tab) => ({ ...tab, path: `/${tab.path}` })); + renderTabs('/explorer/live', { tabs: slashedTabs }); + const links = screen.getAllByRole('link'); + expect(links[0]).toHaveAttribute('href', '/explorer/live'); + expect(links[1]).toHaveAttribute('href', '/explorer/explore'); + }); + + it('renders no links when tabs is an empty array', () => { + render( + + + , + ); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); +}); diff --git a/src/testing/tests/FinchSidebar.test.tsx b/src/testing/tests/FinchSidebar.test.tsx index 4ba315a9..3f00a495 100644 --- a/src/testing/tests/FinchSidebar.test.tsx +++ b/src/testing/tests/FinchSidebar.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { MemoryRouter } from 'react-router'; -import FinchSidebar from '../../components/FinchSidebar'; +import FinchSidebar from '../../components/FinchAppLayout/FinchSidebar'; import { RouteItem } from '../../types/navigationRouterTypes'; const mockRoutes: RouteItem[] = [ @@ -57,6 +57,38 @@ describe('FinchSidebar Component', () => { expect(settingsLink).toHaveClass('inactive-test-class'); }); + it('applies the active class to a route link while one of its tabs is showing', () => { + const tabbedRoutes: RouteItem[] = [ + { + path: '/explorer', + label: 'Explorer', + tabs: [{ path: 'live', label: 'Live', element:
}], + }, + { path: '/settings', label: 'Settings', element:
}, + ]; + renderSidebar('/explorer/live', { + routes: tabbedRoutes, + classNameActiveLink: 'active-test-class', + }); + expect(screen.getByText('Explorer').closest('a')).toHaveClass('active-test-class'); + }); + + it('applies the active class to the root link while one of its tabs is showing', () => { + const rootTabbedRoutes: RouteItem[] = [ + { + path: '/', + label: 'Home', + tabs: [{ path: 'live', label: 'Live', element:
}], + }, + { path: '/settings', label: 'Settings', element:
}, + ]; + renderSidebar('/-/live', { + routes: rootTabbedRoutes, + classNameActiveLink: 'active-test-class', + }); + expect(screen.getByText('Home').closest('a')).toHaveClass('active-test-class'); + }); + it('renders route icons when provided', () => { const routesWithIcons: RouteItem[] = [ { diff --git a/src/testing/tests/pageRoutes.test.tsx b/src/testing/tests/pageRoutes.test.tsx new file mode 100644 index 00000000..ae0e7e7d --- /dev/null +++ b/src/testing/tests/pageRoutes.test.tsx @@ -0,0 +1,201 @@ +import { describe, it, expect } from 'vitest'; +import { matchRoutes } from 'react-router'; +import { + buildPageRoutes, + toRoutePath, + toTabPath, +} from '../../components/FinchAppLayout/utils/pageRoutes'; +import { RouteItem } from '../../types/navigationRouterTypes'; + +function tabbedRoute(routePath: string, tabPath: string): RouteItem[] { + return [ + { + path: routePath, + label: 'Explorer', + tabs: [{ path: tabPath, label: 'Live', element:
}], + }, + ]; +} + +function matchedPaths(routes: RouteItem[], url: string) { + return matchRoutes(buildPageRoutes(routes), url)?.map((match) => match.route.path); +} + +describe('toRoutePath', () => { + it('gives a route path one leading slash and no trailing one', () => { + expect(['data', '/data', '/data/', '//data//'].map(toRoutePath)).toEqual([ + '/data', + '/data', + '/data', + '/data', + ]); + }); + + it('collapses repeated slashes inside a route path', () => { + expect(toRoutePath('/data//archive')).toBe('/data/archive'); + }); + + it('leaves the root path as a single slash', () => { + expect(toRoutePath('/')).toBe('/'); + }); +}); + +describe('toTabPath', () => { + it('nests a tab beneath its route however either path was written', () => { + const urls = [ + { basePath: '/explorer', tab: { path: 'live', label: 'Live' } }, + { basePath: '/explorer', tab: { path: '/live', label: 'Live' } }, + { basePath: 'explorer/', tab: { path: '/live/', label: 'Live' } }, + ].map(toTabPath); + expect(urls).toEqual(['/explorer/live', '/explorer/live', '/explorer/live']); + }); + + it('collapses repeated slashes inside a tab path', () => { + expect( + toTabPath({ basePath: '/explorer', tab: { path: 'live//detail', label: 'Live' } }), + ).toBe('/explorer/live/detail'); + }); + + it('hides a root route tab behind the reserved dash segment', () => { + expect(toTabPath({ basePath: '/', tab: { path: 'live', label: 'Live' } })).toBe('/-/live'); + }); + + it('rejects a tab whose path holds no segment', () => { + expect(() => + toTabPath({ basePath: '/explorer', tab: { path: '/', label: 'Live' } }), + ).toThrow(/needs a path segment/); + }); +}); + +describe('buildPageRoutes', () => { + it('matches a route however its own path was written', () => { + for (const routePath of ['explorer', '/explorer', '/explorer/']) { + expect(matchedPaths(tabbedRoute(routePath, 'live'), '/explorer/live')).toEqual([ + '/explorer', + 'live', + ]); + } + }); + + it('matches a tab however its path was written', () => { + for (const tabPath of ['live', '/live', '/live/']) { + expect(matchedPaths(tabbedRoute('/explorer', tabPath), '/explorer/live')).toEqual([ + '/explorer', + 'live', + ]); + } + }); + + it('matches a tab whose path holds repeated slashes', () => { + expect( + matchedPaths(tabbedRoute('/explorer', 'live//detail'), '/explorer/live/detail'), + ).toEqual(['/explorer', 'live/detail']); + }); + + it('does not leak the route path into a tab that repeats it', () => { + expect(matchedPaths(tabbedRoute('/test', '/testing'), '/test/testing')).toEqual([ + '/test', + 'testing', + ]); + }); + + it('matches a root route tab beneath the reserved dash segment', () => { + expect(matchedPaths(tabbedRoute('/', 'live'), '/-/live')).toEqual(['/', '-/live']); + }); + + it('leaves urls outside the dash segment to the rest of the app', () => { + const routes = [...tabbedRoute('/', 'live'), ...tabbedRoute('/data', 'recent')]; + expect(matchedPaths(routes, '/data/recent')).toEqual(['/data', 'recent']); + expect(matchedPaths(routes, '/nowhere')).toBeUndefined(); + }); + + it('catches an unknown url beneath the dash segment', () => { + expect(matchedPaths(tabbedRoute('/', 'live'), '/-/bogus')).toEqual(['/', '-/*']); + }); + + it('rejects a tab whose path holds no segment', () => { + for (const tabPath of ['', '/', '//']) { + expect(() => buildPageRoutes(tabbedRoute('/explorer', tabPath))).toThrow( + /needs a path segment/, + ); + } + }); + + it('rejects two tabs of a route that resolve to the same url', () => { + const routes: RouteItem[] = [ + { + path: '/explorer', + label: 'Explorer', + tabs: [ + { path: 'live', label: 'Live', element:
}, + { path: '/live/', label: 'Also live', element:
}, + ], + }, + ]; + expect(() => buildPageRoutes(routes)).toThrow( + /Two pages both resolve to "\/explorer\/live": the "Live" tab of "\/explorer" and the "Also live" tab of "\/explorer"/, + ); + }); + + it('rejects two routes that resolve to the same path', () => { + const routes: RouteItem[] = [ + { path: 'data', label: 'Data', element:
}, + { path: '/data/', label: 'Archive', element:
}, + ]; + expect(() => buildPageRoutes(routes)).toThrow( + /Two pages both resolve to "\/data": the route "Data" and the route "Archive"/, + ); + }); + + it('matches a route and its tab however the url was cased', () => { + expect(matchedPaths(tabbedRoute('/explorer', 'live'), '/Explorer/LIVE')).toEqual([ + '/explorer', + 'live', + ]); + }); + + it('rejects two routes whose paths differ only by case', () => { + const routes: RouteItem[] = [ + { path: '/data', label: 'Data', element:
}, + { path: '/Data', label: 'Archive', element:
}, + ]; + expect(() => buildPageRoutes(routes)).toThrow( + /Two pages both resolve to "\/data": the route "Data" and the route "Archive"\. Urls ignore case, so "\/Data" is the same url\./, + ); + }); + + it('rejects a route that lands on another route tab url', () => { + const routes: RouteItem[] = [ + ...tabbedRoute('/explorer', 'live'), + { path: '/explorer/live', label: 'Live page', element:
}, + ]; + expect(() => buildPageRoutes(routes)).toThrow( + /Two pages both resolve to "\/explorer\/live": the "Live" tab of "\/explorer" and the route "Live page"/, + ); + }); + + it('rejects a route that lands on a root route tab url', () => { + const routes: RouteItem[] = [ + ...tabbedRoute('/', 'live'), + { path: '/-/live', label: 'Other', element:
}, + ]; + expect(() => buildPageRoutes(routes)).toThrow( + /Two pages both resolve to "\/-\/live": the "Live" tab of "\/" and the route "Other"/, + ); + }); + + it('rejects two tabbed routes that both catch unknown urls', () => { + const routes = [...tabbedRoute('/', 'live'), ...tabbedRoute('/-', 'daily')]; + expect(() => buildPageRoutes(routes)).toThrow( + /Two pages both resolve to "\/-\/\*": the fallback of "\/" and the fallback of "\/-"/, + ); + }); + + it('keeps a route beneath the dash segment that takes no tab url', () => { + const routes: RouteItem[] = [ + ...tabbedRoute('/', 'live'), + { path: '/-/reports', label: 'Reports', element:
}, + ]; + expect(matchedPaths(routes, '/-/reports')).toEqual(['/-/reports']); + }); +}); diff --git a/src/testing/tests/useActiveRoute.test.tsx b/src/testing/tests/useActiveRoute.test.tsx new file mode 100644 index 00000000..05329bac --- /dev/null +++ b/src/testing/tests/useActiveRoute.test.tsx @@ -0,0 +1,44 @@ +import { renderHook } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { MemoryRouter } from 'react-router'; +import { useActiveRoute } from '../../components/FinchAppLayout/hooks/useActiveRoute'; +import { RouteItem } from '../../types/navigationRouterTypes'; + +const withCatchAll: RouteItem[] = [ + { path: '*', label: 'Not found', element:
}, + { path: '/data', label: 'Data', element:
}, +]; + +const withDynamicSibling: RouteItem[] = [ + { path: '/data', label: 'Data', tabs: [{ path: 'live', label: 'Live', element:
}] }, + { path: '/data/:id', label: 'Data Item', element:
}, +]; + +const tabbedRoutes: RouteItem[] = [ + { path: '/data', label: 'Data', tabs: [{ path: 'live', label: 'Live', element:
}] }, +]; + +function activeRouteAt(path: string, routes: RouteItem[]) { + const { result } = renderHook(() => useActiveRoute(routes), { + wrapper: ({ children }) => {children}, + }); + return result.current; +} + +describe('useActiveRoute', () => { + it('ignores a catch-all route when a concrete route matches', () => { + expect(activeRouteAt('/data', withCatchAll)?.label).toBe('Data'); + }); + + it('ranks a tab route above a dynamic sibling that could also match', () => { + expect(activeRouteAt('/data/live', withDynamicSibling)?.label).toBe('Data'); + }); + + it('keeps an unknown tab subpath on its parent route', () => { + expect(activeRouteAt('/data/typo', tabbedRoutes)?.label).toBe('Data'); + }); + + it('returns undefined when no route matches', () => { + expect(activeRouteAt('/nowhere', tabbedRoutes)).toBeUndefined(); + }); +}); diff --git a/src/types/navigationRouterTypes.ts b/src/types/navigationRouterTypes.ts index 3fb53ee6..9dcaca25 100644 --- a/src/types/navigationRouterTypes.ts +++ b/src/types/navigationRouterTypes.ts @@ -1,15 +1,47 @@ -/** Defines a single navigable route entry in the application router. */ -export type RouteItem = { - /** The URL path for this route (e.g. `"/dashboard"`). */ +type RouteBase = { + /** The URL path for this route (e.g. `"/dashboard"`). The leading slash is optional and repeated slashes collapse. Must be static; dynamic segments like `:id` are not supported, since the sidebar links straight to this path. */ path: string; /** Human-readable label shown in navigation UI. */ label: string; - /** The React component rendered when this route is active. */ - element: React.ReactNode; /** Optional icon displayed alongside the route label in navigation. */ icon?: React.ReactNode; /** When `true`, the page background is rendered as transparent against the main content color and sets text color to white, when 'false' it is rendered with white background and default text color*/ isBackgroundTransparent?: boolean; /** Additional CSS classes applied to the inner container of the route element. */ classNameContainer?: string; + /** Whether this route's label is shown in the header. Overrides the layout's setting. Defaults to `true`. */ + showPageTitle?: boolean; +}; + +/** + * Defines a single navigable route entry in the application router. + * + * A route renders either a single `element` or a strip of `tabs`, never both. + */ +export type RouteItem = RouteBase & + ( + | { + /** The React component rendered when this route is active. */ + element: React.ReactNode; + tabs?: never; + } + | { + element?: never; + /** Tabs rendered in a strip above the page. Each tab becomes a nested route. On the root route `"/"` they sit under a reserved `-` segment, so `"live"` lands at `/-/live`. */ + tabs: RouteTab[]; + } + ); + +/** A single tab belonging to a route that declares `tabs`. */ +export type RouteTab = { + /** Path appended to the parent route path (e.g. `"live"` or `"/live"` under `"/explorer"`). Leading and trailing slashes are optional and repeated ones collapse, but the path cannot be empty. */ + path: string; + /** Label shown on the tab. */ + label: string; + /** The React component rendered when this tab is active. */ + element: React.ReactNode; + /** When `true`, this tab's background is transparent against the main content color and its text is white. Falls back to the parent route's setting. */ + isBackgroundTransparent?: boolean; + /** Additional CSS classes applied to the inner container of this tab's element. Merged on top of the parent route's. */ + classNameContainer?: string; };