diff --git a/.eslintrc.cjs b/.eslintrc.cjs index d08459e1a..3b8789c48 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -114,6 +114,7 @@ module.exports = { 'warn', { assertFunctionNames: ['expectVisible', 'expectRowVisible'] }, ], + 'playwright/no-force-option': 'off', }, }, ], diff --git a/app/components/form/NavGuardModal.tsx b/app/components/form/NavGuardModal.tsx new file mode 100644 index 000000000..5012b4af3 --- /dev/null +++ b/app/components/form/NavGuardModal.tsx @@ -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 +}) => ( + + + Are you sure you want to leave this form? Your progress will be lost. + + + +) + +export { NavGuardModal } diff --git a/app/components/form/SideModalForm.tsx b/app/components/form/SideModalForm.tsx index e0e02b7cb..07c0d6692 100644 --- a/app/components/form/SideModalForm.tsx +++ b/app/components/form/SideModalForm.tsx @@ -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' @@ -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}`) */ @@ -89,9 +91,13 @@ export function SideModalForm({ ? `Update ${resourceName}` : submitLabel || title || `Create ${resourceName}` + const { isDirty } = form.formState + const [showNavGuard, setShowNavGuard] = useState(false) + const guardedDismiss = () => (isDirty ? setShowNavGuard(true) : onDismiss()) + return ( ({ )} + {showNavGuard && ( + setShowNavGuard(false)} onAction={onDismiss} /> + )} ) } diff --git a/test/e2e/nav-guard-modal.e2e.ts b/test/e2e/nav-guard-modal.e2e.ts new file mode 100644 index 000000000..4e27dbe52 --- /dev/null +++ b/test/e2e/nav-guard-modal.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) +})