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