Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
f70476b
Most of the work to get breadcrumbs in place of top bar pickers
charliepark Oct 30, 2024
db213c1
Bot commit: format with prettier
github-actions[bot] Oct 30, 2024
9747166
Merge branch 'main' into breadcrumbs
charliepark Oct 30, 2024
c6bd472
Refactor; add System page breadcrumbs
charliepark Oct 30, 2024
98025ff
proper arrow; spacing tweaks
charliepark Oct 30, 2024
252119c
Tighten top bar by a few pixels
charliepark Oct 30, 2024
73cab06
No back arrow on root pages; no white link when only one item on page
charliepark Oct 30, 2024
83df24e
Update tests
charliepark Oct 30, 2024
c69cdca
Style tweaks
benjaminleonard Oct 31, 2024
73b1e03
Fix current selected item icon alignment
benjaminleonard Oct 31, 2024
f8e094f
Cleanup
benjaminleonard Oct 31, 2024
ee3a658
Breadcrumbs powered by `useMatches()` (#2531)
david-crespo Nov 1, 2024
a956f21
Update expected strings in e2e tests
charliepark Nov 1, 2024
f990c1e
Fix multiple locator match issue, though we might change text on button
charliepark Nov 1, 2024
7670c0c
use main to select connect button instead of connect breadcrumb
david-crespo Nov 4, 2024
737b0a4
slight refactor on system/silos crumb
charliepark Nov 4, 2024
b8f26e7
move Breadcrumbs into TopBar
charliepark Nov 4, 2024
c6ae8ce
prop name systemOrSilo
charliepark Nov 4, 2024
6de6bc3
adjust main pane / footer height on serial console
charliepark Nov 5, 2024
7b9e551
Merge branch 'main' into breadcrumbs
charliepark Nov 7, 2024
dc8a973
add titleOnly concept for form crumbs, apply to all routes
david-crespo Nov 7, 2024
0c95737
--top-bar-height CSS var
david-crespo Nov 7, 2024
dfb3143
fix ssh keys and floating IP edit crumbs
david-crespo Nov 7, 2024
e5291e3
fix z-index on modal dialog overlay so it covers topbar
david-crespo Nov 7, 2024
73a4dba
Bot commit: format with prettier
github-actions[bot] Nov 7, 2024
7e90d2e
Merge main into breadcrumbs
david-crespo Nov 8, 2024
4621a82
fix crumbs of pageless route nodes, very zany snapshot test for the c…
david-crespo Nov 8, 2024
45efeff
use helpers to make everything a little cleaner
david-crespo Nov 9, 2024
9ded00f
write a test, find a bug!
david-crespo Nov 10, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions app/components/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -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().filter((c) => !c.titleOnly)
const isTopLevel = crumbs.length <= 1
return (
<nav
className="flex items-center gap-0.5 overflow-clip pr-4 text-sans-md"
aria-label="Breadcrumbs"
>
<PrevArrow12Icon
className={cn('mx-1.5 flex-shrink-0 text-quinary', isTopLevel && 'opacity-40')}
/>

{intersperse(
crumbs.map(({ label, path }, i) => (
<Link
to={path}
className={cn(
'whitespace-nowrap text-sans-md hover:text-secondary',
// make the last breadcrumb brighter, but only if we're below the top level
!isTopLevel && i === crumbs.length - 1 ? 'text-secondary' : 'text-tertiary'
)}
key={`${label}|${path}`}
>
{label}
</Link>
)),
<Slash />
)}
</nav>
)
}
2 changes: 1 addition & 1 deletion app/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ Sidebar.Nav = ({ children, heading }: SidebarNav) => (
<Truncate text={heading} maxLength={24} />
</div>
)}
<nav>
<nav aria-label="Sidebar navigation">
<ul className="space-y-0.5">{children}</ul>
</nav>
</div>
Expand Down
14 changes: 6 additions & 8 deletions app/components/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,35 +11,33 @@ import React from 'react'
import { navToLogin, useApiMutation } from '@oxide/api'
import { DirectionDownIcon, Profile16Icon } from '@oxide/design-system/icons/react'

import { Breadcrumbs } from '~/components/Breadcrumbs'
import { SiloSystemPicker } from '~/components/TopBarPicker'
import { useCurrentUser } from '~/layouts/AuthenticatedLayout'
import { buttonStyle } from '~/ui/lib/Button'
import * as DropdownMenu from '~/ui/lib/DropdownMenu'
import { pb } from '~/util/path-builder'

export function TopBar({ children }: { children: React.ReactNode }) {
export function TopBar({ systemOrSilo }: { systemOrSilo: 'system' | 'silo' }) {
const logout = useApiMutation('logout', {
onSuccess: () => navToLogin({ includeCurrent: false }),
})
// fetch happens in loader wrapping all authed pages
const { me } = useCurrentUser()

// toArray filters out nulls, which is essential because the silo/system
// picker is going to come in null when the user isn't supposed to see it
const [cornerPicker, ...otherPickers] = React.Children.toArray(children)

// The height of this component is governed by the `PageContainer`
// It's important that this component returns two distinct elements (wrapped in a fragment).
// Each element will occupy one of the top column slots provided by `PageContainer`.
return (
<>
<div className="flex items-center border-b border-r px-3 border-secondary">
{cornerPicker}
<SiloSystemPicker value={systemOrSilo} />
</div>
{/* Height is governed by PageContainer grid */}
{/* shrink-0 is needed to prevent getting squished by body content */}
<div className="z-topBar border-b bg-default border-secondary">
<div className="mx-3 flex h-[60px] shrink-0 items-center justify-between">
<div className="flex items-center">{otherPickers}</div>
<div className="mx-3 flex h-[--top-bar-height] shrink-0 items-center justify-between">
<Breadcrumbs />
<div className="flex items-center gap-2">
<DropdownMenu.Root>
<DropdownMenu.Trigger
Expand Down
181 changes: 4 additions & 177 deletions app/components/TopBarPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,9 @@
import cn from 'classnames'
import { Link } from 'react-router-dom'

import { useApiQuery, type Project } from '@oxide/api'
import {
Folder16Icon,
SelectArrows6Icon,
Success12Icon,
} from '@oxide/design-system/icons/react'
import { SelectArrows6Icon, Success12Icon } from '@oxide/design-system/icons/react'

import {
useInstanceSelector,
useIpPoolSelector,
useSiloSelector,
useSledParams,
useVpcRouterSelector,
useVpcSelector,
} from '~/hooks/use-params'
import { useCurrentUser } from '~/layouts/AuthenticatedLayout'
import { PAGE_SIZE } from '~/table/QueryTable'
import { buttonStyle } from '~/ui/lib/Button'
import * as DropdownMenu from '~/ui/lib/DropdownMenu'
import { Identicon } from '~/ui/lib/Identicon'
Expand Down Expand Up @@ -124,10 +110,10 @@ const TopBarPicker = (props: TopBarPickerProps) => {
to={to}
className={cn({ 'is-selected': isSelected })}
>
<span className="flex w-full items-center gap-2">
{label}
<div className="flex w-full items-center gap-2">
<div className="flex-grow">{label}</div>
{isSelected && <Success12Icon className="-mr-3 block" />}
</span>
</div>
</DropdownMenu.LinkItem>
)
})
Expand Down Expand Up @@ -207,162 +193,3 @@ export function SiloSystemPicker({ value }: { value: 'silo' | 'system' }) {
/>
)
}

/** Used when drilling down into a silo from the System view. */
export function SiloPicker() {
// picker only shows up when a silo is in scope
const { silo: siloName } = useSiloSelector()
const { data } = useApiQuery('siloList', { query: { limit: PAGE_SIZE } })
const items = (data?.items || []).map((silo) => ({
label: silo.name,
to: pb.silo({ silo: silo.name }),
}))

return (
<TopBarPicker
aria-label="Switch silo"
category="Silo"
icon={<BigIdenticon name={siloName} />}
current={siloName}
items={items}
noItemsText="No silos found"
/>
)
}

/** Used when drilling down into a pool from the System/Networking view. */
export function IpPoolPicker() {
// picker only shows up when a pool is in scope
const { pool: poolName } = useIpPoolSelector()
const { data } = useApiQuery('ipPoolList', { query: { limit: PAGE_SIZE } })
const items = (data?.items || []).map((pool) => ({
label: pool.name,
to: pb.ipPool({ pool: pool.name }),
}))

return (
<TopBarPicker
aria-label="Switch pool"
category="IP Pools"
current={poolName}
items={items}
noItemsText="No IP pools found"
/>
)
}

/** Used when drilling down into a VPC from the Silo view. */
export function VpcPicker() {
// picker only shows up when a VPC is in scope
const { project, vpc } = useVpcSelector()
const { data } = useApiQuery('vpcList', { query: { project, limit: PAGE_SIZE } })
const items = (data?.items || []).map((v) => ({
label: v.name,
to: pb.vpc({ project, vpc: v.name }),
}))

return (
<TopBarPicker
aria-label="Switch VPC"
category="VPC"
current={vpc}
items={items}
noItemsText="No VPCs found"
to={pb.vpc({ project, vpc })}
/>
)
}

/** Used when drilling down into a VPC Router from the Silo view. */
export function VpcRouterPicker() {
// picker only shows up when a router is in scope
const { project, vpc, router } = useVpcRouterSelector()
const { data } = useApiQuery('vpcRouterList', {
query: { project, vpc, limit: PAGE_SIZE },
})
const items = (data?.items || []).map((r) => ({
label: r.name,
to: pb.vpcRouter({ vpc, project, router: r.name }),
}))

return (
<TopBarPicker
aria-label="Switch router"
category="router"
current={router}
items={items}
noItemsText="No routers found"
/>
)
}

const NoProjectLogo = () => (
<div className="flex h-[34px] w-[34px] items-center justify-center rounded text-secondary bg-secondary">
<Folder16Icon />
</div>
)

export function ProjectPicker({ project }: { project?: Project }) {
const { data: projects } = useApiQuery('projectList', { query: { limit: 200 } })
const items = (projects?.items || []).map(({ name }) => ({
label: name,
to: pb.project({ project: name }),
}))

return (
<TopBarPicker
aria-label="Switch project"
icon={project ? undefined : <NoProjectLogo />}
category="Project"
current={project?.name}
to={project ? pb.project({ project: project.name }) : undefined}
items={items}
noItemsText="No projects found"
/>
)
}

export function InstancePicker() {
// picker only shows up when an instance is in scope
const instanceSelector = useInstanceSelector()
const { project, instance } = instanceSelector
const { data: instances } = useApiQuery('instanceList', {
query: { project, limit: PAGE_SIZE },
})
const items = (instances?.items || []).map(({ name }) => ({
label: name,
to: pb.instance({ project, instance: name }),
}))
return (
<TopBarPicker
aria-label="Switch instance"
category="Instance"
current={instance}
to={pb.instance({ project, instance })}
items={items}
noItemsText="No instances found"
/>
)
}

export function SledPicker() {
// picker only shows up when a sled is in scope
const { sledId } = useSledParams()
const { data: sleds } = useApiQuery('sledList', {
query: { limit: PAGE_SIZE },
})
const items = (sleds?.items || []).map(({ id }) => ({
label: id,
to: pb.sled({ sledId: id }),
}))
return (
<TopBarPicker
aria-label="Switch sled"
category="Sled"
current={sledId}
to={pb.sled({ sledId })}
items={items}
noItemsText="No sleds found"
/>
)
}
71 changes: 71 additions & 0 deletions app/hooks/use-crumbs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* 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 { useMatches, type Params, type UIMatch } from 'react-router-dom'

import { invariant } from '~/util/invariant'

type Crumb = {
crumb: MakeStr
/**
* Side modal forms have their own routes and their own crumbs that we want
* in the page title, but it's weird for them to affect the nav breadcrumbs
* because the side modal form opens on top of the page with an overlay
* covering the background and not interactive. It feels weird for the
* breadcrumbs to change in the background when you open a form. So we use
* `titleOnly` to mark the form crumbs as not part of the nav breadcrumbs.
*/
titleOnly?: true
/**
* Some route nodes don't have their own pages, but rather just redirect
* immediately to their first child node. In this case, we need the crumb to
* link directly to that child, otherwise we get a weird flash due to linking
* to the parent node and waiting for the redirect.
*/
path?: MakeStr
}

type MatchWithCrumb = UIMatch<unknown, Crumb>

type MakeStr = string | ((p: Params) => string)

/** Helper to make crumb definitions less verbose */
export const makeCrumb = (crumb: MakeStr, path?: MakeStr) => ({ crumb, path })

function hasCrumb(m: UIMatch): m is MatchWithCrumb {
return !!(m.handle && typeof m.handle === 'object' && 'crumb' in m.handle)
}

/**
* Throw if crumb is not a string or function. It would be nice if TS enforced
* this at the `<Route>` call, but overriding the type declarations is hard and
* `createRoutesFromChildren` rejects a custom Route component.
*/
function checkCrumbType(m: MatchWithCrumb): MatchWithCrumb {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const crumbType = typeof m.handle.crumb
invariant(
crumbType === 'string' || crumbType === 'function',
`Route crumb must be a string or function if present. Check <Route> for ${m.pathname}.`
)
return m
}

export const matchesToCrumbs = (matches: UIMatch[]) =>
matches
.filter(hasCrumb)
.map(checkCrumbType)
.map((m) => {
const { crumb, path } = m.handle
return {
label: typeof crumb === 'function' ? crumb(m.params) : crumb,
path: typeof path === 'function' ? path(m.params) : path || m.pathname,
titleOnly: !!m.handle.titleOnly,
}
})

export const useCrumbs = () => matchesToCrumbs(useMatches())
Loading
Loading