Skip to content

Commit b4b1103

Browse files
Switch VPC tab bar to route tabs (#2249)
* Switch param to route tabs: VPC firewall rules and subnets * Test fixes * Add missing paths to test * clean up vpc and instance detail in path builder * fix warning on firewall rules leaf route without element * clean up path params and path builder stuff a bit * tweaks * cut some lines * don't need to prefetch vpcView on the tabs, parent route covers it * 404 on edit non-existent rule * don't need to prefetch vpcView on firewall rule create form either --------- Co-authored-by: David Crespo <david.crespo@oxidecomputer.com>
1 parent dcec501 commit b4b1103

19 files changed

+302
-159
lines changed

app/api/path-params.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export type NetworkInterface = Merge<Instance, { interface?: string }>
1616
export type Snapshot = Merge<Project, { snapshot?: string }>
1717
export type Vpc = Merge<Project, { vpc?: string }>
1818
export type VpcSubnet = Merge<Vpc, { subnet?: string }>
19+
export type FirewallRule = Merge<Vpc, { rule?: string }>
1920
export type Silo = { silo?: string }
2021
export type IdentityProvider = Merge<Silo, { provider: string }>
2122
export type SystemUpdate = { version: string }

app/forms/firewall-rules-create.tsx

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,18 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8+
import { useMemo } from 'react'
89
import { useController, type Control } from 'react-hook-form'
10+
import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom'
11+
import * as R from 'remeda'
912

1013
import {
14+
apiQueryClient,
1115
firewallRuleGetToPut,
1216
parsePortRange,
1317
useApiMutation,
1418
useApiQueryClient,
19+
usePrefetchedApiQuery,
1520
type ApiError,
1621
type VpcFirewallRule,
1722
type VpcFirewallRuleHostFilter,
@@ -27,7 +32,8 @@ import { NumberField } from '~/components/form/fields/NumberField'
2732
import { RadioField } from '~/components/form/fields/RadioField'
2833
import { TextField, TextFieldInner } from '~/components/form/fields/TextField'
2934
import { SideModalForm } from '~/components/form/SideModalForm'
30-
import { useForm, useVpcSelector } from '~/hooks'
35+
import { getVpcSelector, useForm, useVpcSelector } from '~/hooks'
36+
import { addToast } from '~/stores/toast'
3137
import { Badge } from '~/ui/lib/Badge'
3238
import { Button } from '~/ui/lib/Button'
3339
import { FormDivider } from '~/ui/lib/Divider'
@@ -36,6 +42,7 @@ import * as MiniTable from '~/ui/lib/MiniTable'
3642
import { TextInputHint } from '~/ui/lib/TextInput'
3743
import { KEYS } from '~/ui/util/keys'
3844
import { links } from '~/util/links'
45+
import { pb } from '~/util/path-builder'
3946

4047
export type FirewallRuleValues = {
4148
enabled: boolean
@@ -552,30 +559,33 @@ export const CommonFields = ({ error, control }: CommonFieldsProps) => {
552559
)
553560
}
554561

555-
// TODO: validate priority again
556-
// export const validationSchema = Yup.object({
557-
// priority: Yup.number().integer().min(0).max(65535).required('Required'),
558-
// })
559-
560-
type CreateFirewallRuleFormProps = {
561-
onDismiss: () => void
562-
existingRules: VpcFirewallRule[]
562+
CreateFirewallRuleForm.loader = async ({ params }: LoaderFunctionArgs) => {
563+
await apiQueryClient.prefetchQuery('vpcFirewallRulesView', {
564+
query: getVpcSelector(params),
565+
})
566+
return null
563567
}
564568

565-
export function CreateFirewallRuleForm({
566-
onDismiss,
567-
existingRules,
568-
}: CreateFirewallRuleFormProps) {
569+
export function CreateFirewallRuleForm() {
569570
const vpcSelector = useVpcSelector()
570571
const queryClient = useApiQueryClient()
571572

573+
const navigate = useNavigate()
574+
const onDismiss = () => navigate(pb.vpcFirewallRules(vpcSelector))
575+
572576
const updateRules = useApiMutation('vpcFirewallRulesUpdate', {
573577
onSuccess() {
574578
queryClient.invalidateQueries('vpcFirewallRulesView')
575-
onDismiss()
579+
addToast({ content: 'Your firewall rule has been created' })
580+
navigate(pb.vpcFirewallRules(vpcSelector))
576581
},
577582
})
578583

584+
const { data } = usePrefetchedApiQuery('vpcFirewallRulesView', {
585+
query: vpcSelector,
586+
})
587+
const existingRules = useMemo(() => R.sortBy(data.rules, (r) => r.priority), [data])
588+
579589
const form = useForm({ defaultValues })
580590

581591
return (

app/forms/firewall-rules-edit.tsx

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,36 +5,63 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8+
import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom'
9+
810
import {
11+
apiQueryClient,
912
firewallRuleGetToPut,
1013
useApiMutation,
1114
useApiQueryClient,
12-
type VpcFirewallRule,
15+
usePrefetchedApiQuery,
1316
} from '@oxide/api'
1417

18+
import { trigger404 } from '~/components/ErrorBoundary'
1519
import { SideModalForm } from '~/components/form/SideModalForm'
16-
import { useForm, useVpcSelector } from '~/hooks'
20+
import {
21+
getFirewallRuleSelector,
22+
useFirewallRuleSelector,
23+
useForm,
24+
useVpcSelector,
25+
} from '~/hooks'
26+
import { invariant } from '~/util/invariant'
27+
import { pb } from '~/util/path-builder'
1728

1829
import {
1930
CommonFields,
2031
valuesToRuleUpdate,
2132
type FirewallRuleValues,
2233
} from './firewall-rules-create'
2334

24-
type EditFirewallRuleFormProps = {
25-
onDismiss: () => void
26-
existingRules: VpcFirewallRule[]
27-
originalRule: VpcFirewallRule
35+
EditFirewallRuleForm.loader = async ({ params }: LoaderFunctionArgs) => {
36+
const { project, vpc, rule } = getFirewallRuleSelector(params)
37+
38+
const data = await apiQueryClient.fetchQuery('vpcFirewallRulesView', {
39+
query: { project, vpc },
40+
})
41+
42+
const originalRule = data.rules.find((r) => r.name === rule)
43+
if (!originalRule) throw trigger404
44+
45+
return null
2846
}
2947

30-
export function EditFirewallRuleForm({
31-
onDismiss,
32-
existingRules,
33-
originalRule,
34-
}: EditFirewallRuleFormProps) {
48+
export function EditFirewallRuleForm() {
49+
const { vpc, project, rule } = useFirewallRuleSelector()
3550
const vpcSelector = useVpcSelector()
3651
const queryClient = useApiQueryClient()
3752

53+
const { data } = usePrefetchedApiQuery('vpcFirewallRulesView', {
54+
query: { project, vpc },
55+
})
56+
57+
const originalRule = data.rules.find((r) => r.name === rule)
58+
59+
// we shouldn't hit this because of the trigger404 in the loader
60+
invariant(originalRule, 'Firewall rule must exist')
61+
62+
const navigate = useNavigate()
63+
const onDismiss = () => navigate(pb.vpcFirewallRules(vpcSelector))
64+
3865
const updateRules = useApiMutation('vpcFirewallRulesUpdate', {
3966
onSuccess() {
4067
queryClient.invalidateQueries('vpcFirewallRulesView')
@@ -72,9 +99,10 @@ export function EditFirewallRuleForm({
7299
onSubmit={(values) => {
73100
// note different filter logic from create: filter out the rule with the
74101
// *original* name because we need to overwrite that rule
75-
const otherRules = existingRules
102+
const otherRules = data.rules
76103
.filter((r) => r.name !== originalRule.name)
77104
.map(firewallRuleGetToPut)
105+
78106
updateRules.mutate({
79107
query: vpcSelector,
80108
body: {

app/forms/instance-create.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ export function CreateInstanceForm() {
184184
instance
185185
)
186186
addToast({ content: 'Your instance has been created' })
187-
navigate(pb.instancePage({ project, instance: instance.name }))
187+
navigate(pb.instance({ project, instance: instance.name }))
188188
},
189189
})
190190

app/forms/subnet-create.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8+
import { useNavigate } from 'react-router-dom'
9+
810
import { useApiMutation, useApiQueryClient, type VpcSubnetCreate } from '@oxide/api'
911

1012
import { DescriptionField } from '~/components/form/fields/DescriptionField'
@@ -13,21 +15,21 @@ import { TextField } from '~/components/form/fields/TextField'
1315
import { SideModalForm } from '~/components/form/SideModalForm'
1416
import { useForm, useVpcSelector } from '~/hooks'
1517
import { FormDivider } from '~/ui/lib/Divider'
18+
import { pb } from '~/util/path-builder'
1619

1720
const defaultValues: VpcSubnetCreate = {
1821
name: '',
1922
description: '',
2023
ipv4Block: '',
2124
}
2225

23-
type CreateSubnetFormProps = {
24-
onDismiss: () => void
25-
}
26-
27-
export function CreateSubnetForm({ onDismiss }: CreateSubnetFormProps) {
26+
export function CreateSubnetForm() {
2827
const vpcSelector = useVpcSelector()
2928
const queryClient = useApiQueryClient()
3029

30+
const navigate = useNavigate()
31+
const onDismiss = () => navigate(pb.vpcSubnets(vpcSelector))
32+
3133
const createSubnet = useApiMutation('vpcSubnetCreate', {
3234
onSuccess() {
3335
queryClient.invalidateQueries('vpcSubnetList')

app/forms/subnet-edit.tsx

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,37 +5,52 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8+
import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom'
89
import * as R from 'remeda'
910

1011
import {
12+
apiQueryClient,
1113
useApiMutation,
1214
useApiQueryClient,
13-
type VpcSubnet,
15+
usePrefetchedApiQuery,
1416
type VpcSubnetUpdate,
1517
} from '@oxide/api'
1618

1719
import { DescriptionField } from '~/components/form/fields/DescriptionField'
1820
import { NameField } from '~/components/form/fields/NameField'
1921
import { SideModalForm } from '~/components/form/SideModalForm'
20-
import { useForm, useVpcSelector } from '~/hooks'
22+
import { getVpcSubnetSelector, useForm, useVpcSubnetSelector } from '~/hooks'
23+
import { pb } from '~/util/path-builder'
2124

22-
type EditSubnetFormProps = {
23-
onDismiss: () => void
24-
editing: VpcSubnet
25+
EditSubnetForm.loader = async ({ params }: LoaderFunctionArgs) => {
26+
const { project, vpc, subnet } = getVpcSubnetSelector(params)
27+
await apiQueryClient.prefetchQuery('vpcSubnetView', {
28+
query: { project, vpc },
29+
path: { subnet },
30+
})
31+
return null
2532
}
2633

27-
export function EditSubnetForm({ onDismiss, editing }: EditSubnetFormProps) {
28-
const vpcSelector = useVpcSelector()
34+
export function EditSubnetForm() {
35+
const { project, vpc, subnet: subnetName } = useVpcSubnetSelector()
2936
const queryClient = useApiQueryClient()
3037

38+
const navigate = useNavigate()
39+
const onDismiss = () => navigate(pb.vpcSubnets({ project, vpc }))
40+
41+
const { data: subnet } = usePrefetchedApiQuery('vpcSubnetView', {
42+
query: { project, vpc },
43+
path: { subnet: subnetName },
44+
})
45+
3146
const updateSubnet = useApiMutation('vpcSubnetUpdate', {
3247
onSuccess() {
3348
queryClient.invalidateQueries('vpcSubnetList')
3449
onDismiss()
3550
},
3651
})
3752

38-
const defaultValues = R.pick(editing, ['name', 'description']) satisfies VpcSubnetUpdate
53+
const defaultValues = R.pick(subnet, ['name', 'description']) satisfies VpcSubnetUpdate
3954

4055
const form = useForm({ defaultValues })
4156

@@ -47,8 +62,8 @@ export function EditSubnetForm({ onDismiss, editing }: EditSubnetFormProps) {
4762
onDismiss={onDismiss}
4863
onSubmit={(body) => {
4964
updateSubnet.mutate({
50-
path: { subnet: editing.name },
51-
query: vpcSelector,
65+
path: { subnet: subnet.name },
66+
query: { project, vpc },
5267
body,
5368
})
5469
}}

app/hooks/use-params.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export const getProjectSelector = requireParams('project')
3636
export const getFloatingIpSelector = requireParams('project', 'floatingIp')
3737
export const getInstanceSelector = requireParams('project', 'instance')
3838
export const getVpcSelector = requireParams('project', 'vpc')
39+
export const getFirewallRuleSelector = requireParams('project', 'vpc', 'rule')
40+
export const getVpcSubnetSelector = requireParams('project', 'vpc', 'subnet')
3941
export const getSiloSelector = requireParams('silo')
4042
export const getSiloImageSelector = requireParams('image')
4143
export const getIdpSelector = requireParams('silo', 'provider')
@@ -77,6 +79,8 @@ export const useProjectSnapshotSelector = () =>
7779
useSelectedParams(getProjectSnapshotSelector)
7880
export const useInstanceSelector = () => useSelectedParams(getInstanceSelector)
7981
export const useVpcSelector = () => useSelectedParams(getVpcSelector)
82+
export const useVpcSubnetSelector = () => useSelectedParams(getVpcSubnetSelector)
83+
export const useFirewallRuleSelector = () => useSelectedParams(getFirewallRuleSelector)
8084
export const useSiloSelector = () => useSelectedParams(getSiloSelector)
8185
export const useSiloImageSelector = () => useSelectedParams(getSiloImageSelector)
8286
export const useIdpSelector = () => useSelectedParams(getIdpSelector)

app/pages/project/instances/InstancesPage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export function InstancesPage() {
8080
},
8181
...(instances?.items || []).map((i) => ({
8282
value: i.name,
83-
onSelect: () => navigate(pb.instancePage({ project, instance: i.name })),
83+
onSelect: () => navigate(pb.instance({ project, instance: i.name })),
8484
navGroup: 'Go to instance',
8585
})),
8686
],
@@ -97,7 +97,7 @@ export function InstancesPage() {
9797
const columns = useMemo(
9898
() => [
9999
colHelper.accessor('name', {
100-
cell: makeLinkCell((instance) => pb.instancePage({ project, instance })),
100+
cell: makeLinkCell((instance) => pb.instance({ project, instance })),
101101
}),
102102
colHelper.accessor((i) => ({ ncpus: i.ncpus, memory: i.memory }), {
103103
header: 'CPU, RAM',

app/pages/project/vpcs/VpcPage/VpcPage.tsx

Lines changed: 10 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,38 +10,27 @@ import type { LoaderFunctionArgs } from 'react-router-dom'
1010
import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api'
1111
import { Networking24Icon } from '@oxide/design-system/icons/react'
1212

13-
import { QueryParamTabs } from '~/components/QueryParamTabs'
13+
import { RouteTabs, Tab } from '~/components/RouteTabs'
1414
import { getVpcSelector, useVpcSelector } from '~/hooks'
1515
import { EmptyCell } from '~/table/cells/EmptyCell'
16-
import { PAGE_SIZE } from '~/table/QueryTable'
1716
import { DateTime } from '~/ui/lib/DateTime'
1817
import { PageHeader, PageTitle } from '~/ui/lib/PageHeader'
1918
import { PropertiesTable } from '~/ui/lib/PropertiesTable'
20-
import { Tabs } from '~/ui/lib/Tabs'
19+
import { pb } from '~/util/path-builder'
2120

2221
import { VpcDocsPopover } from '../VpcsPage'
23-
import { VpcFirewallRulesTab } from './tabs/VpcFirewallRulesTab'
24-
import { VpcSubnetsTab } from './tabs/VpcSubnetsTab'
2522

2623
VpcPage.loader = async ({ params }: LoaderFunctionArgs) => {
2724
const { project, vpc } = getVpcSelector(params)
28-
await Promise.all([
29-
apiQueryClient.prefetchQuery('vpcView', { path: { vpc }, query: { project } }),
30-
apiQueryClient.prefetchQuery('vpcFirewallRulesView', {
31-
query: { project, vpc },
32-
}),
33-
apiQueryClient.prefetchQuery('vpcSubnetList', {
34-
query: { project, vpc, limit: PAGE_SIZE },
35-
}),
36-
])
25+
await apiQueryClient.prefetchQuery('vpcView', { path: { vpc }, query: { project } })
3726
return null
3827
}
3928

4029
export function VpcPage() {
41-
const { project, vpc: vpcName } = useVpcSelector()
30+
const vpcSelector = useVpcSelector()
4231
const { data: vpc } = usePrefetchedApiQuery('vpcView', {
43-
path: { vpc: vpcName },
44-
query: { project },
32+
path: { vpc: vpcSelector.vpc },
33+
query: { project: vpcSelector.project },
4534
})
4635

4736
return (
@@ -67,18 +56,10 @@ export function VpcPage() {
6756
</PropertiesTable>
6857
</PropertiesTable.Group>
6958

70-
<QueryParamTabs className="full-width" defaultValue="firewall-rules">
71-
<Tabs.List>
72-
<Tabs.Trigger value="firewall-rules">Firewall Rules</Tabs.Trigger>
73-
<Tabs.Trigger value="subnets">Subnets</Tabs.Trigger>
74-
</Tabs.List>
75-
<Tabs.Content value="firewall-rules">
76-
<VpcFirewallRulesTab />
77-
</Tabs.Content>
78-
<Tabs.Content value="subnets">
79-
<VpcSubnetsTab />
80-
</Tabs.Content>
81-
</QueryParamTabs>
59+
<RouteTabs fullWidth>
60+
<Tab to={pb.vpcFirewallRules(vpcSelector)}>Firewall Rules</Tab>
61+
<Tab to={pb.vpcSubnets(vpcSelector)}>Subnets</Tab>
62+
</RouteTabs>
8263
</>
8364
)
8465
}

0 commit comments

Comments
 (0)