Skip to content

Commit 9ef82ba

Browse files
charlieparkgithub-actions[bot]benjaminleonarddavid-crespo
authored
Bring back topbar breadcrumbs (#2529)
* Most of the work to get breadcrumbs in place of top bar pickers * Bot commit: format with prettier * Refactor; add System page breadcrumbs * proper arrow; spacing tweaks * Tighten top bar by a few pixels * No back arrow on root pages; no white link when only one item on page * Update tests * Style tweaks * Fix current selected item icon alignment * Cleanup * 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 <[email protected]> * Update expected strings in e2e tests * Fix multiple locator match issue, though we might change text on button * use main to select connect button instead of connect breadcrumb * slight refactor on system/silos crumb * move Breadcrumbs into TopBar * prop name systemOrSilo * adjust main pane / footer height on serial console * add titleOnly concept for form crumbs, apply to all routes * --top-bar-height CSS var * fix ssh keys and floating IP edit crumbs * fix z-index on modal dialog overlay so it covers topbar * Bot commit: format with prettier * fix crumbs of pageless route nodes, very zany snapshot test for the crumbs * use helpers to make everything a little cleaner * write a test, find a bug! funny how that works --------- Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Benjamin Leonard <[email protected]> Co-authored-by: David Crespo <[email protected]> Co-authored-by: David Crespo <[email protected]>
1 parent 23beefe commit 9ef82ba

26 files changed

+1570
-547
lines changed

app/components/Breadcrumbs.tsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
import cn from 'classnames'
9+
import { Link } from 'react-router-dom'
10+
11+
import { PrevArrow12Icon } from '@oxide/design-system/icons/react'
12+
13+
import { useCrumbs } from '~/hooks/use-crumbs'
14+
import { Slash } from '~/ui/lib/Slash'
15+
import { intersperse } from '~/util/array'
16+
17+
export function Breadcrumbs() {
18+
const crumbs = useCrumbs().filter((c) => !c.titleOnly)
19+
const isTopLevel = crumbs.length <= 1
20+
return (
21+
<nav
22+
className="flex items-center gap-0.5 overflow-clip pr-4 text-sans-md"
23+
aria-label="Breadcrumbs"
24+
>
25+
<PrevArrow12Icon
26+
className={cn('mx-1.5 flex-shrink-0 text-quinary', isTopLevel && 'opacity-40')}
27+
/>
28+
29+
{intersperse(
30+
crumbs.map(({ label, path }, i) => (
31+
<Link
32+
to={path}
33+
className={cn(
34+
'whitespace-nowrap text-sans-md hover:text-secondary',
35+
// make the last breadcrumb brighter, but only if we're below the top level
36+
!isTopLevel && i === crumbs.length - 1 ? 'text-secondary' : 'text-tertiary'
37+
)}
38+
key={`${label}|${path}`}
39+
>
40+
{label}
41+
</Link>
42+
)),
43+
<Slash />
44+
)}
45+
</nav>
46+
)
47+
}

app/components/Sidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ Sidebar.Nav = ({ children, heading }: SidebarNav) => (
7777
<Truncate text={heading} maxLength={24} />
7878
</div>
7979
)}
80-
<nav>
80+
<nav aria-label="Sidebar navigation">
8181
<ul className="space-y-0.5">{children}</ul>
8282
</nav>
8383
</div>

app/components/TopBar.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,35 +11,33 @@ import React from 'react'
1111
import { navToLogin, useApiMutation } from '@oxide/api'
1212
import { DirectionDownIcon, Profile16Icon } from '@oxide/design-system/icons/react'
1313

14+
import { Breadcrumbs } from '~/components/Breadcrumbs'
15+
import { SiloSystemPicker } from '~/components/TopBarPicker'
1416
import { useCurrentUser } from '~/layouts/AuthenticatedLayout'
1517
import { buttonStyle } from '~/ui/lib/Button'
1618
import * as DropdownMenu from '~/ui/lib/DropdownMenu'
1719
import { pb } from '~/util/path-builder'
1820

19-
export function TopBar({ children }: { children: React.ReactNode }) {
21+
export function TopBar({ systemOrSilo }: { systemOrSilo: 'system' | 'silo' }) {
2022
const logout = useApiMutation('logout', {
2123
onSuccess: () => navToLogin({ includeCurrent: false }),
2224
})
2325
// fetch happens in loader wrapping all authed pages
2426
const { me } = useCurrentUser()
2527

26-
// toArray filters out nulls, which is essential because the silo/system
27-
// picker is going to come in null when the user isn't supposed to see it
28-
const [cornerPicker, ...otherPickers] = React.Children.toArray(children)
29-
3028
// The height of this component is governed by the `PageContainer`
3129
// It's important that this component returns two distinct elements (wrapped in a fragment).
3230
// Each element will occupy one of the top column slots provided by `PageContainer`.
3331
return (
3432
<>
3533
<div className="flex items-center border-b border-r px-3 border-secondary">
36-
{cornerPicker}
34+
<SiloSystemPicker value={systemOrSilo} />
3735
</div>
3836
{/* Height is governed by PageContainer grid */}
3937
{/* shrink-0 is needed to prevent getting squished by body content */}
4038
<div className="z-topBar border-b bg-default border-secondary">
41-
<div className="mx-3 flex h-[60px] shrink-0 items-center justify-between">
42-
<div className="flex items-center">{otherPickers}</div>
39+
<div className="mx-3 flex h-[--top-bar-height] shrink-0 items-center justify-between">
40+
<Breadcrumbs />
4341
<div className="flex items-center gap-2">
4442
<DropdownMenu.Root>
4543
<DropdownMenu.Trigger

app/components/TopBarPicker.tsx

Lines changed: 4 additions & 177 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,9 @@
88
import cn from 'classnames'
99
import { Link } from 'react-router-dom'
1010

11-
import { useApiQuery, type Project } from '@oxide/api'
12-
import {
13-
Folder16Icon,
14-
SelectArrows6Icon,
15-
Success12Icon,
16-
} from '@oxide/design-system/icons/react'
11+
import { SelectArrows6Icon, Success12Icon } from '@oxide/design-system/icons/react'
1712

18-
import {
19-
useInstanceSelector,
20-
useIpPoolSelector,
21-
useSiloSelector,
22-
useSledParams,
23-
useVpcRouterSelector,
24-
useVpcSelector,
25-
} from '~/hooks/use-params'
2613
import { useCurrentUser } from '~/layouts/AuthenticatedLayout'
27-
import { PAGE_SIZE } from '~/table/QueryTable'
2814
import { buttonStyle } from '~/ui/lib/Button'
2915
import * as DropdownMenu from '~/ui/lib/DropdownMenu'
3016
import { Identicon } from '~/ui/lib/Identicon'
@@ -124,10 +110,10 @@ const TopBarPicker = (props: TopBarPickerProps) => {
124110
to={to}
125111
className={cn({ 'is-selected': isSelected })}
126112
>
127-
<span className="flex w-full items-center gap-2">
128-
{label}
113+
<div className="flex w-full items-center gap-2">
114+
<div className="flex-grow">{label}</div>
129115
{isSelected && <Success12Icon className="-mr-3 block" />}
130-
</span>
116+
</div>
131117
</DropdownMenu.LinkItem>
132118
)
133119
})
@@ -207,162 +193,3 @@ export function SiloSystemPicker({ value }: { value: 'silo' | 'system' }) {
207193
/>
208194
)
209195
}
210-
211-
/** Used when drilling down into a silo from the System view. */
212-
export function SiloPicker() {
213-
// picker only shows up when a silo is in scope
214-
const { silo: siloName } = useSiloSelector()
215-
const { data } = useApiQuery('siloList', { query: { limit: PAGE_SIZE } })
216-
const items = (data?.items || []).map((silo) => ({
217-
label: silo.name,
218-
to: pb.silo({ silo: silo.name }),
219-
}))
220-
221-
return (
222-
<TopBarPicker
223-
aria-label="Switch silo"
224-
category="Silo"
225-
icon={<BigIdenticon name={siloName} />}
226-
current={siloName}
227-
items={items}
228-
noItemsText="No silos found"
229-
/>
230-
)
231-
}
232-
233-
/** Used when drilling down into a pool from the System/Networking view. */
234-
export function IpPoolPicker() {
235-
// picker only shows up when a pool is in scope
236-
const { pool: poolName } = useIpPoolSelector()
237-
const { data } = useApiQuery('ipPoolList', { query: { limit: PAGE_SIZE } })
238-
const items = (data?.items || []).map((pool) => ({
239-
label: pool.name,
240-
to: pb.ipPool({ pool: pool.name }),
241-
}))
242-
243-
return (
244-
<TopBarPicker
245-
aria-label="Switch pool"
246-
category="IP Pools"
247-
current={poolName}
248-
items={items}
249-
noItemsText="No IP pools found"
250-
/>
251-
)
252-
}
253-
254-
/** Used when drilling down into a VPC from the Silo view. */
255-
export function VpcPicker() {
256-
// picker only shows up when a VPC is in scope
257-
const { project, vpc } = useVpcSelector()
258-
const { data } = useApiQuery('vpcList', { query: { project, limit: PAGE_SIZE } })
259-
const items = (data?.items || []).map((v) => ({
260-
label: v.name,
261-
to: pb.vpc({ project, vpc: v.name }),
262-
}))
263-
264-
return (
265-
<TopBarPicker
266-
aria-label="Switch VPC"
267-
category="VPC"
268-
current={vpc}
269-
items={items}
270-
noItemsText="No VPCs found"
271-
to={pb.vpc({ project, vpc })}
272-
/>
273-
)
274-
}
275-
276-
/** Used when drilling down into a VPC Router from the Silo view. */
277-
export function VpcRouterPicker() {
278-
// picker only shows up when a router is in scope
279-
const { project, vpc, router } = useVpcRouterSelector()
280-
const { data } = useApiQuery('vpcRouterList', {
281-
query: { project, vpc, limit: PAGE_SIZE },
282-
})
283-
const items = (data?.items || []).map((r) => ({
284-
label: r.name,
285-
to: pb.vpcRouter({ vpc, project, router: r.name }),
286-
}))
287-
288-
return (
289-
<TopBarPicker
290-
aria-label="Switch router"
291-
category="router"
292-
current={router}
293-
items={items}
294-
noItemsText="No routers found"
295-
/>
296-
)
297-
}
298-
299-
const NoProjectLogo = () => (
300-
<div className="flex h-[34px] w-[34px] items-center justify-center rounded text-secondary bg-secondary">
301-
<Folder16Icon />
302-
</div>
303-
)
304-
305-
export function ProjectPicker({ project }: { project?: Project }) {
306-
const { data: projects } = useApiQuery('projectList', { query: { limit: 200 } })
307-
const items = (projects?.items || []).map(({ name }) => ({
308-
label: name,
309-
to: pb.project({ project: name }),
310-
}))
311-
312-
return (
313-
<TopBarPicker
314-
aria-label="Switch project"
315-
icon={project ? undefined : <NoProjectLogo />}
316-
category="Project"
317-
current={project?.name}
318-
to={project ? pb.project({ project: project.name }) : undefined}
319-
items={items}
320-
noItemsText="No projects found"
321-
/>
322-
)
323-
}
324-
325-
export function InstancePicker() {
326-
// picker only shows up when an instance is in scope
327-
const instanceSelector = useInstanceSelector()
328-
const { project, instance } = instanceSelector
329-
const { data: instances } = useApiQuery('instanceList', {
330-
query: { project, limit: PAGE_SIZE },
331-
})
332-
const items = (instances?.items || []).map(({ name }) => ({
333-
label: name,
334-
to: pb.instance({ project, instance: name }),
335-
}))
336-
return (
337-
<TopBarPicker
338-
aria-label="Switch instance"
339-
category="Instance"
340-
current={instance}
341-
to={pb.instance({ project, instance })}
342-
items={items}
343-
noItemsText="No instances found"
344-
/>
345-
)
346-
}
347-
348-
export function SledPicker() {
349-
// picker only shows up when a sled is in scope
350-
const { sledId } = useSledParams()
351-
const { data: sleds } = useApiQuery('sledList', {
352-
query: { limit: PAGE_SIZE },
353-
})
354-
const items = (sleds?.items || []).map(({ id }) => ({
355-
label: id,
356-
to: pb.sled({ sledId: id }),
357-
}))
358-
return (
359-
<TopBarPicker
360-
aria-label="Switch sled"
361-
category="Sled"
362-
current={sledId}
363-
to={pb.sled({ sledId })}
364-
items={items}
365-
noItemsText="No sleds found"
366-
/>
367-
)
368-
}

app/hooks/use-crumbs.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
import { useMatches, type Params, type UIMatch } from 'react-router-dom'
9+
10+
import { invariant } from '~/util/invariant'
11+
12+
type Crumb = {
13+
crumb: MakeStr
14+
/**
15+
* Side modal forms have their own routes and their own crumbs that we want
16+
* in the page title, but it's weird for them to affect the nav breadcrumbs
17+
* because the side modal form opens on top of the page with an overlay
18+
* covering the background and not interactive. It feels weird for the
19+
* breadcrumbs to change in the background when you open a form. So we use
20+
* `titleOnly` to mark the form crumbs as not part of the nav breadcrumbs.
21+
*/
22+
titleOnly?: true
23+
/**
24+
* Some route nodes don't have their own pages, but rather just redirect
25+
* immediately to their first child node. In this case, we need the crumb to
26+
* link directly to that child, otherwise we get a weird flash due to linking
27+
* to the parent node and waiting for the redirect.
28+
*/
29+
path?: MakeStr
30+
}
31+
32+
type MatchWithCrumb = UIMatch<unknown, Crumb>
33+
34+
type MakeStr = string | ((p: Params) => string)
35+
36+
/** Helper to make crumb definitions less verbose */
37+
export const makeCrumb = (crumb: MakeStr, path?: MakeStr) => ({ crumb, path })
38+
39+
function hasCrumb(m: UIMatch): m is MatchWithCrumb {
40+
return !!(m.handle && typeof m.handle === 'object' && 'crumb' in m.handle)
41+
}
42+
43+
/**
44+
* Throw if crumb is not a string or function. It would be nice if TS enforced
45+
* this at the `<Route>` call, but overriding the type declarations is hard and
46+
* `createRoutesFromChildren` rejects a custom Route component.
47+
*/
48+
function checkCrumbType(m: MatchWithCrumb): MatchWithCrumb {
49+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
50+
const crumbType = typeof m.handle.crumb
51+
invariant(
52+
crumbType === 'string' || crumbType === 'function',
53+
`Route crumb must be a string or function if present. Check <Route> for ${m.pathname}.`
54+
)
55+
return m
56+
}
57+
58+
export const matchesToCrumbs = (matches: UIMatch[]) =>
59+
matches
60+
.filter(hasCrumb)
61+
.map(checkCrumbType)
62+
.map((m) => {
63+
const { crumb, path } = m.handle
64+
return {
65+
label: typeof crumb === 'function' ? crumb(m.params) : crumb,
66+
path: typeof path === 'function' ? path(m.params) : path || m.pathname,
67+
titleOnly: !!m.handle.titleOnly,
68+
}
69+
})
70+
71+
export const useCrumbs = () => matchesToCrumbs(useMatches())

0 commit comments

Comments
 (0)