From 577be18db009f25169694e7f43f74f79479da232 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 17 Jul 2024 10:38:10 -0400 Subject: [PATCH 1/5] Simpler nav guard for SideModalForm --- app/components/form/NavGuardModal.tsx | 31 +++++++++++++++++++++++++++ app/components/form/SideModalForm.tsx | 15 ++++++++++--- 2 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 app/components/form/NavGuardModal.tsx diff --git a/app/components/form/NavGuardModal.tsx b/app/components/form/NavGuardModal.tsx new file mode 100644 index 0000000000..5012b4af3f --- /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 e0e02b7cbe..bc5e814d37 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 [showNavGuard, setShowNavGuard] = useState(false) + const guardedDismiss = () => + form.formState.isDirty ? setShowNavGuard(true) : onDismiss() + return ( ({ - {onSubmit && ( @@ -134,6 +140,9 @@ export function SideModalForm({ )} + {showNavGuard && ( + setShowNavGuard(false)} onAction={onDismiss} /> + )} ) } From cb5486a86ef066f5b28702cb58d0ea1262322d3a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 17 Jul 2024 10:56:02 -0400 Subject: [PATCH 2/5] fix issue with isDirty evaluation --- app/components/form/SideModalForm.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/form/SideModalForm.tsx b/app/components/form/SideModalForm.tsx index bc5e814d37..84b699da4a 100644 --- a/app/components/form/SideModalForm.tsx +++ b/app/components/form/SideModalForm.tsx @@ -91,9 +91,9 @@ export function SideModalForm({ ? `Update ${resourceName}` : submitLabel || title || `Create ${resourceName}` + const { isDirty } = form.formState const [showNavGuard, setShowNavGuard] = useState(false) - const guardedDismiss = () => - form.formState.isDirty ? setShowNavGuard(true) : onDismiss() + const guardedDismiss = () => (isDirty ? setShowNavGuard(true) : onDismiss()) return ( Date: Wed, 17 Jul 2024 10:57:21 -0400 Subject: [PATCH 3/5] Don't trigger guard when clicking normal cancel button on form --- app/components/form/SideModalForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/form/SideModalForm.tsx b/app/components/form/SideModalForm.tsx index 84b699da4a..07c0d66920 100644 --- a/app/components/form/SideModalForm.tsx +++ b/app/components/form/SideModalForm.tsx @@ -124,7 +124,7 @@ export function SideModalForm({ - {onSubmit && ( From 5e3401542b6a83e5c83a2cc9865de362512ea8ed Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 17 Jul 2024 11:14:45 -0400 Subject: [PATCH 4/5] Add test --- .eslintrc.cjs | 1 + test/e2e/nav-guard-modal.e2e.ts | 47 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 test/e2e/nav-guard-modal.e2e.ts 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/test/e2e/nav-guard-modal.e2e.ts b/test/e2e/nav-guard-modal.e2e.ts new file mode 100644 index 0000000000..4e27dbe52b --- /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) +}) From a90c0056c2356fa2879aadd9e1550e450c27deb5 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 14 Nov 2024 13:31:29 -0800 Subject: [PATCH 5/5] a little cleanup --- app/components/form/NavGuardModal.tsx | 9 ++------- app/components/form/SideModalForm.tsx | 3 +-- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/app/components/form/NavGuardModal.tsx b/app/components/form/NavGuardModal.tsx index 5012b4af3f..b94b29c160 100644 --- a/app/components/form/NavGuardModal.tsx +++ b/app/components/form/NavGuardModal.tsx @@ -7,13 +7,8 @@ */ import { Modal } from '~/ui/lib/Modal' -const NavGuardModal = ({ - onAction, - onDismiss, -}: { - onAction: () => void - onDismiss: () => void -}) => ( +type NavGuardModalProps = { onAction: () => void; onDismiss: () => void } +const NavGuardModal = ({ onAction, onDismiss }: NavGuardModalProps) => ( Are you sure you want to leave this form? Your progress will be lost. diff --git a/app/components/form/SideModalForm.tsx b/app/components/form/SideModalForm.tsx index 4a83bf45e4..a089273838 100644 --- a/app/components/form/SideModalForm.tsx +++ b/app/components/form/SideModalForm.tsx @@ -11,11 +11,10 @@ import { NavigationType, useNavigationType } from 'react-router-dom' import type { ApiError } from '@oxide/api' +import { NavGuardModal } from '~/components/form/NavGuardModal' 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}`) */