Skip to content

Commit c1d017a

Browse files
feat(ui): ask before closing doc drawer with edits (#14324)
Fixes #13947 This change will present the leave without saving modal when a user has edits on a document in a drawer and then attempts to close the drawer.
1 parent 1f166ba commit c1d017a

File tree

6 files changed

+148
-40
lines changed

6 files changed

+148
-40
lines changed

packages/ui/src/elements/DocumentDrawer/DrawerHeader/index.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,38 @@
11
'use client'
22

3+
import { useCallback } from 'react'
4+
35
import { Gutter } from '../../../elements/Gutter/index.js'
46
import { useModal } from '../../../elements/Modal/index.js'
57
import { RenderTitle } from '../../../elements/RenderTitle/index.js'
8+
import { useFormModified } from '../../../forms/Form/index.js'
69
import { XIcon } from '../../../icons/X/index.js'
710
import { useDocumentInfo } from '../../../providers/DocumentInfo/index.js'
811
import { useDocumentTitle } from '../../../providers/DocumentTitle/index.js'
912
import { useTranslation } from '../../../providers/Translation/index.js'
1013
import { IDLabel } from '../../IDLabel/index.js'
14+
import { LeaveWithoutSavingModal } from '../../LeaveWithoutSaving/index.js'
1115
import { documentDrawerBaseClass } from '../index.js'
1216
import './index.scss'
1317

18+
const leaveWithoutSavingModalSlug = 'leave-without-saving-doc-drawer'
19+
1420
export const DocumentDrawerHeader: React.FC<{
1521
AfterHeader?: React.ReactNode
1622
drawerSlug: string
1723
showDocumentID?: boolean
1824
}> = ({ AfterHeader, drawerSlug, showDocumentID = true }) => {
19-
const { closeModal } = useModal()
25+
const { closeModal, openModal } = useModal()
2026
const { t } = useTranslation()
27+
const isModified = useFormModified()
28+
29+
const handleOnClose = useCallback(() => {
30+
if (isModified) {
31+
openModal(leaveWithoutSavingModalSlug)
32+
} else {
33+
closeModal(drawerSlug)
34+
}
35+
}, [isModified, openModal, closeModal, drawerSlug])
2136

2237
return (
2338
<Gutter className={`${documentDrawerBaseClass}__header`}>
@@ -28,7 +43,7 @@ export const DocumentDrawerHeader: React.FC<{
2843
<button
2944
aria-label={t('general:close')}
3045
className={`${documentDrawerBaseClass}__header-close`}
31-
onClick={() => closeModal(drawerSlug)}
46+
onClick={handleOnClose}
3247
type="button"
3348
>
3449
<XIcon />
@@ -38,6 +53,11 @@ export const DocumentDrawerHeader: React.FC<{
3853
{AfterHeader ? (
3954
<div className={`${documentDrawerBaseClass}__after-header`}>{AfterHeader}</div>
4055
) : null}
56+
57+
<LeaveWithoutSavingModal
58+
modalSlug={leaveWithoutSavingModalSlug}
59+
onConfirm={() => closeModal(drawerSlug)}
60+
/>
4161
</Gutter>
4262
)
4363
}

packages/ui/src/elements/LeaveWithoutSaving/index.tsx

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,40 +10,46 @@ import { ConfirmationModal } from '../ConfirmationModal/index.js'
1010
import { useModal } from '../Modal/index.js'
1111
import { usePreventLeave } from './usePreventLeave.js'
1212

13-
const modalSlug = 'leave-without-saving'
14-
1513
type LeaveWithoutSavingProps = {
14+
disablePreventLeave?: boolean
15+
modalSlug?: string
1616
onConfirm?: () => Promise<void> | void
1717
onPrevent?: (nextHref: null | string) => void
1818
}
1919

20-
export const LeaveWithoutSaving: React.FC<LeaveWithoutSavingProps> = ({ onConfirm, onPrevent }) => {
20+
const leaveWithoutSavingModalSlug = 'leave-without-saving'
21+
22+
export const LeaveWithoutSaving: React.FC<LeaveWithoutSavingProps> = ({
23+
disablePreventLeave = false,
24+
onConfirm,
25+
onPrevent,
26+
}) => {
27+
const modalSlug = leaveWithoutSavingModalSlug
2128
const { closeModal, openModal } = useModal()
2229
const modified = useFormModified()
2330
const { isValid } = useForm()
2431
const { user } = useAuth()
2532
const [hasAccepted, setHasAccepted] = React.useState(false)
26-
const { t } = useTranslation()
2733

28-
const prevent = Boolean((modified || !isValid) && user)
34+
const prevent = !disablePreventLeave && Boolean((modified || !isValid) && user)
2935

3036
const handlePrevent = useCallback(() => {
3137
const activeHref = (document.activeElement as HTMLAnchorElement)?.href || null
3238
if (onPrevent) {
3339
onPrevent(activeHref)
3440
}
3541
openModal(modalSlug)
36-
}, [openModal, onPrevent])
42+
}, [openModal, onPrevent, modalSlug])
3743

3844
const handleAccept = useCallback(() => {
3945
closeModal(modalSlug)
40-
}, [closeModal])
46+
}, [closeModal, modalSlug])
4147

4248
usePreventLeave({ hasAccepted, onAccept: handleAccept, onPrevent: handlePrevent, prevent })
4349

4450
const onCancel: OnCancel = useCallback(() => {
4551
closeModal(modalSlug)
46-
}, [closeModal])
52+
}, [closeModal, modalSlug])
4753

4854
const handleConfirm = useCallback(async () => {
4955
if (onConfirm) {
@@ -56,6 +62,22 @@ export const LeaveWithoutSaving: React.FC<LeaveWithoutSavingProps> = ({ onConfir
5662
setHasAccepted(true)
5763
}, [onConfirm])
5864

65+
return (
66+
<LeaveWithoutSavingModal modalSlug={modalSlug} onCancel={onCancel} onConfirm={handleConfirm} />
67+
)
68+
}
69+
70+
export const LeaveWithoutSavingModal = ({
71+
modalSlug,
72+
onCancel,
73+
onConfirm,
74+
}: {
75+
modalSlug: string
76+
onCancel?: OnCancel
77+
onConfirm: () => Promise<void> | void
78+
}) => {
79+
const { t } = useTranslation()
80+
5981
return (
6082
<ConfirmationModal
6183
body={t('general:changesNotSaved')}
@@ -64,7 +86,7 @@ export const LeaveWithoutSaving: React.FC<LeaveWithoutSavingProps> = ({ onConfir
6486
heading={t('general:leaveWithoutSaving')}
6587
modalSlug={modalSlug}
6688
onCancel={onCancel}
67-
onConfirm={handleConfirm}
89+
onConfirm={onConfirm}
6890
/>
6991
)
7092
}

packages/ui/src/elements/LeaveWithoutSaving/usePreventLeave.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,10 @@ export const usePreventLeave = ({
143143
}
144144
}
145145

146-
// Add the global click event listener
147-
document.addEventListener('click', handleClick, true)
146+
if (prevent) {
147+
// Add the global click event listener
148+
document.addEventListener('click', handleClick, true)
149+
}
148150

149151
// Clean up the global click event listener when the component is unmounted
150152
return () => {

packages/ui/src/views/Edit/index.tsx

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
/* eslint-disable react-compiler/react-compiler -- TODO: fix */
21
'use client'
32

43
import type { ClientUser, DocumentViewClientProps } from 'payload'
54

5+
import { useModal } from '@faceless-ui/modal'
66
import { useRouter, useSearchParams } from 'next/navigation.js'
77
import { formatAdminURL } from 'payload/shared'
88
import React, { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -112,6 +112,7 @@ export function DefaultEditView({
112112
onRestore,
113113
onSave: onSaveFromContext,
114114
} = useDocumentDrawerContext()
115+
const { closeModal } = useModal()
115116

116117
const isInDrawer = Boolean(drawerSlug)
117118

@@ -175,18 +176,20 @@ export function DefaultEditView({
175176
(globalConfig?.versions?.drafts && globalConfig?.versions?.drafts?.autosave),
176177
)
177178

178-
const preventLeaveWithoutSaving =
179-
typeof disableLeaveWithoutSaving !== 'undefined' ? !disableLeaveWithoutSaving : !autosaveEnabled
180-
181179
const [isReadOnlyForIncomingUser, setIsReadOnlyForIncomingUser] = useState(false)
182180
const [showTakeOverModal, setShowTakeOverModal] = useState(false)
183181

184182
const [editSessionStartTime, setEditSessionStartTime] = useState(Date.now())
185183

186184
const lockExpiryTime = lastUpdateTime + lockDurationInMilliseconds
187-
188185
const isLockExpired = Date.now() > lockExpiryTime
189186

187+
const preventLeaveWithoutSaving =
188+
!isReadOnlyForIncomingUser &&
189+
(typeof disableLeaveWithoutSaving !== 'undefined'
190+
? !disableLeaveWithoutSaving
191+
: !autosaveEnabled)
192+
190193
const schemaPathSegments = useMemo(() => [entitySlug], [entitySlug])
191194

192195
const [validateBeforeSubmit, setValidateBeforeSubmit] = useState(() => {
@@ -253,16 +256,14 @@ export function DefaultEditView({
253256
nextPath.includes(path),
254257
)
255258

256-
// Only retain the lock if the user is still viewing the document
257-
if (!isInternalView) {
258-
if (isLockOwnedByCurrentUser) {
259-
try {
260-
await unlockDocument(id, collectionSlug ?? globalSlug)
261-
setDocumentIsLocked(false)
262-
setCurrentEditor(null)
263-
} catch (err) {
264-
console.error('Failed to unlock before leave', err) // eslint-disable-line no-console
265-
}
259+
// Remove the lock if the user is navigating away from the document view they have locked
260+
if (isLockOwnedByCurrentUser && !isInternalView) {
261+
try {
262+
await unlockDocument(id, collectionSlug ?? globalSlug)
263+
setDocumentIsLocked(false)
264+
setCurrentEditor(null)
265+
} catch (err) {
266+
console.error('Failed to unlock before leave', err) // eslint-disable-line no-console
266267
}
267268
}
268269
}
@@ -577,7 +578,7 @@ export function DefaultEditView({
577578
}}
578579
/>
579580
)}
580-
{!isReadOnlyForIncomingUser && preventLeaveWithoutSaving && (
581+
{preventLeaveWithoutSaving && (
581582
<LeaveWithoutSaving onConfirm={handleLeaveConfirm} onPrevent={handlePrevent} />
582583
)}
583584
{!isInDrawer && (
@@ -675,6 +676,7 @@ export function DefaultEditView({
675676
readOnly={!hasSavePermission}
676677
requirePassword={!id}
677678
setValidateBeforeSubmit={setValidateBeforeSubmit}
679+
// eslint-disable-next-line react-compiler/react-compiler
678680
useAPIKey={auth.useAPIKey}
679681
username={data?.username}
680682
verify={auth.verify}

test/admin/e2e/document-view/e2e.spec.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,15 @@ const description = 'Description'
4949

5050
let payload: PayloadTestSDK<Config>
5151

52-
import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js'
53-
import { openNav } from 'helpers/e2e/toggleNav.js'
5452
import path from 'path'
5553
import { fileURLToPath } from 'url'
5654

5755
import type { PayloadTestSDK } from '../../../helpers/sdk/index.js'
5856

57+
import { navigateToDoc } from '../../../helpers/e2e/navigateToDoc.js'
58+
import { selectInput } from '../../../helpers/e2e/selectInput.js'
59+
import { openDocDrawer } from '../../../helpers/e2e/toggleDocDrawer.js'
60+
import { openNav } from '../../../helpers/e2e/toggleNav.js'
5961
import { reInitializeDB } from '../../../helpers/reInitializeDB.js'
6062
import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
6163
const filename = fileURLToPath(import.meta.url)
@@ -805,6 +807,43 @@ describe('Document View', () => {
805807
await expect(customEditMenuItem).toHaveAttribute('href', `/custom-action?id=${docId}`)
806808
})
807809
})
810+
811+
describe('save before leaving modal', () => {
812+
test('should prompt in drawer with edits', async () => {
813+
await page.goto(postsUrl.create)
814+
await page.locator('#field-title').fill('sean')
815+
await saveDocAndAssert(page)
816+
817+
await page.goto(postsUrl.create)
818+
await page.locator('#field-title').fill('heros')
819+
await selectInput({
820+
multiSelect: false,
821+
option: 'sean',
822+
filter: 'sean',
823+
selectLocator: page.locator('#field-relationship'),
824+
selectType: 'relationship',
825+
})
826+
await saveDocAndAssert(page)
827+
await openDocDrawer({
828+
page,
829+
selector: '#field-relationship button.relationship--single-value__drawer-toggler',
830+
})
831+
const editModal = page.locator('.drawer--is-open .collection-edit')
832+
await editModal.locator('#field-title').fill('new sean')
833+
834+
// Attempt to close the drawer
835+
const closeButton = editModal.locator('button.doc-drawer__header-close')
836+
await closeButton.click()
837+
838+
const leaveModal = page.locator('#leave-without-saving-doc-drawer')
839+
await expect(leaveModal).toBeVisible()
840+
await leaveModal.locator('#confirm-cancel').click()
841+
await expect(editModal).toBeVisible()
842+
await closeButton.click()
843+
await leaveModal.locator('#confirm-action').click()
844+
await expect(editModal).toBeHidden()
845+
})
846+
})
808847
})
809848

810849
async function createPost(overrides?: Partial<Post>): Promise<Post> {

0 commit comments

Comments
 (0)