diff --git a/app/components/form/fields/DiskSizeField.tsx b/app/components/form/fields/DiskSizeField.tsx deleted file mode 100644 index 44cce80ff..000000000 --- a/app/components/form/fields/DiskSizeField.tsx +++ /dev/null @@ -1,59 +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 ( - { - // 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) - }} - {...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..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) @@ -242,7 +245,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" />
- {