Skip to content
Merged
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 @@ -118,6 +118,7 @@ module.exports = {
'warn',
{ assertFunctionNames: ['expectVisible', 'expectRowVisible', 'expectOptions'] },
],
'playwright/no-force-option': 'off',
},
},
],
Expand Down
8 changes: 5 additions & 3 deletions app/components/form/FullPageForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export function FullPageForm<TFieldValues extends FieldValues>({
</form>

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

Expand All @@ -126,10 +126,12 @@ const ConfirmNavigation = ({ blocker }: { blocker: Blocker }) => (
isOpen={blocker.state === 'blocked'}
onDismiss={() => blocker.reset?.()}
title="Confirm navigation"
narrow
>
<Modal.Section>
Are you sure you want to leave this page? <br /> You will lose all progress on this
form.
Are you sure you want to leave this page?
<br />
All progress will be lost.
</Modal.Section>
<Modal.Footer
onDismiss={() => blocker.reset?.()}
Expand Down
34 changes: 31 additions & 3 deletions app/components/form/SideModalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
*
* 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'

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

import { Button } from '~/ui/lib/Button'
import { Modal } from '~/ui/lib/Modal'
import { SideModal } from '~/ui/lib/SideModal'

type CreateFormProps = {
Expand Down Expand Up @@ -80,7 +81,6 @@ export function SideModalForm<TFieldValues extends FieldValues>({
subtitle,
}: SideModalFormProps<TFieldValues>) {
const id = useId()
const { isSubmitting } = form.formState

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

// must be destructured up here to subscribe to changes. inlining
// form.formState.isDirty does not work
const { isDirty, isSubmitting } = form.formState
const [showNavGuard, setShowNavGuard] = useState(false)

return (
<SideModal
onDismiss={onDismiss}
onDismiss={() => (isDirty ? setShowNavGuard(true) : onDismiss())}
isOpen
title={title || `${formType === 'edit' ? 'Edit' : 'Create'} ${resourceName}`}
animate={useShouldAnimateModal()}
Expand Down Expand Up @@ -139,6 +144,29 @@ export function SideModalForm<TFieldValues extends FieldValues>({
</Button>
)}
</SideModal.Footer>

{showNavGuard && (
<Modal
isOpen
onDismiss={() => setShowNavGuard(false)}
title="Confirm navigation"
narrow
overlay={false}
>
<Modal.Section>
Are you sure you want to leave this form?
<br />
All progress will be lost.
</Modal.Section>
<Modal.Footer
onAction={onDismiss}
onDismiss={() => setShowNavGuard(false)}
cancelText="Keep editing"
actionText="Leave form"
actionType="danger"
/>
</Modal>
)}
</SideModal>
)
}
32 changes: 23 additions & 9 deletions app/ui/lib/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/
import * as Dialog from '@radix-ui/react-dialog'
import { animated, useTransition } from '@react-spring/web'
import cn from 'classnames'
import React, { forwardRef, useId } from 'react'

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

export type ModalProps = {
title?: string
title: string
isOpen: boolean
children?: React.ReactNode
onDismiss: () => void
/** Default false. Only needed in a couple of spots. */
narrow?: true
/** Default true. We only need to hide it for the rare case of modal on top of modal. */
overlay?: boolean
}

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

export function Modal({ children, onDismiss, title, isOpen }: ModalProps) {
export function Modal({
children,
onDismiss,
title,
isOpen,
narrow,
overlay = true,
}: ModalProps) {
const titleId = useId()
const AnimatedDialogContent = animated(Dialog.Content)

Expand All @@ -54,9 +66,13 @@ export function Modal({ children, onDismiss, title, isOpen }: ModalProps) {
modal={false}
>
<Dialog.Portal>
<DialogOverlay />
{overlay && <DialogOverlay />}

<AnimatedDialogContent
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"
className={cn(
'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',
narrow ? 'max-w-[24rem]' : 'max-w-[32rem]'
)}
aria-labelledby={titleId}
style={{
transform: y.to((value) => `translate3d(-50%, ${-50 + value}%, 0px)`),
Expand All @@ -68,11 +84,9 @@ export function Modal({ children, onDismiss, title, isOpen }: ModalProps) {
// https://github.com/oxidecomputer/console/issues/1745
onFocusOutside={(e) => e.preventDefault()}
>
{title && (
<Dialog.Title asChild>
<ModalTitle id={titleId}>{title}</ModalTitle>
</Dialog.Title>
)}
<Dialog.Title asChild>
<ModalTitle id={titleId}>{title}</ModalTitle>
</Dialog.Title>
{children}
<Dialog.Close
className="absolute right-2 top-3 flex rounded p-2 hover:bg-hover"
Expand Down
48 changes: 48 additions & 0 deletions test/e2e/nav-guard-modal.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* 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, expectObscured, test } from './utils'

test('navigating away from SideModal form triggers nav guard', async ({ page }) => {
const floatingIpsPage = '/projects/mock-project/floating-ips'
const formModal = page.getByRole('dialog', { name: 'Create floating IP' })
const confirmModal = page.getByRole('dialog', { name: 'Confirm navigation' })

await page.goto(floatingIpsPage)

// we don't have to force click here because it's not covered by the modal overlay yet
await expect(formModal).toBeHidden()
const somethingOnPage = page.getByRole('heading', { name: 'Floating IPs' })
await somethingOnPage.click({ trial: true }) // test that it's not obscured

// now open the modal
await page.getByRole('link', { name: 'New Floating IP' }).click()
await expectObscured(somethingOnPage) // it's covered by overlay
await expect(formModal).toBeVisible()
await formModal.getByRole('textbox', { name: 'Name' }).fill('my-floating-ip')

// form is now dirty, so clicking away should trigger the nav guard
// force: true allows us to click in that spot even though the thing is obscured
await expect(confirmModal).toBeHidden()
await somethingOnPage.click({ force: true })
await expect(formModal).toBeVisible()
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)
})