feat(richtext-lexical): support copy & pasting and drag & dopping files/images into the editor (#13868)
This PR adds support for inserting images into the rich text editor via both **copy & paste** and **drag & drop**, whether from local files or image DOM nodes. It leverages the bulk uploads UI to provide a smooth workflow for: - Selecting the target collection - Filling in any required fields defined on the uploads collection - Uploading multiple images at once This significantly improves the UX for adding images to rich text, and also works seamlessly when pasting images from external editors like Google Docs or Microsoft Word. Test pre-release: `3.57.0-internal.801ab5a` ## Showcase - drag & drop images from computer https://github.com/user-attachments/assets/c558c034-d2e4-40d8-9035-c0681389fb7b ## Showcase - copy & paste images from computer https://github.com/user-attachments/assets/f36faf94-5274-4151-b141-00aff2b0efa4 ## Showcase - copy & paste image DOM nodes https://github.com/user-attachments/assets/2839ed0f-3f28-4e8d-8b47-01d0cb947edc --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1211217132290841
This commit is contained in:
8
.github/workflows/main.yml
vendored
8
.github/workflows/main.yml
vendored
@@ -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
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { ShimmerEffect } from '@payloadcms/ui'
|
||||
|
||||
import '../index.scss'
|
||||
|
||||
export const PendingUploadComponent = (): React.ReactNode => {
|
||||
return (
|
||||
<div className={'lexical-upload'}>
|
||||
<ShimmerEffect height={'95px'} width={'203px'} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,53 +1,25 @@
|
||||
'use client'
|
||||
import type { SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode.js'
|
||||
import type { DOMConversionMap, DOMConversionOutput, LexicalNode, Spread } from 'lexical'
|
||||
import type { DOMConversionMap, LexicalNode } from 'lexical'
|
||||
import type { JSX } from 'react'
|
||||
|
||||
import ObjectID from 'bson-objectid'
|
||||
import { $applyNodeReplacement } from 'lexical'
|
||||
import * as React from 'react'
|
||||
|
||||
import type { UploadData } from '../../server/nodes/UploadNode.js'
|
||||
import type {
|
||||
Internal_UploadData,
|
||||
SerializedUploadNode,
|
||||
UploadData,
|
||||
} from '../../server/nodes/UploadNode.js'
|
||||
|
||||
import { isGoogleDocCheckboxImg, UploadServerNode } from '../../server/nodes/UploadNode.js'
|
||||
import { $convertUploadElement } from '../../server/nodes/conversions.js'
|
||||
import { UploadServerNode } from '../../server/nodes/UploadNode.js'
|
||||
import { PendingUploadComponent } from '../component/pending/index.js'
|
||||
|
||||
const RawUploadComponent = React.lazy(() =>
|
||||
import('../../client/component/index.js').then((module) => ({ default: module.UploadComponent })),
|
||||
)
|
||||
|
||||
function $convertUploadElement(domNode: HTMLImageElement): DOMConversionOutput | null {
|
||||
if (
|
||||
domNode.hasAttribute('data-lexical-upload-relation-to') &&
|
||||
domNode.hasAttribute('data-lexical-upload-id')
|
||||
) {
|
||||
const id = domNode.getAttribute('data-lexical-upload-id')
|
||||
const relationTo = domNode.getAttribute('data-lexical-upload-relation-to')
|
||||
|
||||
if (id != null && relationTo != null) {
|
||||
const node = $createUploadNode({
|
||||
data: {
|
||||
fields: {},
|
||||
relationTo,
|
||||
value: id,
|
||||
},
|
||||
})
|
||||
return { node }
|
||||
}
|
||||
}
|
||||
const img = domNode
|
||||
if (img.src.startsWith('file:///') || isGoogleDocCheckboxImg(img)) {
|
||||
return null
|
||||
}
|
||||
// TODO: Auto-upload functionality here!
|
||||
//}
|
||||
return null
|
||||
}
|
||||
|
||||
export type SerializedUploadNode = {
|
||||
children?: never // required so that our typed editor state doesn't automatically add children
|
||||
type: 'upload'
|
||||
} & Spread<UploadData, SerializedDecoratorBlockNode>
|
||||
|
||||
export class UploadNode extends UploadServerNode {
|
||||
static override clone(node: UploadServerNode): UploadServerNode {
|
||||
return super.clone(node)
|
||||
@@ -60,7 +32,7 @@ export class UploadNode extends UploadServerNode {
|
||||
static override importDOM(): DOMConversionMap<HTMLImageElement> {
|
||||
return {
|
||||
img: (node) => ({
|
||||
conversion: $convertUploadElement,
|
||||
conversion: (domNode) => $convertUploadElement(domNode, $createUploadNode),
|
||||
priority: 0,
|
||||
}),
|
||||
}
|
||||
@@ -75,9 +47,10 @@ export class UploadNode extends UploadServerNode {
|
||||
serializedNode.version = 3
|
||||
}
|
||||
|
||||
const importedData: UploadData = {
|
||||
const importedData: Internal_UploadData = {
|
||||
id: serializedNode.id,
|
||||
fields: serializedNode.fields,
|
||||
pending: (serializedNode as Internal_UploadData).pending,
|
||||
relationTo: serializedNode.relationTo,
|
||||
value: serializedNode.value,
|
||||
}
|
||||
@@ -89,6 +62,9 @@ export class UploadNode extends UploadServerNode {
|
||||
}
|
||||
|
||||
override decorate(): JSX.Element {
|
||||
if ((this.__data as Internal_UploadData).pending) {
|
||||
return <PendingUploadComponent />
|
||||
}
|
||||
return <RawUploadComponent data={this.__data} nodeKey={this.getKey()} />
|
||||
}
|
||||
|
||||
@@ -105,6 +81,7 @@ export function $createUploadNode({
|
||||
if (!data?.id) {
|
||||
data.id = new ObjectID.default().toHexString()
|
||||
}
|
||||
|
||||
return $applyNodeReplacement(new UploadNode({ data: data as UploadData }))
|
||||
}
|
||||
|
||||
|
||||
@@ -2,42 +2,225 @@
|
||||
import type { LexicalCommand } from 'lexical'
|
||||
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
|
||||
import { $insertNodeToNearestRoot, mergeRegister } from '@lexical/utils'
|
||||
import { useConfig } from '@payloadcms/ui'
|
||||
import { $dfsIterator, $insertNodeToNearestRoot, mergeRegister } from '@lexical/utils'
|
||||
import { useBulkUpload, useConfig, useEffectEvent, useModal } from '@payloadcms/ui'
|
||||
import ObjectID from 'bson-objectid'
|
||||
import {
|
||||
$createRangeSelection,
|
||||
$getPreviousSelection,
|
||||
$getSelection,
|
||||
$isParagraphNode,
|
||||
$isRangeSelection,
|
||||
$setSelection,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
createCommand,
|
||||
DROP_COMMAND,
|
||||
getDOMSelectionFromTarget,
|
||||
isHTMLElement,
|
||||
PASTE_COMMAND,
|
||||
} from 'lexical'
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
import type { PluginComponent } from '../../../typesClient.js'
|
||||
import type { UploadData } from '../../server/nodes/UploadNode.js'
|
||||
import type { Internal_UploadData, UploadData } from '../../server/nodes/UploadNode.js'
|
||||
import type { UploadFeaturePropsClient } from '../index.js'
|
||||
|
||||
import { UploadDrawer } from '../drawer/index.js'
|
||||
import { $createUploadNode, UploadNode } from '../nodes/UploadNode.js'
|
||||
import { $createUploadNode, $isUploadNode, UploadNode } from '../nodes/UploadNode.js'
|
||||
|
||||
export type InsertUploadPayload = Readonly<Omit<UploadData, 'id'> & Partial<Pick<UploadData, 'id'>>>
|
||||
|
||||
declare global {
|
||||
interface DragEvent {
|
||||
rangeOffset?: number
|
||||
rangeParent?: Node
|
||||
}
|
||||
}
|
||||
|
||||
function canDropImage(event: DragEvent): boolean {
|
||||
const target = event.target
|
||||
return !!(
|
||||
isHTMLElement(target) &&
|
||||
!target.closest('code, span.editor-image') &&
|
||||
isHTMLElement(target.parentElement) &&
|
||||
target.parentElement.closest('div.ContentEditable__root')
|
||||
)
|
||||
}
|
||||
|
||||
function getDragSelection(event: DragEvent): null | Range | undefined {
|
||||
// Source: https://github.com/AlessioGr/lexical/blob/main/packages/lexical-playground/src/plugins/ImagesPlugin/index.tsx
|
||||
let range
|
||||
const domSelection = getDOMSelectionFromTarget(event.target)
|
||||
if (document.caretRangeFromPoint) {
|
||||
range = document.caretRangeFromPoint(event.clientX, event.clientY)
|
||||
} else if (event.rangeParent && domSelection !== null) {
|
||||
domSelection.collapse(event.rangeParent, event.rangeOffset || 0)
|
||||
range = domSelection.getRangeAt(0)
|
||||
} else {
|
||||
throw Error(`Cannot get the selection when dragging`)
|
||||
}
|
||||
|
||||
return range
|
||||
}
|
||||
|
||||
export const INSERT_UPLOAD_COMMAND: LexicalCommand<InsertUploadPayload> =
|
||||
createCommand('INSERT_UPLOAD_COMMAND')
|
||||
|
||||
export const UploadPlugin: PluginComponent<UploadFeaturePropsClient> = ({ clientProps }) => {
|
||||
type FileToUpload = {
|
||||
alt?: string
|
||||
file: File
|
||||
/**
|
||||
* Bulk Upload Form ID that should be created, which can then be matched
|
||||
* against the node formID if the upload is successful
|
||||
*/
|
||||
formID: string
|
||||
}
|
||||
|
||||
export const UploadPlugin: PluginComponent<UploadFeaturePropsClient> = () => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const {
|
||||
config: { collections },
|
||||
} = useConfig()
|
||||
|
||||
const {
|
||||
drawerSlug: bulkUploadDrawerSlug,
|
||||
setCollectionSlug,
|
||||
setInitialForms,
|
||||
setOnCancel,
|
||||
setOnSuccess,
|
||||
setSelectableCollections,
|
||||
} = useBulkUpload()
|
||||
|
||||
const { isModalOpen, openModal } = useModal()
|
||||
|
||||
const openBulkUpload = useEffectEvent(({ files }: { files: FileToUpload[] }) => {
|
||||
if (files?.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
setInitialForms((initialForms) => [
|
||||
...(initialForms ?? []),
|
||||
...files.map((file) => ({
|
||||
file: file.file,
|
||||
formID: file.formID,
|
||||
})),
|
||||
])
|
||||
|
||||
if (!isModalOpen(bulkUploadDrawerSlug)) {
|
||||
const uploadCollections = collections.filter(({ upload }) => !!upload).map(({ slug }) => slug)
|
||||
if (!uploadCollections.length || !uploadCollections[0]) {
|
||||
return
|
||||
}
|
||||
|
||||
setCollectionSlug(uploadCollections[0])
|
||||
setSelectableCollections(uploadCollections)
|
||||
|
||||
setOnCancel(() => {
|
||||
// Remove all the pending upload nodes that were added but not uploaded
|
||||
editor.update(() => {
|
||||
for (const dfsNode of $dfsIterator()) {
|
||||
const node = dfsNode.node
|
||||
|
||||
if ($isUploadNode(node)) {
|
||||
const nodeData = node.getData()
|
||||
if ((nodeData as Internal_UploadData)?.pending) {
|
||||
node.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
setOnSuccess((newDocs) => {
|
||||
const newDocsMap = new Map(newDocs.map((doc) => [doc.formID, doc]))
|
||||
editor.update(() => {
|
||||
for (const dfsNode of $dfsIterator()) {
|
||||
const node = dfsNode.node
|
||||
if ($isUploadNode(node)) {
|
||||
const nodeData: Internal_UploadData = node.getData()
|
||||
|
||||
if (nodeData?.pending) {
|
||||
const newDoc = newDocsMap.get(nodeData.pending?.formID)
|
||||
if (newDoc) {
|
||||
node.replace(
|
||||
$createUploadNode({
|
||||
data: {
|
||||
id: new ObjectID.default().toHexString(),
|
||||
fields: {},
|
||||
relationTo: newDoc.collectionSlug,
|
||||
value: newDoc.doc.id,
|
||||
} as UploadData,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
openModal(bulkUploadDrawerSlug)
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([UploadNode])) {
|
||||
throw new Error('UploadPlugin: UploadNode not registered on editor')
|
||||
}
|
||||
|
||||
return mergeRegister(
|
||||
/**
|
||||
* Handle auto-uploading files if you copy & paste an image dom element from the clipboard
|
||||
*/
|
||||
editor.registerNodeTransform(UploadNode, (node) => {
|
||||
const nodeData: Internal_UploadData = node.getData()
|
||||
if (!nodeData?.pending) {
|
||||
return
|
||||
}
|
||||
|
||||
async function upload() {
|
||||
let transformedImage: FileToUpload | null = null
|
||||
|
||||
const src = nodeData?.pending?.src
|
||||
const formID = nodeData?.pending?.formID as string
|
||||
|
||||
if (src?.startsWith('data:')) {
|
||||
// It's a base64-encoded image
|
||||
const mimeMatch = src.match(/data:(image\/[a-zA-Z]+);base64,/)
|
||||
const mimeType = mimeMatch ? mimeMatch[1] : 'image/png' // Default to PNG if MIME type not found
|
||||
const base64Data = src.replace(/^data:image\/[a-zA-Z]+;base64,/, '')
|
||||
const byteCharacters = atob(base64Data)
|
||||
const byteNumbers = new Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
const file = new File([byteArray], 'pasted-image.' + mimeType?.split('/')[1], {
|
||||
type: mimeType,
|
||||
})
|
||||
transformedImage = { alt: undefined, file, formID }
|
||||
} else if (src?.startsWith('http') || src?.startsWith('https')) {
|
||||
// It's an image URL
|
||||
const res = await fetch(src)
|
||||
const blob = await res.blob()
|
||||
const inferredFileName =
|
||||
src.split('/').pop() || 'pasted-image' + blob.type.split('/')[1]
|
||||
const file = new File([blob], inferredFileName, {
|
||||
type: blob.type,
|
||||
})
|
||||
|
||||
transformedImage = { alt: undefined, file, formID }
|
||||
}
|
||||
|
||||
if (!transformedImage) {
|
||||
return
|
||||
}
|
||||
|
||||
openBulkUpload({ files: [transformedImage] })
|
||||
}
|
||||
void upload()
|
||||
}),
|
||||
editor.registerCommand<InsertUploadPayload>(
|
||||
INSERT_UPLOAD_COMMAND,
|
||||
(payload: InsertUploadPayload) => {
|
||||
@@ -70,6 +253,145 @@ export const UploadPlugin: PluginComponent<UploadFeaturePropsClient> = ({ client
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
editor.registerCommand(
|
||||
PASTE_COMMAND,
|
||||
(event) => {
|
||||
// Pending UploadNodes are automatically created when importDOM is called. However, if you paste a file from your computer
|
||||
// directly, importDOM won't be called, as it's not a HTML dom element. So we need to handle that case here.
|
||||
|
||||
if (!(event instanceof ClipboardEvent)) {
|
||||
return false
|
||||
}
|
||||
const clipboardData = event.clipboardData
|
||||
|
||||
if (!clipboardData?.types?.length || clipboardData?.types?.includes('text/html')) {
|
||||
// HTML is handled through importDOM => registerNodeTransform for pending UploadNode
|
||||
return false
|
||||
}
|
||||
|
||||
const files: FileToUpload[] = []
|
||||
if (clipboardData?.files?.length) {
|
||||
Array.from(clipboardData.files).forEach((file) => {
|
||||
files.push({
|
||||
alt: '',
|
||||
file,
|
||||
formID: new ObjectID.default().toHexString(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (files.length) {
|
||||
// Insert a pending UploadNode for each image
|
||||
editor.update(() => {
|
||||
const selection = $getSelection() || $getPreviousSelection()
|
||||
|
||||
if ($isRangeSelection(selection)) {
|
||||
for (const file of files) {
|
||||
const pendingUploadNode = new UploadNode({
|
||||
data: {
|
||||
pending: {
|
||||
formID: file.formID,
|
||||
src: URL.createObjectURL(file.file),
|
||||
},
|
||||
} as Internal_UploadData,
|
||||
})
|
||||
// we need to get the focus node before inserting the upload node, as $insertNodeToNearestRoot can change the focus node
|
||||
const { focus } = selection
|
||||
const focusNode = focus.getNode()
|
||||
// Insert upload node BEFORE potentially removing focusNode, as $insertNodeToNearestRoot errors if the focusNode doesn't exist
|
||||
$insertNodeToNearestRoot(pendingUploadNode)
|
||||
|
||||
// Delete the node it it's an empty paragraph
|
||||
if ($isParagraphNode(focusNode) && !focusNode.__first) {
|
||||
focusNode.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Open the bulk drawer - the node transform will not open it for us, as it does not handle blob/file uploads
|
||||
openBulkUpload({ files })
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
// Handle drag & drop of files from the desktop into the editor
|
||||
editor.registerCommand(
|
||||
DROP_COMMAND,
|
||||
(event) => {
|
||||
if (!(event instanceof DragEvent)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const dt = event.dataTransfer
|
||||
|
||||
if (!dt?.types?.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const files: FileToUpload[] = []
|
||||
if (dt?.files?.length) {
|
||||
Array.from(dt.files).forEach((file) => {
|
||||
files.push({
|
||||
alt: '',
|
||||
file,
|
||||
formID: new ObjectID.default().toHexString(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (files.length) {
|
||||
// Prevent the default browser drop handling, which would open the file in the browser
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
// Insert a PendingUploadNode for each image
|
||||
editor.update(() => {
|
||||
if (canDropImage(event)) {
|
||||
const range = getDragSelection(event)
|
||||
const selection = $createRangeSelection()
|
||||
if (range !== null && range !== undefined) {
|
||||
selection.applyDOMRange(range)
|
||||
}
|
||||
$setSelection(selection)
|
||||
|
||||
for (const file of files) {
|
||||
const pendingUploadNode = new UploadNode({
|
||||
data: {
|
||||
pending: {
|
||||
formID: file.formID,
|
||||
src: URL.createObjectURL(file.file),
|
||||
},
|
||||
} as Internal_UploadData,
|
||||
})
|
||||
// we need to get the focus node before inserting the upload node, as $insertNodeToNearestRoot can change the focus node
|
||||
const { focus } = selection
|
||||
const focusNode = focus.getNode()
|
||||
// Insert upload node BEFORE potentially removing focusNode, as $insertNodeToNearestRoot errors if the focusNode doesn't exist
|
||||
$insertNodeToNearestRoot(pendingUploadNode)
|
||||
|
||||
// Delete the node it it's an empty paragraph
|
||||
if ($isParagraphNode(focusNode) && !focusNode.__first) {
|
||||
focusNode.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Open the bulk drawer - the node transform will not open it for us, as it does not handle blob/file uploads
|
||||
openBulkUpload({ files })
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
)
|
||||
}, [editor])
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode.js'
|
||||
import type {
|
||||
DOMConversionMap,
|
||||
DOMConversionOutput,
|
||||
DOMExportOutput,
|
||||
ElementFormatType,
|
||||
LexicalNode,
|
||||
@@ -20,7 +19,8 @@ import type { JSX } from 'react'
|
||||
import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode.js'
|
||||
import ObjectID from 'bson-objectid'
|
||||
import { $applyNodeReplacement } from 'lexical'
|
||||
import * as React from 'react'
|
||||
|
||||
import { $convertUploadElement } from './conversions.js'
|
||||
|
||||
export type UploadData<TUploadExtraFieldsData extends JsonObject = JsonObject> = {
|
||||
[TCollectionSlug in CollectionSlug]: {
|
||||
@@ -37,6 +37,23 @@ export type UploadData<TUploadExtraFieldsData extends JsonObject = JsonObject> =
|
||||
}
|
||||
}[CollectionSlug]
|
||||
|
||||
/**
|
||||
* Internal use only - UploadData type that can contain a pending state
|
||||
* @internal
|
||||
*/
|
||||
export type Internal_UploadData<TUploadExtraFieldsData extends JsonObject = JsonObject> = {
|
||||
pending?: {
|
||||
/**
|
||||
* ID that corresponds to the bulk upload form ID
|
||||
*/
|
||||
formID: string
|
||||
/**
|
||||
* src value of the image dom element
|
||||
*/
|
||||
src: string
|
||||
}
|
||||
} & UploadData<TUploadExtraFieldsData>
|
||||
|
||||
/**
|
||||
* UploadDataImproved is a more precise type, and will replace UploadData in Payload v4.
|
||||
* This type is for internal use only as it will be deprecated in the future.
|
||||
@@ -59,43 +76,6 @@ export type UploadDataImproved<TUploadExtraFieldsData extends JsonObject = JsonO
|
||||
}
|
||||
}[UploadCollectionSlug]
|
||||
|
||||
export function isGoogleDocCheckboxImg(img: HTMLImageElement): boolean {
|
||||
return (
|
||||
img.parentElement != null &&
|
||||
img.parentElement.tagName === 'LI' &&
|
||||
img.previousSibling === null &&
|
||||
img.getAttribute('aria-roledescription') === 'checkbox'
|
||||
)
|
||||
}
|
||||
|
||||
function $convertUploadServerElement(domNode: HTMLImageElement): DOMConversionOutput | null {
|
||||
if (
|
||||
domNode.hasAttribute('data-lexical-upload-relation-to') &&
|
||||
domNode.hasAttribute('data-lexical-upload-id')
|
||||
) {
|
||||
const id = domNode.getAttribute('data-lexical-upload-id')
|
||||
const relationTo = domNode.getAttribute('data-lexical-upload-relation-to')
|
||||
|
||||
if (id != null && relationTo != null) {
|
||||
const node = $createUploadServerNode({
|
||||
data: {
|
||||
fields: {},
|
||||
relationTo,
|
||||
value: id,
|
||||
},
|
||||
})
|
||||
return { node }
|
||||
}
|
||||
}
|
||||
const img = domNode
|
||||
if (img.src.startsWith('file:///') || isGoogleDocCheckboxImg(img)) {
|
||||
return null
|
||||
}
|
||||
// TODO: Auto-upload functionality here!
|
||||
//}
|
||||
return null
|
||||
}
|
||||
|
||||
export type SerializedUploadNode = {
|
||||
children?: never // required so that our typed editor state doesn't automatically add children
|
||||
type: 'upload'
|
||||
@@ -132,7 +112,7 @@ export class UploadServerNode extends DecoratorBlockNode {
|
||||
static override importDOM(): DOMConversionMap<HTMLImageElement> {
|
||||
return {
|
||||
img: (node) => ({
|
||||
conversion: $convertUploadServerElement,
|
||||
conversion: (domNode) => $convertUploadElement(domNode, $createUploadServerNode),
|
||||
priority: 0,
|
||||
}),
|
||||
}
|
||||
@@ -147,9 +127,10 @@ export class UploadServerNode extends DecoratorBlockNode {
|
||||
serializedNode.version = 3
|
||||
}
|
||||
|
||||
const importedData: UploadData = {
|
||||
const importedData: Internal_UploadData = {
|
||||
id: serializedNode.id,
|
||||
fields: serializedNode.fields,
|
||||
pending: (serializedNode as Internal_UploadData).pending,
|
||||
relationTo: serializedNode.relationTo,
|
||||
value: serializedNode.value,
|
||||
}
|
||||
@@ -165,14 +146,19 @@ export class UploadServerNode extends DecoratorBlockNode {
|
||||
}
|
||||
|
||||
override decorate(): JSX.Element {
|
||||
// @ts-expect-error
|
||||
return <RawUploadComponent data={this.__data} format={this.__format} nodeKey={this.getKey()} />
|
||||
return null as unknown as JSX.Element
|
||||
}
|
||||
|
||||
override exportDOM(): DOMExportOutput {
|
||||
const element = document.createElement('img')
|
||||
element.setAttribute('data-lexical-upload-id', String(this.__data?.value))
|
||||
element.setAttribute('data-lexical-upload-relation-to', this.__data?.relationTo)
|
||||
const data = this.__data as Internal_UploadData
|
||||
if (data.pending) {
|
||||
element.setAttribute('data-lexical-pending-upload-form-id', String(data?.pending?.formID))
|
||||
element.setAttribute('src', data?.pending?.src || '')
|
||||
} else {
|
||||
element.setAttribute('data-lexical-upload-id', String(data?.value))
|
||||
element.setAttribute('data-lexical-upload-relation-to', data?.relationTo)
|
||||
}
|
||||
|
||||
return { element }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -5,8 +5,10 @@ import { useWindowInfo } from '@faceless-ui/window-info'
|
||||
import { isImage } from 'payload/shared'
|
||||
import React from 'react'
|
||||
|
||||
import { SelectInput } from '../../../fields/Select/Input.js'
|
||||
import { ChevronIcon } from '../../../icons/Chevron/index.js'
|
||||
import { XIcon } from '../../../icons/X/index.js'
|
||||
import { useConfig } from '../../../providers/Config/index.js'
|
||||
import { useTranslation } from '../../../providers/Translation/index.js'
|
||||
import { AnimateHeight } from '../../AnimateHeight/index.js'
|
||||
import { Button } from '../../Button/index.js'
|
||||
@@ -18,9 +20,9 @@ import { createThumbnail } from '../../Thumbnail/createThumbnail.js'
|
||||
import { Thumbnail } from '../../Thumbnail/index.js'
|
||||
import { Actions } from '../ActionsBar/index.js'
|
||||
import { AddFilesView } from '../AddFilesView/index.js'
|
||||
import './index.scss'
|
||||
import { useFormsManager } from '../FormsManager/index.js'
|
||||
import { useBulkUpload } from '../index.js'
|
||||
import './index.scss'
|
||||
|
||||
const addMoreFilesDrawerSlug = 'bulk-upload-drawer--add-more-files'
|
||||
|
||||
@@ -36,7 +38,7 @@ export function FileSidebar() {
|
||||
setActiveIndex,
|
||||
totalErrorCount,
|
||||
} = useFormsManager()
|
||||
const { initialFiles, maxFiles } = useBulkUpload()
|
||||
const { initialFiles, initialForms, maxFiles } = useBulkUpload()
|
||||
const { i18n, t } = useTranslation()
|
||||
const { closeModal, openModal } = useModal()
|
||||
const [showFiles, setShowFiles] = React.useState(false)
|
||||
@@ -66,7 +68,17 @@ export function FileSidebar() {
|
||||
return formattedSize
|
||||
}, [])
|
||||
|
||||
const totalFileCount = isInitializing ? initialFiles.length : forms.length
|
||||
const totalFileCount = isInitializing
|
||||
? (initialFiles?.length ?? initialForms?.length)
|
||||
: forms.length
|
||||
|
||||
const {
|
||||
collectionSlug: bulkUploadCollectionSlug,
|
||||
selectableCollections,
|
||||
setCollectionSlug,
|
||||
} = useBulkUpload()
|
||||
|
||||
const { getEntityConfig } = useConfig()
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -74,6 +86,29 @@ export function FileSidebar() {
|
||||
>
|
||||
{breakpoints.m && showFiles ? <div className={`${baseClass}__mobileBlur`} /> : null}
|
||||
<div className={`${baseClass}__header`}>
|
||||
{selectableCollections?.length > 1 && (
|
||||
<SelectInput
|
||||
className={`${baseClass}__collectionSelect`}
|
||||
isClearable={false}
|
||||
name="groupBy"
|
||||
onChange={(e) => {
|
||||
const val: string =
|
||||
typeof e === 'object' && 'value' in e
|
||||
? (e?.value as string)
|
||||
: (e as unknown as string)
|
||||
setCollectionSlug(val)
|
||||
}}
|
||||
options={
|
||||
selectableCollections?.map((coll) => {
|
||||
const config = getEntityConfig({ collectionSlug: coll })
|
||||
return { label: config.labels.singular, value: config.slug }
|
||||
}) || []
|
||||
}
|
||||
path="groupBy"
|
||||
required
|
||||
value={bulkUploadCollectionSlug}
|
||||
/>
|
||||
)}
|
||||
<div className={`${baseClass}__headerTopRow`}>
|
||||
<div className={`${baseClass}__header__text`}>
|
||||
<ErrorPill count={totalErrorCount} i18n={i18n} withMessage />
|
||||
@@ -130,8 +165,10 @@ export function FileSidebar() {
|
||||
<div className={`${baseClass}__animateWrapper`}>
|
||||
<AnimateHeight height={!breakpoints.m || showFiles ? 'auto' : 0}>
|
||||
<div className={`${baseClass}__filesContainer`}>
|
||||
{isInitializing && forms.length === 0 && initialFiles.length > 0
|
||||
? Array.from(initialFiles).map((file, index) => (
|
||||
{isInitializing &&
|
||||
forms.length === 0 &&
|
||||
(initialFiles?.length > 0 || initialForms?.length > 0)
|
||||
? (initialFiles ? Array.from(initialFiles) : initialForms).map((file, index) => (
|
||||
<ShimmerEffect
|
||||
animationDelay={`calc(${index} * ${60}ms)`}
|
||||
height="35px"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
CollectionSlug,
|
||||
Data,
|
||||
DocumentSlots,
|
||||
FormState,
|
||||
JsonObject,
|
||||
SanitizedDocumentPermissions,
|
||||
UploadEdits,
|
||||
} from 'payload'
|
||||
@@ -16,6 +18,7 @@ import { toast } from 'sonner'
|
||||
import type { State } from './reducer.js'
|
||||
|
||||
import { fieldReducer } from '../../../forms/Form/fieldReducer.js'
|
||||
import { useEffectEvent } from '../../../hooks/useEffectEvent.js'
|
||||
import { useConfig } from '../../../providers/Config/index.js'
|
||||
import { useLocale } from '../../../providers/Locale/index.js'
|
||||
import { useServerFunctions } from '../../../providers/ServerFunctions/index.js'
|
||||
@@ -86,6 +89,12 @@ const initialState: State = {
|
||||
totalErrorCount: 0,
|
||||
}
|
||||
|
||||
export type InitialForms = Array<{
|
||||
file: File
|
||||
formID?: string
|
||||
initialState?: FormState | null
|
||||
}>
|
||||
|
||||
type FormsManagerProps = {
|
||||
readonly children: React.ReactNode
|
||||
}
|
||||
@@ -118,7 +127,16 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
|
||||
|
||||
const { toggleLoadingOverlay } = useLoadingOverlay()
|
||||
const { closeModal } = useModal()
|
||||
const { collectionSlug, drawerSlug, initialFiles, onSuccess, setInitialFiles } = useBulkUpload()
|
||||
const {
|
||||
collectionSlug,
|
||||
drawerSlug,
|
||||
initialFiles,
|
||||
initialForms,
|
||||
onSuccess,
|
||||
setInitialFiles,
|
||||
setInitialForms,
|
||||
setSuccessfullyUploaded,
|
||||
} = useBulkUpload()
|
||||
|
||||
const [isUploading, setIsUploading] = React.useState(false)
|
||||
const [loadingText, setLoadingText] = React.useState('')
|
||||
@@ -244,12 +262,38 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
|
||||
if (!hasInitializedState) {
|
||||
await initializeSharedFormState()
|
||||
}
|
||||
dispatch({ type: 'ADD_FORMS', files, initialState: initialStateRef.current })
|
||||
dispatch({
|
||||
type: 'ADD_FORMS',
|
||||
forms: Array.from(files).map((file) => ({
|
||||
file,
|
||||
initialState: initialStateRef.current,
|
||||
})),
|
||||
})
|
||||
toggleLoadingOverlay({ isLoading: false, key: 'addingDocs' })
|
||||
},
|
||||
[initializeSharedFormState, hasInitializedState, toggleLoadingOverlay, activeIndex, forms],
|
||||
)
|
||||
|
||||
const addFilesEffectEvent = useEffectEvent(addFiles)
|
||||
|
||||
const addInitialForms = useEffectEvent(async (initialForms: InitialForms) => {
|
||||
toggleLoadingOverlay({ isLoading: true, key: 'addingDocs' })
|
||||
|
||||
if (!hasInitializedState) {
|
||||
await initializeSharedFormState()
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'ADD_FORMS',
|
||||
forms: initialForms.map((form) => ({
|
||||
...form,
|
||||
initialState: form?.initialState || initialStateRef.current,
|
||||
})),
|
||||
})
|
||||
|
||||
toggleLoadingOverlay({ isLoading: false, key: 'addingDocs' })
|
||||
})
|
||||
|
||||
const removeFile: FormsManagerContext['removeFile'] = React.useCallback((index) => {
|
||||
dispatch({ type: 'REMOVE_FORM', index })
|
||||
}, [])
|
||||
@@ -275,7 +319,14 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
|
||||
formState: currentFormsData,
|
||||
uploadEdits: currentForms[activeIndex].uploadEdits,
|
||||
}
|
||||
const newDocs = []
|
||||
const newDocs: Array<{
|
||||
collectionSlug: CollectionSlug
|
||||
doc: JsonObject
|
||||
/**
|
||||
* ID of the form that created this document
|
||||
*/
|
||||
formID: string
|
||||
}> = []
|
||||
|
||||
setIsUploading(true)
|
||||
|
||||
@@ -309,7 +360,11 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
|
||||
const json = await req.json()
|
||||
|
||||
if (req.status === 201 && json?.doc) {
|
||||
newDocs.push(json.doc)
|
||||
newDocs.push({
|
||||
collectionSlug,
|
||||
doc: json.doc,
|
||||
formID: form.formID,
|
||||
})
|
||||
}
|
||||
|
||||
// should expose some sort of helper for this
|
||||
@@ -380,6 +435,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
|
||||
|
||||
if (successCount) {
|
||||
toast.success(`Successfully saved ${successCount} files`)
|
||||
setSuccessfullyUploaded(true)
|
||||
|
||||
if (typeof onSuccess === 'function') {
|
||||
onSuccess(newDocs, errorCount)
|
||||
@@ -403,20 +459,23 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
|
||||
|
||||
if (remainingForms.length === 0) {
|
||||
setInitialFiles(undefined)
|
||||
setInitialForms(undefined)
|
||||
}
|
||||
},
|
||||
[
|
||||
setInitialFiles,
|
||||
actionURL,
|
||||
collectionSlug,
|
||||
getUploadHandler,
|
||||
t,
|
||||
forms,
|
||||
activeIndex,
|
||||
closeModal,
|
||||
drawerSlug,
|
||||
onSuccess,
|
||||
t,
|
||||
actionURL,
|
||||
code,
|
||||
collectionSlug,
|
||||
getUploadHandler,
|
||||
onSuccess,
|
||||
closeModal,
|
||||
setSuccessfullyUploaded,
|
||||
drawerSlug,
|
||||
setInitialFiles,
|
||||
setInitialForms,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -504,7 +563,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
|
||||
void initializeSharedDocPermissions()
|
||||
}
|
||||
|
||||
if (initialFiles) {
|
||||
if (initialFiles || initialForms) {
|
||||
if (!hasInitializedState || !hasInitializedDocPermissions) {
|
||||
setIsInitializing(true)
|
||||
} else {
|
||||
@@ -512,19 +571,28 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
|
||||
}
|
||||
}
|
||||
|
||||
if (hasInitializedState && initialFiles && !hasInitializedWithFiles.current) {
|
||||
void addFiles(initialFiles)
|
||||
if (
|
||||
hasInitializedState &&
|
||||
(initialForms?.length || initialFiles?.length) &&
|
||||
!hasInitializedWithFiles.current
|
||||
) {
|
||||
if (initialForms?.length) {
|
||||
void addInitialForms(initialForms)
|
||||
}
|
||||
if (initialFiles?.length) {
|
||||
void addFilesEffectEvent(initialFiles)
|
||||
}
|
||||
hasInitializedWithFiles.current = true
|
||||
}
|
||||
return
|
||||
}, [
|
||||
addFiles,
|
||||
initialFiles,
|
||||
initializeSharedFormState,
|
||||
initializeSharedDocPermissions,
|
||||
collectionSlug,
|
||||
hasInitializedState,
|
||||
hasInitializedDocPermissions,
|
||||
initialForms,
|
||||
])
|
||||
|
||||
return (
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import type { JsonObject } from 'payload'
|
||||
import type { CollectionSlug, JsonObject } from 'payload'
|
||||
|
||||
import { useModal } from '@faceless-ui/modal'
|
||||
import { validateMimeType } from 'payload/shared'
|
||||
import React from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useEffectEvent } from '../../hooks/useEffectEvent.js'
|
||||
import { useConfig } from '../../providers/Config/index.js'
|
||||
import { EditDepthProvider } from '../../providers/EditDepth/index.js'
|
||||
import { useTranslation } from '../../providers/Translation/index.js'
|
||||
@@ -14,7 +15,7 @@ import { UploadControlsProvider } from '../../providers/UploadControls/index.js'
|
||||
import { Drawer, useDrawerDepth } from '../Drawer/index.js'
|
||||
import { AddFilesView } from './AddFilesView/index.js'
|
||||
import { AddingFilesView } from './AddingFilesView/index.js'
|
||||
import { FormsManagerProvider, useFormsManager } from './FormsManager/index.js'
|
||||
import { FormsManagerProvider, type InitialForms, useFormsManager } from './FormsManager/index.js'
|
||||
|
||||
const drawerSlug = 'bulk-upload-drawer-slug'
|
||||
|
||||
@@ -72,7 +73,56 @@ export type BulkUploadProps = {
|
||||
}
|
||||
|
||||
export function BulkUploadDrawer() {
|
||||
const { drawerSlug } = useBulkUpload()
|
||||
const {
|
||||
drawerSlug,
|
||||
onCancel,
|
||||
setInitialFiles,
|
||||
setInitialForms,
|
||||
setOnCancel,
|
||||
setOnSuccess,
|
||||
setSelectableCollections,
|
||||
setSuccessfullyUploaded,
|
||||
successfullyUploaded,
|
||||
} = useBulkUpload()
|
||||
const { modalState } = useModal()
|
||||
const previousModalStateRef = React.useRef(modalState)
|
||||
|
||||
/**
|
||||
* This is used to trigger onCancel when the drawer is closed (=> forms reset, as FormsManager is unmounted)
|
||||
*/
|
||||
const onModalStateChanged = useEffectEvent((modalState) => {
|
||||
const previousModalState = previousModalStateRef.current[drawerSlug]
|
||||
const currentModalState = modalState[drawerSlug]
|
||||
|
||||
if (typeof currentModalState === 'undefined' && typeof previousModalState === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
if (previousModalState?.isOpen !== currentModalState?.isOpen) {
|
||||
if (!currentModalState?.isOpen) {
|
||||
if (!successfullyUploaded) {
|
||||
// It's only cancelled if successfullyUploaded is not set. Otherwise, this would simply be a modal close after success
|
||||
// => do not call cancel, just reset everything
|
||||
if (typeof onCancel === 'function') {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Reset everything to defaults
|
||||
setInitialFiles(undefined)
|
||||
setInitialForms(undefined)
|
||||
setOnCancel(() => () => null)
|
||||
setOnSuccess(() => () => null)
|
||||
setSelectableCollections(null)
|
||||
setSuccessfullyUploaded(false)
|
||||
}
|
||||
}
|
||||
previousModalStateRef.current = modalState
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
onModalStateChanged(modalState)
|
||||
}, [modalState])
|
||||
|
||||
return (
|
||||
<Drawer gutter={false} Header={null} slug={drawerSlug}>
|
||||
@@ -87,32 +137,68 @@ export function BulkUploadDrawer() {
|
||||
)
|
||||
}
|
||||
|
||||
type BulkUploadContext = {
|
||||
collectionSlug: string
|
||||
export type BulkUploadContext = {
|
||||
collectionSlug: CollectionSlug
|
||||
drawerSlug: string
|
||||
initialFiles: FileList
|
||||
/**
|
||||
* Like initialFiles, but allows manually providing initial form state or the form ID for each file
|
||||
*/
|
||||
initialForms: InitialForms
|
||||
maxFiles: number
|
||||
onCancel: () => void
|
||||
onSuccess: (newDocs: JsonObject[], errorCount: number) => void
|
||||
onSuccess: (
|
||||
uploadedForms: Array<{
|
||||
collectionSlug: CollectionSlug
|
||||
doc: JsonObject
|
||||
/**
|
||||
* ID of the form that created this document
|
||||
*/
|
||||
formID: string
|
||||
}>,
|
||||
errorCount: number,
|
||||
) => void
|
||||
/**
|
||||
* An array of collection slugs that can be selected in the collection dropdown (if applicable)
|
||||
* @default null - collection cannot be selected
|
||||
*/
|
||||
selectableCollections?: null | string[]
|
||||
setCollectionSlug: (slug: string) => void
|
||||
setInitialFiles: (files: FileList) => void
|
||||
setInitialForms: (
|
||||
forms: ((forms: InitialForms | undefined) => InitialForms | undefined) | InitialForms,
|
||||
) => void
|
||||
setMaxFiles: (maxFiles: number) => void
|
||||
setOnCancel: (onCancel: BulkUploadContext['onCancel']) => void
|
||||
setOnSuccess: (onSuccess: BulkUploadContext['onSuccess']) => void
|
||||
/**
|
||||
* Set the collections that can be selected in the collection dropdown (if applicable)
|
||||
*
|
||||
* @default null - collection cannot be selected
|
||||
*/
|
||||
setSelectableCollections: (collections: null | string[]) => void
|
||||
setSuccessfullyUploaded: (successfullyUploaded: boolean) => void
|
||||
successfullyUploaded: boolean
|
||||
}
|
||||
|
||||
const Context = React.createContext<BulkUploadContext>({
|
||||
collectionSlug: '',
|
||||
drawerSlug: '',
|
||||
initialFiles: undefined,
|
||||
initialForms: [],
|
||||
maxFiles: undefined,
|
||||
onCancel: () => null,
|
||||
onSuccess: () => null,
|
||||
selectableCollections: null,
|
||||
setCollectionSlug: () => null,
|
||||
setInitialFiles: () => null,
|
||||
setInitialForms: () => null,
|
||||
setMaxFiles: () => null,
|
||||
setOnCancel: () => null,
|
||||
setOnSuccess: () => null,
|
||||
setSelectableCollections: () => null,
|
||||
setSuccessfullyUploaded: () => false,
|
||||
successfullyUploaded: false,
|
||||
})
|
||||
export function BulkUploadProvider({
|
||||
children,
|
||||
@@ -121,20 +207,23 @@ export function BulkUploadProvider({
|
||||
readonly children: React.ReactNode
|
||||
readonly drawerSlugPrefix?: string
|
||||
}) {
|
||||
const [selectableCollections, setSelectableCollections] = React.useState<null | string[]>(null)
|
||||
const [collection, setCollection] = React.useState<string>()
|
||||
const [onSuccessFunction, setOnSuccessFunction] = React.useState<BulkUploadContext['onSuccess']>()
|
||||
const [onCancelFunction, setOnCancelFunction] = React.useState<BulkUploadContext['onCancel']>()
|
||||
const [initialFiles, setInitialFiles] = React.useState<FileList>(undefined)
|
||||
const [initialForms, setInitialForms] = React.useState<InitialForms>(undefined)
|
||||
const [maxFiles, setMaxFiles] = React.useState<number>(undefined)
|
||||
const drawerSlug = `${drawerSlugPrefix ? `${drawerSlugPrefix}-` : ''}${useBulkUploadDrawerSlug()}`
|
||||
const [successfullyUploaded, setSuccessfullyUploaded] = React.useState<boolean>(false)
|
||||
|
||||
const setCollectionSlug: BulkUploadContext['setCollectionSlug'] = (slug) => {
|
||||
setCollection(slug)
|
||||
}
|
||||
const drawerSlug = `${drawerSlugPrefix ? `${drawerSlugPrefix}-` : ''}${useBulkUploadDrawerSlug()}`
|
||||
|
||||
const setOnSuccess: BulkUploadContext['setOnSuccess'] = (onSuccess) => {
|
||||
setOnSuccessFunction(() => onSuccess)
|
||||
}
|
||||
const setOnCancel: BulkUploadContext['setOnCancel'] = (onCancel) => {
|
||||
setOnCancelFunction(() => onCancel)
|
||||
}
|
||||
|
||||
return (
|
||||
<Context
|
||||
@@ -142,22 +231,28 @@ export function BulkUploadProvider({
|
||||
collectionSlug: collection,
|
||||
drawerSlug,
|
||||
initialFiles,
|
||||
initialForms,
|
||||
maxFiles,
|
||||
onCancel: () => {
|
||||
if (typeof onCancelFunction === 'function') {
|
||||
onCancelFunction()
|
||||
}
|
||||
},
|
||||
onSuccess: (docIDs, errorCount) => {
|
||||
onSuccess: (newDocs, errorCount) => {
|
||||
if (typeof onSuccessFunction === 'function') {
|
||||
onSuccessFunction(docIDs, errorCount)
|
||||
onSuccessFunction(newDocs, errorCount)
|
||||
}
|
||||
},
|
||||
setCollectionSlug,
|
||||
selectableCollections,
|
||||
setCollectionSlug: setCollection,
|
||||
setInitialFiles,
|
||||
setInitialForms,
|
||||
setMaxFiles,
|
||||
setOnCancel: setOnCancelFunction,
|
||||
setOnCancel,
|
||||
setOnSuccess,
|
||||
setSelectableCollections,
|
||||
setSuccessfullyUploaded,
|
||||
successfullyUploaded,
|
||||
}}
|
||||
>
|
||||
<React.Fragment>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -22,7 +22,7 @@ import { OnDemandForm } from './collections/OnDemandForm/index.js'
|
||||
import { OnDemandOutsideForm } from './collections/OnDemandOutsideForm/index.js'
|
||||
import RichTextFields from './collections/RichText/index.js'
|
||||
import TextFields from './collections/Text/index.js'
|
||||
import Uploads from './collections/Upload/index.js'
|
||||
import { Uploads, Uploads2 } from './collections/Upload/index.js'
|
||||
import TabsWithRichText from './globals/TabsWithRichText.js'
|
||||
import { seed } from './seed.js'
|
||||
|
||||
@@ -49,6 +49,7 @@ export const baseConfig: Partial<Config> = {
|
||||
RichTextFields,
|
||||
TextFields,
|
||||
Uploads,
|
||||
Uploads2,
|
||||
ArrayFields,
|
||||
OnDemandForm,
|
||||
OnDemandOutsideForm,
|
||||
@@ -60,9 +61,15 @@ export const baseConfig: Partial<Config> = {
|
||||
baseDir: path.resolve(dirname),
|
||||
},
|
||||
components: {
|
||||
views: {
|
||||
custom: {
|
||||
Component: './components/Image.js#Image',
|
||||
path: '/custom-image',
|
||||
},
|
||||
},
|
||||
beforeDashboard: [
|
||||
{
|
||||
path: './components/CollectionsExplained.tsx#CollectionsExplained',
|
||||
path: './components/CollectionsExplained.js#CollectionsExplained',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
127
test/lexical/collections/_LexicalFullyFeatured/db/e2e.spec.ts
Normal file
127
test/lexical/collections/_LexicalFullyFeatured/db/e2e.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { expect, type Page, test } from '@playwright/test'
|
||||
import { lexicalFullyFeaturedSlug } from 'lexical/slugs.js'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import type { PayloadTestSDK } from '../../../../helpers/sdk/index.js'
|
||||
import type { Config } from '../../../payload-types.js'
|
||||
|
||||
import { ensureCompilationIsDone, saveDocAndAssert } from '../../../../helpers.js'
|
||||
import { AdminUrlUtil } from '../../../../helpers/adminUrlUtil.js'
|
||||
import { initPayloadE2ENoConfig } from '../../../../helpers/initPayloadE2ENoConfig.js'
|
||||
import { reInitializeDB } from '../../../../helpers/reInitializeDB.js'
|
||||
import { TEST_TIMEOUT_LONG } from '../../../../playwright.config.js'
|
||||
import { LexicalHelpers, type PasteMode } from '../../utils.js'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const currentFolder = path.dirname(filename)
|
||||
const dirname = path.resolve(currentFolder, '../../../')
|
||||
|
||||
let payload: PayloadTestSDK<Config>
|
||||
let serverURL: string
|
||||
|
||||
const { beforeAll, beforeEach, describe } = test
|
||||
|
||||
// This test suite resets the database before each test to ensure a clean state and cannot be run in parallel.
|
||||
// Use this for tests that modify the database.
|
||||
describe('Lexical Fully Featured - database', () => {
|
||||
let lexical: LexicalHelpers
|
||||
let url: AdminUrlUtil
|
||||
beforeAll(async ({ browser }, testInfo) => {
|
||||
testInfo.setTimeout(TEST_TIMEOUT_LONG)
|
||||
process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
|
||||
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
|
||||
|
||||
const page = await browser.newPage()
|
||||
await ensureCompilationIsDone({ page, serverURL })
|
||||
await page.close()
|
||||
})
|
||||
beforeEach(async ({ page }) => {
|
||||
await reInitializeDB({
|
||||
serverURL,
|
||||
snapshotKey: 'lexicalTest',
|
||||
uploadsDir: [path.resolve(dirname, './collections/Upload/uploads')],
|
||||
})
|
||||
url = new AdminUrlUtil(serverURL, lexicalFullyFeaturedSlug)
|
||||
lexical = new LexicalHelpers(page)
|
||||
await page.goto(url.create)
|
||||
await lexical.editor.first().focus()
|
||||
})
|
||||
|
||||
describe('auto upload', () => {
|
||||
const filePath = path.resolve(dirname, './collections/Upload/payload.jpg')
|
||||
|
||||
async function uploadsTest(page: Page, mode: 'cmd+v' | PasteMode, expectedFileName?: string) {
|
||||
if (mode === 'cmd+v') {
|
||||
await page.keyboard.press('Meta+V')
|
||||
await page.keyboard.press('Control+V')
|
||||
} else {
|
||||
await lexical.pasteFile({ filePath, mode })
|
||||
}
|
||||
|
||||
await expect(lexical.drawer).toBeVisible()
|
||||
await lexical.drawer.locator('.bulk-upload--actions-bar').getByText('Save').click()
|
||||
await expect(lexical.drawer).toBeHidden()
|
||||
|
||||
await expect(lexical.editor.locator('.lexical-upload')).toHaveCount(1)
|
||||
await expect(lexical.editor.locator('.lexical-upload__doc-drawer-toggler')).toHaveText(
|
||||
expectedFileName || 'payload-1.jpg',
|
||||
)
|
||||
|
||||
const uploadedImage = await payload.find({
|
||||
collection: 'uploads',
|
||||
where: { filename: { equals: expectedFileName || 'payload-1.jpg' } },
|
||||
})
|
||||
expect(uploadedImage.totalDocs).toBe(1)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test('ensure auto upload by copy & pasting image works when pasting a blob', async ({
|
||||
page,
|
||||
}) => {
|
||||
await uploadsTest(page, 'blob')
|
||||
})
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test('ensure auto upload by copy & pasting image works when pasting as html', async ({
|
||||
page,
|
||||
}) => {
|
||||
// blob will be put in src of img tag => cannot infer file name
|
||||
await uploadsTest(page, 'html', 'pasted-image.jpeg')
|
||||
})
|
||||
|
||||
test('ensure auto upload by copy & pasting image works when pasting from website', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(url.admin + '/custom-image')
|
||||
await page.keyboard.press('Meta+A')
|
||||
await page.keyboard.press('Control+A')
|
||||
|
||||
await page.keyboard.press('Meta+C')
|
||||
await page.keyboard.press('Control+C')
|
||||
|
||||
await page.goto(url.create)
|
||||
await lexical.editor.first().focus()
|
||||
await expect(lexical.editor).toBeFocused()
|
||||
|
||||
await uploadsTest(page, 'cmd+v')
|
||||
|
||||
// Save page
|
||||
await saveDocAndAssert(page)
|
||||
|
||||
const lexicalFullyFeatured = await payload.find({
|
||||
collection: lexicalFullyFeaturedSlug,
|
||||
limit: 1,
|
||||
})
|
||||
const richText = lexicalFullyFeatured?.docs?.[0]?.richText
|
||||
|
||||
const headingNode = richText?.root?.children[0]
|
||||
expect(headingNode).toBeDefined()
|
||||
expect(headingNode?.children?.[1]?.text).toBe('This is an image:')
|
||||
|
||||
const uploadNode = richText?.root?.children?.[1]?.children?.[0]
|
||||
// @ts-expect-error unsafe access is fine in tests
|
||||
expect(uploadNode.value?.filename).toBe('payload-1.jpg')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,33 +1,37 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { AdminUrlUtil } from 'helpers/adminUrlUtil.js'
|
||||
import { reInitializeDB } from 'helpers/reInitializeDB.js'
|
||||
import { lexicalFullyFeaturedSlug } from 'lexical/slugs.js'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import type { PayloadTestSDK } from '../../../helpers/sdk/index.js'
|
||||
import type { Config } from '../../payload-types.js'
|
||||
|
||||
import { ensureCompilationIsDone } from '../../../helpers.js'
|
||||
import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
|
||||
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
|
||||
import { lexicalFullyFeaturedSlug } from '../../../lexical/slugs.js'
|
||||
import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
|
||||
import { LexicalHelpers } from '../utils.js'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const currentFolder = path.dirname(filename)
|
||||
const dirname = path.resolve(currentFolder, '../../')
|
||||
|
||||
let payload: PayloadTestSDK<Config>
|
||||
let serverURL: string
|
||||
|
||||
const { beforeAll, beforeEach, describe } = test
|
||||
|
||||
// Unlike the other suites, this one runs in parallel, as they run on the `lexical-fully-featured/create` URL and are "pure" tests
|
||||
// PLEASE do not reset the database or perform any operations that modify it in this file.
|
||||
test.describe.configure({ mode: 'parallel' })
|
||||
|
||||
const { serverURL } = await initPayloadE2ENoConfig({
|
||||
dirname,
|
||||
})
|
||||
|
||||
describe('Lexical Fully Featured', () => {
|
||||
let lexical: LexicalHelpers
|
||||
beforeAll(async ({ browser }, testInfo) => {
|
||||
testInfo.setTimeout(TEST_TIMEOUT_LONG)
|
||||
process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
|
||||
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
|
||||
|
||||
const page = await browser.newPage()
|
||||
await ensureCompilationIsDone({ page, serverURL })
|
||||
await page.close()
|
||||
|
||||
@@ -1,8 +1,35 @@
|
||||
import type { Locator, Page } from 'playwright'
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { wait } from 'payload/shared'
|
||||
|
||||
export type PasteMode = 'blob' | 'html'
|
||||
|
||||
function inferMimeFromExt(ext: string): string {
|
||||
switch (ext.toLowerCase()) {
|
||||
case '.gif':
|
||||
return 'image/gif'
|
||||
case '.jpeg':
|
||||
case '.jpg':
|
||||
return 'image/jpeg'
|
||||
case '.png':
|
||||
return 'image/png'
|
||||
case '.svg':
|
||||
return 'image/svg+xml'
|
||||
case '.webp':
|
||||
return 'image/webp'
|
||||
default:
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
}
|
||||
|
||||
async function readAsBase64(filePath: string): Promise<string> {
|
||||
const buf = await fs.promises.readFile(filePath)
|
||||
return Buffer.from(buf).toString('base64')
|
||||
}
|
||||
|
||||
export class LexicalHelpers {
|
||||
page: Page
|
||||
constructor(page: Page) {
|
||||
@@ -89,6 +116,8 @@ export class LexicalHelpers {
|
||||
}
|
||||
|
||||
async paste(type: 'html' | 'markdown', text: string) {
|
||||
await this.page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
|
||||
await this.page.evaluate(
|
||||
async ([text, type]) => {
|
||||
const blob = new Blob([text!], { type: type === 'html' ? 'text/html' : 'text/markdown' })
|
||||
@@ -100,6 +129,54 @@ export class LexicalHelpers {
|
||||
await this.page.keyboard.press(`ControlOrMeta+v`)
|
||||
}
|
||||
|
||||
async pasteFile({ filePath, mode: modeFromArgs }: { filePath: string; mode?: PasteMode }) {
|
||||
const mode: PasteMode = modeFromArgs ?? 'blob'
|
||||
const name = path.basename(filePath)
|
||||
const mime = inferMimeFromExt(path.extname(name))
|
||||
|
||||
// Build payloads per mode
|
||||
let payload:
|
||||
| { bytes: number[]; kind: 'blob'; mime: string; name: string }
|
||||
| { html: string; kind: 'html' } = { html: '', kind: 'html' }
|
||||
|
||||
if (mode === 'blob') {
|
||||
const buf = await fs.promises.readFile(filePath)
|
||||
payload = { kind: 'blob', bytes: Array.from(buf), name, mime }
|
||||
} else if (mode === 'html') {
|
||||
const b64 = await readAsBase64(filePath)
|
||||
const src = `data:${mime};base64,${b64}`
|
||||
const html = `<img src="${src}" alt="${name}">`
|
||||
payload = { kind: 'html', html }
|
||||
}
|
||||
|
||||
await this.page.evaluate((p) => {
|
||||
const target =
|
||||
(document.activeElement as HTMLElement | null) ||
|
||||
document.querySelector('[contenteditable="true"]') ||
|
||||
document.body
|
||||
|
||||
const dt = new DataTransfer()
|
||||
|
||||
if (p.kind === 'blob') {
|
||||
const file = new File([new Uint8Array(p.bytes)], p.name, { type: p.mime })
|
||||
dt.items.add(file)
|
||||
} else if (p.kind === 'html') {
|
||||
dt.setData('text/html', p.html)
|
||||
}
|
||||
|
||||
try {
|
||||
const evt = new ClipboardEvent('paste', {
|
||||
clipboardData: dt,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
target.dispatchEvent(evt)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, payload)
|
||||
}
|
||||
|
||||
async save(container: 'document' | 'drawer') {
|
||||
if (container === 'drawer') {
|
||||
await this.drawer.getByText('Save').click()
|
||||
|
||||
22
test/lexical/components/Image.tsx
Normal file
22
test/lexical/components/Image.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { AdminViewServerProps } from 'payload'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export const Image: React.FC<AdminViewServerProps> = async ({ payload }) => {
|
||||
const images = await payload.find({
|
||||
collection: 'uploads',
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
if (!images?.docs?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>This is an image:</h2>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
<img src={images?.docs?.[0]?.url as string} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -97,6 +97,7 @@ export interface Config {
|
||||
'rich-text-fields': RichTextField;
|
||||
'text-fields': TextField;
|
||||
uploads: Upload;
|
||||
uploads2: Uploads2;
|
||||
'array-fields': ArrayField;
|
||||
OnDemandForm: OnDemandForm;
|
||||
OnDemandOutsideForm: OnDemandOutsideForm;
|
||||
@@ -121,6 +122,7 @@ export interface Config {
|
||||
'rich-text-fields': RichTextFieldsSelect<false> | RichTextFieldsSelect<true>;
|
||||
'text-fields': TextFieldsSelect<false> | TextFieldsSelect<true>;
|
||||
uploads: UploadsSelect<false> | UploadsSelect<true>;
|
||||
uploads2: Uploads2Select<false> | Uploads2Select<true>;
|
||||
'array-fields': ArrayFieldsSelect<false> | ArrayFieldsSelect<true>;
|
||||
OnDemandForm: OnDemandFormSelect<false> | OnDemandFormSelect<true>;
|
||||
OnDemandOutsideForm: OnDemandOutsideFormSelect<false> | OnDemandOutsideFormSelect<true>;
|
||||
@@ -760,6 +762,27 @@ export interface Upload {
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "uploads2".
|
||||
*/
|
||||
export interface Uploads2 {
|
||||
id: string;
|
||||
text?: string | null;
|
||||
media?: (string | null) | Upload;
|
||||
altText?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
url?: string | null;
|
||||
thumbnailURL?: string | null;
|
||||
filename?: string | null;
|
||||
mimeType?: string | null;
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "array-fields".
|
||||
@@ -996,6 +1019,10 @@ export interface PayloadLockedDocument {
|
||||
relationTo: 'uploads';
|
||||
value: number | Upload;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'uploads2';
|
||||
value: string | Uploads2;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'array-fields';
|
||||
value: number | ArrayField;
|
||||
@@ -1288,6 +1315,26 @@ export interface UploadsSelect<T extends boolean = true> {
|
||||
focalX?: T;
|
||||
focalY?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "uploads2_select".
|
||||
*/
|
||||
export interface Uploads2Select<T extends boolean = true> {
|
||||
text?: T;
|
||||
media?: T;
|
||||
altText?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
url?: T;
|
||||
thumbnailURL?: T;
|
||||
filename?: T;
|
||||
mimeType?: T;
|
||||
filesize?: T;
|
||||
width?: T;
|
||||
height?: T;
|
||||
focalX?: T;
|
||||
focalY?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "array-fields_select".
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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:')
|
||||
|
||||
Reference in New Issue
Block a user