Skip to content

Commit 888051f

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

File tree

5 files changed

+206
-62
lines changed

5 files changed

+206
-62
lines changed

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

Lines changed: 157 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,38 @@ 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+
if (
117+
values.targetType &&
118+
values.targetValue && // TODO: validate
119+
!values.targets.some(
120+
(t) =>
121+
t.value === values.targetValue &&
122+
t.type === values.targetType
123+
)
124+
) {
96125
setFieldValue('targets', [
97-
...targets,
98-
{ type: targetType, name: targetName },
126+
...values.targets,
127+
{ type: values.targetType, value: values.targetValue },
99128
])
129+
setFieldValue('targetValue', '')
130+
// TODO: clear dropdown too?
100131
}
101132
}}
102133
>
@@ -113,17 +144,20 @@ const CommonForm = ({ id, error }: FormProps) => {
113144
</Table.HeaderRow>
114145
</Table.Header>
115146
<Table.Body>
116-
{targets.map((t) => (
117-
<Table.Row key={t.name}>
147+
{values.targets.map((t) => (
148+
<Table.Row key={`${t.type}|${t.value}`}>
149+
{/* TODO: should be the pretty type label, not the type key */}
118150
<Table.Cell>{t.type}</Table.Cell>
119-
<Table.Cell>{t.name}</Table.Cell>
151+
<Table.Cell>{t.value}</Table.Cell>
120152
<Table.Cell>
121153
<Delete10Icon
122154
className="cursor-pointer"
123155
onClick={() => {
124156
setFieldValue(
125157
'targets',
126-
targets.filter((t1) => t1.name !== t.name)
158+
values.targets.filter(
159+
(t1) => t1.value !== t.value || t1.type !== t.type
160+
)
127161
)
128162
}}
129163
/>
@@ -144,28 +178,52 @@ const CommonForm = ({ id, error }: FormProps) => {
144178
{ value: 'ip', label: 'IP' },
145179
{ value: 'internet_gateway', label: 'Internet Gateway' },
146180
]}
181+
onChange={(item) => {
182+
setFieldValue('hostType', item?.value)
183+
}}
147184
/>
148185
<div className="space-y-0.5">
149186
{/* For everything but IP this is a name, but for IP it's an IP.
150187
So we should probably have the label on this field change when the
151188
host type changes. Also need to confirm that it's just an IP and
152189
not a block. */}
153-
<FieldTitle htmlFor="host-filter-value">Value</FieldTitle>
154-
<TextFieldHint id="host-filter-value-hint">
190+
<FieldTitle htmlFor="hostValue">Value</FieldTitle>
191+
<TextFieldHint id="hostValue-hint">
155192
For IP, an address. For the rest, a name. [TODO: copy]
156193
</TextFieldHint>
157194
<TextField
158-
id="host-filter-value"
159-
name="host-filter-value"
160-
aria-describedby="host-filter-value-hint"
195+
id="hostValue"
196+
name="hostValue"
197+
aria-describedby="hostValue-hint"
161198
/>
162199
</div>
163200

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

171229
<Table className="w-full">
@@ -177,13 +235,26 @@ const CommonForm = ({ id, error }: FormProps) => {
177235
</Table.HeaderRow>
178236
</Table.Header>
179237
<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>
238+
{values.hosts.map((h) => (
239+
<Table.Row key={`${h.type}|${h.value}`}>
240+
{/* TODO: should be the pretty type label, not the type key */}
241+
<Table.Cell>{h.type}</Table.Cell>
242+
<Table.Cell>{h.value}</Table.Cell>
243+
<Table.Cell>
244+
<Delete10Icon
245+
className="cursor-pointer"
246+
onClick={() => {
247+
setFieldValue(
248+
'hosts',
249+
values.hosts.filter(
250+
(h1) => h1.value !== h.value && h1.type !== h.type
251+
)
252+
)
253+
}}
254+
/>
255+
</Table.Cell>
256+
</Table.Row>
257+
))}
187258
</Table.Body>
188259
</Table>
189260
</SideModal.Section>
@@ -204,21 +275,37 @@ const CommonForm = ({ id, error }: FormProps) => {
204275
<Button variant="ghost" className="mr-2.5">
205276
Clear
206277
</Button>
207-
<Button variant="dim">Add port filter</Button>
278+
<Button
279+
variant="dim"
280+
onClick={() => {
281+
const portRange = values.portRange.trim()
282+
const ports = parsePortRange(portRange)
283+
if (!ports) return
284+
const [p1, p2] = ports
285+
if (p2 === null || p2 > p1) {
286+
// TODO: can ranges overlap? don't see why not, API can union them
287+
setFieldValue('ports', [...values.ports, portRange])
288+
}
289+
}}
290+
>
291+
Add port filter
292+
</Button>
208293
</div>
209294
<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>
295+
{values.ports.map((p) => (
296+
<li key={p}>
297+
{p}
298+
<Delete10Icon
299+
className="cursor-pointer ml-2"
300+
onClick={() => {
301+
setFieldValue(
302+
'ports',
303+
values.ports.filter((p1) => p1 !== p)
304+
)
305+
}}
306+
/>
307+
</li>
308+
))}
222309
</ul>
223310
</div>
224311
</SideModal.Section>
@@ -308,25 +395,34 @@ export function CreateFirewallRuleModal({
308395
onDismiss={dismiss}
309396
>
310397
<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: [],
398+
initialValues={
399+
{
400+
enabled: false,
401+
priority: '',
402+
name: '',
403+
description: '',
404+
action: 'allow',
405+
direction: 'inbound',
324406

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