Skip to content

Commit cfb5eb2

Browse files
committed
ports, hosts, and targets subforms actually work
1 parent 36dbae3 commit cfb5eb2

File tree

5 files changed

+209
-62
lines changed

5 files changed

+209
-62
lines changed

app/pages/project/networking/VpcPage/modals/firewall-rules.tsx

Lines changed: 156 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -18,24 +18,45 @@ import {
1818
TextFieldHint,
1919
} from '@oxide/ui'
2020
import type { VpcFirewallRule, ErrorResponse } from '@oxide/api'
21-
import { useApiMutation, useApiQueryClient } from '@oxide/api'
21+
import { parsePortRange, useApiMutation, useApiQueryClient } from '@oxide/api'
2222
import { getServerError } from 'app/util/errors'
2323

2424
type FormProps = {
2525
error: ErrorResponse | null
2626
id: string
2727
}
2828

29-
// TODO (can pass to useFormikContext to get it to behave)
30-
// type FormState = {}
29+
type Values = {
30+
enabled: boolean
31+
priority: string
32+
name: string
33+
description: string
34+
action: VpcFirewallRule['action']
35+
direction: VpcFirewallRule['direction']
36+
37+
protocols: NonNullable<VpcFirewallRule['filters']['protocols']>
38+
39+
// port subform
40+
ports: NonNullable<VpcFirewallRule['filters']['ports']>
41+
portRange: string
42+
43+
// host subform
44+
hosts: NonNullable<VpcFirewallRule['filters']['hosts']>
45+
hostType: string
46+
hostValue: string
47+
48+
// target subform
49+
targets: VpcFirewallRule['targets']
50+
targetType: string
51+
targetValue: string
52+
}
53+
54+
// TODO: pressing enter in ports, hosts, and targets value field should "submit" subform
3155

3256
// the moment the two forms diverge, inline them rather than introducing BS
3357
// props here
3458
const CommonForm = ({ id, error }: FormProps) => {
35-
const {
36-
setFieldValue,
37-
values: { targetName, targetType, targets },
38-
} = useFormikContext()
59+
const { setFieldValue, values } = useFormikContext<Values>()
3960
return (
4061
<Form id={id}>
4162
<SideModal.Section>
@@ -75,28 +96,39 @@ const CommonForm = ({ id, error }: FormProps) => {
7596
{ value: 'subnet', label: 'VPC Subnet' },
7697
{ value: 'instance', label: 'Instance' },
7798
]}
78-
// TODO: this is kind of a hack? move this inside Dropdown somehow
7999
onChange={(item) => {
80100
setFieldValue('targetType', item?.value)
81101
}}
82102
/>
83103
<div className="space-y-0.5">
84-
<FieldTitle htmlFor="targetName">Name</FieldTitle>
85-
<TextField id="targetName" name="targetName" />
104+
<FieldTitle htmlFor="targetValue">Name</FieldTitle>
105+
<TextField id="targetValue" name="targetValue" />
86106
</div>
87107

88108
<div className="flex justify-end">
109+
{/* TODO does this clear out the form or the existing targets? */}
89110
<Button variant="ghost" className="mr-2.5">
90111
Clear
91112
</Button>
92113
<Button
93114
variant="dim"
94115
onClick={() => {
95-
if (!targets.some((t) => t.name === targetName)) {
116+
// TODO: show error instead of ignoring click
117+
if (
118+
values.targetType &&
119+
values.targetValue && // TODO: validate
120+
!values.targets.some(
121+
(t) =>
122+
t.value === values.targetValue &&
123+
t.type === values.targetType
124+
)
125+
) {
96126
setFieldValue('targets', [
97-
...targets,
98-
{ type: targetType, name: targetName },
127+
...values.targets,
128+
{ type: values.targetType, value: values.targetValue },
99129
])
130+
setFieldValue('targetValue', '')
131+
// TODO: clear dropdown too?
100132
}
101133
}}
102134
>
@@ -113,17 +145,20 @@ const CommonForm = ({ id, error }: FormProps) => {
113145
</Table.HeaderRow>
114146
</Table.Header>
115147
<Table.Body>
116-
{targets.map((t) => (
117-
<Table.Row key={t.name}>
148+
{values.targets.map((t) => (
149+
<Table.Row key={`${t.type}|${t.value}`}>
150+
{/* TODO: should be the pretty type label, not the type key */}
118151
<Table.Cell>{t.type}</Table.Cell>
119-
<Table.Cell>{t.name}</Table.Cell>
152+
<Table.Cell>{t.value}</Table.Cell>
120153
<Table.Cell>
121154
<Delete10Icon
122155
className="cursor-pointer"
123156
onClick={() => {
124157
setFieldValue(
125158
'targets',
126-
targets.filter((t1) => t1.name !== t.name)
159+
values.targets.filter(
160+
(t1) => t1.value !== t.value || t1.type !== t.type
161+
)
127162
)
128163
}}
129164
/>
@@ -144,28 +179,53 @@ const CommonForm = ({ id, error }: FormProps) => {
144179
{ value: 'ip', label: 'IP' },
145180
{ value: 'internet_gateway', label: 'Internet Gateway' },
146181
]}
182+
onChange={(item) => {
183+
setFieldValue('hostType', item?.value)
184+
}}
147185
/>
148186
<div className="space-y-0.5">
149187
{/* For everything but IP this is a name, but for IP it's an IP.
150188
So we should probably have the label on this field change when the
151189
host type changes. Also need to confirm that it's just an IP and
152190
not a block. */}
153-
<FieldTitle htmlFor="host-filter-value">Value</FieldTitle>
154-
<TextFieldHint id="host-filter-value-hint">
191+
<FieldTitle htmlFor="hostValue">Value</FieldTitle>
192+
<TextFieldHint id="hostValue-hint">
155193
For IP, an address. For the rest, a name. [TODO: copy]
156194
</TextFieldHint>
157195
<TextField
158-
id="host-filter-value"
159-
name="host-filter-value"
160-
aria-describedby="host-filter-value-hint"
196+
id="hostValue"
197+
name="hostValue"
198+
aria-describedby="hostValue-hint"
161199
/>
162200
</div>
163201

164202
<div className="flex justify-end">
165203
<Button variant="ghost" className="mr-2.5">
166204
Clear
167205
</Button>
168-
<Button variant="dim">Add host filter</Button>
206+
<Button
207+
variant="dim"
208+
onClick={() => {
209+
// TODO: show error instead of ignoring click
210+
if (
211+
values.hostType &&
212+
values.hostValue && // TODO: validate
213+
!values.hosts.some(
214+
(t) =>
215+
t.value === values.hostValue || t.type === values.hostType
216+
)
217+
) {
218+
setFieldValue('hosts', [
219+
...values.hosts,
220+
{ type: values.hostType, value: values.hostValue },
221+
])
222+
setFieldValue('hostValue', '')
223+
// TODO: clear dropdown too?
224+
}
225+
}}
226+
>
227+
Add host filter
228+
</Button>
169229
</div>
170230

171231
<Table className="w-full">
@@ -177,13 +237,26 @@ const CommonForm = ({ id, error }: FormProps) => {
177237
</Table.HeaderRow>
178238
</Table.Header>
179239
<Table.Body>
180-
<Table.Row>
181-
<Table.Cell>VPC</Table.Cell>
182-
<Table.Cell>my-vpc</Table.Cell>
183-
<Table.Cell>
184-
<Delete10Icon className="cursor-pointer" />
185-
</Table.Cell>
186-
</Table.Row>
240+
{values.hosts.map((h) => (
241+
<Table.Row key={`${h.type}|${h.value}`}>
242+
{/* TODO: should be the pretty type label, not the type key */}
243+
<Table.Cell>{h.type}</Table.Cell>
244+
<Table.Cell>{h.value}</Table.Cell>
245+
<Table.Cell>
246+
<Delete10Icon
247+
className="cursor-pointer"
248+
onClick={() => {
249+
setFieldValue(
250+
'hosts',
251+
values.hosts.filter(
252+
(h1) => h1.value !== h.value && h1.type !== h.type
253+
)
254+
)
255+
}}
256+
/>
257+
</Table.Cell>
258+
</Table.Row>
259+
))}
187260
</Table.Body>
188261
</Table>
189262
</SideModal.Section>
@@ -204,21 +277,34 @@ const CommonForm = ({ id, error }: FormProps) => {
204277
<Button variant="ghost" className="mr-2.5">
205278
Clear
206279
</Button>
207-
<Button variant="dim">Add port filter</Button>
280+
<Button
281+
variant="dim"
282+
onClick={() => {
283+
const portRange = values.portRange.trim()
284+
// TODO: show error instead of ignoring the click
285+
if (!parsePortRange(portRange)) return
286+
setFieldValue('ports', [...values.ports, portRange])
287+
setFieldValue('portRange', '')
288+
}}
289+
>
290+
Add port filter
291+
</Button>
208292
</div>
209293
<ul>
210-
<li>
211-
1234
212-
<Delete10Icon className="cursor-pointer ml-2" />
213-
</li>
214-
<li>
215-
456-567
216-
<Delete10Icon className="cursor-pointer ml-2" />
217-
</li>
218-
<li>
219-
8080-8086
220-
<Delete10Icon className="cursor-pointer ml-2" />
221-
</li>
294+
{values.ports.map((p) => (
295+
<li key={p}>
296+
{p}
297+
<Delete10Icon
298+
className="cursor-pointer ml-2"
299+
onClick={() => {
300+
setFieldValue(
301+
'ports',
302+
values.ports.filter((p1) => p1 !== p)
303+
)
304+
}}
305+
/>
306+
</li>
307+
))}
222308
</ul>
223309
</div>
224310
</SideModal.Section>
@@ -308,25 +394,34 @@ export function CreateFirewallRuleModal({
308394
onDismiss={dismiss}
309395
>
310396
<Formik
311-
initialValues={{
312-
enabled: false,
313-
priority: '',
314-
name: '',
315-
description: '',
316-
action: 'allow',
317-
direction: 'inbound',
318-
// TODO: in the request body, these go in a `filters` object. we probably don't
319-
// need such nesting here though. not even sure how to do it
320-
// filters
321-
protocols: [],
322-
ports: [],
323-
hosts: [],
397+
initialValues={
398+
{
399+
enabled: false,
400+
priority: '',
401+
name: '',
402+
description: '',
403+
action: 'allow',
404+
direction: 'inbound',
324405

325-
// target subform
326-
targets: [],
327-
targetType: '',
328-
targetName: '',
329-
}}
406+
// in the request body, these go in a `filters` object. we probably don't
407+
// need such nesting here though. not even sure how to do it
408+
protocols: [],
409+
410+
// port subform
411+
ports: [],
412+
portRange: '',
413+
414+
// host subform
415+
hosts: [],
416+
hostType: '',
417+
hostValue: '',
418+
419+
// target subform
420+
targets: [],
421+
targetType: '',
422+
targetValue: '',
423+
} as Values // best way to tell formik this type
424+
}
330425
validationSchema={Yup.object({
331426
priority: Yup.number()
332427
.integer()

app/pages/project/networking/VpcPage/tabs/VpcFirewallRulesTab.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export const VpcFirewallRulesTab = () => {
2020

2121
const { Table, Column } = useQueryTable('vpcFirewallRulesGet', vpcParams)
2222

23-
const [createModalOpen, setCreateModalOpen] = useState(false)
23+
const [createModalOpen, setCreateModalOpen] = useState(true)
2424
const [editing, setEditing] = useState<VpcFirewallRule | null>(null)
2525

2626
const actions = (rule: VpcFirewallRule): MenuAction[] => [

libs/api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const useApiQuery = getUseApiQuery(api.methods)
2626
export const useApiMutation = getUseApiMutation(api.methods)
2727
export const useApiQueryClient = getUseApiQueryClient<A>()
2828

29+
export * from './parse'
2930
export * from './__generated__/Api'
3031

3132
// for convenience so we can do `import type { ApiTypes } from '@oxide/api'`

libs/api/parse.spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { parsePortRange } from './parse'
2+
3+
describe('parsePortRange', () => {
4+
describe('parses', () => {
5+
it('parses single ports up to 5 digits', () => {
6+
expect(parsePortRange('0')).toEqual([0, 0])
7+
expect(parsePortRange('1')).toEqual([1, 1])
8+
expect(parsePortRange('123')).toEqual([123, 123])
9+
expect(parsePortRange('12356')).toEqual([12356, 12356])
10+
})
11+
12+
it('parses ranges', () => {
13+
expect(parsePortRange('123-456')).toEqual([123, 456])
14+
expect(parsePortRange('1-45690')).toEqual([1, 45690])
15+
expect(parsePortRange('5-5')).toEqual([5, 5])
16+
})
17+
})
18+
19+
describe('rejects', () => {
20+
it('nonsense', () => {
21+
expect(parsePortRange('12a5')).toEqual(null)
22+
expect(parsePortRange('lkajsdfha')).toEqual(null)
23+
})
24+
25+
it('p2 < p1', () => {
26+
expect(parsePortRange('123-45')).toEqual(null)
27+
})
28+
29+
it('too many digits', () => {
30+
expect(parsePortRange('239032')).toEqual(null)
31+
})
32+
})
33+
})

libs/api/parse.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
type PortRange = [number, number]
2+
3+
/** Parse '1234' into [1234, 1234] and '80-100' into [80, 100] */
4+
// TODO: parsing should probably throw errors rather than returning
5+
// null so we can annotate the failure with a reason
6+
export function parsePortRange(portRange: string): PortRange | null {
7+
// TODO: pull pattern from openapi spec (requires generator work)
8+
const match = /^([0-9]{1,5})((?:-)[0-9]{1,5})?$/.exec(portRange)
9+
if (!match) return null
10+
11+
const p1 = parseInt(match[1], 10)
12+
// API treats a single port as a range with the same start and end
13+
const p2 = match[2] ? parseInt(match[2].slice(1), 10) : p1
14+
15+
if (p2 < p1) return null
16+
17+
return [p1, p2]
18+
}

0 commit comments

Comments
 (0)