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'), {