Skip to content

Commit

Permalink
Choose custom router on subnet create/edit forms (#2393)
Browse files Browse the repository at this point in the history
* Add combobox to subnet create and edit forms

---------

Co-authored-by: David Crespo <[email protected]>
  • Loading branch information
charliepark and david-crespo authored Aug 22, 2024
1 parent 1bb9270 commit 8028f9a
Show file tree
Hide file tree
Showing 8 changed files with 239 additions and 25 deletions.
44 changes: 44 additions & 0 deletions app/components/form/fields/useItemsList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* 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 { useMemo } from 'react'

import { useApiQuery } from '~/api'
import { useVpcSelector } from '~/hooks'

/**
* Special value indicating no router. Must use helper functions to convert
* `undefined` to this when populating form, and this back to `undefined` in
* onSubmit.
*/
const NO_ROUTER = '||no router||'

/** Convert form value to value for PUT body */
export function customRouterFormToData(value: string): string | undefined {
return value === NO_ROUTER ? undefined : value
}

/** Convert value from response body to form value */
export function customRouterDataToForm(value: string | undefined): string {
return value || NO_ROUTER
}

export const useCustomRouterItems = () => {
const vpcSelector = useVpcSelector()
const { data, isLoading } = useApiQuery('vpcRouterList', { query: vpcSelector })

const routerItems = useMemo(() => {
const items = (data?.items || [])
.filter((item) => item.kind === 'custom')
.map((router) => ({ value: router.id, label: router.name }))

return [{ value: NO_ROUTER, label: 'None' }, ...items]
}, [data])

return { isLoading, items: routerItems }
}
36 changes: 34 additions & 2 deletions app/forms/subnet-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,27 @@ import { useNavigate } from 'react-router-dom'
import { useApiMutation, useApiQueryClient, type VpcSubnetCreate } from '@oxide/api'

import { DescriptionField } from '~/components/form/fields/DescriptionField'
import { ListboxField } from '~/components/form/fields/ListboxField'
import { NameField } from '~/components/form/fields/NameField'
import { TextField } from '~/components/form/fields/TextField'
import {
customRouterDataToForm,
customRouterFormToData,
useCustomRouterItems,
} from '~/components/form/fields/useItemsList'
import { SideModalForm } from '~/components/form/SideModalForm'
import { useForm, useVpcSelector } from '~/hooks'
import { FormDivider } from '~/ui/lib/Divider'
import { pb } from '~/util/path-builder'

const defaultValues: VpcSubnetCreate = {
const defaultValues: Required<VpcSubnetCreate> = {
name: '',
description: '',
ipv4Block: '',
ipv6Block: '',
// populate the form field with the value corresponding to an undefined custom
// router on a subnet response
customRouter: customRouterDataToForm(undefined),
}

export function CreateSubnetForm() {
Expand All @@ -38,14 +48,26 @@ export function CreateSubnetForm() {
})

const form = useForm({ defaultValues })
const { isLoading, items } = useCustomRouterItems()

return (
<SideModalForm
form={form}
formType="create"
resourceName="subnet"
onDismiss={onDismiss}
onSubmit={(body) => createSubnet.mutate({ query: vpcSelector, body })}
onSubmit={({ name, description, ipv4Block, ipv6Block, customRouter }) =>
createSubnet.mutate({
query: vpcSelector,
body: {
name,
description,
ipv4Block,
ipv6Block: ipv6Block.trim() || undefined,
customRouter: customRouterFormToData(customRouter),
},
})
}
loading={createSubnet.isPending}
submitError={createSubnet.error}
>
Expand All @@ -54,6 +76,16 @@ export function CreateSubnetForm() {
<FormDivider />
<TextField name="ipv4Block" label="IPv4 block" required control={form.control} />
<TextField name="ipv6Block" label="IPv6 block" control={form.control} />
<FormDivider />
<ListboxField
label="Custom router"
name="customRouter"
placeholder="Select a custom router"
isLoading={isLoading}
items={items}
control={form.control}
required
/>
</SideModalForm>
)
}
31 changes: 28 additions & 3 deletions app/forms/subnet-edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
* Copyright Oxide Computer Company
*/
import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom'
import * as R from 'remeda'

import {
apiQueryClient,
Expand All @@ -17,9 +16,16 @@ import {
} from '@oxide/api'

import { DescriptionField } from '~/components/form/fields/DescriptionField'
import { ListboxField } from '~/components/form/fields/ListboxField'
import { NameField } from '~/components/form/fields/NameField'
import {
customRouterDataToForm,
customRouterFormToData,
useCustomRouterItems,
} from '~/components/form/fields/useItemsList'
import { SideModalForm } from '~/components/form/SideModalForm'
import { getVpcSubnetSelector, useForm, useVpcSubnetSelector } from '~/hooks'
import { FormDivider } from '~/ui/lib/Divider'
import { pb } from '~/util/path-builder'

EditSubnetForm.loader = async ({ params }: LoaderFunctionArgs) => {
Expand Down Expand Up @@ -50,9 +56,14 @@ export function EditSubnetForm() {
},
})

const defaultValues: VpcSubnetUpdate = R.pick(subnet, ['name', 'description'])
const defaultValues: Required<VpcSubnetUpdate> = {
name: subnet.name,
description: subnet.description,
customRouter: customRouterDataToForm(subnet.customRouterId),
}

const form = useForm({ defaultValues })
const { isLoading, items } = useCustomRouterItems()

return (
<SideModalForm
Expand All @@ -64,14 +75,28 @@ export function EditSubnetForm() {
updateSubnet.mutate({
path: { subnet: subnet.name },
query: { project, vpc },
body,
body: {
name: body.name,
description: body.description,
customRouter: customRouterFormToData(body.customRouter),
},
})
}}
loading={updateSubnet.isPending}
submitError={updateSubnet.error}
>
<NameField name="name" control={form.control} />
<DescriptionField name="description" control={form.control} />
<FormDivider />
<ListboxField
label="Custom router"
name="customRouter"
placeholder="Select a custom router"
isLoading={isLoading}
items={items}
control={form.control}
required
/>
</SideModalForm>
)
}
7 changes: 7 additions & 0 deletions app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import { getVpcSelector, useVpcSelector } from '~/hooks'
import { confirmDelete } from '~/stores/confirm-delete'
import { makeLinkCell } from '~/table/cells/LinkCell'
import { RouterLinkCell } from '~/table/cells/RouterLinkCell'
import { TwoLineCell } from '~/table/cells/TwoLineCell'
import { getActionsCol, type MenuAction } from '~/table/columns/action-col'
import { Columns } from '~/table/columns/common'
Expand Down Expand Up @@ -75,10 +76,16 @@ export function VpcSubnetsTab() {
colHelper.accessor('name', {
cell: makeLinkCell((subnet) => pb.vpcSubnetsEdit({ ...vpcSelector, subnet })),
}),
colHelper.accessor('description', Columns.description),
colHelper.accessor((vpc) => [vpc.ipv4Block, vpc.ipv6Block] as const, {
header: 'IP Block',
cell: (info) => <TwoLineCell value={[...info.getValue()]} />,
}),
colHelper.accessor('customRouterId', {
header: 'Custom Router',
// RouterLinkCell needed, as we need to convert the customRouterId to the custom router's name
cell: (info) => <RouterLinkCell value={info.getValue()} />,
}),
colHelper.accessor('timeCreated', Columns.timeCreated),
getActionsCol(makeActions),
],
Expand Down
36 changes: 36 additions & 0 deletions app/table/cells/RouterLinkCell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* 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 { useApiQuery } from '~/api'
import { useVpcSelector } from '~/hooks'
import { Badge } from '~/ui/lib/Badge'
import { pb } from '~/util/path-builder'

import { EmptyCell, SkeletonCell } from './EmptyCell'
import { LinkCell } from './LinkCell'

export const RouterLinkCell = ({ value }: { value?: string }) => {
const { project, vpc } = useVpcSelector()
const { data: router, isError } = useApiQuery(
'vpcRouterView',
{
path: { router: value! },
query: { project, vpc },
},
{ enabled: !!value }
)
if (!value) return <EmptyCell />
// probably not possible but let’s be safe
if (isError) return <Badge color="neutral">Deleted</Badge>
if (!router) return <SkeletonCell /> // loading
return (
<LinkCell to={pb.vpcRouter({ project, vpc, router: router.name })}>
{router.name}
</LinkCell>
)
}
10 changes: 9 additions & 1 deletion mock-api/msw/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1151,6 +1151,7 @@ export const handlers = makeHandlers({
const subnets = db.vpcSubnets.filter((s) => s.vpc_id === vpc.id)
return paginated(query, subnets)
},

vpcSubnetCreate({ body, query }) {
const vpc = lookup.vpc(query)
errIfExists(db.vpcSubnets, { vpc_id: vpc.id, name: body.name })
Expand All @@ -1159,7 +1160,10 @@ export const handlers = makeHandlers({
const newSubnet: Json<Api.VpcSubnet> = {
id: uuid(),
vpc_id: vpc.id,
...body,
name: body.name,
description: body.description,
ipv4_block: body.ipv4_block,
custom_router_id: body.custom_router,
// required in subnet create but not in update, so we need a fallback.
// API says "A random `/64` block will be assigned if one is not
// provided." Our fallback is not random, but it should be good enough.
Expand All @@ -1178,6 +1182,10 @@ export const handlers = makeHandlers({
}
updateDesc(subnet, body)

// match the API's arguably undesirable behavior -- key
// not present and value of null are treated the same
subnet.custom_router_id = body.custom_router

return subnet
},
vpcSubnetDelete({ path, query }) {
Expand Down
1 change: 1 addition & 0 deletions mock-api/vpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export const vpcSubnet2: Json<VpcSubnet> = {
name: 'mock-subnet-2',
vpc_id: vpc.id,
ipv4_block: '10.1.1.2/24',
custom_router_id: customRouter.id,
}

export function defaultFirewallRules(vpcId: string): Json<VpcFirewallRule[]> {
Expand Down
Loading

0 comments on commit 8028f9a

Please sign in to comment.