Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
aaa586c
Move instance tabs to routes
just-be-dev Oct 28, 2022
be722f8
Remove unused paths from pb
just-be-dev Oct 28, 2022
a212deb
Update path builder snapshots
just-be-dev Oct 28, 2022
35cde4a
Add roles to route tabs; fix tab names
just-be-dev Oct 28, 2022
88aad28
Merge branch 'main' into route-tabs
just-be-dev Oct 28, 2022
59dc7f4
Fix navigation issues in tests
just-be-dev Oct 28, 2022
f500bc6
Remove serial console page for now
just-be-dev Oct 28, 2022
abf5e95
Merge branch 'main' into route-tabs
just-be-dev Oct 31, 2022
0220001
Lazy load metrics tab
just-be-dev Oct 31, 2022
74a03bd
Merge branch 'main' into route-tabs
just-be-dev Nov 2, 2022
047dd93
Merge branch 'main' into route-tabs
just-be-dev Nov 2, 2022
85b5d68
Merge branch 'main' into route-tabs
just-be-dev Nov 7, 2022
1aadbff
Update props to be more explicit
just-be-dev Nov 7, 2022
615229c
Correct instance tabs paths
just-be-dev Nov 7, 2022
8f7c4f5
Update routetabs to be closer to full aria spec
just-be-dev Nov 7, 2022
4a14b29
Add comment about describedby
just-be-dev Nov 7, 2022
bd8ae69
Implement wrap around behavior for arrows
just-be-dev Nov 8, 2022
57854aa
Experimentally solve routing issue
just-be-dev Nov 8, 2022
c8fd325
Update route tests
just-be-dev Nov 8, 2022
e0c2026
fix e2e paths
just-be-dev Nov 8, 2022
bc426a5
Merge branch 'main' into route-tabs
just-be-dev Nov 9, 2022
ac1cb45
Move argument into options of useIsActivePath
just-be-dev Nov 9, 2022
88bd3e9
Fix tab layout styles
just-be-dev Nov 10, 2022
df542a4
Merge branch 'main' into route-tabs
just-be-dev Nov 10, 2022
a0743a3
Fix tab panel focus ring
just-be-dev Nov 10, 2022
782dead
Improve instancePage comment
just-be-dev Nov 10, 2022
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
66 changes: 66 additions & 0 deletions app/components/RouteTabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import cn from 'classnames'
import type { ReactNode } from 'react'
import { Link, Outlet } from 'react-router-dom'

import { useIsActivePath } from 'app/hooks/use-is-active-path'

const selectTab = (e: React.KeyboardEvent<HTMLDivElement>) => {
const target = e.target as HTMLDivElement
if (e.key === 'ArrowLeft') {
e.stopPropagation()
e.preventDefault()

const sibling = (target.previousSibling ??
target.parentElement!.lastChild!) as HTMLDivElement

sibling.focus()
sibling.click()
} else if (e.key === 'ArrowRight') {
e.stopPropagation()
e.preventDefault()

const sibling = (target.nextSibling ??
target.parentElement!.firstChild!) as HTMLDivElement

sibling.focus()
sibling.click()
}
}

export interface RouteTabsProps {
children: ReactNode
fullWidth?: boolean
}
export function RouteTabs({ children, fullWidth }: RouteTabsProps) {
return (
<div className={cn('ox-tabs', { 'full-width': fullWidth })}>
{/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */}
<div role="tablist" className="ox-tabs-list flex" onKeyDown={selectTab}>
{children}
</div>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wonder if this should be a <nav>

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh and maybe it's not really a role=tablist? From a control POV it's really just a nav menu styled like tabs unless we implement arrow key behavior. Overall kind of unsure about how to think about this from an a11y perspective.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the accessibility behavior here should be tabs. Given where it's at in the flow and the fact that it's tied with the panel I think that's what makes the most sense here. I've updated the route tabs to be closer to the proper aria definition of tabs. The only thing that I know that I'm missing is the aria-describedby for the panel to tab relationship. I'm not home right now so I can't really thoroughly test it with my typical screen reader, but keyboard nav works now mostly as you'd expect.

{/* TODO: Add aria-describedby for active tab */}
<div className="ox-tabs-panel" role="tabpanel" tabIndex={0}>
<Outlet />
</div>
</div>
)
}

export interface TabProps {
to: string
children: ReactNode
}
export const Tab = ({ to, children }: TabProps) => {
const isActive = useIsActivePath({ to })
return (
<Link
role="tab"
to={to}
className={cn('ox-tab', { 'is-selected': isActive })}
tabIndex={isActive ? 0 : -1}
aria-selected={isActive}
>
<div>{children}</div>
</Link>
)
}
2 changes: 1 addition & 1 deletion app/forms/instance-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export function CreateInstanceForm() {
title: 'Success!',
content: 'Your instance has been created.',
})
navigate(pb.instance({ ...pageParams, instanceName: instance.name }))
navigate(pb.instancePage({ ...pageParams, instanceName: instance.name }))
},
})

Expand Down
30 changes: 30 additions & 0 deletions app/hooks/use-is-active-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useLocation, useResolvedPath } from 'react-router-dom'

interface ActivePathOptions {
to: string
end?: boolean
}
/**
* Returns true if the provided path is currently active.
*
* This implementation is based on logic from React Router's NavLink component.
*
* @see https://github.com/remix-run/react-router/blob/67f16e73603765158c63a27afb70d3a4b3e823d3/packages/react-router-dom/index.tsx#L448-L467
*
* @param to The path to check
* @param options.end Ensure this path isn't matched as "active" when its descendant paths are matched.
*/
export const useIsActivePath = ({ to, end }: ActivePathOptions) => {
const path = useResolvedPath(to)
const location = useLocation()

const toPathname = path.pathname
const locationPathname = location.pathname

return (
locationPathname === toPathname ||
(!end &&
locationPathname.startsWith(toPathname) &&
locationPathname.charAt(toPathname.length) === '/')
)
}
2 changes: 1 addition & 1 deletion app/pages/__tests__/click-everything.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ test("Click through everything and make it's all there", async ({ page }) => {
'role=heading[name*=db1]',
'role=tab[name="Storage"]',
'role=tab[name="Metrics"]',
'role=tab[name="Networking"]',
'role=tab[name="Network Interfaces"]',
'role=table[name="Boot disk"] >> role=cell[name="disk-1"]',
'role=table[name="Attached disks"] >> role=cell[name="disk-2"]',
// buttons disabled while instance is running
Expand Down
2 changes: 1 addition & 1 deletion app/pages/__tests__/instance/networking.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ test('Instance networking tab', async ({ page }) => {
await page.goto('/orgs/maze-war/projects/mock-project/instances/db1')

// Instance networking tab
await page.click('role=tab[name="Networking"]')
await page.click('role=tab[name="Network Interfaces"]')

const table = page.locator('table')
await expectRowVisible(table, { name: 'my-nic', primary: 'primary' })
Expand Down
2 changes: 1 addition & 1 deletion app/pages/project/disks/DisksPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ function AttachedInstance({
return instance ? (
<Link
className="text-sans-semi-md text-accent hover:underline"
to={pb.instance({ orgName, projectName, instanceName: instance.name })}
to={pb.instancePage({ orgName, projectName, instanceName: instance.name })}
>
{instance.name}
</Link>
Expand Down
5 changes: 3 additions & 2 deletions app/pages/project/instances/InstancesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ export function InstancesPage() {
{ value: 'New instance', onSelect: () => navigate(pb.instanceNew(projectParams)) },
...(instances?.items || []).map((i) => ({
value: i.name,
onSelect: () => navigate(pb.instance({ ...projectParams, instanceName: i.name })),
onSelect: () =>
navigate(pb.instancePage({ ...projectParams, instanceName: i.name })),
navGroup: 'Go to instance',
})),
],
Expand Down Expand Up @@ -103,7 +104,7 @@ export function InstancesPage() {
<Column
accessor="name"
cell={linkCell((instanceName) =>
pb.instance({ orgName, projectName, instanceName })
pb.instancePage({ orgName, projectName, instanceName })
)}
/>
<Column
Expand Down
41 changes: 9 additions & 32 deletions app/pages/project/instances/instance/InstancePage.tsx
Original file line number Diff line number Diff line change
@@ -1,47 +1,19 @@
import filesize from 'filesize'
import React, { Suspense, memo, useMemo } from 'react'
import { useMemo } from 'react'
import type { LoaderFunctionArgs } from 'react-router-dom'
import { useNavigate } from 'react-router-dom'

import { apiQueryClient, useApiQuery, useApiQueryClient } from '@oxide/api'
import { Instances24Icon, PageHeader, PageTitle, PropertiesTable, Tab } from '@oxide/ui'
import { Instances24Icon, PageHeader, PageTitle, PropertiesTable } from '@oxide/ui'
import { pick } from '@oxide/util'

import { MoreActionsMenu } from 'app/components/MoreActionsMenu'
import { RouteTabs, Tab } from 'app/components/RouteTabs'
import { InstanceStatusBadge } from 'app/components/StatusBadge'
import { Tabs } from 'app/components/Tabs'
import { requireInstanceParams, useQuickActions, useRequiredParams } from 'app/hooks'
import { pb } from 'app/util/path-builder'

import { useMakeInstanceActions } from '../actions'
import { NetworkingTab } from './tabs/NetworkingTab'
import { SerialConsoleTab } from './tabs/SerialConsoleTab'
import { StorageTab } from './tabs/StorageTab'

const MetricsTab = React.lazy(() => import('./tabs/MetricsTab'))

const InstanceTabs = memo(() => (
<Tabs id="tabs-instance" fullWidth>
<Tab>Storage</Tab>
<Tab.Panel>
<StorageTab />
</Tab.Panel>
<Tab>Metrics</Tab>
<Tab.Panel>
<Suspense fallback={null}>
<MetricsTab />
</Suspense>
</Tab.Panel>
<Tab>Networking</Tab>
<Tab.Panel>
<NetworkingTab />
</Tab.Panel>
<Tab>Serial Console</Tab>
<Tab.Panel>
<SerialConsoleTab />
</Tab.Panel>
</Tabs>
))

InstancePage.loader = async ({ params }: LoaderFunctionArgs) => {
await apiQueryClient.prefetchQuery('instanceView', {
Expand Down Expand Up @@ -111,7 +83,12 @@ export function InstancePage() {
</PropertiesTable.Row>
</PropertiesTable>
</PropertiesTable.Group>
<InstanceTabs />
<RouteTabs fullWidth>
<Tab to={pb.instanceStorage(instanceParams)}>Storage</Tab>
<Tab to={pb.instanceMetrics(instanceParams)}>Metrics</Tab>
<Tab to={pb.nics(instanceParams)}>Network Interfaces</Tab>
<Tab to={pb.serialConsole(instanceParams)}>Serial Console</Tab>
</RouteTabs>
</>
)
}
40 changes: 0 additions & 40 deletions app/pages/project/instances/instance/SerialConsolePage.tsx

This file was deleted.

2 changes: 1 addition & 1 deletion app/pages/project/instances/instance/tabs/MetricsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ function DiskMetrics({ disks }: { disks: Disk[] }) {

return (
<>
<div className="mt-8 mb-4 flex justify-between">
<div className="mb-4 flex justify-between">
<Listbox
className="w-48"
aria-label="Choose disk"
Expand Down
4 changes: 2 additions & 2 deletions app/pages/project/instances/instance/tabs/StorageTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export function StorageTab() {
if (!data) return null

return (
<div className="mt-8">
<>
<h2 id={bootLabelId} className="mb-4 text-mono-sm text-secondary">
Boot disk
</h2>
Expand Down Expand Up @@ -175,6 +175,6 @@ export function StorageTab() {
{showDiskAttach && (
<AttachDiskSideModalForm onDismiss={() => setShowDiskAttach(false)} />
)}
</div>
</>
)
}
46 changes: 38 additions & 8 deletions app/routes.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react'
import React, { Suspense } from 'react'
import { Navigate, Route, createRoutesFromElements } from 'react-router-dom'

import { RouterDataErrorBoundary } from './components/ErrorBoundary'
Expand Down Expand Up @@ -40,12 +40,18 @@ import {
VpcPage,
VpcsPage,
} from './pages/project'
import { SerialConsolePage } from './pages/project/instances/instance/SerialConsolePage'
import { NetworkingTab } from './pages/project/instances/instance/tabs/NetworkingTab'
import { SerialConsoleTab } from './pages/project/instances/instance/tabs/SerialConsoleTab'
import { StorageTab } from './pages/project/instances/instance/tabs/StorageTab'
import { ProfilePage } from './pages/settings/ProfilePage'
import { SSHKeysPage } from './pages/settings/SSHKeysPage'
import SilosPage from './pages/system/SilosPage'
import { pb } from './util/path-builder'

const MetricsTab = React.lazy(
() => import('./pages/project/instances/instance/tabs/MetricsTab')
)

const orgCrumb: CrumbFunc = (m) => m.params.orgName!
const projectCrumb: CrumbFunc = (m) => m.params.projectName!
const instanceCrumb: CrumbFunc = (m) => m.params.instanceName!
Expand Down Expand Up @@ -162,15 +168,39 @@ export const routes = createRoutesFromElements(
loader={CreateInstanceForm.loader}
handle={{ crumb: 'New instance' }}
/>
<Route
path="instances/:instanceName"
element={<Navigate to="storage" replace />}
/>
<Route path="instances" handle={{ crumb: 'Instances' }}>
<Route index element={<InstancesPage />} loader={InstancesPage.loader} />
<Route path=":instanceName" handle={{ crumb: instanceCrumb }}>
<Route index element={<InstancePage />} loader={InstancePage.loader} />
<Route
path="serial-console"
element={<SerialConsolePage />}
handle={{ crumb: 'serial-console' }}
/>
<Route element={<InstancePage />} loader={InstancePage.loader}>
<Route
path="storage"
element={<StorageTab />}
handle={{ crumb: 'storage' }}
/>
<Route
path="network-interfaces"
element={<NetworkingTab />}
handle={{ crumb: 'network-interfaces' }}
/>
<Route
path="metrics"
element={
<Suspense fallback={null}>
<MetricsTab />
</Suspense>
}
handle={{ crumb: 'metrics' }}
/>
<Route
path="serial-console"
element={<SerialConsoleTab />}
handle={{ crumb: 'serial-console' }}
/>
</Route>
</Route>
</Route>

Expand Down
7 changes: 3 additions & 4 deletions app/test/instance-create.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { globalImages } from '@oxide/api-mocks'

import { expectVisible, test } from 'app/test/e2e'
import { pb } from 'app/util/path-builder'

test.beforeEach(async ({ createProject, orgName, projectName }) => {
await createProject(orgName, projectName)
Expand All @@ -12,7 +13,7 @@ test('can invoke instance create form from instances page', async ({
projectName,
genName,
}) => {
await page.goto(`/orgs/${orgName}/projects/${projectName}/instances`)
await page.goto(pb.instances({ orgName, projectName }))
await page.locator('text="New Instance"').click()

await expectVisible(page, [
Expand Down Expand Up @@ -41,9 +42,7 @@ test('can invoke instance create form from instances page', async ({

await page.locator('button:has-text("Create instance")').click()

await page.waitForURL(
`/orgs/${orgName}/projects/${projectName}/instances/${instanceName}`
)
await page.waitForURL(pb.instancePage({ orgName, projectName, instanceName }))

await expectVisible(page, [
`h1:has-text("${instanceName}")`,
Expand Down
4 changes: 4 additions & 0 deletions app/util/path-builder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,12 @@ test('path builder', () => {
"diskNew": "/orgs/a/projects/b/disks-new",
"disks": "/orgs/a/projects/b/disks",
"instance": "/orgs/a/projects/b/instances/c",
"instanceMetrics": "/orgs/a/projects/b/instances/c/metrics",
"instanceNew": "/orgs/a/projects/b/instances-new",
"instancePage": "/orgs/a/projects/b/instances/c/storage",
"instanceStorage": "/orgs/a/projects/b/instances/c/storage",
"instances": "/orgs/a/projects/b/instances",
"nics": "/orgs/a/projects/b/instances/c/network-interfaces",
"org": "/orgs/a",
"orgAccess": "/orgs/a/access",
"orgEdit": "/orgs/a/edit",
Expand Down
Loading