Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Choose custom router on subnet create/edit forms #2393

Merged
merged 15 commits into from
Aug 22, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
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
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: subnet, 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 (!subnet) return <SkeletonCell /> // loading
return (
<LinkCell to={pb.vpcRouter({ project, vpc, router: subnet.name })}>
{subnet.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
Loading