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)
+})