-
Notifications
You must be signed in to change notification settings - Fork 23
Upgrade react-hook-form and use new form-level validate
#3188
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,7 +5,7 @@ | |
| * | ||
| * Copyright Oxide Computer Company | ||
| */ | ||
| import { useForm, type FieldErrors } from 'react-hook-form' | ||
| import { useForm } from 'react-hook-form' | ||
| import { useNavigate } from 'react-router' | ||
|
|
||
| import { | ||
|
|
@@ -41,65 +41,62 @@ const defaultValues: MemberAddForm = { | |
| maxPrefixLength: NaN, | ||
| } | ||
|
|
||
| // Using a resolver overrides all field-level validation (required, min, max, | ||
| // etc.), so this function must cover everything. Field-level props like | ||
| // `required` on subnet and `min`/`max` on NumberFields still affect UI display | ||
| // and stepper behavior, but their RHF validation rules are inert. | ||
| export function createResolver(poolVersion: IpVersion) { | ||
| return (values: MemberAddForm) => { | ||
| const errors: FieldErrors<MemberAddForm> = {} | ||
| const maxBound = poolVersion === 'v4' ? 32 : 128 | ||
|
|
||
| const parsed = parseIpNet(values.subnet) | ||
| if (parsed.type === 'error') { | ||
| errors.subnet = { type: 'pattern', message: parsed.message } | ||
| } else if (parsed.type !== poolVersion) { | ||
| errors.subnet = { | ||
| type: 'pattern', | ||
| message: `IP${parsed.type} subnet not allowed in IP${poolVersion} pool`, | ||
| } | ||
| // Uses form-level validate (RHF ≥7.72.0) so we can look at all three fields | ||
| // together. Unlike `resolver`, this runs alongside field-level validation, so | ||
| // `required` / `min` / `max` on the fields still apply. | ||
| export function validateForm(poolVersion: IpVersion, values: MemberAddForm) { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Look at this with whitespace changes hidden to see what a small change it is. Basically it's just the return type changing. https://github.com/oxidecomputer/console/pull/3188/changes?w=1 |
||
| const maxBound = poolVersion === 'v4' ? 32 : 128 | ||
| const parsed = parseIpNet(values.subnet) | ||
| const { minPrefixLength: minPL, maxPrefixLength: maxPL } = values | ||
| const subnetWidth = parsed.type !== 'error' ? parsed.width : undefined | ||
| const inRange = (v: number) => !Number.isNaN(v) && v >= 0 && v <= maxBound | ||
|
|
||
| const errors: Partial<Record<keyof MemberAddForm, { type: string; message: string }>> = {} | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't love this, will revisit. |
||
|
|
||
| if (parsed.type === 'error') { | ||
| errors.subnet = { type: 'pattern', message: parsed.message } | ||
| } else if (parsed.type !== poolVersion) { | ||
| errors.subnet = { | ||
| type: 'pattern', | ||
| message: `IP${parsed.type} subnet not allowed in IP${poolVersion} pool`, | ||
| } | ||
| } | ||
|
|
||
| const { minPrefixLength: minPL, maxPrefixLength: maxPL } = values | ||
| const subnetWidth = parsed.type !== 'error' ? parsed.width : undefined | ||
| const inRange = (v: number) => !Number.isNaN(v) && v >= 0 && v <= maxBound | ||
|
|
||
| // min and max prefix length are optional, and NaN is the value they have | ||
| // when they're unset (matching NumberField) | ||
|
|
||
| // min prefix: bounds → ordering → subnet width | ||
| if (!Number.isNaN(minPL) && !inRange(minPL)) { | ||
| errors.minPrefixLength = { | ||
| type: 'validate', | ||
| message: `Must be between 0 and ${maxBound}`, | ||
| } | ||
| } else if (inRange(minPL) && inRange(maxPL) && minPL > maxPL) { | ||
| errors.minPrefixLength = { | ||
| type: 'validate', | ||
| message: 'Min prefix length must be ≤ max prefix length', | ||
| } | ||
| } else if (inRange(minPL) && subnetWidth !== undefined && minPL < subnetWidth) { | ||
| errors.minPrefixLength = { | ||
| type: 'validate', | ||
| message: `Must be ≥ subnet prefix length (${subnetWidth})`, | ||
| } | ||
| } | ||
| // min and max prefix length are optional, and NaN is the value they have | ||
| // when they're unset (matching NumberField) | ||
|
|
||
| // max prefix: bounds → subnet width | ||
| if (!Number.isNaN(maxPL) && !inRange(maxPL)) { | ||
| errors.maxPrefixLength = { | ||
| type: 'validate', | ||
| message: `Must be between 0 and ${maxBound}`, | ||
| } | ||
| } else if (inRange(maxPL) && subnetWidth !== undefined && maxPL < subnetWidth) { | ||
| errors.maxPrefixLength = { | ||
| type: 'validate', | ||
| message: `Must be ≥ subnet prefix length (${subnetWidth})`, | ||
| } | ||
| // min prefix: bounds → ordering → subnet width | ||
| if (!Number.isNaN(minPL) && !inRange(minPL)) { | ||
| errors.minPrefixLength = { | ||
| type: 'validate', | ||
| message: `Must be between 0 and ${maxBound}`, | ||
| } | ||
| } else if (inRange(minPL) && inRange(maxPL) && minPL > maxPL) { | ||
| errors.minPrefixLength = { | ||
| type: 'validate', | ||
| message: 'Min prefix length must be ≤ max prefix length', | ||
| } | ||
| } else if (inRange(minPL) && subnetWidth !== undefined && minPL < subnetWidth) { | ||
| errors.minPrefixLength = { | ||
| type: 'validate', | ||
| message: `Must be ≥ subnet prefix length (${subnetWidth})`, | ||
| } | ||
| } | ||
|
|
||
| return { values: Object.keys(errors).length > 0 ? {} : values, errors } | ||
| // max prefix: bounds → subnet width | ||
| if (!Number.isNaN(maxPL) && !inRange(maxPL)) { | ||
| errors.maxPrefixLength = { | ||
| type: 'validate', | ||
| message: `Must be between 0 and ${maxBound}`, | ||
| } | ||
| } else if (inRange(maxPL) && subnetWidth !== undefined && maxPL < subnetWidth) { | ||
| errors.maxPrefixLength = { | ||
| type: 'validate', | ||
| message: `Must be ≥ subnet prefix length (${subnetWidth})`, | ||
| } | ||
| } | ||
|
|
||
| return Object.keys(errors).length > 0 ? errors : true | ||
| } | ||
|
|
||
| export const handle = titleCrumb('Add Member') | ||
|
|
@@ -125,8 +122,7 @@ export default function SubnetPoolMemberAdd() { | |
|
|
||
| const form = useForm<MemberAddForm>({ | ||
| defaultValues, | ||
| // doesn't need to be memoized, doesn't trigger renders | ||
| resolver: createResolver(poolData.ipVersion), | ||
| validate: ({ formValues }) => validateForm(poolData.ipVersion, formValues), | ||
| }) | ||
|
|
||
| const maxBound = poolData.ipVersion === 'v4' ? 32 : 128 | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe this was originally a resolver because pools were not locked to a single IP version, so we had to make sure the start and end were of the same version. Now we can just compare each one individually to the version of the pool. So this can all just be a field-level validation. This change does not rely on form-level validation, this was just discovered while looking for candidates for form-level validation.