feat: document drawer controls (#7679)

## Description

Currently, you cannot create, delete, or duplicate documents within the
document drawer directly. To create a document within a relationship
field, for example, you must first navigate to the parent field and open
the "create new" drawer. Similarly (but worse), to duplicate or delete a
document, you must _navigate to the parent document to perform these
actions_ which is incredibly disruptive to the content editing workflow.
This becomes especially apparent within the relationship field where you
can edit documents inline, but cannot duplicate or delete them. This PR
supports all document-level actions within the document drawer so that
these actions can be performed on-the-fly without navigating away.

Inline duplication flow on a polymorphic "hasOne" relationship:


https://github.com/user-attachments/assets/bb80404a-079d-44a1-b9bc-14eb2ab49a46

Inline deletion flow on a polymorphic "hasOne" relationship:


https://github.com/user-attachments/assets/10f3587f-f70a-4cca-83ee-5dbcad32f063

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## Checklist:

- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] Existing test suite passes locally with my changes
This commit is contained in:
Jacob Fletcher
2024-09-11 14:34:03 -04:00
committed by GitHub
parent ec3730722b
commit 51bc8b4416
24 changed files with 591 additions and 77 deletions

View File

@@ -43,6 +43,7 @@ export const DefaultEditView: React.FC = () => {
BeforeFields, BeforeFields,
collectionSlug, collectionSlug,
disableActions, disableActions,
disableCreate,
disableLeaveWithoutSaving, disableLeaveWithoutSaving,
docPermissions, docPermissions,
getDocPreferences, getDocPreferences,
@@ -54,7 +55,12 @@ export const DefaultEditView: React.FC = () => {
initialState, initialState,
isEditing, isEditing,
isInitializing, isInitializing,
onDelete,
onDrawerCreate,
onDuplicate,
onSave: onSaveFromContext, onSave: onSaveFromContext,
redirectAfterDelete,
redirectAfterDuplicate,
} = useDocumentInfo() } = useDocumentInfo()
const { refreshCookieAsync, user } = useAuth() const { refreshCookieAsync, user } = useAuth()
@@ -223,11 +229,18 @@ export const DefaultEditView: React.FC = () => {
apiURL={apiURL} apiURL={apiURL}
data={data} data={data}
disableActions={disableActions} disableActions={disableActions}
disableCreate={disableCreate}
hasPublishPermission={hasPublishPermission} hasPublishPermission={hasPublishPermission}
hasSavePermission={hasSavePermission} hasSavePermission={hasSavePermission}
id={id} id={id}
isEditing={isEditing} isEditing={isEditing}
onDelete={onDelete}
onDrawerCreate={onDrawerCreate}
onDuplicate={onDuplicate}
onSave={onSave}
permissions={docPermissions} permissions={docPermissions}
redirectAfterDelete={redirectAfterDelete}
redirectAfterDuplicate={redirectAfterDuplicate}
slug={collectionConfig?.slug || globalConfig?.slug} slug={collectionConfig?.slug || globalConfig?.slug}
/> />
<DocumentFields <DocumentFields

View File

@@ -1,19 +1,23 @@
'use client' 'use client'
import type { SanitizedCollectionConfig } from 'payload' import type { ClientCollectionConfig, SanitizedCollectionConfig } from 'payload'
import { Modal, useModal } from '@faceless-ui/modal' import { Modal, useModal } from '@faceless-ui/modal'
import { getTranslation } from '@payloadcms/translations' import { getTranslation } from '@payloadcms/translations'
import { useRouter } from 'next/navigation.js' import { useRouter } from 'next/navigation.js'
import React, { useCallback, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { DocumentInfoContext } from '../../providers/DocumentInfo/index.js'
import { useForm } from '../../forms/Form/context.js' import { useForm } from '../../forms/Form/context.js'
import { useConfig } from '../../providers/Config/index.js' import { useConfig } from '../../providers/Config/index.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js' import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { useEditDepth } from '../../providers/EditDepth/index.js'
import { useTranslation } from '../../providers/Translation/index.js' import { useTranslation } from '../../providers/Translation/index.js'
import { requests } from '../../utilities/api.js' import { requests } from '../../utilities/api.js'
import { formatAdminURL } from '../../utilities/formatAdminURL.js' import { formatAdminURL } from '../../utilities/formatAdminURL.js'
import { Button } from '../Button/index.js' import { Button } from '../Button/index.js'
import { drawerZBase } from '../Drawer/index.js'
import { PopupList } from '../Popup/index.js' import { PopupList } from '../Popup/index.js'
import { Translation } from '../Translation/index.js' import { Translation } from '../Translation/index.js'
import './index.scss' import './index.scss'
@@ -21,30 +25,44 @@ import './index.scss'
const baseClass = 'delete-document' const baseClass = 'delete-document'
export type Props = { export type Props = {
buttonId?: string readonly buttonId?: string
collectionSlug: SanitizedCollectionConfig['slug'] readonly collectionSlug: SanitizedCollectionConfig['slug']
id?: string readonly id?: string
singularLabel: SanitizedCollectionConfig['labels']['singular'] readonly onDelete?: DocumentInfoContext['onDelete']
title?: string readonly redirectAfterDelete?: boolean
useAsTitle: SanitizedCollectionConfig['admin']['useAsTitle'] readonly singularLabel: SanitizedCollectionConfig['labels']['singular']
readonly title?: string
readonly useAsTitle: SanitizedCollectionConfig['admin']['useAsTitle']
} }
export const DeleteDocument: React.FC<Props> = (props) => { export const DeleteDocument: React.FC<Props> = (props) => {
const { id, buttonId, collectionSlug, singularLabel, title: titleFromProps } = props const {
id,
buttonId,
collectionSlug,
onDelete,
redirectAfterDelete = true,
singularLabel,
title: titleFromProps,
} = props
const { const {
config: { config: {
routes: { admin: adminRoute, api }, routes: { admin: adminRoute, api },
serverURL, serverURL,
}, },
getEntityConfig,
} = useConfig() } = useConfig()
const collectionConfig = getEntityConfig({ collectionSlug }) as ClientCollectionConfig
const { setModified } = useForm() const { setModified } = useForm()
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const { toggleModal } = useModal() const { closeModal, toggleModal } = useModal()
const router = useRouter() const router = useRouter()
const { i18n, t } = useTranslation() const { i18n, t } = useTranslation()
const { title } = useDocumentInfo() const { title } = useDocumentInfo()
const editDepth = useEditDepth()
const titleToRender = titleFromProps || title || id const titleToRender = titleFromProps || title || id
@@ -55,9 +73,16 @@ export const DeleteDocument: React.FC<Props> = (props) => {
toast.error(t('error:deletingTitle', { title })) toast.error(t('error:deletingTitle', { title }))
}, [t, title]) }, [t, title])
useEffect(() => {
return () => {
closeModal(modalSlug)
}
}, [closeModal, modalSlug])
const handleDelete = useCallback(async () => { const handleDelete = useCallback(async () => {
setDeleting(true) setDeleting(true)
setModified(false) setModified(false)
try { try {
await requests await requests
.delete(`${serverURL}${api}/${collectionSlug}/${id}`, { .delete(`${serverURL}${api}/${collectionSlug}/${id}`, {
@@ -73,19 +98,32 @@ export const DeleteDocument: React.FC<Props> = (props) => {
if (res.status < 400) { if (res.status < 400) {
setDeleting(false) setDeleting(false)
toggleModal(modalSlug) toggleModal(modalSlug)
toast.success( toast.success(
t('general:titleDeleted', { label: getTranslation(singularLabel, i18n), title }) || t('general:titleDeleted', { label: getTranslation(singularLabel, i18n), title }) ||
json.message, json.message,
) )
return router.push( if (redirectAfterDelete) {
formatAdminURL({ return router.push(
adminRoute, formatAdminURL({
path: `/collections/${collectionSlug}`, adminRoute,
}), path: `/collections/${collectionSlug}`,
) }),
)
}
if (typeof onDelete === 'function') {
await onDelete({ id, collectionConfig })
}
toggleModal(modalSlug)
return
} }
toggleModal(modalSlug) toggleModal(modalSlug)
if (json.errors) { if (json.errors) {
json.errors.forEach((error) => toast.error(error.message)) json.errors.forEach((error) => toast.error(error.message))
} else { } else {
@@ -114,6 +152,9 @@ export const DeleteDocument: React.FC<Props> = (props) => {
router, router,
adminRoute, adminRoute,
addDefaultError, addDefaultError,
redirectAfterDelete,
onDelete,
collectionConfig,
]) ])
if (id) { if (id) {
@@ -128,7 +169,13 @@ export const DeleteDocument: React.FC<Props> = (props) => {
> >
{t('general:delete')} {t('general:delete')}
</PopupList.Button> </PopupList.Button>
<Modal className={baseClass} slug={modalSlug}> <Modal
className={baseClass}
slug={modalSlug}
style={{
zIndex: drawerZBase + editDepth,
}}
>
<div className={`${baseClass}__wrapper`}> <div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}> <div className={`${baseClass}__content`}>
<h1>{t('general:confirmDeletion')}</h1> <h1>{t('general:confirmDeletion')}</h1>
@@ -158,7 +205,11 @@ export const DeleteDocument: React.FC<Props> = (props) => {
</Button> </Button>
<Button <Button
id="confirm-delete" id="confirm-delete"
onClick={deleting ? undefined : handleDelete} onClick={() => {
if (!deleting) {
void handleDelete()
}
}}
size="large" size="large"
> >
{deleting ? t('general:deleting') : t('general:confirm')} {deleting ? t('general:deleting') : t('general:confirm')}

View File

@@ -10,7 +10,10 @@ import type {
import { getTranslation } from '@payloadcms/translations' import { getTranslation } from '@payloadcms/translations'
import React, { Fragment, useEffect } from 'react' import React, { Fragment, useEffect } from 'react'
import type { DocumentInfoContext } from '../../providers/DocumentInfo/types.js'
import { useConfig } from '../../providers/Config/index.js' import { useConfig } from '../../providers/Config/index.js'
import { useEditDepth } from '../../providers/EditDepth/index.js'
import { useTranslation } from '../../providers/Translation/index.js' import { useTranslation } from '../../providers/Translation/index.js'
import { formatAdminURL } from '../../utilities/formatAdminURL.js' import { formatAdminURL } from '../../utilities/formatAdminURL.js'
import { formatDate } from '../../utilities/formatDate.js' import { formatDate } from '../../utilities/formatDate.js'
@@ -32,12 +35,20 @@ export const DocumentControls: React.FC<{
readonly apiURL: string readonly apiURL: string
readonly data?: any readonly data?: any
readonly disableActions?: boolean readonly disableActions?: boolean
readonly disableCreate?: boolean
readonly hasPublishPermission?: boolean readonly hasPublishPermission?: boolean
readonly hasSavePermission?: boolean readonly hasSavePermission?: boolean
id?: number | string id?: number | string
readonly isAccountView?: boolean readonly isAccountView?: boolean
readonly isEditing?: boolean readonly isEditing?: boolean
readonly onDelete?: DocumentInfoContext['onDelete']
readonly onDrawerCreate?: () => void
/* Only available if `redirectAfterDuplicate` is `false` */
readonly onDuplicate?: DocumentInfoContext['onDuplicate']
readonly onSave?: DocumentInfoContext['onSave']
readonly permissions: CollectionPermission | GlobalPermission | null readonly permissions: CollectionPermission | GlobalPermission | null
readonly redirectAfterDelete?: boolean
readonly redirectAfterDuplicate?: boolean
readonly slug: SanitizedCollectionConfig['slug'] readonly slug: SanitizedCollectionConfig['slug']
}> = (props) => { }> = (props) => {
const { const {
@@ -45,14 +56,22 @@ export const DocumentControls: React.FC<{
slug, slug,
data, data,
disableActions, disableActions,
disableCreate,
hasSavePermission, hasSavePermission,
isAccountView, isAccountView,
isEditing, isEditing,
onDelete,
onDrawerCreate,
onDuplicate,
permissions, permissions,
redirectAfterDelete,
redirectAfterDuplicate,
} = props } = props
const { i18n } = useTranslation() const { i18n } = useTranslation()
const editDepth = useEditDepth()
const { config, getEntityConfig } = useConfig() const { config, getEntityConfig } = useConfig()
const collectionConfig = getEntityConfig({ collectionSlug: slug }) as ClientCollectionConfig const collectionConfig = getEntityConfig({ collectionSlug: slug }) as ClientCollectionConfig
@@ -218,18 +237,30 @@ export const DocumentControls: React.FC<{
<PopupList.ButtonGroup> <PopupList.ButtonGroup>
{hasCreatePermission && ( {hasCreatePermission && (
<React.Fragment> <React.Fragment>
<PopupList.Button {!disableCreate && (
href={formatAdminURL({ <Fragment>
adminRoute, {editDepth > 1 ? (
path: `/collections/${collectionConfig?.slug}/create`, <PopupList.Button id="action-create" onClick={onDrawerCreate}>
})} {i18n.t('general:createNew')}
id="action-create" </PopupList.Button>
> ) : (
{i18n.t('general:createNew')} <PopupList.Button
</PopupList.Button> href={formatAdminURL({
adminRoute,
path: `/collections/${collectionConfig?.slug}/create`,
})}
id="action-create"
>
{i18n.t('general:createNew')}
</PopupList.Button>
)}
</Fragment>
)}
{!collectionConfig.disableDuplicate && isEditing && ( {!collectionConfig.disableDuplicate && isEditing && (
<DuplicateDocument <DuplicateDocument
id={id.toString()} id={id.toString()}
onDuplicate={onDuplicate}
redirectAfterDuplicate={redirectAfterDuplicate}
singularLabel={collectionConfig?.labels?.singular} singularLabel={collectionConfig?.labels?.singular}
slug={collectionConfig?.slug} slug={collectionConfig?.slug}
/> />
@@ -241,6 +272,8 @@ export const DocumentControls: React.FC<{
buttonId="action-delete" buttonId="action-delete"
collectionSlug={collectionConfig?.slug} collectionSlug={collectionConfig?.slug}
id={id.toString()} id={id.toString()}
onDelete={onDelete}
redirectAfterDelete={redirectAfterDelete}
singularLabel={collectionConfig?.labels?.singular} singularLabel={collectionConfig?.labels?.singular}
useAsTitle={collectionConfig?.admin?.useAsTitle} useAsTitle={collectionConfig?.admin?.useAsTitle}
/> />

View File

@@ -22,9 +22,14 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
id: existingDocID, id: existingDocID,
AfterFields, AfterFields,
collectionSlug, collectionSlug,
disableActions,
drawerSlug, drawerSlug,
Header, Header,
onDelete: onDeleteFromProps,
onDuplicate: onDuplicateFromProps,
onSave: onSaveFromProps, onSave: onSaveFromProps,
redirectAfterDelete,
redirectAfterDuplicate,
}) => { }) => {
const { config } = useConfig() const { config } = useConfig()
@@ -74,6 +79,34 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
[onSaveFromProps, collectionConfig], [onSaveFromProps, collectionConfig],
) )
const onDuplicate = useCallback<DocumentDrawerProps['onSave']>(
(args) => {
setDocID(args.doc.id)
if (typeof onDuplicateFromProps === 'function') {
void onDuplicateFromProps({
...args,
collectionConfig,
})
}
},
[onDuplicateFromProps, collectionConfig],
)
const onDelete = useCallback<DocumentDrawerProps['onDelete']>(
(args) => {
if (typeof onDeleteFromProps === 'function') {
void onDeleteFromProps({
...args,
collectionConfig,
})
}
closeModal(drawerSlug)
},
[onDeleteFromProps, collectionConfig, closeModal, drawerSlug],
)
return ( return (
<DocumentInfoProvider <DocumentInfoProvider
AfterFields={AfterFields} AfterFields={AfterFields}
@@ -100,12 +133,19 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
</Gutter> </Gutter>
} }
collectionSlug={collectionConfig.slug} collectionSlug={collectionConfig.slug}
disableActions disableActions={disableActions}
disableLeaveWithoutSaving disableLeaveWithoutSaving
id={docID} id={docID}
isEditing={isEditing} isEditing={isEditing}
onDelete={onDelete}
onDrawerCreate={() => {
setDocID(null)
}}
onDuplicate={onDuplicate}
onLoadError={onLoadError} onLoadError={onLoadError}
onSave={onSave} onSave={onSave}
redirectAfterDelete={redirectAfterDelete !== undefined ? redirectAfterDelete : false}
redirectAfterDuplicate={redirectAfterDuplicate !== undefined ? redirectAfterDuplicate : false}
> >
<RenderComponent mappedComponent={Edit} /> <RenderComponent mappedComponent={Edit} />
</DocumentInfoProvider> </DocumentInfoProvider>

View File

@@ -7,9 +7,14 @@ import type { Props as DrawerProps } from '../Drawer/types.js'
export type DocumentDrawerProps = { export type DocumentDrawerProps = {
readonly AfterFields?: React.ReactNode readonly AfterFields?: React.ReactNode
readonly collectionSlug: string readonly collectionSlug: string
readonly disableActions?: boolean
readonly drawerSlug?: string readonly drawerSlug?: string
readonly id?: null | number | string readonly id?: null | number | string
readonly onDelete?: DocumentInfoContext['onDelete']
readonly onDuplicate?: DocumentInfoContext['onDuplicate']
readonly onSave?: DocumentInfoContext['onSave'] readonly onSave?: DocumentInfoContext['onSave']
readonly redirectAfterDelete?: boolean
readonly redirectAfterDuplicate?: boolean
} & Pick<DrawerProps, 'Header'> } & Pick<DrawerProps, 'Header'>
export type DocumentTogglerProps = { export type DocumentTogglerProps = {

View File

@@ -11,7 +11,8 @@ import { Gutter } from '../Gutter/index.js'
import './index.scss' import './index.scss'
const baseClass = 'drawer' const baseClass = 'drawer'
const zBase = 100
export const drawerZBase = 100
export const formatDrawerSlug = ({ slug, depth }: { depth: number; slug: string }): string => export const formatDrawerSlug = ({ slug, depth }: { depth: number; slug: string }): string =>
`drawer_${depth}_${slug}` `drawer_${depth}_${slug}`
@@ -83,7 +84,7 @@ export const Drawer: React.FC<Props> = ({
.join(' ')} .join(' ')}
slug={slug} slug={slug}
style={{ style={{
zIndex: zBase + drawerDepth, zIndex: drawerZBase + drawerDepth,
}} }}
> >
{(!drawerDepth || drawerDepth === 1) && <div className={`${baseClass}__blur-bg`} />} {(!drawerDepth || drawerDepth === 1) && <div className={`${baseClass}__blur-bg`} />}

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import type { SanitizedCollectionConfig } from 'payload' import type { ClientCollectionConfig, SanitizedCollectionConfig } from 'payload'
import { Modal, useModal } from '@faceless-ui/modal' import { Modal, useModal } from '@faceless-ui/modal'
import { getTranslation } from '@payloadcms/translations' import { getTranslation } from '@payloadcms/translations'
@@ -8,25 +8,37 @@ import { useRouter } from 'next/navigation.js'
import React, { useCallback, useState } from 'react' import React, { useCallback, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { DocumentInfoContext } from '../../providers/DocumentInfo/types.js'
import { useForm, useFormModified } from '../../forms/Form/context.js' import { useForm, useFormModified } from '../../forms/Form/context.js'
import { useConfig } from '../../providers/Config/index.js' import { useConfig } from '../../providers/Config/index.js'
import { useEditDepth } from '../../providers/EditDepth/index.js'
import { useLocale } from '../../providers/Locale/index.js' import { useLocale } from '../../providers/Locale/index.js'
import { useTranslation } from '../../providers/Translation/index.js' import { useTranslation } from '../../providers/Translation/index.js'
import { requests } from '../../utilities/api.js' import { requests } from '../../utilities/api.js'
import { formatAdminURL } from '../../utilities/formatAdminURL.js' import { formatAdminURL } from '../../utilities/formatAdminURL.js'
import { Button } from '../Button/index.js' import { Button } from '../Button/index.js'
import { drawerZBase } from '../Drawer/index.js'
import { PopupList } from '../Popup/index.js' import { PopupList } from '../Popup/index.js'
import './index.scss' import './index.scss'
const baseClass = 'duplicate' const baseClass = 'duplicate'
export type Props = { export type Props = {
id: string readonly id: string
singularLabel: SanitizedCollectionConfig['labels']['singular'] readonly onDuplicate?: DocumentInfoContext['onDuplicate']
slug: string readonly redirectAfterDuplicate?: boolean
readonly singularLabel: SanitizedCollectionConfig['labels']['singular']
readonly slug: string
} }
export const DuplicateDocument: React.FC<Props> = ({ id, slug, singularLabel }) => { export const DuplicateDocument: React.FC<Props> = ({
id,
slug,
onDuplicate,
redirectAfterDuplicate = true,
singularLabel,
}) => {
const router = useRouter() const router = useRouter()
const modified = useFormModified() const modified = useFormModified()
const { toggleModal } = useModal() const { toggleModal } = useModal()
@@ -38,13 +50,18 @@ export const DuplicateDocument: React.FC<Props> = ({ id, slug, singularLabel })
routes: { admin: adminRoute, api: apiRoute }, routes: { admin: adminRoute, api: apiRoute },
serverURL, serverURL,
}, },
getEntityConfig,
} = useConfig() } = useConfig()
const collectionConfig = getEntityConfig({ collectionSlug: slug }) as ClientCollectionConfig
const [hasClicked, setHasClicked] = useState<boolean>(false) const [hasClicked, setHasClicked] = useState<boolean>(false)
const { i18n, t } = useTranslation() const { i18n, t } = useTranslation()
const modalSlug = `duplicate-${id}` const modalSlug = `duplicate-${id}`
const editDepth = useEditDepth()
const handleClick = useCallback( const handleClick = useCallback(
async (override = false) => { async (override = false) => {
setHasClicked(true) setHasClicked(true)
@@ -72,13 +89,21 @@ export const DuplicateDocument: React.FC<Props> = ({ id, slug, singularLabel })
message || message ||
t('general:successfullyDuplicated', { label: getTranslation(singularLabel, i18n) }), t('general:successfullyDuplicated', { label: getTranslation(singularLabel, i18n) }),
) )
setModified(false) setModified(false)
router.push(
formatAdminURL({ if (redirectAfterDuplicate) {
adminRoute, router.push(
path: `/collections/${slug}/${doc.id}${locale?.code ? `?locale=${locale.code}` : ''}`, formatAdminURL({
}), adminRoute,
) path: `/collections/${slug}/${doc.id}${locale?.code ? `?locale=${locale.code}` : ''}`,
}),
)
}
if (typeof onDuplicate === 'function') {
void onDuplicate({ collectionConfig, doc })
}
} else { } else {
toast.error( toast.error(
errors?.[0].message || errors?.[0].message ||
@@ -100,9 +125,12 @@ export const DuplicateDocument: React.FC<Props> = ({ id, slug, singularLabel })
modalSlug, modalSlug,
t, t,
singularLabel, singularLabel,
onDuplicate,
redirectAfterDuplicate,
setModified, setModified,
router, router,
adminRoute, adminRoute,
collectionConfig,
], ],
) )
@@ -117,7 +145,13 @@ export const DuplicateDocument: React.FC<Props> = ({ id, slug, singularLabel })
{t('general:duplicate')} {t('general:duplicate')}
</PopupList.Button> </PopupList.Button>
{modified && hasClicked && ( {modified && hasClicked && (
<Modal className={`${baseClass}__modal`} slug={modalSlug}> <Modal
className={`${baseClass}__modal`}
slug={modalSlug}
style={{
zIndex: drawerZBase + editDepth,
}}
>
<div className={`${baseClass}__wrapper`}> <div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}> <div className={`${baseClass}__content`}>
<h1>{t('general:confirmDuplication')}</h1> <h1>{t('general:confirmDuplication')}</h1>

View File

@@ -1,19 +1,21 @@
import type { CommonProps, GroupBase, Props as ReactSelectStateManagerProps } from 'react-select' import type { CommonProps, GroupBase, Props as ReactSelectStateManagerProps } from 'react-select'
import type { DocumentDrawerProps } from '../DocumentDrawer/types.js' import type { DocumentDrawerProps, UseDocumentDrawer } from '../DocumentDrawer/types.js'
type CustomSelectProps = { type CustomSelectProps = {
disableKeyDown?: boolean disableKeyDown?: boolean
disableMouseDown?: boolean disableMouseDown?: boolean
DocumentDrawerToggler?: ReturnType<UseDocumentDrawer>[1]
draggableProps?: any draggableProps?: any
droppableRef?: React.RefObject<HTMLDivElement | null> droppableRef?: React.RefObject<HTMLDivElement | null>
onDelete?: DocumentDrawerProps['onDelete']
onDocumentDrawerOpen: (args: { onDocumentDrawerOpen: (args: {
collectionSlug: string collectionSlug: string
hasReadPermission: boolean hasReadPermission: boolean
id: number | string id: number | string
}) => void }) => void
onDuplicate?: DocumentDrawerProps['onSave']
onSave?: DocumentDrawerProps['onSave'] onSave?: DocumentDrawerProps['onSave']
setDrawerIsOpen?: (isOpen: boolean) => void
} }
// augment the types for the `Select` component from `react-select` // augment the types for the `Select` component from `react-select`

View File

@@ -251,8 +251,6 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
dispatchOptions({ dispatchOptions({
type: 'ADD', type: 'ADD',
collection, collection,
// TODO: fix this
// @ts-expect-error-next-line
config, config,
docs: data.docs, docs: data.docs,
i18n, i18n,
@@ -264,8 +262,6 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
dispatchOptions({ dispatchOptions({
type: 'ADD', type: 'ADD',
collection, collection,
// TODO: fix this
// @ts-expect-error-next-line
config, config,
docs: [], docs: [],
i18n, i18n,
@@ -380,8 +376,6 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
dispatchOptions({ dispatchOptions({
type: 'ADD', type: 'ADD',
collection, collection,
// TODO: fix this
// @ts-expect-error-next-line
config, config,
docs, docs,
i18n, i18n,
@@ -458,14 +452,91 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
dispatchOptions({ dispatchOptions({
type: 'UPDATE', type: 'UPDATE',
collection: args.collectionConfig, collection: args.collectionConfig,
// TODO: fix this
// @ts-expect-error-next-line
config, config,
doc: args.doc, doc: args.doc,
i18n, i18n,
}) })
if (hasMany) {
setValue(
valueRef.current
? (valueRef.current as Option[]).map((option) => {
if (option.value === args.doc.id) {
return {
relationTo: args.collectionConfig.slug,
value: args.doc.id,
}
}
return option
})
: null,
)
} else {
setValue({
relationTo: args.collectionConfig.slug,
value: args.doc.id,
})
}
}, },
[i18n, config], [i18n, config, hasMany, setValue],
)
const onDuplicate = useCallback<DocumentDrawerProps['onDuplicate']>(
(args) => {
dispatchOptions({
type: 'ADD',
collection: args.collectionConfig,
config,
docs: [args.doc],
i18n,
sort: true,
})
if (hasMany) {
setValue(
valueRef.current
? (valueRef.current as Option[]).concat({
relationTo: args.collectionConfig.slug,
value: args.doc.id,
} as Option)
: null,
)
} else {
setValue({
relationTo: args.collectionConfig.slug,
value: args.doc.id,
})
}
},
[i18n, config, hasMany, setValue],
)
const onDelete = useCallback<DocumentDrawerProps['onDelete']>(
(args) => {
dispatchOptions({
id: args.id,
type: 'REMOVE',
collection: args.collectionConfig,
config,
i18n,
})
if (hasMany) {
setValue(
valueRef.current
? (valueRef.current as Option[]).filter((option) => {
return option.value !== args.id
})
: null,
)
} else {
setValue(null)
}
return
},
[i18n, config, hasMany, setValue],
) )
const filterOption = useCallback((item: Option, searchFilter: string) => { const filterOption = useCallback((item: Option, searchFilter: string) => {
@@ -657,7 +728,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
/> />
</div> </div>
{currentlyOpenRelationship.collectionSlug && currentlyOpenRelationship.hasReadPermission && ( {currentlyOpenRelationship.collectionSlug && currentlyOpenRelationship.hasReadPermission && (
<DocumentDrawer onSave={onSave} /> <DocumentDrawer onDelete={onDelete} onDuplicate={onDuplicate} onSave={onSave} />
)} )}
</div> </div>
) )

View File

@@ -146,6 +146,27 @@ export const optionsReducer = (state: OptionGroup[], action: Action): OptionGrou
return newOptions return newOptions
} }
case 'REMOVE': {
const { id, collection } = action
const newOptions = [...state]
const indexOfGroup = newOptions.findIndex(
(optionGroup) => optionGroup.label === collection.labels.plural,
)
if (indexOfGroup === -1) {
return newOptions
}
newOptions[indexOfGroup] = {
...newOptions[indexOfGroup],
options: newOptions[indexOfGroup].options.filter((option) => option.value !== id),
}
return newOptions
}
default: { default: {
return state return state
} }

View File

@@ -1,5 +1,5 @@
import type { I18nClient } from '@payloadcms/translations' import type { I18nClient } from '@payloadcms/translations'
import type { ClientCollectionConfig, FilterOptionsResult, SanitizedConfig } from 'payload' import type { ClientCollectionConfig, ClientConfig, FilterOptionsResult } from 'payload'
export type Option = { export type Option = {
label: string label: string
@@ -27,7 +27,7 @@ type CLEAR = {
type UPDATE = { type UPDATE = {
collection: ClientCollectionConfig collection: ClientCollectionConfig
config: SanitizedConfig config: ClientConfig
doc: any doc: any
i18n: I18nClient i18n: I18nClient
type: 'UPDATE' type: 'UPDATE'
@@ -35,7 +35,7 @@ type UPDATE = {
type ADD = { type ADD = {
collection: ClientCollectionConfig collection: ClientCollectionConfig
config: SanitizedConfig config: ClientConfig
docs: any[] docs: any[]
i18n: I18nClient i18n: I18nClient
ids?: (number | string)[] ids?: (number | string)[]
@@ -43,7 +43,15 @@ type ADD = {
type: 'ADD' type: 'ADD'
} }
export type Action = ADD | CLEAR | UPDATE type REMOVE = {
collection: ClientCollectionConfig
config: ClientConfig
i18n: I18nClient
id: string
type: 'REMOVE'
}
export type Action = ADD | CLEAR | REMOVE | UPDATE
export type GetResults = (args: { export type GetResults = (args: {
filterOptions?: FilterOptionsResult filterOptions?: FilterOptionsResult

View File

@@ -580,7 +580,7 @@ const DocumentInfo: React.FC<
export const DocumentInfoProvider: React.FC< export const DocumentInfoProvider: React.FC<
{ {
children: React.ReactNode readonly children: React.ReactNode
} & DocumentInfoProps } & DocumentInfoProps
> = (props) => { > = (props) => {
return ( return (

View File

@@ -24,6 +24,7 @@ export type DocumentInfoProps = {
BeforeFields?: React.ReactNode BeforeFields?: React.ReactNode
collectionSlug?: SanitizedCollectionConfig['slug'] collectionSlug?: SanitizedCollectionConfig['slug']
disableActions?: boolean disableActions?: boolean
disableCreate?: boolean
disableLeaveWithoutSaving?: boolean disableLeaveWithoutSaving?: boolean
docPermissions?: DocumentPermissions docPermissions?: DocumentPermissions
globalSlug?: SanitizedGlobalConfig['slug'] globalSlug?: SanitizedGlobalConfig['slug']
@@ -33,8 +34,25 @@ export type DocumentInfoProps = {
initialData?: Data initialData?: Data
initialState?: FormState initialState?: FormState
isEditing?: boolean isEditing?: boolean
onDelete?: (args: {
collectionConfig?: ClientCollectionConfig
id: string
}) => Promise<void> | void
onDrawerCreate?: () => void
/* only available if `redirectAfterDuplicate` is `false` */
onDuplicate?: (args: {
collectionConfig?: ClientCollectionConfig
doc: TypeWithID
}) => Promise<void> | void
onLoadError?: (data?: any) => Promise<void> | void onLoadError?: (data?: any) => Promise<void> | void
onSave?: (data: Data) => Promise<void> | void onSave?: (args: {
collectionConfig?: ClientCollectionConfig
doc: TypeWithID
operation: 'create' | 'update'
result: Data
}) => Promise<void> | void
redirectAfterDelete?: boolean
redirectAfterDuplicate?: boolean
} }
export type DocumentInfoContext = { export type DocumentInfoContext = {

View File

@@ -3,6 +3,7 @@ import type { TypeWithID } from 'payload'
import { expect, test } from '@playwright/test' import { expect, test } from '@playwright/test'
import { devUser } from 'credentials.js' import { devUser } from 'credentials.js'
import { openDocControls } from 'helpers/e2e/openDocControls.js'
import path from 'path' import path from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
@@ -21,7 +22,6 @@ import {
getRoutes, getRoutes,
initPageConsoleErrorCatch, initPageConsoleErrorCatch,
login, login,
openDocControls,
openNav, openNav,
saveDocAndAssert, saveDocAndAssert,
} from '../helpers.js' } from '../helpers.js'

View File

@@ -12,7 +12,6 @@ import {
exactText, exactText,
getRoutes, getRoutes,
initPageConsoleErrorCatch, initPageConsoleErrorCatch,
openDocControls,
openNav, openNav,
saveDocAndAssert, saveDocAndAssert,
saveDocHotkeyAndAssert, saveDocHotkeyAndAssert,
@@ -61,6 +60,7 @@ const description = 'Description'
let payload: PayloadTestSDK<Config> let payload: PayloadTestSDK<Config>
import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js' import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js'
import { openDocControls } from 'helpers/e2e/openDocControls.js'
import path from 'path' import path from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'

View File

@@ -21,6 +21,7 @@ export const seed = async (_payload) => {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
_payload.db?.collections[coll.slug]?.ensureIndexes(function (err) { _payload.db?.collections[coll.slug]?.ensureIndexes(function (err) {
if (err) { if (err) {
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
reject(err) reject(err)
} }
resolve(true) resolve(true)
@@ -42,12 +43,12 @@ export const seed = async (_payload) => {
depth: 0, depth: 0,
overrideAccess: true, overrideAccess: true,
}), }),
...[...Array(11)].map(() => async () => { ...[...Array(11)].map((_, i) => async () => {
const postDoc = await _payload.create({ const postDoc = await _payload.create({
collection: postsCollectionSlug, collection: postsCollectionSlug,
data: { data: {
description: 'Description', description: 'Description',
title: 'Title', title: `Post ${i + 1}`,
}, },
depth: 0, depth: 0,
overrideAccess: true, overrideAccess: true,

View File

@@ -1,6 +1,7 @@
import type { Page } from '@playwright/test' import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test' import { expect, test } from '@playwright/test'
import { openDocControls } from 'helpers/e2e/openDocControls.js'
import path from 'path' import path from 'path'
import { wait } from 'payload/shared' import { wait } from 'payload/shared'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
@@ -21,7 +22,6 @@ import {
ensureCompilationIsDone, ensureCompilationIsDone,
initPageConsoleErrorCatch, initPageConsoleErrorCatch,
openCreateDocDrawer, openCreateDocDrawer,
openDocControls,
openDocDrawer, openDocDrawer,
saveDocAndAssert, saveDocAndAssert,
} from '../helpers.js' } from '../helpers.js'

View File

@@ -1,6 +1,8 @@
import type { Page } from '@playwright/test' import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test' import { expect, test } from '@playwright/test'
import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js'
import { openDocControls } from 'helpers/e2e/openDocControls.js'
import path from 'path' import path from 'path'
import { wait } from 'payload/shared' import { wait } from 'payload/shared'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
@@ -13,6 +15,7 @@ import {
exactText, exactText,
initPageConsoleErrorCatch, initPageConsoleErrorCatch,
openCreateDocDrawer, openCreateDocDrawer,
openDocDrawer,
saveDocAndAssert, saveDocAndAssert,
saveDocHotkeyAndAssert, saveDocHotkeyAndAssert,
} from '../../../helpers.js' } from '../../../helpers.js'
@@ -238,7 +241,7 @@ describe('relationship', () => {
}) })
// Related issue: https://github.com/payloadcms/payload/issues/2815 // Related issue: https://github.com/payloadcms/payload/issues/2815
test('should modify fields in relationship drawer', async () => { test('should edit document in relationship drawer', async () => {
await page.goto(url.create) await page.goto(url.create)
await page.waitForURL(`**/${url.create}`) await page.waitForURL(`**/${url.create}`)
// First fill out the relationship field, as it's required // First fill out the relationship field, as it's required
@@ -269,9 +272,12 @@ describe('relationship', () => {
// Now open the drawer again to edit the `text` field _using the keyboard_ // Now open the drawer again to edit the `text` field _using the keyboard_
// Mimic real user behavior by typing into the field with spaces and backspaces // Mimic real user behavior by typing into the field with spaces and backspaces
// Explicitly use both `down` and `type` to cover edge cases // Explicitly use both `down` and `type` to cover edge cases
await page
.locator('#field-relationshipHasMany button.relationship--multi-value-label__drawer-toggler') await openDocDrawer(
.click() page,
'#field-relationshipHasMany button.relationship--multi-value-label__drawer-toggler',
)
await page.locator('[id^=doc-drawer_text-fields_1_] #field-text').click() await page.locator('[id^=doc-drawer_text-fields_1_] #field-text').click()
await page.keyboard.down('1') await page.keyboard.down('1')
await page.keyboard.type('23') await page.keyboard.type('23')
@@ -303,7 +309,7 @@ describe('relationship', () => {
// Drawers opened through the edit button are prone to issues due to the use of stopPropagation for certain // Drawers opened through the edit button are prone to issues due to the use of stopPropagation for certain
// events - specifically for drawers opened through the edit button. This test is to ensure that drawers // events - specifically for drawers opened through the edit button. This test is to ensure that drawers
// opened through the edit button can be saved using the hotkey. // opened through the edit button can be saved using the hotkey.
test('should save using hotkey in edit document drawer', async () => { test('should save using hotkey in document drawer', async () => {
await page.goto(url.create) await page.goto(url.create)
// First fill out the relationship field, as it's required // First fill out the relationship field, as it's required
await openCreateDocDrawer(page, '#field-relationship') await openCreateDocDrawer(page, '#field-relationship')
@@ -333,14 +339,181 @@ describe('relationship', () => {
}, },
}, },
}) })
const relationshipDocuments = await payload.find({ const relationshipDocuments = await payload.find({
collection: relationshipFieldsSlug, collection: relationshipFieldsSlug,
}) })
// The Seeded text document should now have a text field with value 'some updated text value', // The Seeded text document should now have a text field with value 'some updated text value',
expect(seededTextDocument.docs.length).toEqual(1) expect(seededTextDocument.docs.length).toEqual(1)
// but the relationship document should NOT exist, as the hotkey should have saved the drawer and not the parent page // but the relationship document should NOT exist, as the hotkey should have saved the drawer and not the parent page
expect(relationshipDocuments.docs.length).toEqual(0) // NOTE: the value here represents the number of documents _before_ the test was run
expect(relationshipDocuments.docs.length).toEqual(2)
})
describe('should create document within document drawer', () => {
test('has one', async () => {
await navigateToDoc(page, url)
const originalValue = await page
.locator('#field-relationship .relationship--single-value')
.textContent()
await openDocDrawer(page, '#field-relationship .relationship--single-value__drawer-toggler')
const drawer1Content = page.locator('[id^=doc-drawer_text-fields_1_] .drawer__content')
const originalDrawerID = await drawer1Content.locator('.id-label').textContent()
await openDocControls(drawer1Content)
await drawer1Content.locator('#action-create').click()
await wait(1000) // wait for /form-state to return
const title = 'Created from drawer'
await drawer1Content.locator('#field-text').fill(title)
await saveDocAndAssert(page, '[id^=doc-drawer_text-fields_1_] .drawer__content #action-save')
const newDrawerID = drawer1Content.locator('.id-label')
await expect(newDrawerID).not.toHaveText(originalDrawerID)
await page.locator('[id^=doc-drawer_text-fields_1_] .drawer__close').click()
await page.locator('#field-relationship').scrollIntoViewIfNeeded()
await expect(
page.locator('#field-relationship .relationship--single-value__text', {
hasText: exactText(originalValue),
}),
).toBeHidden()
await expect(
page.locator('#field-relationship .relationship--single-value__text', {
hasText: exactText(title),
}),
).toBeVisible()
await page.locator('#field-relationship .rs__control').click()
await expect(
page.locator('.rs__option', {
hasText: exactText(title),
}),
).toBeVisible()
})
test.skip('has many', async () => {})
})
describe('should duplicate document within document drawer', () => {
test('has one', async () => {
await navigateToDoc(page, url)
await wait(500)
const fieldControl = page.locator('#field-relationship .rs__control')
const originalValue = await page
.locator('#field-relationship .relationship--single-value__text')
.textContent()
await fieldControl.click()
await expect(
page.locator('.rs__option', {
hasText: exactText(originalValue),
}),
).toBeVisible()
await openDocDrawer(page, '#field-relationship .relationship--single-value__drawer-toggler')
const drawer1Content = page.locator('[id^=doc-drawer_text-fields_1_] .drawer__content')
const originalID = await drawer1Content.locator('.id-label').textContent()
const originalText = 'Text'
await drawer1Content.locator('#field-text').fill(originalText)
await saveDocAndAssert(page, '[id^=doc-drawer_text-fields_1_] .drawer__content #action-save')
await openDocControls(drawer1Content)
await drawer1Content.locator('#action-duplicate').click()
const duplicateID = drawer1Content.locator('.id-label')
await expect(duplicateID).not.toHaveText(originalID)
await page.locator('[id^=doc-drawer_text-fields_1_] .drawer__close').click()
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
await page.locator('#field-relationship').scrollIntoViewIfNeeded()
const newValue = `${originalText} - duplicate` // this is added via a `beforeDuplicate` hook
await expect(
page.locator('#field-relationship .relationship--single-value__text', {
hasText: exactText(originalValue),
}),
).toBeHidden()
await expect(
page.locator('#field-relationship .relationship--single-value__text', {
hasText: exactText(newValue),
}),
).toBeVisible()
await page.locator('#field-relationship .rs__control').click()
await expect(
page.locator('.rs__option', {
hasText: exactText(newValue),
}),
).toBeVisible()
})
test.skip('has many', async () => {})
})
describe('should delete document within document drawer', () => {
test('has one', async () => {
await navigateToDoc(page, url)
await wait(500)
const originalValue = await page
.locator('#field-relationship .relationship--single-value__text')
.textContent()
await page.locator('#field-relationship .rs__control').click()
await expect(
page.locator('#field-relationship .rs__option', {
hasText: exactText(originalValue),
}),
).toBeVisible()
await openDocDrawer(
page,
'#field-relationship button.relationship--single-value__drawer-toggler',
)
const drawer1Content = page.locator('[id^=doc-drawer_text-fields_1_] .drawer__content')
const originalID = await drawer1Content.locator('.id-label').textContent()
await openDocControls(drawer1Content)
await drawer1Content.locator('#action-delete').click()
await page
.locator('[id^=delete-].payload__modal-item.delete-document[open] button#confirm-delete')
.click()
await expect(drawer1Content).toBeHidden()
await expect(
page.locator('#field-relationship .relationship--single-value__text'),
).toBeHidden()
await expect(page.locator('#field-relationship .rs__placeholder')).toBeVisible()
await page.locator('#field-relationship .rs__control').click()
await wait(500)
await expect(
page.locator('#field-relationship .rs__option', {
hasText: exactText(originalValue),
}),
).toBeHidden()
await expect(
page.locator('#field-relationship .rs__option', {
hasText: exactText(`Untitled - ${originalID}`),
}),
).toBeHidden()
})
test.skip('has many', async () => {})
}) })
// TODO: Fix this. This test flakes due to react select // TODO: Fix this. This test flakes due to react select

View File

@@ -13,6 +13,9 @@ const TextFields: CollectionConfig = {
name: 'text', name: 'text',
type: 'text', type: 'text',
required: true, required: true,
hooks: {
beforeDuplicate: [({ value }) => `${value} - duplicate`],
},
}, },
{ {
name: 'localizedText', name: 'localizedText',

View File

@@ -957,8 +957,15 @@ export interface RowField {
title: string; title: string;
field_with_width_a?: string | null; field_with_width_a?: string | null;
field_with_width_b?: string | null; field_with_width_b?: string | null;
field_with_width_30_percent?: string | null;
field_with_width_60_percent?: string | null;
field_with_width_20_percent?: string | null;
field_within_collapsible_a?: string | null; field_within_collapsible_a?: string | null;
field_within_collapsible_b?: string | null; field_within_collapsible_b?: string | null;
field_20_percent_width_within_row_a?: string | null;
no_set_width_within_row_b?: string | null;
no_set_width_within_row_c?: string | null;
field_20_percent_width_within_row_d?: string | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }

View File

@@ -45,6 +45,7 @@ import {
numberFieldsSlug, numberFieldsSlug,
pointFieldsSlug, pointFieldsSlug,
radioFieldsSlug, radioFieldsSlug,
relationshipFieldsSlug,
richTextFieldsSlug, richTextFieldsSlug,
selectFieldsSlug, selectFieldsSlug,
tabsFieldsSlug, tabsFieldsSlug,
@@ -339,6 +340,37 @@ export const seed = async (_payload: Payload) => {
overrideAccess: true, overrideAccess: true,
}) })
const relationshipField1 = await _payload.create({
collection: relationshipFieldsSlug,
data: {
text: 'Relationship 1',
relationship: {
relationTo: textFieldsSlug,
value: createdTextDoc.id,
},
},
depth: 0,
overrideAccess: true,
})
try {
await _payload.create({
collection: relationshipFieldsSlug,
data: {
text: 'Relationship 2',
relationToSelf: relationshipField1.id,
relationship: {
relationTo: textFieldsSlug,
value: createdAnotherTextDoc.id,
},
},
depth: 0,
overrideAccess: true,
})
} catch (e) {
console.error(e)
}
await _payload.create({ await _payload.create({
collection: lexicalFieldsSlug, collection: lexicalFieldsSlug,
data: lexicalDocWithRelId, data: lexicalDocWithRelId,

View File

@@ -226,11 +226,6 @@ export async function closeNav(page: Page): Promise<void> {
await expect(page.locator('.template-default.template-default--nav-open')).toBeHidden() await expect(page.locator('.template-default.template-default--nav-open')).toBeHidden()
} }
export async function openDocControls(page: Page): Promise<void> {
await page.locator('.doc-controls__popup >> .popup-button').click()
await expect(page.locator('.doc-controls__popup >> .popup__content')).toBeVisible()
}
export async function changeLocale(page: Page, newLocale: string) { export async function changeLocale(page: Page, newLocale: string) {
await page.locator('.localizer >> button').first().click() await page.locator('.localizer >> button').first().click()
await page await page

View File

@@ -0,0 +1,6 @@
import { type Locator, type Page, expect } from '@playwright/test'
export async function openDocControls(page: Locator | Page): Promise<void> {
await page.locator('.doc-controls__popup >> .popup-button').click()
await expect(page.locator('.doc-controls__popup >> .popup__content')).toBeVisible()
}

View File

@@ -1,6 +1,7 @@
import type { Page } from '@playwright/test' import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test' import { expect, test } from '@playwright/test'
import { openDocControls } from 'helpers/e2e/openDocControls.js'
import path from 'path' import path from 'path'
import { wait } from 'payload/shared' import { wait } from 'payload/shared'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
@@ -12,7 +13,6 @@ import {
changeLocale, changeLocale,
ensureCompilationIsDone, ensureCompilationIsDone,
initPageConsoleErrorCatch, initPageConsoleErrorCatch,
openDocControls,
saveDocAndAssert, saveDocAndAssert,
} from '../helpers.js' } from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'