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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -251,8 +251,6 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
dispatchOptions({
type: 'ADD',
collection,
// TODO: fix this
// @ts-expect-error-next-line
config,
docs: data.docs,
i18n,
@@ -264,8 +262,6 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
dispatchOptions({
type: 'ADD',
collection,
// TODO: fix this
// @ts-expect-error-next-line
config,
docs: [],
i18n,
@@ -380,8 +376,6 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
dispatchOptions({
type: 'ADD',
collection,
// TODO: fix this
// @ts-expect-error-next-line
config,
docs,
i18n,
@@ -458,14 +452,91 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
dispatchOptions({
type: 'UPDATE',
collection: args.collectionConfig,
// TODO: fix this
// @ts-expect-error-next-line
config,
doc: args.doc,
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) => {
@@ -657,7 +728,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
/>
</div>
{currentlyOpenRelationship.collectionSlug && currentlyOpenRelationship.hasReadPermission && (
<DocumentDrawer onSave={onSave} />
<DocumentDrawer onDelete={onDelete} onDuplicate={onDuplicate} onSave={onSave} />
)}
</div>
)

View File

@@ -146,6 +146,27 @@ export const optionsReducer = (state: OptionGroup[], action: Action): OptionGrou
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: {
return state
}

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ export type DocumentInfoProps = {
BeforeFields?: React.ReactNode
collectionSlug?: SanitizedCollectionConfig['slug']
disableActions?: boolean
disableCreate?: boolean
disableLeaveWithoutSaving?: boolean
docPermissions?: DocumentPermissions
globalSlug?: SanitizedGlobalConfig['slug']
@@ -33,8 +34,25 @@ export type DocumentInfoProps = {
initialData?: Data
initialState?: FormState
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
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 = {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
import type { Page } 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 { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
@@ -13,6 +15,7 @@ import {
exactText,
initPageConsoleErrorCatch,
openCreateDocDrawer,
openDocDrawer,
saveDocAndAssert,
saveDocHotkeyAndAssert,
} from '../../../helpers.js'
@@ -238,7 +241,7 @@ describe('relationship', () => {
})
// 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.waitForURL(`**/${url.create}`)
// 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_
// Mimic real user behavior by typing into the field with spaces and backspaces
// Explicitly use both `down` and `type` to cover edge cases
await page
.locator('#field-relationshipHasMany button.relationship--multi-value-label__drawer-toggler')
.click()
await openDocDrawer(
page,
'#field-relationshipHasMany button.relationship--multi-value-label__drawer-toggler',
)
await page.locator('[id^=doc-drawer_text-fields_1_] #field-text').click()
await page.keyboard.down('1')
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
// 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.
test('should save using hotkey in edit document drawer', async () => {
test('should save using hotkey in document drawer', async () => {
await page.goto(url.create)
// First fill out the relationship field, as it's required
await openCreateDocDrawer(page, '#field-relationship')
@@ -333,14 +339,181 @@ describe('relationship', () => {
},
},
})
const relationshipDocuments = await payload.find({
collection: relationshipFieldsSlug,
})
// The Seeded text document should now have a text field with value 'some updated text value',
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
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

View File

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

View File

@@ -957,8 +957,15 @@ export interface RowField {
title: string;
field_with_width_a?: 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_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;
createdAt: string;
}

View File

@@ -45,6 +45,7 @@ import {
numberFieldsSlug,
pointFieldsSlug,
radioFieldsSlug,
relationshipFieldsSlug,
richTextFieldsSlug,
selectFieldsSlug,
tabsFieldsSlug,
@@ -339,6 +340,37 @@ export const seed = async (_payload: Payload) => {
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({
collection: lexicalFieldsSlug,
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()
}
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) {
await page.locator('.localizer >> button').first().click()
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 { expect, test } from '@playwright/test'
import { openDocControls } from 'helpers/e2e/openDocControls.js'
import path from 'path'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
@@ -12,7 +13,6 @@ import {
changeLocale,
ensureCompilationIsDone,
initPageConsoleErrorCatch,
openDocControls,
saveDocAndAssert,
} from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'