Skip to content

Commit 8028f9a

Browse files
Choose custom router on subnet create/edit forms (#2393)
* Add combobox to subnet create and edit forms --------- Co-authored-by: David Crespo <david.crespo@oxidecomputer.com>
1 parent 1bb9270 commit 8028f9a

File tree

8 files changed

+239
-25
lines changed

8 files changed

+239
-25
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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 { useMemo } from 'react'
10+
11+
import { useApiQuery } from '~/api'
12+
import { useVpcSelector } from '~/hooks'
13+
14+
/**
15+
* Special value indicating no router. Must use helper functions to convert
16+
* `undefined` to this when populating form, and this back to `undefined` in
17+
* onSubmit.
18+
*/
19+
const NO_ROUTER = '||no router||'
20+
21+
/** Convert form value to value for PUT body */
22+
export function customRouterFormToData(value: string): string | undefined {
23+
return value === NO_ROUTER ? undefined : value
24+
}
25+
26+
/** Convert value from response body to form value */
27+
export function customRouterDataToForm(value: string | undefined): string {
28+
return value || NO_ROUTER
29+
}
30+
31+
export const useCustomRouterItems = () => {
32+
const vpcSelector = useVpcSelector()
33+
const { data, isLoading } = useApiQuery('vpcRouterList', { query: vpcSelector })
34+
35+
const routerItems = useMemo(() => {
36+
const items = (data?.items || [])
37+
.filter((item) => item.kind === 'custom')
38+
.map((router) => ({ value: router.id, label: router.name }))
39+
40+
return [{ value: NO_ROUTER, label: 'None' }, ...items]
41+
}, [data])
42+
43+
return { isLoading, items: routerItems }
44+
}

app/forms/subnet-create.tsx

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,27 @@ import { useNavigate } from 'react-router-dom'
1010
import { useApiMutation, useApiQueryClient, type VpcSubnetCreate } from '@oxide/api'
1111

1212
import { DescriptionField } from '~/components/form/fields/DescriptionField'
13+
import { ListboxField } from '~/components/form/fields/ListboxField'
1314
import { NameField } from '~/components/form/fields/NameField'
1415
import { TextField } from '~/components/form/fields/TextField'
16+
import {
17+
customRouterDataToForm,
18+
customRouterFormToData,
19+
useCustomRouterItems,
20+
} from '~/components/form/fields/useItemsList'
1521
import { SideModalForm } from '~/components/form/SideModalForm'
1622
import { useForm, useVpcSelector } from '~/hooks'
1723
import { FormDivider } from '~/ui/lib/Divider'
1824
import { pb } from '~/util/path-builder'
1925

20-
const defaultValues: VpcSubnetCreate = {
26+
const defaultValues: Required<VpcSubnetCreate> = {
2127
name: '',
2228
description: '',
2329
ipv4Block: '',
30+
ipv6Block: '',
31+
// populate the form field with the value corresponding to an undefined custom
32+
// router on a subnet response
33+
customRouter: customRouterDataToForm(undefined),
2434
}
2535

2636
export function CreateSubnetForm() {
@@ -38,14 +48,26 @@ export function CreateSubnetForm() {
3848
})
3949

4050
const form = useForm({ defaultValues })
51+
const { isLoading, items } = useCustomRouterItems()
4152

4253
return (
4354
<SideModalForm
4455
form={form}
4556
formType="create"
4657
resourceName="subnet"
4758
onDismiss={onDismiss}
48-
onSubmit={(body) => createSubnet.mutate({ query: vpcSelector, body })}
59+
onSubmit={({ name, description, ipv4Block, ipv6Block, customRouter }) =>
60+
createSubnet.mutate({
61+
query: vpcSelector,
62+
body: {
63+
name,
64+
description,
65+
ipv4Block,
66+
ipv6Block: ipv6Block.trim() || undefined,
67+
customRouter: customRouterFormToData(customRouter),
68+
},
69+
})
70+
}
4971
loading={createSubnet.isPending}
5072
submitError={createSubnet.error}
5173
>
@@ -54,6 +76,16 @@ export function CreateSubnetForm() {
5476
<FormDivider />
5577
<TextField name="ipv4Block" label="IPv4 block" required control={form.control} />
5678
<TextField name="ipv6Block" label="IPv6 block" control={form.control} />
79+
<FormDivider />
80+
<ListboxField
81+
label="Custom router"
82+
name="customRouter"
83+
placeholder="Select a custom router"
84+
isLoading={isLoading}
85+
items={items}
86+
control={form.control}
87+
required
88+
/>
5789
</SideModalForm>
5890
)
5991
}

app/forms/subnet-edit.tsx

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
* Copyright Oxide Computer Company
77
*/
88
import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom'
9-
import * as R from 'remeda'
109

1110
import {
1211
apiQueryClient,
@@ -17,9 +16,16 @@ import {
1716
} from '@oxide/api'
1817

1918
import { DescriptionField } from '~/components/form/fields/DescriptionField'
19+
import { ListboxField } from '~/components/form/fields/ListboxField'
2020
import { NameField } from '~/components/form/fields/NameField'
21+
import {
22+
customRouterDataToForm,
23+
customRouterFormToData,
24+
useCustomRouterItems,
25+
} from '~/components/form/fields/useItemsList'
2126
import { SideModalForm } from '~/components/form/SideModalForm'
2227
import { getVpcSubnetSelector, useForm, useVpcSubnetSelector } from '~/hooks'
28+
import { FormDivider } from '~/ui/lib/Divider'
2329
import { pb } from '~/util/path-builder'
2430

2531
EditSubnetForm.loader = async ({ params }: LoaderFunctionArgs) => {
@@ -50,9 +56,14 @@ export function EditSubnetForm() {
5056
},
5157
})
5258

53-
const defaultValues: VpcSubnetUpdate = R.pick(subnet, ['name', 'description'])
59+
const defaultValues: Required<VpcSubnetUpdate> = {
60+
name: subnet.name,
61+
description: subnet.description,
62+
customRouter: customRouterDataToForm(subnet.customRouterId),
63+
}
5464

5565
const form = useForm({ defaultValues })
66+
const { isLoading, items } = useCustomRouterItems()
5667

5768
return (
5869
<SideModalForm
@@ -64,14 +75,28 @@ export function EditSubnetForm() {
6475
updateSubnet.mutate({
6576
path: { subnet: subnet.name },
6677
query: { project, vpc },
67-
body,
78+
body: {
79+
name: body.name,
80+
description: body.description,
81+
customRouter: customRouterFormToData(body.customRouter),
82+
},
6883
})
6984
}}
7085
loading={updateSubnet.isPending}
7186
submitError={updateSubnet.error}
7287
>
7388
<NameField name="name" control={form.control} />
7489
<DescriptionField name="description" control={form.control} />
90+
<FormDivider />
91+
<ListboxField
92+
label="Custom router"
93+
name="customRouter"
94+
placeholder="Select a custom router"
95+
isLoading={isLoading}
96+
items={items}
97+
control={form.control}
98+
required
99+
/>
75100
</SideModalForm>
76101
)
77102
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import { getVpcSelector, useVpcSelector } from '~/hooks'
2020
import { confirmDelete } from '~/stores/confirm-delete'
2121
import { makeLinkCell } from '~/table/cells/LinkCell'
22+
import { RouterLinkCell } from '~/table/cells/RouterLinkCell'
2223
import { TwoLineCell } from '~/table/cells/TwoLineCell'
2324
import { getActionsCol, type MenuAction } from '~/table/columns/action-col'
2425
import { Columns } from '~/table/columns/common'
@@ -75,10 +76,16 @@ export function VpcSubnetsTab() {
7576
colHelper.accessor('name', {
7677
cell: makeLinkCell((subnet) => pb.vpcSubnetsEdit({ ...vpcSelector, subnet })),
7778
}),
79+
colHelper.accessor('description', Columns.description),
7880
colHelper.accessor((vpc) => [vpc.ipv4Block, vpc.ipv6Block] as const, {
7981
header: 'IP Block',
8082
cell: (info) => <TwoLineCell value={[...info.getValue()]} />,
8183
}),
84+
colHelper.accessor('customRouterId', {
85+
header: 'Custom Router',
86+
// RouterLinkCell needed, as we need to convert the customRouterId to the custom router's name
87+
cell: (info) => <RouterLinkCell value={info.getValue()} />,
88+
}),
8289
colHelper.accessor('timeCreated', Columns.timeCreated),
8390
getActionsCol(makeActions),
8491
],

app/table/cells/RouterLinkCell.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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 { useApiQuery } from '~/api'
10+
import { useVpcSelector } from '~/hooks'
11+
import { Badge } from '~/ui/lib/Badge'
12+
import { pb } from '~/util/path-builder'
13+
14+
import { EmptyCell, SkeletonCell } from './EmptyCell'
15+
import { LinkCell } from './LinkCell'
16+
17+
export const RouterLinkCell = ({ value }: { value?: string }) => {
18+
const { project, vpc } = useVpcSelector()
19+
const { data: router, isError } = useApiQuery(
20+
'vpcRouterView',
21+
{
22+
path: { router: value! },
23+
query: { project, vpc },
24+
},
25+
{ enabled: !!value }
26+
)
27+
if (!value) return <EmptyCell />
28+
// probably not possible but let’s be safe
29+
if (isError) return <Badge color="neutral">Deleted</Badge>
30+
if (!router) return <SkeletonCell /> // loading
31+
return (
32+
<LinkCell to={pb.vpcRouter({ project, vpc, router: router.name })}>
33+
{router.name}
34+
</LinkCell>
35+
)
36+
}

mock-api/msw/handlers.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1151,6 +1151,7 @@ export const handlers = makeHandlers({
11511151
const subnets = db.vpcSubnets.filter((s) => s.vpc_id === vpc.id)
11521152
return paginated(query, subnets)
11531153
},
1154+
11541155
vpcSubnetCreate({ body, query }) {
11551156
const vpc = lookup.vpc(query)
11561157
errIfExists(db.vpcSubnets, { vpc_id: vpc.id, name: body.name })
@@ -1159,7 +1160,10 @@ export const handlers = makeHandlers({
11591160
const newSubnet: Json<Api.VpcSubnet> = {
11601161
id: uuid(),
11611162
vpc_id: vpc.id,
1162-
...body,
1163+
name: body.name,
1164+
description: body.description,
1165+
ipv4_block: body.ipv4_block,
1166+
custom_router_id: body.custom_router,
11631167
// required in subnet create but not in update, so we need a fallback.
11641168
// API says "A random `/64` block will be assigned if one is not
11651169
// provided." Our fallback is not random, but it should be good enough.
@@ -1178,6 +1182,10 @@ export const handlers = makeHandlers({
11781182
}
11791183
updateDesc(subnet, body)
11801184

1185+
// match the API's arguably undesirable behavior -- key
1186+
// not present and value of null are treated the same
1187+
subnet.custom_router_id = body.custom_router
1188+
11811189
return subnet
11821190
},
11831191
vpcSubnetDelete({ path, query }) {

mock-api/vpc.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ export const vpcSubnet2: Json<VpcSubnet> = {
155155
name: 'mock-subnet-2',
156156
vpc_id: vpc.id,
157157
ipv4_block: '10.1.1.2/24',
158+
custom_router_id: customRouter.id,
158159
}
159160

160161
export function defaultFirewallRules(vpcId: string): Json<VpcFirewallRule[]> {

0 commit comments

Comments
 (0)