diff --git a/.eslintrc.cjs b/.eslintrc.cjs index a212a7b04a..8df277691e 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 new file mode 100644 index 0000000000..fa717d35d5 --- /dev/null +++ b/app/components/form/FormNavGuard.tsx @@ -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({ + 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]) + + return isSubmitting || isSubmitSuccessful ? null : ( + 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="Keep editing" + actionText="Leave form" + actionType="danger" + /> + + ) +} diff --git a/app/components/form/FullPageForm.tsx b/app/components/form/FullPageForm.tsx index 4ca6f79b91..2ce9468941 100644 --- a/app/components/form/FullPageForm.tsx +++ b/app/components/form/FullPageForm.tsx @@ -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' @@ -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 && ( @@ -120,23 +96,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..8ce8eb5b7d 100644 --- a/app/components/form/SideModalForm.tsx +++ b/app/components/form/SideModalForm.tsx @@ -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' @@ -47,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) /** @@ -103,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 @@ -111,10 +112,14 @@ 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) + // 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} + 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 92bff170d6..2aba284bd6 100644 --- a/app/forms/vpc-create.tsx +++ b/app/forms/vpc-create.tsx @@ -21,6 +21,7 @@ const defaultValues: VpcCreate = { name: '', description: '', dnsName: '', + ipv6Prefix: undefined, } export function CreateVpcSideModalForm() { @@ -49,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} diff --git a/test/e2e/nav-guard.e2e.ts b/test/e2e/nav-guard.e2e.ts new file mode 100644 index 0000000000..4e27dbe52b --- /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 + // 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() + + // 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; 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() + await expect(confirmModal).toBeHidden() + await expect(formModal).toBeHidden() + await expect(page).toHaveURL(floatingIpsPage) +})