Skip to content
Draft
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions app/components/StopInstancePrompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* 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 { useEffect, useState, type ReactNode } from 'react'

import {
instanceTransitioning,
useApiMutation,
useApiQuery,
useApiQueryClient,
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'

const POLL_INTERVAL_FAST = 2000 // 2 seconds

type StopInstancePromptProps = {
instance: Instance
children: ReactNode
}

export function StopInstancePrompt({ instance, children }: StopInstancePromptProps) {
const queryClient = useApiQueryClient()
const [isStoppingInstance, setIsStoppingInstance] = useState(false)

const { data } = useApiQuery(
'instanceView',
{
path: { instance: instance.name },
query: { project: instance.projectId },
},
{
refetchInterval:
isStoppingInstance || instanceTransitioning(instance) ? POLL_INTERVAL_FAST : false,
}
)

const { mutateAsync: stopInstanceAsync } = useApiMutation('instanceStop', {
onSuccess: () => {
setIsStoppingInstance(true)
addToast(
<>
Stopping instance <HL>{instance.name}</HL>
</>
)
},
onError: (error) => {
addToast({
variant: 'error',
title: `Error stopping instance '${instance.name}'`,
content: error.message,
})
setIsStoppingInstance(false)
},
})

const handleStopInstance = () => {
stopInstanceAsync({
path: { instance: instance.name },
query: { project: instance.projectId },
})
}

const currentInstance = data || instance

useEffect(() => {
if (!data) {
return
}
if (isStoppingInstance && data.runState === 'stopped') {
queryClient.invalidateQueries('instanceView')
setIsStoppingInstance(false)
}
}, [isStoppingInstance, data, queryClient])

if (
!currentInstance ||
(currentInstance.runState !== 'stopping' && currentInstance.runState !== 'running')
) {
return null
}

return (
<Message
variant="notice"
content={
<>
{children}{' '}
<Button
size="xs"
className="mt-3"
variant="notice"
onClick={handleStopInstance}
loading={isStoppingInstance}
>
Stop instance
</Button>
</>
}
/>
)
}
82 changes: 82 additions & 0 deletions app/components/form/ModalForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* 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<TFieldValues extends FieldValues> = {
form: UseFormReturn<TFieldValues>
children: ReactNode
/** Must be provided with a reason describing why it's disabled */
submitDisabled?: string
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<ModalProps, 'isOpen'>

export function ModalForm<TFieldValues extends FieldValues>({
form,
children,
onDismiss,
submitDisabled,
submitError,
title,
onSubmit,
submitLabel = 'Save',
loading,
width = 'medium',
overlay = true,
}: ModalFormProps<TFieldValues>) {
const id = useId()
const { isSubmitting } = form.formState
return (
<Modal isOpen onDismiss={onDismiss} title={title} width={width} overlay={overlay}>
<Modal.Body>
<Modal.Section>
{submitError && (
<Message variant="error" title="Error" content={submitError.message} />
)}
<form
id={id}
className="ox-form"
autoComplete="off"
onSubmit={(e) => {
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}
</form>
</Modal.Section>
</Modal.Body>
<Modal.Footer
onDismiss={onDismiss}
formId={id}
actionText={submitLabel}
disabled={!!submitDisabled}
actionLoading={loading || isSubmitting}
/>
</Modal>
)
}
8 changes: 0 additions & 8 deletions app/components/form/SideModalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,6 @@ type EditFormProps = {

type SideModalFormProps<TFieldValues extends FieldValues> = {
form: UseFormReturn<TFieldValues>
/**
* 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
Expand Down
4 changes: 2 additions & 2 deletions app/components/form/fields/DisksTableField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

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'
Expand Down Expand Up @@ -115,7 +115,7 @@
/>
)}
{showDiskAttach && (
<AttachDiskSideModalForm
<AttachDiskModalForm

Check failure on line 118 in app/components/form/fields/DisksTableField.tsx

View workflow job for this annotation

GitHub Actions / ci

Property 'instance' is missing in type '{ onDismiss: () => void; onSubmit: (values: { name: string; }) => void; diskNamesToExclude: string[]; }' but required in type 'AttachDiskProps'.
onDismiss={() => setShowDiskAttach(false)}
onSubmit={(values) => {
onChange([...items, { type: 'attach', ...values }])
Expand Down
35 changes: 23 additions & 12 deletions app/forms/disk-attach.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -25,22 +26,24 @@ type AttachDiskProps = {
diskNamesToExclude?: string[]
loading?: boolean
submitError?: ApiError | null
instance: Instance
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requiring the instance isn't compatible with the use of this on the instance create form, but we can figure out how to deal with that.

}

/**
* 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(
Expand All @@ -54,26 +57,34 @@ export function AttachDiskSideModalForm({
)

const form = useForm({ defaultValues })
const { control } = form

return (
<SideModalForm
<ModalForm
form={form}
formType="create"
resourceName="disk"
onDismiss={onDismiss}
submitLabel="Attach disk"
submitError={submitError}
loading={loading}
title="Attach disk"
onSubmit={onSubmit}
loading={loading}
submitError={submitError}
onDismiss={onDismiss}
width="medium"
submitDisabled={
!instanceCan.attachDisk(instance) ? 'Instance must be stopped' : undefined
}
>
<StopInstancePrompt instance={instance}>
An instance must be stopped to attach a disk.
</StopInstancePrompt>
<ComboboxField
label="Disk name"
placeholder="Select a disk"
name="name"
items={detachedDisks}
required
control={form.control}
control={control}
isLoading={isPending}
/>
</SideModalForm>
</ModalForm>
)
}
Loading
Loading