diff --git a/app/components/form/fields/DescriptionField.tsx b/app/components/form/fields/DescriptionField.tsx index 51b2b4ed36..07f48e610e 100644 --- a/app/components/form/fields/DescriptionField.tsx +++ b/app/components/form/fields/DescriptionField.tsx @@ -15,7 +15,7 @@ const MAX_LEN = 512 export function DescriptionField< TFieldValues extends FieldValues, TName extends FieldPath, ->(props: Omit, 'validate'>) { +>(props: Omit, 'validate'>) { return } diff --git a/app/components/form/fields/DiskSizeField.tsx b/app/components/form/fields/DiskSizeField.tsx index 39afca5b36..2ae7f603a5 100644 --- a/app/components/form/fields/DiskSizeField.tsx +++ b/app/components/form/fields/DiskSizeField.tsx @@ -15,7 +15,7 @@ import type { TextFieldProps } from './TextField' interface DiskSizeProps< TFieldValues extends FieldValues, TName extends FieldPath, -> extends TextFieldProps { +> extends TextFieldProps { minSize?: number } @@ -26,7 +26,6 @@ export function DiskSizeField< return ( , 'validate'> & { label?: string }) { +}: Omit, 'validate'> & { label?: string }) { return ( validateName(name, label, required)} diff --git a/app/components/form/fields/NumberField.tsx b/app/components/form/fields/NumberField.tsx index 238e88b119..7ca8da4b9c 100644 --- a/app/components/form/fields/NumberField.tsx +++ b/app/components/form/fields/NumberField.tsx @@ -27,7 +27,7 @@ export function NumberField< helpText, required, ...props -}: Omit, 'id'>) { +}: Omit, 'id' | 'type'>) { // id is omitted from props because we generate it here const id = useId() return ( @@ -68,7 +68,10 @@ export const NumberFieldInner = < description, required, id: idProp, -}: TextFieldProps) => { + transform, + min, + max, +}: TextFieldProps) => { const generatedId = useId() const id = idProp || generatedId @@ -77,7 +80,7 @@ export const NumberFieldInner = < name={name} control={control} rules={{ required, validate }} - render={({ field: { value, ...fieldRest }, fieldState: { error } }) => { + render={({ field: { value, onChange, ...fieldRest }, fieldState: { error } }) => { return ( <> onChange(transform ? transform(v) : v)} + minValue={typeof min !== 'undefined' ? Number(min) : undefined} + maxValue={typeof max !== 'undefined' ? Number(max) : undefined} {...fieldRest} /> diff --git a/app/components/form/fields/TextField.tsx b/app/components/form/fields/TextField.tsx index dc53ff6f3c..4b075a68c8 100644 --- a/app/components/form/fields/TextField.tsx +++ b/app/components/form/fields/TextField.tsx @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ import cn from 'classnames' -import { useId } from 'react' +import { useId, type HTMLInputTypeAttribute } from 'react' import { Controller, type Control, @@ -28,12 +28,13 @@ import { capitalize } from '@oxide/util' import { ErrorMessage } from './ErrorMessage' export interface TextFieldProps< + Type, TFieldValues extends FieldValues, TName extends FieldPath, -> extends UITextFieldProps { +> extends Omit { name: TName /** HTML type attribute, defaults to text */ - type?: string + type?: Omit /** Will default to name if not provided */ label?: string /** @@ -56,6 +57,11 @@ export interface TextFieldProps< units?: string validate?: Validate, TFieldValues> control: Control + /** + * This function can be provided to alter the value of the input + * as the input is changed + */ + transform?: (value: Type) => Type | undefined } export function TextField< @@ -69,7 +75,7 @@ export function TextField< helpText, required, ...props -}: Omit, 'id'> & UITextAreaProps) { +}: Omit, 'id'> & UITextAreaProps) { // id is omitted from props because we generate it here const id = useId() return ( @@ -90,17 +96,9 @@ export function TextField< ) } -function numberToInputValue(value: number) { - // could add `|| value === 0`, but that means when the value is 0, we always - // show an empty string, which is weird, and doubly bad because then the - // browser apparently fails to validate it against minimum (if one is - // provided). I found it let me submit instance create with 0 CPUs. - return isNaN(value) ? '' : value.toString() -} - /** * Primarily exists for `TextField`, but we occasionally also need a plain field - * without a label on it. Note special handling of `type="number"`. + * without a label on it. * * Note that `id` is an allowed prop, unlike in `TextField`, where it is always * generated from `name`. This is because we need to pass the generated ID in @@ -119,8 +117,9 @@ export const TextFieldInner = < description, required, id: idProp, + transform, ...props -}: TextFieldProps & UITextAreaProps) => { +}: TextFieldProps & UITextAreaProps) => { const generatedId = useId() const id = idProp || generatedId return ( @@ -128,40 +127,21 @@ export const TextFieldInner = < name={name} control={control} rules={{ required, validate }} - render={({ field: { onChange, value, ...fieldRest }, fieldState: { error } }) => { + render={({ field: { onChange, ...fieldRest }, fieldState: { error } }) => { return ( <> { - if (type === 'number') { - if (e.target.value.trim() === '') { - onChange(0) - } else if (!isNaN(e.target.valueAsNumber)) { - onChange(e.target.valueAsNumber) - } - // otherwise ignore the input. this means, for example, typing - // letters does nothing. If we instead said take anything - // that's NaN and fall back to 0, typing a letter would reset - // the field to 0, which is silly. Browsers are supposed to - // ignore non-numeric input for you anyway, but Firefox does - // not. - return - } - - onChange(e.target.value) - }} - value={type === 'number' ? numberToInputValue(value) : value} + onChange={(e) => + onChange(transform ? transform(e.target.value) : e.target.value) + } {...fieldRest} {...props} /> diff --git a/app/forms/firewall-rules-create.tsx b/app/forms/firewall-rules-create.tsx index 59a0b4631f..5b985c5f89 100644 --- a/app/forms/firewall-rules-create.tsx +++ b/app/forms/firewall-rules-create.tsx @@ -29,6 +29,7 @@ import { SideModalForm, TextField, } from 'app/components/form' +import { NumberField } from 'app/components/form/fields/NumberField' import { useForm, useVpcSelector } from 'app/hooks' export type FirewallRuleValues = { @@ -151,8 +152,7 @@ export const CommonFields = ({ error, control }: CommonFieldsProps) => { - - - - + (ip.trim() === '' ? undefined : ip)} + /> ) } diff --git a/app/test/e2e/firewall-rules.e2e.ts b/app/test/e2e/firewall-rules.e2e.ts index 60b8f84f12..081831741d 100644 --- a/app/test/e2e/firewall-rules.e2e.ts +++ b/app/test/e2e/firewall-rules.e2e.ts @@ -118,7 +118,7 @@ test('can update firewall rule', async ({ page }) => { await expect(page.locator('input[name=name]')).toHaveValue('allow-icmp') // priority is populated - await expect(page.locator('input[name=priority]')).toHaveValue('65534') + await expect(page.locator('input[name=priority]')).toHaveValue('65,534') // protocol is populated await expect(page.locator('label >> text=ICMP')).toBeChecked() diff --git a/libs/api/__generated__/validate.ts b/libs/api/__generated__/validate.ts index 3ca5da16d6..cc47cfd499 100644 --- a/libs/api/__generated__/validate.ts +++ b/libs/api/__generated__/validate.ts @@ -117,7 +117,11 @@ export const AddressLot = z.preprocess( */ export const AddressLotBlock = z.preprocess( processResponseBody, - z.object({ firstAddress: z.string(), id: z.string().uuid(), lastAddress: z.string() }) + z.object({ + firstAddress: z.string().ip(), + id: z.string().uuid(), + lastAddress: z.string().ip(), + }) ) /** @@ -125,7 +129,7 @@ export const AddressLotBlock = z.preprocess( */ export const AddressLotBlockCreate = z.preprocess( processResponseBody, - z.object({ firstAddress: z.string(), lastAddress: z.string() }) + z.object({ firstAddress: z.string().ip(), lastAddress: z.string().ip() }) ) /** @@ -284,7 +288,7 @@ export const BgpImportedRouteIpv4 = z.preprocess( export const BgpPeerConfig = z.preprocess( processResponseBody, z.object({ - addr: z.string(), + addr: z.string().ip(), bgpAnnounceSet: NameOrId, bgpConfig: NameOrId, connectRetry: z.number().min(0).max(4294967295), @@ -318,7 +322,7 @@ export const BgpPeerState = z.preprocess( export const BgpPeerStatus = z.preprocess( processResponseBody, z.object({ - addr: z.string(), + addr: z.string().ip(), localAsn: z.number().min(0).max(4294967295), remoteAsn: z.number().min(0).max(4294967295), state: BgpPeerState, @@ -994,7 +998,7 @@ export const IpKind = z.preprocess(processResponseBody, z.enum(['ephemeral', 'fl export const ExternalIp = z.preprocess( processResponseBody, - z.object({ ip: z.string(), kind: IpKind }) + z.object({ ip: z.string().ip(), kind: IpKind }) ) /** @@ -1253,7 +1257,7 @@ export const InstanceNetworkInterfaceCreate = z.preprocess( processResponseBody, z.object({ description: z.string(), - ip: z.string().optional(), + ip: z.string().ip().optional(), name: Name, subnetName: Name, vpcName: Name, @@ -1324,7 +1328,7 @@ export const InstanceNetworkInterface = z.preprocess( description: z.string(), id: z.string().uuid(), instanceId: z.string().uuid(), - ip: z.string(), + ip: z.string().ip(), mac: MacAddr, name: Name, primary: SafeBoolean, @@ -1536,7 +1540,7 @@ export const LoopbackAddress = z.preprocess( export const LoopbackAddressCreate = z.preprocess( processResponseBody, z.object({ - address: z.string(), + address: z.string().ip(), addressLot: NameOrId, anycast: SafeBoolean, mask: z.number().min(0).max(255), @@ -1733,7 +1737,11 @@ export const RoleResultsPage = z.preprocess( */ export const Route = z.preprocess( processResponseBody, - z.object({ dst: IpNet, gw: z.string(), vid: z.number().min(0).max(65535).optional() }) + z.object({ + dst: IpNet, + gw: z.string().ip(), + vid: z.number().min(0).max(65535).optional(), + }) ) /** @@ -1752,7 +1760,7 @@ export const RouteConfig = z.preprocess( export const RouteDestination = z.preprocess( processResponseBody, z.union([ - z.object({ type: z.enum(['ip']), value: z.string() }), + z.object({ type: z.enum(['ip']), value: z.string().ip() }), z.object({ type: z.enum(['ip_net']), value: IpNet }), z.object({ type: z.enum(['vpc']), value: Name }), z.object({ type: z.enum(['subnet']), value: Name }), @@ -1765,7 +1773,7 @@ export const RouteDestination = z.preprocess( export const RouteTarget = z.preprocess( processResponseBody, z.union([ - z.object({ type: z.enum(['ip']), value: z.string() }), + z.object({ type: z.enum(['ip']), value: z.string().ip() }), z.object({ type: z.enum(['vpc']), value: Name }), z.object({ type: z.enum(['subnet']), value: Name }), z.object({ type: z.enum(['instance']), value: Name }), @@ -2155,7 +2163,7 @@ export const SwitchPortApplySettings = z.preprocess( export const SwitchPortBgpPeerConfig = z.preprocess( processResponseBody, z.object({ - addr: z.string(), + addr: z.string().ip(), bgpConfigId: z.string().uuid(), interfaceName: z.string(), portSettingsId: z.string().uuid(), @@ -2429,7 +2437,7 @@ export const VpcFirewallRuleHostFilter = z.preprocess( z.object({ type: z.enum(['vpc']), value: Name }), z.object({ type: z.enum(['subnet']), value: Name }), z.object({ type: z.enum(['instance']), value: Name }), - z.object({ type: z.enum(['ip']), value: z.string() }), + z.object({ type: z.enum(['ip']), value: z.string().ip() }), z.object({ type: z.enum(['ip_net']), value: IpNet }), ]) ) @@ -2468,7 +2476,7 @@ export const VpcFirewallRuleTarget = z.preprocess( z.object({ type: z.enum(['vpc']), value: Name }), z.object({ type: z.enum(['subnet']), value: Name }), z.object({ type: z.enum(['instance']), value: Name }), - z.object({ type: z.enum(['ip']), value: z.string() }), + z.object({ type: z.enum(['ip']), value: z.string().ip() }), z.object({ type: z.enum(['ip_net']), value: IpNet }), ]) ) @@ -3961,7 +3969,7 @@ export const NetworkingLoopbackAddressDeleteParams = z.preprocess( processResponseBody, z.object({ path: z.object({ - address: z.string(), + address: z.string().ip(), rackId: z.string().uuid(), subnetMask: z.number().min(0).max(255), switchLocation: Name, diff --git a/libs/ui/lib/number-input/NumberInput.tsx b/libs/ui/lib/number-input/NumberInput.tsx index a1c0bc356a..690e2336ab 100644 --- a/libs/ui/lib/number-input/NumberInput.tsx +++ b/libs/ui/lib/number-input/NumberInput.tsx @@ -20,6 +20,7 @@ import { useNumberFieldState } from 'react-stately' export type NumberInputProps = { className?: string error?: boolean + name?: string } export const NumberInput = React.forwardRef< @@ -46,8 +47,10 @@ export const NumberInput = React.forwardRef< {...groupProps} >