Skip to content

Commit 5c7c8b5

Browse files
Attach disk modal + ModalForm (#2746)
* Attach disk modal instead of side modal * Tweak disabled text button colour * Type fix * Also needed on `ModalForm` * Already defaults to medium * convert image promote modal * clean up props a bit * convert image demote too * demotion notice copy tweak * convert floating IP attach * remove unnecessary fragment * Attach floating IP modal and prop tweaks * add success toast for disk attach --------- Co-authored-by: David Crespo <[email protected]>
1 parent 1d8c3b7 commit 5c7c8b5

File tree

11 files changed

+280
-229
lines changed

11 files changed

+280
-229
lines changed

app/components/AttachFloatingIpModal.tsx

Lines changed: 36 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ import { ListboxField } from '~/components/form/fields/ListboxField'
1313
import { HL } from '~/components/HL'
1414
import { addToast } from '~/stores/toast'
1515
import { Message } from '~/ui/lib/Message'
16-
import { Modal } from '~/ui/lib/Modal'
1716
import { Slash } from '~/ui/lib/Slash'
1817

18+
import { ModalForm } from './form/ModalForm'
19+
1920
function FloatingIpLabel({ fip }: { fip: FloatingIp }) {
2021
return (
2122
<div className="text-secondary selected:text-accent-secondary">
@@ -60,40 +61,39 @@ export const AttachFloatingIpModal = ({
6061
const floatingIp = form.watch('floatingIp')
6162

6263
return (
63-
<Modal isOpen title="Attach floating IP" onDismiss={onDismiss}>
64-
<Modal.Body>
65-
<Modal.Section>
66-
<Message
67-
variant="info"
68-
content={`Instance ‘${instance.name}’ will be reachable at the selected IP address`}
69-
/>
70-
<form>
71-
<ListboxField
72-
control={form.control}
73-
name="floatingIp"
74-
label="Floating IP"
75-
placeholder="Select a floating IP"
76-
items={floatingIps.map((ip) => ({
77-
value: ip.id,
78-
label: <FloatingIpLabel fip={ip} />,
79-
selectedLabel: ip.name,
80-
}))}
81-
required
82-
/>
83-
</form>
84-
</Modal.Section>
85-
</Modal.Body>
86-
<Modal.Footer
87-
actionText="Attach"
88-
disabled={!floatingIp}
89-
onAction={() =>
90-
floatingIpAttach.mutate({
91-
path: { floatingIp }, // note that this is an ID!
92-
body: { kind: 'instance', parent: instance.id },
93-
})
94-
}
95-
onDismiss={onDismiss}
96-
></Modal.Footer>
97-
</Modal>
64+
<ModalForm
65+
form={form}
66+
onDismiss={onDismiss}
67+
submitLabel="Attach floating IP"
68+
submitError={floatingIpAttach.error}
69+
loading={floatingIpAttach.isPending}
70+
title="Attach floating IP"
71+
onSubmit={() =>
72+
floatingIpAttach.mutate({
73+
path: { floatingIp }, // note that this is an ID!
74+
body: { kind: 'instance', parent: instance.id },
75+
})
76+
}
77+
submitDisabled={!floatingIp}
78+
>
79+
<Message
80+
variant="info"
81+
content={`Instance ‘${instance.name}’ will be reachable at the selected IP address`}
82+
/>
83+
<form>
84+
<ListboxField
85+
control={form.control}
86+
name="floatingIp"
87+
label="Floating IP"
88+
placeholder="Select a floating IP"
89+
items={floatingIps.map((ip) => ({
90+
value: ip.id,
91+
label: <FloatingIpLabel fip={ip} />,
92+
selectedLabel: ip.name,
93+
}))}
94+
required
95+
/>
96+
</form>
97+
</ModalForm>
9898
)
9999
}

app/components/form/ModalForm.tsx

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
9+
import { useId, type ReactNode } from 'react'
10+
import type { FieldValues, UseFormReturn } from 'react-hook-form'
11+
12+
import type { ApiError } from '@oxide/api'
13+
14+
import { Message } from '~/ui/lib/Message'
15+
import { Modal, type ModalProps } from '~/ui/lib/Modal'
16+
17+
type ModalFormProps<TFieldValues extends FieldValues> = {
18+
form: UseFormReturn<TFieldValues>
19+
children: ReactNode
20+
21+
/** Must be provided with a reason describing why it's disabled */
22+
submitDisabled?: boolean
23+
onSubmit: (values: TFieldValues) => void
24+
submitLabel: string
25+
26+
// require loading and error so we can't forget to hook them up. there are a
27+
// few forms that don't need them, so we'll use dummy values
28+
29+
/** Error from the API call */
30+
submitError: ApiError | null
31+
loading: boolean
32+
} & Omit<ModalProps, 'isOpen'>
33+
34+
export function ModalForm<TFieldValues extends FieldValues>({
35+
form,
36+
children,
37+
onDismiss,
38+
submitDisabled = false,
39+
submitError,
40+
title,
41+
onSubmit,
42+
submitLabel = 'Save',
43+
loading,
44+
width = 'medium',
45+
overlay = true,
46+
}: ModalFormProps<TFieldValues>) {
47+
const id = useId()
48+
const { isSubmitting } = form.formState
49+
50+
return (
51+
<Modal isOpen onDismiss={onDismiss} title={title} width={width} overlay={overlay}>
52+
<Modal.Body>
53+
<Modal.Section>
54+
{submitError && (
55+
<Message variant="error" title="Error" content={submitError.message} />
56+
)}
57+
<form
58+
id={id}
59+
className="ox-form"
60+
autoComplete="off"
61+
onSubmit={(e) => {
62+
if (!onSubmit) return
63+
// This modal being in a portal doesn't prevent the submit event
64+
// from bubbling up out of the portal. Normally that's not a
65+
// problem, but sometimes (e.g., instance create) we render the
66+
// SideModalForm from inside another form, in which case submitting
67+
// the inner form submits the outer form unless we stop propagation
68+
e.stopPropagation()
69+
form.handleSubmit(onSubmit)(e)
70+
}}
71+
>
72+
{children}
73+
</form>
74+
</Modal.Section>
75+
</Modal.Body>
76+
<Modal.Footer
77+
onDismiss={onDismiss}
78+
formId={id}
79+
actionText={submitLabel}
80+
disabled={submitDisabled}
81+
actionLoading={loading || isSubmitting}
82+
/>
83+
</Modal>
84+
)
85+
}

app/components/form/SideModalForm.tsx

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,6 @@ type EditFormProps = {
3030

3131
type SideModalFormProps<TFieldValues extends FieldValues> = {
3232
form: UseFormReturn<TFieldValues>
33-
/**
34-
* A function that returns the fields.
35-
*
36-
* Implemented as a function so we can pass `control` to the fields in the
37-
* calling code. We could do that internally with `cloneElement` instead, but
38-
* then in the calling code, the field would not infer `TFieldValues` and
39-
* constrain the `name` prop to paths in the values object.
40-
*/
4133
children: ReactNode
4234
onDismiss: () => void
4335
resourceName: string

app/components/form/fields/DisksTableField.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { useController, type Control } from 'react-hook-form'
1010

1111
import type { DiskCreate } from '@oxide/api'
1212

13-
import { AttachDiskSideModalForm } from '~/forms/disk-attach'
13+
import { AttachDiskModalForm } from '~/forms/disk-attach'
1414
import { CreateDiskSideModalForm } from '~/forms/disk-create'
1515
import type { InstanceCreateInput } from '~/forms/instance-create'
1616
import { Badge } from '~/ui/lib/Badge'
@@ -115,7 +115,7 @@ export function DisksTableField({
115115
/>
116116
)}
117117
{showDiskAttach && (
118-
<AttachDiskSideModalForm
118+
<AttachDiskModalForm
119119
onDismiss={() => setShowDiskAttach(false)}
120120
onSubmit={(values) => {
121121
onChange([...items, { type: 'attach', ...values }])

app/forms/disk-attach.tsx

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { useForm } from 'react-hook-form'
1111
import { useApiQuery, type ApiError } from '@oxide/api'
1212

1313
import { ComboboxField } from '~/components/form/fields/ComboboxField'
14-
import { SideModalForm } from '~/components/form/SideModalForm'
14+
import { ModalForm } from '~/components/form/ModalForm'
1515
import { useProjectSelector } from '~/hooks/use-params'
1616
import { toComboboxItems } from '~/ui/lib/Combobox'
1717
import { ALL_ISH } from '~/util/consts'
@@ -31,7 +31,7 @@ type AttachDiskProps = {
3131
* Can be used with either a `setState` or a real mutation as `onSubmit`, hence
3232
* the optional `loading` and `submitError`
3333
*/
34-
export function AttachDiskSideModalForm({
34+
export function AttachDiskModalForm({
3535
onSubmit,
3636
onDismiss,
3737
diskNamesToExclude = [],
@@ -40,7 +40,7 @@ export function AttachDiskSideModalForm({
4040
}: AttachDiskProps) {
4141
const { project } = useProjectSelector()
4242

43-
const { data } = useApiQuery('diskList', {
43+
const { data, isPending } = useApiQuery('diskList', {
4444
query: { project, limit: ALL_ISH },
4545
})
4646
const detachedDisks = useMemo(
@@ -54,26 +54,27 @@ export function AttachDiskSideModalForm({
5454
)
5555

5656
const form = useForm({ defaultValues })
57+
const { control } = form
5758

5859
return (
59-
<SideModalForm
60+
<ModalForm
6061
form={form}
61-
formType="create"
62-
resourceName="disk"
62+
onDismiss={onDismiss}
63+
submitLabel="Attach disk"
64+
submitError={submitError}
65+
loading={loading}
6366
title="Attach disk"
6467
onSubmit={onSubmit}
65-
loading={loading}
66-
submitError={submitError}
67-
onDismiss={onDismiss}
6868
>
6969
<ComboboxField
7070
label="Disk name"
7171
placeholder="Select a disk"
7272
name="name"
7373
items={detachedDisks}
7474
required
75-
control={form.control}
75+
control={control}
76+
isLoading={isPending}
7677
/>
77-
</SideModalForm>
78+
</ModalForm>
7879
)
7980
}

0 commit comments

Comments
 (0)