Skip to content

Commit 2afb5d0

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

File tree

5 files changed

+204
-62
lines changed

5 files changed

+204
-62
lines changed

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

Lines changed: 155 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -18,24 +18,43 @@ 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+
}
3153

3254
// the moment the two forms diverge, inline them rather than introducing BS
3355
// props here
3456
const CommonForm = ({ id, error }: FormProps) => {
35-
const {
36-
setFieldValue,
37-
values: { targetName, targetType, targets },
38-
} = useFormikContext()
57+
const { setFieldValue, values } = useFormikContext<Values>()
3958
return (
4059
<Form id={id}>
4160
<SideModal.Section>
@@ -75,28 +94,38 @@ const CommonForm = ({ id, error }: FormProps) => {
7594
{ value: 'subnet', label: 'VPC Subnet' },
7695
{ value: 'instance', label: 'Instance' },
7796
]}
78-
// TODO: this is kind of a hack? move this inside Dropdown somehow
7997
onChange={(item) => {
8098
setFieldValue('targetType', item?.value)
8199
}}
82100
/>
83101
<div className="space-y-0.5">
84-
<FieldTitle htmlFor="targetName">Name</FieldTitle>
85-
<TextField id="targetName" name="targetName" />
102+
<FieldTitle htmlFor="targetValue">Name</FieldTitle>
103+
<TextField id="targetValue" name="targetValue" />
86104
</div>
87105

88106
<div className="flex justify-end">
107+
{/* TODO does this clear out the form or the existing targets? */}
89108
<Button variant="ghost" className="mr-2.5">
90109
Clear
91110
</Button>
92111
<Button
93112
variant="dim"
94113
onClick={() => {
95-
if (!targets.some((t) => t.name === targetName)) {
114+
if (
115+
values.targetType &&
116+
values.targetValue && // TODO: validate
117+
!values.targets.some(
118+
(t) =>
119+
t.value === values.targetValue &&
120+
t.type === values.targetType
121+
)
122+
) {
96123
setFieldValue('targets', [
97-
...targets,
98-
{ type: targetType, name: targetName },
124+
...values.targets,
125+
{ type: values.targetType, value: values.targetValue },
99126
])
127+
setFieldValue('targetValue', '')
128+
// TODO: clear dropdown too?
100129
}
101130
}}
102131
>
@@ -113,17 +142,20 @@ const CommonForm = ({ id, error }: FormProps) => {
113142
</Table.HeaderRow>
114143
</Table.Header>
115144
<Table.Body>
116-
{targets.map((t) => (
117-
<Table.Row key={t.name}>
145+
{values.targets.map((t) => (
146+
<Table.Row key={`${t.type}|${t.value}`}>
147+
{/* TODO: should be the pretty type label, not the type key */}
118148
<Table.Cell>{t.type}</Table.Cell>
119-
<Table.Cell>{t.name}</Table.Cell>
149+
<Table.Cell>{t.value}</Table.Cell>
120150
<Table.Cell>
121151
<Delete10Icon
122152
className="cursor-pointer"
123153
onClick={() => {
124154
setFieldValue(
125155
'targets',
126-
targets.filter((t1) => t1.name !== t.name)
156+
values.targets.filter(
157+
(t1) => t1.value !== t.value || t1.type !== t.type
158+
)
127159
)
128160
}}
129161
/>
@@ -144,28 +176,52 @@ const CommonForm = ({ id, error }: FormProps) => {
144176
{ value: 'ip', label: 'IP' },
145177
{ value: 'internet_gateway', label: 'Internet Gateway' },
146178
]}
179+
onChange={(item) => {
180+
setFieldValue('hostType', item?.value)
181+
}}
147182
/>
148183
<div className="space-y-0.5">
149184
{/* For everything but IP this is a name, but for IP it's an IP.
150185
So we should probably have the label on this field change when the
151186
host type changes. Also need to confirm that it's just an IP and
152187
not a block. */}
153-
<FieldTitle htmlFor="host-filter-value">Value</FieldTitle>
154-
<TextFieldHint id="host-filter-value-hint">
188+
<FieldTitle htmlFor="hostValue">Value</FieldTitle>
189+
<TextFieldHint id="hostValue-hint">
155190
For IP, an address. For the rest, a name. [TODO: copy]
156191
</TextFieldHint>
157192
<TextField
158-
id="host-filter-value"
159-
name="host-filter-value"
160-
aria-describedby="host-filter-value-hint"
193+
id="hostValue"
194+
name="hostValue"
195+
aria-describedby="hostValue-hint"
161196
/>
162197
</div>
163198

164199
<div className="flex justify-end">
165200
<Button variant="ghost" className="mr-2.5">
166201
Clear
167202
</Button>
168-
<Button variant="dim">Add host filter</Button>
203+
<Button
204+
variant="dim"
205+
onClick={() => {
206+
if (
207+
values.hostType &&
208+
values.hostValue && // TODO: validate
209+
!values.hosts.some(
210+
(t) =>
211+
t.value === values.hostValue || t.type === values.hostType
212+
)
213+
) {
214+
setFieldValue('hosts', [
215+
...values.hosts,
216+
{ type: values.hostType, value: values.hostValue },
217+
])
218+
setFieldValue('hostValue', '')
219+
// TODO: clear dropdown too?
220+
}
221+
}}
222+
>
223+
Add host filter
224+
</Button>
169225
</div>
170226

171227
<Table className="w-full">
@@ -177,13 +233,26 @@ const CommonForm = ({ id, error }: FormProps) => {
177233
</Table.HeaderRow>
178234
</Table.Header>
179235
<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>
236+
{values.hosts.map((h) => (
237+
<Table.Row key={`${h.type}|${h.value}`}>
238+
{/* TODO: should be the pretty type label, not the type key */}
239+
<Table.Cell>{h.type}</Table.Cell>
240+
<Table.Cell>{h.value}</Table.Cell>
241+
<Table.Cell>
242+
<Delete10Icon
243+
className="cursor-pointer"
244+
onClick={() => {
245+
setFieldValue(
246+
'hosts',
247+
values.hosts.filter(
248+
(h1) => h1.value !== h.value && h1.type !== h.type
249+
)
250+
)
251+
}}
252+
/>
253+
</Table.Cell>
254+
</Table.Row>
255+
))}
187256
</Table.Body>
188257
</Table>
189258
</SideModal.Section>
@@ -204,21 +273,37 @@ const CommonForm = ({ id, error }: FormProps) => {
204273
<Button variant="ghost" className="mr-2.5">
205274
Clear
206275
</Button>
207-
<Button variant="dim">Add port filter</Button>
276+
<Button
277+
variant="dim"
278+
onClick={() => {
279+
const portRange = values.portRange.trim()
280+
const ports = parsePortRange(portRange)
281+
if (!ports) return
282+
const [p1, p2] = ports
283+
if (p2 === null || p2 > p1) {
284+
// TODO: can ranges overlap? don't see why not, API can union them
285+
setFieldValue('ports', [...values.ports, portRange])
286+
}
287+
}}
288+
>
289+
Add port filter
290+
</Button>
208291
</div>
209292
<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>
293+
{values.ports.map((p) => (
294+
<li key={p}>
295+
{p}
296+
<Delete10Icon
297+
className="cursor-pointer ml-2"
298+
onClick={() => {
299+
setFieldValue(
300+
'ports',
301+
values.ports.filter((p1) => p1 !== p)
302+
)
303+
}}
304+
/>
305+
</li>
306+
))}
222307
</ul>
223308
</div>
224309
</SideModal.Section>
@@ -308,25 +393,34 @@ export function CreateFirewallRuleModal({
308393
onDismiss={dismiss}
309394
>
310395
<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: [],
396+
initialValues={
397+
{
398+
enabled: false,
399+
priority: '',
400+
name: '',
401+
description: '',
402+
action: 'allow',
403+
direction: 'inbound',
324404

325-
// target subform
326-
targets: [],
327-
targetType: '',
328-
targetName: '',
329-
}}
405+
// in the request body, these go in a `filters` object. we probably don't
406+
// need such nesting here though. not even sure how to do it
407+
protocols: [],
408+
409+
// port subform
410+
ports: [],
411+
portRange: '',
412+
413+
// host subform
414+
hosts: [],
415+
hostType: '',
416+
hostValue: '',
417+
418+
// target subform
419+
targets: [],
420+
targetType: '',
421+
targetValue: '',
422+
} as Values // best way to tell formik this type
423+
}
330424
validationSchema={Yup.object({
331425
priority: Yup.number()
332426
.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, null])
7+
expect(parsePortRange('1')).toEqual([1, null])
8+
expect(parsePortRange('123')).toEqual([123, null])
9+
expect(parsePortRange('12356')).toEqual([12356, null])
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: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
type PortRange = [number, number | null]
2+
3+
export function parsePortRange(portRange: string): PortRange | null {
4+
// TODO: pull pattern from openapi spec (requires generator work)
5+
const match = /^([0-9]{1,5})((?:-)[0-9]{1,5})?$/.exec(portRange)
6+
if (!match) return null
7+
8+
const p1 = parseInt(match[1], 10)
9+
const p2 = match[2] ? parseInt(match[2].slice(1), 10) : null
10+
11+
if (p2 !== null && p2 < p1) return null
12+
13+
return [p1, p2]
14+
}

0 commit comments

Comments
 (0)