Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simpler nav guard for SideModalForm #2328

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
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 @@ -114,6 +114,7 @@ module.exports = {
'warn',
{ assertFunctionNames: ['expectVisible', 'expectRowVisible'] },
],
'playwright/no-force-option': 'off',
},
},
],
Expand Down
31 changes: 31 additions & 0 deletions app/components/form/NavGuardModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* 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 { Modal } from '~/ui/lib/Modal'

const NavGuardModal = ({
onAction,
onDismiss,
}: {
onAction: () => void
onDismiss: () => void
}) => (
<Modal isOpen onDismiss={onDismiss} title="Confirm navigation">
<Modal.Section>
Are you sure you want to leave this form? Your progress will be lost.
</Modal.Section>
<Modal.Footer
onAction={onAction}
onDismiss={onDismiss}
cancelText="Keep editing"
actionText="Leave form"
actionType="danger"
/>
</Modal>
)

export { NavGuardModal }
13 changes: 11 additions & 2 deletions app/components/form/SideModalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*
* Copyright Oxide Computer Company
*/
import { useEffect, useId, type ReactNode } from 'react'
import { useEffect, useId, useState, type ReactNode } from 'react'
import type { FieldValues, UseFormReturn } from 'react-hook-form'
import { NavigationType, useNavigationType } from 'react-router-dom'

Expand All @@ -14,6 +14,8 @@ import type { ApiError } from '@oxide/api'
import { Button } from '~/ui/lib/Button'
import { SideModal } from '~/ui/lib/SideModal'

import { NavGuardModal } from './NavGuardModal'

type CreateFormProps = {
formType: 'create'
/** Only needed if you need to override the default button text (`Create ${resourceName}`) */
Expand Down Expand Up @@ -89,9 +91,13 @@ export function SideModalForm<TFieldValues extends FieldValues>({
? `Update ${resourceName}`
: submitLabel || title || `Create ${resourceName}`

const { isDirty } = form.formState
const [showNavGuard, setShowNavGuard] = useState(false)
const guardedDismiss = () => (isDirty ? setShowNavGuard(true) : onDismiss())

return (
<SideModal
onDismiss={onDismiss}
onDismiss={guardedDismiss}
isOpen
title={title || `${formType === 'edit' ? 'Edit' : 'Create'} ${resourceName}`}
animate={useShouldAnimateModal()}
Expand Down Expand Up @@ -134,6 +140,9 @@ export function SideModalForm<TFieldValues extends FieldValues>({
</Button>
)}
</SideModal.Footer>
{showNavGuard && (
<NavGuardModal onDismiss={() => setShowNavGuard(false)} onAction={onDismiss} />
)}
</SideModal>
)
}
47 changes: 47 additions & 0 deletions test/e2e/nav-guard-modal.e2e.ts
Original file line number Diff line number Diff line change
@@ -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)
})
Loading