feat(richtext-lexical): support copy & pasting and drag & dopping files/images into the editor (#13868)

This PR adds support for inserting images into the rich text editor via
both **copy & paste** and **drag & drop**, whether from local files or
image DOM nodes.

It leverages the bulk uploads UI to provide a smooth workflow for:
- Selecting the target collection
- Filling in any required fields defined on the uploads collection
- Uploading multiple images at once

This significantly improves the UX for adding images to rich text, and
also works seamlessly when pasting images from external editors like
Google Docs or Microsoft Word.

Test pre-release: `3.57.0-internal.801ab5a`

## Showcase - drag & drop images from computer


https://github.com/user-attachments/assets/c558c034-d2e4-40d8-9035-c0681389fb7b

## Showcase - copy & paste images from computer


https://github.com/user-attachments/assets/f36faf94-5274-4151-b141-00aff2b0efa4

## Showcase - copy & paste image DOM nodes


https://github.com/user-attachments/assets/2839ed0f-3f28-4e8d-8b47-01d0cb947edc

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211217132290841
This commit is contained in:
Alessio Gravili
2025-09-24 08:04:46 -07:00
committed by GitHub
parent 062c1d7e89
commit 59414bd8f1
23 changed files with 1061 additions and 160 deletions

View File

@@ -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

View File

@@ -0,0 +1,13 @@
'use client'
import { ShimmerEffect } from '@payloadcms/ui'
import '../index.scss'
export const PendingUploadComponent = (): React.ReactNode => {
return (
<div className={'lexical-upload'}>
<ShimmerEffect height={'95px'} width={'203px'} />
</div>
)
}

View File

@@ -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<UploadData, SerializedDecoratorBlockNode>
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<HTMLImageElement> {
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 <PendingUploadComponent />
}
return <RawUploadComponent data={this.__data} nodeKey={this.getKey()} />
}
@@ -105,6 +81,7 @@ export function $createUploadNode({
if (!data?.id) {
data.id = new ObjectID.default().toHexString()
}
return $applyNodeReplacement(new UploadNode({ data: data as UploadData }))
}

View File

@@ -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<Omit<UploadData, 'id'> & Partial<Pick<UploadData, 'id'>>>
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<InsertUploadPayload> =
createCommand('INSERT_UPLOAD_COMMAND')
export const UploadPlugin: PluginComponent<UploadFeaturePropsClient> = ({ 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<UploadFeaturePropsClient> = () => {
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<InsertUploadPayload>(
INSERT_UPLOAD_COMMAND,
(payload: InsertUploadPayload) => {
@@ -70,6 +253,145 @@ export const UploadPlugin: PluginComponent<UploadFeaturePropsClient> = ({ 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])

View File

@@ -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<TUploadExtraFieldsData extends JsonObject = JsonObject> = {
[TCollectionSlug in CollectionSlug]: {
@@ -37,6 +37,23 @@ export type UploadData<TUploadExtraFieldsData extends JsonObject = JsonObject> =
}
}[CollectionSlug]
/**
* Internal use only - UploadData type that can contain a pending state
* @internal
*/
export type Internal_UploadData<TUploadExtraFieldsData extends JsonObject = JsonObject> = {
pending?: {
/**
* ID that corresponds to the bulk upload form ID
*/
formID: string
/**
* src value of the image dom element
*/
src: string
}
} & UploadData<TUploadExtraFieldsData>
/**
* 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<TUploadExtraFieldsData extends JsonObject = JsonO
}
}[UploadCollectionSlug]
export function isGoogleDocCheckboxImg(img: HTMLImageElement): boolean {
return (
img.parentElement != null &&
img.parentElement.tagName === 'LI' &&
img.previousSibling === null &&
img.getAttribute('aria-roledescription') === 'checkbox'
)
}
function $convertUploadServerElement(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 = $createUploadServerNode({
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'
@@ -132,7 +112,7 @@ export class UploadServerNode extends DecoratorBlockNode {
static override importDOM(): DOMConversionMap<HTMLImageElement> {
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 <RawUploadComponent data={this.__data} format={this.__format} nodeKey={this.getKey()} />
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 }
}

View File

@@ -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 }
}

View File

@@ -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;

View File

@@ -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 (
<div
@@ -74,6 +86,29 @@ export function FileSidebar() {
>
{breakpoints.m && showFiles ? <div className={`${baseClass}__mobileBlur`} /> : null}
<div className={`${baseClass}__header`}>
{selectableCollections?.length > 1 && (
<SelectInput
className={`${baseClass}__collectionSelect`}
isClearable={false}
name="groupBy"
onChange={(e) => {
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}
/>
)}
<div className={`${baseClass}__headerTopRow`}>
<div className={`${baseClass}__header__text`}>
<ErrorPill count={totalErrorCount} i18n={i18n} withMessage />
@@ -130,8 +165,10 @@ export function FileSidebar() {
<div className={`${baseClass}__animateWrapper`}>
<AnimateHeight height={!breakpoints.m || showFiles ? 'auto' : 0}>
<div className={`${baseClass}__filesContainer`}>
{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) => (
<ShimmerEffect
animationDelay={`calc(${index} * ${60}ms)`}
height="35px"

View File

@@ -1,9 +1,11 @@
'use client'
import type {
CollectionSlug,
Data,
DocumentSlots,
FormState,
JsonObject,
SanitizedDocumentPermissions,
UploadEdits,
} from 'payload'
@@ -16,6 +18,7 @@ import { toast } from 'sonner'
import type { State } from './reducer.js'
import { fieldReducer } from '../../../forms/Form/fieldReducer.js'
import { useEffectEvent } from '../../../hooks/useEffectEvent.js'
import { useConfig } from '../../../providers/Config/index.js'
import { useLocale } from '../../../providers/Locale/index.js'
import { useServerFunctions } from '../../../providers/ServerFunctions/index.js'
@@ -86,6 +89,12 @@ const initialState: State = {
totalErrorCount: 0,
}
export type InitialForms = Array<{
file: File
formID?: string
initialState?: FormState | null
}>
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 (

View File

@@ -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: {},

View File

@@ -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 (
<Drawer gutter={false} Header={null} slug={drawerSlug}>
@@ -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<BulkUploadContext>({
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 | string[]>(null)
const [collection, setCollection] = React.useState<string>()
const [onSuccessFunction, setOnSuccessFunction] = React.useState<BulkUploadContext['onSuccess']>()
const [onCancelFunction, setOnCancelFunction] = React.useState<BulkUploadContext['onCancel']>()
const [initialFiles, setInitialFiles] = React.useState<FileList>(undefined)
const [initialForms, setInitialForms] = React.useState<InitialForms>(undefined)
const [maxFiles, setMaxFiles] = React.useState<number>(undefined)
const drawerSlug = `${drawerSlugPrefix ? `${drawerSlugPrefix}-` : ''}${useBulkUploadDrawerSlug()}`
const [successfullyUploaded, setSuccessfullyUploaded] = React.useState<boolean>(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 (
<Context
@@ -142,22 +231,28 @@ export function BulkUploadProvider({
collectionSlug: collection,
drawerSlug,
initialFiles,
initialForms,
maxFiles,
onCancel: () => {
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,
}}
>
<React.Fragment>

View File

@@ -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(

View File

@@ -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<Config> = {
RichTextFields,
TextFields,
Uploads,
Uploads2,
ArrayFields,
OnDemandForm,
OnDemandOutsideForm,
@@ -60,9 +61,15 @@ export const baseConfig: Partial<Config> = {
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',
},
],
},

View File

@@ -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'

View File

@@ -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',
},
],
}

View File

@@ -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<Config>
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<Config>({ 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')
})
})
})

View File

@@ -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<Config>
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<Config>({ dirname }))
const page = await browser.newPage()
await ensureCompilationIsDone({ page, serverURL })
await page.close()

View File

@@ -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<string> {
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 = `<img src="${src}" alt="${name}">`
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()

View File

@@ -0,0 +1,22 @@
import type { AdminViewServerProps } from 'payload'
import React from 'react'
export const Image: React.FC<AdminViewServerProps> = async ({ payload }) => {
const images = await payload.find({
collection: 'uploads',
limit: 1,
})
if (!images?.docs?.length) {
return null
}
return (
<div>
<h2>This is an image:</h2>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img src={images?.docs?.[0]?.url as string} />
</div>
)
}

View File

@@ -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<false> | RichTextFieldsSelect<true>;
'text-fields': TextFieldsSelect<false> | TextFieldsSelect<true>;
uploads: UploadsSelect<false> | UploadsSelect<true>;
uploads2: Uploads2Select<false> | Uploads2Select<true>;
'array-fields': ArrayFieldsSelect<false> | ArrayFieldsSelect<true>;
OnDemandForm: OnDemandFormSelect<false> | OnDemandFormSelect<true>;
OnDemandOutsideForm: OnDemandOutsideFormSelect<false> | OnDemandOutsideFormSelect<true>;
@@ -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<T extends boolean = true> {
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "uploads2_select".
*/
export interface Uploads2Select<T extends boolean = true> {
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".

View File

@@ -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: {

View File

@@ -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 = [

View File

@@ -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:')