Skip to content
Closed
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
1 change: 1 addition & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ module.exports = {
'warn',
{ assertFunctionNames: ['expectVisible', 'expectRowVisible'] },
],
'playwright/no-force-option': 'off',
},
},
],
Expand Down
56 changes: 56 additions & 0 deletions app/components/form/FormNavGuard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* 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 } from 'react'
import { type FieldValues, type UseFormReturn } from 'react-hook-form'
import { useBlocker } from 'react-router-dom'

import { Modal } from '~/ui/lib/Modal'

export function FormNavGuard<TFieldValues extends FieldValues>({
form,
}: {
form: UseFormReturn<TFieldValues>
Copy link
Collaborator

Choose a reason for hiding this comment

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

This doesn't need to be generic since we're not actually using the form values. I think you can do form: UseFormReturn with no param and it'll work.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Both of the callsites for this have form as a UseFormReturn<TFieldValues>. I think it'd be easier to just pass it in as-is and set the type within the new component to also have the generic? (TS complains at the callsites if I strip it from the new component's type) Or is there an aspect to generics that I'm missing?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yep, I was wrong. I thought that leaving it off like this

-export function FormNavGuard<TFieldValues extends FieldValues>({
-  form,
-}: {
-  form: UseFormReturn<TFieldValues>
-}) {
+export function FormNavGuard({ form }: { form: UseFormReturn }) {
   const { isDirty, isSubmitting, isSubmitSuccessful } = form.formState
   // Confirms with the user if they want to navigate away if the form is
   // dirty. Does not intercept everything e.g. refreshes or closing the tab

would be more like UseFormReturn<any> (which does work btw because it doesn't care about types anymore after), but it's actually UseFormReturn<FieldValues> (the default value for that generic param) and surprisingly, that is incompatible with our TFieldValues extends FieldValues. I think you're right, the best thing is to leave it as-is.

}) {
const { isDirty, isSubmitting, isSubmitSuccessful } = form.formState
// Confirms with the user if they want to navigate away if the form is
// dirty. Does not intercept everything e.g. refreshes or closing the tab
// but serves to reduce the possibility of a user accidentally losing their
// progress.
const blocker = useBlocker(isDirty && !isSubmitSuccessful)

// Gating on !isSubmitSuccessful above makes the blocker stop blocking nav
// after a successful submit. However, this can take a little time (there is a
// render in between when isSubmitSuccessful is true but the blocker is still
// ready to block), so we also have this useEffect that lets blocked requests
// through if submit is succesful but the blocker hasn't gotten a chance to
// stop blocking yet.
useEffect(() => {
if (blocker.state === 'blocked' && isSubmitSuccessful) {
blocker.proceed()
}
}, [blocker, isSubmitSuccessful])

return isSubmitting || isSubmitSuccessful ? null : (
<Modal
isOpen={blocker.state === 'blocked'}
onDismiss={() => blocker.reset?.()}
title="Confirm navigation"
>
<Modal.Section>
Are you sure you want to leave this form? Your progress will be lost.
</Modal.Section>
<Modal.Footer
onDismiss={() => blocker.reset?.()}
onAction={() => blocker.proceed?.()}
cancelText="Keep editing"
actionText="Leave form"
actionType="danger"
/>
</Modal>
)
}
52 changes: 4 additions & 48 deletions app/components/form/FullPageForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,12 @@
*
* Copyright Oxide Computer Company
*/
import { cloneElement, useEffect, type ReactNode } from 'react'
import { cloneElement, type ReactNode } from 'react'
import type { FieldValues, UseFormReturn } from 'react-hook-form'
import { useBlocker, type Blocker } from 'react-router-dom'

import type { ApiError } from '@oxide/api'

import { Modal } from '~/ui/lib/Modal'
import { FormNavGuard } from '~/components/form/FormNavGuard'
import { flattenChildren, pluckFirstOfType } from '~/util/children'
import { classed } from '~/util/classed'

Expand Down Expand Up @@ -55,26 +54,7 @@ export function FullPageForm<TFieldValues extends FieldValues>({
onSubmit,
submitError,
}: FullPageFormProps<TFieldValues>) {
const { isSubmitting, isDirty, isSubmitSuccessful } = form.formState

// Confirms with the user if they want to navigate away if the form is
// dirty. Does not intercept everything e.g. refreshes or closing the tab
// but serves to reduce the possibility of a user accidentally losing their
// progress.
const blocker = useBlocker(isDirty && !isSubmitSuccessful)

// Gating on !isSubmitSuccessful above makes the blocker stop blocking nav
// after a successful submit. However, this can take a little time (there is a
// render in between when isSubmitSuccessful is true but the blocker is still
// ready to block), so we also have this useEffect that lets blocked requests
// through if submit is succesful but the blocker hasn't gotten a chance to
// stop blocking yet.
useEffect(() => {
if (blocker.state === 'blocked' && isSubmitSuccessful) {
blocker.proceed()
}
}, [blocker, isSubmitSuccessful])

const { isSubmitting } = form.formState
const childArray = flattenChildren(children)
const actions = pluckFirstOfType(childArray, Form.Actions)

Expand All @@ -98,13 +78,9 @@ export function FullPageForm<TFieldValues extends FieldValues>({
autoComplete="off"
>
{childArray}
<FormNavGuard form={form} />
</form>

{/* rendering of the modal must be gated on isSubmitSuccessful because
there is a brief moment where isSubmitSuccessful is true but the proceed()
hasn't fired yet, which means we get a brief flash of this modal */}
{!isSubmitSuccessful && <ConfirmNavigation blocker={blocker} />}

{actions && (
<PageActions>
<PageActionsContainer>
Expand All @@ -120,23 +96,3 @@ export function FullPageForm<TFieldValues extends FieldValues>({
</>
)
}

const ConfirmNavigation = ({ blocker }: { blocker: Blocker }) => (
<Modal
isOpen={blocker.state === 'blocked'}
onDismiss={() => blocker.reset?.()}
title="Confirm navigation"
>
<Modal.Section>
Are you sure you want to leave this page? <br /> You will lose all progress on this
form.
</Modal.Section>
<Modal.Footer
onDismiss={() => blocker.reset?.()}
onAction={() => blocker.proceed?.()}
cancelText="Continue editing"
actionText="Leave this page"
actionType="danger"
/>
</Modal>
)
11 changes: 8 additions & 3 deletions app/components/form/SideModalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { NavigationType, useNavigationType } from 'react-router-dom'

import type { ApiError } from '@oxide/api'

import { FormNavGuard } from '~/components/form/FormNavGuard'
import { Button } from '~/ui/lib/Button'
import { SideModal } from '~/ui/lib/SideModal'

Expand Down Expand Up @@ -47,7 +48,7 @@ type SideModalFormProps<TFieldValues extends FieldValues> = {
/** Only needed if you need to override the default title (Create/Edit ${resourceName}) */
title?: string
subtitle?: ReactNode
onSubmit?: (values: TFieldValues) => void
onSubmit?: (values: TFieldValues) => Promise<void>
} & (CreateFormProps | EditFormProps)

/**
Expand Down Expand Up @@ -103,18 +104,22 @@ export function SideModalForm<TFieldValues extends FieldValues>({
id={id}
className="ox-form is-side-modal"
autoComplete="off"
onSubmit={(e) => {
onSubmit={async (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)
// Important to await here so isSubmitSuccessful doesn't become true
// until the submit is actually successful. Note you must use await
// mutateAsync() inside onSubmit in order to make this wait
await form.handleSubmit(onSubmit)(e)
}}
>
{children}
<FormNavGuard form={form} />
</form>
</SideModal.Body>
<SideModal.Footer error={!!submitError}>
Expand Down
1 change: 1 addition & 0 deletions app/components/form/fields/DisksTableField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export function DisksTableField({
onSubmit={(values) => {
onChange([...items, { type: 'attach', ...values }])
setShowDiskAttach(false)
return Promise.resolve()
}}
diskNamesToExclude={items.filter((i) => i.type === 'attach').map((i) => i.name)}
/>
Expand Down
1 change: 1 addition & 0 deletions app/components/form/fields/NetworkInterfaceField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export function NetworkInterfaceField({
params: [...value.params, networkInterface],
})
setShowForm(false)
return Promise.resolve()
}}
onDismiss={() => setShowForm(false)}
/>
Expand Down
2 changes: 1 addition & 1 deletion app/forms/disk-attach.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const defaultValues = { name: '' }

type AttachDiskProps = {
/** If defined, this overrides the usual mutation */
onSubmit: (diskAttach: { name: string }) => void
onSubmit: (diskAttach: { name: string }) => Promise<void>
onDismiss: () => void
diskNamesToExclude?: string[]
loading?: boolean
Expand Down
6 changes: 4 additions & 2 deletions app/forms/disk-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,11 @@ export function CreateDiskSideModalForm({
formType="create"
resourceName="disk"
onDismiss={() => onDismiss(navigate)}
onSubmit={({ size, ...rest }) => {
onSubmit={async ({ size, ...rest }) => {
const body = { size: size * GiB, ...rest }
onSubmit ? onSubmit(body) : createDisk.mutate({ query: { project }, body })
onSubmit
? onSubmit(body)
: await createDisk.mutateAsync({ query: { project }, body })
}}
loading={createDisk.isPending}
submitError={createDisk.error}
Expand Down
4 changes: 2 additions & 2 deletions app/forms/firewall-rules-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -617,13 +617,13 @@ export function CreateFirewallRuleForm() {
resourceName="rule"
title="Add firewall rule"
onDismiss={onDismiss}
onSubmit={(values) => {
onSubmit={async (values) => {
// TODO: this silently overwrites existing rules with the current name.
// we should probably at least warn and confirm, if not reject as invalid
const otherRules = existingRules
.filter((r) => r.name !== values.name)
.map(firewallRuleGetToPut)
updateRules.mutate({
await updateRules.mutateAsync({
query: vpcSelector,
body: {
rules: [...otherRules, valuesToRuleUpdate(values)],
Expand Down
4 changes: 2 additions & 2 deletions app/forms/firewall-rules-edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,14 @@ export function EditFirewallRuleForm() {
formType="edit"
resourceName="rule"
onDismiss={onDismiss}
onSubmit={(values) => {
onSubmit={async (values) => {
// note different filter logic from create: filter out the rule with the
// *original* name because we need to overwrite that rule
const otherRules = data.rules
.filter((r) => r.name !== originalRule.name)
.map(firewallRuleGetToPut)

updateRules.mutate({
await updateRules.mutateAsync({
query: vpcSelector,
body: {
rules: [...otherRules, valuesToRuleUpdate(values)],
Expand Down
4 changes: 3 additions & 1 deletion app/forms/floating-ip-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ export function CreateFloatingIpSideModalForm() {
formType="create"
resourceName="floating IP"
onDismiss={() => navigate(pb.floatingIps(projectSelector))}
onSubmit={(body) => createFloatingIp.mutate({ query: projectSelector, body })}
onSubmit={async (body) => {
await createFloatingIp.mutateAsync({ query: projectSelector, body })
}}
loading={createFloatingIp.isPending}
submitError={createFloatingIp.error}
>
Expand Down
4 changes: 2 additions & 2 deletions app/forms/floating-ip-edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ export function EditFloatingIpSideModalForm() {
formType="edit"
resourceName="floating IP"
onDismiss={onDismiss}
onSubmit={({ name, description }) => {
editFloatingIp.mutate({
onSubmit={async ({ name, description }) => {
await editFloatingIp.mutateAsync({
path: { floatingIp: floatingIpSelector.floatingIp },
query: { project: floatingIpSelector.project },
body: { name, description },
Expand Down
6 changes: 3 additions & 3 deletions app/forms/image-from-snapshot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,12 @@ export function CreateImageFromSnapshotSideModalForm() {
title="Create image from snapshot"
submitLabel="Create image"
onDismiss={onDismiss}
onSubmit={(body) =>
createImage.mutate({
onSubmit={async (body) => {
await createImage.mutateAsync({
query: { project },
body: { ...body, source: { type: 'snapshot', id: data.id } },
})
}
}}
submitError={createImage.error}
>
<PropertiesTable>
Expand Down
4 changes: 2 additions & 2 deletions app/forms/ip-pool-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ export function CreateIpPoolSideModalForm() {
formType="create"
resourceName="IP pool"
onDismiss={onDismiss}
onSubmit={({ name, description }) => {
createPool.mutate({ body: { name, description } })
onSubmit={async ({ name, description }) => {
await createPool.mutateAsync({ body: { name, description } })
}}
loading={createPool.isPending}
submitError={createPool.error}
Expand Down
4 changes: 2 additions & 2 deletions app/forms/ip-pool-edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ export function EditIpPoolSideModalForm() {
formType="edit"
resourceName="IP pool"
onDismiss={onDismiss}
onSubmit={({ name, description }) => {
editPool.mutate({ path: poolSelector, body: { name, description } })
onSubmit={async ({ name, description }) => {
await editPool.mutateAsync({ path: poolSelector, body: { name, description } })
}}
loading={editPool.isPending}
submitError={editPool.error}
Expand Down
4 changes: 3 additions & 1 deletion app/forms/ip-pool-range-add.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@ export function IpPoolAddRangeSideModalForm() {
resourceName="IP range"
title="Add IP range"
onDismiss={onDismiss}
onSubmit={(body) => addRange.mutate({ path: { pool }, body })}
onSubmit={async (body) => {
await addRange.mutateAsync({ path: { pool }, body })
}}
loading={addRange.isPending}
submitError={addRange.error}
>
Expand Down
2 changes: 1 addition & 1 deletion app/forms/network-interface-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const defaultValues: InstanceNetworkInterfaceCreate = {

type CreateNetworkInterfaceFormProps = {
onDismiss: () => void
onSubmit: (values: InstanceNetworkInterfaceCreate) => void
onSubmit: (values: InstanceNetworkInterfaceCreate) => Promise<void>
loading?: boolean
submitError?: ApiError | null
}
Expand Down
4 changes: 2 additions & 2 deletions app/forms/network-interface-edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ export function EditNetworkInterfaceForm({
formType="edit"
resourceName="network interface"
onDismiss={onDismiss}
onSubmit={(body) => {
onSubmit={async (body) => {
const interfaceName = defaultValues.name
editNetworkInterface.mutate({
await editNetworkInterface.mutateAsync({
path: { interface: interfaceName },
query: instanceSelector,
body,
Expand Down
8 changes: 4 additions & 4 deletions app/forms/project-access.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,15 @@ export function ProjectAccessAddUserSideModal({ onDismiss, policy }: AddRoleModa
resourceName="role"
form={form}
formType="create"
onSubmit={({ identityId, roleName }) => {
onSubmit={async ({ identityId, roleName }) => {
// can't happen because roleName is validated not to be '', but TS
// wants to be sure
if (roleName === '') return

// actor is guaranteed to be in the list because it came from there
const identityType = actors.find((a) => a.id === identityId)!.identityType

updatePolicy.mutate({
await updatePolicy.mutateAsync({
path: { project },
body: updateRole({ identityId, identityType, roleName }, policy),
})
Expand Down Expand Up @@ -108,8 +108,8 @@ export function ProjectAccessEditUserSideModal({
formType="edit"
resourceName="role"
title={`Change project role for ${name}`}
onSubmit={({ roleName }) => {
updatePolicy.mutate({
onSubmit={async ({ roleName }) => {
await updatePolicy.mutateAsync({
path: { project },
body: updateRole({ identityId, identityType, roleName }, policy),
})
Expand Down
4 changes: 2 additions & 2 deletions app/forms/project-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ export function CreateProjectSideModalForm() {
formType="create"
resourceName="project"
onDismiss={onDismiss}
onSubmit={({ name, description }) => {
createProject.mutate({ body: { name, description } })
onSubmit={async ({ name, description }) => {
await createProject.mutateAsync({ body: { name, description } })
}}
loading={createProject.isPending}
submitError={createProject.error}
Expand Down
Loading