Skip to content

Commit 4b699e0

Browse files
authored
Add dropdowns to router route form for destinations and targets (#2448)
* Use human-friendly copy on placeholders and descriptions
1 parent 9c9dc14 commit 4b699e0

File tree

6 files changed

+139
-52
lines changed

6 files changed

+139
-52
lines changed

app/api/hooks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ export const getUsePrefetchedApiQuery =
177177
`Expected query to be prefetched.
178178
Key: ${JSON.stringify(queryKey)}
179179
Ensure the following:
180-
• loader is running
180+
• loader is called in routes.tsx and is running
181181
• query matches in both the loader and the component
182182
• request isn't erroring-out server-side (check the Networking tab)
183183
• mock API endpoint is implemented in handlers.ts

app/forms/vpc-router-route-common.tsx

Lines changed: 98 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,21 @@
88

99
import type { UseFormReturn } from 'react-hook-form'
1010

11-
import type {
12-
RouteDestination,
13-
RouterRouteCreate,
14-
RouterRouteUpdate,
15-
RouteTarget,
11+
import {
12+
usePrefetchedApiQuery,
13+
type Instance,
14+
type RouteDestination,
15+
type RouterRouteCreate,
16+
type RouterRouteUpdate,
17+
type RouteTarget,
18+
type VpcSubnet,
1619
} from '~/api'
20+
import { ComboboxField } from '~/components/form/fields/ComboboxField'
1721
import { DescriptionField } from '~/components/form/fields/DescriptionField'
1822
import { ListboxField } from '~/components/form/fields/ListboxField'
1923
import { NameField } from '~/components/form/fields/NameField'
2024
import { TextField } from '~/components/form/fields/TextField'
25+
import { useVpcRouterSelector } from '~/hooks/use-params'
2126
import { Message } from '~/ui/lib/Message'
2227

2328
export type RouteFormValues = RouterRouteCreate | Required<RouterRouteUpdate>
@@ -52,65 +57,122 @@ const targetTypes: Record<Exclude<RouteTarget['type'], 'subnet' | 'vpc'>, string
5257
drop: 'Drop',
5358
}
5459

55-
const toItems = (mapping: Record<string, string>) =>
60+
const destinationValuePlaceholder: Record<RouteDestination['type'], string | undefined> = {
61+
ip: 'Enter an IP',
62+
ip_net: 'Enter an IP network',
63+
subnet: 'Select a subnet',
64+
vpc: undefined,
65+
}
66+
67+
const destinationValueDescription: Record<RouteDestination['type'], string | undefined> = {
68+
ip: 'An IP address, like 192.168.1.222',
69+
ip_net: 'An IP network, like 192.168.0.0/16',
70+
subnet: undefined,
71+
vpc: undefined,
72+
}
73+
74+
/** possible targetTypes needing placeholders are instances or IPs (internet_gateway has no placeholder) */
75+
const targetValuePlaceholder: Record<RouteTarget['type'], string | undefined> = {
76+
ip: 'Enter an IP',
77+
instance: 'Select an instance',
78+
internet_gateway: undefined,
79+
drop: undefined,
80+
subnet: undefined,
81+
vpc: undefined,
82+
}
83+
84+
const targetValueDescription: Record<RouteTarget['type'], string | undefined> = {
85+
ip: 'An IP address, like 10.0.1.5',
86+
instance: undefined,
87+
internet_gateway: routeFormMessage.internetGatewayTargetValue,
88+
drop: undefined,
89+
subnet: undefined,
90+
vpc: undefined,
91+
}
92+
93+
const toListboxItems = (mapping: Record<string, string>) =>
5694
Object.entries(mapping).map(([value, label]) => ({ value, label }))
5795

96+
const toComboboxItems = (items: Array<Instance | VpcSubnet>) =>
97+
items.map(({ name }) => ({ value: name, label: name }))
98+
5899
type RouteFormFieldsProps = {
59100
form: UseFormReturn<RouteFormValues>
60-
isDisabled?: boolean
101+
disabled?: boolean
61102
}
62-
export const RouteFormFields = ({ form, isDisabled }: RouteFormFieldsProps) => {
103+
export const RouteFormFields = ({ form, disabled }: RouteFormFieldsProps) => {
104+
const routerSelector = useVpcRouterSelector()
105+
const { project, vpc } = routerSelector
106+
// usePrefetchedApiQuery items below are initially fetched in the loaders in vpc-router-route-create and -edit
107+
const {
108+
data: { items: vpcSubnets },
109+
} = usePrefetchedApiQuery('vpcSubnetList', { query: { project, vpc, limit: 1000 } })
110+
const {
111+
data: { items: instances },
112+
} = usePrefetchedApiQuery('instanceList', { query: { project, limit: 1000 } })
113+
63114
const { control } = form
115+
const destinationType = form.watch('destination.type')
64116
const targetType = form.watch('target.type')
117+
const destinationValueProps = {
118+
name: 'destination.value' as const,
119+
label: 'Destination value',
120+
control,
121+
placeholder: destinationValuePlaceholder[destinationType],
122+
required: true,
123+
disabled,
124+
description: destinationValueDescription[destinationType],
125+
}
126+
const targetValueProps = {
127+
name: 'target.value' as const,
128+
label: 'Target value',
129+
control,
130+
placeholder: targetValuePlaceholder[targetType],
131+
required: true,
132+
// 'internet_gateway' targetTypes can only have the value 'outbound', so we disable the field
133+
disabled: disabled || targetType === 'internet_gateway',
134+
description: targetValueDescription[targetType],
135+
}
65136
return (
66137
<>
67-
{isDisabled && (
138+
{disabled && (
68139
<Message variant="info" content={routeFormMessage.vpcSubnetNotModifiable} />
69140
)}
70-
<NameField name="name" control={control} disabled={isDisabled} />
71-
<DescriptionField name="description" control={control} disabled={isDisabled} />
141+
<NameField name="name" control={control} disabled={disabled} />
142+
<DescriptionField name="description" control={control} disabled={disabled} />
72143
<ListboxField
73144
name="destination.type"
74145
label="Destination type"
75146
control={control}
76-
items={toItems(destTypes)}
147+
items={toListboxItems(destTypes)}
77148
placeholder="Select a destination type"
78149
required
79-
disabled={isDisabled}
80-
/>
81-
<TextField
82-
name="destination.value"
83-
label="Destination value"
84-
control={control}
85-
placeholder="Enter a destination value"
86-
required
87-
disabled={isDisabled}
150+
onChange={() => {
151+
form.setValue('destination.value', '')
152+
}}
153+
disabled={disabled}
88154
/>
155+
{destinationType === 'subnet' ? (
156+
<ComboboxField {...destinationValueProps} items={toComboboxItems(vpcSubnets)} />
157+
) : (
158+
<TextField {...destinationValueProps} />
159+
)}
89160
<ListboxField
90161
name="target.type"
91162
label="Target type"
92163
control={control}
93-
items={toItems(targetTypes)}
164+
items={toListboxItems(targetTypes)}
94165
placeholder="Select a target type"
95166
required
96167
onChange={(value) => {
97168
form.setValue('target.value', value === 'internet_gateway' ? 'outbound' : '')
98169
}}
99-
disabled={isDisabled}
170+
disabled={disabled}
100171
/>
101-
{targetType !== 'drop' && (
102-
<TextField
103-
name="target.value"
104-
label="Target value"
105-
control={control}
106-
placeholder="Enter a target value"
107-
required
108-
// 'internet_gateway' targetTypes can only have the value 'outbound', so we disable the field
109-
disabled={isDisabled || targetType === 'internet_gateway'}
110-
description={
111-
targetType === 'internet_gateway' && routeFormMessage.internetGatewayTargetValue
112-
}
113-
/>
172+
{targetType === 'drop' ? null : targetType === 'instance' ? (
173+
<ComboboxField {...targetValueProps} items={toComboboxItems(instances)} />
174+
) : (
175+
<TextField {...targetValueProps} />
114176
)}
115177
</>
116178
)

app/forms/vpc-router-route-create.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@
66
* Copyright Oxide Computer Company
77
*/
88
import { useForm } from 'react-hook-form'
9-
import { useNavigate } from 'react-router-dom'
9+
import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom'
1010

11-
import { useApiMutation, useApiQueryClient } from '@oxide/api'
11+
import { apiQueryClient, useApiMutation, useApiQueryClient } from '@oxide/api'
1212

1313
import { SideModalForm } from '~/components/form/SideModalForm'
1414
import { RouteFormFields, type RouteFormValues } from '~/forms/vpc-router-route-common'
15-
import { useVpcRouterSelector } from '~/hooks/use-params'
15+
import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks/use-params'
1616
import { addToast } from '~/stores/toast'
1717
import { pb } from '~/util/path-builder'
1818

@@ -23,6 +23,19 @@ const defaultValues: RouteFormValues = {
2323
target: { type: 'ip', value: '' },
2424
}
2525

26+
CreateRouterRouteSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => {
27+
const { project, vpc } = getVpcRouterSelector(params)
28+
await Promise.all([
29+
apiQueryClient.prefetchQuery('vpcSubnetList', {
30+
query: { project, vpc, limit: 1000 },
31+
}),
32+
apiQueryClient.prefetchQuery('instanceList', {
33+
query: { project, limit: 1000 },
34+
}),
35+
])
36+
return null
37+
}
38+
2639
export function CreateRouterRouteSideModalForm() {
2740
const queryClient = useApiQueryClient()
2841
const routerSelector = useVpcRouterSelector()

app/forms/vpc-router-route-edit.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,19 @@ import { addToast } from '~/stores/toast'
2727
import { pb } from '~/util/path-builder'
2828

2929
EditRouterRouteSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => {
30-
const { route, ...routerSelector } = getVpcRouterRouteSelector(params)
31-
await apiQueryClient.prefetchQuery('vpcRouterRouteView', {
32-
path: { route },
33-
query: routerSelector,
34-
})
30+
const { project, vpc, router, route } = getVpcRouterRouteSelector(params)
31+
await Promise.all([
32+
apiQueryClient.prefetchQuery('vpcRouterRouteView', {
33+
path: { route },
34+
query: { project, vpc, router },
35+
}),
36+
apiQueryClient.prefetchQuery('vpcSubnetList', {
37+
query: { project, vpc, limit: 1000 },
38+
}),
39+
apiQueryClient.prefetchQuery('instanceList', {
40+
query: { project, limit: 1000 },
41+
}),
42+
])
3543
return null
3644
}
3745

@@ -51,7 +59,7 @@ export function EditRouterRouteSideModalForm() {
5159
'destination',
5260
])
5361
const form = useForm({ defaultValues })
54-
const isDisabled = route?.kind === 'vpc_subnet'
62+
const disabled = route?.kind === 'vpc_subnet'
5563

5664
const updateRouterRoute = useApiMutation('vpcRouterRouteUpdate', {
5765
onSuccess() {
@@ -82,9 +90,9 @@ export function EditRouterRouteSideModalForm() {
8290
}
8391
loading={updateRouterRoute.isPending}
8492
submitError={updateRouterRoute.error}
85-
submitDisabled={isDisabled ? routeFormMessage.vpcSubnetNotModifiable : undefined}
93+
submitDisabled={disabled ? routeFormMessage.vpcSubnetNotModifiable : undefined}
8694
>
87-
<RouteFormFields form={form} isDisabled={isDisabled} />
95+
<RouteFormFields form={form} disabled={disabled} />
8896
</SideModalForm>
8997
)
9098
}

app/routes.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,7 @@ export const routes = createRoutesFromElements(
424424
<Route
425425
path="routes-new"
426426
element={<CreateRouterRouteSideModalForm />}
427+
loader={CreateRouterRouteSideModalForm.loader}
427428
handle={{ crumb: 'New Route' }}
428429
/>
429430
<Route

test/e2e/vpcs.e2e.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88
import { expect, test } from '@playwright/test'
99

10-
import { clickRowAction, expectRowVisible } from './utils'
10+
import { clickRowAction, expectRowVisible, selectOption } from './utils'
1111

1212
test('can nav to VpcPage from /', async ({ page }) => {
1313
await page.goto('/')
@@ -248,13 +248,16 @@ test('can create, update, and delete Route', async ({ page }) => {
248248

249249
// update the route by clicking the edit button
250250
await clickRowAction(page, 'new-route', 'Edit')
251-
await page.getByRole('textbox', { name: 'Destination value' }).fill('0.0.0.1')
251+
// change the destination type to VPC subnet: `mock-subnet`
252+
await selectOption(page, 'Destination type', 'Subnet')
253+
await selectOption(page, 'Destination value', 'mock-subnet')
254+
await page.getByRole('textbox', { name: 'Target value' }).fill('0.0.0.1')
252255
await page.getByRole('button', { name: 'Update route' }).click()
253256
await expect(routeRows).toHaveCount(2)
254257
await expectRowVisible(table, {
255258
Name: 'new-route',
256-
Destination: 'IP0.0.0.1',
257-
Target: 'IP1.1.1.1',
259+
Destination: 'VPC subnetmock-subnet',
260+
Target: 'IP0.0.0.1',
258261
})
259262

260263
// delete the route

0 commit comments

Comments
 (0)