diff --git a/app/components/AttachFloatingIpModal.tsx b/app/components/AttachFloatingIpModal.tsx index 87fa708b7..a2022fe94 100644 --- a/app/components/AttachFloatingIpModal.tsx +++ b/app/components/AttachFloatingIpModal.tsx @@ -13,9 +13,10 @@ import { ListboxField } from '~/components/form/fields/ListboxField' import { HL } from '~/components/HL' import { addToast } from '~/stores/toast' import { Message } from '~/ui/lib/Message' -import { Modal } from '~/ui/lib/Modal' import { Slash } from '~/ui/lib/Slash' +import { ModalForm } from './form/ModalForm' + function FloatingIpLabel({ fip }: { fip: FloatingIp }) { return (
@@ -60,40 +61,39 @@ export const AttachFloatingIpModal = ({ const floatingIp = form.watch('floatingIp') return ( - - - - -
- ({ - value: ip.id, - label: , - selectedLabel: ip.name, - }))} - required - /> - -
-
- - floatingIpAttach.mutate({ - path: { floatingIp }, // note that this is an ID! - body: { kind: 'instance', parent: instance.id }, - }) - } - onDismiss={onDismiss} - > -
+ + floatingIpAttach.mutate({ + path: { floatingIp }, // note that this is an ID! + body: { kind: 'instance', parent: instance.id }, + }) + } + submitDisabled={!floatingIp} + > + +
+ ({ + value: ip.id, + label: , + selectedLabel: ip.name, + }))} + required + /> + +
) } diff --git a/app/components/StopInstancePrompt.tsx b/app/components/StopInstancePrompt.tsx new file mode 100644 index 000000000..162bb1d9f --- /dev/null +++ b/app/components/StopInstancePrompt.tsx @@ -0,0 +1,64 @@ +/* + * 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 ReactNode } from 'react' + +import { apiQueryClient, useApiMutation, type Instance } from '@oxide/api' + +import { HL } from '~/components/HL' +import { addToast } from '~/stores/toast' +import { Button } from '~/ui/lib/Button' +import { Message } from '~/ui/lib/Message' + +type StopInstancePromptProps = { + instance: Instance + children: ReactNode +} + +export function StopInstancePrompt({ instance, children }: StopInstancePromptProps) { + const isStoppingInstance = instance.runState === 'stopping' + + const stopInstance = useApiMutation('instanceStop', { + onSuccess: () => { + // trigger polling by the top level InstancePage one + apiQueryClient.invalidateQueries('instanceView') + addToast(<>Stopping instance {instance.name}) // prettier-ignore + }, + onError: (error) => { + addToast({ + variant: 'error', + title: `Error stopping instance '${instance.name}'`, + content: error.message, + }) + }, + }) + + return ( + + {children}{' '} + + + } + /> + ) +} diff --git a/app/components/form/ModalForm.tsx b/app/components/form/ModalForm.tsx new file mode 100644 index 000000000..be0897196 --- /dev/null +++ b/app/components/form/ModalForm.tsx @@ -0,0 +1,85 @@ +/* + * 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 { useId, type ReactNode } from 'react' +import type { FieldValues, UseFormReturn } from 'react-hook-form' + +import type { ApiError } from '@oxide/api' + +import { Message } from '~/ui/lib/Message' +import { Modal, type ModalProps } from '~/ui/lib/Modal' + +type ModalFormProps = { + form: UseFormReturn + children: ReactNode + + /** Must be provided with a reason describing why it's disabled */ + submitDisabled?: boolean + onSubmit: (values: TFieldValues) => void + submitLabel: string + + // require loading and error so we can't forget to hook them up. there are a + // few forms that don't need them, so we'll use dummy values + + /** Error from the API call */ + submitError: ApiError | null + loading: boolean +} & Omit + +export function ModalForm({ + form, + children, + onDismiss, + submitDisabled = false, + submitError, + title, + onSubmit, + submitLabel = 'Save', + loading, + width = 'medium', + overlay = true, +}: ModalFormProps) { + const id = useId() + const { isSubmitting } = form.formState + + return ( + + + + {submitError && ( + + )} +
{ + if (!onSubmit) return + // 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() + form.handleSubmit(onSubmit)(e) + }} + > + {children} +
+
+
+ +
+ ) +} diff --git a/app/components/form/SideModalForm.tsx b/app/components/form/SideModalForm.tsx index 3e353052e..5d8f15ba5 100644 --- a/app/components/form/SideModalForm.tsx +++ b/app/components/form/SideModalForm.tsx @@ -30,14 +30,6 @@ type EditFormProps = { type SideModalFormProps = { form: UseFormReturn - /** - * 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: ReactNode onDismiss: () => void resourceName: string diff --git a/app/components/form/fields/DisksTableField.tsx b/app/components/form/fields/DisksTableField.tsx index 2d9aa1270..6134b505f 100644 --- a/app/components/form/fields/DisksTableField.tsx +++ b/app/components/form/fields/DisksTableField.tsx @@ -10,7 +10,7 @@ import { useController, type Control } from 'react-hook-form' import type { DiskCreate } from '@oxide/api' -import { AttachDiskSideModalForm } from '~/forms/disk-attach' +import { AttachDiskModalForm } from '~/forms/disk-attach' import { CreateDiskSideModalForm } from '~/forms/disk-create' import type { InstanceCreateInput } from '~/forms/instance-create' import { Badge } from '~/ui/lib/Badge' @@ -115,7 +115,7 @@ export function DisksTableField({ /> )} {showDiskAttach && ( - setShowDiskAttach(false)} onSubmit={(values) => { onChange([...items, { type: 'attach', ...values }]) diff --git a/app/forms/disk-attach.tsx b/app/forms/disk-attach.tsx index 5a6eeaaaf..d9cf9808b 100644 --- a/app/forms/disk-attach.tsx +++ b/app/forms/disk-attach.tsx @@ -8,10 +8,11 @@ import { useMemo } from 'react' import { useForm } from 'react-hook-form' -import { useApiQuery, type ApiError } from '@oxide/api' +import { instanceCan, useApiQuery, type ApiError, type Instance } from '@oxide/api' import { ComboboxField } from '~/components/form/fields/ComboboxField' -import { SideModalForm } from '~/components/form/SideModalForm' +import { ModalForm } from '~/components/form/ModalForm' +import { StopInstancePrompt } from '~/components/StopInstancePrompt' import { useProjectSelector } from '~/hooks/use-params' import { toComboboxItems } from '~/ui/lib/Combobox' import { ALL_ISH } from '~/util/consts' @@ -25,22 +26,24 @@ type AttachDiskProps = { diskNamesToExclude?: string[] loading?: boolean submitError?: ApiError | null + instance?: Instance } /** * Can be used with either a `setState` or a real mutation as `onSubmit`, hence * the optional `loading` and `submitError` */ -export function AttachDiskSideModalForm({ +export function AttachDiskModalForm({ onSubmit, onDismiss, diskNamesToExclude = [], loading = false, submitError = null, + instance, }: AttachDiskProps) { const { project } = useProjectSelector() - const { data } = useApiQuery('diskList', { + const { data, isPending } = useApiQuery('diskList', { query: { project, limit: ALL_ISH }, }) const detachedDisks = useMemo( @@ -54,26 +57,33 @@ export function AttachDiskSideModalForm({ ) const form = useForm({ defaultValues }) + const { control } = form return ( - + {instance && ['stopping', 'running'].includes(instance.runState) && ( + + An instance must be stopped to attach a disk. + + )} - + ) } diff --git a/app/pages/SiloImagesPage.tsx b/app/pages/SiloImagesPage.tsx index 82f0ca89d..30b3a565b 100644 --- a/app/pages/SiloImagesPage.tsx +++ b/app/pages/SiloImagesPage.tsx @@ -7,7 +7,7 @@ */ import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo, useState } from 'react' -import { useForm, type FieldValues } from 'react-hook-form' +import { useForm } from 'react-hook-form' import { Outlet } from 'react-router' import { @@ -24,6 +24,7 @@ import { DocsPopover } from '~/components/DocsPopover' import { ComboboxField } from '~/components/form/fields/ComboboxField' import { toImageComboboxItem } from '~/components/form/fields/ImageSelectField' import { ListboxField } from '~/components/form/fields/ListboxField' +import { ModalForm } from '~/components/form/ModalForm' import { HL } from '~/components/HL' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' @@ -35,7 +36,6 @@ import { Button } from '~/ui/lib/Button' import { toComboboxItems } from '~/ui/lib/Combobox' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { Message } from '~/ui/lib/Message' -import { Modal } from '~/ui/lib/Modal' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { TableActions } from '~/ui/lib/Table' import { docLinks } from '~/util/links' @@ -129,7 +129,7 @@ type Values = { project: string | null; image: string | null } const defaultValues: Values = { project: null, image: null } const PromoteImageModal = ({ onDismiss }: { onDismiss: () => void }) => { - const { control, handleSubmit, watch, resetField } = useForm({ defaultValues }) + const form = useForm({ defaultValues }) const queryClient = useApiQueryClient() @@ -146,7 +146,7 @@ const PromoteImageModal = ({ onDismiss }: { onDismiss: () => void }) => { const projects = useApiQuery('projectList', {}) const projectItems = useMemo(() => toComboboxItems(projects.data?.items), [projects.data]) - const selectedProject = watch('project') + const selectedProject = form.watch('project') // can only fetch images if a project is selected const images = useApiQuery( @@ -159,53 +159,52 @@ const PromoteImageModal = ({ onDismiss }: { onDismiss: () => void }) => { [images.data] ) - const onSubmit = ({ image, project }: Values) => { - if (!image || !project) return - promoteImage.mutate({ path: { image } }) - } - return ( - - - -
- { - resetField('image') // reset image field when the project changes - }} - isLoading={projects.isPending} - required - control={control} - /> - - - -
-
- { + if (!image || !project) return // shouldn't happen because of validation + promoteImage.mutate({ path: { image } }) + }} + onDismiss={onDismiss} + submitLabel="Promote" + > + { + form.resetField('image') // reset image field when the project changes + }} + isLoading={projects.isPending} + required + control={form.control} + /> + -
+ + ) } +type DemoteFormValues = { + project: string | undefined +} + const DemoteImageModal = ({ onDismiss, image, @@ -213,20 +212,17 @@ const DemoteImageModal = ({ onDismiss: () => void image: Image }) => { - const { control, handleSubmit, watch } = useForm() + const defaultValues: DemoteFormValues = { project: undefined } + const form = useForm({ defaultValues }) - const selectedProject: string | undefined = watch('project') + const selectedProject: string | undefined = form.watch('project') const queryClient = useApiQueryClient() const demoteImage = useApiMutation('imageDemote', { onSuccess(data) { addToast({ - content: ( - <> - Image {data.name} demoted - - ), + content: <>Image {data.name} demoted, // prettier-ignore cta: selectedProject ? { text: `View images in ${selectedProject}`, @@ -243,51 +239,40 @@ const DemoteImageModal = ({ onSettled: onDismiss, }) - const onSubmit = (data: FieldValues) => { - demoteImage.mutate({ path: { image: image.id }, query: { project: data.project } }) - } - const projects = useApiQuery('projectList', {}) const projectItems = useMemo(() => toComboboxItems(projects.data?.items), [projects.data]) return ( - - - -
{ - e.stopPropagation() - handleSubmit(onSubmit)(e) - }} - className="space-y-4" - > -

- Demoting: {image.name} -

+ { + if (!project) return // shouldn't happen because of validation + demoteImage.mutate({ path: { image: image.id }, query: { project } }) + }} + onDismiss={onDismiss} + submitLabel="Demote" + > +

+ Demoting: {image.name} +

- + - - -
-
- -
+ ) } diff --git a/app/pages/project/floating-ips/FloatingIpsPage.tsx b/app/pages/project/floating-ips/FloatingIpsPage.tsx index a7c5f4d4b..b4a6caa74 100644 --- a/app/pages/project/floating-ips/FloatingIpsPage.tsx +++ b/app/pages/project/floating-ips/FloatingIpsPage.tsx @@ -23,6 +23,7 @@ import { IpGlobal16Icon, IpGlobal24Icon } from '@oxide/design-system/icons/react import { DocsPopover } from '~/components/DocsPopover' import { ListboxField } from '~/components/form/fields/ListboxField' +import { ModalForm } from '~/components/form/ModalForm' import { HL } from '~/components/HL' import { makeCrumb } from '~/hooks/use-crumbs' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' @@ -38,7 +39,6 @@ import { CopyableIp } from '~/ui/lib/CopyableIp' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { Message } from '~/ui/lib/Message' -import { Modal } from '~/ui/lib/Modal' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { TableActions } from '~/ui/lib/Table' import { ALL_ISH } from '~/util/consts' @@ -258,45 +258,41 @@ const AttachFloatingIpModal = ({ addToast({ title: 'Error', content: err.message, variant: 'error' }) }, }) + const form = useForm({ defaultValues: { instanceId: '' } }) - const instanceId = form.watch('instanceId') return ( - - - - - The selected instance will be reachable at {address} - - } - /> -
- ({ value: i.id, label: i.name }))} - label="Instance" - required - placeholder="Select an instance" - /> - -
-
- - floatingIpAttach.mutate({ - path: { floatingIp }, - query: { project }, - body: { kind: 'instance', parent: instanceId }, - }) + { + floatingIpAttach.mutate({ + path: { floatingIp }, + query: { project }, + body: { kind: 'instance', parent: instanceId }, + }) + }} + submitLabel="Attach" + submitError={floatingIpAttach.error} + loading={floatingIpAttach.isPending} + onDismiss={onDismiss} + > + + The selected instance will be reachable at {address} + } - onDismiss={onDismiss} - > -
+ /> + ({ value: i.id, label: i.name }))} + label="Instance" + required + placeholder="Select an instance" + /> + ) } diff --git a/app/pages/project/instances/StorageTab.tsx b/app/pages/project/instances/StorageTab.tsx index fb58644b7..a1090c0be 100644 --- a/app/pages/project/instances/StorageTab.tsx +++ b/app/pages/project/instances/StorageTab.tsx @@ -25,7 +25,7 @@ import { Storage24Icon } from '@oxide/design-system/icons/react' import { HL } from '~/components/HL' import { DiskStateBadge } from '~/components/StateBadge' -import { AttachDiskSideModalForm } from '~/forms/disk-attach' +import { AttachDiskModalForm } from '~/forms/disk-attach' import { CreateDiskSideModalForm } from '~/forms/disk-create' import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' import { confirmAction } from '~/stores/confirm-action' @@ -295,13 +295,6 @@ export default function StorageTab() { setShowDiskCreate(false) setShowDiskAttach(false) }, - onError(err) { - addToast({ - title: 'Failed to attach disk', - content: err.message, - variant: 'error', - }) - }, }) const bootDisksTable = useReactTable({ @@ -341,35 +334,22 @@ export default function StorageTab() { -
- - -
+ +
{otherDisks.length > 0 ? ( @@ -395,13 +375,14 @@ export default function StorageTab() { /> )} {showDiskAttach && ( - setShowDiskAttach(false)} onSubmit={({ name }) => { attachDisk.mutate({ ...instancePathQuery, body: { disk: name } }) }} loading={attachDisk.isPending} submitError={attachDisk.error} + instance={instance} /> )}
diff --git a/app/ui/lib/Button.tsx b/app/ui/lib/Button.tsx index e7b27f383..90141be0d 100644 --- a/app/ui/lib/Button.tsx +++ b/app/ui/lib/Button.tsx @@ -13,13 +13,14 @@ import { Spinner } from '~/ui/lib/Spinner' import { Tooltip } from '~/ui/lib/Tooltip' import { Wrap } from '~/ui/util/wrap' -export const buttonSizes = ['sm', 'icon', 'base'] as const -export const variants = ['primary', 'secondary', 'ghost', 'danger'] as const +export const buttonSizes = ['xs', 'sm', 'icon', 'base'] as const +export const variants = ['primary', 'secondary', 'ghost', 'danger', 'notice'] as const export type ButtonSize = (typeof buttonSizes)[number] export type Variant = (typeof variants)[number] const sizeStyle: Record = { + xs: 'h-6 px-2 text-mono-xs', sm: 'h-8 px-3 text-mono-sm [&>svg]:w-4', // meant for buttons that only contain a single icon icon: 'h-8 w-8 text-mono-sm [&>svg]:w-4', @@ -115,7 +116,7 @@ export const Button = ({ animate={{ opacity: 1, y: '-50%', x: '-50%' }} initial={{ opacity: 0, y: 'calc(-50% - 25px)', x: '-50%' }} transition={{ type: 'spring', duration: 0.3, bounce: 0 }} - className="absolute left-1/2 top-1/2" + className="absolute left-1/2 top-1/2 flex items-center justify-center" > diff --git a/app/ui/lib/Modal.tsx b/app/ui/lib/Modal.tsx index ba48886dd..9247c40b7 100644 --- a/app/ui/lib/Modal.tsx +++ b/app/ui/lib/Modal.tsx @@ -123,7 +123,7 @@ Modal.Footer = ({ actionText, actionLoading, cancelText, - disabled = false, + disabled, formId, }: FooterProps) => (