From d2d0bcb4bc10232a99d9d2160c398f0d1cbc3d45 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Sun, 30 Oct 2022 09:47:09 -0500 Subject: [PATCH 01/17] convert network interface create/edit to RHF --- .../form/fields/NetworkInterfaceField.tsx | 25 +++--- .../fields/SubnetListbox.tsx | 31 +++---- app/components/hook-form/index.ts | 1 + app/forms/network-interface-create.tsx | 84 +++++++++---------- app/forms/network-interface-edit.tsx | 76 ++++++++--------- app/hooks/use-params.ts | 1 + .../instances/instance/tabs/NetworkingTab.tsx | 23 +++-- 7 files changed, 116 insertions(+), 125 deletions(-) rename app/components/{form => hook-form}/fields/SubnetListbox.tsx (58%) diff --git a/app/components/form/fields/NetworkInterfaceField.tsx b/app/components/form/fields/NetworkInterfaceField.tsx index 4e2f6149c..344228ddc 100644 --- a/app/components/form/fields/NetworkInterfaceField.tsx +++ b/app/components/form/fields/NetworkInterfaceField.tsx @@ -5,7 +5,7 @@ import type { InstanceNetworkInterfaceAttachment, NetworkInterfaceCreate } from import { Button, Error16Icon, MiniTable, Radio } from '@oxide/ui' import { RadioField } from 'app/components/form' -import CreateNetworkInterfaceSideModalForm from 'app/forms/network-interface-create' +import CreateNetworkInterfaceForm from 'app/forms/network-interface-create' export function NetworkInterfaceField() { const [showForm, setShowForm] = useState(false) @@ -84,17 +84,18 @@ export function NetworkInterfaceField() { )} - { - setValue({ - type: 'create', - params: [...value.params, networkInterface], - }) - setShowForm(false) - }} - onDismiss={() => setShowForm(false)} - /> + {showForm && ( + { + setValue({ + type: 'create', + params: [...value.params, networkInterface], + }) + setShowForm(false) + }} + onDismiss={() => setShowForm(false)} + /> + )}
- setCreateModalOpen(false)} - /> - setEditing(null)} - /> + {createModalOpen && ( + setCreateModalOpen(false)} /> + )} + {editing && ( + setEditing(null)} /> + )} ) } From 0711674fd3ecfc5075ee01a06a8d6fa7353a49d2 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Sun, 30 Oct 2022 10:35:11 -0500 Subject: [PATCH 02/17] make a dent in instance create and FullPageForm --- app/components/form/FullPageForm.tsx | 32 +- app/components/form/SideModalForm.tsx | 58 --- app/components/form/fields/index.ts | 2 - .../fields/DisksTableField.tsx | 0 .../fields/ImageSelectField.tsx | 4 +- .../fields/NetworkInterfaceField.tsx | 27 +- app/components/hook-form/index.ts | 3 + app/forms/instance-create.tsx | 336 +++++++++--------- 8 files changed, 221 insertions(+), 241 deletions(-) delete mode 100644 app/components/form/SideModalForm.tsx rename app/components/{form => hook-form}/fields/DisksTableField.tsx (100%) rename app/components/{form => hook-form}/fields/ImageSelectField.tsx (98%) rename app/components/{form => hook-form}/fields/NetworkInterfaceField.tsx (85%) diff --git a/app/components/form/FullPageForm.tsx b/app/components/form/FullPageForm.tsx index 915fdc3aa..7260d4fec 100644 --- a/app/components/form/FullPageForm.tsx +++ b/app/components/form/FullPageForm.tsx @@ -1,49 +1,65 @@ -import type { ReactElement } from 'react' +import type { ReactElement, ReactNode } from 'react' import { useState } from 'react' import { cloneElement } from 'react' +import type { Control, FieldValues, UseFormProps } from 'react-hook-form' +import { useForm } from 'react-hook-form' +import type { ErrorResult } from '@oxide/api' import { PageHeader, PageTitle } from '@oxide/ui' import { classed, flattenChildren, pluckFirstOfType } from '@oxide/util' import { PageActions } from '../PageActions' -import type { FormProps } from './Form' import { Form } from './Form' -interface FullPageFormProps extends Omit, 'setSubmitState'> { +interface FullPageFormProps { id: string title: string icon: ReactElement submitDisabled?: boolean error?: Error + formOptions: UseFormProps + /** Error from the API call */ + submitError: ErrorResult | null + /** + * A function that returns the fields. + * + * Implemented as a function so we can pass `control` to the fields in the + * calling code. We could do that internally with `cloneElement` instead, but + * then in the calling code, the field would not infer `TFieldValues` and + * constrain the `name` prop to paths in the values object. + */ + children: (control: Control) => ReactNode } const PageActionsContainer = classed.div`flex h-20 items-center gutter` export function FullPageForm>({ + id, title, children, submitDisabled = false, error, icon, - ...formProps + formOptions, }: FullPageFormProps) { const [submitState, setSubmitState] = useState(true) const childArray = flattenChildren(children) const actions = pluckFirstOfType(childArray, Form.Actions) + const { control } = useForm(formOptions) return ( <> {title} -
- {childArray} -
+
+ {children(control)} +
{actions && ( {cloneElement(actions, { - formId: formProps.id, + formId: id, submitDisabled: submitDisabled || !submitState, error, })} diff --git a/app/components/form/SideModalForm.tsx b/app/components/form/SideModalForm.tsx deleted file mode 100644 index e3b7bd5f6..000000000 --- a/app/components/form/SideModalForm.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import type { ReactNode } from 'react' -import { useState } from 'react' - -import { SideModal } from '@oxide/ui' -import { flattenChildren, pluckFirstOfType } from '@oxide/util' - -import type { FormProps } from './Form' -import { Form } from './Form' - -interface SideModalFormProps extends Omit, 'setSubmitState'> { - isOpen: boolean - onDismiss: () => void - submitDisabled?: boolean - error?: Error - title: ReactNode -} - -export function SideModalForm>({ - id, - children, - onDismiss, - isOpen, - submitDisabled = false, - error, - title, - ...formProps -}: SideModalFormProps) { - const [submitState, setSubmitState] = useState(true) - const childArray = flattenChildren(children) - const submit = pluckFirstOfType(childArray, Form.Submit) - - return ( - - {title && {title}} - -
- {childArray} -
-
- - - {submit || {title}} - - - -
- ) -} diff --git a/app/components/form/fields/index.ts b/app/components/form/fields/index.ts index 618d8aeaf..23c84cf22 100644 --- a/app/components/form/fields/index.ts +++ b/app/components/form/fields/index.ts @@ -1,10 +1,8 @@ export * from './CheckboxField' export * from './DescriptionField' export * from './DiskSizeField' -export * from './DisksTableField' export * from './ListboxField' export * from './NameField' -export * from './NetworkInterfaceField' export * from './RadioField' export * from './TextField' export * from './DateTimeRangePicker' diff --git a/app/components/form/fields/DisksTableField.tsx b/app/components/hook-form/fields/DisksTableField.tsx similarity index 100% rename from app/components/form/fields/DisksTableField.tsx rename to app/components/hook-form/fields/DisksTableField.tsx diff --git a/app/components/form/fields/ImageSelectField.tsx b/app/components/hook-form/fields/ImageSelectField.tsx similarity index 98% rename from app/components/form/fields/ImageSelectField.tsx rename to app/components/hook-form/fields/ImageSelectField.tsx index 0843bc87f..52c2786fc 100644 --- a/app/components/form/fields/ImageSelectField.tsx +++ b/app/components/hook-form/fields/ImageSelectField.tsx @@ -19,8 +19,8 @@ import { } from '@oxide/ui' import { classed, groupBy } from '@oxide/util' -import type { RadioFieldProps } from './RadioField' -import { RadioField } from './RadioField' +import type { RadioFieldProps } from '../../form/fields/RadioField' +import { RadioField } from '../../form/fields/RadioField' const ArchDistroIcon = (props: { className?: string }) => { return ( diff --git a/app/components/form/fields/NetworkInterfaceField.tsx b/app/components/hook-form/fields/NetworkInterfaceField.tsx similarity index 85% rename from app/components/form/fields/NetworkInterfaceField.tsx rename to app/components/hook-form/fields/NetworkInterfaceField.tsx index 344228ddc..1f01432bb 100644 --- a/app/components/form/fields/NetworkInterfaceField.tsx +++ b/app/components/hook-form/fields/NetworkInterfaceField.tsx @@ -1,13 +1,20 @@ import { useField } from 'formik' import { useState } from 'react' +import type { Control, FieldPath, FieldValues } from 'react-hook-form' import type { InstanceNetworkInterfaceAttachment, NetworkInterfaceCreate } from '@oxide/api' -import { Button, Error16Icon, MiniTable, Radio } from '@oxide/ui' +import { Button, Error16Icon, MiniTable } from '@oxide/ui' -import { RadioField } from 'app/components/form' +import { RadioField } from 'app/components/hook-form' import CreateNetworkInterfaceForm from 'app/forms/network-interface-create' -export function NetworkInterfaceField() { +export function NetworkInterfaceField({ + name, + control, +}: { + name: FieldPath + control: Control +}) { const [showForm, setShowForm] = useState(false) /** @@ -24,7 +31,7 @@ export function NetworkInterfaceField() {
- None - Default - Custom - + items={[ + { label: 'None', value: 'none' }, + { label: 'Default', value: 'default' }, + { label: 'Custom', value: 'create' }, + ]} + control={control} + /> {value.type === 'create' && ( <> {value.params.length > 0 && ( diff --git a/app/components/hook-form/index.ts b/app/components/hook-form/index.ts index 76c277336..1e5454418 100644 --- a/app/components/hook-form/index.ts +++ b/app/components/hook-form/index.ts @@ -1,8 +1,11 @@ export * from './fields/CheckboxField' export * from './fields/DescriptionField' export * from './fields/DiskSizeField' +export * from './fields/DisksTableField' +export * from './fields/ImageSelectField' export * from './fields/ListboxField' export * from './fields/NameField' +export * from './fields/NetworkInterfaceField' export * from './fields/RadioField' export * from './fields/SubnetListbox' export * from './fields/TextField' diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index aa984af6e..c4e310d7b 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -1,4 +1,3 @@ -import * as Yup from 'yup' import invariant from 'tiny-invariant' import type { @@ -18,25 +17,25 @@ import { Tabs, TextInputHint, } from '@oxide/ui' -import { GiB } from '@oxide/util' +import { GiB, classed } from '@oxide/util' -import type { DiskTableItem } from 'app/components/form' -import { CheckboxField } from 'app/components/form' import { FullPageForm } from 'app/components/form' -import { DiskSizeField } from 'app/components/form' +import type { DiskTableItem } from 'app/components/hook-form' import { + CheckboxField, DescriptionField, + DiskSizeField, DisksTableField, - Form, + ImageSelectField, NameField, NetworkInterfaceField, RadioField, TextField, -} from 'app/components/form' -import { ImageSelectField } from 'app/components/form/fields/ImageSelectField' -import type { CreateFormProps } from 'app/forms' +} from 'app/components/hook-form' import { useRequiredParams, useToast } from 'app/hooks' +const Heading = classed.h2`text-content text-sans-light-2xl` + type InstanceCreateInput = Assign< InstanceCreate, { @@ -49,7 +48,7 @@ type InstanceCreateInput = Assign< } > -const values: InstanceCreateInput = { +const defaultValues: InstanceCreateInput = { name: '', description: '', /** @@ -76,15 +75,13 @@ const values: InstanceCreateInput = { start: true, } -export function CreateInstanceForm({ - id = 'create-instance-form', - initialValues = values, - onSubmit, - onSuccess, - onDismiss, - onError, - ...props -}: CreateFormProps) { +// TODO: inline these, get rid of InstanceCreatePage +type CreateInstanceFormProps = { + onDismiss: () => void + onSuccess: (instance: Instance) => void +} + +export function CreateInstanceForm({ onSuccess, onDismiss }: CreateInstanceFormProps) { const queryClient = useApiQueryClient() const addToast = useToast() const pageParams = useRequiredParams('orgName', 'projectName') @@ -108,176 +105,191 @@ export function CreateInstanceForm({ }) onSuccess?.(instance) }, - onError, }) const images = useApiQuery('systemImageList', {}).data?.items || [] - initialValues.globalImage = images[0]?.id || '' - return ( } - validationSchema={Yup.object({ - // needed to cover case where there are no images, in which case there - // are no individual radio fields marked required, which unfortunately - // is how required radio fields work - globalImage: Yup.string().required(), - })} - onSubmit={ - onSubmit || - (async (values) => { - const instance = INSTANCE_SIZES.find((option) => option.id === values['type']) - invariant(instance, 'Expected instance type to be defined') - const image = images.find((i) => values.globalImage === i.id) - invariant(image, 'Expected image to be defined') + // validationSchema={Yup.object({ + // // needed to cover case where there are no images, in which case there + // // are no individual radio fields marked required, which unfortunately + // // is how required radio fields work + // globalImage: Yup.string().required(), + // })} + onSubmit={async (values) => { + const instance = INSTANCE_SIZES.find((option) => option.id === values['type']) + invariant(instance, 'Expected instance type to be defined') + const image = images.find((i) => values.globalImage === i.id) + invariant(image, 'Expected image to be defined') - const bootDiskName = values.bootDiskName || genName(values.name, image.name) + const bootDiskName = values.bootDiskName || genName(values.name, image.name) - await createDisk.mutateAsync({ - path: pageParams, - body: { - // TODO: Determine the pattern of the default boot disk name - name: bootDiskName, - description: `Created as a boot disk for ${values.bootDiskName}`, - // TODO: Verify size is larger than the minimum image size - size: values.bootDiskSize * GiB, - diskSource: { - type: 'global_image', - imageId: values.globalImage, - }, - }, - }) - createInstance.mutate({ - path: pageParams, - body: { - name: values.name, - hostname: values.hostname || values.name, - description: `An instance in project: ${pageParams.projectName}`, - memory: instance.memory * GiB, - ncpus: instance.ncpus, - disks: [ - { - type: 'attach', - name: bootDiskName, - }, - ...values.disks, - ], - externalIps: [{ type: 'ephemeral' }], - start: values.start, + await createDisk.mutateAsync({ + path: pageParams, + body: { + // TODO: Determine the pattern of the default boot disk name + name: bootDiskName, + description: `Created as a boot disk for ${values.bootDiskName}`, + // TODO: Verify size is larger than the minimum image size + size: values.bootDiskSize * GiB, + diskSource: { + type: 'global_image', + imageId: values.globalImage, }, - }) + }, }) - } - {...props} + createInstance.mutate({ + path: pageParams, + body: { + name: values.name, + hostname: values.hostname || values.name, + description: `An instance in project: ${pageParams.projectName}`, + memory: instance.memory * GiB, + ncpus: instance.ncpus, + disks: [ + { + type: 'attach', + name: bootDiskName, + }, + ...values.disks, + ], + externalIps: [{ type: 'ephemeral' }], + start: values.start, + }, + }) + }} submitDisabled={createDisk.isLoading || createInstance.isLoading} - error={(createDisk.error?.error || createInstance.error?.error) as Error | undefined} + submitError={createDisk.error || createInstance.error} > - - - - Start Instance - + {(control) => ( + <> + + + + Start Instance + - + - Hardware - - General Purpose - - - General purpose instances provide a good balance of CPU, memory, and high - performance storage; well suited for a wide range of use cases. - - - {renderLargeRadioCards('general')} - - + Hardware + + General Purpose + + + General purpose instances provide a good balance of CPU, memory, and high + performance storage; well suited for a wide range of use cases. + + + {renderLargeRadioCards('general')} + + - CPU Optimized - - - CPU optimized instances provide a good balance of... - - - {renderLargeRadioCards('cpuOptimized')} - - + CPU Optimized + + + CPU optimized instances provide a good balance of... + + + {renderLargeRadioCards('cpuOptimized')} + + - Memory optimized - - - CPU optimized instances provide a good balance of... - - - {renderLargeRadioCards('memoryOptimized')} - - + Memory optimized + + + CPU optimized instances provide a good balance of... + + + {renderLargeRadioCards('memoryOptimized')} + + - Custom - - - Custom instances... - - - {renderLargeRadioCards('custom')} - - - + Custom + + + Custom instances... + + + {renderLargeRadioCards('custom')} + + + - + - Boot disk - - Distros - - {images.length === 0 && No images found} - + Boot disk + + Distros + + {images.length === 0 && No images found} + - - - - Images - - No images found - - Snapshots - - No snapshots found - - - - Additional disks + + + + Images + + No images found + + Snapshots + + No snapshots found + + + + Additional disks - + - - Networking + + Networking - + - + - - - Create instance - - {onDismiss && } - + {/* + + Create instance + + {onDismiss && } + */} + + )} ) } From 9df2c51adc448b73c655684717a11ca5603eae27 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Sun, 30 Oct 2022 10:37:48 -0500 Subject: [PATCH 03/17] delete some fields --- .../form/fields/DescriptionField.tsx | 23 ----------- app/components/form/fields/DiskSizeField.tsx | 24 ----------- app/components/form/fields/NameField.tsx | 40 ------------------- app/components/form/fields/index.ts | 3 -- .../fields/NameField.spec.tsx | 4 +- 5 files changed, 2 insertions(+), 92 deletions(-) delete mode 100644 app/components/form/fields/DescriptionField.tsx delete mode 100644 app/components/form/fields/DiskSizeField.tsx delete mode 100644 app/components/form/fields/NameField.tsx rename app/components/{form => hook-form}/fields/NameField.spec.tsx (90%) diff --git a/app/components/form/fields/DescriptionField.tsx b/app/components/form/fields/DescriptionField.tsx deleted file mode 100644 index 129876692..000000000 --- a/app/components/form/fields/DescriptionField.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { TextFieldProps } from './TextField' -import { TextField } from './TextField' - -// TODO: Pull this from generated types -const MAX_LEN = 512 - -export interface DescriptionFieldProps extends Omit { - name?: string -} - -export function DescriptionField({ - name = 'description', - ...textFieldProps -}: DescriptionFieldProps) { - return -} - -// TODO Update JSON schema to match this, add fuzz testing between this and name pattern -export function validateDescription(name: string) { - if (name.length > MAX_LEN) { - return `A description must be no longer than ${MAX_LEN} characters` - } -} diff --git a/app/components/form/fields/DiskSizeField.tsx b/app/components/form/fields/DiskSizeField.tsx deleted file mode 100644 index 4e69908ba..000000000 --- a/app/components/form/fields/DiskSizeField.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { TextFieldProps } from './TextField' -import { TextField } from './TextField' - -interface DiskSizeProps extends Omit { - minSize?: number -} - -export function DiskSizeField({ - required = true, - name = 'diskSize', - minSize = 1, - ...props -}: DiskSizeProps) { - return ( - - ) -} diff --git a/app/components/form/fields/NameField.tsx b/app/components/form/fields/NameField.tsx deleted file mode 100644 index e3847ab83..000000000 --- a/app/components/form/fields/NameField.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { capitalize } from '@oxide/util' - -import type { TextFieldProps } from './TextField' -import { TextField } from './TextField' - -export interface NameFieldProps extends Omit { - name?: string -} - -export function NameField({ - required = true, - name = 'name', - label = capitalize(name), - ...textFieldProps -}: NameFieldProps) { - return ( - - ) -} - -// TODO Update JSON schema to match this, add fuzz testing between this and name pattern -export const getNameValidator = (label: string, required: boolean) => (name: string) => { - if (!required && !name) return - - if (name.length === 0) { - return `${label} is required` - } else if (!/^[a-z]/.test(name)) { - return 'Must start with a lower-case letter' - } else if (!/[a-z0-9]$/.test(name)) { - return 'Must end with a letter or number' - } else if (!/^[a-z0-9-]+$/.test(name)) { - return 'Can only contain lower-case letters, numbers, and dashes' - } -} diff --git a/app/components/form/fields/index.ts b/app/components/form/fields/index.ts index 23c84cf22..002bc4917 100644 --- a/app/components/form/fields/index.ts +++ b/app/components/form/fields/index.ts @@ -1,8 +1,5 @@ export * from './CheckboxField' -export * from './DescriptionField' -export * from './DiskSizeField' export * from './ListboxField' -export * from './NameField' export * from './RadioField' export * from './TextField' export * from './DateTimeRangePicker' diff --git a/app/components/form/fields/NameField.spec.tsx b/app/components/hook-form/fields/NameField.spec.tsx similarity index 90% rename from app/components/form/fields/NameField.spec.tsx rename to app/components/hook-form/fields/NameField.spec.tsx index 1daf3be52..f8a4977b3 100644 --- a/app/components/form/fields/NameField.spec.tsx +++ b/app/components/hook-form/fields/NameField.spec.tsx @@ -1,7 +1,7 @@ -import { getNameValidator } from './NameField' +import { validateName } from './NameField' describe('validateName', () => { - const validate = getNameValidator('Name', true) + const validate = (name: string) => validateName(name, 'Name', true) it('returns undefined for valid names', () => { expect(validate('abc')).toBeUndefined() expect(validate('abc-def')).toBeUndefined() From eece9415688b41f8ab268861a17f8cd6c9c79a16 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Sun, 30 Oct 2022 18:41:55 -0500 Subject: [PATCH 04/17] mess everything up real bad (but instance create renders) --- app/components/form/fields/CheckboxField.tsx | 13 ---- app/components/form/fields/ListboxField.tsx | 53 ------------- app/components/form/fields/RadioField.tsx | 68 ---------------- app/components/form/fields/TextField.tsx | 77 ------------------- app/components/form/fields/index.ts | 4 - app/components/form/index.ts | 3 +- .../{form => hook-form}/FullPageForm.tsx | 20 +++-- .../hook-form/fields/DisksTableField.tsx | 24 ++++-- .../hook-form/fields/ImageSelectField.tsx | 16 ++-- .../fields/NetworkInterfaceField.tsx | 17 ++-- .../hook-form/fields/SubnetListbox.tsx | 4 +- app/components/hook-form/index.ts | 1 + app/forms/index.ts | 23 ------ app/forms/instance-create.tsx | 60 +++++++++------ app/hooks/useFieldError.tsx | 6 -- app/pages/settings/AppearancePage.tsx | 53 ------------- app/pages/settings/HotkeysPage.tsx | 39 ---------- app/pages/settings/ProfilePage.tsx | 59 +++++++------- app/routes.tsx | 8 -- 19 files changed, 120 insertions(+), 428 deletions(-) delete mode 100644 app/components/form/fields/CheckboxField.tsx delete mode 100644 app/components/form/fields/ListboxField.tsx delete mode 100644 app/components/form/fields/RadioField.tsx delete mode 100644 app/components/form/fields/TextField.tsx rename app/components/{form => hook-form}/FullPageForm.tsx (78%) delete mode 100644 app/forms/index.ts delete mode 100644 app/hooks/useFieldError.tsx delete mode 100644 app/pages/settings/AppearancePage.tsx delete mode 100644 app/pages/settings/HotkeysPage.tsx diff --git a/app/components/form/fields/CheckboxField.tsx b/app/components/form/fields/CheckboxField.tsx deleted file mode 100644 index 0ace09912..000000000 --- a/app/components/form/fields/CheckboxField.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type { FieldAttributes } from 'formik' -import { Field } from 'formik' - -import type { CheckboxProps } from '@oxide/ui' -import { Checkbox } from '@oxide/ui' - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type CheckboxFieldProps = CheckboxProps & Omit, 'type'> - -/** Formik Field version of Checkbox */ -export const CheckboxField = (props: CheckboxFieldProps) => ( - -) diff --git a/app/components/form/fields/ListboxField.tsx b/app/components/form/fields/ListboxField.tsx deleted file mode 100644 index 4050d7892..000000000 --- a/app/components/form/fields/ListboxField.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import cn from 'classnames' -import { useField } from 'formik' - -import type { ListboxProps } from '@oxide/ui' -import { FieldLabel, Listbox, TextInputHint } from '@oxide/ui' - -export type ListboxFieldProps = { - name: string - id: string - className?: string - label: string - required?: boolean - helpText?: string - description?: string -} & Pick - -export function ListboxField({ - id, - items, - label, - name, - disabled, - required, - description, - helpText, - className, -}: ListboxFieldProps) { - const [, { value }, { setValue }] = useField({ - name, - validate: (v) => (required && !v ? `${name} is required` : undefined), - }) - return ( -
-
- - {label} - - {helpText && {helpText}} -
- setValue(i?.value)} - disabled={disabled} - aria-labelledby={cn(`${id}-label`, { - [`${id}-help-text`]: !!description, - })} - aria-describedby={description ? `${id}-label-tip` : undefined} - name={name} - /> -
- ) -} diff --git a/app/components/form/fields/RadioField.tsx b/app/components/form/fields/RadioField.tsx deleted file mode 100644 index a02a2d874..000000000 --- a/app/components/form/fields/RadioField.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import cn from 'classnames' -import { useField } from 'formik' - -import type { RadioGroupProps } from '@oxide/ui' -import { FieldLabel, RadioGroup, TextInputHint } from '@oxide/ui' - -// TODO: Centralize these docstrings perhaps on the `FieldLabel` component? -export interface RadioFieldProps extends Omit { - id: string - /** Will default to id if not provided */ - name?: string - /** Will default to name if not provided */ - label?: string - /** - * Displayed inline as supplementary text to the label. Should - * only be used for text that's necessary context for helping - * complete the input. This will be announced in tandem with the - * label when using a screen reader. - */ - helpText?: string - /** - * Displayed in a tooltip beside the title. This field should be used - * for auxiliary context that helps users understand extra context about - * a field but isn't specifically required to know how to complete the input. - * This is announced as an `aria-description` - * - * @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-description - */ - description?: string - placeholder?: string - units?: string -} - -export function RadioField({ - id, - name = id, - label, - helpText, - description, - units, - ...props -}: RadioFieldProps) { - const [field, { initialValue }] = useField({ name }) - return ( -
-
- {label && ( - - {label} {units && ({units})} - - )} - {/* TODO: Figure out where this hint field def should live */} - {helpText && {helpText}} -
- -
- ) -} - -export { Radio } from '@oxide/ui' diff --git a/app/components/form/fields/TextField.tsx b/app/components/form/fields/TextField.tsx deleted file mode 100644 index 7e3536a8c..000000000 --- a/app/components/form/fields/TextField.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import cn from 'classnames' -import type { FieldValidator } from 'formik' -import { useField } from 'formik' - -import type { - TextAreaProps as UITextAreaProps, - TextInputBaseProps as UITextFieldProps, -} from '@oxide/ui' -import { TextInputError } from '@oxide/ui' -import { TextInputHint } from '@oxide/ui' -import { FieldLabel, TextInput as UITextField } from '@oxide/ui' -import { capitalize } from '@oxide/util' - -export interface TextFieldProps extends UITextFieldProps { - id: string - /** Will default to id if not provided */ - name?: string - /** HTML type attribute, defaults to text */ - type?: string - /** Will default to name if not provided */ - label?: string - /** - * Displayed inline as supplementary text to the label. Should - * only be used for text that's necessary context for helping - * complete the input. This will be announced in tandem with the - * label when using a screen reader. - */ - helpText?: string - /** - * Displayed in a tooltip beside the title. This field should be used - * for auxiliary context that helps users understand extra context about - * a field but isn't specifically required to know how to complete the input. - * This is announced as an `aria-description` - * - * @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-description - */ - description?: string - placeholder?: string - units?: string - validate?: FieldValidator -} - -export function TextField({ - id, - name = id, - type = 'text', - label = capitalize(name), - units, - validate, - ...props -}: TextFieldProps & UITextAreaProps) { - const { description, helpText, required } = props - const [field, meta] = useField({ name, validate, type }) - return ( -
-
- - {label} {units && ({units})} - -
- {helpText && {helpText}} - - {meta.error} -
- ) -} diff --git a/app/components/form/fields/index.ts b/app/components/form/fields/index.ts index 002bc4917..2e25826ac 100644 --- a/app/components/form/fields/index.ts +++ b/app/components/form/fields/index.ts @@ -1,5 +1 @@ -export * from './CheckboxField' -export * from './ListboxField' -export * from './RadioField' -export * from './TextField' export * from './DateTimeRangePicker' diff --git a/app/components/form/index.ts b/app/components/form/index.ts index dfe159bd9..f398f634f 100644 --- a/app/components/form/index.ts +++ b/app/components/form/index.ts @@ -1,4 +1,3 @@ export * from './fields' export * from './Form' -export * from './FullPageForm' -export * from './SideModalForm' +export * from '../hook-form/FullPageForm' diff --git a/app/components/form/FullPageForm.tsx b/app/components/hook-form/FullPageForm.tsx similarity index 78% rename from app/components/form/FullPageForm.tsx rename to app/components/hook-form/FullPageForm.tsx index 7260d4fec..01f881fc9 100644 --- a/app/components/form/FullPageForm.tsx +++ b/app/components/hook-form/FullPageForm.tsx @@ -1,5 +1,4 @@ import type { ReactElement, ReactNode } from 'react' -import { useState } from 'react' import { cloneElement } from 'react' import type { Control, FieldValues, UseFormProps } from 'react-hook-form' import { useForm } from 'react-hook-form' @@ -9,7 +8,7 @@ import { PageHeader, PageTitle } from '@oxide/ui' import { classed, flattenChildren, pluckFirstOfType } from '@oxide/util' import { PageActions } from '../PageActions' -import { Form } from './Form' +import { Form } from '../form/Form' interface FullPageFormProps { id: string @@ -18,6 +17,7 @@ interface FullPageFormProps { submitDisabled?: boolean error?: Error formOptions: UseFormProps + onSubmit: (values: TFieldValues) => Promise /** Error from the API call */ submitError: ErrorResult | null /** @@ -33,7 +33,7 @@ interface FullPageFormProps { const PageActionsContainer = classed.div`flex h-20 items-center gutter` -export function FullPageForm>({ +export function FullPageForm({ id, title, children, @@ -41,18 +41,22 @@ export function FullPageForm>({ error, icon, formOptions, -}: FullPageFormProps) { - const [submitState, setSubmitState] = useState(true) + onSubmit, +}: FullPageFormProps) { const childArray = flattenChildren(children) const actions = pluckFirstOfType(childArray, Form.Actions) - const { control } = useForm(formOptions) + const { + control, + handleSubmit, + formState: { isSubmitted }, // TODO: should this be isSubmitting? + } = useForm(formOptions) return ( <> {title} -
+ {children(control)}
{actions && ( @@ -60,7 +64,7 @@ export function FullPageForm>({ {cloneElement(actions, { formId: id, - submitDisabled: submitDisabled || !submitState, + submitDisabled: submitDisabled || !isSubmitted, error, })} diff --git a/app/components/hook-form/fields/DisksTableField.tsx b/app/components/hook-form/fields/DisksTableField.tsx index 0af0bffd2..8152945bc 100644 --- a/app/components/hook-form/fields/DisksTableField.tsx +++ b/app/components/hook-form/fields/DisksTableField.tsx @@ -1,5 +1,6 @@ -import { useField } from 'formik' import { useState } from 'react' +import type { Control, FieldPath, FieldValues } from 'react-hook-form' +import { useController } from 'react-hook-form' import type { DiskCreate, DiskIdentifier } from '@oxide/api' import { Button, Error16Icon, FieldLabel, MiniTable } from '@oxide/ui' @@ -11,13 +12,20 @@ export type DiskTableItem = | (DiskCreate & { type: 'create' }) | (DiskIdentifier & { type: 'attach' }) -export function DisksTableField() { +export function DisksTableField({ + control, + name, +}: { + control: Control + name: FieldPath +}) { const [showDiskCreate, setShowDiskCreate] = useState(false) const [showDiskAttach, setShowDiskAttach] = useState(false) - const [, { value: items = [] }, { setValue: setItems }] = useField({ - name: 'disks', - }) + // TODO: value needs to get DiskTableItem[] type somehow + const { + field: { value: items, onChange }, + } = useController({ control, name }) return ( <> @@ -43,7 +51,7 @@ export function DisksTableField() { {item.type} @@ -72,7 +80,7 @@ export function DisksTableField() { {showDiskCreate && ( { - setItems([...items, { type: 'create', ...values }]) + onChange([...items, { type: 'create', ...values }]) setShowDiskCreate(false) }} onDismiss={() => setShowDiskCreate(false)} @@ -82,7 +90,7 @@ export function DisksTableField() { setShowDiskAttach(false)} onSubmit={(values) => { - setItems([...items, { type: 'attach', ...values }]) + onChange([...items, { type: 'attach', ...values }]) setShowDiskAttach(false) }} /> diff --git a/app/components/hook-form/fields/ImageSelectField.tsx b/app/components/hook-form/fields/ImageSelectField.tsx index 52c2786fc..bb5172532 100644 --- a/app/components/hook-form/fields/ImageSelectField.tsx +++ b/app/components/hook-form/fields/ImageSelectField.tsx @@ -5,6 +5,7 @@ import type { ComponentType } from 'react' import { useState } from 'react' import { useCallback } from 'react' import { useEffect } from 'react' +import type { FieldPath, FieldValues } from 'react-hook-form' import type { GlobalImage } from '@oxide/api' import { @@ -19,8 +20,8 @@ import { } from '@oxide/ui' import { classed, groupBy } from '@oxide/util' -import type { RadioFieldProps } from '../../form/fields/RadioField' -import { RadioField } from '../../form/fields/RadioField' +import type { RadioFieldProps } from './RadioField' +import { RadioField } from './RadioField' const ArchDistroIcon = (props: { className?: string }) => { return ( @@ -89,12 +90,17 @@ function distroDisplay(image: GlobalImage): GlobalImage & { } } -interface ImageSelectFieldProps extends Omit { - name: string +type ImageSelectFieldProps< + TFieldValues extends FieldValues, + TName extends FieldPath +> = Omit, 'children'> & { images: GlobalImage[] } -export function ImageSelectField({ images, name, ...props }: ImageSelectFieldProps) { +export function ImageSelectField< + TFieldValues extends FieldValues, + TName extends FieldPath +>({ images, name, ...props }: ImageSelectFieldProps) { return ( {groupBy(images, (i) => i.distribution).map(([distroName, distroValues]) => ( diff --git a/app/components/hook-form/fields/NetworkInterfaceField.tsx b/app/components/hook-form/fields/NetworkInterfaceField.tsx index 1f01432bb..d8cb26ddd 100644 --- a/app/components/hook-form/fields/NetworkInterfaceField.tsx +++ b/app/components/hook-form/fields/NetworkInterfaceField.tsx @@ -1,6 +1,6 @@ -import { useField } from 'formik' import { useState } from 'react' import type { Control, FieldPath, FieldValues } from 'react-hook-form' +import { useController } from 'react-hook-form' import type { InstanceNetworkInterfaceAttachment, NetworkInterfaceCreate } from '@oxide/api' import { Button, Error16Icon, MiniTable } from '@oxide/ui' @@ -23,9 +23,10 @@ export function NetworkInterfaceField({ */ const [oldParams, setOldParams] = useState([]) - const [, { value }, { setValue }] = useField({ - name: 'networkInterfaces', - }) + // TODO: value needs to get NetworkInterfaceCreate[] type somehow + const { + field: { value, onChange }, + } = useController({ control, name }) return (
@@ -43,8 +44,8 @@ export function NetworkInterfaceField({ } newType === 'create' - ? setValue({ type: newType, params: oldParams }) - : setValue({ type: newType }) + ? onChange({ type: newType, params: oldParams }) + : onChange({ type: newType }) }} items={[ { label: 'None', value: 'none' }, @@ -78,7 +79,7 @@ export function NetworkInterfaceField({