diff --git a/app/forms/firewall-rules-common.tsx b/app/forms/firewall-rules-common.tsx index 809799b72..7a48be93c 100644 --- a/app/forms/firewall-rules-common.tsx +++ b/app/forms/firewall-rules-common.tsx @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ -import { useEffect, type ReactNode } from 'react' +import { useEffect, useState, type ReactNode } from 'react' import { useController, useForm, type Control } from 'react-hook-form' import { @@ -40,7 +40,7 @@ import { KEYS } from '~/ui/util/keys' import { ALL_ISH } from '~/util/consts' import { validateIp, validateIpNet } from '~/util/ip' import { links } from '~/util/links' -import { capitalize } from '~/util/str' +import { capitalize, commaSeries } from '~/util/str' import { type FirewallRuleValues } from './firewall-rules-util' @@ -88,10 +88,12 @@ const TargetAndHostFilterSubform = ({ sectionType, control, messageContent, + updateSubformStates, }: { sectionType: 'target' | 'host' control: Control messageContent: ReactNode + updateSubformStates: (subform: keyof ActiveSubforms, value: boolean) => void }) => { const { project, vpc } = useVpcSelector() // prefetchedApiQueries below are prefetched in firewall-rules-create and -edit @@ -125,8 +127,11 @@ const TargetAndHostFilterSubform = ({ // https://github.com/react-hook-form/react-hook-form/blob/9a497a70a/src/logic/createFormControl.ts#L1194-L1203 const { isSubmitSuccessful: subformSubmitSuccessful } = subform.formState useEffect(() => { - if (subformSubmitSuccessful) subform.reset(targetAndHostDefaultValues) - }, [subformSubmitSuccessful, subform]) + if (subformSubmitSuccessful) { + subform.reset(targetAndHostDefaultValues) + updateSubformStates(sectionType, false) + } + }, [subformSubmitSuccessful, subform, updateSubformStates, sectionType]) const [valueType, value] = subform.watch(['type', 'value']) const sectionItems = { @@ -143,9 +148,15 @@ const TargetAndHostFilterSubform = ({ // back to validating on submit instead of change. Also resets readyToSubmit. const onTypeChange = () => { subform.reset({ type: subform.getValues('type'), value: '' }) + updateSubformStates(sectionType, false) } const onInputChange = (value: string) => { subform.setValue('value', value) + updateSubformStates(sectionType, value.length > 0) + } + const onClear = () => { + subform.reset() + updateSubformStates(sectionType, false) } return ( @@ -178,6 +189,7 @@ const TargetAndHostFilterSubform = ({ description="Select an option or enter a custom value" control={subformControl} onEnter={submitSubform} + onChange={() => updateSubformStates(sectionType, true)} onInputChange={onInputChange} items={items} allowArbitraryValues @@ -212,7 +224,7 @@ const TargetAndHostFilterSubform = ({ subform.reset()} + onClear={onClear} onSubmit={submitSubform} /> {field.value.length > 0 && ( @@ -289,6 +301,7 @@ type CommonFieldsProps = { control: Control nameTaken: (name: string) => boolean error: ApiError | null + updateSubformStates: (subform: keyof ActiveSubforms, value: boolean) => void } const targetAndHostDefaultValues: TargetAndHostFormValues = { @@ -296,7 +309,12 @@ const targetAndHostDefaultValues: TargetAndHostFormValues = { value: '', } -export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) => { +export const CommonFields = ({ + control, + nameTaken, + error, + updateSubformStates, +}: CommonFieldsProps) => { // Ports const portRangeForm = useForm({ defaultValues: { portRange: '' } }) const ports = useController({ name: 'ports', control }).field @@ -307,7 +325,11 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) = // that it is not already in the list ports.onChange([...ports.value, portRangeValue]) portRangeForm.reset() + updateSubformStates('port', false) }) + useEffect(() => { + updateSubformStates('port', portValue.length > 0) + }, [updateSubformStates, portValue]) return ( <> @@ -406,6 +428,7 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) =

} + updateSubformStates={updateSubformStates} /> @@ -453,7 +476,10 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) = portRangeForm.reset()} + onClear={() => { + portRangeForm.reset() + updateSubformStates('port', false) + }} onSubmit={submitPortRange} /> @@ -500,6 +526,7 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) = traffic. For an outbound rule, they match the destination. } + updateSubformStates={updateSubformStates} /> {error && ( @@ -511,3 +538,33 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) = ) } + +export type ActiveSubforms = { target: boolean; port: boolean; host: boolean } +export const defaultActiveSubforms: ActiveSubforms = { + target: false, + port: false, + host: false, +} + +export function useSubformStates(defaultActiveSubforms: ActiveSubforms) { + const [subformStates, setSubformStates] = useState(defaultActiveSubforms) + const updateSubformStates = (subform: keyof ActiveSubforms, value: boolean) => { + setSubformStates((prev) => ({ ...prev, [subform]: value })) + } + return { subformStates, updateSubformStates } +} + +export const getActiveSubformList = (subformStates: ActiveSubforms) => + commaSeries( + Object.keys(subformStates).filter((key) => subformStates[key as keyof ActiveSubforms]), + 'and' + ) + .replace('port', 'port filter') + .replace('host', 'host filter') + +export const submitDisabledMessage = (subformStates: ActiveSubforms) => { + const activeSubformList = getActiveSubformList(subformStates) + return activeSubformList.length > 0 + ? `You have an unsaved ${activeSubformList} entry; save or clear ${activeSubformList.includes('and') ? 'them' : 'it'} to create this firewall rule` + : '' +} diff --git a/app/forms/firewall-rules-create.tsx b/app/forms/firewall-rules-create.tsx index 6aeacbee6..8ee4f186b 100644 --- a/app/forms/firewall-rules-create.tsx +++ b/app/forms/firewall-rules-create.tsx @@ -25,7 +25,12 @@ import { addToast } from '~/stores/toast' import { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' -import { CommonFields } from './firewall-rules-common' +import { + CommonFields, + defaultActiveSubforms, + submitDisabledMessage, + useSubformStates, +} from './firewall-rules-common' import { valuesToRuleUpdate, type FirewallRuleValues } from './firewall-rules-util' export const handle = titleCrumb('New Rule') @@ -73,6 +78,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { } export default function CreateFirewallRuleForm() { + const { subformStates, updateSubformStates } = useSubformStates(defaultActiveSubforms) const vpcSelector = useVpcSelector() const queryClient = useApiQueryClient() @@ -121,14 +127,16 @@ export default function CreateFirewallRuleForm() { }) }} loading={updateRules.isPending} - submitError={updateRules.error} submitLabel="Add rule" + submitDisabled={submitDisabledMessage(subformStates)} + submitError={updateRules.error} > !!existingRules.find((r) => r.name === name)} error={updateRules.error} + updateSubformStates={updateSubformStates} // TODO: there should also be a form-level error so if the name is off // screen, it doesn't look like the submit button isn't working. Maybe // instead of setting a root error, it would be more robust to show a diff --git a/app/forms/firewall-rules-edit.tsx b/app/forms/firewall-rules-edit.tsx index 7a5ac6e54..3296327a3 100644 --- a/app/forms/firewall-rules-edit.tsx +++ b/app/forms/firewall-rules-edit.tsx @@ -30,7 +30,12 @@ import { ALL_ISH } from '~/util/consts' import { invariant } from '~/util/invariant' import { pb } from '~/util/path-builder' -import { CommonFields } from './firewall-rules-common' +import { + CommonFields, + defaultActiveSubforms, + submitDisabledMessage, + useSubformStates, +} from './firewall-rules-common' import { valuesToRuleUpdate, type FirewallRuleValues } from './firewall-rules-util' export const handle = titleCrumb('Edit Rule') @@ -58,6 +63,8 @@ export default function EditFirewallRuleForm() { const vpcSelector = useVpcSelector() const queryClient = useApiQueryClient() + const { subformStates, updateSubformStates } = useSubformStates(defaultActiveSubforms) + const { data: firewallRules } = usePrefetchedApiQuery('vpcFirewallRulesView', { query: { project, vpc }, }) @@ -125,6 +132,7 @@ export default function EditFirewallRuleForm() { // validationSchema={validationSchema} // validateOnBlur loading={updateRules.isPending} + submitDisabled={submitDisabledMessage(subformStates)} submitError={updateRules.error} > !!otherRules.find((r) => r.name === name)} error={updateRules.error} + updateSubformStates={updateSubformStates} /> )