Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
72 changes: 36 additions & 36 deletions app/components/AttachFloatingIpModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="text-secondary selected:text-accent-secondary">
Expand Down Expand Up @@ -60,40 +61,39 @@ export const AttachFloatingIpModal = ({
const floatingIp = form.watch('floatingIp')

return (
<Modal isOpen title="Attach floating IP" onDismiss={onDismiss}>
<Modal.Body>
<Modal.Section>
<Message
variant="info"
content={`Instance ‘${instance.name}’ will be reachable at the selected IP address`}
/>
<form>
<ListboxField
control={form.control}
name="floatingIp"
label="Floating IP"
placeholder="Select a floating IP"
items={floatingIps.map((ip) => ({
value: ip.id,
label: <FloatingIpLabel fip={ip} />,
selectedLabel: ip.name,
}))}
required
/>
</form>
</Modal.Section>
</Modal.Body>
<Modal.Footer
actionText="Attach"
disabled={!floatingIp}
onAction={() =>
floatingIpAttach.mutate({
path: { floatingIp }, // note that this is an ID!
body: { kind: 'instance', parent: instance.id },
})
}
onDismiss={onDismiss}
></Modal.Footer>
</Modal>
<ModalForm
form={form}
onDismiss={onDismiss}
submitLabel="Attach floating IP"
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Do we want to think about our approach to labels here? As in do we use the full version as it is, or just use the verb for. Aka "attach", "create", "save"

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think just the verb, otherwise it's too long.

Copy link
Collaborator

@david-crespo david-crespo Apr 9, 2025

Choose a reason for hiding this comment

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

On second thought I like the nouns. I'll try this as Attach IP to make it shorter.

submitError={floatingIpAttach.error}
loading={floatingIpAttach.isPending}
title="Attach floating IP"
onSubmit={() =>
floatingIpAttach.mutate({
path: { floatingIp }, // note that this is an ID!
body: { kind: 'instance', parent: instance.id },
})
}
submitDisabled={!floatingIp}
>
<Message
variant="info"
content={`Instance ‘${instance.name}’ will be reachable at the selected IP address`}
/>
<form>
<ListboxField
control={form.control}
name="floatingIp"
label="Floating IP"
placeholder="Select a floating IP"
items={floatingIps.map((ip) => ({
value: ip.id,
label: <FloatingIpLabel fip={ip} />,
selectedLabel: ip.name,
}))}
required
/>
</form>
</ModalForm>
)
}
85 changes: 85 additions & 0 deletions app/components/form/ModalForm.tsx
Original file line number Diff line number Diff line change
@@ -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<TFieldValues extends FieldValues> = {
form: UseFormReturn<TFieldValues>
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<ModalProps, 'isOpen'>

export function ModalForm<TFieldValues extends FieldValues>({
form,
children,
onDismiss,
submitDisabled = false,
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
Copy link
Contributor Author

Choose a reason for hiding this comment

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

On the SideModelForm and FullPageForm we let the user pass in the buttons directly. This way is more foolproof, but maybe worth considering unifying our approach.

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.
*/
Copy link
Collaborator

Choose a reason for hiding this comment

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

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 { 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'
Expand Down Expand Up @@ -115,7 +115,7 @@ export function DisksTableField({
/>
)}
{showDiskAttach && (
<AttachDiskSideModalForm
<AttachDiskModalForm
onDismiss={() => setShowDiskAttach(false)}
onSubmit={(values) => {
onChange([...items, { type: 'attach', ...values }])
Expand Down
23 changes: 12 additions & 11 deletions app/forms/disk-attach.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { useForm } from 'react-hook-form'
import { useApiQuery, type ApiError } from '@oxide/api'

import { ComboboxField } from '~/components/form/fields/ComboboxField'
import { SideModalForm } from '~/components/form/SideModalForm'
import { ModalForm } from '~/components/form/ModalForm'
import { useProjectSelector } from '~/hooks/use-params'
import { toComboboxItems } from '~/ui/lib/Combobox'
import { ALL_ISH } from '~/util/consts'
Expand All @@ -31,7 +31,7 @@ type AttachDiskProps = {
* 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 = [],
Expand All @@ -40,7 +40,7 @@ export function AttachDiskSideModalForm({
}: 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 +54,27 @@ 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}
>
<ComboboxField
label="Disk name"
placeholder="Select a disk"
name="name"
items={detachedDisks}
required
control={form.control}
control={control}
isLoading={isPending}
/>
</SideModalForm>
</ModalForm>
)
}
Loading
Loading