From ee3a6587986a763252172f6819b27ab5c112c217 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 1 Nov 2024 15:27:26 -0500 Subject: [PATCH] Breadcrumbs powered by `useMatches()` (#2531) * first pass at matches-based breadcrumbs. route config changes required * kinda fix things in the route config * use-title.ts -> use-crumbs.ts * Update import --------- Co-authored-by: Charlie Park --- app/components/Breadcrumbs.tsx | 47 +++ app/components/TopBarBreadcrumbs.tsx | 184 ---------- app/hooks/{use-title.ts => use-crumbs.ts} | 15 +- app/layouts/ProjectLayout.tsx | 4 +- app/layouts/RootLayout.tsx | 13 +- app/layouts/SiloLayout.tsx | 4 +- app/layouts/SystemLayout.tsx | 4 +- app/routes.tsx | 404 ++++++++++++---------- app/ui/styles/components/breadcrumbs.css | 16 - app/ui/styles/index.css | 3 +- test/e2e/ip-pools.e2e.ts | 2 +- test/e2e/networking.e2e.ts | 2 +- test/e2e/vpcs.e2e.ts | 2 +- 13 files changed, 289 insertions(+), 411 deletions(-) create mode 100644 app/components/Breadcrumbs.tsx delete mode 100644 app/components/TopBarBreadcrumbs.tsx rename app/hooks/{use-title.ts => use-crumbs.ts} (79%) delete mode 100644 app/ui/styles/components/breadcrumbs.css diff --git a/app/components/Breadcrumbs.tsx b/app/components/Breadcrumbs.tsx new file mode 100644 index 0000000000..54c61c72aa --- /dev/null +++ b/app/components/Breadcrumbs.tsx @@ -0,0 +1,47 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import cn from 'classnames' +import { Link } from 'react-router-dom' + +import { PrevArrow12Icon } from '@oxide/design-system/icons/react' + +import { useCrumbs } from '~/hooks/use-crumbs' +import { Slash } from '~/ui/lib/Slash' +import { intersperse } from '~/util/array' + +export function Breadcrumbs() { + const crumbs = useCrumbs() + const isTopLevel = crumbs.length <= 1 + return ( + + ) +} diff --git a/app/components/TopBarBreadcrumbs.tsx b/app/components/TopBarBreadcrumbs.tsx deleted file mode 100644 index 1d4e0cd1eb..0000000000 --- a/app/components/TopBarBreadcrumbs.tsx +++ /dev/null @@ -1,184 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ -import cn from 'classnames' -import { Link, useParams } from 'react-router-dom' - -import { PrevArrow12Icon } from '@oxide/design-system/icons/react' - -import { Slash } from '~/ui/lib/Slash' -import { pb } from '~/util/path-builder' - -export const TopBarBreadcrumbs = () => { - const [, firstPathItem, secondPathItem, thirdPathItem, , fifthPathItem] = - window.location.pathname.split('/') - - const { project } = useParams() - // if there's no secondPathItem on the silo section, there's no page to go "back" to - // the secondPathItem is top-level within the system section and therefore we check for thirdPathItem - const isTopLevel = (firstPathItem === 'system' && !thirdPathItem) || !secondPathItem - return ( - - ) -} - -type BreadcrumbProps = { - to: string - label: string - includeSeparator?: boolean -} -export const Breadcrumb = ({ to, label, includeSeparator = true }: BreadcrumbProps) => ( - <> - {includeSeparator && } - - {label} - - -) - -const InstanceBreadcrumb = ({ project }: { project: string }) => { - const { instance } = useParams() - return ( - <> - - {instance && } - - ) -} - -const VpcsBreadcrumb = ({ project }: { project: string }) => { - const { vpc } = useParams() - return ( - <> - - {vpc && } - - ) -} - -const VpcRouterBreadcrumb = ({ project }: { project: string }) => { - const { vpc, router } = useParams() - return ( - <> - {vpc && } - {vpc && router && ( - - )} - - ) -} - -const SilosBreadcrumb = () => { - const { silo } = useParams() - return ( - <> - - {silo && } - - ) -} - -const SystemSledInventoryBreadcrumb = () => { - const { sledId } = useParams() - return ( - <> - - {sledId && } - - ) -} - -const SystemIpPoolsBreadcrumb = () => { - const { pool } = useParams() - return ( - <> - - {pool && } - - ) -} diff --git a/app/hooks/use-title.ts b/app/hooks/use-crumbs.ts similarity index 79% rename from app/hooks/use-title.ts rename to app/hooks/use-crumbs.ts index f627f2306a..0cd084caee 100644 --- a/app/hooks/use-title.ts +++ b/app/hooks/use-crumbs.ts @@ -36,15 +36,12 @@ function checkCrumbType(m: MatchWithCrumb): MatchWithCrumb { return m } -/** - * non top-level route: Instances / mock-project / Projects / maze-war / Oxide Console - * top-level route: Oxide Console - */ -export const useTitle = () => +export const useCrumbs = () => useMatches() .filter(hasCrumb) .map(checkCrumbType) - .map((m) => (typeof m.handle.crumb === 'function' ? m.handle.crumb(m) : m.handle.crumb)) - .reverse() - .concat('Oxide Console') // if there are no crumbs, we're still Oxide Console - .join(' / ') + .map((m) => { + const label = + typeof m.handle.crumb === 'function' ? m.handle.crumb(m) : m.handle.crumb + return { label, path: m.pathname } + }) diff --git a/app/layouts/ProjectLayout.tsx b/app/layouts/ProjectLayout.tsx index daedb9843d..be8da8f38f 100644 --- a/app/layouts/ProjectLayout.tsx +++ b/app/layouts/ProjectLayout.tsx @@ -20,8 +20,8 @@ import { Storage16Icon, } from '@oxide/design-system/icons/react' +import { Breadcrumbs } from '~/components/Breadcrumbs' import { TopBar } from '~/components/TopBar' -import { TopBarBreadcrumbs } from '~/components/TopBarBreadcrumbs' import { SiloSystemPicker } from '~/components/TopBarPicker' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { useQuickActions } from '~/hooks/use-quick-actions' @@ -79,7 +79,7 @@ export function ProjectLayout({ overrideContentPane }: ProjectLayoutProps) { - + diff --git a/app/layouts/RootLayout.tsx b/app/layouts/RootLayout.tsx index 6cf6d455fa..a7029149b6 100644 --- a/app/layouts/RootLayout.tsx +++ b/app/layouts/RootLayout.tsx @@ -10,7 +10,18 @@ import { Outlet, useNavigation } from 'react-router-dom' import { MswBanner } from '~/components/MswBanner' import { ToastStack } from '~/components/ToastStack' -import { useTitle } from '~/hooks/use-title' +import { useCrumbs } from '~/hooks/use-crumbs' + +/** + * non top-level route: Instances / mock-project / Projects / maze-war / Oxide Console + * top-level route: Oxide Console + */ +export const useTitle = () => + useCrumbs() + .map((c) => c.label) + .reverse() + .concat('Oxide Console') // if there are no crumbs, we're still Oxide Console + .join(' / ') /** * Root layout that applies to the entire app. Modify sparingly. It's rare for diff --git a/app/layouts/SiloLayout.tsx b/app/layouts/SiloLayout.tsx index 142ed9bba6..bc472e5716 100644 --- a/app/layouts/SiloLayout.tsx +++ b/app/layouts/SiloLayout.tsx @@ -15,9 +15,9 @@ import { Metrics16Icon, } from '@oxide/design-system/icons/react' +import { Breadcrumbs } from '~/components/Breadcrumbs' import { DocsLinkItem, NavLinkItem, Sidebar } from '~/components/Sidebar' import { TopBar } from '~/components/TopBar' -import { TopBarBreadcrumbs } from '~/components/TopBarBreadcrumbs' import { SiloSystemPicker } from '~/components/TopBarPicker' import { useQuickActions } from '~/hooks/use-quick-actions' import { Divider } from '~/ui/lib/Divider' @@ -55,7 +55,7 @@ export function SiloLayout() { - + diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index 0d3c080e04..9058085919 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -16,10 +16,10 @@ import { Servers16Icon, } from '@oxide/design-system/icons/react' +import { Breadcrumbs } from '~/components/Breadcrumbs' import { trigger404 } from '~/components/ErrorBoundary' import { DocsLinkItem, NavLinkItem, Sidebar } from '~/components/Sidebar' import { TopBar } from '~/components/TopBar' -import { TopBarBreadcrumbs } from '~/components/TopBarBreadcrumbs' import { SiloSystemPicker } from '~/components/TopBarPicker' import { useQuickActions } from '~/hooks/use-quick-actions' import { Divider } from '~/ui/lib/Divider' @@ -90,7 +90,7 @@ export function SystemLayout() { - + diff --git a/app/routes.tsx b/app/routes.tsx index 86b159f5fa..4ccd41f2fe 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -39,7 +39,7 @@ import { CreateRouterSideModalForm } from './forms/vpc-router-create' import { EditRouterSideModalForm } from './forms/vpc-router-edit' import { CreateRouterRouteSideModalForm } from './forms/vpc-router-route-create' import { EditRouterRouteSideModalForm } from './forms/vpc-router-route-edit' -import type { CrumbFunc } from './hooks/use-title' +import type { CrumbFunc } from './hooks/use-crumbs' import { AuthenticatedLayout } from './layouts/AuthenticatedLayout' import { AuthLayout } from './layouts/AuthLayout' import { SerialConsoleContentPane } from './layouts/helpers' @@ -93,6 +93,7 @@ import { pb } from './util/path-builder' const projectCrumb: CrumbFunc = (m) => m.params.project! const instanceCrumb: CrumbFunc = (m) => m.params.instance! const vpcCrumb: CrumbFunc = (m) => m.params.vpc! +const routerCrumb: CrumbFunc = (m) => m.params.router! const siloCrumb: CrumbFunc = (m) => m.params.silo! const poolCrumb: CrumbFunc = (m) => m.params.pool! @@ -252,8 +253,13 @@ export const routes = createRoutesFromElements( /> - }> - + {/* these are here instead of under projects because they need to use SiloLayout */} + } + > + } @@ -266,6 +272,7 @@ export const routes = createRoutesFromElements( handle={{ crumb: 'Edit project' }} /> + } @@ -276,228 +283,245 @@ export const routes = createRoutesFromElements( {/* PROJECT */} - {/* Serial console page gets its own little section here because it - cannot use the normal .*/} - } />} - loader={ProjectLayout.loader} - handle={{ crumb: projectCrumb }} - > - - - } - handle={{ crumb: 'Serial Console' }} - /> - - - - - } - loader={ProjectLayout.loader} - handle={{ crumb: projectCrumb }} - > + + {/* Serial console page gets its own little section here because it + cannot use the normal .*/} } - loader={CreateInstanceForm.loader} - handle={{ crumb: 'New instance' }} - /> - - } loader={InstancesPage.loader} /> - - } /> - } loader={InstancePage.loader}> - } - loader={StorageTab.loader} - handle={{ crumb: 'Storage' }} - /> - } - loader={NetworkingTab.loader} - handle={{ crumb: 'Networking' }} - /> - } - loader={MetricsTab.loader} - handle={{ crumb: 'metrics' }} - /> + path=":project" + element={} />} + loader={ProjectLayout.loader} + handle={{ crumb: projectCrumb }} + > + + } - loader={ConnectTab.loader} - handle={{ crumb: 'Connect' }} + path="serial-console" + loader={SerialConsolePage.loader} + element={} + handle={{ crumb: 'Serial Console' }} /> - }> - + } + loader={ProjectLayout.loader} + handle={{ crumb: projectCrumb }} + > } - handle={{ crumb: 'New VPC' }} + path="instances-new" + element={} + loader={CreateInstanceForm.loader} + handle={{ crumb: 'New instance' }} /> - - - - - } loader={VpcPage.loader}> - } - loader={VpcFirewallRulesTab.loader} - /> - } loader={VpcFirewallRulesTab.loader}> + + } loader={InstancesPage.loader} /> + + } /> + } loader={InstancePage.loader}> } - loader={EditVpcSideModalForm.loader} - handle={{ crumb: 'Edit VPC' }} + path="storage" + element={} + loader={StorageTab.loader} + handle={{ crumb: 'Storage' }} /> } + loader={NetworkingTab.loader} + handle={{ crumb: 'Networking' }} /> } - loader={CreateFirewallRuleForm.loader} - handle={{ crumb: 'New Firewall Rule' }} + path="metrics" + element={} + loader={MetricsTab.loader} + handle={{ crumb: 'Metrics' }} /> } - loader={EditFirewallRuleForm.loader} - handle={{ crumb: 'Edit Firewall Rule' }} + path="connect" + element={} + loader={ConnectTab.loader} + handle={{ crumb: 'Connect' }} /> - } loader={VpcSubnetsTab.loader}> - + + + + }> + + } + handle={{ crumb: 'New VPC' }} + /> + + + + + } loader={VpcPage.loader}> } - handle={{ crumb: 'New Subnet' }} + index + element={} + loader={VpcFirewallRulesTab.loader} /> } - loader={EditSubnetForm.loader} - handle={{ crumb: 'Edit Subnet' }} - /> - - } loader={VpcRoutersTab.loader}> - + element={} + loader={VpcFirewallRulesTab.loader} + > + } + loader={EditVpcSideModalForm.loader} + handle={{ crumb: 'Edit VPC' }} + /> + + } + loader={CreateFirewallRuleForm.loader} + handle={{ crumb: 'New Firewall Rule' }} + /> + } + loader={EditFirewallRuleForm.loader} + handle={{ crumb: 'Edit Firewall Rule' }} + /> + + } loader={VpcSubnetsTab.loader}> + + } + handle={{ crumb: 'New Subnet' }} + /> } - loader={EditRouterSideModalForm.loader} - handle={{ crumb: 'Edit Router' }} + path="subnets/:subnet/edit" + element={} + loader={EditSubnetForm.loader} + handle={{ crumb: 'Edit Subnet' }} /> + } loader={VpcRoutersTab.loader}> + + } + loader={EditRouterSideModalForm.loader} + handle={{ crumb: 'Edit Router' }} + /> + + } + handle={{ crumb: 'New Router' }} + /> + + + + + + + } - handle={{ crumb: 'New Router' }} - /> + path=":router" + element={} + loader={RouterPage.loader} + handle={{ crumb: routerCrumb }} + > + } + loader={RouterPage.loader} + handle={{ crumb: 'Routes' }} + > + } + loader={CreateRouterRouteSideModalForm.loader} + handle={{ crumb: 'New Route' }} + /> + } + loader={EditRouterRouteSideModalForm.loader} + handle={{ crumb: 'Edit Route' }} + /> + + - - } - loader={RouterPage.loader} - handle={{ crumb: 'Routes' }} - path="vpcs/:vpc/routers/:router" - > - } - loader={CreateRouterRouteSideModalForm.loader} - handle={{ crumb: 'New Route' }} - /> - } - loader={EditRouterRouteSideModalForm.loader} - handle={{ crumb: 'Edit Route' }} - /> - - } loader={FloatingIpsPage.loader}> - - } - handle={{ crumb: 'New Floating IP' }} - /> - } - loader={EditFloatingIpSideModalForm.loader} - handle={{ crumb: 'Edit Floating IP' }} - /> - + } loader={FloatingIpsPage.loader}> + + } + handle={{ crumb: 'New Floating IP' }} + /> + } + loader={EditFloatingIpSideModalForm.loader} + handle={{ crumb: 'Edit Floating IP' }} + /> + - } loader={DisksPage.loader}> - navigate('../disks')} /> - } - handle={{ crumb: 'New disk' }} - /> + } loader={DisksPage.loader}> + navigate('../disks')} /> + } + handle={{ crumb: 'New disk' }} + /> - - + + - } loader={SnapshotsPage.loader}> - - } - handle={{ crumb: 'New snapshot' }} - /> - } - loader={CreateImageFromSnapshotSideModalForm.loader} - handle={{ crumb: 'Create image from snapshot' }} - /> - + } loader={SnapshotsPage.loader}> + + } + handle={{ crumb: 'New snapshot' }} + /> + } + loader={CreateImageFromSnapshotSideModalForm.loader} + handle={{ crumb: 'Create image from snapshot' }} + /> + - } loader={ImagesPage.loader}> - - } - /> + } loader={ImagesPage.loader}> + + } + /> + } + loader={EditProjectImageSideModalForm.loader} + handle={{ crumb: 'Edit Image' }} + /> + } - loader={EditProjectImageSideModalForm.loader} - handle={{ crumb: 'Edit Image' }} + path="access" + element={} + loader={ProjectAccessPage.loader} + handle={{ crumb: 'Access' }} /> - } - loader={ProjectAccessPage.loader} - handle={{ crumb: 'Access' }} - /> diff --git a/app/ui/styles/components/breadcrumbs.css b/app/ui/styles/components/breadcrumbs.css deleted file mode 100644 index df10372af6..0000000000 --- a/app/ui/styles/components/breadcrumbs.css +++ /dev/null @@ -1,16 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ - -/* - * The last .ox-breadcrumb in the list should have text-default applied, but - * if there's only a single breadcrumb, it should be the regular text color - * (single breadcrumbs don't have a span in front of them). - */ -span + .ox-breadcrumb:last-child { - @apply text-secondary; -} diff --git a/app/ui/styles/index.css b/app/ui/styles/index.css index f49b824cfe..271ee0782c 100644 --- a/app/ui/styles/index.css +++ b/app/ui/styles/index.css @@ -2,7 +2,7 @@ * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * + * * Copyright Oxide Computer Company */ @@ -24,7 +24,6 @@ @import 'simplebar-react/dist/simplebar.min.css'; @import './fonts.css'; -@import './components/breadcrumbs.css'; @import './components/button.css'; @import './components/menu-button.css'; @import './components/menu-list.css'; diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index bf4c18464e..c4861cf650 100644 --- a/test/e2e/ip-pools.e2e.ts +++ b/test/e2e/ip-pools.e2e.ts @@ -288,7 +288,7 @@ test('remove range', async ({ page }) => { // go back to the pool and verify the utilization column changed // use the topbar breadcrumb to get there - const breadcrumbs = page.getByRole('navigation', { name: 'Breadcrumb navigation' }) + const breadcrumbs = page.getByRole('navigation', { name: 'Breadcrumbs' }) await breadcrumbs.getByRole('link', { name: 'IP Pools' }).click() await expectRowVisible(table, { name: 'ip-pool-1', diff --git a/test/e2e/networking.e2e.ts b/test/e2e/networking.e2e.ts index df938ce552..b13bf99b52 100644 --- a/test/e2e/networking.e2e.ts +++ b/test/e2e/networking.e2e.ts @@ -47,7 +47,7 @@ test('Create and edit VPC', async ({ page }) => { } // now go back up a level to vpcs table - const breadcrumbs = page.getByRole('navigation', { name: 'Breadcrumb navigation' }) + const breadcrumbs = page.getByRole('navigation', { name: 'Breadcrumbs' }) await breadcrumbs.getByRole('link', { name: 'VPCs' }).click() await expect(table.getByRole('row')).toHaveCount(3) // header plus two rows await expectRowVisible(table, { diff --git a/test/e2e/vpcs.e2e.ts b/test/e2e/vpcs.e2e.ts index 4f8e81123c..c9c1f45882 100644 --- a/test/e2e/vpcs.e2e.ts +++ b/test/e2e/vpcs.e2e.ts @@ -62,7 +62,7 @@ test('can edit VPC', async ({ page }) => { await expect(page.getByText('descriptionupdated description')).toBeVisible() // go to the VPCs list page and verify the name and description change - const breadcrumbs = page.getByRole('navigation', { name: 'Breadcrumb navigation' }) + const breadcrumbs = page.getByRole('navigation', { name: 'Breadcrumbs' }) await breadcrumbs.getByRole('link', { name: 'VPCs' }).click() await expect(page.getByRole('table').locator('tbody >> tr')).toHaveCount(1) await expectRowVisible(page.getByRole('table'), {