From 59414bd8f1838b94fdccf8c65237a58d134685e9 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Wed, 24 Sep 2025 08:04:46 -0700 Subject: [PATCH] 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 --- .github/workflows/main.yml | 8 + .../upload/client/component/pending/index.tsx | 13 + .../upload/client/nodes/UploadNode.tsx | 55 +-- .../features/upload/client/plugin/index.tsx | 332 +++++++++++++++++- .../upload/server/nodes/UploadNode.tsx | 76 ++-- .../upload/server/nodes/conversions.ts | 68 ++++ .../BulkUpload/FileSidebar/index.scss | 15 +- .../elements/BulkUpload/FileSidebar/index.tsx | 47 ++- .../BulkUpload/FormsManager/index.tsx | 100 +++++- .../BulkUpload/FormsManager/reducer.ts | 15 +- packages/ui/src/elements/BulkUpload/index.tsx | 125 ++++++- packages/ui/src/fields/Upload/Input.tsx | 20 +- test/lexical/baseConfig.ts | 11 +- .../LexicalHeadingFeature/e2e.spec.ts | 1 - test/lexical/collections/Upload/index.ts | 16 +- .../_LexicalFullyFeatured/db/e2e.spec.ts | 127 +++++++ .../_LexicalFullyFeatured/e2e.spec.ts | 18 +- test/lexical/collections/utils.ts | 77 ++++ test/lexical/components/Image.tsx | 22 ++ test/lexical/payload-types.ts | 47 +++ test/lexical/seed.ts | 9 + test/lexical/slugs.ts | 2 + test/runE2E.ts | 17 +- 23 files changed, 1061 insertions(+), 160 deletions(-) create mode 100644 packages/richtext-lexical/src/features/upload/client/component/pending/index.tsx create mode 100644 packages/richtext-lexical/src/features/upload/server/nodes/conversions.ts create mode 100644 test/lexical/collections/_LexicalFullyFeatured/db/e2e.spec.ts create mode 100644 test/lexical/components/Image.tsx 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 = `${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() 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:')