diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 70eab607d..ad7744834 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -287,6 +287,10 @@ jobs:
- folders
- hooks
- lexical__collections___LexicalFullyFeatured
+ - lexical__collections___LexicalFullyFeatured__db
+ - lexical__collections__LexicalHeadingFeature
+ - lexical__collections__LexicalJSXConverter
+ - lexical__collections__LexicalLinkFeature
- lexical__collections__OnDemandForm
- lexical__collections__Lexical__e2e__main
- lexical__collections__Lexical__e2e__blocks
@@ -427,6 +431,10 @@ jobs:
- folders
- hooks
- lexical__collections___LexicalFullyFeatured
+ - lexical__collections___LexicalFullyFeatured__db
+ - lexical__collections__LexicalHeadingFeature
+ - lexical__collections__LexicalJSXConverter
+ - lexical__collections__LexicalLinkFeature
- lexical__collections__OnDemandForm
- lexical__collections__Lexical__e2e__main
- lexical__collections__Lexical__e2e__blocks
diff --git a/packages/richtext-lexical/src/features/upload/client/component/pending/index.tsx b/packages/richtext-lexical/src/features/upload/client/component/pending/index.tsx
new file mode 100644
index 000000000..185120309
--- /dev/null
+++ b/packages/richtext-lexical/src/features/upload/client/component/pending/index.tsx
@@ -0,0 +1,13 @@
+'use client'
+
+import { ShimmerEffect } from '@payloadcms/ui'
+
+import '../index.scss'
+
+export const PendingUploadComponent = (): React.ReactNode => {
+ return (
+
+
+
+ )
+}
diff --git a/packages/richtext-lexical/src/features/upload/client/nodes/UploadNode.tsx b/packages/richtext-lexical/src/features/upload/client/nodes/UploadNode.tsx
index b64a3d734..baa0ec860 100644
--- a/packages/richtext-lexical/src/features/upload/client/nodes/UploadNode.tsx
+++ b/packages/richtext-lexical/src/features/upload/client/nodes/UploadNode.tsx
@@ -1,53 +1,25 @@
'use client'
-import type { SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode.js'
-import type { DOMConversionMap, DOMConversionOutput, LexicalNode, Spread } from 'lexical'
+import type { DOMConversionMap, LexicalNode } from 'lexical'
import type { JSX } from 'react'
import ObjectID from 'bson-objectid'
import { $applyNodeReplacement } from 'lexical'
import * as React from 'react'
-import type { UploadData } from '../../server/nodes/UploadNode.js'
+import type {
+ Internal_UploadData,
+ SerializedUploadNode,
+ UploadData,
+} from '../../server/nodes/UploadNode.js'
-import { isGoogleDocCheckboxImg, UploadServerNode } from '../../server/nodes/UploadNode.js'
+import { $convertUploadElement } from '../../server/nodes/conversions.js'
+import { UploadServerNode } from '../../server/nodes/UploadNode.js'
+import { PendingUploadComponent } from '../component/pending/index.js'
const RawUploadComponent = React.lazy(() =>
import('../../client/component/index.js').then((module) => ({ default: module.UploadComponent })),
)
-function $convertUploadElement(domNode: HTMLImageElement): DOMConversionOutput | null {
- if (
- domNode.hasAttribute('data-lexical-upload-relation-to') &&
- domNode.hasAttribute('data-lexical-upload-id')
- ) {
- const id = domNode.getAttribute('data-lexical-upload-id')
- const relationTo = domNode.getAttribute('data-lexical-upload-relation-to')
-
- if (id != null && relationTo != null) {
- const node = $createUploadNode({
- data: {
- fields: {},
- relationTo,
- value: id,
- },
- })
- return { node }
- }
- }
- const img = domNode
- if (img.src.startsWith('file:///') || isGoogleDocCheckboxImg(img)) {
- return null
- }
- // TODO: Auto-upload functionality here!
- //}
- return null
-}
-
-export type SerializedUploadNode = {
- children?: never // required so that our typed editor state doesn't automatically add children
- type: 'upload'
-} & Spread
-
export class UploadNode extends UploadServerNode {
static override clone(node: UploadServerNode): UploadServerNode {
return super.clone(node)
@@ -60,7 +32,7 @@ export class UploadNode extends UploadServerNode {
static override importDOM(): DOMConversionMap {
return {
img: (node) => ({
- conversion: $convertUploadElement,
+ conversion: (domNode) => $convertUploadElement(domNode, $createUploadNode),
priority: 0,
}),
}
@@ -75,9 +47,10 @@ export class UploadNode extends UploadServerNode {
serializedNode.version = 3
}
- const importedData: UploadData = {
+ const importedData: Internal_UploadData = {
id: serializedNode.id,
fields: serializedNode.fields,
+ pending: (serializedNode as Internal_UploadData).pending,
relationTo: serializedNode.relationTo,
value: serializedNode.value,
}
@@ -89,6 +62,9 @@ export class UploadNode extends UploadServerNode {
}
override decorate(): JSX.Element {
+ if ((this.__data as Internal_UploadData).pending) {
+ return
+ }
return
}
@@ -105,6 +81,7 @@ export function $createUploadNode({
if (!data?.id) {
data.id = new ObjectID.default().toHexString()
}
+
return $applyNodeReplacement(new UploadNode({ data: data as UploadData }))
}
diff --git a/packages/richtext-lexical/src/features/upload/client/plugin/index.tsx b/packages/richtext-lexical/src/features/upload/client/plugin/index.tsx
index 5ee652610..f12b51e18 100644
--- a/packages/richtext-lexical/src/features/upload/client/plugin/index.tsx
+++ b/packages/richtext-lexical/src/features/upload/client/plugin/index.tsx
@@ -2,42 +2,225 @@
import type { LexicalCommand } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
-import { $insertNodeToNearestRoot, mergeRegister } from '@lexical/utils'
-import { useConfig } from '@payloadcms/ui'
+import { $dfsIterator, $insertNodeToNearestRoot, mergeRegister } from '@lexical/utils'
+import { useBulkUpload, useConfig, useEffectEvent, useModal } from '@payloadcms/ui'
+import ObjectID from 'bson-objectid'
import {
+ $createRangeSelection,
$getPreviousSelection,
$getSelection,
$isParagraphNode,
$isRangeSelection,
+ $setSelection,
COMMAND_PRIORITY_EDITOR,
+ COMMAND_PRIORITY_LOW,
createCommand,
+ DROP_COMMAND,
+ getDOMSelectionFromTarget,
+ isHTMLElement,
+ PASTE_COMMAND,
} from 'lexical'
import React, { useEffect } from 'react'
import type { PluginComponent } from '../../../typesClient.js'
-import type { UploadData } from '../../server/nodes/UploadNode.js'
+import type { Internal_UploadData, UploadData } from '../../server/nodes/UploadNode.js'
import type { UploadFeaturePropsClient } from '../index.js'
import { UploadDrawer } from '../drawer/index.js'
-import { $createUploadNode, UploadNode } from '../nodes/UploadNode.js'
+import { $createUploadNode, $isUploadNode, UploadNode } from '../nodes/UploadNode.js'
export type InsertUploadPayload = Readonly & Partial>>
+declare global {
+ interface DragEvent {
+ rangeOffset?: number
+ rangeParent?: Node
+ }
+}
+
+function canDropImage(event: DragEvent): boolean {
+ const target = event.target
+ return !!(
+ isHTMLElement(target) &&
+ !target.closest('code, span.editor-image') &&
+ isHTMLElement(target.parentElement) &&
+ target.parentElement.closest('div.ContentEditable__root')
+ )
+}
+
+function getDragSelection(event: DragEvent): null | Range | undefined {
+ // Source: https://github.com/AlessioGr/lexical/blob/main/packages/lexical-playground/src/plugins/ImagesPlugin/index.tsx
+ let range
+ const domSelection = getDOMSelectionFromTarget(event.target)
+ if (document.caretRangeFromPoint) {
+ range = document.caretRangeFromPoint(event.clientX, event.clientY)
+ } else if (event.rangeParent && domSelection !== null) {
+ domSelection.collapse(event.rangeParent, event.rangeOffset || 0)
+ range = domSelection.getRangeAt(0)
+ } else {
+ throw Error(`Cannot get the selection when dragging`)
+ }
+
+ return range
+}
+
export const INSERT_UPLOAD_COMMAND: LexicalCommand =
createCommand('INSERT_UPLOAD_COMMAND')
-export const UploadPlugin: PluginComponent = ({ clientProps }) => {
+type FileToUpload = {
+ alt?: string
+ file: File
+ /**
+ * Bulk Upload Form ID that should be created, which can then be matched
+ * against the node formID if the upload is successful
+ */
+ formID: string
+}
+
+export const UploadPlugin: PluginComponent = () => {
const [editor] = useLexicalComposerContext()
const {
config: { collections },
} = useConfig()
+ const {
+ drawerSlug: bulkUploadDrawerSlug,
+ setCollectionSlug,
+ setInitialForms,
+ setOnCancel,
+ setOnSuccess,
+ setSelectableCollections,
+ } = useBulkUpload()
+
+ const { isModalOpen, openModal } = useModal()
+
+ const openBulkUpload = useEffectEvent(({ files }: { files: FileToUpload[] }) => {
+ if (files?.length === 0) {
+ return
+ }
+
+ setInitialForms((initialForms) => [
+ ...(initialForms ?? []),
+ ...files.map((file) => ({
+ file: file.file,
+ formID: file.formID,
+ })),
+ ])
+
+ if (!isModalOpen(bulkUploadDrawerSlug)) {
+ const uploadCollections = collections.filter(({ upload }) => !!upload).map(({ slug }) => slug)
+ if (!uploadCollections.length || !uploadCollections[0]) {
+ return
+ }
+
+ setCollectionSlug(uploadCollections[0])
+ setSelectableCollections(uploadCollections)
+
+ setOnCancel(() => {
+ // Remove all the pending upload nodes that were added but not uploaded
+ editor.update(() => {
+ for (const dfsNode of $dfsIterator()) {
+ const node = dfsNode.node
+
+ if ($isUploadNode(node)) {
+ const nodeData = node.getData()
+ if ((nodeData as Internal_UploadData)?.pending) {
+ node.remove()
+ }
+ }
+ }
+ })
+ })
+
+ setOnSuccess((newDocs) => {
+ const newDocsMap = new Map(newDocs.map((doc) => [doc.formID, doc]))
+ editor.update(() => {
+ for (const dfsNode of $dfsIterator()) {
+ const node = dfsNode.node
+ if ($isUploadNode(node)) {
+ const nodeData: Internal_UploadData = node.getData()
+
+ if (nodeData?.pending) {
+ const newDoc = newDocsMap.get(nodeData.pending?.formID)
+ if (newDoc) {
+ node.replace(
+ $createUploadNode({
+ data: {
+ id: new ObjectID.default().toHexString(),
+ fields: {},
+ relationTo: newDoc.collectionSlug,
+ value: newDoc.doc.id,
+ } as UploadData,
+ }),
+ )
+ }
+ }
+ }
+ }
+ })
+ })
+
+ openModal(bulkUploadDrawerSlug)
+ }
+ })
+
useEffect(() => {
if (!editor.hasNodes([UploadNode])) {
throw new Error('UploadPlugin: UploadNode not registered on editor')
}
return mergeRegister(
+ /**
+ * Handle auto-uploading files if you copy & paste an image dom element from the clipboard
+ */
+ editor.registerNodeTransform(UploadNode, (node) => {
+ const nodeData: Internal_UploadData = node.getData()
+ if (!nodeData?.pending) {
+ return
+ }
+
+ async function upload() {
+ let transformedImage: FileToUpload | null = null
+
+ const src = nodeData?.pending?.src
+ const formID = nodeData?.pending?.formID as string
+
+ if (src?.startsWith('data:')) {
+ // It's a base64-encoded image
+ const mimeMatch = src.match(/data:(image\/[a-zA-Z]+);base64,/)
+ const mimeType = mimeMatch ? mimeMatch[1] : 'image/png' // Default to PNG if MIME type not found
+ const base64Data = src.replace(/^data:image\/[a-zA-Z]+;base64,/, '')
+ const byteCharacters = atob(base64Data)
+ const byteNumbers = new Array(byteCharacters.length)
+ for (let i = 0; i < byteCharacters.length; i++) {
+ byteNumbers[i] = byteCharacters.charCodeAt(i)
+ }
+ const byteArray = new Uint8Array(byteNumbers)
+ const file = new File([byteArray], 'pasted-image.' + mimeType?.split('/')[1], {
+ type: mimeType,
+ })
+ transformedImage = { alt: undefined, file, formID }
+ } else if (src?.startsWith('http') || src?.startsWith('https')) {
+ // It's an image URL
+ const res = await fetch(src)
+ const blob = await res.blob()
+ const inferredFileName =
+ src.split('/').pop() || 'pasted-image' + blob.type.split('/')[1]
+ const file = new File([blob], inferredFileName, {
+ type: blob.type,
+ })
+
+ transformedImage = { alt: undefined, file, formID }
+ }
+
+ if (!transformedImage) {
+ return
+ }
+
+ openBulkUpload({ files: [transformedImage] })
+ }
+ void upload()
+ }),
editor.registerCommand(
INSERT_UPLOAD_COMMAND,
(payload: InsertUploadPayload) => {
@@ -70,6 +253,145 @@ export const UploadPlugin: PluginComponent = ({ client
},
COMMAND_PRIORITY_EDITOR,
),
+ editor.registerCommand(
+ PASTE_COMMAND,
+ (event) => {
+ // Pending UploadNodes are automatically created when importDOM is called. However, if you paste a file from your computer
+ // directly, importDOM won't be called, as it's not a HTML dom element. So we need to handle that case here.
+
+ if (!(event instanceof ClipboardEvent)) {
+ return false
+ }
+ const clipboardData = event.clipboardData
+
+ if (!clipboardData?.types?.length || clipboardData?.types?.includes('text/html')) {
+ // HTML is handled through importDOM => registerNodeTransform for pending UploadNode
+ return false
+ }
+
+ const files: FileToUpload[] = []
+ if (clipboardData?.files?.length) {
+ Array.from(clipboardData.files).forEach((file) => {
+ files.push({
+ alt: '',
+ file,
+ formID: new ObjectID.default().toHexString(),
+ })
+ })
+ }
+
+ if (files.length) {
+ // Insert a pending UploadNode for each image
+ editor.update(() => {
+ const selection = $getSelection() || $getPreviousSelection()
+
+ if ($isRangeSelection(selection)) {
+ for (const file of files) {
+ const pendingUploadNode = new UploadNode({
+ data: {
+ pending: {
+ formID: file.formID,
+ src: URL.createObjectURL(file.file),
+ },
+ } as Internal_UploadData,
+ })
+ // we need to get the focus node before inserting the upload node, as $insertNodeToNearestRoot can change the focus node
+ const { focus } = selection
+ const focusNode = focus.getNode()
+ // Insert upload node BEFORE potentially removing focusNode, as $insertNodeToNearestRoot errors if the focusNode doesn't exist
+ $insertNodeToNearestRoot(pendingUploadNode)
+
+ // Delete the node it it's an empty paragraph
+ if ($isParagraphNode(focusNode) && !focusNode.__first) {
+ focusNode.remove()
+ }
+ }
+ }
+ })
+
+ // Open the bulk drawer - the node transform will not open it for us, as it does not handle blob/file uploads
+ openBulkUpload({ files })
+
+ return true
+ }
+
+ return false
+ },
+ COMMAND_PRIORITY_LOW,
+ ),
+ // Handle drag & drop of files from the desktop into the editor
+ editor.registerCommand(
+ DROP_COMMAND,
+ (event) => {
+ if (!(event instanceof DragEvent)) {
+ return false
+ }
+
+ const dt = event.dataTransfer
+
+ if (!dt?.types?.length) {
+ return false
+ }
+
+ const files: FileToUpload[] = []
+ if (dt?.files?.length) {
+ Array.from(dt.files).forEach((file) => {
+ files.push({
+ alt: '',
+ file,
+ formID: new ObjectID.default().toHexString(),
+ })
+ })
+ }
+
+ if (files.length) {
+ // Prevent the default browser drop handling, which would open the file in the browser
+ event.preventDefault()
+ event.stopPropagation()
+
+ // Insert a PendingUploadNode for each image
+ editor.update(() => {
+ if (canDropImage(event)) {
+ const range = getDragSelection(event)
+ const selection = $createRangeSelection()
+ if (range !== null && range !== undefined) {
+ selection.applyDOMRange(range)
+ }
+ $setSelection(selection)
+
+ for (const file of files) {
+ const pendingUploadNode = new UploadNode({
+ data: {
+ pending: {
+ formID: file.formID,
+ src: URL.createObjectURL(file.file),
+ },
+ } as Internal_UploadData,
+ })
+ // we need to get the focus node before inserting the upload node, as $insertNodeToNearestRoot can change the focus node
+ const { focus } = selection
+ const focusNode = focus.getNode()
+ // Insert upload node BEFORE potentially removing focusNode, as $insertNodeToNearestRoot errors if the focusNode doesn't exist
+ $insertNodeToNearestRoot(pendingUploadNode)
+
+ // Delete the node it it's an empty paragraph
+ if ($isParagraphNode(focusNode) && !focusNode.__first) {
+ focusNode.remove()
+ }
+ }
+ }
+ })
+
+ // Open the bulk drawer - the node transform will not open it for us, as it does not handle blob/file uploads
+ openBulkUpload({ files })
+
+ return true
+ }
+
+ return false
+ },
+ COMMAND_PRIORITY_LOW,
+ ),
)
}, [editor])
diff --git a/packages/richtext-lexical/src/features/upload/server/nodes/UploadNode.tsx b/packages/richtext-lexical/src/features/upload/server/nodes/UploadNode.tsx
index 15120f34b..f9dfb655b 100644
--- a/packages/richtext-lexical/src/features/upload/server/nodes/UploadNode.tsx
+++ b/packages/richtext-lexical/src/features/upload/server/nodes/UploadNode.tsx
@@ -1,7 +1,6 @@
import type { SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode.js'
import type {
DOMConversionMap,
- DOMConversionOutput,
DOMExportOutput,
ElementFormatType,
LexicalNode,
@@ -20,7 +19,8 @@ import type { JSX } from 'react'
import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode.js'
import ObjectID from 'bson-objectid'
import { $applyNodeReplacement } from 'lexical'
-import * as React from 'react'
+
+import { $convertUploadElement } from './conversions.js'
export type UploadData = {
[TCollectionSlug in CollectionSlug]: {
@@ -37,6 +37,23 @@ export type UploadData =
}
}[CollectionSlug]
+/**
+ * Internal use only - UploadData type that can contain a pending state
+ * @internal
+ */
+export type Internal_UploadData = {
+ pending?: {
+ /**
+ * ID that corresponds to the bulk upload form ID
+ */
+ formID: string
+ /**
+ * src value of the image dom element
+ */
+ src: string
+ }
+} & UploadData
+
/**
* UploadDataImproved is a more precise type, and will replace UploadData in Payload v4.
* This type is for internal use only as it will be deprecated in the future.
@@ -59,43 +76,6 @@ export type UploadDataImproved {
return {
img: (node) => ({
- conversion: $convertUploadServerElement,
+ conversion: (domNode) => $convertUploadElement(domNode, $createUploadServerNode),
priority: 0,
}),
}
@@ -147,9 +127,10 @@ export class UploadServerNode extends DecoratorBlockNode {
serializedNode.version = 3
}
- const importedData: UploadData = {
+ const importedData: Internal_UploadData = {
id: serializedNode.id,
fields: serializedNode.fields,
+ pending: (serializedNode as Internal_UploadData).pending,
relationTo: serializedNode.relationTo,
value: serializedNode.value,
}
@@ -165,14 +146,19 @@ export class UploadServerNode extends DecoratorBlockNode {
}
override decorate(): JSX.Element {
- // @ts-expect-error
- return
+ return null as unknown as JSX.Element
}
override exportDOM(): DOMExportOutput {
const element = document.createElement('img')
- element.setAttribute('data-lexical-upload-id', String(this.__data?.value))
- element.setAttribute('data-lexical-upload-relation-to', this.__data?.relationTo)
+ const data = this.__data as Internal_UploadData
+ if (data.pending) {
+ element.setAttribute('data-lexical-pending-upload-form-id', String(data?.pending?.formID))
+ element.setAttribute('src', data?.pending?.src || '')
+ } else {
+ element.setAttribute('data-lexical-upload-id', String(data?.value))
+ element.setAttribute('data-lexical-upload-relation-to', data?.relationTo)
+ }
return { element }
}
diff --git a/packages/richtext-lexical/src/features/upload/server/nodes/conversions.ts b/packages/richtext-lexical/src/features/upload/server/nodes/conversions.ts
new file mode 100644
index 000000000..b3fa4330b
--- /dev/null
+++ b/packages/richtext-lexical/src/features/upload/server/nodes/conversions.ts
@@ -0,0 +1,68 @@
+// This file contains functions used to convert dom elements to upload or pending upload lexical nodes. It requires the actual node
+// creation functions to be passed in to stay compatible with both client and server code.
+import type { DOMConversionOutput } from 'lexical'
+
+import ObjectID from 'bson-objectid'
+
+import type { $createUploadNode } from '../../client/nodes/UploadNode.js'
+import type { $createUploadServerNode, Internal_UploadData } from './UploadNode.js'
+
+export function isGoogleDocCheckboxImg(img: HTMLImageElement): boolean {
+ return (
+ img.parentElement != null &&
+ img.parentElement.tagName === 'LI' &&
+ img.previousSibling === null &&
+ img.getAttribute('aria-roledescription') === 'checkbox'
+ )
+}
+
+export function $convertUploadElement(
+ domNode: HTMLImageElement,
+ $createNode: typeof $createUploadNode | typeof $createUploadServerNode,
+): DOMConversionOutput | null {
+ if (domNode.hasAttribute('data-lexical-pending-upload-form-id')) {
+ const formID = domNode.getAttribute('data-lexical-pending-upload-form-id')
+
+ if (formID != null) {
+ const node = $createNode({
+ data: {
+ pending: {
+ formID,
+ src: domNode.getAttribute('src') || '',
+ },
+ } as Internal_UploadData,
+ })
+ return { node }
+ }
+ }
+ if (
+ domNode.hasAttribute('data-lexical-upload-relation-to') &&
+ domNode.hasAttribute('data-lexical-upload-id')
+ ) {
+ const id = domNode.getAttribute('data-lexical-upload-id')
+ const relationTo = domNode.getAttribute('data-lexical-upload-relation-to')
+
+ if (id != null && relationTo != null) {
+ const node = $createNode({
+ data: {
+ fields: {},
+ relationTo,
+ value: id,
+ },
+ })
+ return { node }
+ }
+ }
+
+ // Create pending UploadNode. Auto-Upload functionality will then be handled by the node transform
+ const node = $createNode({
+ data: {
+ pending: {
+ formID: new ObjectID.default().toHexString(),
+ src: domNode.getAttribute('src') || '',
+ },
+ } as Internal_UploadData,
+ })
+
+ return { node }
+}
diff --git a/packages/ui/src/elements/BulkUpload/FileSidebar/index.scss b/packages/ui/src/elements/BulkUpload/FileSidebar/index.scss
index 7b0662083..98ce4890c 100644
--- a/packages/ui/src/elements/BulkUpload/FileSidebar/index.scss
+++ b/packages/ui/src/elements/BulkUpload/FileSidebar/index.scss
@@ -8,7 +8,7 @@
display: flex;
flex-direction: column;
width: 300px;
- overflow: auto;
+ overflow: visible;
max-height: 100%;
&__header {
@@ -28,6 +28,19 @@
}
}
+ &__collectionSelect {
+ width: 100%;
+
+ .react-select {
+ width: 100%;
+ }
+ .field-type__wrap {
+ width: 100%;
+ padding-block: var(--base);
+ padding-inline: var(--file-gutter-h);
+ }
+ }
+
&__headerTopRow {
display: flex;
align-items: center;
diff --git a/packages/ui/src/elements/BulkUpload/FileSidebar/index.tsx b/packages/ui/src/elements/BulkUpload/FileSidebar/index.tsx
index cb417b8f4..04d10bfc6 100644
--- a/packages/ui/src/elements/BulkUpload/FileSidebar/index.tsx
+++ b/packages/ui/src/elements/BulkUpload/FileSidebar/index.tsx
@@ -5,8 +5,10 @@ import { useWindowInfo } from '@faceless-ui/window-info'
import { isImage } from 'payload/shared'
import React from 'react'
+import { SelectInput } from '../../../fields/Select/Input.js'
import { ChevronIcon } from '../../../icons/Chevron/index.js'
import { XIcon } from '../../../icons/X/index.js'
+import { useConfig } from '../../../providers/Config/index.js'
import { useTranslation } from '../../../providers/Translation/index.js'
import { AnimateHeight } from '../../AnimateHeight/index.js'
import { Button } from '../../Button/index.js'
@@ -18,9 +20,9 @@ import { createThumbnail } from '../../Thumbnail/createThumbnail.js'
import { Thumbnail } from '../../Thumbnail/index.js'
import { Actions } from '../ActionsBar/index.js'
import { AddFilesView } from '../AddFilesView/index.js'
+import './index.scss'
import { useFormsManager } from '../FormsManager/index.js'
import { useBulkUpload } from '../index.js'
-import './index.scss'
const addMoreFilesDrawerSlug = 'bulk-upload-drawer--add-more-files'
@@ -36,7 +38,7 @@ export function FileSidebar() {
setActiveIndex,
totalErrorCount,
} = useFormsManager()
- const { initialFiles, maxFiles } = useBulkUpload()
+ const { initialFiles, initialForms, maxFiles } = useBulkUpload()
const { i18n, t } = useTranslation()
const { closeModal, openModal } = useModal()
const [showFiles, setShowFiles] = React.useState(false)
@@ -66,7 +68,17 @@ export function FileSidebar() {
return formattedSize
}, [])
- const totalFileCount = isInitializing ? initialFiles.length : forms.length
+ const totalFileCount = isInitializing
+ ? (initialFiles?.length ?? initialForms?.length)
+ : forms.length
+
+ const {
+ collectionSlug: bulkUploadCollectionSlug,
+ selectableCollections,
+ setCollectionSlug,
+ } = useBulkUpload()
+
+ const { getEntityConfig } = useConfig()
return (
{breakpoints.m && showFiles ?
: null}
+ {selectableCollections?.length > 1 && (
+
{
+ const val: string =
+ typeof e === 'object' && 'value' in e
+ ? (e?.value as string)
+ : (e as unknown as string)
+ setCollectionSlug(val)
+ }}
+ options={
+ selectableCollections?.map((coll) => {
+ const config = getEntityConfig({ collectionSlug: coll })
+ return { label: config.labels.singular, value: config.slug }
+ }) || []
+ }
+ path="groupBy"
+ required
+ value={bulkUploadCollectionSlug}
+ />
+ )}
@@ -130,8 +165,10 @@ export function FileSidebar() {
- {isInitializing && forms.length === 0 && initialFiles.length > 0
- ? Array.from(initialFiles).map((file, index) => (
+ {isInitializing &&
+ forms.length === 0 &&
+ (initialFiles?.length > 0 || initialForms?.length > 0)
+ ? (initialFiles ? Array.from(initialFiles) : initialForms).map((file, index) => (
+
type FormsManagerProps = {
readonly children: React.ReactNode
}
@@ -118,7 +127,16 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
const { toggleLoadingOverlay } = useLoadingOverlay()
const { closeModal } = useModal()
- const { collectionSlug, drawerSlug, initialFiles, onSuccess, setInitialFiles } = useBulkUpload()
+ const {
+ collectionSlug,
+ drawerSlug,
+ initialFiles,
+ initialForms,
+ onSuccess,
+ setInitialFiles,
+ setInitialForms,
+ setSuccessfullyUploaded,
+ } = useBulkUpload()
const [isUploading, setIsUploading] = React.useState(false)
const [loadingText, setLoadingText] = React.useState('')
@@ -244,12 +262,38 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
if (!hasInitializedState) {
await initializeSharedFormState()
}
- dispatch({ type: 'ADD_FORMS', files, initialState: initialStateRef.current })
+ dispatch({
+ type: 'ADD_FORMS',
+ forms: Array.from(files).map((file) => ({
+ file,
+ initialState: initialStateRef.current,
+ })),
+ })
toggleLoadingOverlay({ isLoading: false, key: 'addingDocs' })
},
[initializeSharedFormState, hasInitializedState, toggleLoadingOverlay, activeIndex, forms],
)
+ const addFilesEffectEvent = useEffectEvent(addFiles)
+
+ const addInitialForms = useEffectEvent(async (initialForms: InitialForms) => {
+ toggleLoadingOverlay({ isLoading: true, key: 'addingDocs' })
+
+ if (!hasInitializedState) {
+ await initializeSharedFormState()
+ }
+
+ dispatch({
+ type: 'ADD_FORMS',
+ forms: initialForms.map((form) => ({
+ ...form,
+ initialState: form?.initialState || initialStateRef.current,
+ })),
+ })
+
+ toggleLoadingOverlay({ isLoading: false, key: 'addingDocs' })
+ })
+
const removeFile: FormsManagerContext['removeFile'] = React.useCallback((index) => {
dispatch({ type: 'REMOVE_FORM', index })
}, [])
@@ -275,7 +319,14 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
formState: currentFormsData,
uploadEdits: currentForms[activeIndex].uploadEdits,
}
- const newDocs = []
+ const newDocs: Array<{
+ collectionSlug: CollectionSlug
+ doc: JsonObject
+ /**
+ * ID of the form that created this document
+ */
+ formID: string
+ }> = []
setIsUploading(true)
@@ -309,7 +360,11 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
const json = await req.json()
if (req.status === 201 && json?.doc) {
- newDocs.push(json.doc)
+ newDocs.push({
+ collectionSlug,
+ doc: json.doc,
+ formID: form.formID,
+ })
}
// should expose some sort of helper for this
@@ -380,6 +435,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
if (successCount) {
toast.success(`Successfully saved ${successCount} files`)
+ setSuccessfullyUploaded(true)
if (typeof onSuccess === 'function') {
onSuccess(newDocs, errorCount)
@@ -403,20 +459,23 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
if (remainingForms.length === 0) {
setInitialFiles(undefined)
+ setInitialForms(undefined)
}
},
[
- setInitialFiles,
- actionURL,
- collectionSlug,
- getUploadHandler,
- t,
forms,
activeIndex,
- closeModal,
- drawerSlug,
- onSuccess,
+ t,
+ actionURL,
code,
+ collectionSlug,
+ getUploadHandler,
+ onSuccess,
+ closeModal,
+ setSuccessfullyUploaded,
+ drawerSlug,
+ setInitialFiles,
+ setInitialForms,
],
)
@@ -504,7 +563,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
void initializeSharedDocPermissions()
}
- if (initialFiles) {
+ if (initialFiles || initialForms) {
if (!hasInitializedState || !hasInitializedDocPermissions) {
setIsInitializing(true)
} else {
@@ -512,19 +571,28 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
}
}
- if (hasInitializedState && initialFiles && !hasInitializedWithFiles.current) {
- void addFiles(initialFiles)
+ if (
+ hasInitializedState &&
+ (initialForms?.length || initialFiles?.length) &&
+ !hasInitializedWithFiles.current
+ ) {
+ if (initialForms?.length) {
+ void addInitialForms(initialForms)
+ }
+ if (initialFiles?.length) {
+ void addFilesEffectEvent(initialFiles)
+ }
hasInitializedWithFiles.current = true
}
return
}, [
- addFiles,
initialFiles,
initializeSharedFormState,
initializeSharedDocPermissions,
collectionSlug,
hasInitializedState,
hasInitializedDocPermissions,
+ initialForms,
])
return (
diff --git a/packages/ui/src/elements/BulkUpload/FormsManager/reducer.ts b/packages/ui/src/elements/BulkUpload/FormsManager/reducer.ts
index a87b1461d..e1e9869a4 100644
--- a/packages/ui/src/elements/BulkUpload/FormsManager/reducer.ts
+++ b/packages/ui/src/elements/BulkUpload/FormsManager/reducer.ts
@@ -2,6 +2,8 @@ import type { FormState, UploadEdits } from 'payload'
import { v4 as uuidv4 } from 'uuid'
+import type { InitialForms } from './index.js'
+
export type State = {
activeIndex: number
forms: {
@@ -28,8 +30,7 @@ type Action =
uploadEdits?: UploadEdits
}
| {
- files: FileList
- initialState: FormState | null
+ forms: InitialForms
type: 'ADD_FORMS'
}
| {
@@ -49,16 +50,16 @@ export function formsManagementReducer(state: State, action: Action): State {
switch (action.type) {
case 'ADD_FORMS': {
const newForms: State['forms'] = []
- for (let i = 0; i < action.files.length; i++) {
+ for (let i = 0; i < action.forms.length; i++) {
newForms[i] = {
errorCount: 0,
- formID: crypto.randomUUID ? crypto.randomUUID() : uuidv4(),
+ formID: action.forms[i].formID ?? (crypto.randomUUID ? crypto.randomUUID() : uuidv4()),
formState: {
- ...(action.initialState || {}),
+ ...(action.forms[i].initialState || {}),
file: {
- initialValue: action.files[i],
+ initialValue: action.forms[i].file,
valid: true,
- value: action.files[i],
+ value: action.forms[i].file,
},
},
uploadEdits: {},
diff --git a/packages/ui/src/elements/BulkUpload/index.tsx b/packages/ui/src/elements/BulkUpload/index.tsx
index cbd3c0348..9d284bcbb 100644
--- a/packages/ui/src/elements/BulkUpload/index.tsx
+++ b/packages/ui/src/elements/BulkUpload/index.tsx
@@ -1,12 +1,13 @@
'use client'
-import type { JsonObject } from 'payload'
+import type { CollectionSlug, JsonObject } from 'payload'
import { useModal } from '@faceless-ui/modal'
import { validateMimeType } from 'payload/shared'
-import React from 'react'
+import React, { useEffect } from 'react'
import { toast } from 'sonner'
+import { useEffectEvent } from '../../hooks/useEffectEvent.js'
import { useConfig } from '../../providers/Config/index.js'
import { EditDepthProvider } from '../../providers/EditDepth/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
@@ -14,7 +15,7 @@ import { UploadControlsProvider } from '../../providers/UploadControls/index.js'
import { Drawer, useDrawerDepth } from '../Drawer/index.js'
import { AddFilesView } from './AddFilesView/index.js'
import { AddingFilesView } from './AddingFilesView/index.js'
-import { FormsManagerProvider, useFormsManager } from './FormsManager/index.js'
+import { FormsManagerProvider, type InitialForms, useFormsManager } from './FormsManager/index.js'
const drawerSlug = 'bulk-upload-drawer-slug'
@@ -72,7 +73,56 @@ export type BulkUploadProps = {
}
export function BulkUploadDrawer() {
- const { drawerSlug } = useBulkUpload()
+ const {
+ drawerSlug,
+ onCancel,
+ setInitialFiles,
+ setInitialForms,
+ setOnCancel,
+ setOnSuccess,
+ setSelectableCollections,
+ setSuccessfullyUploaded,
+ successfullyUploaded,
+ } = useBulkUpload()
+ const { modalState } = useModal()
+ const previousModalStateRef = React.useRef(modalState)
+
+ /**
+ * This is used to trigger onCancel when the drawer is closed (=> forms reset, as FormsManager is unmounted)
+ */
+ const onModalStateChanged = useEffectEvent((modalState) => {
+ const previousModalState = previousModalStateRef.current[drawerSlug]
+ const currentModalState = modalState[drawerSlug]
+
+ if (typeof currentModalState === 'undefined' && typeof previousModalState === 'undefined') {
+ return
+ }
+
+ if (previousModalState?.isOpen !== currentModalState?.isOpen) {
+ if (!currentModalState?.isOpen) {
+ if (!successfullyUploaded) {
+ // It's only cancelled if successfullyUploaded is not set. Otherwise, this would simply be a modal close after success
+ // => do not call cancel, just reset everything
+ if (typeof onCancel === 'function') {
+ onCancel()
+ }
+ }
+
+ // Reset everything to defaults
+ setInitialFiles(undefined)
+ setInitialForms(undefined)
+ setOnCancel(() => () => null)
+ setOnSuccess(() => () => null)
+ setSelectableCollections(null)
+ setSuccessfullyUploaded(false)
+ }
+ }
+ previousModalStateRef.current = modalState
+ })
+
+ useEffect(() => {
+ onModalStateChanged(modalState)
+ }, [modalState])
return (
@@ -87,32 +137,68 @@ export function BulkUploadDrawer() {
)
}
-type BulkUploadContext = {
- collectionSlug: string
+export type BulkUploadContext = {
+ collectionSlug: CollectionSlug
drawerSlug: string
initialFiles: FileList
+ /**
+ * Like initialFiles, but allows manually providing initial form state or the form ID for each file
+ */
+ initialForms: InitialForms
maxFiles: number
onCancel: () => void
- onSuccess: (newDocs: JsonObject[], errorCount: number) => void
+ onSuccess: (
+ uploadedForms: Array<{
+ collectionSlug: CollectionSlug
+ doc: JsonObject
+ /**
+ * ID of the form that created this document
+ */
+ formID: string
+ }>,
+ errorCount: number,
+ ) => void
+ /**
+ * An array of collection slugs that can be selected in the collection dropdown (if applicable)
+ * @default null - collection cannot be selected
+ */
+ selectableCollections?: null | string[]
setCollectionSlug: (slug: string) => void
setInitialFiles: (files: FileList) => void
+ setInitialForms: (
+ forms: ((forms: InitialForms | undefined) => InitialForms | undefined) | InitialForms,
+ ) => void
setMaxFiles: (maxFiles: number) => void
setOnCancel: (onCancel: BulkUploadContext['onCancel']) => void
setOnSuccess: (onSuccess: BulkUploadContext['onSuccess']) => void
+ /**
+ * Set the collections that can be selected in the collection dropdown (if applicable)
+ *
+ * @default null - collection cannot be selected
+ */
+ setSelectableCollections: (collections: null | string[]) => void
+ setSuccessfullyUploaded: (successfullyUploaded: boolean) => void
+ successfullyUploaded: boolean
}
const Context = React.createContext({
collectionSlug: '',
drawerSlug: '',
initialFiles: undefined,
+ initialForms: [],
maxFiles: undefined,
onCancel: () => null,
onSuccess: () => null,
+ selectableCollections: null,
setCollectionSlug: () => null,
setInitialFiles: () => null,
+ setInitialForms: () => null,
setMaxFiles: () => null,
setOnCancel: () => null,
setOnSuccess: () => null,
+ setSelectableCollections: () => null,
+ setSuccessfullyUploaded: () => false,
+ successfullyUploaded: false,
})
export function BulkUploadProvider({
children,
@@ -121,20 +207,23 @@ export function BulkUploadProvider({
readonly children: React.ReactNode
readonly drawerSlugPrefix?: string
}) {
+ const [selectableCollections, setSelectableCollections] = React.useState(null)
const [collection, setCollection] = React.useState()
const [onSuccessFunction, setOnSuccessFunction] = React.useState()
const [onCancelFunction, setOnCancelFunction] = React.useState()
const [initialFiles, setInitialFiles] = React.useState(undefined)
+ const [initialForms, setInitialForms] = React.useState(undefined)
const [maxFiles, setMaxFiles] = React.useState(undefined)
- const drawerSlug = `${drawerSlugPrefix ? `${drawerSlugPrefix}-` : ''}${useBulkUploadDrawerSlug()}`
+ const [successfullyUploaded, setSuccessfullyUploaded] = React.useState(false)
- const setCollectionSlug: BulkUploadContext['setCollectionSlug'] = (slug) => {
- setCollection(slug)
- }
+ const drawerSlug = `${drawerSlugPrefix ? `${drawerSlugPrefix}-` : ''}${useBulkUploadDrawerSlug()}`
const setOnSuccess: BulkUploadContext['setOnSuccess'] = (onSuccess) => {
setOnSuccessFunction(() => onSuccess)
}
+ const setOnCancel: BulkUploadContext['setOnCancel'] = (onCancel) => {
+ setOnCancelFunction(() => onCancel)
+ }
return (
{
if (typeof onCancelFunction === 'function') {
onCancelFunction()
}
},
- onSuccess: (docIDs, errorCount) => {
+ onSuccess: (newDocs, errorCount) => {
if (typeof onSuccessFunction === 'function') {
- onSuccessFunction(docIDs, errorCount)
+ onSuccessFunction(newDocs, errorCount)
}
},
- setCollectionSlug,
+ selectableCollections,
+ setCollectionSlug: setCollection,
setInitialFiles,
+ setInitialForms,
setMaxFiles,
- setOnCancel: setOnCancelFunction,
+ setOnCancel,
setOnSuccess,
+ setSelectableCollections,
+ setSuccessfullyUploaded,
+ successfullyUploaded,
}}
>
diff --git a/packages/ui/src/fields/Upload/Input.tsx b/packages/ui/src/fields/Upload/Input.tsx
index 708aca76d..10af94822 100644
--- a/packages/ui/src/fields/Upload/Input.tsx
+++ b/packages/ui/src/fields/Upload/Input.tsx
@@ -20,7 +20,7 @@ import React, { useCallback, useEffect, useMemo } from 'react'
import type { ListDrawerProps } from '../../elements/ListDrawer/types.js'
import type { PopulateDocs, ReloadDoc } from './types.js'
-import { useBulkUpload } from '../../elements/BulkUpload/index.js'
+import { type BulkUploadContext, useBulkUpload } from '../../elements/BulkUpload/index.js'
import { Button } from '../../elements/Button/index.js'
import { useDocumentDrawer } from '../../elements/DocumentDrawer/index.js'
import { Dropzone } from '../../elements/Dropzone/index.js'
@@ -244,33 +244,33 @@ export function UploadInput(props: UploadInputProps) {
[code, serverURL, api, i18n.language, t, hasMany],
)
- const onUploadSuccess = useCallback(
- (newDocs: JsonObject[]) => {
+ const onUploadSuccess: BulkUploadContext['onSuccess'] = useCallback(
+ (uploadedForms) => {
if (hasMany) {
const mergedValue = [
...(Array.isArray(value) ? value : []),
- ...newDocs.map((doc) => doc.id),
+ ...uploadedForms.map((form) => form.doc.id),
]
onChange(mergedValue)
setPopulatedDocs((currentDocs) => [
...(currentDocs || []),
- ...newDocs.map((doc) => ({
- relationTo: activeRelationTo,
- value: doc,
+ ...uploadedForms.map((form) => ({
+ relationTo: form.collectionSlug,
+ value: form.doc,
})),
])
} else {
- const firstDoc = newDocs[0]
+ const firstDoc = uploadedForms[0].doc
onChange(firstDoc.id)
setPopulatedDocs([
{
- relationTo: activeRelationTo,
+ relationTo: firstDoc.collectionSlug,
value: firstDoc,
},
])
}
},
- [value, onChange, activeRelationTo, hasMany],
+ [value, onChange, hasMany],
)
const onLocalFileSelection = React.useCallback(
diff --git a/test/lexical/baseConfig.ts b/test/lexical/baseConfig.ts
index f9c28c027..24bb0a093 100644
--- a/test/lexical/baseConfig.ts
+++ b/test/lexical/baseConfig.ts
@@ -22,7 +22,7 @@ import { OnDemandForm } from './collections/OnDemandForm/index.js'
import { OnDemandOutsideForm } from './collections/OnDemandOutsideForm/index.js'
import RichTextFields from './collections/RichText/index.js'
import TextFields from './collections/Text/index.js'
-import Uploads from './collections/Upload/index.js'
+import { Uploads, Uploads2 } from './collections/Upload/index.js'
import TabsWithRichText from './globals/TabsWithRichText.js'
import { seed } from './seed.js'
@@ -49,6 +49,7 @@ export const baseConfig: Partial = {
RichTextFields,
TextFields,
Uploads,
+ Uploads2,
ArrayFields,
OnDemandForm,
OnDemandOutsideForm,
@@ -60,9 +61,15 @@ export const baseConfig: Partial = {
baseDir: path.resolve(dirname),
},
components: {
+ views: {
+ custom: {
+ Component: './components/Image.js#Image',
+ path: '/custom-image',
+ },
+ },
beforeDashboard: [
{
- path: './components/CollectionsExplained.tsx#CollectionsExplained',
+ path: './components/CollectionsExplained.js#CollectionsExplained',
},
],
},
diff --git a/test/lexical/collections/LexicalHeadingFeature/e2e.spec.ts b/test/lexical/collections/LexicalHeadingFeature/e2e.spec.ts
index 185c0839f..96395a5e7 100644
--- a/test/lexical/collections/LexicalHeadingFeature/e2e.spec.ts
+++ b/test/lexical/collections/LexicalHeadingFeature/e2e.spec.ts
@@ -1,6 +1,5 @@
import { expect, test } from '@playwright/test'
import { AdminUrlUtil } from 'helpers/adminUrlUtil.js'
-import { reInitializeDB } from 'helpers/reInitializeDB.js'
import { lexicalHeadingFeatureSlug } from 'lexical/slugs.js'
import path from 'path'
import { fileURLToPath } from 'url'
diff --git a/test/lexical/collections/Upload/index.ts b/test/lexical/collections/Upload/index.ts
index e74514a46..dd443c33a 100644
--- a/test/lexical/collections/Upload/index.ts
+++ b/test/lexical/collections/Upload/index.ts
@@ -3,11 +3,11 @@ import type { CollectionConfig } from 'payload'
import path from 'path'
import { fileURLToPath } from 'url'
-import { uploadsSlug } from '../../slugs.js'
+import { uploads2Slug, uploadsSlug } from '../../slugs.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
-const Uploads: CollectionConfig = {
+export const Uploads: CollectionConfig = {
slug: uploadsSlug,
fields: [
{
@@ -34,4 +34,14 @@ const Uploads: CollectionConfig = {
},
}
-export default Uploads
+export const Uploads2: CollectionConfig = {
+ ...Uploads,
+ slug: uploads2Slug,
+ fields: [
+ ...Uploads.fields,
+ {
+ name: 'altText',
+ type: 'text',
+ },
+ ],
+}
diff --git a/test/lexical/collections/_LexicalFullyFeatured/db/e2e.spec.ts b/test/lexical/collections/_LexicalFullyFeatured/db/e2e.spec.ts
new file mode 100644
index 000000000..3b84db23c
--- /dev/null
+++ b/test/lexical/collections/_LexicalFullyFeatured/db/e2e.spec.ts
@@ -0,0 +1,127 @@
+import { expect, type Page, test } from '@playwright/test'
+import { lexicalFullyFeaturedSlug } from 'lexical/slugs.js'
+import path from 'path'
+import { fileURLToPath } from 'url'
+
+import type { PayloadTestSDK } from '../../../../helpers/sdk/index.js'
+import type { Config } from '../../../payload-types.js'
+
+import { ensureCompilationIsDone, saveDocAndAssert } from '../../../../helpers.js'
+import { AdminUrlUtil } from '../../../../helpers/adminUrlUtil.js'
+import { initPayloadE2ENoConfig } from '../../../../helpers/initPayloadE2ENoConfig.js'
+import { reInitializeDB } from '../../../../helpers/reInitializeDB.js'
+import { TEST_TIMEOUT_LONG } from '../../../../playwright.config.js'
+import { LexicalHelpers, type PasteMode } from '../../utils.js'
+
+const filename = fileURLToPath(import.meta.url)
+const currentFolder = path.dirname(filename)
+const dirname = path.resolve(currentFolder, '../../../')
+
+let payload: PayloadTestSDK
+let serverURL: string
+
+const { beforeAll, beforeEach, describe } = test
+
+// This test suite resets the database before each test to ensure a clean state and cannot be run in parallel.
+// Use this for tests that modify the database.
+describe('Lexical Fully Featured - database', () => {
+ let lexical: LexicalHelpers
+ let url: AdminUrlUtil
+ beforeAll(async ({ browser }, testInfo) => {
+ testInfo.setTimeout(TEST_TIMEOUT_LONG)
+ process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
+ ;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname }))
+
+ const page = await browser.newPage()
+ await ensureCompilationIsDone({ page, serverURL })
+ await page.close()
+ })
+ beforeEach(async ({ page }) => {
+ await reInitializeDB({
+ serverURL,
+ snapshotKey: 'lexicalTest',
+ uploadsDir: [path.resolve(dirname, './collections/Upload/uploads')],
+ })
+ url = new AdminUrlUtil(serverURL, lexicalFullyFeaturedSlug)
+ lexical = new LexicalHelpers(page)
+ await page.goto(url.create)
+ await lexical.editor.first().focus()
+ })
+
+ describe('auto upload', () => {
+ const filePath = path.resolve(dirname, './collections/Upload/payload.jpg')
+
+ async function uploadsTest(page: Page, mode: 'cmd+v' | PasteMode, expectedFileName?: string) {
+ if (mode === 'cmd+v') {
+ await page.keyboard.press('Meta+V')
+ await page.keyboard.press('Control+V')
+ } else {
+ await lexical.pasteFile({ filePath, mode })
+ }
+
+ await expect(lexical.drawer).toBeVisible()
+ await lexical.drawer.locator('.bulk-upload--actions-bar').getByText('Save').click()
+ await expect(lexical.drawer).toBeHidden()
+
+ await expect(lexical.editor.locator('.lexical-upload')).toHaveCount(1)
+ await expect(lexical.editor.locator('.lexical-upload__doc-drawer-toggler')).toHaveText(
+ expectedFileName || 'payload-1.jpg',
+ )
+
+ const uploadedImage = await payload.find({
+ collection: 'uploads',
+ where: { filename: { equals: expectedFileName || 'payload-1.jpg' } },
+ })
+ expect(uploadedImage.totalDocs).toBe(1)
+ }
+
+ // eslint-disable-next-line playwright/expect-expect
+ test('ensure auto upload by copy & pasting image works when pasting a blob', async ({
+ page,
+ }) => {
+ await uploadsTest(page, 'blob')
+ })
+
+ // eslint-disable-next-line playwright/expect-expect
+ test('ensure auto upload by copy & pasting image works when pasting as html', async ({
+ page,
+ }) => {
+ // blob will be put in src of img tag => cannot infer file name
+ await uploadsTest(page, 'html', 'pasted-image.jpeg')
+ })
+
+ test('ensure auto upload by copy & pasting image works when pasting from website', async ({
+ page,
+ }) => {
+ await page.goto(url.admin + '/custom-image')
+ await page.keyboard.press('Meta+A')
+ await page.keyboard.press('Control+A')
+
+ await page.keyboard.press('Meta+C')
+ await page.keyboard.press('Control+C')
+
+ await page.goto(url.create)
+ await lexical.editor.first().focus()
+ await expect(lexical.editor).toBeFocused()
+
+ await uploadsTest(page, 'cmd+v')
+
+ // Save page
+ await saveDocAndAssert(page)
+
+ const lexicalFullyFeatured = await payload.find({
+ collection: lexicalFullyFeaturedSlug,
+ limit: 1,
+ })
+ const richText = lexicalFullyFeatured?.docs?.[0]?.richText
+
+ const headingNode = richText?.root?.children[0]
+ expect(headingNode).toBeDefined()
+ expect(headingNode?.children?.[1]?.text).toBe('This is an image:')
+
+ const uploadNode = richText?.root?.children?.[1]?.children?.[0]
+ // @ts-expect-error unsafe access is fine in tests
+ expect(uploadNode.value?.filename).toBe('payload-1.jpg')
+ })
+ })
+})
diff --git a/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts b/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts
index eaf14e201..1742be995 100644
--- a/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts
+++ b/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts
@@ -1,33 +1,37 @@
import { expect, test } from '@playwright/test'
-import { AdminUrlUtil } from 'helpers/adminUrlUtil.js'
-import { reInitializeDB } from 'helpers/reInitializeDB.js'
-import { lexicalFullyFeaturedSlug } from 'lexical/slugs.js'
import path from 'path'
import { fileURLToPath } from 'url'
+import type { PayloadTestSDK } from '../../../helpers/sdk/index.js'
+import type { Config } from '../../payload-types.js'
+
import { ensureCompilationIsDone } from '../../../helpers.js'
+import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
+import { lexicalFullyFeaturedSlug } from '../../../lexical/slugs.js'
import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
import { LexicalHelpers } from '../utils.js'
+
const filename = fileURLToPath(import.meta.url)
const currentFolder = path.dirname(filename)
const dirname = path.resolve(currentFolder, '../../')
+let payload: PayloadTestSDK
+let serverURL: string
+
const { beforeAll, beforeEach, describe } = test
// Unlike the other suites, this one runs in parallel, as they run on the `lexical-fully-featured/create` URL and are "pure" tests
// PLEASE do not reset the database or perform any operations that modify it in this file.
test.describe.configure({ mode: 'parallel' })
-const { serverURL } = await initPayloadE2ENoConfig({
- dirname,
-})
-
describe('Lexical Fully Featured', () => {
let lexical: LexicalHelpers
beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
+ ;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname }))
+
const page = await browser.newPage()
await ensureCompilationIsDone({ page, serverURL })
await page.close()
diff --git a/test/lexical/collections/utils.ts b/test/lexical/collections/utils.ts
index 03075143c..9f5d2bdaf 100644
--- a/test/lexical/collections/utils.ts
+++ b/test/lexical/collections/utils.ts
@@ -1,8 +1,35 @@
import type { Locator, Page } from 'playwright'
import { expect } from '@playwright/test'
+import fs from 'fs'
+import path from 'path'
import { wait } from 'payload/shared'
+export type PasteMode = 'blob' | 'html'
+
+function inferMimeFromExt(ext: string): string {
+ switch (ext.toLowerCase()) {
+ case '.gif':
+ return 'image/gif'
+ case '.jpeg':
+ case '.jpg':
+ return 'image/jpeg'
+ case '.png':
+ return 'image/png'
+ case '.svg':
+ return 'image/svg+xml'
+ case '.webp':
+ return 'image/webp'
+ default:
+ return 'application/octet-stream'
+ }
+}
+
+async function readAsBase64(filePath: string): Promise {
+ const buf = await fs.promises.readFile(filePath)
+ return Buffer.from(buf).toString('base64')
+}
+
export class LexicalHelpers {
page: Page
constructor(page: Page) {
@@ -89,6 +116,8 @@ export class LexicalHelpers {
}
async paste(type: 'html' | 'markdown', text: string) {
+ await this.page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
+
await this.page.evaluate(
async ([text, type]) => {
const blob = new Blob([text!], { type: type === 'html' ? 'text/html' : 'text/markdown' })
@@ -100,6 +129,54 @@ export class LexicalHelpers {
await this.page.keyboard.press(`ControlOrMeta+v`)
}
+ async pasteFile({ filePath, mode: modeFromArgs }: { filePath: string; mode?: PasteMode }) {
+ const mode: PasteMode = modeFromArgs ?? 'blob'
+ const name = path.basename(filePath)
+ const mime = inferMimeFromExt(path.extname(name))
+
+ // Build payloads per mode
+ let payload:
+ | { bytes: number[]; kind: 'blob'; mime: string; name: string }
+ | { html: string; kind: 'html' } = { html: '', kind: 'html' }
+
+ if (mode === 'blob') {
+ const buf = await fs.promises.readFile(filePath)
+ payload = { kind: 'blob', bytes: Array.from(buf), name, mime }
+ } else if (mode === 'html') {
+ const b64 = await readAsBase64(filePath)
+ const src = `data:${mime};base64,${b64}`
+ const html = `
`
+ payload = { kind: 'html', html }
+ }
+
+ await this.page.evaluate((p) => {
+ const target =
+ (document.activeElement as HTMLElement | null) ||
+ document.querySelector('[contenteditable="true"]') ||
+ document.body
+
+ const dt = new DataTransfer()
+
+ if (p.kind === 'blob') {
+ const file = new File([new Uint8Array(p.bytes)], p.name, { type: p.mime })
+ dt.items.add(file)
+ } else if (p.kind === 'html') {
+ dt.setData('text/html', p.html)
+ }
+
+ try {
+ const evt = new ClipboardEvent('paste', {
+ clipboardData: dt,
+ bubbles: true,
+ cancelable: true,
+ })
+ target.dispatchEvent(evt)
+ } catch {
+ /* ignore */
+ }
+ }, payload)
+ }
+
async save(container: 'document' | 'drawer') {
if (container === 'drawer') {
await this.drawer.getByText('Save').click()
diff --git a/test/lexical/components/Image.tsx b/test/lexical/components/Image.tsx
new file mode 100644
index 000000000..f28f4c012
--- /dev/null
+++ b/test/lexical/components/Image.tsx
@@ -0,0 +1,22 @@
+import type { AdminViewServerProps } from 'payload'
+
+import React from 'react'
+
+export const Image: React.FC = async ({ payload }) => {
+ const images = await payload.find({
+ collection: 'uploads',
+ limit: 1,
+ })
+
+ if (!images?.docs?.length) {
+ return null
+ }
+
+ return (
+
+
This is an image:
+ {/* eslint-disable-next-line jsx-a11y/alt-text */}
+

+
+ )
+}
diff --git a/test/lexical/payload-types.ts b/test/lexical/payload-types.ts
index d2e0f053f..fa934ea72 100644
--- a/test/lexical/payload-types.ts
+++ b/test/lexical/payload-types.ts
@@ -97,6 +97,7 @@ export interface Config {
'rich-text-fields': RichTextField;
'text-fields': TextField;
uploads: Upload;
+ uploads2: Uploads2;
'array-fields': ArrayField;
OnDemandForm: OnDemandForm;
OnDemandOutsideForm: OnDemandOutsideForm;
@@ -121,6 +122,7 @@ export interface Config {
'rich-text-fields': RichTextFieldsSelect | RichTextFieldsSelect;
'text-fields': TextFieldsSelect | TextFieldsSelect;
uploads: UploadsSelect | UploadsSelect;
+ uploads2: Uploads2Select | Uploads2Select;
'array-fields': ArrayFieldsSelect | ArrayFieldsSelect;
OnDemandForm: OnDemandFormSelect | OnDemandFormSelect;
OnDemandOutsideForm: OnDemandOutsideFormSelect | OnDemandOutsideFormSelect;
@@ -760,6 +762,27 @@ export interface Upload {
focalX?: number | null;
focalY?: number | null;
}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "uploads2".
+ */
+export interface Uploads2 {
+ id: string;
+ text?: string | null;
+ media?: (string | null) | Upload;
+ altText?: string | null;
+ updatedAt: string;
+ createdAt: string;
+ url?: string | null;
+ thumbnailURL?: string | null;
+ filename?: string | null;
+ mimeType?: string | null;
+ filesize?: number | null;
+ width?: number | null;
+ height?: number | null;
+ focalX?: number | null;
+ focalY?: number | null;
+}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "array-fields".
@@ -996,6 +1019,10 @@ export interface PayloadLockedDocument {
relationTo: 'uploads';
value: number | Upload;
} | null)
+ | ({
+ relationTo: 'uploads2';
+ value: string | Uploads2;
+ } | null)
| ({
relationTo: 'array-fields';
value: number | ArrayField;
@@ -1288,6 +1315,26 @@ export interface UploadsSelect {
focalX?: T;
focalY?: T;
}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "uploads2_select".
+ */
+export interface Uploads2Select {
+ text?: T;
+ media?: T;
+ altText?: T;
+ updatedAt?: T;
+ createdAt?: T;
+ url?: T;
+ thumbnailURL?: T;
+ filename?: T;
+ mimeType?: T;
+ filesize?: T;
+ width?: T;
+ height?: T;
+ focalX?: T;
+ focalY?: T;
+}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "array-fields_select".
diff --git a/test/lexical/seed.ts b/test/lexical/seed.ts
index c249b4888..71b3eec48 100644
--- a/test/lexical/seed.ts
+++ b/test/lexical/seed.ts
@@ -16,6 +16,7 @@ import {
lexicalRelationshipFieldsSlug,
richTextFieldsSlug,
textFieldsSlug,
+ uploads2Slug,
uploadsSlug,
usersSlug,
} from './slugs.js'
@@ -125,6 +126,14 @@ export const seed = async (_payload: Payload) => {
overrideAccess: true,
})
+ const createdPNGDoc2 = await _payload.create({
+ collection: uploads2Slug,
+ data: {},
+ file: pngFile,
+ depth: 0,
+ overrideAccess: true,
+ })
+
const createdJPGDoc = await _payload.create({
collection: uploadsSlug,
data: {
diff --git a/test/lexical/slugs.ts b/test/lexical/slugs.ts
index a2fb62064..e40df09f6 100644
--- a/test/lexical/slugs.ts
+++ b/test/lexical/slugs.ts
@@ -15,6 +15,8 @@ export const richTextFieldsSlug = 'rich-text-fields'
// Auxiliary slugs
export const textFieldsSlug = 'text-fields'
export const uploadsSlug = 'uploads'
+export const uploads2Slug = 'uploads2'
+
export const arrayFieldsSlug = 'array-fields'
export const collectionSlugs = [
diff --git a/test/runE2E.ts b/test/runE2E.ts
index 9cd436969..ce4fd57b8 100644
--- a/test/runE2E.ts
+++ b/test/runE2E.ts
@@ -82,17 +82,26 @@ if (!suiteName) {
// Run specific suite
clearWebpackCache()
- const suitePath: string | undefined = path
- .resolve(dirname, inputSuitePath, 'e2e.spec.ts')
+ const suiteFolderPath: string | undefined = path
+ .resolve(dirname, inputSuitePath)
.replaceAll('__', '/')
+ const allSuitesInFolder = await globby(`${suiteFolderPath.replace(/\\/g, '/')}/*e2e.spec.ts`)
+
const baseTestFolder = inputSuitePath.split('__')[0]
- if (!suitePath || !baseTestFolder) {
+ if (!baseTestFolder || !allSuitesInFolder?.length) {
throw new Error(`No test suite found for ${suiteName}`)
}
- executePlaywright(suitePath, baseTestFolder, false, suiteConfigPath)
+ console.log(`\n\nExecuting all ${allSuitesInFolder.length} E2E tests...\n\n`)
+
+ console.log(`${allSuitesInFolder.join('\n')}\n`)
+
+ for (const file of allSuitesInFolder) {
+ clearWebpackCache()
+ executePlaywright(file, baseTestFolder, false, suiteConfigPath)
+ }
}
console.log('\nRESULTS:')