diff --git a/app/components/form/Form.tsx b/app/components/form/Form.tsx index 69b8ffdb8..8fa4d343a 100644 --- a/app/components/form/Form.tsx +++ b/app/components/form/Form.tsx @@ -1,10 +1,5 @@ import cn from 'classnames' -import type { FormikConfig } from 'formik' -import { Formik, Form as FormikForm } from 'formik' -import { useFormikContext } from 'formik' -import type { ReactNode } from 'react' import { cloneElement } from 'react' -import { useEffect } from 'react' import invariant from 'tiny-invariant' import type { ButtonProps } from '@oxide/ui' @@ -14,49 +9,6 @@ import { addProps, classed, flattenChildren, isOneOf, pluckFirstOfType } from '@ import './form.css' -export interface FormProps extends FormikConfig { - id: string - className?: string - children: ReactNode - /** true if submission can happen, false otherwise */ - setSubmitState?: (state: boolean) => void -} - -export function Form>({ - id, - children, - className, - setSubmitState, - ...formikProps -}: FormProps) { - // Coerce container so it can be used in wrap - return ( - - - {children} - - - - ) -} - -/** - * This annoying little component exists solely to inform the parent when the submit state changes. - */ -const FormSubmitState = ({ - setSubmitState, -}: { - setSubmitState?: (state: boolean) => void -}) => { - const context = useFormikContext() - useEffect(() => { - if (setSubmitState) { - setSubmitState(context.dirty && context.isValid) - } - }, [context.dirty, context.isValid, setSubmitState]) - return null -} - interface FormActionsProps { formId?: string children: React.ReactNode @@ -65,58 +17,63 @@ interface FormActionsProps { className?: string } -/** - * This component is the area at the bottom of a form that contains - * the submit button and any other actions. The first button is automatically - * given a type of "submit." Default styles are applied all buttons but can be - * overridden. - */ -Form.Actions = ({ - children, - formId, - submitDisabled = true, - error, - className, -}: FormActionsProps) => { - // eslint-disable-next-line react-hooks/rules-of-hooks - const childArray = flattenChildren(children).map( - addProps((i, props) => ({ - size: 'sm', - ...props, - })) - ) +export const Form = { + /** + * This component is the area at the bottom of a form that contains + * the submit button and any other actions. The first button is automatically + * given a type of "submit." Default styles are applied all buttons but can be + * overridden. + */ + Actions: ({ + children, + formId, + submitDisabled = true, + error, + className, + }: FormActionsProps) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const childArray = flattenChildren(children).map( + addProps((i, props) => ({ + size: 'sm', + ...props, + })) + ) - invariant( - isOneOf(childArray, [Form.Submit, Form.Cancel, Button]), - 'Form.Actions should only receive Button components as children' - ) + invariant( + isOneOf(childArray, [Form.Submit, Form.Cancel, Button]), + 'Form.Actions should only receive Button components as children' + ) - const submit = pluckFirstOfType(childArray, Form.Submit) + const submit = pluckFirstOfType(childArray, Form.Submit) - invariant(submit, 'Form.Actions must contain a Form.Submit component') + invariant(submit, 'Form.Actions must contain a Form.Submit component') - return ( -
- {cloneElement(submit, { form: formId, disabled: submitDisabled })} - {childArray} - {error && ( -
- - {error.message} -
- )} -
- ) -} + return ( +
+ {cloneElement(submit, { form: formId, disabled: submitDisabled })} + {childArray} + {error && ( +
+ + {error.message} +
+ )} +
+ ) + }, -Form.Submit = (props: ButtonProps) => -) + Cancel: (props: ButtonProps) => ( + + ), -Form.Heading = classed.h2`text-content text-sans-light-2xl` + Heading: classed.h2`text-content text-sans-light-2xl`, +} diff --git a/app/components/form/FullPageForm.tsx b/app/components/form/FullPageForm.tsx deleted file mode 100644 index 915fdc3aa..000000000 --- a/app/components/form/FullPageForm.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type { ReactElement } from 'react' -import { useState } from 'react' -import { cloneElement } from 'react' - -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'> { - id: string - title: string - icon: ReactElement - submitDisabled?: boolean - error?: Error -} - -const PageActionsContainer = classed.div`flex h-20 items-center gutter` - -export function FullPageForm>({ - title, - children, - submitDisabled = false, - error, - icon, - ...formProps -}: FullPageFormProps) { - const [submitState, setSubmitState] = useState(true) - const childArray = flattenChildren(children) - const actions = pluckFirstOfType(childArray, Form.Actions) - - return ( - <> - - {title} - -
- {childArray} -
- {actions && ( - - - {cloneElement(actions, { - formId: formProps.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/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/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/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/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/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 618d8aeaf..2e25826ac 100644 --- a/app/components/form/fields/index.ts +++ b/app/components/form/fields/index.ts @@ -1,10 +1 @@ -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/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/hook-form/FullPageForm.tsx b/app/components/hook-form/FullPageForm.tsx new file mode 100644 index 000000000..34ad98503 --- /dev/null +++ b/app/components/hook-form/FullPageForm.tsx @@ -0,0 +1,73 @@ +import type { ReactElement, ReactNode } from 'react' +import { cloneElement } from 'react' +import type { FieldValues, UseFormProps, UseFormReturn } 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 { Form } from '../form/Form' + +interface FullPageFormProps { + id: string + title: string + icon: ReactElement + submitDisabled?: boolean + error?: Error + formOptions: UseFormProps + onSubmit: (values: TFieldValues) => Promise + /** 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: (form: UseFormReturn) => ReactNode +} + +const PageActionsContainer = classed.div`flex h-20 items-center gutter` + +export function FullPageForm({ + id, + title, + children, + submitDisabled = false, + error, + icon, + formOptions, + onSubmit, +}: FullPageFormProps) { + const form = useForm(formOptions) + const { isSubmitting, isDirty } = form.formState + + const childArray = flattenChildren(children(form)) + const actions = pluckFirstOfType(childArray, Form.Actions) + + return ( + <> + + {title} + +
+ {childArray} +
+ {actions && ( + + + {cloneElement(actions, { + formId: id, + submitDisabled: submitDisabled || isSubmitting || !isDirty, + error, + })} + + + )} + + ) +} diff --git a/app/components/hook-form/SideModalForm.tsx b/app/components/hook-form/SideModalForm.tsx index 948e00f78..08c7c7d58 100644 --- a/app/components/hook-form/SideModalForm.tsx +++ b/app/components/hook-form/SideModalForm.tsx @@ -64,7 +64,15 @@ export function SideModalForm({ id={id} className="ox-form is-side-modal" autoComplete="off" - onSubmit={handleSubmit(onSubmit)} + onSubmit={(e) => { + // This modal being in a portal doesn't prevent the submit event + // from bubbling up out of the portal. Normally that's not a + // problem, but sometimes (e.g., instance create) we render the + // SideModalForm from inside another form, in which case submitting + // the inner form submits the outer form unless we stop propagation + e.stopPropagation() + handleSubmit(onSubmit)(e) + }} > {children(control)} diff --git a/app/components/form/fields/DisksTableField.tsx b/app/components/hook-form/fields/DisksTableField.tsx similarity index 80% rename from app/components/form/fields/DisksTableField.tsx rename to app/components/hook-form/fields/DisksTableField.tsx index 0af0bffd2..e05329762 100644 --- a/app/components/form/fields/DisksTableField.tsx +++ b/app/components/hook-form/fields/DisksTableField.tsx @@ -1,23 +1,29 @@ -import { useField } from 'formik' import { useState } from 'react' +import type { Control } 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' import AttachDiskSideModalForm from 'app/forms/disk-attach' import { CreateDiskSideModalForm } from 'app/forms/disk-create' +import type { InstanceCreateInput } from 'app/forms/instance-create' export type DiskTableItem = | (DiskCreate & { type: 'create' }) | (DiskIdentifier & { type: 'attach' }) -export function DisksTableField() { +/** + * Designed less for reuse, more to encapsulate logic that would otherwise + * clutter the instance create form. + */ +export function DisksTableField({ control }: { control: Control }) { const [showDiskCreate, setShowDiskCreate] = useState(false) const [showDiskAttach, setShowDiskAttach] = useState(false) - const [, { value: items = [] }, { setValue: setItems }] = useField({ - name: 'disks', - }) + const { + field: { value: items, onChange }, + } = useController({ control, name: 'disks' }) return ( <> @@ -43,7 +49,7 @@ export function DisksTableField() { {item.type} @@ -72,7 +78,7 @@ export function DisksTableField() { {showDiskCreate && ( { - setItems([...items, { type: 'create', ...values }]) + onChange([...items, { type: 'create', ...values }]) setShowDiskCreate(false) }} onDismiss={() => setShowDiskCreate(false)} @@ -82,7 +88,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/form/fields/ImageSelectField.tsx b/app/components/hook-form/fields/ImageSelectField.tsx similarity index 72% rename from app/components/form/fields/ImageSelectField.tsx rename to app/components/hook-form/fields/ImageSelectField.tsx index 0843bc87f..29d45afdd 100644 --- a/app/components/form/fields/ImageSelectField.tsx +++ b/app/components/hook-form/fields/ImageSelectField.tsx @@ -1,10 +1,8 @@ import cn from 'classnames' import { useSelect } from 'downshift' -import { useField } from 'formik' import type { ComponentType } from 'react' -import { useState } from 'react' -import { useCallback } from 'react' -import { useEffect } from 'react' +import type { Control } from 'react-hook-form' +import { useController } from 'react-hook-form' import type { GlobalImage } from '@oxide/api' import { @@ -13,14 +11,14 @@ import { FedoraDistroIcon, Images24Icon, RadioCard, + RadioGroup, SelectArrows6Icon, UbuntuDistroIcon, WindowsDistroIcon, } from '@oxide/ui' import { classed, groupBy } from '@oxide/util' -import type { RadioFieldProps } from './RadioField' -import { RadioField } from './RadioField' +import type { InstanceCreateInput } from 'app/forms/instance-create' const ArchDistroIcon = (props: { className?: string }) => { return ( @@ -35,92 +33,70 @@ const ArchDistroIcon = (props: { className?: string }) => { ) } -function distroDisplay(image: GlobalImage): GlobalImage & { +function distroDisplay(image: GlobalImage): { label: string Icon: ComponentType<{ className?: string }> } { const distro = image.distribution.toLowerCase() if (distro.includes('ubuntu')) { - return { - label: 'Ubuntu', - Icon: UbuntuDistroIcon, - ...image, - } + return { label: 'Ubuntu', Icon: UbuntuDistroIcon } } if (distro.includes('debian')) { - return { - label: 'Debian', - Icon: DebianDistroIcon, - ...image, - } + return { label: 'Debian', Icon: DebianDistroIcon } } if (distro.includes('centos')) { - return { - label: 'CentOS', - Icon: CentosDistroIcon, - ...image, - } + return { label: 'CentOS', Icon: CentosDistroIcon } } if (distro.includes('fedora')) { - return { - label: 'Fedora', - Icon: FedoraDistroIcon, - ...image, - } + return { label: 'Fedora', Icon: FedoraDistroIcon } } if (distro.includes('arch')) { - return { - label: 'Arch', - Icon: ArchDistroIcon, - ...image, - } + return { label: 'Arch', Icon: ArchDistroIcon } } if (distro.includes('windows')) { - return { - label: 'Windows', - Icon: WindowsDistroIcon, - ...image, - } + return { label: 'Windows', Icon: WindowsDistroIcon } } return { label: distro.replace(/-/g, ' ').replace(/(?:\s)[a-z]/g, (x) => x.toUpperCase()), Icon: Images24Icon, - ...image, } } -interface ImageSelectFieldProps extends Omit { - name: string +type ImageSelectFieldProps = { + required: boolean images: GlobalImage[] + control: Control } -export function ImageSelectField({ images, name, ...props }: ImageSelectFieldProps) { +export function ImageSelectField({ images, control, required }: ImageSelectFieldProps) { return ( - + {groupBy(images, (i) => i.distribution).map(([distroName, distroValues]) => ( - + ))} - + ) } const Outline = classed.div`absolute z-10 h-full w-full rounded border border-accent pointer-events-none` -interface ImageSelectProps { +function ImageSelect({ + images, + control, +}: { images: GlobalImage[] - fieldName: string -} -function ImageSelect({ images, fieldName }: ImageSelectProps) { - const distros = images.map(distroDisplay) - const { label, Icon, id } = distros[0] - const [, { value }, { setValue }] = useField(fieldName) - const [currentDistro, setCurrentDistro] = useState(id) + control: Control +}) { + const distros = images.map((image) => ({ ...image, ...distroDisplay(image) })) + const { label, Icon } = distros[0] - useEffect(() => { - if (distros.some((d) => d.id === value)) { - setCurrentDistro(value) - } - }, [distros, value]) + const { + field: { value, onChange }, + } = useController({ control, name: 'globalImage' }) + + // current distro is the one from the field value *if* it exists in the list + // of distros. default to first distro in the list + const currentDistro = distros.find((d) => d.id === value)?.id || distros[0].id const select = useSelect({ initialSelectedItem: distros[0], @@ -128,21 +104,21 @@ function ImageSelect({ images, fieldName }: ImageSelectProps) { itemToString: (distro) => distro?.version || '', onSelectedItemChange(changes) { if (changes.selectedItem) { - setValue(changes.selectedItem.id) + onChange(changes.selectedItem.id) } }, }) - const onClick = useCallback(() => { + const onClick = () => { if (select.selectedItem) { - setValue(select.selectedItem.id) + onChange(select.selectedItem.id) } - }, [select.selectedItem, setValue]) + } const selected = currentDistro === value return (
{ - 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() diff --git a/app/components/form/fields/NetworkInterfaceField.tsx b/app/components/hook-form/fields/NetworkInterfaceField.tsx similarity index 66% rename from app/components/form/fields/NetworkInterfaceField.tsx rename to app/components/hook-form/fields/NetworkInterfaceField.tsx index 4e2f6149c..20814f6a6 100644 --- a/app/components/form/fields/NetworkInterfaceField.tsx +++ b/app/components/hook-form/fields/NetworkInterfaceField.tsx @@ -1,13 +1,22 @@ -import { useField } from 'formik' import { useState } from 'react' +import type { Control } from 'react-hook-form' +import { useController } from 'react-hook-form' import type { InstanceNetworkInterfaceAttachment, NetworkInterfaceCreate } from '@oxide/api' -import { Button, Error16Icon, MiniTable, Radio } from '@oxide/ui' +import { Button, Error16Icon, FieldLabel, MiniTable, Radio, RadioGroup } from '@oxide/ui' -import { RadioField } from 'app/components/form' -import CreateNetworkInterfaceSideModalForm from 'app/forms/network-interface-create' +import type { InstanceCreateInput } from 'app/forms/instance-create' +import CreateNetworkInterfaceForm from 'app/forms/network-interface-create' -export function NetworkInterfaceField() { +/** + * Designed less for reuse, more to encapsulate logic that would otherwise + * clutter the instance create form. + */ +export function NetworkInterfaceField({ + control, +}: { + control: Control +}) { const [showForm, setShowForm] = useState(false) /** @@ -16,18 +25,19 @@ export function NetworkInterfaceField() { */ const [oldParams, setOldParams] = useState([]) - const [, { value }, { setValue }] = useField({ - name: 'networkInterfaces', - }) + const { + field: { value, onChange }, + } = useController({ control, name: 'networkInterfaces' }) return (
- Network interface + { const newType = event.target.value as InstanceNetworkInterfaceAttachment['type'] @@ -36,14 +46,14 @@ export function NetworkInterfaceField() { } newType === 'create' - ? setValue({ type: newType, params: oldParams }) - : setValue({ type: newType }) + ? onChange({ type: newType, params: oldParams }) + : onChange({ type: newType }) }} > None Default Custom - + {value.type === 'create' && ( <> {value.params.length > 0 && ( @@ -69,7 +79,7 @@ export function NetworkInterfaceField() {
- setCreateModalOpen(false)} - /> - setEditing(null)} - /> + {createModalOpen && ( + setCreateModalOpen(false)} /> + )} + {editing && ( + setEditing(null)} /> + )} ) } diff --git a/app/pages/settings/AppearancePage.tsx b/app/pages/settings/AppearancePage.tsx deleted file mode 100644 index 0c388aaed..000000000 --- a/app/pages/settings/AppearancePage.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Radio, RadioCard, Settings24Icon } from '@oxide/ui' - -import { DarkTheme, LightTheme } from 'app/components/ThemeIcons' -import { FullPageForm } from 'app/components/form' -import { Form, RadioField } from 'app/components/form' - -export function AppearancePage() { - return ( - <> - {}} - icon={} - error={new Error('This form is not yet implemented')} - > - - -
- - Default (Dark) -
-
- -
- - Light -
-
-
- - - Default - High Contrast - - - - Use system settings - Enabled - Disabled - - - Save -
- - ) -} diff --git a/app/pages/settings/HotkeysPage.tsx b/app/pages/settings/HotkeysPage.tsx deleted file mode 100644 index e93378560..000000000 --- a/app/pages/settings/HotkeysPage.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Settings24Icon } from '@oxide/ui' - -import { FullPageForm, Radio, RadioField } from 'app/components/form' -import { ListboxField } from 'app/components/form' -import { Form } from 'app/components/form' - -const Meta = navigator.userAgent.match(/Mac/i) ? '⌘' : 'Ctrl' - -export function HotkeysPage() { - return ( - <> - {}} - icon={} - error={new Error('This form is not yet implemented')} - > - - - Enabled - Disabled - - Save - - - ) -} diff --git a/app/pages/settings/ProfilePage.tsx b/app/pages/settings/ProfilePage.tsx index 7e9ec915e..4815527f5 100644 --- a/app/pages/settings/ProfilePage.tsx +++ b/app/pages/settings/ProfilePage.tsx @@ -6,7 +6,7 @@ import { useApiQuery } from '@oxide/api' import { Table } from '@oxide/table' import { Settings24Icon } from '@oxide/ui' -import { FullPageForm, TextField } from 'app/components/form' +import { FullPageForm, TextField } from 'app/components/hook-form' const colHelper = createColumnHelper() @@ -31,34 +31,41 @@ export function ProfilePage() { {}} - error={new Error('This form is not yet implemented')} icon={} + submitError={null} + onSubmit={() => Promise.resolve()} > - -

Groups

- - - Your user information is managed by your organization. - - To update, contact your{' '} - - IDP admin - - . - - + {({ control }) => ( + <> + +

Groups

+
+ + Your user information is managed by your organization. + + To update, contact your{' '} + + IDP admin + + . + + + + )} ) } diff --git a/app/routes.tsx b/app/routes.tsx index 76e751622..2542e59d5 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -3,6 +3,7 @@ import { Navigate, Route, createRoutesFromElements } from 'react-router-dom' import { RouterDataErrorBoundary } from './components/ErrorBoundary' import { CreateDiskSideModalForm } from './forms/disk-create' +import { CreateInstanceForm } from './forms/instance-create' import { CreateOrgSideModalForm } from './forms/org-create' import { EditOrgSideModalForm } from './forms/org-edit' import { CreateProjectSideModalForm } from './forms/project-create' @@ -39,10 +40,7 @@ import { VpcPage, VpcsPage, } from './pages/project' -import { InstanceCreatePage } from './pages/project/instances/InstanceCreatePage' import { SerialConsolePage } from './pages/project/instances/instance/SerialConsolePage' -import { AppearancePage } from './pages/settings/AppearancePage' -import { HotkeysPage } from './pages/settings/HotkeysPage' import { ProfilePage } from './pages/settings/ProfilePage' import { SSHKeysPage } from './pages/settings/SSHKeysPage' import SilosPage from './pages/system/SilosPage' @@ -70,11 +68,6 @@ export const routes = createRoutesFromElements( }> } /> } handle={{ crumb: 'Profile' }} /> - } - handle={{ crumb: 'Appearance' }} - /> } loader={SSHKeysPage.loader}> - } handle={{ crumb: 'Hotkeys' }} /> } loader={SystemLayout.loader}> @@ -166,7 +158,8 @@ export const routes = createRoutesFromElements( > } + element={} + loader={CreateInstanceForm.loader} handle={{ crumb: 'New instance' }} /> diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 65212da35..9ef6db123 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -13,11 +13,9 @@ test('path builder', () => { expect(Object.fromEntries(Object.entries(pb).map(([key, fn]) => [key, fn(params)]))) .toMatchInlineSnapshot(` { - "appearance": "/settings/appearance", "deviceSuccess": "/device/success", "diskNew": "/orgs/a/projects/b/disks-new", "disks": "/orgs/a/projects/b/disks", - "hotkeys": "/settings/hotkeys", "instance": "/orgs/a/projects/b/instances/c", "instanceNew": "/orgs/a/projects/b/instances-new", "instances": "/orgs/a/projects/b/instances", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index a0e65cf8f..1f995d1c3 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -50,8 +50,6 @@ export const pb = { settings: () => '/settings', profile: () => '/settings/profile', - appearance: () => '/settings/appearance', - hotkeys: () => '/settings/hotkeys', sshKeys: () => '/settings/ssh-keys', sshKeyNew: () => '/settings/ssh-keys-new', diff --git a/libs/api-mocks/global-image.ts b/libs/api-mocks/global-image.ts index f2b9b99eb..872c0f2b2 100644 --- a/libs/api-mocks/global-image.ts +++ b/libs/api-mocks/global-image.ts @@ -5,7 +5,7 @@ import type { Json } from './json-type' export const globalImages: Json[] = [ { id: 'ae46ddf5-a8d5-40fa-bcda-fcac606e3f9b', - name: 'ubuntu-22.04', + name: 'ubuntu-22-04', description: 'Latest Ubuntu LTS', distribution: 'ubuntu', version: '22.04', @@ -16,7 +16,7 @@ export const globalImages: Json[] = [ }, { id: 'a2ea1d7a-cc5a-4fda-a400-e2d2b18f53c5', - name: 'ubuntu-20.04', + name: 'ubuntu-20-04', description: 'Previous LTS', time_created: new Date().toISOString(), time_modified: new Date().toISOString(), diff --git a/libs/ui/lib/radio-group/RadioGroup.tsx b/libs/ui/lib/radio-group/RadioGroup.tsx index 83975cdb1..894ef0f5f 100644 --- a/libs/ui/lib/radio-group/RadioGroup.tsx +++ b/libs/ui/lib/radio-group/RadioGroup.tsx @@ -49,7 +49,7 @@ export const RadioGroupHint = classed.p`text-base text-secondary text-sans-sm ma export type RadioGroupProps = { // gets passed to all the radios. this is what defines them as a group name: string - children: React.ReactElement[] + children: React.ReactElement | React.ReactElement[] // gets passed to all the radios (technically only needs to be on one) required?: boolean // gets passed to all the radios diff --git a/package-lock.json b/package-lock.json index 05ba539cf..6ab1f3681 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,6 @@ "date-fns": "^2.28.0", "downshift": "^6.1.2", "filesize": "^8.0.7", - "formik": "^2.2.9", "match-sorter": "^6.3.1", "mousetrap": "^1.6.5", "prop-types": "^15.7.2", @@ -6056,14 +6055,6 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, - "node_modules/deepmerge": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", - "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/default-browser": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-3.1.0.tgz", @@ -8075,34 +8066,6 @@ "node": ">= 6" } }, - "node_modules/formik": { - "version": "2.2.9", - "resolved": "https://registry.npmjs.org/formik/-/formik-2.2.9.tgz", - "integrity": "sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==", - "funding": [ - { - "type": "individual", - "url": "https://opencollective.com/formik" - } - ], - "dependencies": { - "deepmerge": "^2.1.1", - "hoist-non-react-statics": "^3.3.0", - "lodash": "^4.17.21", - "lodash-es": "^4.17.21", - "react-fast-compare": "^2.0.1", - "tiny-warning": "^1.0.2", - "tslib": "^1.10.0" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/formik/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -8562,19 +8525,6 @@ "@babel/runtime": "^7.7.6" } }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "dependencies": { - "react-is": "^16.7.0" - } - }, - "node_modules/hoist-non-react-statics/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - }, "node_modules/html-encoding-sniffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", @@ -12941,11 +12891,6 @@ "react": ">=16.13.1" } }, - "node_modules/react-fast-compare": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", - "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==" - }, "node_modules/react-focus-lock": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/react-focus-lock/-/react-focus-lock-2.9.1.tgz", @@ -20989,11 +20934,6 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, - "deepmerge": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", - "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==" - }, "default-browser": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-3.1.0.tgz", @@ -22428,27 +22368,6 @@ "mime-types": "^2.1.12" } }, - "formik": { - "version": "2.2.9", - "resolved": "https://registry.npmjs.org/formik/-/formik-2.2.9.tgz", - "integrity": "sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==", - "requires": { - "deepmerge": "^2.1.1", - "hoist-non-react-statics": "^3.3.0", - "lodash": "^4.17.21", - "lodash-es": "^4.17.21", - "react-fast-compare": "^2.0.1", - "tiny-warning": "^1.0.2", - "tslib": "^1.10.0" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - } - } - }, "forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -22781,21 +22700,6 @@ "@babel/runtime": "^7.7.6" } }, - "hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "requires": { - "react-is": "^16.7.0" - }, - "dependencies": { - "react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - } - } - }, "html-encoding-sniffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", @@ -25965,11 +25869,6 @@ "@babel/runtime": "^7.12.5" } }, - "react-fast-compare": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", - "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==" - }, "react-focus-lock": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/react-focus-lock/-/react-focus-lock-2.9.1.tgz", diff --git a/package.json b/package.json index 9a35dbb7b..5c43b46ca 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "start:mock-api": "node -r esbuild-register ./tools/start_mock_api.ts", "build": "vite build", "build-for-nexus": "API_URL='' vite build", - "ci": "npx tsc && npm run lint && npm run test run && npm run e2ec", + "ci": "npm run tsc && npm run lint && npm run test run && npm run e2ec", + "tsc": "tsc", "test": "vitest", "e2e": "playwright test", "e2ec": "BROWSER=chrome playwright test", @@ -40,7 +41,6 @@ "date-fns": "^2.28.0", "downshift": "^6.1.2", "filesize": "^8.0.7", - "formik": "^2.2.9", "match-sorter": "^6.3.1", "mousetrap": "^1.6.5", "prop-types": "^15.7.2",