Skip to content

Commit 4f92755

Browse files
Require all final breadcrumbs to not point to their parents (#3337)
Inspired by a comment on [Charlie's webhooks PR](https://github.com/oxidecomputer/console/pull/3320/changes#diff-c8f1bef7c0f906ca5002103709d1125057a54691dbbe673d4978670628c61cbeR80). I was curious on whether there were terminal breadcrumbs that didn't abide by this reasoning, and inadvertently pointed to parent pages. And there were! Breadcrumbs breaking this rule fell into two cases: 1. Pointing to a parent view that defaulted back to "where you just came from". Not a big deal, but a little silly 2. Pointing to a parent view that then defaulted to _some other place._ That's weird! Co-authored-by: David Crespo <david.crespo@oxidecomputer.com>
1 parent b2c28c7 commit 4f92755

7 files changed

Lines changed: 67 additions & 25 deletions

File tree

app/pages/project/vpcs/VpcRoutersTab.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { api, getListQFn, q, queryClient, useApiMutation, type VpcRouter } from
1313

1414
import { HL } from '~/components/HL'
1515
import { routeFormMessage } from '~/forms/vpc-router-route-common'
16+
import { makeCrumb } from '~/hooks/use-crumbs'
1617
import { getVpcSelector, useVpcSelector } from '~/hooks/use-params'
1718
import { useQuickActions } from '~/hooks/use-quick-actions'
1819
import { confirmDelete } from '~/stores/confirm-delete'
@@ -36,7 +37,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
3637
return null
3738
}
3839

39-
export const handle = { crumb: 'Routers' }
40+
export const handle = makeCrumb('Routers', (p) => pb.vpcRouters(getVpcSelector(p)))
4041

4142
export default function VpcRoutersTab() {
4243
const vpcSelector = useVpcSelector()

app/pages/project/vpcs/VpcSubnetsTab.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Outlet, type LoaderFunctionArgs } from 'react-router'
1111

1212
import { api, getListQFn, queryClient, useApiMutation, type VpcSubnet } from '@oxide/api'
1313

14+
import { makeCrumb } from '~/hooks/use-crumbs'
1415
import { getVpcSelector, useVpcSelector } from '~/hooks/use-params'
1516
import { useQuickActions } from '~/hooks/use-quick-actions'
1617
import { confirmDelete } from '~/stores/confirm-delete'
@@ -36,7 +37,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
3637
return null
3738
}
3839

39-
export const handle = { crumb: 'VPC Subnets' }
40+
export const handle = makeCrumb('VPC Subnets', (p) => pb.vpcSubnets(getVpcSelector(p)))
4041

4142
export default function VpcSubnetsTab() {
4243
const vpcSelector = useVpcSelector()

app/pages/system/networking/IpPoolsPage.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { Badge } from '@oxide/design-system/ui'
1818
import { DocsPopover } from '~/components/DocsPopover'
1919
import { HL } from '~/components/HL'
2020
import { IpVersionBadge } from '~/components/IpVersionBadge'
21+
import { makeCrumb } from '~/hooks/use-crumbs'
2122
import { useQuickActions } from '~/hooks/use-quick-actions'
2223
import { confirmDelete } from '~/stores/confirm-delete'
2324
import { addToast } from '~/stores/toast'
@@ -87,7 +88,7 @@ export async function clientLoader() {
8788
return null
8889
}
8990

90-
export const handle = { crumb: 'IP Pools' }
91+
export const handle = makeCrumb('IP Pools', pb.ipPools())
9192

9293
export default function IpPoolsPage() {
9394
const navigate = useNavigate()

app/pages/system/networking/SubnetPoolsPage.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { Subnet16Icon, Subnet24Icon } from '@oxide/design-system/icons/react'
2424
import { DocsPopover } from '~/components/DocsPopover'
2525
import { HL } from '~/components/HL'
2626
import { IpVersionBadge } from '~/components/IpVersionBadge'
27+
import { makeCrumb } from '~/hooks/use-crumbs'
2728
import { useQuickActions } from '~/hooks/use-quick-actions'
2829
import { confirmDelete } from '~/stores/confirm-delete'
2930
import { addToast } from '~/stores/toast'
@@ -86,7 +87,7 @@ export async function clientLoader() {
8687
return null
8788
}
8889

89-
export const handle = { crumb: 'Subnet Pools' }
90+
export const handle = makeCrumb('Subnet Pools', pb.subnetPools())
9091

9192
export default function SubnetPoolsPage() {
9293
const navigate = useNavigate()

app/routes.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
import { NotFound } from './components/ErrorPage'
1919
import { PageSkeleton } from './components/PageSkeleton.tsx'
2020
import { makeCrumb, type Crumb } from './hooks/use-crumbs'
21-
import { getInstanceSelector, getVpcSelector } from './hooks/use-params'
21+
import { getInstanceSelector, getProjectSelector, getVpcSelector } from './hooks/use-params'
2222
import { pb } from './util/path-builder'
2323

2424
// hack because RR doesn't export the redirect type
@@ -195,7 +195,7 @@ export const routes = createRoutesFromElements(
195195
lazy={() => import('./pages/system/inventory/DisksTab').then(convert)}
196196
/>
197197
</Route>
198-
<Route path="inventory" handle={{ crumb: 'Inventory' }}>
198+
<Route path="inventory" handle={makeCrumb('Inventory', pb.sledInventory())}>
199199
<Route path="sleds" handle={{ crumb: 'Sleds' }}>
200200
{/* a crumb for the sled ID looks ridiculous, unfortunately */}
201201
<Route
@@ -330,7 +330,13 @@ export const routes = createRoutesFromElements(
330330
lazy={() => import('./layouts/SerialConsoleLayout').then(convert)}
331331
>
332332
<Route path="instances" handle={{ crumb: 'Instances' }}>
333-
<Route path=":instance" handle={makeCrumb((p) => p.instance!)}>
333+
<Route
334+
path=":instance"
335+
handle={makeCrumb(
336+
(p) => p.instance!,
337+
(p) => pb.instance(getInstanceSelector(p))
338+
)}
339+
>
334340
<Route
335341
path="serial-console"
336342
lazy={() =>
@@ -506,7 +512,13 @@ export const routes = createRoutesFromElements(
506512
</Route>
507513
</Route>
508514
<Route path="vpcs" handle={{ crumb: 'VPCs' }}>
509-
<Route path=":vpc" handle={makeCrumb((p) => p.vpc!)}>
515+
<Route
516+
path=":vpc"
517+
handle={makeCrumb(
518+
(p) => p.vpc!,
519+
(p) => pb.vpc(getVpcSelector(p))
520+
)}
521+
>
510522
<Route path="routers" handle={{ crumb: 'Routers' }}>
511523
<Route
512524
path=":router"
@@ -600,7 +612,7 @@ export const routes = createRoutesFromElements(
600612
/>
601613
<Route
602614
lazy={() => import('./pages/project/affinity/AffinityPage').then(convert)}
603-
handle={{ crumb: 'Affinity Groups' }}
615+
handle={makeCrumb('Affinity Groups', (p) => pb.affinity(getProjectSelector(p)))}
604616
>
605617
<Route
606618
path="affinity-new"

app/util/__snapshots__/path-builder.spec.ts.snap

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ exports[`breadcrumbs 2`] = `
3737
},
3838
{
3939
"label": "Affinity Groups",
40-
"path": "/projects/p/",
40+
"path": "/projects/p/affinity",
4141
},
4242
],
4343
"antiAffinityGroup (/projects/p/affinity/aag)": [
@@ -456,13 +456,13 @@ exports[`breadcrumbs 2`] = `
456456
"ipPools (/system/networking/ip-pools)": [
457457
{
458458
"label": "IP Pools",
459-
"path": "/system/networking/",
459+
"path": "/system/networking/ip-pools",
460460
},
461461
],
462462
"ipPoolsNew (/system/networking/ip-pools-new)": [
463463
{
464464
"label": "IP Pools",
465-
"path": "/system/networking/",
465+
"path": "/system/networking/ip-pools",
466466
},
467467
],
468468
"profile (/settings/profile)": [
@@ -592,7 +592,7 @@ exports[`breadcrumbs 2`] = `
592592
},
593593
{
594594
"label": "i",
595-
"path": "/projects/p/instances/i",
595+
"path": "/projects/p/instances/i/storage",
596596
},
597597
{
598598
"label": "Serial Console",
@@ -750,7 +750,7 @@ exports[`breadcrumbs 2`] = `
750750
"sledInstances (/system/inventory/sleds/5c56b522-c9b8-49e4-9f9a-8d52a89ec3e0/instances)": [
751751
{
752752
"label": "Inventory",
753-
"path": "/system/inventory",
753+
"path": "/system/inventory/sleds",
754754
},
755755
{
756756
"label": "Sleds",
@@ -880,13 +880,13 @@ exports[`breadcrumbs 2`] = `
880880
"subnetPools (/system/networking/subnet-pools)": [
881881
{
882882
"label": "Subnet Pools",
883-
"path": "/system/networking/",
883+
"path": "/system/networking/subnet-pools",
884884
},
885885
],
886886
"subnetPoolsNew (/system/networking/subnet-pools-new)": [
887887
{
888888
"label": "Subnet Pools",
889-
"path": "/system/networking/",
889+
"path": "/system/networking/subnet-pools",
890890
},
891891
],
892892
"systemUpdate (/system/update)": [
@@ -1088,7 +1088,7 @@ exports[`breadcrumbs 2`] = `
10881088
},
10891089
{
10901090
"label": "v",
1091-
"path": "/projects/p/vpcs/v",
1091+
"path": "/projects/p/vpcs/v/firewall-rules",
10921092
},
10931093
{
10941094
"label": "Routers",
@@ -1122,7 +1122,7 @@ exports[`breadcrumbs 2`] = `
11221122
},
11231123
{
11241124
"label": "Routers",
1125-
"path": "/projects/p/vpcs/v/",
1125+
"path": "/projects/p/vpcs/v/routers",
11261126
},
11271127
],
11281128
"vpcRouterRouteEdit (/projects/p/vpcs/v/routers/r/routes/rr/edit)": [
@@ -1140,7 +1140,7 @@ exports[`breadcrumbs 2`] = `
11401140
},
11411141
{
11421142
"label": "v",
1143-
"path": "/projects/p/vpcs/v",
1143+
"path": "/projects/p/vpcs/v/firewall-rules",
11441144
},
11451145
{
11461146
"label": "Routers",
@@ -1170,7 +1170,7 @@ exports[`breadcrumbs 2`] = `
11701170
},
11711171
{
11721172
"label": "v",
1173-
"path": "/projects/p/vpcs/v",
1173+
"path": "/projects/p/vpcs/v/firewall-rules",
11741174
},
11751175
{
11761176
"label": "Routers",
@@ -1204,7 +1204,7 @@ exports[`breadcrumbs 2`] = `
12041204
},
12051205
{
12061206
"label": "Routers",
1207-
"path": "/projects/p/vpcs/v/",
1207+
"path": "/projects/p/vpcs/v/routers",
12081208
},
12091209
],
12101210
"vpcRoutersNew (/projects/p/vpcs/v/routers-new)": [
@@ -1226,7 +1226,7 @@ exports[`breadcrumbs 2`] = `
12261226
},
12271227
{
12281228
"label": "Routers",
1229-
"path": "/projects/p/vpcs/v/",
1229+
"path": "/projects/p/vpcs/v/routers",
12301230
},
12311231
],
12321232
"vpcSubnets (/projects/p/vpcs/v/subnets)": [
@@ -1248,7 +1248,7 @@ exports[`breadcrumbs 2`] = `
12481248
},
12491249
{
12501250
"label": "VPC Subnets",
1251-
"path": "/projects/p/vpcs/v/",
1251+
"path": "/projects/p/vpcs/v/subnets",
12521252
},
12531253
],
12541254
"vpcSubnetsEdit (/projects/p/vpcs/v/subnets/su/edit)": [
@@ -1270,7 +1270,7 @@ exports[`breadcrumbs 2`] = `
12701270
},
12711271
{
12721272
"label": "VPC Subnets",
1273-
"path": "/projects/p/vpcs/v/",
1273+
"path": "/projects/p/vpcs/v/subnets",
12741274
},
12751275
],
12761276
"vpcSubnetsNew (/projects/p/vpcs/v/subnets-new)": [
@@ -1292,7 +1292,7 @@ exports[`breadcrumbs 2`] = `
12921292
},
12931293
{
12941294
"label": "VPC Subnets",
1295-
"path": "/projects/p/vpcs/v/",
1295+
"path": "/projects/p/vpcs/v/subnets",
12961296
},
12971297
],
12981298
"vpcs (/projects/p/vpcs)": [

app/util/path-builder.spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,29 @@ test('breadcrumbs', async () => {
186186

187187
expect(Object.fromEntries(pairs)).toMatchSnapshot()
188188
})
189+
190+
// Some pages don't have self-referential breadcrumbs (like /instances-new). But
191+
// these pages also aren't _targeted_ by breadcrumbs! Any page that's targeted
192+
// by a breadcrumb should be able to point to itself.
193+
test('every page reachable by breadcrumb should have a self-referential breadcrumb', async () => {
194+
// as far as react router is concerned, /blah and /blah/ are equivalent
195+
const dropFinalSlash = (p: string) => p.replace(/\/$/, '')
196+
197+
const allCrumbs = await Promise.all(
198+
Object.values(pb).map(async (fn) => {
199+
const pathname = fn(params)
200+
const matches = await getMatches(pathname)
201+
return matchesToCrumbs(matches).filter(({ titleOnly }) => !titleOnly)
202+
})
203+
)
204+
const allPaths = new Set(allCrumbs.flat().map(({ path }) => path))
205+
206+
for (const path of allPaths) {
207+
const crumbs = matchesToCrumbs(await getMatches(path)).filter(
208+
({ titleOnly }) => !titleOnly
209+
)
210+
const last = R.last(crumbs)
211+
if (last === undefined) expect.fail(`Found no breadcrumbs for ${path}`)
212+
expect(dropFinalSlash(path)).toEqual(dropFinalSlash(last.path))
213+
}
214+
})

0 commit comments

Comments
 (0)