Skip to content

Commit 62844ae

Browse files
committed
Add logic to prevent form abandonment
1 parent c147df0 commit 62844ae

File tree

2 files changed

+58
-5
lines changed

2 files changed

+58
-5
lines changed

app/forms/firewall-rules-common.tsx

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { validateIp, validateIpNet } from '~/util/ip'
4242
import { links } from '~/util/links'
4343
import { capitalize } from '~/util/str'
4444

45+
import { type ActiveSubforms } from './firewall-rules-create'
4546
import { type FirewallRuleValues } from './firewall-rules-util'
4647

4748
/**
@@ -88,10 +89,12 @@ const TargetAndHostFilterSubform = ({
8889
sectionType,
8990
control,
9091
messageContent,
92+
updateSubformStates,
9193
}: {
9294
sectionType: 'target' | 'host'
9395
control: Control<FirewallRuleValues>
9496
messageContent: ReactNode
97+
updateSubformStates: (subform: keyof ActiveSubforms, value: boolean) => void
9598
}) => {
9699
const { project, vpc } = useVpcSelector()
97100
// prefetchedApiQueries below are prefetched in firewall-rules-create and -edit
@@ -125,8 +128,11 @@ const TargetAndHostFilterSubform = ({
125128
// https://github.com/react-hook-form/react-hook-form/blob/9a497a70a/src/logic/createFormControl.ts#L1194-L1203
126129
const { isSubmitSuccessful: subformSubmitSuccessful } = subform.formState
127130
useEffect(() => {
128-
if (subformSubmitSuccessful) subform.reset(targetAndHostDefaultValues)
129-
}, [subformSubmitSuccessful, subform])
131+
if (subformSubmitSuccessful) {
132+
subform.reset(targetAndHostDefaultValues)
133+
updateSubformStates(sectionType, false)
134+
}
135+
}, [subformSubmitSuccessful, subform, updateSubformStates, sectionType])
130136

131137
const [valueType, value] = subform.watch(['type', 'value'])
132138
const sectionItems = {
@@ -143,9 +149,15 @@ const TargetAndHostFilterSubform = ({
143149
// back to validating on submit instead of change. Also resets readyToSubmit.
144150
const onTypeChange = () => {
145151
subform.reset({ type: subform.getValues('type'), value: '' })
152+
updateSubformStates(sectionType, false)
146153
}
147154
const onInputChange = (value: string) => {
148155
subform.setValue('value', value)
156+
updateSubformStates(sectionType, value.length > 0)
157+
}
158+
const onClear = () => {
159+
subform.reset()
160+
updateSubformStates(sectionType, false)
149161
}
150162

151163
return (
@@ -178,6 +190,7 @@ const TargetAndHostFilterSubform = ({
178190
description="Select an option or enter a custom value"
179191
control={subformControl}
180192
onEnter={submitSubform}
193+
onChange={() => updateSubformStates(sectionType, true)}
181194
onInputChange={onInputChange}
182195
items={items}
183196
allowArbitraryValues
@@ -212,7 +225,7 @@ const TargetAndHostFilterSubform = ({
212225
<MiniTable.ClearAndAddButtons
213226
addButtonCopy={`Add ${sectionType === 'host' ? 'host filter' : 'target'}`}
214227
disabled={!value}
215-
onClear={() => subform.reset()}
228+
onClear={onClear}
216229
onSubmit={submitSubform}
217230
/>
218231
{field.value.length > 0 && (
@@ -289,14 +302,20 @@ type CommonFieldsProps = {
289302
control: Control<FirewallRuleValues>
290303
nameTaken: (name: string) => boolean
291304
error: ApiError | null
305+
updateSubformStates: (subform: keyof ActiveSubforms, value: boolean) => void
292306
}
293307

294308
const targetAndHostDefaultValues: TargetAndHostFormValues = {
295309
type: 'vpc',
296310
value: '',
297311
}
298312

299-
export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) => {
313+
export const CommonFields = ({
314+
control,
315+
nameTaken,
316+
error,
317+
updateSubformStates,
318+
}: CommonFieldsProps) => {
300319
// Ports
301320
const portRangeForm = useForm({ defaultValues: { portRange: '' } })
302321
const ports = useController({ name: 'ports', control }).field
@@ -307,7 +326,11 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) =
307326
// that it is not already in the list
308327
ports.onChange([...ports.value, portRangeValue])
309328
portRangeForm.reset()
329+
updateSubformStates('port', false)
310330
})
331+
useEffect(() => {
332+
updateSubformStates('port', portValue.length > 0)
333+
}, [updateSubformStates, portValue])
311334

312335
return (
313336
<>
@@ -406,6 +429,7 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) =
406429
</p>
407430
</>
408431
}
432+
updateSubformStates={updateSubformStates}
409433
/>
410434

411435
<FormDivider />
@@ -453,7 +477,10 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) =
453477
<MiniTable.ClearAndAddButtons
454478
addButtonCopy="Add port filter"
455479
disabled={!portValue}
456-
onClear={() => portRangeForm.reset()}
480+
onClear={() => {
481+
portRangeForm.reset()
482+
updateSubformStates('port', false)
483+
}}
457484
onSubmit={submitPortRange}
458485
/>
459486
</div>
@@ -500,6 +527,7 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) =
500527
traffic. For an outbound rule, they match the destination.
501528
</>
502529
}
530+
updateSubformStates={updateSubformStates}
503531
/>
504532

505533
{error && (

app/forms/firewall-rules-create.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8+
import { useState } from 'react'
89
import { useForm } from 'react-hook-form'
910
import { useNavigate, useParams, type LoaderFunctionArgs } from 'react-router'
1011

@@ -23,6 +24,7 @@ import { getVpcSelector, useVpcSelector } from '~/hooks/use-params'
2324
import { addToast } from '~/stores/toast'
2425
import { ALL_ISH } from '~/util/consts'
2526
import { pb } from '~/util/path-builder'
27+
import { commaSeries } from '~/util/str'
2628

2729
import { CommonFields } from './firewall-rules-common'
2830
import { valuesToRuleUpdate, type FirewallRuleValues } from './firewall-rules-util'
@@ -69,7 +71,24 @@ CreateFirewallRuleForm.loader = async ({ params }: LoaderFunctionArgs) => {
6971
return null
7072
}
7173

74+
export type ActiveSubforms = { target: boolean; port: boolean; host: boolean }
75+
const defaultActiveSubforms: ActiveSubforms = { target: false, port: false, host: false }
76+
7277
export function CreateFirewallRuleForm() {
78+
const [subformStates, setSubformStates] = useState(defaultActiveSubforms)
79+
const updateSubformStates = (subform: keyof ActiveSubforms, value: boolean) => {
80+
setSubformStates({
81+
...subformStates,
82+
[subform]: value,
83+
})
84+
}
85+
const activeSubformList = commaSeries(
86+
Object.keys(subformStates).filter((key) => subformStates[key as keyof ActiveSubforms]),
87+
'and'
88+
)
89+
.replace('port', 'port filter')
90+
.replace('host', 'host filter')
91+
7392
const vpcSelector = useVpcSelector()
7493
const queryClient = useApiQueryClient()
7594

@@ -120,12 +139,18 @@ export function CreateFirewallRuleForm() {
120139
loading={updateRules.isPending}
121140
submitError={updateRules.error}
122141
submitLabel="Add rule"
142+
submitDisabled={
143+
activeSubformList.length
144+
? `You have an unsaved ${activeSubformList} entry; save or clear ${activeSubformList.includes('and') ? 'them' : 'it'} to create this firewall rule`
145+
: undefined
146+
}
123147
>
124148
<CommonFields
125149
control={form.control}
126150
// error if name is already in use
127151
nameTaken={(name) => !!existingRules.find((r) => r.name === name)}
128152
error={updateRules.error}
153+
updateSubformStates={updateSubformStates}
129154
// TODO: there should also be a form-level error so if the name is off
130155
// screen, it doesn't look like the submit button isn't working. Maybe
131156
// instead of setting a root error, it would be more robust to show a

0 commit comments

Comments
 (0)