Skip to content

Commit 22482fc

Browse files
convert silo page tabs to route-based navigation (#2912)
* convert silo page tabs to path-based routing * update snapshots for routes * Update navigation within test * Update tests * don't need to wait for visible, lockfile changes * make silo base route private, make IdPs route canonical * fix idps tab content disappearing when side modal is open * not worried about backwards compatibility with query params * might as well use apiq in new code * use invalidateEndpoint --------- Co-authored-by: David Crespo <[email protected]>
1 parent 5c393b4 commit 22482fc

File tree

12 files changed

+216
-124
lines changed

12 files changed

+216
-124
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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+
9+
import { usePrefetchedApiQuery } from '@oxide/api'
10+
import { Cloud24Icon, NextArrow12Icon } from '@oxide/design-system/icons/react'
11+
12+
import { useSiloSelector } from '~/hooks/use-params'
13+
import { Badge } from '~/ui/lib/Badge'
14+
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
15+
import { TableEmptyBox } from '~/ui/lib/Table'
16+
17+
export default function SiloFleetRolesTab() {
18+
const siloSelector = useSiloSelector()
19+
const { data: silo } = usePrefetchedApiQuery('siloView', { path: siloSelector })
20+
21+
const roleMapPairs = Object.entries(silo.mappedFleetRoles).flatMap(
22+
([fleetRole, siloRoles]) =>
23+
siloRoles.map((siloRole) => [siloRole, fleetRole] as [string, string])
24+
)
25+
26+
if (roleMapPairs.length === 0) {
27+
return (
28+
<TableEmptyBox>
29+
<EmptyMessage
30+
icon={<Cloud24Icon />}
31+
title="Mapped fleet roles"
32+
// TODO: better empty state explaining that no roles are mapped so nothing will happen
33+
body="Silo roles can automatically grant a fleet role. This silo has no role mappings configured."
34+
/>
35+
</TableEmptyBox>
36+
)
37+
}
38+
39+
return (
40+
<>
41+
<p className="mb-4 text-default">Silo roles can automatically grant a fleet role.</p>
42+
<ul className="space-y-3">
43+
{roleMapPairs.map(([siloRole, fleetRole]) => (
44+
<li key={siloRole + '|' + fleetRole} className="flex items-center">
45+
<Badge>Silo {siloRole}</Badge>
46+
<NextArrow12Icon className="mx-3 text-default" aria-label="maps to" />
47+
<span className="text-sans-md text-default">Fleet {fleetRole}</span>
48+
</li>
49+
))}
50+
</ul>
51+
</>
52+
)
53+
}

app/pages/system/silos/SiloIdpsTab.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@
77
*/
88
import { createColumnHelper } from '@tanstack/react-table'
99
import { useMemo } from 'react'
10-
import { Outlet } from 'react-router'
10+
import { Outlet, type LoaderFunctionArgs } from 'react-router'
1111

1212
import { Cloud24Icon } from '@oxide/design-system/icons/react'
1313

14-
import { getListQFn, type IdentityProvider } from '~/api'
15-
import { useSiloSelector } from '~/hooks/use-params'
14+
import { getListQFn, queryClient, type IdentityProvider } from '~/api'
15+
import { getSiloSelector, useSiloSelector } from '~/hooks/use-params'
1616
import { LinkCell } from '~/table/cells/LinkCell'
1717
import { Columns } from '~/table/columns/common'
1818
import { useQueryTable } from '~/table/QueryTable'
@@ -30,7 +30,13 @@ const colHelper = createColumnHelper<IdentityProvider>()
3030
export const siloIdpList = (silo: string) =>
3131
getListQFn('siloIdentityProviderList', { query: { silo } })
3232

33-
export function SiloIdpsTab() {
33+
export async function clientLoader({ params }: LoaderFunctionArgs) {
34+
const { silo } = getSiloSelector(params)
35+
await queryClient.prefetchQuery(siloIdpList(silo).optionsFn())
36+
return null
37+
}
38+
39+
export default function SiloIdpsTab() {
3440
const { silo } = useSiloSelector()
3541

3642
const columns = useMemo(

app/pages/system/silos/SiloIpPoolsTab.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,20 @@ import { useQuery } from '@tanstack/react-query'
1010
import { createColumnHelper } from '@tanstack/react-table'
1111
import { useCallback, useMemo, useState } from 'react'
1212
import { useForm } from 'react-hook-form'
13+
import { type LoaderFunctionArgs } from 'react-router'
1314

14-
import { getListQFn, useApiMutation, useApiQueryClient, type SiloIpPool } from '@oxide/api'
15+
import {
16+
getListQFn,
17+
queryClient,
18+
useApiMutation,
19+
useApiQueryClient,
20+
type SiloIpPool,
21+
} from '@oxide/api'
1522
import { Networking24Icon } from '@oxide/design-system/icons/react'
1623

1724
import { ComboboxField } from '~/components/form/fields/ComboboxField'
1825
import { HL } from '~/components/HL'
19-
import { useSiloSelector } from '~/hooks/use-params'
26+
import { getSiloSelector, useSiloSelector } from '~/hooks/use-params'
2027
import { confirmAction } from '~/stores/confirm-action'
2128
import { addToast } from '~/stores/toast'
2229
import { DefaultPoolCell } from '~/table/cells/DefaultPoolCell'
@@ -62,7 +69,13 @@ const allSiloPoolsQuery = (silo: string) =>
6269
export const siloIpPoolsQuery = (silo: string) =>
6370
getListQFn('siloIpPoolList', { path: { silo } })
6471

65-
export function SiloIpPoolsTab() {
72+
export async function clientLoader({ params }: LoaderFunctionArgs) {
73+
const { silo } = getSiloSelector(params)
74+
await queryClient.prefetchQuery(siloIpPoolsQuery(silo).optionsFn())
75+
return null
76+
}
77+
78+
export default function SiloIpPoolsTab() {
6679
const { silo } = useSiloSelector()
6780
const [showLinkModal, setShowLinkModal] = useState(false)
6881
const queryClient = useApiQueryClient()

app/pages/system/silos/SiloPage.tsx

Lines changed: 12 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -7,33 +7,21 @@
77
*/
88
import { type LoaderFunctionArgs } from 'react-router'
99

10-
import { apiQueryClient, queryClient, usePrefetchedApiQuery } from '@oxide/api'
11-
import { Cloud16Icon, Cloud24Icon, NextArrow12Icon } from '@oxide/design-system/icons/react'
10+
import { Cloud16Icon, Cloud24Icon } from '@oxide/design-system/icons/react'
1211

12+
import { apiq, queryClient, usePrefetchedQuery } from '~/api'
1313
import { DocsPopover } from '~/components/DocsPopover'
14-
import { QueryParamTabs } from '~/components/QueryParamTabs'
14+
import { RouteTabs, Tab } from '~/components/RouteTabs'
1515
import { makeCrumb } from '~/hooks/use-crumbs'
1616
import { getSiloSelector, useSiloSelector } from '~/hooks/use-params'
17-
import { Badge } from '~/ui/lib/Badge'
18-
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
1917
import { PageHeader, PageTitle } from '~/ui/lib/PageHeader'
2018
import { PropertiesTable } from '~/ui/lib/PropertiesTable'
21-
import { TableEmptyBox } from '~/ui/lib/Table'
22-
import { Tabs } from '~/ui/lib/Tabs'
2319
import { docLinks } from '~/util/links'
24-
25-
import { siloIdpList, SiloIdpsTab } from './SiloIdpsTab'
26-
import { siloIpPoolsQuery, SiloIpPoolsTab } from './SiloIpPoolsTab'
27-
import { SiloQuotasTab } from './SiloQuotasTab'
20+
import { pb } from '~/util/path-builder'
2821

2922
export async function clientLoader({ params }: LoaderFunctionArgs) {
3023
const { silo } = getSiloSelector(params)
31-
await Promise.all([
32-
apiQueryClient.prefetchQuery('siloView', { path: { silo } }),
33-
apiQueryClient.prefetchQuery('siloUtilizationView', { path: { silo } }),
34-
queryClient.prefetchQuery(siloIdpList(silo).optionsFn()),
35-
queryClient.prefetchQuery(siloIpPoolsQuery(silo).optionsFn()),
36-
])
24+
await queryClient.prefetchQuery(apiq('siloView', { path: { silo } }))
3725
return null
3826
}
3927

@@ -42,12 +30,7 @@ export const handle = makeCrumb((p) => p.silo!)
4230
export default function SiloPage() {
4331
const siloSelector = useSiloSelector()
4432

45-
const { data: silo } = usePrefetchedApiQuery('siloView', { path: siloSelector })
46-
47-
const roleMapPairs = Object.entries(silo.mappedFleetRoles).flatMap(
48-
([fleetRole, siloRoles]) =>
49-
siloRoles.map((siloRole) => [siloRole, fleetRole] as [string, string])
50-
)
33+
const { data: silo } = usePrefetchedQuery(apiq('siloView', { path: siloSelector }))
5134

5235
return (
5336
<>
@@ -73,50 +56,12 @@ export default function SiloPage() {
7356
<PropertiesTable.DateRow date={silo.timeModified} label="Last Modified" />
7457
</PropertiesTable>
7558

76-
<QueryParamTabs className="full-width" defaultValue="idps">
77-
<Tabs.List>
78-
<Tabs.Trigger value="idps">Identity Providers</Tabs.Trigger>
79-
<Tabs.Trigger value="ip-pools">IP Pools</Tabs.Trigger>
80-
<Tabs.Trigger value="quotas">Quotas</Tabs.Trigger>
81-
<Tabs.Trigger value="fleet-roles">Fleet roles</Tabs.Trigger>
82-
</Tabs.List>
83-
<Tabs.Content value="idps">
84-
<SiloIdpsTab />
85-
</Tabs.Content>
86-
<Tabs.Content value="ip-pools">
87-
<SiloIpPoolsTab />
88-
</Tabs.Content>
89-
<Tabs.Content value="quotas">
90-
<SiloQuotasTab />
91-
</Tabs.Content>
92-
<Tabs.Content value="fleet-roles">
93-
{/* TODO: better empty state explaining that no roles are mapped so nothing will happen */}
94-
{roleMapPairs.length === 0 ? (
95-
<TableEmptyBox>
96-
<EmptyMessage
97-
icon={<Cloud24Icon />}
98-
title="Mapped fleet roles"
99-
body="Silo roles can automatically grant a fleet role. This silo has no role mappings configured."
100-
/>
101-
</TableEmptyBox>
102-
) : (
103-
<>
104-
<p className="mb-4 text-default">
105-
Silo roles can automatically grant a fleet role.
106-
</p>
107-
<ul className="space-y-3">
108-
{roleMapPairs.map(([siloRole, fleetRole]) => (
109-
<li key={siloRole + '|' + fleetRole} className="flex items-center">
110-
<Badge>Silo {siloRole}</Badge>
111-
<NextArrow12Icon className="mx-3 text-default" aria-label="maps to" />
112-
<span className="text-sans-md text-default">Fleet {fleetRole}</span>
113-
</li>
114-
))}
115-
</ul>
116-
</>
117-
)}
118-
</Tabs.Content>
119-
</QueryParamTabs>
59+
<RouteTabs fullWidth>
60+
<Tab to={pb.siloIdps(siloSelector)}>Identity Providers</Tab>
61+
<Tab to={pb.siloIpPools(siloSelector)}>IP Pools</Tab>
62+
<Tab to={pb.siloQuotas(siloSelector)}>Quotas</Tab>
63+
<Tab to={pb.siloFleetRoles(siloSelector)}>Fleet roles</Tab>
64+
</RouteTabs>
12065
</>
12166
)
12267
}

app/pages/system/silos/SiloQuotasTab.tsx

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,19 @@
88

99
import { useState } from 'react'
1010
import { useForm } from 'react-hook-form'
11+
import { type LoaderFunctionArgs } from 'react-router'
1112
import type { SetNonNullable } from 'type-fest'
1213

1314
import {
14-
apiQueryClient,
15+
apiq,
16+
queryClient,
1517
useApiMutation,
16-
usePrefetchedApiQuery,
18+
usePrefetchedQuery,
1719
type SiloQuotasUpdate,
1820
} from '~/api'
1921
import { NumberField } from '~/components/form/fields/NumberField'
2022
import { SideModalForm } from '~/components/form/SideModalForm'
21-
import { useSiloSelector } from '~/hooks/use-params'
23+
import { getSiloSelector, useSiloSelector } from '~/hooks/use-params'
2224
import { addToast } from '~/stores/toast'
2325
import { Button } from '~/ui/lib/Button'
2426
import { Message } from '~/ui/lib/Message'
@@ -29,11 +31,17 @@ import { bytesToGiB, GiB } from '~/util/units'
2931

3032
const Unit = classed.span`ml-1 text-secondary`
3133

32-
export function SiloQuotasTab() {
34+
export async function clientLoader({ params }: LoaderFunctionArgs) {
35+
const { silo } = getSiloSelector(params)
36+
await queryClient.prefetchQuery(apiq('siloUtilizationView', { path: { silo } }))
37+
return null
38+
}
39+
40+
export default function SiloQuotasTab() {
3341
const { silo } = useSiloSelector()
34-
const { data: utilization } = usePrefetchedApiQuery('siloUtilizationView', {
35-
path: { silo: silo },
36-
})
42+
const { data: utilization } = usePrefetchedQuery(
43+
apiq('siloUtilizationView', { path: { silo } })
44+
)
3745

3846
const { allocated: quotas, provisioned } = utilization
3947

@@ -91,9 +99,11 @@ export function SiloQuotasTab() {
9199

92100
function EditQuotasForm({ onDismiss }: { onDismiss: () => void }) {
93101
const { silo } = useSiloSelector()
94-
const { data: utilization } = usePrefetchedApiQuery('siloUtilizationView', {
95-
path: { silo: silo },
96-
})
102+
const { data: utilization } = usePrefetchedQuery(
103+
apiq('siloUtilizationView', {
104+
path: { silo: silo },
105+
})
106+
)
97107
const quotas = utilization.allocated
98108

99109
// required because we need to rule out undefined because NumberField hates that
@@ -107,7 +117,7 @@ function EditQuotasForm({ onDismiss }: { onDismiss: () => void }) {
107117

108118
const updateQuotas = useApiMutation('siloQuotasUpdate', {
109119
onSuccess() {
110-
apiQueryClient.invalidateQueries('siloUtilizationView')
120+
queryClient.invalidateEndpoint('siloUtilizationView')
111121
addToast({ content: 'Quotas updated' })
112122
onDismiss()
113123
},

app/routes.tsx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,13 +129,30 @@ export const routes = createRoutesFromElements(
129129
path=":silo"
130130
lazy={() => import('./pages/system/silos/SiloPage').then(convert)}
131131
>
132+
{/* Nesting keeps IdPs tab contents rendered when side modals are open*/}
133+
<Route index element={<Navigate to="idps" replace />} />
134+
<Route lazy={() => import('./pages/system/silos/SiloIdpsTab').then(convert)}>
135+
<Route path="idps" element={null} />
136+
<Route
137+
path="idps-new"
138+
lazy={() => import('./forms/idp/create').then(convert)}
139+
/>
140+
<Route
141+
path="idps/saml/:provider"
142+
lazy={() => import('./forms/idp/edit').then(convert)}
143+
/>
144+
</Route>
145+
<Route
146+
path="ip-pools"
147+
lazy={() => import('./pages/system/silos/SiloIpPoolsTab').then(convert)}
148+
/>
132149
<Route
133-
path="idps-new"
134-
lazy={() => import('./forms/idp/create').then(convert)}
150+
path="quotas"
151+
lazy={() => import('./pages/system/silos/SiloQuotasTab').then(convert)}
135152
/>
136153
<Route
137-
path="idps/saml/:provider"
138-
lazy={() => import('./forms/idp/edit').then(convert)}
154+
path="fleet-roles"
155+
lazy={() => import('./pages/system/silos/SiloFleetRolesTab').then(convert)}
139156
/>
140157
</Route>
141158
</Route>

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

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -537,7 +537,7 @@ exports[`breadcrumbs 2`] = `
537537
"path": "/projects/p/instances/i/serial-console",
538538
},
539539
],
540-
"silo (/system/silos/s)": [
540+
"silo (/system/silos/s/idps)": [
541541
{
542542
"label": "Silos",
543543
"path": "/system/silos",
@@ -553,6 +553,26 @@ exports[`breadcrumbs 2`] = `
553553
"path": "/access",
554554
},
555555
],
556+
"siloFleetRoles (/system/silos/s/fleet-roles)": [
557+
{
558+
"label": "Silos",
559+
"path": "/system/silos",
560+
},
561+
{
562+
"label": "s",
563+
"path": "/system/silos/s",
564+
},
565+
],
566+
"siloIdps (/system/silos/s/idps)": [
567+
{
568+
"label": "Silos",
569+
"path": "/system/silos",
570+
},
571+
{
572+
"label": "s",
573+
"path": "/system/silos/s",
574+
},
575+
],
556576
"siloIdpsNew (/system/silos/s/idps-new)": [
557577
{
558578
"label": "Silos",
@@ -575,7 +595,17 @@ exports[`breadcrumbs 2`] = `
575595
"path": "/images",
576596
},
577597
],
578-
"siloIpPools (/system/silos/s?tab=ip-pools)": [
598+
"siloIpPools (/system/silos/s/ip-pools)": [
599+
{
600+
"label": "Silos",
601+
"path": "/system/silos",
602+
},
603+
{
604+
"label": "s",
605+
"path": "/system/silos/s",
606+
},
607+
],
608+
"siloQuotas (/system/silos/s/quotas)": [
579609
{
580610
"label": "Silos",
581611
"path": "/system/silos",

0 commit comments

Comments
 (0)