From e7707813093d1e729af43fbb581be22526857dad Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 3 Jul 2024 16:08:41 -0400 Subject: [PATCH 01/11] Add blocker to SideModalForm to protect from some accidental data loss --- app/components/form/ConfirmNavigation.tsx | 30 +++++++++++++++++++++++ app/components/form/FullPageForm.tsx | 24 ++---------------- app/components/form/SideModalForm.tsx | 25 +++++++++++++++++-- 3 files changed, 55 insertions(+), 24 deletions(-) create mode 100644 app/components/form/ConfirmNavigation.tsx diff --git a/app/components/form/ConfirmNavigation.tsx b/app/components/form/ConfirmNavigation.tsx new file mode 100644 index 0000000000..89102ebf1e --- /dev/null +++ b/app/components/form/ConfirmNavigation.tsx @@ -0,0 +1,30 @@ +/* + * 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 { Blocker } from 'react-router-dom' + +import { Modal } from '~/ui/lib/Modal' + +export const ConfirmNavigation = ({ blocker }: { blocker: Blocker }) => ( + blocker.reset?.()} + title="Confirm navigation" + > + + Are you sure you want to leave this page?
You will lose all progress on this + form. +
+ blocker.reset?.()} + onAction={() => blocker.proceed?.()} + cancelText="Continue editing" + actionText="Leave this page" + actionType="danger" + /> +
+) diff --git a/app/components/form/FullPageForm.tsx b/app/components/form/FullPageForm.tsx index 4ca6f79b91..df064ea509 100644 --- a/app/components/form/FullPageForm.tsx +++ b/app/components/form/FullPageForm.tsx @@ -7,16 +7,16 @@ */ import { cloneElement, useEffect, type ReactNode } from 'react' import type { FieldValues, UseFormReturn } from 'react-hook-form' -import { useBlocker, type Blocker } from 'react-router-dom' +import { useBlocker } from 'react-router-dom' import type { ApiError } from '@oxide/api' -import { Modal } from '~/ui/lib/Modal' import { flattenChildren, pluckFirstOfType } from '~/util/children' import { classed } from '~/util/classed' import { Form } from '../form/Form' import { PageActions } from '../PageActions' +import { ConfirmNavigation } from './ConfirmNavigation' interface FullPageFormProps { id: string @@ -120,23 +120,3 @@ export function FullPageForm({ ) } - -const ConfirmNavigation = ({ blocker }: { blocker: Blocker }) => ( - blocker.reset?.()} - title="Confirm navigation" - > - - Are you sure you want to leave this page?
You will lose all progress on this - form. -
- blocker.reset?.()} - onAction={() => blocker.proceed?.()} - cancelText="Continue editing" - actionText="Leave this page" - actionType="danger" - /> -
-) diff --git a/app/components/form/SideModalForm.tsx b/app/components/form/SideModalForm.tsx index e0e02b7cbe..36c8858b6c 100644 --- a/app/components/form/SideModalForm.tsx +++ b/app/components/form/SideModalForm.tsx @@ -7,13 +7,15 @@ */ import { useEffect, useId, type ReactNode } from 'react' import type { FieldValues, UseFormReturn } from 'react-hook-form' -import { NavigationType, useNavigationType } from 'react-router-dom' +import { NavigationType, useBlocker, useNavigationType } from 'react-router-dom' import type { ApiError } from '@oxide/api' import { Button } from '~/ui/lib/Button' import { SideModal } from '~/ui/lib/SideModal' +import { ConfirmNavigation } from './ConfirmNavigation' + type CreateFormProps = { formType: 'create' /** Only needed if you need to override the default button text (`Create ${resourceName}`) */ @@ -75,7 +77,7 @@ export function SideModalForm({ subtitle, }: SideModalFormProps) { const id = useId() - const { isSubmitting } = form.formState + const { isSubmitting, isDirty, isSubmitSuccessful } = form.formState useEffect(() => { if (submitError?.errorCode === 'ObjectAlreadyExists' && 'name' in form.getValues()) { @@ -84,6 +86,24 @@ export function SideModalForm({ } }, [submitError, form]) + // 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 label = formType === 'edit' ? `Update ${resourceName}` @@ -134,6 +154,7 @@ export function SideModalForm({ )} + {!isSubmitSuccessful && } ) } From e3480d20df166b5bb84d047fa664d15a31772b6e Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 3 Jul 2024 16:31:54 -0400 Subject: [PATCH 02/11] Extract FormNavGuard to its own component --- app/components/form/ConfirmNavigation.tsx | 30 ----------- app/components/form/FormNavGuard.tsx | 62 +++++++++++++++++++++++ app/components/form/FullPageForm.tsx | 32 ++---------- app/components/form/SideModalForm.tsx | 27 ++-------- 4 files changed, 70 insertions(+), 81 deletions(-) delete mode 100644 app/components/form/ConfirmNavigation.tsx create mode 100644 app/components/form/FormNavGuard.tsx diff --git a/app/components/form/ConfirmNavigation.tsx b/app/components/form/ConfirmNavigation.tsx deleted file mode 100644 index 89102ebf1e..0000000000 --- a/app/components/form/ConfirmNavigation.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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 { Blocker } from 'react-router-dom' - -import { Modal } from '~/ui/lib/Modal' - -export const ConfirmNavigation = ({ blocker }: { blocker: Blocker }) => ( - blocker.reset?.()} - title="Confirm navigation" - > - - Are you sure you want to leave this page?
You will lose all progress on this - form. -
- blocker.reset?.()} - onAction={() => blocker.proceed?.()} - cancelText="Continue editing" - actionText="Leave this page" - actionType="danger" - /> -
-) diff --git a/app/components/form/FormNavGuard.tsx b/app/components/form/FormNavGuard.tsx new file mode 100644 index 0000000000..c7471185af --- /dev/null +++ b/app/components/form/FormNavGuard.tsx @@ -0,0 +1,62 @@ +/* + * 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({ + 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 + // 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]) + + // 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 */} + if (isSubmitting || isSubmitSuccessful) { + return null + } + + return ( + blocker.reset?.()} + title="Confirm navigation" + > + + Are you sure you want to leave this form? Your progress will be lost. + + blocker.reset?.()} + onAction={() => blocker.proceed?.()} + cancelText="Continue editing" + actionText="Leave this form" + actionType="danger" + /> + + ) +} diff --git a/app/components/form/FullPageForm.tsx b/app/components/form/FullPageForm.tsx index df064ea509..2ce9468941 100644 --- a/app/components/form/FullPageForm.tsx +++ b/app/components/form/FullPageForm.tsx @@ -5,18 +5,17 @@ * * 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 } from 'react-router-dom' import type { ApiError } from '@oxide/api' +import { FormNavGuard } from '~/components/form/FormNavGuard' import { flattenChildren, pluckFirstOfType } from '~/util/children' import { classed } from '~/util/classed' import { Form } from '../form/Form' import { PageActions } from '../PageActions' -import { ConfirmNavigation } from './ConfirmNavigation' interface FullPageFormProps { id: string @@ -55,26 +54,7 @@ export function FullPageForm({ onSubmit, submitError, }: FullPageFormProps) { - 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) @@ -98,13 +78,9 @@ export function FullPageForm({ autoComplete="off" > {childArray} + - {/* 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 && } - {actions && ( diff --git a/app/components/form/SideModalForm.tsx b/app/components/form/SideModalForm.tsx index 36c8858b6c..bb3a2bba72 100644 --- a/app/components/form/SideModalForm.tsx +++ b/app/components/form/SideModalForm.tsx @@ -7,15 +7,14 @@ */ import { useEffect, useId, type ReactNode } from 'react' import type { FieldValues, UseFormReturn } from 'react-hook-form' -import { NavigationType, useBlocker, useNavigationType } from 'react-router-dom' +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' -import { ConfirmNavigation } from './ConfirmNavigation' - type CreateFormProps = { formType: 'create' /** Only needed if you need to override the default button text (`Create ${resourceName}`) */ @@ -77,7 +76,7 @@ export function SideModalForm({ subtitle, }: SideModalFormProps) { const id = useId() - const { isSubmitting, isDirty, isSubmitSuccessful } = form.formState + const { isSubmitting } = form.formState useEffect(() => { if (submitError?.errorCode === 'ObjectAlreadyExists' && 'name' in form.getValues()) { @@ -86,24 +85,6 @@ export function SideModalForm({ } }, [submitError, form]) - // 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 label = formType === 'edit' ? `Update ${resourceName}` @@ -135,6 +116,7 @@ export function SideModalForm({ }} > {children} + @@ -154,7 +136,6 @@ export function SideModalForm({ )} - {!isSubmitSuccessful && } ) } From 5ad50c32d0eb0b1957ee4452d3f84470d5f26b92 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 3 Jul 2024 16:38:30 -0400 Subject: [PATCH 03/11] formatting --- app/components/form/FormNavGuard.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/components/form/FormNavGuard.tsx b/app/components/form/FormNavGuard.tsx index c7471185af..279ab70e26 100644 --- a/app/components/form/FormNavGuard.tsx +++ b/app/components/form/FormNavGuard.tsx @@ -4,7 +4,8 @@ * file, you can obtain one at https://mozilla.org/MPL/2.0/. * * Copyright Oxide Computer Company - */ import { useEffect } from 'react' + */ +import { useEffect } from 'react' import { type FieldValues, type UseFormReturn } from 'react-hook-form' import { useBlocker } from 'react-router-dom' From f1b6807f8a30925001605e68456033c6f764bf5c Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 9 Jul 2024 13:00:37 -0400 Subject: [PATCH 04/11] include optional field in defaultValues; probably will need more of these --- app/forms/vpc-create.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/forms/vpc-create.tsx b/app/forms/vpc-create.tsx index 92bff170d6..ced3be6431 100644 --- a/app/forms/vpc-create.tsx +++ b/app/forms/vpc-create.tsx @@ -21,6 +21,7 @@ const defaultValues: VpcCreate = { name: '', description: '', dnsName: '', + ipv6Prefix: '', } export function CreateVpcSideModalForm() { From 23926dbb58a377d96d88fa4e71239a0da6f2e3be Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 10 Jul 2024 12:01:51 -0400 Subject: [PATCH 05/11] defaultValue must be undefined or zod complains on submit; refactor --- app/components/form/FormNavGuard.tsx | 9 +-------- app/forms/vpc-create.tsx | 2 +- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/app/components/form/FormNavGuard.tsx b/app/components/form/FormNavGuard.tsx index 279ab70e26..9b62f1b573 100644 --- a/app/components/form/FormNavGuard.tsx +++ b/app/components/form/FormNavGuard.tsx @@ -35,14 +35,7 @@ export function FormNavGuard({ } }, [blocker, isSubmitSuccessful]) - // 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 */} - if (isSubmitting || isSubmitSuccessful) { - return null - } - - return ( + return isSubmitting || isSubmitSuccessful ? null : ( blocker.reset?.()} diff --git a/app/forms/vpc-create.tsx b/app/forms/vpc-create.tsx index ced3be6431..f464612ab0 100644 --- a/app/forms/vpc-create.tsx +++ b/app/forms/vpc-create.tsx @@ -21,7 +21,7 @@ const defaultValues: VpcCreate = { name: '', description: '', dnsName: '', - ipv6Prefix: '', + ipv6Prefix: undefined, } export function CreateVpcSideModalForm() { From 83897db9ea503c5cab6f45fc4253045cbc99f8e4 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 16 Jul 2024 16:00:41 -0400 Subject: [PATCH 06/11] Switch to async on form create/edits --- app/components/form/SideModalForm.tsx | 6 +++--- app/components/form/fields/DisksTableField.tsx | 1 + app/components/form/fields/NetworkInterfaceField.tsx | 1 + app/forms/disk-attach.tsx | 2 +- app/forms/disk-create.tsx | 6 ++++-- app/forms/firewall-rules-create.tsx | 4 ++-- app/forms/firewall-rules-edit.tsx | 4 ++-- app/forms/floating-ip-create.tsx | 4 +++- app/forms/floating-ip-edit.tsx | 4 ++-- app/forms/image-from-snapshot.tsx | 6 +++--- app/forms/ip-pool-create.tsx | 4 ++-- app/forms/ip-pool-edit.tsx | 4 ++-- app/forms/ip-pool-range-add.tsx | 4 +++- app/forms/network-interface-create.tsx | 2 +- app/forms/network-interface-edit.tsx | 4 ++-- app/forms/project-access.tsx | 8 ++++---- app/forms/project-create.tsx | 4 ++-- app/forms/project-edit.tsx | 7 +++++-- app/forms/silo-access.tsx | 8 ++++---- app/forms/silo-create.tsx | 4 ++-- app/forms/snapshot-create.tsx | 4 ++-- app/forms/ssh-key-create.tsx | 4 +++- app/forms/subnet-create.tsx | 4 +++- app/forms/subnet-edit.tsx | 4 ++-- app/forms/vpc-create.tsx | 4 +++- app/forms/vpc-edit.tsx | 4 ++-- .../project/instances/instance/tabs/NetworkingTab.tsx | 4 +++- app/pages/project/instances/instance/tabs/StorageTab.tsx | 4 ++-- 28 files changed, 69 insertions(+), 50 deletions(-) diff --git a/app/components/form/SideModalForm.tsx b/app/components/form/SideModalForm.tsx index bb3a2bba72..f19d47861d 100644 --- a/app/components/form/SideModalForm.tsx +++ b/app/components/form/SideModalForm.tsx @@ -48,7 +48,7 @@ type SideModalFormProps = { /** 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 } & (CreateFormProps | EditFormProps) /** @@ -104,7 +104,7 @@ export function SideModalForm({ 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 @@ -112,7 +112,7 @@ export function SideModalForm({ // 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) + await form.handleSubmit(onSubmit)(e) }} > {children} diff --git a/app/components/form/fields/DisksTableField.tsx b/app/components/form/fields/DisksTableField.tsx index f8389ee650..aed83fbdc8 100644 --- a/app/components/form/fields/DisksTableField.tsx +++ b/app/components/form/fields/DisksTableField.tsx @@ -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)} /> diff --git a/app/components/form/fields/NetworkInterfaceField.tsx b/app/components/form/fields/NetworkInterfaceField.tsx index 68c1a6b880..4de62c9043 100644 --- a/app/components/form/fields/NetworkInterfaceField.tsx +++ b/app/components/form/fields/NetworkInterfaceField.tsx @@ -116,6 +116,7 @@ export function NetworkInterfaceField({ params: [...value.params, networkInterface], }) setShowForm(false) + return Promise.resolve() }} onDismiss={() => setShowForm(false)} /> diff --git a/app/forms/disk-attach.tsx b/app/forms/disk-attach.tsx index 7895c81b91..f3a6f71352 100644 --- a/app/forms/disk-attach.tsx +++ b/app/forms/disk-attach.tsx @@ -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 onDismiss: () => void diskNamesToExclude?: string[] loading?: boolean diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx index ae4d88d2be..f487207530 100644 --- a/app/forms/disk-create.tsx +++ b/app/forms/disk-create.tsx @@ -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} diff --git a/app/forms/firewall-rules-create.tsx b/app/forms/firewall-rules-create.tsx index f936ca4389..9900486f80 100644 --- a/app/forms/firewall-rules-create.tsx +++ b/app/forms/firewall-rules-create.tsx @@ -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)], diff --git a/app/forms/firewall-rules-edit.tsx b/app/forms/firewall-rules-edit.tsx index 2c1086d641..d7e2816602 100644 --- a/app/forms/firewall-rules-edit.tsx +++ b/app/forms/firewall-rules-edit.tsx @@ -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)], diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 6bc8fc8cb0..6b7c02b351 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -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} > diff --git a/app/forms/floating-ip-edit.tsx b/app/forms/floating-ip-edit.tsx index d0120aa6a1..821f532f95 100644 --- a/app/forms/floating-ip-edit.tsx +++ b/app/forms/floating-ip-edit.tsx @@ -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 }, diff --git a/app/forms/image-from-snapshot.tsx b/app/forms/image-from-snapshot.tsx index 14126fc975..d1792ff61c 100644 --- a/app/forms/image-from-snapshot.tsx +++ b/app/forms/image-from-snapshot.tsx @@ -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} > diff --git a/app/forms/ip-pool-create.tsx b/app/forms/ip-pool-create.tsx index f3d633fbc1..f12faf4ca0 100644 --- a/app/forms/ip-pool-create.tsx +++ b/app/forms/ip-pool-create.tsx @@ -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} diff --git a/app/forms/ip-pool-edit.tsx b/app/forms/ip-pool-edit.tsx index befe790dcf..ed91b4e8b6 100644 --- a/app/forms/ip-pool-edit.tsx +++ b/app/forms/ip-pool-edit.tsx @@ -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} diff --git a/app/forms/ip-pool-range-add.tsx b/app/forms/ip-pool-range-add.tsx index c06babb6fa..019912d0ef 100644 --- a/app/forms/ip-pool-range-add.tsx +++ b/app/forms/ip-pool-range-add.tsx @@ -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} > diff --git a/app/forms/network-interface-create.tsx b/app/forms/network-interface-create.tsx index c3e452d322..994d46689c 100644 --- a/app/forms/network-interface-create.tsx +++ b/app/forms/network-interface-create.tsx @@ -28,7 +28,7 @@ const defaultValues: InstanceNetworkInterfaceCreate = { type CreateNetworkInterfaceFormProps = { onDismiss: () => void - onSubmit: (values: InstanceNetworkInterfaceCreate) => void + onSubmit: (values: InstanceNetworkInterfaceCreate) => Promise loading?: boolean submitError?: ApiError | null } diff --git a/app/forms/network-interface-edit.tsx b/app/forms/network-interface-edit.tsx index d274bab439..d0d16bc132 100644 --- a/app/forms/network-interface-edit.tsx +++ b/app/forms/network-interface-edit.tsx @@ -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, diff --git a/app/forms/project-access.tsx b/app/forms/project-access.tsx index 1f02b7c1f0..18d036b1a1 100644 --- a/app/forms/project-access.tsx +++ b/app/forms/project-access.tsx @@ -45,7 +45,7 @@ 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 @@ -53,7 +53,7 @@ export function ProjectAccessAddUserSideModal({ onDismiss, policy }: AddRoleModa // 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), }) @@ -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), }) diff --git a/app/forms/project-create.tsx b/app/forms/project-create.tsx index 9c4b2f1567..e0f4a284df 100644 --- a/app/forms/project-create.tsx +++ b/app/forms/project-create.tsx @@ -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} diff --git a/app/forms/project-edit.tsx b/app/forms/project-edit.tsx index a610c95c0d..175da414c1 100644 --- a/app/forms/project-edit.tsx +++ b/app/forms/project-edit.tsx @@ -59,8 +59,11 @@ export function EditProjectSideModalForm() { formType="edit" resourceName="project" onDismiss={onDismiss} - onSubmit={({ name, description }) => { - editProject.mutate({ path: projectSelector, body: { name, description } }) + onSubmit={async ({ name, description }) => { + await editProject.mutateAsync({ + path: projectSelector, + body: { name, description }, + }) }} loading={editProject.isPending} submitError={editProject.error} diff --git a/app/forms/silo-access.tsx b/app/forms/silo-access.tsx index 1001d9d5ab..8060aba070 100644 --- a/app/forms/silo-access.tsx +++ b/app/forms/silo-access.tsx @@ -44,7 +44,7 @@ export function SiloAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalPr resourceName="role" title="Add user or group" onDismiss={onDismiss} - 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 @@ -53,7 +53,7 @@ export function SiloAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalPr // 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({ body: updateRole({ identityId, identityType, roleName }, policy), }) }} @@ -103,8 +103,8 @@ export function SiloAccessEditUserSideModal({ formType="edit" resourceName="role" title={`Change silo role for ${name}`} - onSubmit={({ roleName }) => { - updatePolicy.mutate({ + onSubmit={async ({ roleName }) => { + await updatePolicy.mutateAsync({ body: updateRole({ identityId, identityType, roleName }, policy), }) }} diff --git a/app/forms/silo-create.tsx b/app/forms/silo-create.tsx index 8e2a42ef07..ee93e4f8f4 100644 --- a/app/forms/silo-create.tsx +++ b/app/forms/silo-create.tsx @@ -72,7 +72,7 @@ export function CreateSiloSideModalForm() { formType="create" resourceName="silo" onDismiss={onDismiss} - onSubmit={({ + onSubmit={async ({ adminGroupName, siloAdminGetsFleetAdmin, siloViewerGetsFleetViewer, @@ -86,7 +86,7 @@ export function CreateSiloSideModalForm() { if (siloViewerGetsFleetViewer) { mappedFleetRoles['viewer'] = ['viewer'] } - createSilo.mutate({ + await createSilo.mutateAsync({ body: { // no point setting it to empty string or whitespace adminGroupName: adminGroupName?.trim() || undefined, diff --git a/app/forms/snapshot-create.tsx b/app/forms/snapshot-create.tsx index 0080251420..d871352a2a 100644 --- a/app/forms/snapshot-create.tsx +++ b/app/forms/snapshot-create.tsx @@ -66,8 +66,8 @@ export function CreateSnapshotSideModalForm() { formType="create" resourceName="snapshot" onDismiss={onDismiss} - onSubmit={(values) => { - createSnapshot.mutate({ query: projectSelector, body: values }) + onSubmit={async (values) => { + await createSnapshot.mutateAsync({ query: projectSelector, body: values }) }} submitError={createSnapshot.error} > diff --git a/app/forms/ssh-key-create.tsx b/app/forms/ssh-key-create.tsx index 54a1e49c73..023b19c3be 100644 --- a/app/forms/ssh-key-create.tsx +++ b/app/forms/ssh-key-create.tsx @@ -50,7 +50,9 @@ export function CreateSSHKeySideModalForm({ onDismiss, message }: Props) { resourceName="SSH key" title="Add SSH key" onDismiss={handleDismiss} - onSubmit={(body) => createSshKey.mutate({ body })} + onSubmit={async (body) => { + await createSshKey.mutateAsync({ body }) + }} loading={createSshKey.isPending} submitError={createSshKey.error} > diff --git a/app/forms/subnet-create.tsx b/app/forms/subnet-create.tsx index 0ae3de8696..6236c1cb94 100644 --- a/app/forms/subnet-create.tsx +++ b/app/forms/subnet-create.tsx @@ -45,7 +45,9 @@ export function CreateSubnetForm() { formType="create" resourceName="subnet" onDismiss={onDismiss} - onSubmit={(body) => createSubnet.mutate({ query: vpcSelector, body })} + onSubmit={async (body) => { + await createSubnet.mutateAsync({ query: vpcSelector, body }) + }} loading={createSubnet.isPending} submitError={createSubnet.error} > diff --git a/app/forms/subnet-edit.tsx b/app/forms/subnet-edit.tsx index 734f0197b4..beffce67d6 100644 --- a/app/forms/subnet-edit.tsx +++ b/app/forms/subnet-edit.tsx @@ -60,8 +60,8 @@ export function EditSubnetForm() { formType="edit" resourceName="subnet" onDismiss={onDismiss} - onSubmit={(body) => { - updateSubnet.mutate({ + onSubmit={async (body) => { + await updateSubnet.mutateAsync({ path: { subnet: subnet.name }, query: { project, vpc }, body, diff --git a/app/forms/vpc-create.tsx b/app/forms/vpc-create.tsx index f464612ab0..2aba284bd6 100644 --- a/app/forms/vpc-create.tsx +++ b/app/forms/vpc-create.tsx @@ -50,7 +50,9 @@ export function CreateVpcSideModalForm() { form={form} formType="create" resourceName="VPC" - onSubmit={(values) => createVpc.mutate({ query: projectSelector, body: values })} + onSubmit={async (values) => { + await createVpc.mutateAsync({ query: projectSelector, body: values }) + }} onDismiss={() => navigate(pb.vpcs(projectSelector))} loading={createVpc.isPending} submitError={createVpc.error} diff --git a/app/forms/vpc-edit.tsx b/app/forms/vpc-edit.tsx index b50a73ab40..2f383ae895 100644 --- a/app/forms/vpc-edit.tsx +++ b/app/forms/vpc-edit.tsx @@ -60,8 +60,8 @@ export function EditVpcSideModalForm() { formType="edit" resourceName="VPC" onDismiss={onDismiss} - onSubmit={({ name, description, dnsName }) => { - editVpc.mutate({ + onSubmit={async ({ name, description, dnsName }) => { + await editVpc.mutateAsync({ path: { vpc: vpcName }, query: { project }, body: { name, description, dnsName }, diff --git a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx index 27fecb8c71..3bbdbcb68e 100644 --- a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx +++ b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx @@ -424,7 +424,9 @@ export function NetworkingTab() { {createModalOpen && ( setCreateModalOpen(false)} - onSubmit={(body) => createNic.mutate({ query: instanceSelector, body })} + onSubmit={async (body) => { + await createNic.mutateAsync({ query: instanceSelector, body }) + }} submitError={createNic.error} /> )} diff --git a/app/pages/project/instances/instance/tabs/StorageTab.tsx b/app/pages/project/instances/instance/tabs/StorageTab.tsx index bf4b1f0503..60cc8375aa 100644 --- a/app/pages/project/instances/instance/tabs/StorageTab.tsx +++ b/app/pages/project/instances/instance/tabs/StorageTab.tsx @@ -219,8 +219,8 @@ export function StorageTab() { {showDiskAttach && ( setShowDiskAttach(false)} - onSubmit={({ name }) => { - attachDisk.mutate({ ...instancePathQuery, body: { disk: name } }) + onSubmit={async ({ name }) => { + await attachDisk.mutateAsync({ ...instancePathQuery, body: { disk: name } }) }} loading={attachDisk.isPending} submitError={attachDisk.error} From 450ee0237f2534d838a0f24cda96ca655a1f63f1 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 16 Jul 2024 16:34:46 -0400 Subject: [PATCH 07/11] add test --- .eslintrc.cjs | 1 + app/components/form/FormNavGuard.tsx | 4 +-- test/e2e/floating-ip-create.e2e.ts | 37 ++++++++++++++++++++++ test/e2e/nav-guard.e2e.ts | 47 ++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 test/e2e/nav-guard.e2e.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index a212a7b04a..0b2bba6a89 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -113,6 +113,7 @@ module.exports = { 'warn', { assertFunctionNames: ['expectVisible', 'expectRowVisible'] }, ], + 'playwright/no-force-option': 'off' }, }, ], diff --git a/app/components/form/FormNavGuard.tsx b/app/components/form/FormNavGuard.tsx index 9b62f1b573..fa717d35d5 100644 --- a/app/components/form/FormNavGuard.tsx +++ b/app/components/form/FormNavGuard.tsx @@ -47,8 +47,8 @@ export function FormNavGuard({ blocker.reset?.()} onAction={() => blocker.proceed?.()} - cancelText="Continue editing" - actionText="Leave this form" + cancelText="Keep editing" + actionText="Leave form" actionType="danger" /> diff --git a/test/e2e/floating-ip-create.e2e.ts b/test/e2e/floating-ip-create.e2e.ts index 62ba9d2d8c..3a0bd3db7a 100644 --- a/test/e2e/floating-ip-create.e2e.ts +++ b/test/e2e/floating-ip-create.e2e.ts @@ -96,3 +96,40 @@ test('can detach and attach a floating IP', async ({ page }) => { 'Attached to instance': 'db1', }) }) + +test('navigating away triggers nav guard', async ({ page }) => { + const floatingIpsPage = '/projects/mock-project/floating-ips' + const floatingIpName = 'my-floating-ip' + const formModal = page.getByRole('dialog', { name: 'Create floating IP' }) + const confirmModal = page.getByRole('dialog', { name: 'Confirm navigation' }) + + await page.goto(floatingIpsPage) + await page.locator('text="New Floating IP"').click() + + await expectVisible(page, [ + 'role=heading[name*="Create floating IP"]', + 'role=textbox[name="Name"]', + 'role=textbox[name="Description"]', + 'role=button[name="Advanced"]', + 'role=button[name="Create floating IP"]', + ]) + + await page.fill('input[name=name]', floatingIpName) + + // form is now dirty, so clicking away should trigger the nav guard + await page.getByRole('link', { name: 'Instances' }).click() + await expect(confirmModal).toBeVisible() + + // go back to the form + await page.getByRole('button', { name: 'Keep editing' }).click() + await expect(confirmModal).toBeHidden() + await expect(formModal).toBeVisible() + + // now try to navigate away again + await page.keyboard.press('Escape') + await expect(confirmModal).toBeVisible() + await page.getByRole('button', { name: 'Leave form' }).click() + await expect(confirmModal).toBeHidden() + await expect(formModal).toBeHidden() + await expect(page).toHaveURL(floatingIpsPage) +}) diff --git a/test/e2e/nav-guard.e2e.ts b/test/e2e/nav-guard.e2e.ts new file mode 100644 index 0000000000..0c1e73ff4a --- /dev/null +++ b/test/e2e/nav-guard.e2e.ts @@ -0,0 +1,47 @@ +/* + * 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 { expect, expectVisible, test } from './utils' + +test('navigating away from SideModal form triggers nav guard', async ({ page }) => { + const floatingIpsPage = '/projects/mock-project/floating-ips' + const floatingIpName = 'my-floating-ip' + const formModal = page.getByRole('dialog', { name: 'Create floating IP' }) + const confirmModal = page.getByRole('dialog', { name: 'Confirm navigation' }) + + await page.goto(floatingIpsPage) + await page.locator('text="New Floating IP"').click() + + await expectVisible(page, [ + 'role=heading[name*="Create floating IP"]', + 'role=textbox[name="Name"]', + 'role=textbox[name="Description"]', + 'role=button[name="Advanced"]', + 'role=button[name="Create floating IP"]', + ]) + + await page.fill('input[name=name]', floatingIpName) + + // form is now dirty, so clicking away should trigger the nav guard + // await page.keyboard.press('Escape') + await page.getByRole('link', { name: 'Instances' }).click({ force: true }) + await expect(confirmModal).toBeVisible() + + // go back to the form + await page.getByRole('button', { name: 'Keep editing' }).click() + await expect(confirmModal).toBeHidden() + await expect(formModal).toBeVisible() + + // now try to navigate away again + await page.keyboard.press('Escape') + await expect(confirmModal).toBeVisible() + await page.getByRole('button', { name: 'Leave form' }).click() + await expect(confirmModal).toBeHidden() + await expect(formModal).toBeHidden() + await expect(page).toHaveURL(floatingIpsPage) +}) From 546636bc593b46a20189f729008a5fc81506bb59 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 16 Jul 2024 20:37:31 +0000 Subject: [PATCH 08/11] Bot commit: format with prettier --- .eslintrc.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 0b2bba6a89..8df277691e 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -113,7 +113,7 @@ module.exports = { 'warn', { assertFunctionNames: ['expectVisible', 'expectRowVisible'] }, ], - 'playwright/no-force-option': 'off' + 'playwright/no-force-option': 'off', }, }, ], From 86e9bd3efafbce67c580693437ecdc1d97aadd65 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 16 Jul 2024 16:38:58 -0400 Subject: [PATCH 09/11] clean up test --- test/e2e/nav-guard.e2e.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/e2e/nav-guard.e2e.ts b/test/e2e/nav-guard.e2e.ts index 0c1e73ff4a..4e27dbe52b 100644 --- a/test/e2e/nav-guard.e2e.ts +++ b/test/e2e/nav-guard.e2e.ts @@ -28,7 +28,7 @@ test('navigating away from SideModal form triggers nav guard', async ({ page }) await page.fill('input[name=name]', floatingIpName) // form is now dirty, so clicking away should trigger the nav guard - // await page.keyboard.press('Escape') + // force: true allows us to click even though the "Instances" link is inactive await page.getByRole('link', { name: 'Instances' }).click({ force: true }) await expect(confirmModal).toBeVisible() @@ -37,7 +37,7 @@ test('navigating away from SideModal form triggers nav guard', async ({ page }) await expect(confirmModal).toBeHidden() await expect(formModal).toBeVisible() - // now try to navigate away again + // now try to navigate away again; verify that clicking the Escape key also triggers it await page.keyboard.press('Escape') await expect(confirmModal).toBeVisible() await page.getByRole('button', { name: 'Leave form' }).click() From 9efa3dc7c5f45010e92b283690af5c9a119a8f6e Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 16 Jul 2024 17:03:41 -0400 Subject: [PATCH 10/11] add important comment about async/await --- app/components/form/SideModalForm.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/components/form/SideModalForm.tsx b/app/components/form/SideModalForm.tsx index f19d47861d..8ce8eb5b7d 100644 --- a/app/components/form/SideModalForm.tsx +++ b/app/components/form/SideModalForm.tsx @@ -112,6 +112,9 @@ export function SideModalForm({ // SideModalForm from inside another form, in which case submitting // the inner form submits the outer form unless we stop propagation e.stopPropagation() + // 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) }} > From f6222cf0f4db68ea796fbb31a009a0f3667ef80e Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 16 Jul 2024 17:20:37 -0400 Subject: [PATCH 11/11] Remove test from unrelated suite --- test/e2e/floating-ip-create.e2e.ts | 37 ------------------------------ 1 file changed, 37 deletions(-) diff --git a/test/e2e/floating-ip-create.e2e.ts b/test/e2e/floating-ip-create.e2e.ts index 3a0bd3db7a..62ba9d2d8c 100644 --- a/test/e2e/floating-ip-create.e2e.ts +++ b/test/e2e/floating-ip-create.e2e.ts @@ -96,40 +96,3 @@ test('can detach and attach a floating IP', async ({ page }) => { 'Attached to instance': 'db1', }) }) - -test('navigating away triggers nav guard', async ({ page }) => { - const floatingIpsPage = '/projects/mock-project/floating-ips' - const floatingIpName = 'my-floating-ip' - const formModal = page.getByRole('dialog', { name: 'Create floating IP' }) - const confirmModal = page.getByRole('dialog', { name: 'Confirm navigation' }) - - await page.goto(floatingIpsPage) - await page.locator('text="New Floating IP"').click() - - await expectVisible(page, [ - 'role=heading[name*="Create floating IP"]', - 'role=textbox[name="Name"]', - 'role=textbox[name="Description"]', - 'role=button[name="Advanced"]', - 'role=button[name="Create floating IP"]', - ]) - - await page.fill('input[name=name]', floatingIpName) - - // form is now dirty, so clicking away should trigger the nav guard - await page.getByRole('link', { name: 'Instances' }).click() - await expect(confirmModal).toBeVisible() - - // go back to the form - await page.getByRole('button', { name: 'Keep editing' }).click() - await expect(confirmModal).toBeHidden() - await expect(formModal).toBeVisible() - - // now try to navigate away again - await page.keyboard.press('Escape') - await expect(confirmModal).toBeVisible() - await page.getByRole('button', { name: 'Leave form' }).click() - await expect(confirmModal).toBeHidden() - await expect(formModal).toBeHidden() - await expect(page).toHaveURL(floatingIpsPage) -})