From 227ff92def4174663c8ad2243b0ca5dac8c65a68 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 9 Sep 2026 08:56:07 -0500 Subject: [PATCH 1/2] Consolidate number field min/max validation and revalidate when bounds change --- app/components/form/fields/DiskSizeField.tsx | 15 +------------- app/components/form/fields/ListboxField.tsx | 6 +++++- app/components/form/fields/NumberField.tsx | 21 +++++++++++++++++--- app/forms/disk-create.tsx | 7 ++++++- app/forms/external-subnet-create.tsx | 2 ++ test/e2e/external-subnets.e2e.ts | 8 ++++++++ 6 files changed, 40 insertions(+), 19 deletions(-) diff --git a/app/components/form/fields/DiskSizeField.tsx b/app/components/form/fields/DiskSizeField.tsx index 44cce80ff..4b4824830 100644 --- a/app/components/form/fields/DiskSizeField.tsx +++ b/app/components/form/fields/DiskSizeField.tsx @@ -39,20 +39,7 @@ export function DiskSizeField< name={name} min={min} max={max} - validate={(diskSizeGiB) => { - // Run a number of default validators - if (Number.isNaN(diskSizeGiB)) { - return 'Disk size is required' - } - if (diskSizeGiB < min) { - return `Must be at least ${min} GiB` - } - if (max !== undefined && diskSizeGiB > max) { - return `Can be at most ${max} GiB` - } - // Run any additional validators passed in from the callsite - return validate?.(diskSizeGiB) - }} + validate={validate} {...props} /> ) diff --git a/app/components/form/fields/ListboxField.tsx b/app/components/form/fields/ListboxField.tsx index b4947038b..3b647eebf 100644 --- a/app/components/form/fields/ListboxField.tsx +++ b/app/components/form/fields/ListboxField.tsx @@ -11,6 +11,7 @@ import { type Control, type FieldPath, type FieldValues, + type RegisterOptions, } from 'react-hook-form' import { Listbox, type ListboxItem } from '~/ui/lib/Listbox' @@ -37,6 +38,8 @@ export type ListboxFieldProps< isLoading?: boolean noItemsPlaceholder?: string hideOptionalTag?: boolean + /** Other fields to re-validate when this one changes */ + deps?: RegisterOptions['deps'] } export function ListboxField< @@ -57,10 +60,11 @@ export function ListboxField< noItemsPlaceholder, hideOptionalTag, hideLabel, + deps, }: ListboxFieldProps) { // TODO: recreate this logic // validate: (v) => (required && !v ? `${name} is required` : undefined), - const { field, fieldState } = useController({ name, control, rules: { required } }) + const { field, fieldState } = useController({ name, control, rules: { required, deps } }) return (
{/* passing the generated id is very important for a11y */} - +
) } @@ -72,6 +79,7 @@ export const NumberFieldInner = < disabled, max, min = 0, + units, }: TextFieldProps) => { const generatedId = useId() const id = idProp || generatedId @@ -85,9 +93,16 @@ export const NumberFieldInner = < rules: { required, deps, - // it seems we need special logic to enforce required on NaN + // RHF's required rule doesn't catch NaN, and its min/max rules don't + // know about units, so we do all three here. The input itself no longer + // clamps, so this is what stops out-of-range values. validate(value, values) { - if (required && Number.isNaN(value)) return `${label} is required` + if (Number.isNaN(value)) return required ? `${label} is required` : undefined + const suffix = units ? ` ${units}` : '' + if (min !== undefined && value < Number(min)) + return `Must be at least ${min}${suffix}` + if (max !== undefined && value > Number(max)) + return `Can be at most ${max}${suffix}` return validate?.(value, values) }, }, diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx index 262ab7b3a..9047e9c23 100644 --- a/app/forms/disk-create.tsx +++ b/app/forms/disk-create.tsx @@ -242,7 +242,12 @@ const DiskBackendField = ({ }) => { const { field: { value: diskBackend, onChange }, - } = useController({ control, name: 'diskBackend' }) + } = useController({ + control, + name: 'diskBackend', + // Switching disk type changes the size limit. + rules: { deps: 'size' }, + }) // react-hook-form types onChange as (...event: any[]) => void // https://github.com/react-hook-form/react-hook-form/issues/10466 const setDiskBackend: (value: DiskBackendForm) => void = onChange diff --git a/app/forms/external-subnet-create.tsx b/app/forms/external-subnet-create.tsx index 404cde2cf..3b2830ac6 100644 --- a/app/forms/external-subnet-create.tsx +++ b/app/forms/external-subnet-create.tsx @@ -132,6 +132,8 @@ export default function CreateExternalSubnetSideModalForm() { items={pools.items.map(toPoolItem)} required description="Subnet pool to allocate from" + // the pool's IP version sets the prefix length max + deps="prefixLength" /> { From e83ba1eda00bb95602839a385265bd8f626b2e0b Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 9 Sep 2026 12:31:55 -0500 Subject: [PATCH 2/2] Inline DiskSizeField into its two callers --- app/components/form/fields/DiskSizeField.tsx | 46 -------------------- app/forms/disk-create.tsx | 7 ++- app/forms/instance-create.tsx | 5 ++- 3 files changed, 8 insertions(+), 50 deletions(-) delete mode 100644 app/components/form/fields/DiskSizeField.tsx diff --git a/app/components/form/fields/DiskSizeField.tsx b/app/components/form/fields/DiskSizeField.tsx deleted file mode 100644 index 4b4824830..000000000 --- a/app/components/form/fields/DiskSizeField.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ -import type { FieldPathByValue, FieldValues, ValidateResult } from 'react-hook-form' - -import { NumberField } from './NumberField' -import type { TextFieldProps } from './TextField' - -interface DiskSizeProps< - TFieldValues extends FieldValues, - TName extends FieldPathByValue, -> extends Omit, 'min' | 'max' | 'validate'> { - // replace max and min with our own because original max/min allow string - min?: number - /** Undefined means no client-side limit (e.g., for local disks) */ - max: number | undefined - validate?(diskSizeGiB: number): ValidateResult -} - -export function DiskSizeField< - TFieldValues extends FieldValues, - TName extends FieldPathByValue, ->({ - required = true, - name, - min = 1, - max, - validate, - ...props -}: DiskSizeProps) { - return ( - - ) -} diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx index 9047e9c23..6371eda76 100644 --- a/app/forms/disk-create.tsx +++ b/app/forms/disk-create.tsx @@ -24,10 +24,10 @@ import { import { CheckboxField } from '~/components/form/fields/CheckboxField' import { DescriptionField } from '~/components/form/fields/DescriptionField' -import { DiskSizeField } from '~/components/form/fields/DiskSizeField' import { toImageComboboxItem } from '~/components/form/fields/ImageSelectField' import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' +import { NumberField } from '~/components/form/fields/NumberField' import { RadioField } from '~/components/form/fields/RadioField' import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' @@ -207,9 +207,12 @@ export function CreateDiskSideModalForm({ }} /> - undefined) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index e838cdf20..211ee7aa3 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -49,7 +49,6 @@ import { DocsPopover } from '~/components/DocsPopover' import { CheckboxField } from '~/components/form/fields/CheckboxField' import { ComboboxField } from '~/components/form/fields/ComboboxField' import { DescriptionField } from '~/components/form/fields/DescriptionField' -import { DiskSizeField } from '~/components/form/fields/DiskSizeField' import { DisksTableField, type DiskTableItem, @@ -487,11 +486,13 @@ export default function CreateInstanceForm() { const bootDiskSizeAndName = ( <>
-