Skip to content

Commit 6fe6936

Browse files
Simpler nav guard for SideModalForm (#2328)
* Simpler nav guard for SideModalForm * fix issue with isDirty evaluation * Don't trigger guard when clicking normal cancel button on form * Add test * a little cleanup * inline some stuff * inline the entire nav guard modal * tweak the test. we love to test * make confirm nav on side modal and full page forms a little narrower * fix double overlay by turning off overlay for side modal confirm only --------- Co-authored-by: David Crespo <david.crespo@oxidecomputer.com>
1 parent fcd3441 commit 6fe6936

File tree

5 files changed

+108
-15
lines changed

5 files changed

+108
-15
lines changed

.eslintrc.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ module.exports = {
118118
'warn',
119119
{ assertFunctionNames: ['expectVisible', 'expectRowVisible', 'expectOptions'] },
120120
],
121+
'playwright/no-force-option': 'off',
121122
},
122123
},
123124
],

app/components/form/FullPageForm.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export function FullPageForm<TFieldValues extends FieldValues>({
101101
</form>
102102

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

@@ -126,10 +126,12 @@ const ConfirmNavigation = ({ blocker }: { blocker: Blocker }) => (
126126
isOpen={blocker.state === 'blocked'}
127127
onDismiss={() => blocker.reset?.()}
128128
title="Confirm navigation"
129+
narrow
129130
>
130131
<Modal.Section>
131-
Are you sure you want to leave this page? <br /> You will lose all progress on this
132-
form.
132+
Are you sure you want to leave this page?
133+
<br />
134+
All progress will be lost.
133135
</Modal.Section>
134136
<Modal.Footer
135137
onDismiss={() => blocker.reset?.()}

app/components/form/SideModalForm.tsx

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8-
import { useEffect, useId, type ReactNode } from 'react'
8+
import { useEffect, useId, useState, type ReactNode } from 'react'
99
import type { FieldValues, UseFormReturn } from 'react-hook-form'
1010
import { NavigationType, useNavigationType } from 'react-router-dom'
1111

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

1414
import { Button } from '~/ui/lib/Button'
15+
import { Modal } from '~/ui/lib/Modal'
1516
import { SideModal } from '~/ui/lib/SideModal'
1617

1718
type CreateFormProps = {
@@ -80,7 +81,6 @@ export function SideModalForm<TFieldValues extends FieldValues>({
8081
subtitle,
8182
}: SideModalFormProps<TFieldValues>) {
8283
const id = useId()
83-
const { isSubmitting } = form.formState
8484

8585
useEffect(() => {
8686
if (submitError?.errorCode === 'ObjectAlreadyExists' && 'name' in form.getValues()) {
@@ -94,9 +94,14 @@ export function SideModalForm<TFieldValues extends FieldValues>({
9494
? `Update ${resourceName}`
9595
: submitLabel || title || `Create ${resourceName}`
9696

97+
// must be destructured up here to subscribe to changes. inlining
98+
// form.formState.isDirty does not work
99+
const { isDirty, isSubmitting } = form.formState
100+
const [showNavGuard, setShowNavGuard] = useState(false)
101+
97102
return (
98103
<SideModal
99-
onDismiss={onDismiss}
104+
onDismiss={() => (isDirty ? setShowNavGuard(true) : onDismiss())}
100105
isOpen
101106
title={title || `${formType === 'edit' ? 'Edit' : 'Create'} ${resourceName}`}
102107
animate={useShouldAnimateModal()}
@@ -139,6 +144,29 @@ export function SideModalForm<TFieldValues extends FieldValues>({
139144
</Button>
140145
)}
141146
</SideModal.Footer>
147+
148+
{showNavGuard && (
149+
<Modal
150+
isOpen
151+
onDismiss={() => setShowNavGuard(false)}
152+
title="Confirm navigation"
153+
narrow
154+
overlay={false}
155+
>
156+
<Modal.Section>
157+
Are you sure you want to leave this form?
158+
<br />
159+
All progress will be lost.
160+
</Modal.Section>
161+
<Modal.Footer
162+
onAction={onDismiss}
163+
onDismiss={() => setShowNavGuard(false)}
164+
cancelText="Keep editing"
165+
actionText="Leave form"
166+
actionType="danger"
167+
/>
168+
</Modal>
169+
)}
142170
</SideModal>
143171
)
144172
}

app/ui/lib/Modal.tsx

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88
import * as Dialog from '@radix-ui/react-dialog'
99
import { animated, useTransition } from '@react-spring/web'
10+
import cn from 'classnames'
1011
import React, { forwardRef, useId } from 'react'
1112

1213
import { Close12Icon } from '@oxide/design-system/icons/react'
@@ -18,17 +19,28 @@ import { DialogOverlay } from './DialogOverlay'
1819
import { ModalContext } from './modal-context'
1920

2021
export type ModalProps = {
21-
title?: string
22+
title: string
2223
isOpen: boolean
2324
children?: React.ReactNode
2425
onDismiss: () => void
26+
/** Default false. Only needed in a couple of spots. */
27+
narrow?: true
28+
/** Default true. We only need to hide it for the rare case of modal on top of modal. */
29+
overlay?: boolean
2530
}
2631

2732
// Note that the overlay has z-index 30 and content has 40. This is to make sure
2833
// both land on top of a side modal in the regrettable case where we have both
2934
// on screen at once.
3035

31-
export function Modal({ children, onDismiss, title, isOpen }: ModalProps) {
36+
export function Modal({
37+
children,
38+
onDismiss,
39+
title,
40+
isOpen,
41+
narrow,
42+
overlay = true,
43+
}: ModalProps) {
3244
const titleId = useId()
3345
const AnimatedDialogContent = animated(Dialog.Content)
3446

@@ -54,9 +66,13 @@ export function Modal({ children, onDismiss, title, isOpen }: ModalProps) {
5466
modal={false}
5567
>
5668
<Dialog.Portal>
57-
<DialogOverlay />
69+
{overlay && <DialogOverlay />}
70+
5871
<AnimatedDialogContent
59-
className="pointer-events-auto fixed left-1/2 top-1/2 z-modal m-0 flex max-h-[min(800px,80vh)] w-auto min-w-[28rem] max-w-[32rem] flex-col justify-between rounded-lg border p-0 bg-raise border-secondary elevation-2"
72+
className={cn(
73+
'pointer-events-auto fixed left-1/2 top-1/2 z-modal m-0 flex max-h-[min(800px,80vh)] w-auto min-w-[24rem] flex-col justify-between rounded-lg border p-0 bg-raise border-secondary elevation-2',
74+
narrow ? 'max-w-[24rem]' : 'max-w-[32rem]'
75+
)}
6076
aria-labelledby={titleId}
6177
style={{
6278
transform: y.to((value) => `translate3d(-50%, ${-50 + value}%, 0px)`),
@@ -68,11 +84,9 @@ export function Modal({ children, onDismiss, title, isOpen }: ModalProps) {
6884
// https://github.com/oxidecomputer/console/issues/1745
6985
onFocusOutside={(e) => e.preventDefault()}
7086
>
71-
{title && (
72-
<Dialog.Title asChild>
73-
<ModalTitle id={titleId}>{title}</ModalTitle>
74-
</Dialog.Title>
75-
)}
87+
<Dialog.Title asChild>
88+
<ModalTitle id={titleId}>{title}</ModalTitle>
89+
</Dialog.Title>
7690
{children}
7791
<Dialog.Close
7892
className="absolute right-2 top-3 flex rounded p-2 hover:bg-hover"

test/e2e/nav-guard-modal.e2e.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
9+
import { expect, expectObscured, test } from './utils'
10+
11+
test('navigating away from SideModal form triggers nav guard', async ({ page }) => {
12+
const floatingIpsPage = '/projects/mock-project/floating-ips'
13+
const formModal = page.getByRole('dialog', { name: 'Create floating IP' })
14+
const confirmModal = page.getByRole('dialog', { name: 'Confirm navigation' })
15+
16+
await page.goto(floatingIpsPage)
17+
18+
// we don't have to force click here because it's not covered by the modal overlay yet
19+
await expect(formModal).toBeHidden()
20+
const somethingOnPage = page.getByRole('heading', { name: 'Floating IPs' })
21+
await somethingOnPage.click({ trial: true }) // test that it's not obscured
22+
23+
// now open the modal
24+
await page.getByRole('link', { name: 'New Floating IP' }).click()
25+
await expectObscured(somethingOnPage) // it's covered by overlay
26+
await expect(formModal).toBeVisible()
27+
await formModal.getByRole('textbox', { name: 'Name' }).fill('my-floating-ip')
28+
29+
// form is now dirty, so clicking away should trigger the nav guard
30+
// force: true allows us to click in that spot even though the thing is obscured
31+
await expect(confirmModal).toBeHidden()
32+
await somethingOnPage.click({ force: true })
33+
await expect(formModal).toBeVisible()
34+
await expect(confirmModal).toBeVisible()
35+
36+
// go back to the form
37+
await page.getByRole('button', { name: 'Keep editing' }).click()
38+
await expect(confirmModal).toBeHidden()
39+
await expect(formModal).toBeVisible()
40+
41+
// now try to navigate away again; verify that clicking the Escape key also triggers it
42+
await page.keyboard.press('Escape')
43+
await expect(confirmModal).toBeVisible()
44+
await page.getByRole('button', { name: 'Leave form' }).click()
45+
await expect(confirmModal).toBeHidden()
46+
await expect(formModal).toBeHidden()
47+
await expect(page).toHaveURL(floatingIpsPage)
48+
})

0 commit comments

Comments
 (0)