chore: separate Lexical tests into dedicated suite (#12047)

Lexical tests comprise almost half of the collections in the fields
suite, and are starting to become complex to manage.

They are sometimes related to other auxiliary collections, so
refactoring one test sometimes breaks another, seemingly unrelated one.

In addition, the fields suite is very large, taking a long time to
compile. This will make it faster.

Some ideas for future refactorings:
- 3 main collections: defaultFeatures, fully featured, and legacy.
Legacy is the current one that has multiple editors and could later be
migrated to the first two.
- Avoid collections with more than 1 editor.
- Create reseed buttons to restore the editor to certain states, to
avoid a proliferation of collections and documents.
- Reduce the complexity of the three auxiliary collections (text, array,
upload), which are rarely or never used and have many fields designed
for tests in the fields suite.
This commit is contained in:
Germán Jabloñski
2025-04-10 20:47:26 -03:00
committed by GitHub
parent 272914c818
commit a66f90ebb6
68 changed files with 2668 additions and 1210 deletions

View File

@@ -0,0 +1,63 @@
import { fileURLToPath } from 'node:url'
import path from 'path'
import { type Config } from 'payload'
import ArrayFields from './collections/Array/index.js'
import {
getLexicalFieldsCollection,
lexicalBlocks,
lexicalInlineBlocks,
} from './collections/Lexical/index.js'
import { LexicalAccessControl } from './collections/LexicalAccessControl/index.js'
import { LexicalInBlock } from './collections/LexicalInBlock/index.js'
import { LexicalLocalizedFields } from './collections/LexicalLocalized/index.js'
import { LexicalMigrateFields } from './collections/LexicalMigrate/index.js'
import { LexicalObjectReferenceBugCollection } from './collections/LexicalObjectReferenceBug/index.js'
import { LexicalRelationshipsFields } from './collections/LexicalRelationships/index.js'
import RichTextFields from './collections/RichText/index.js'
import TextFields from './collections/Text/index.js'
import Uploads from './collections/Upload/index.js'
import TabsWithRichText from './globals/TabsWithRichText.js'
import { clearAndSeedEverything } from './seed.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export const baseConfig: Partial<Config> = {
// ...extend config here
collections: [
getLexicalFieldsCollection({
blocks: lexicalBlocks,
inlineBlocks: lexicalInlineBlocks,
}),
LexicalMigrateFields,
LexicalLocalizedFields,
LexicalObjectReferenceBugCollection,
LexicalInBlock,
LexicalAccessControl,
LexicalRelationshipsFields,
RichTextFields,
TextFields,
Uploads,
ArrayFields,
],
globals: [TabsWithRichText],
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
onInit: async (payload) => {
if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') {
await clearAndSeedEverything(payload)
}
},
localization: {
defaultLocale: 'en',
fallback: true,
locales: ['en', 'es'],
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
}

View File

@@ -0,0 +1,29 @@
'use client'
import { useForm } from '@payloadcms/ui'
const AddRowButton = () => {
const { addFieldRow } = useForm()
const handleClick = () => {
addFieldRow({
path: 'externallyUpdatedArray',
schemaPath: 'externallyUpdatedArray',
subFieldState: {
text: {
initialValue: 'Hello, world!',
valid: true,
value: 'Hello, world!',
},
},
})
}
return (
<button id="updateArrayExternally" onClick={handleClick} type="button">
Add Row
</button>
)
}
export default AddRowButton

View File

@@ -0,0 +1,12 @@
'use client'
import type { ArrayFieldClientComponent } from 'payload'
import { ArrayField } from '@payloadcms/ui'
export const CustomArrayField: ArrayFieldClientComponent = (props) => {
return (
<div id="custom-array-field">
<ArrayField {...props} />
</div>
)
}

View File

@@ -0,0 +1,11 @@
import type { TextFieldServerComponent } from 'payload'
import { TextField } from '@payloadcms/ui'
export const CustomTextField: TextFieldServerComponent = ({ clientField, path }) => {
return (
<div id="custom-text-field">
<TextField field={clientField} path={path} />
</div>
)
}

View File

@@ -0,0 +1,15 @@
'use client'
import type { PayloadClientReactComponent, RowLabelComponent } from 'payload'
import { useRowLabel } from '@payloadcms/ui'
import React from 'react'
export const ArrayRowLabel: PayloadClientReactComponent<RowLabelComponent> = () => {
const { data } = useRowLabel<{ title: string }>()
return (
<div id="custom-array-row-label" style={{ color: 'coral', textTransform: 'uppercase' }}>
{data.title || 'Untitled'}
</div>
)
}

View File

@@ -0,0 +1,268 @@
import type { CollectionConfig } from 'payload'
import { arrayFieldsSlug } from '../../slugs.js'
export const arrayDefaultValue = [{ text: 'row one' }, { text: 'row two' }]
const ArrayFields: CollectionConfig = {
admin: {
enableRichTextLink: false,
},
fields: [
{
name: 'title',
type: 'text',
required: false,
},
{
name: 'items',
defaultValue: arrayDefaultValue,
fields: [
{
name: 'text',
type: 'text',
required: true,
},
{
name: 'anotherText',
type: 'text',
},
{
name: 'uiField',
type: 'ui',
admin: {
components: {
Field: {
path: './collections/Array/LabelComponent.js#ArrayRowLabel',
serverProps: {
// While this doesn't do anything, this will reproduce a bug where having server-only props in here will throw a "Functions cannot be passed directly to Client Components" error
someFn: () => 'Hello',
},
},
},
},
},
{
name: 'localizedText',
type: 'text',
localized: true,
},
{
name: 'subArray',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'textTwo',
label: 'Second text field',
type: 'text',
required: true,
defaultValue: 'default',
},
{
type: 'row',
fields: [
{
name: 'textInRow',
type: 'text',
required: true,
defaultValue: 'default',
},
],
},
],
type: 'array',
},
],
required: true,
type: 'array',
},
{
name: 'collapsedArray',
admin: {
initCollapsed: true,
},
fields: [
{
name: 'text',
required: true,
type: 'text',
},
],
type: 'array',
},
{
name: 'localized',
defaultValue: arrayDefaultValue,
fields: [
{
name: 'text',
required: true,
type: 'text',
},
],
localized: true,
required: true,
type: 'array',
},
{
name: 'readOnly',
admin: {
readOnly: true,
},
defaultValue: [
{
text: 'defaultValue',
},
{
text: 'defaultValue2',
},
],
fields: [
{
name: 'text',
type: 'text',
},
],
type: 'array',
},
{
name: 'potentiallyEmptyArray',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'groupInRow',
fields: [
{
name: 'textInGroupInRow',
type: 'text',
},
],
type: 'group',
},
],
type: 'array',
},
{
name: 'rowLabelAsComponent',
admin: {
components: {
RowLabel: '/collections/Array/LabelComponent.js#ArrayRowLabel',
},
description: 'Row labels rendered as react components.',
},
fields: [
{
name: 'title',
type: 'text',
},
],
type: 'array',
},
{
name: 'arrayWithMinRows',
fields: [
{
name: 'text',
type: 'text',
},
],
minRows: 2,
type: 'array',
},
{
name: 'disableSort',
defaultValue: arrayDefaultValue,
admin: {
isSortable: false,
},
fields: [
{
name: 'text',
required: true,
type: 'text',
},
],
type: 'array',
},
{
name: 'nestedArrayLocalized',
type: 'array',
fields: [
{
type: 'array',
name: 'array',
fields: [
{
name: 'text',
type: 'text',
localized: true,
},
],
},
],
},
{
name: 'externallyUpdatedArray',
type: 'array',
fields: [
{
name: 'customTextField',
type: 'ui',
admin: {
components: {
Field: '/collections/Array/CustomTextField.js#CustomTextField',
},
},
},
],
},
{
name: 'customArrayField',
type: 'array',
admin: {
components: {
Field: '/collections/Array/CustomArrayField.js#CustomArrayField',
},
},
fields: [
{
name: 'text',
type: 'text',
},
],
},
{
name: 'ui',
type: 'ui',
admin: {
components: {
Field: '/collections/Array/AddRowButton.js',
},
},
},
{
name: 'arrayWithLabels',
type: 'array',
labels: {
singular: ({ t }) => t('authentication:account'),
plural: ({ t }) => t('authentication:generate'),
},
fields: [
{
name: 'text',
type: 'text',
},
],
},
],
slug: arrayFieldsSlug,
versions: true,
}
export default ArrayFields

View File

@@ -0,0 +1,68 @@
import type { RequiredDataFromCollection } from 'payload/types'
import type { ArrayField } from '../../payload-types.js'
export const arrayDoc: RequiredDataFromCollection<ArrayField> = {
arrayWithMinRows: [
{
text: 'first row',
},
{
text: 'second row',
},
],
collapsedArray: [
{
text: 'initialize collapsed',
},
],
items: [
{
text: 'first row',
},
{
text: 'second row',
},
{
text: 'third row',
},
{
text: 'fourth row',
},
{
text: 'fifth row',
},
{
text: 'sixth row',
},
],
title: 'array doc 1',
}
export const anotherArrayDoc: RequiredDataFromCollection<ArrayField> = {
arrayWithMinRows: [
{
text: 'first row',
},
{
text: 'second row',
},
],
collapsedArray: [
{
text: 'initialize collapsed',
},
],
items: [
{
text: 'first row',
},
{
text: 'second row',
},
{
text: 'third row',
},
],
title: 'array doc 2',
}

View File

@@ -0,0 +1,122 @@
'use client'
import type { DefaultNodeTypes, SerializedBlockNode } from '@payloadcms/richtext-lexical'
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import { getRestPopulateFn } from '@payloadcms/richtext-lexical/client'
import {
convertLexicalToHTML,
type HTMLConvertersFunction,
} from '@payloadcms/richtext-lexical/html'
import {
convertLexicalToHTMLAsync,
type HTMLConvertersFunctionAsync,
} from '@payloadcms/richtext-lexical/html-async'
import { type JSXConvertersFunction, RichText } from '@payloadcms/richtext-lexical/react'
import { useConfig, useDocumentInfo, usePayloadAPI } from '@payloadcms/ui'
import React, { useEffect, useMemo, useState } from 'react'
const jsxConverters: JSXConvertersFunction<DefaultNodeTypes | SerializedBlockNode<any>> = ({
defaultConverters,
}) => ({
...defaultConverters,
blocks: {
myTextBlock: ({ node }) => <div style={{ backgroundColor: 'red' }}>{node.fields.text}</div>,
relationshipBlock: ({ node, nodesToJSX }) => {
return <p>Test</p>
},
},
})
const htmlConverters: HTMLConvertersFunction<DefaultNodeTypes | SerializedBlockNode<any>> = ({
defaultConverters,
}) => ({
...defaultConverters,
blocks: {
myTextBlock: ({ node }) => `<div style="background-color: red;">${node.fields.text}</div>`,
relationshipBlock: () => {
return `<p>Test</p>`
},
},
})
const htmlConvertersAsync: HTMLConvertersFunctionAsync<
DefaultNodeTypes | SerializedBlockNode<any>
> = ({ defaultConverters }) => ({
...defaultConverters,
blocks: {
myTextBlock: ({ node }) => `<div style="background-color: red;">${node.fields.text}</div>`,
relationshipBlock: () => {
return `<p>Test</p>`
},
},
})
export const LexicalRendered: React.FC = () => {
const { id, collectionSlug } = useDocumentInfo()
const {
config: {
routes: { api },
serverURL,
},
} = useConfig()
const [{ data }] = usePayloadAPI(`${serverURL}${api}/${collectionSlug}/${id}`, {
initialParams: {
depth: 1,
},
})
const [{ data: unpopulatedData }] = usePayloadAPI(`${serverURL}${api}/${collectionSlug}/${id}`, {
initialParams: {
depth: 0,
},
})
const html: null | string = useMemo(() => {
if (!data.lexicalWithBlocks) {
return null
}
return convertLexicalToHTML({
converters: htmlConverters,
data: data.lexicalWithBlocks as SerializedEditorState,
})
}, [data.lexicalWithBlocks])
const [htmlFromUnpopulatedData, setHtmlFromUnpopulatedData] = useState<null | string>(null)
useEffect(() => {
async function convert() {
const html = await convertLexicalToHTMLAsync({
converters: htmlConvertersAsync,
data: unpopulatedData.lexicalWithBlocks as SerializedEditorState,
populate: getRestPopulateFn({
apiURL: `${serverURL}${api}`,
}),
})
setHtmlFromUnpopulatedData(html)
}
void convert()
}, [unpopulatedData.lexicalWithBlocks, api, serverURL])
if (!data.lexicalWithBlocks) {
return null
}
return (
<div>
<h1>Rendered JSX:</h1>
<RichText converters={jsxConverters} data={data.lexicalWithBlocks as SerializedEditorState} />
<h1>Rendered HTML:</h1>
{html && <div dangerouslySetInnerHTML={{ __html: html }} />}
<h1>Rendered HTML 2:</h1>
{htmlFromUnpopulatedData && (
<div dangerouslySetInnerHTML={{ __html: htmlFromUnpopulatedData }} />
)}
<h1>Raw JSON:</h1>
<pre>{JSON.stringify(data.lexicalWithBlocks, null, 2)}</pre>
</div>
)
}

View File

@@ -0,0 +1,57 @@
'use client'
import { $isInlineBlockNode, createClientFeature } from '@payloadcms/richtext-lexical/client'
import { $getSelection } from '@payloadcms/richtext-lexical/lexical'
import { CloseMenuIcon } from '@payloadcms/ui'
import { ModifyInlineBlockPlugin } from './plugin.js'
export const ModifyInlineBlockFeatureClient = createClientFeature({
plugins: [
{
Component: ModifyInlineBlockPlugin,
position: 'normal',
},
],
toolbarFixed: {
groups: [
{
key: 'debug',
items: [
{
ChildComponent: CloseMenuIcon,
key: 'setKeyToDebug',
label: 'Set Key To Debug',
onSelect({ editor }) {
editor.update(() => {
const selection = $getSelection()
// Check if selection consist of 1 node and that its an inlineblocknode
const nodes = selection.getNodes()
if (nodes.length !== 1) {
return
}
const node = nodes[0]
if (!$isInlineBlockNode(node)) {
return
}
const fields = node.getFields()
node.setFields({
blockType: fields.blockType,
id: fields.id,
key: 'value2',
})
})
},
},
],
type: 'buttons',
},
],
},
})

View File

@@ -0,0 +1,9 @@
import { createServerFeature } from '@payloadcms/richtext-lexical'
export const ModifyInlineBlockFeature = createServerFeature({
key: 'ModifyInlineBlockFeature',
feature: {
ClientFeature:
'./collections/Lexical/ModifyInlineBlockFeature/feature.client.js#ModifyInlineBlockFeatureClient',
},
})

View File

@@ -0,0 +1,7 @@
'use client'
import type { PluginComponent } from '@payloadcms/richtext-lexical'
export const ModifyInlineBlockPlugin: PluginComponent = () => {
return null
}

View File

@@ -0,0 +1,20 @@
'use client'
import {
BlockCollapsible,
BlockEditButton,
BlockRemoveButton,
} from '@payloadcms/richtext-lexical/client'
import { useFormFields } from '@payloadcms/ui'
import React from 'react'
export const BlockComponent: React.FC = () => {
const key = useFormFields(([fields]) => fields.key)
return (
<BlockCollapsible>
MY BLOCK COMPONENT. Value: {(key?.value as string) ?? '<no value>'}
Edit: <BlockEditButton />
<BlockRemoveButton />
</BlockCollapsible>
)
}

View File

@@ -0,0 +1,10 @@
import type { BlocksFieldServerComponent } from 'payload'
import { BlockCollapsible } from '@payloadcms/richtext-lexical/client'
import React from 'react'
export const BlockComponentRSC: BlocksFieldServerComponent = (props) => {
const { siblingData } = props
return <BlockCollapsible>Data: {siblingData?.key ?? ''}</BlockCollapsible>
}

View File

@@ -0,0 +1,10 @@
'use client'
import { useFormFields } from '@payloadcms/ui'
import React from 'react'
export const LabelComponent: React.FC = () => {
const key = useFormFields(([fields]) => fields.key)
return <div>{(key?.value as string) ?? '<no value>'}yaya</div>
}

View File

@@ -0,0 +1,446 @@
import type { ArrayField, Block, TextFieldSingleValidation } from 'payload'
import { BlocksFeature, FixedToolbarFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
import { textFieldsSlug } from '../../slugs.js'
async function asyncFunction(param: string) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(param?.toUpperCase())
}, 1000)
})
}
export const FilterOptionsBlock: Block = {
slug: 'filterOptionsBlock',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'groupText',
type: 'text',
},
{
name: 'dependsOnDocData',
type: 'relationship',
relationTo: 'text-fields',
filterOptions: ({ data }) => {
if (!data.title) {
return true
}
return {
text: {
equals: data.title,
},
}
},
},
{
name: 'dependsOnSiblingData',
type: 'relationship',
relationTo: 'text-fields',
filterOptions: ({ siblingData }) => {
// @ts-expect-error
if (!siblingData?.groupText) {
return true
}
return {
text: {
equals: (siblingData as any)?.groupText,
},
}
},
},
{
name: 'dependsOnBlockData',
type: 'relationship',
relationTo: 'text-fields',
filterOptions: ({ blockData }) => {
if (!blockData?.text) {
return true
}
return {
text: {
equals: blockData?.text,
},
}
},
},
],
},
],
}
export const ValidationBlock: Block = {
slug: 'validationBlock',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'groupText',
type: 'text',
},
{
name: 'textDependsOnDocData',
type: 'text',
validate: ((value, { data }) => {
if ((data as any)?.title === 'invalid') {
return 'doc title cannot be invalid'
}
return true
}) as TextFieldSingleValidation,
},
{
name: 'textDependsOnSiblingData',
type: 'text',
validate: ((value, { siblingData }) => {
if ((siblingData as any)?.groupText === 'invalid') {
return 'textDependsOnSiblingData sibling field cannot be invalid'
}
}) as TextFieldSingleValidation,
},
{
name: 'textDependsOnBlockData',
type: 'text',
validate: ((value, { blockData }) => {
if ((blockData as any)?.text === 'invalid') {
return 'textDependsOnBlockData sibling field cannot be invalid'
}
}) as TextFieldSingleValidation,
},
],
},
],
}
export const AsyncHooksBlock: Block = {
slug: 'asyncHooksBlock',
fields: [
{
name: 'test1',
label: 'Text',
type: 'text',
hooks: {
afterRead: [
({ value }) => {
return value?.toUpperCase()
},
],
},
},
{
name: 'test2',
label: 'Text',
type: 'text',
hooks: {
afterRead: [
async ({ value }) => {
const valuenew = await asyncFunction(value)
return valuenew
},
],
},
},
],
}
export const BlockColumns = ({ name }: { name: string }): ArrayField => ({
type: 'array',
name,
interfaceName: 'BlockColumns',
admin: {
initCollapsed: true,
},
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'subArray',
type: 'array',
fields: [
{
name: 'requiredText',
type: 'text',
required: true,
},
],
},
],
})
export const ConditionalLayoutBlock: Block = {
fields: [
{
label: 'Layout',
name: 'layout',
type: 'select',
options: ['1', '2', '3'],
defaultValue: '1',
required: true,
},
{
...BlockColumns({ name: 'columns' }),
admin: {
condition: (data, siblingData) => {
return ['1'].includes(siblingData.layout)
},
},
minRows: 1,
maxRows: 1,
},
{
...BlockColumns({ name: 'columns2' }),
admin: {
condition: (data, siblingData) => {
return ['2'].includes(siblingData.layout)
},
},
minRows: 2,
maxRows: 2,
},
{
...BlockColumns({ name: 'columns3' }),
admin: {
condition: (data, siblingData) => {
return ['3'].includes(siblingData.layout)
},
},
minRows: 3,
maxRows: 3,
},
],
slug: 'conditionalLayout',
}
export const TextBlock: Block = {
fields: [
{
name: 'text',
type: 'text',
required: true,
},
],
slug: 'textRequired',
}
export const RadioButtonsBlock: Block = {
interfaceName: 'LexicalBlocksRadioButtonsBlock',
fields: [
{
name: 'radioButtons',
type: 'radio',
options: [
{
label: 'Option 1',
value: 'option1',
},
{
label: 'Option 2',
value: 'option2',
},
{
label: 'Option 3',
value: 'option3',
},
],
},
],
slug: 'radioButtons',
}
export const RichTextBlock: Block = {
fields: [
{
name: 'richTextField',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
FixedToolbarFeature(),
BlocksFeature({
blocks: [
{
fields: [
{
name: 'subRichTextField',
type: 'richText',
editor: lexicalEditor({}),
},
{
name: 'subUploadField',
type: 'upload',
relationTo: 'uploads',
},
],
slug: 'lexicalAndUploadBlock',
},
],
}),
],
}),
},
],
slug: 'richTextBlock',
}
export const UploadAndRichTextBlock: Block = {
fields: [
{
name: 'upload',
type: 'upload',
relationTo: 'uploads',
required: true,
},
{
name: 'richText',
type: 'richText',
editor: lexicalEditor(),
},
],
slug: 'uploadAndRichText',
}
export const RelationshipHasManyBlock: Block = {
fields: [
{
name: 'rel',
type: 'relationship',
hasMany: true,
relationTo: [textFieldsSlug, 'uploads'],
required: true,
},
],
slug: 'relationshipHasManyBlock',
}
export const RelationshipBlock: Block = {
fields: [
{
name: 'rel',
type: 'relationship',
relationTo: 'uploads',
required: true,
},
],
slug: 'relationshipBlock',
}
export const SelectFieldBlock: Block = {
fields: [
{
name: 'select',
type: 'select',
options: [
{
label: 'Option 1',
value: 'option1',
},
{
label: 'Option 2',
value: 'option2',
},
{
label: 'Option 3',
value: 'option3',
},
{
label: 'Option 4',
value: 'option4',
},
{
label: 'Option 5',
value: 'option5',
},
],
},
],
slug: 'select',
}
export const SubBlockBlock: Block = {
slug: 'subBlockLexical',
fields: [
{
name: 'subBlocksLexical',
type: 'blocks',
blocks: [
{
slug: 'contentBlock',
fields: [
{
name: 'richText',
type: 'richText',
required: true,
editor: lexicalEditor(),
},
],
},
{
slug: 'textArea',
fields: [
{
name: 'content',
type: 'textarea',
required: true,
},
],
},
SelectFieldBlock,
],
},
],
}
export const TabBlock: Block = {
slug: 'tabBlock',
fields: [
{
type: 'tabs',
tabs: [
{
label: 'Tab1',
name: 'tab1',
fields: [
{
name: 'text1',
type: 'text',
},
],
},
{
label: 'Tab2',
name: 'tab2',
fields: [
{
name: 'text2',
type: 'text',
},
],
},
],
},
],
}
export const CodeBlock: Block = {
fields: [
{
name: 'code',
type: 'code',
},
],
slug: 'code',
}

View File

@@ -0,0 +1,61 @@
'use client'
import type { SerializedParagraphNode, SerializedTextNode } from '@payloadcms/richtext-lexical'
import { useForm } from '@payloadcms/ui'
import React from 'react'
export const ClearState = ({ fieldName }: { fieldName: string }) => {
const { dispatchFields, fields } = useForm()
const clearState = React.useCallback(() => {
const newState = {
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: '',
version: 1,
} as SerializedTextNode,
],
direction: 'ltr',
format: '',
indent: 0,
textFormat: 0,
textStyle: '',
version: 1,
} as SerializedParagraphNode,
],
direction: 'ltr',
format: '',
indent: 0,
version: 1,
},
}
dispatchFields({
type: 'REPLACE_STATE',
state: {
...fields,
[fieldName]: {
...fields[fieldName],
initialValue: newState,
value: newState,
},
},
})
}, [dispatchFields, fields, fieldName])
return (
<button id={`clear-lexical-${fieldName}`} onClick={clearState} type="button">
Clear State
</button>
)
}

View File

@@ -0,0 +1,6 @@
import { generateLexicalRichText } from './generateLexicalRichText.js'
export const lexicalDocData = {
title: 'Rich Text',
lexicalWithBlocks: generateLexicalRichText(),
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,321 @@
import type {
SerializedBlockNode,
SerializedParagraphNode,
SerializedTextNode,
SerializedUploadNode,
TypedEditorState,
} from '@payloadcms/richtext-lexical'
export function generateLexicalRichText(): TypedEditorState<
SerializedBlockNode | SerializedParagraphNode | SerializedTextNode | SerializedUploadNode
> {
return {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Upload Node:',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
textFormat: 0,
version: 1,
},
{
format: '',
type: 'upload',
version: 2,
id: '665d105a91e1c337ba8308dd',
fields: {
caption: {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Relationship inside Upload Caption:',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
{
format: '',
type: 'relationship',
version: 2,
relationTo: 'text-fields',
value: '{{TEXT_DOC_ID}}',
},
],
direction: 'ltr',
},
},
},
relationTo: 'uploads',
value: '{{UPLOAD_DOC_ID}}',
},
{
format: '',
type: 'block',
version: 2,
fields: {
id: '65298b13db4ef8c744a7faaa',
rel: '{{UPLOAD_DOC_ID}}',
blockName: 'Block Node, with Relationship Field',
blockType: 'relationshipBlock',
},
},
{
format: '',
type: 'block',
version: 2,
fields: {
id: '6565c8668294bf824c24d4a4',
blockName: '',
blockType: 'relationshipHasManyBlock',
rel: [
{
value: '{{TEXT_DOC_ID}}',
relationTo: 'text-fields',
},
{
value: '{{UPLOAD_DOC_ID}}',
relationTo: 'uploads',
},
],
},
},
{
format: '',
type: 'block',
version: 2,
fields: {
id: '65298b1ddb4ef8c744a7faab',
richTextField: {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
children: [
{
format: '',
type: 'relationship',
version: 2,
relationTo: 'rich-text-fields',
value: '{{RICH_TEXT_DOC_ID}}',
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Some text below relationship node 1',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
],
direction: null,
},
},
blockName: 'Block Node, with RichText Field, with Relationship Node',
blockType: 'richTextBlock',
},
},
{
format: '',
type: 'block',
version: 2,
fields: {
id: '65298b2bdb4ef8c744a7faac',
blockName: 'Block Node, with Blocks Field, With RichText Field, With Relationship Node',
blockType: 'subBlockLexical',
subBlocksLexical: [
{
id: '65298b2edb4ef8c744a7faad',
richText: {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
children: [
{
format: '',
type: 'relationship',
version: 2,
relationTo: 'text-fields',
value: '{{TEXT_DOC_ID}}',
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Some text below relationship node 2',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
],
direction: null,
},
},
blockType: 'contentBlock',
},
],
},
},
{
format: '',
type: 'block',
version: 2,
fields: {
id: '65298b49db4ef8c744a7faae',
upload: '{{UPLOAD_DOC_ID}}',
blockName: 'Block Node, With Upload Field',
blockType: 'uploadAndRichText',
},
},
{
children: [],
direction: null,
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
},
{
format: '',
type: 'block',
version: 2,
fields: {
id: '65532e49fe515eb112e605a3',
blockName: 'Radio Buttons 1',
blockType: 'radioButtons',
radioButtons: 'option1',
},
},
{
format: '',
type: 'block',
version: 2,
fields: {
id: '65532e50fe515eb112e605a4',
blockName: 'Radio Buttons 2',
blockType: 'radioButtons',
radioButtons: 'option1',
},
},
{
children: [],
direction: null,
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
},
{
format: '',
type: 'block',
version: 2,
fields: {
id: '65588bfa80fb5a147a378e74',
blockName: '',
blockType: 'conditionalLayout',
layout: '1',
columns: [
{
id: '65588bfb80fb5a147a378e75',
text: 'text in conditionalLayout block',
},
],
},
}, // Do not remove this blocks node. It ensures that validation passes when it's created
{
children: [],
direction: null,
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
},
{
format: '',
type: 'block',
version: 2,
fields: {
id: '666c9dfd189d72626ea301f9',
blockName: '',
tab1: {
text1: 'Some text1',
},
tab2: {
text2: 'Some text2',
},
blockType: 'tabBlock',
},
},
{
format: '',
type: 'block',
version: 2,
fields: {
id: '666c9e0b189d72626ea301fa',
blockName: '',
blockType: 'code',
code: 'Some code\nhello\nworld',
},
},
],
direction: 'ltr',
},
}
}

View File

@@ -0,0 +1,408 @@
import type { ServerEditorConfig } from '@payloadcms/richtext-lexical'
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import type { Block, BlockSlug, CollectionConfig } from 'payload'
import {
BlocksFeature,
defaultEditorFeatures,
EXPERIMENTAL_TableFeature,
FixedToolbarFeature,
getEnabledNodes,
HeadingFeature,
lexicalEditor,
LinkFeature,
sanitizeServerEditorConfig,
TreeViewFeature,
UploadFeature,
} from '@payloadcms/richtext-lexical'
import { createHeadlessEditor } from '@payloadcms/richtext-lexical/lexical/headless'
import { $convertToMarkdownString } from '@payloadcms/richtext-lexical/lexical/markdown'
import { lexicalFieldsSlug } from '../../slugs.js'
import {
AsyncHooksBlock,
CodeBlock,
ConditionalLayoutBlock,
FilterOptionsBlock,
RadioButtonsBlock,
RelationshipBlock,
RelationshipHasManyBlock,
RichTextBlock,
SelectFieldBlock,
SubBlockBlock,
TabBlock,
TextBlock,
UploadAndRichTextBlock,
ValidationBlock,
} from './blocks.js'
import { ModifyInlineBlockFeature } from './ModifyInlineBlockFeature/feature.server.js'
export const lexicalBlocks: (Block | BlockSlug)[] = [
ValidationBlock,
FilterOptionsBlock,
AsyncHooksBlock,
RichTextBlock,
TextBlock,
UploadAndRichTextBlock,
SelectFieldBlock,
RelationshipBlock,
RelationshipHasManyBlock,
SubBlockBlock,
RadioButtonsBlock,
ConditionalLayoutBlock,
TabBlock,
CodeBlock,
{
slug: 'myBlock',
admin: {
components: {},
},
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
{
slug: 'myBlockWithLabel',
admin: {
components: {
Label: '/collections/Lexical/blockComponents/LabelComponent.js#LabelComponent',
},
},
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
{
slug: 'myBlockWithBlock',
admin: {
components: {
Block: '/collections/Lexical/blockComponents/BlockComponent.js#BlockComponent',
},
},
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
{
slug: 'BlockRSC',
admin: {
components: {
Block: '/collections/Lexical/blockComponents/BlockComponentRSC.js#BlockComponentRSC',
},
},
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
{
slug: 'myBlockWithBlockAndLabel',
admin: {
components: {
Block: '/collections/Lexical/blockComponents/BlockComponent.js#BlockComponent',
Label: '/collections/Lexical/blockComponents/LabelComponent.js#LabelComponent',
},
},
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
]
export const lexicalInlineBlocks: (Block | BlockSlug)[] = [
{
slug: 'AvatarGroup',
interfaceName: 'AvatarGroupBlock',
fields: [
{
name: 'avatars',
type: 'array',
minRows: 1,
maxRows: 6,
fields: [
{
name: 'image',
type: 'upload',
relationTo: 'uploads',
},
],
},
],
},
{
slug: 'myInlineBlock',
admin: {
components: {},
},
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
{
slug: 'myInlineBlockWithLabel',
admin: {
components: {
Label: '/collections/Lexical/inlineBlockComponents/LabelComponent.js#LabelComponent',
},
},
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
{
slug: 'myInlineBlockWithBlock',
admin: {
components: {
Block: '/collections/Lexical/inlineBlockComponents/BlockComponent.js#BlockComponent',
},
},
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
{
slug: 'myInlineBlockWithBlockAndLabel',
admin: {
components: {
Block: '/collections/Lexical/inlineBlockComponents/BlockComponent.js#BlockComponent',
Label: '/collections/Lexical/inlineBlockComponents/LabelComponent.js#LabelComponent',
},
},
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
]
export const getLexicalFieldsCollection: (args: {
blocks: (Block | BlockSlug)[]
inlineBlocks: (Block | BlockSlug)[]
}) => CollectionConfig = ({ blocks, inlineBlocks }) => {
const editorConfig: ServerEditorConfig = {
features: [
...defaultEditorFeatures,
//TestRecorderFeature(),
TreeViewFeature(),
//HTMLConverterFeature(),
FixedToolbarFeature(),
LinkFeature({
fields: ({ defaultFields }) => [
...defaultFields,
{
name: 'rel',
type: 'select',
admin: {
description:
'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.',
},
hasMany: true,
label: 'Rel Attribute',
options: ['noopener', 'noreferrer', 'nofollow'],
},
],
}),
UploadFeature({
collections: {
uploads: {
fields: [
{
name: 'caption',
type: 'richText',
editor: lexicalEditor(),
},
],
},
},
}),
ModifyInlineBlockFeature(),
BlocksFeature({
blocks,
inlineBlocks,
}),
EXPERIMENTAL_TableFeature(),
],
}
return {
slug: lexicalFieldsSlug,
access: {
read: () => true,
},
admin: {
listSearchableFields: ['title', 'richTextLexicalCustomFields'],
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'lexicalRootEditor',
type: 'richText',
},
{
name: 'lexicalSimple',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
//TestRecorderFeature(),
TreeViewFeature(),
BlocksFeature({
blocks: [
RichTextBlock,
TextBlock,
UploadAndRichTextBlock,
SelectFieldBlock,
RelationshipBlock,
RelationshipHasManyBlock,
SubBlockBlock,
RadioButtonsBlock,
ConditionalLayoutBlock,
],
}),
HeadingFeature({ enabledHeadingSizes: ['h2', 'h4'] }),
],
}),
},
{
type: 'ui',
name: 'clearLexicalState',
admin: {
components: {
Field: {
path: '/collections/Lexical/components/ClearState.js#ClearState',
clientProps: {
fieldName: 'lexicalSimple',
},
},
},
},
},
{
name: 'lexicalWithBlocks',
type: 'richText',
editor: lexicalEditor({
admin: {
hideGutter: false,
},
features: editorConfig.features,
}),
required: true,
},
//{
// name: 'rendered',
// type: 'ui',
// admin: {
// components: {
// Field: './collections/Lexical/LexicalRendered.js#LexicalRendered',
// },
// },
//},
{
name: 'lexicalWithBlocks_markdown',
type: 'textarea',
hooks: {
afterRead: [
async ({ data, req, siblingData }) => {
const yourSanitizedEditorConfig = await sanitizeServerEditorConfig(
editorConfig,
req.payload.config,
)
const headlessEditor = createHeadlessEditor({
nodes: getEnabledNodes({
editorConfig: yourSanitizedEditorConfig,
}),
})
const yourEditorState: SerializedEditorState = siblingData.lexicalWithBlocks
try {
headlessEditor.update(
() => {
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState))
},
{ discrete: true },
)
} catch (e) {
/* empty */
}
// Export to markdown
let markdown: string = ''
headlessEditor.getEditorState().read(() => {
markdown = $convertToMarkdownString(
yourSanitizedEditorConfig?.features?.markdownTransformers,
)
})
return markdown
},
],
},
},
],
}
}

View File

@@ -0,0 +1,18 @@
import {
InlineBlockContainer,
InlineBlockEditButton,
InlineBlockLabel,
InlineBlockRemoveButton,
} from '@payloadcms/richtext-lexical/client'
import React from 'react'
export const BlockComponent: React.FC = () => {
return (
<InlineBlockContainer>
<p>Test</p>
<InlineBlockEditButton />
<InlineBlockLabel />
<InlineBlockRemoveButton />
</InlineBlockContainer>
)
}

View File

@@ -0,0 +1,10 @@
'use client'
import { useFormFields } from '@payloadcms/ui'
import React from 'react'
export const LabelComponent: React.FC = () => {
const key = useFormFields(([fields]) => fields.key)
return <div>{(key?.value as string) ?? '<no value>'}yaya</div>
}

View File

@@ -0,0 +1,2 @@
export const loremIpsum =
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam hendrerit nisi sed sollicitudin pellentesque. Nunc posuere purus rhoncus pulvinar aliquam. Ut aliquet tristique nisl vitae volutpat. Nulla aliquet porttitor venenatis. Donec a dui et dui fringilla consectetur id nec massa. Aliquam erat volutpat. Sed ut dui ut lacus dictum fermentum vel tincidunt neque. Sed sed lacinia lectus. Duis sit amet sodales felis. Duis nunc eros, mattis at dui ac, convallis semper risus. In adipiscing ultrices tellus, in suscipit massa vehicula eu.'

View File

@@ -0,0 +1,51 @@
import type { CollectionConfig } from 'payload'
import { defaultEditorFeatures, lexicalEditor, LinkFeature } from '@payloadcms/richtext-lexical'
import { lexicalAccessControlSlug } from '../../slugs.js'
export const LexicalAccessControl: CollectionConfig = {
slug: lexicalAccessControlSlug,
access: {
read: () => true,
create: () => false,
},
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'richText',
type: 'richText',
editor: lexicalEditor({
features: [
...defaultEditorFeatures,
LinkFeature({
fields: ({ defaultFields }) => [
...defaultFields,
{
name: 'blocks',
type: 'blocks',
blocks: [
{
slug: 'block',
fields: [
{
name: 'text',
type: 'text',
},
],
},
],
},
],
}),
],
}),
},
],
}

View File

@@ -0,0 +1,68 @@
import type { CollectionConfig } from 'payload'
import { BlocksFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
export const LexicalInBlock: CollectionConfig = {
slug: 'LexicalInBlock',
fields: [
{
name: 'content',
type: 'richText',
editor: lexicalEditor({
features: [
BlocksFeature({
blocks: [
{
slug: 'blockInLexical',
fields: [
{
name: 'lexicalInBlock',
label: 'My Label',
type: 'richText',
required: true,
editor: lexicalEditor(),
admin: {
description: 'Some Description',
},
},
],
},
],
}),
],
}),
},
{
name: 'blocks',
type: 'blocks',
blocks: [
{
slug: 'lexicalInBlock2',
fields: [
{
name: 'lexical',
type: 'richText',
editor: lexicalEditor({
features: [
BlocksFeature({
inlineBlocks: [
{
slug: 'inlineBlockInLexical',
fields: [
{
name: 'text',
type: 'text',
},
],
},
],
}),
],
}),
},
],
},
],
},
],
}

View File

@@ -0,0 +1,44 @@
export function generateLexicalLocalizedRichText(text1: string, text2: string, blockID?: string) {
return {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: text1,
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
},
{
format: '',
type: 'block',
version: 2,
fields: {
id: blockID ?? '66685716795f191f08367b1a',
blockName: '',
textLocalized: text2,
counter: 1,
blockType: 'blockLexicalLocalized',
},
},
],
direction: 'ltr',
},
}
}

View File

@@ -0,0 +1,103 @@
import type { CollectionConfig } from 'payload'
import { BlocksFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
import { lexicalLocalizedFieldsSlug } from '../../slugs.js'
export const LexicalLocalizedFields: CollectionConfig = {
slug: lexicalLocalizedFieldsSlug,
admin: {
useAsTitle: 'title',
listSearchableFields: ['title'],
},
access: {
read: () => true,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
localized: true,
},
{
name: 'lexicalBlocksSubLocalized',
type: 'richText',
admin: {
description: 'Non-localized field with localized block subfields',
},
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
BlocksFeature({
blocks: [
{
slug: 'blockLexicalLocalized',
fields: [
{
name: 'textLocalized',
type: 'text',
localized: true,
},
{
name: 'counter',
type: 'number',
hooks: {
beforeChange: [
({ value }) => {
return value ? value + 1 : 1
},
],
afterRead: [
({ value }) => {
return value ? value * 10 : 10
},
],
},
},
{
name: 'rel',
type: 'relationship',
relationTo: lexicalLocalizedFieldsSlug,
},
],
},
],
}),
],
}),
},
{
name: 'lexicalBlocksLocalized',
admin: {
description: 'Localized field with localized block subfields',
},
type: 'richText',
localized: true,
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
BlocksFeature({
blocks: [
{
slug: 'blockLexicalLocalized2',
fields: [
{
name: 'textLocalized',
type: 'text',
localized: true,
},
{
name: 'rel',
type: 'relationship',
relationTo: lexicalLocalizedFieldsSlug,
},
],
},
],
}),
],
}),
},
],
}

View File

@@ -0,0 +1,60 @@
import type { SerializedRelationshipNode } from '@payloadcms/richtext-lexical'
import type {
SerializedEditorState,
SerializedParagraphNode,
SerializedTextNode,
} from '@payloadcms/richtext-lexical/lexical'
import { lexicalLocalizedFieldsSlug } from '../../slugs.js'
export function textToLexicalJSON({
text,
lexicalLocalizedRelID,
}: {
lexicalLocalizedRelID?: number | string
text: string
}): any {
const editorJSON: SerializedEditorState = {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
direction: 'ltr',
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text,
type: 'text',
version: 1,
} as SerializedTextNode,
],
direction: 'ltr',
format: '',
indent: 0,
textFormat: 0,
type: 'paragraph',
textStyle: '',
version: 1,
} as SerializedParagraphNode,
],
},
}
if (lexicalLocalizedRelID) {
editorJSON.root.children.push({
format: '',
type: 'relationship',
version: 2,
relationTo: lexicalLocalizedFieldsSlug,
value: lexicalLocalizedRelID,
} as SerializedRelationshipNode)
}
return editorJSON
}

View File

@@ -0,0 +1,58 @@
import { generateSlateRichText } from '../RichText/generateSlateRichText.js'
import { payloadPluginLexicalData } from './generatePayloadPluginLexicalData.js'
export const lexicalMigrateDocData = {
title: 'Rich Text',
lexicalWithLexicalPluginData: payloadPluginLexicalData,
lexicalWithSlateData: [
...generateSlateRichText(),
{
children: [
{
text: 'Some block quote',
},
],
type: 'blockquote',
},
],
arrayWithLexicalField: [
{
lexicalInArrayField: getSimpleLexicalData('array 1'),
},
{
lexicalInArrayField: getSimpleLexicalData('array 2'),
},
],
}
export function getSimpleLexicalData(textContent: string) {
return {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: textContent,
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
],
direction: 'ltr',
},
}
}

View File

@@ -0,0 +1,958 @@
export const payloadPluginLexicalData = {
words: 49,
preview:
'paragraph text bold italic underline and all subscript superscript code internal link external link…',
comments: [],
characters: 493,
jsonContent: {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'paragraph text ',
type: 'text',
version: 1,
},
{
detail: 0,
format: 1,
mode: 'normal',
style: '',
text: 'bold',
type: 'text',
version: 1,
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' ',
type: 'text',
version: 1,
},
{
detail: 0,
format: 2,
mode: 'normal',
style: '',
text: 'italic',
type: 'text',
version: 1,
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' ',
type: 'text',
version: 1,
},
{
detail: 0,
format: 8,
mode: 'normal',
style: '',
text: 'underline',
type: 'text',
version: 1,
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' and ',
type: 'text',
version: 1,
},
{
detail: 0,
format: 11,
mode: 'normal',
style: '',
text: 'all',
type: 'text',
version: 1,
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' ',
type: 'text',
version: 1,
},
{
detail: 0,
format: 32,
mode: 'normal',
style: '',
text: 'subscript',
type: 'text',
version: 1,
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' ',
type: 'text',
version: 1,
},
{
detail: 0,
format: 64,
mode: 'normal',
style: '',
text: 'superscript',
type: 'text',
version: 1,
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' ',
type: 'text',
version: 1,
},
{
detail: 0,
format: 16,
mode: 'normal',
style: '',
text: 'code',
type: 'text',
version: 1,
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' ',
type: 'text',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'internal link',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'link',
version: 2,
attributes: {
newTab: true,
linkType: 'internal',
doc: {
value: '{{TEXT_DOC_ID}}',
relationTo: 'text-fields',
data: {}, // populated data
},
text: 'internal link',
},
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' ',
type: 'text',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'external link',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'link',
version: 2,
attributes: {
newTab: true,
nofollow: false,
url: 'https://fewfwef.de',
linkType: 'custom',
text: 'external link',
},
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' s. ',
type: 'text',
version: 1,
},
{
detail: 0,
format: 4,
mode: 'normal',
style: '',
text: 'strikethrough',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'heading 1',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'heading',
version: 1,
tag: 'h1',
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'heading 2',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'heading',
version: 1,
tag: 'h2',
},
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'bullet list ',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'item 2',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 2,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'item 3',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 3,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'list',
version: 1,
listType: 'bullet',
start: 1,
tag: 'ul',
},
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'ordered list',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'item 2',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 2,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'item 3',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 3,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'list',
version: 1,
listType: 'number',
start: 1,
tag: 'ol',
},
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'check list',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'item 2',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 2,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'item 3',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 3,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'list',
version: 1,
listType: 'check',
start: 1,
tag: 'ul',
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'quoteeee',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'quote',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'code block line ',
type: 'code-highlight',
version: 1,
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: '1',
type: 'code-highlight',
version: 1,
highlightType: 'number',
},
{
type: 'linebreak',
version: 1,
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'code block line ',
type: 'code-highlight',
version: 1,
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: '2',
type: 'code-highlight',
version: 1,
highlightType: 'number',
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'code',
version: 1,
language: 'javascript',
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Upload:',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
{
children: [
{
type: 'upload',
version: 1,
rawImagePayload: {
value: {
id: '{{UPLOAD_DOC_ID}}',
},
relationTo: 'uploads',
},
caption: {
editorState: {
root: {
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'upload caption',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'root',
version: 1,
},
},
},
showCaption: true,
data: {}, // populated upload data
},
],
direction: null,
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
{
children: [],
direction: null,
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
{
children: [
{
children: [
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: '2x2 table top left',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
type: 'tablecell',
version: 1,
colSpan: 1,
rowSpan: 1,
backgroundColor: null,
headerState: 3,
},
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: '2x2 table top right',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
type: 'tablecell',
version: 1,
colSpan: 1,
rowSpan: 1,
backgroundColor: null,
headerState: 1,
},
],
direction: null,
format: '',
indent: 0,
type: 'tablerow',
version: 1,
},
{
children: [
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: '2x2 table bottom left',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
type: 'tablecell',
version: 1,
colSpan: 1,
rowSpan: 1,
backgroundColor: null,
headerState: 2,
},
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: '2x2 table bottom right',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
type: 'tablecell',
version: 1,
colSpan: 1,
rowSpan: 1,
backgroundColor: null,
headerState: 0,
},
],
direction: null,
format: '',
indent: 0,
type: 'tablerow',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
type: 'table',
version: 1,
},
{
rows: [
{
cells: [
{
colSpan: 1,
id: 'kafuj',
json: '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}',
type: 'header',
width: null,
},
{
colSpan: 1,
id: 'iussu',
json: '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}',
type: 'header',
width: null,
},
],
height: null,
id: 'tnied',
},
{
cells: [
{
colSpan: 1,
id: 'hpnnv',
json: '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}',
type: 'header',
width: null,
},
{
colSpan: 1,
id: 'ndteg',
json: '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}',
type: 'normal',
width: null,
},
],
height: null,
id: 'rxyey',
},
{
cells: [
{
colSpan: 1,
id: 'rtueq',
json: '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}',
type: 'header',
width: null,
},
{
colSpan: 1,
id: 'vrzoi',
json: '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}',
type: 'normal',
width: null,
},
],
height: null,
id: 'qzglv',
},
],
type: 'tablesheet',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'youtube:',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
{
format: '',
type: 'youtube',
version: 1,
videoID: '3Nwt3qu0_UY',
},
{
children: [
{
equation: '3+3',
inline: true,
type: 'equation',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'collapsible title',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'collapsible-title',
version: 1,
},
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'collabsible conteent',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'collapsible-content',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'collapsible-container',
version: 1,
open: true,
},
{
children: [],
direction: null,
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
{
type: 'horizontalrule',
version: 1,
},
],
direction: 'ltr',
},
},
}

View File

@@ -0,0 +1,158 @@
import type { CollectionConfig } from 'payload'
import {
lexicalEditor,
lexicalHTMLField,
LinkFeature,
TreeViewFeature,
UploadFeature,
} from '@payloadcms/richtext-lexical'
import {
LexicalPluginToLexicalFeature,
SlateToLexicalFeature,
} from '@payloadcms/richtext-lexical/migrate'
import { lexicalMigrateFieldsSlug } from '../../slugs.js'
import { getSimpleLexicalData } from './data.js'
export const LexicalMigrateFields: CollectionConfig = {
slug: lexicalMigrateFieldsSlug,
admin: {
useAsTitle: 'title',
listSearchableFields: ['title', 'richTextLexicalCustomFields'],
},
access: {
read: () => true,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'lexicalWithLexicalPluginData',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
LexicalPluginToLexicalFeature({ quiet: true }),
TreeViewFeature(),
LinkFeature({
fields: ({ defaultFields }) => [
...defaultFields,
{
name: 'rel',
label: 'Rel Attribute',
type: 'select',
hasMany: true,
options: ['noopener', 'noreferrer', 'nofollow'],
admin: {
description:
'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.',
},
},
],
}),
UploadFeature({
collections: {
uploads: {
fields: [
{
name: 'caption',
type: 'richText',
editor: lexicalEditor(),
},
],
},
},
}),
],
}),
},
{
name: 'lexicalWithSlateData',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
SlateToLexicalFeature(),
TreeViewFeature(),
LinkFeature({
fields: ({ defaultFields }) => [
...defaultFields,
{
name: 'rel',
label: 'Rel Attribute',
type: 'select',
hasMany: true,
options: ['noopener', 'noreferrer', 'nofollow'],
admin: {
description:
'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.',
},
},
],
}),
UploadFeature({
collections: {
uploads: {
fields: [
{
name: 'caption',
type: 'richText',
editor: lexicalEditor(),
},
],
},
},
}),
],
}),
},
{
name: 'lexicalSimple',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [...defaultFeatures],
}),
defaultValue: getSimpleLexicalData('simple'),
},
lexicalHTMLField({ htmlFieldName: 'lexicalSimple_html', lexicalFieldName: 'lexicalSimple' }),
{
name: 'groupWithLexicalField',
type: 'group',
fields: [
{
name: 'lexicalInGroupField',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [...defaultFeatures],
}),
defaultValue: getSimpleLexicalData('group'),
},
lexicalHTMLField({
htmlFieldName: 'lexicalInGroupField_html',
lexicalFieldName: 'lexicalInGroupField',
}),
],
},
{
name: 'arrayWithLexicalField',
type: 'array',
fields: [
{
name: 'lexicalInArrayField',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [...defaultFeatures],
}),
},
lexicalHTMLField({
htmlFieldName: 'lexicalInArrayField_html',
lexicalFieldName: 'lexicalInArrayField',
}),
],
},
],
}

View File

@@ -0,0 +1,6 @@
import { payloadPluginLexicalData } from './generatePayloadPluginLexicalData.js'
export const LexicalRichTextDoc = {
title: 'Rich Text',
richTextLexicalWithLexicalPluginData: payloadPluginLexicalData,
}

View File

@@ -0,0 +1,38 @@
import type { CollectionConfig } from 'payload'
import { lexicalEditor, UploadFeature } from '@payloadcms/richtext-lexical'
/**
* Do not change this specific CollectionConfig. Simply having this config in payload used to cause the admin panel to hang.
* Thus, simply having this config in the test suite is enough to test the bug fix and prevent regressions. In case of regression,
* the entire admin panel will hang again and all tests will fail.
*/
export const LexicalObjectReferenceBugCollection: CollectionConfig = {
slug: 'lexicalObjectReferenceBug',
fields: [
{
name: 'lexicalDefault',
type: 'richText',
},
{
name: 'lexicalEditor',
type: 'richText',
editor: lexicalEditor({
features: [
UploadFeature({
collections: {
media: {
fields: [
{
name: 'caption',
type: 'richText',
},
],
},
},
}),
],
}),
},
],
}

View File

@@ -0,0 +1,38 @@
import type { CollectionConfig } from 'payload'
import {
defaultEditorFeatures,
FixedToolbarFeature,
lexicalEditor,
RelationshipFeature,
} from '@payloadcms/richtext-lexical'
import { lexicalRelationshipFieldsSlug } from '../../slugs.js'
export const LexicalRelationshipsFields: CollectionConfig = {
slug: lexicalRelationshipFieldsSlug,
access: {
read: () => true,
},
fields: [
{
name: 'richText',
type: 'richText',
editor: lexicalEditor({
features: [
...defaultEditorFeatures,
RelationshipFeature({
enabledCollections: ['array-fields'],
}),
],
}),
},
{
name: 'richText2',
type: 'richText',
editor: lexicalEditor({
features: [...defaultEditorFeatures, RelationshipFeature(), FixedToolbarFeature()],
}),
},
],
}

View File

@@ -0,0 +1,75 @@
import type { Block } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
export const TextBlock: Block = {
fields: [
{
name: 'text',
type: 'text',
required: true,
},
],
slug: 'textRequired',
}
export const UploadAndRichTextBlock: Block = {
fields: [
{
name: 'upload',
type: 'upload',
relationTo: 'uploads',
required: true,
},
{
name: 'richText',
type: 'richText',
editor: lexicalEditor(),
},
],
slug: 'uploadAndRichText',
}
export const RelationshipBlock: Block = {
fields: [
{
name: 'rel',
type: 'relationship',
relationTo: 'uploads',
required: true,
},
],
slug: 'relationshipBlock',
}
export const SelectFieldBlock: Block = {
fields: [
{
name: 'select',
type: 'select',
options: [
{
label: 'Option 1',
value: 'option1',
},
{
label: 'Option 2',
value: 'option2',
},
{
label: 'Option 3',
value: 'option3',
},
{
label: 'Option 4',
value: 'option4',
},
{
label: 'Option 5',
value: 'option5',
},
],
},
],
slug: 'select',
}

View File

@@ -0,0 +1,135 @@
import { generateLexicalRichText } from './generateLexicalRichText.js'
import { generateSlateRichText } from './generateSlateRichText.js'
export const richTextBlocks = [
{
blockType: 'textBlock',
text: 'Regular text',
},
{
blockType: 'richTextBlockSlate',
text: [
{
children: [
{
text: 'Rich text',
},
],
type: 'h1',
},
],
},
]
export const richTextDocData = {
title: 'Rich Text',
selectHasMany: ['one', 'five'],
richText: generateSlateRichText(),
richTextReadOnly: generateSlateRichText(),
richTextCustomFields: generateSlateRichText(),
lexicalCustomFields: generateLexicalRichText(),
blocks: richTextBlocks,
}
export const richTextBulletsDocData = {
title: 'Bullets and Indentation',
lexicalCustomFields: generateLexicalRichText(),
richText: [
{
type: 'ul',
children: [
{
type: 'li',
children: [
{
children: [
{
text: 'I am semantically connected to my sub-bullets',
},
],
},
{
type: 'ul',
children: [
{
type: 'li',
children: [
{
text: 'I am sub-bullets that are semantically connected to the parent bullet',
},
],
},
],
},
],
},
{
children: [
{
text: 'Normal bullet',
},
],
type: 'li',
},
{
type: 'li',
children: [
{
type: 'ul',
children: [
{
type: 'li',
children: [
{
text: 'I am the old style of sub-bullet',
},
],
},
],
},
],
},
{
type: 'li',
children: [
{
text: 'Another normal bullet',
},
],
},
{
type: 'li',
children: [
{
children: [
{
text: 'This text precedes a nested list',
},
],
},
{
type: 'ul',
children: [
{
type: 'li',
children: [
{
text: 'I am a sub-bullet',
},
],
},
{
type: 'li',
children: [
{
text: 'And I am another sub-bullet',
},
],
},
],
},
],
},
],
},
],
}

View File

@@ -0,0 +1,431 @@
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import path from 'path'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
import {
ensureCompilationIsDone,
initPageConsoleErrorCatch,
saveDocAndAssert,
} from '../../../helpers.js'
import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
import { reInitializeDB } from '../../../helpers/reInitializeDB.js'
import { RESTClient } from '../../../helpers/rest.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
const filename = fileURLToPath(import.meta.url)
const currentFolder = path.dirname(filename)
const dirname = path.resolve(currentFolder, '../../')
const { beforeAll, beforeEach, describe } = test
let client: RESTClient
let page: Page
let serverURL: string
// If we want to make this run in parallel: test.describe.configure({ mode: 'parallel' })
describe('Rich Text', () => {
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
;({ serverURL } = await initPayloadE2ENoConfig<Config>({
dirname,
}))
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await ensureCompilationIsDone({ page, serverURL })
})
beforeEach(async () => {
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})
if (client) {
await client.logout()
}
client = new RESTClient({ defaultSlug: 'users', serverURL })
await client.login()
await ensureCompilationIsDone({ page, serverURL })
})
async function navigateToRichTextFields() {
const url: AdminUrlUtil = new AdminUrlUtil(serverURL, 'rich-text-fields')
await page.goto(url.list)
const linkToDoc = page.locator('.row-1 .cell-title a').first()
await expect(() => expect(linkToDoc).toBeTruthy()).toPass({ timeout: POLL_TOPASS_TIMEOUT })
const linkDocHref = await linkToDoc.getAttribute('href')
await linkToDoc.click()
await page.waitForURL(`**${linkDocHref}`)
}
describe('cell', () => {
test('ensure cells are smaller than 300px in height', async () => {
const url: AdminUrlUtil = new AdminUrlUtil(serverURL, 'rich-text-fields')
await page.goto(url.list) // Navigate to rich-text list view
const table = page.locator('.list-controls ~ .table')
const lexicalCell = table.locator('.cell-lexicalCustomFields').first()
const lexicalHtmlCell = table.locator('.cell-lexicalCustomFields_html').first()
const entireRow = table.locator('.row-1').first()
// Make sure each of the 3 above are no larger than 300px in height:
await expect
.poll(async () => (await lexicalCell.boundingBox()).height, {
timeout: POLL_TOPASS_TIMEOUT,
})
.toBeLessThanOrEqual(300)
await expect
.poll(async () => (await lexicalHtmlCell.boundingBox()).height, {
timeout: POLL_TOPASS_TIMEOUT,
})
.toBeLessThanOrEqual(300)
await expect
.poll(async () => (await entireRow.boundingBox()).height, { timeout: POLL_TOPASS_TIMEOUT })
.toBeLessThanOrEqual(300)
})
})
describe('toolbar', () => {
test('should run url validation', async () => {
await navigateToRichTextFields()
// Open link drawer
await page.locator('.rich-text__toolbar button:not([disabled]) .link').first().click()
// find the drawer
const editLinkModal = page.locator('[id^=drawer_1_rich-text-link-]')
await expect(editLinkModal).toBeVisible()
// Fill values and click Confirm
await editLinkModal.locator('#field-text').fill('link text')
await editLinkModal.locator('label[for="field-linkType-custom-2"]').click()
await editLinkModal.locator('#field-url').fill('')
await wait(200)
await editLinkModal.locator('button[type="submit"]').click()
await wait(400)
const errorField = page.locator(
'[id^=drawer_1_rich-text-link-] .render-fields > :nth-child(3)',
)
const hasErrorClass = await errorField.evaluate((el) => el.classList.contains('error'))
expect(hasErrorClass).toBe(true)
})
// TODO: Flaky test flakes consistently in CI: https://github.com/payloadcms/payload/actions/runs/8913431889/job/24478995959?pr=6155
test.skip('should create new url custom link', async () => {
await navigateToRichTextFields()
// Open link drawer
await page.locator('.rich-text__toolbar button:not([disabled]) .link').first().click()
// find the drawer
const editLinkModal = page.locator('[id^=drawer_1_rich-text-link-]')
await expect(editLinkModal).toBeVisible()
await wait(400)
// Fill values and click Confirm
await editLinkModal.locator('#field-text').fill('link text')
await editLinkModal.locator('label[for="field-linkType-custom-2"]').click()
await editLinkModal.locator('#field-url').fill('https://payloadcms.com')
await editLinkModal.locator('button[type="submit"]').click()
await expect(editLinkModal).toBeHidden()
await wait(400)
await saveDocAndAssert(page)
// Remove link from editor body
await page.locator('span >> text="link text"').click()
const popup = page.locator('.popup--active .rich-text-link__popup')
await expect(popup.locator('.rich-text-link__link-label')).toBeVisible()
await popup.locator('.rich-text-link__link-close').click()
await expect(page.locator('span >> text="link text"')).toHaveCount(0)
})
// TODO: Flaky test flakes consistently in CI: https://github.com/payloadcms/payload/actions/runs/8913769794/job/24480056251?pr=6155
test.skip('should create new internal link', async () => {
await navigateToRichTextFields()
// Open link drawer
await page.locator('.rich-text__toolbar button:not([disabled]) .link').first().click()
// find the drawer
const editLinkModal = page.locator('[id^=drawer_1_rich-text-link-]')
await expect(editLinkModal).toBeVisible()
await wait(400)
// Fill values and click Confirm
await editLinkModal.locator('#field-text').fill('link text')
await editLinkModal.locator('label[for="field-linkType-internal-2"]').click()
await editLinkModal.locator('#field-doc .rs__control').click()
await page.keyboard.type('dev@')
await editLinkModal
.locator('#field-doc .rs__menu .rs__option:has-text("dev@payloadcms.com")')
.click()
// await wait(200);
await editLinkModal.locator('button[type="submit"]').click()
await saveDocAndAssert(page)
})
test('should not create new url link when read only', async () => {
await navigateToRichTextFields()
const modalTrigger = page.locator('.rich-text--read-only .rich-text__toolbar button .link')
await expect(modalTrigger).toBeDisabled()
})
// TODO: this test can't find the selector for the search filter, but functionality works.
// Need to debug
test.skip('should search correct useAsTitle field after toggling collection in list drawer', async () => {
await navigateToRichTextFields()
// open link drawer
const field = page.locator('#field-richText')
const button = field.locator(
'button.rich-text-relationship__list-drawer-toggler.list-drawer__toggler',
)
await button.click()
// check that the search is on the `name` field of the `text-fields` collection
const drawer = page.locator('[id^=list-drawer_1_]')
await expect(drawer.locator('.search-filter__input')).toHaveAttribute(
'placeholder',
'Search by Text',
)
// change the selected collection to `array-fields`
await page.locator('.list-drawer_select-collection-wrap .rs__control').click()
const menu = page.locator('.list-drawer__select-collection-wrap .rs__menu')
await menu.locator('.rs__option').getByText('Array Field').click()
// check that `id` is now the default search field
await expect(drawer.locator('.search-filter__input')).toHaveAttribute(
'placeholder',
'Search by ID',
)
})
test('should only list RTE enabled collections in link drawer', async () => {
await navigateToRichTextFields()
await wait(1000)
await page.locator('.rich-text__toolbar button:not([disabled]) .link').first().click()
const editLinkModal = page.locator('[id^=drawer_1_rich-text-link-]')
await expect(editLinkModal).toBeVisible()
await wait(1000)
await editLinkModal.locator('label[for="field-linkType-internal-2"]').click()
await editLinkModal.locator('.relationship__wrap .rs__control').click()
const menu = page.locator('.relationship__wrap .rs__menu')
// array-fields has enableRichTextLink set to false
await expect(menu).not.toContainText('Array Fields')
})
test('should only list non-upload collections in relationship drawer', async () => {
await navigateToRichTextFields()
await wait(1000)
// Open link drawer
await page
.locator('.rich-text__toolbar button:not([disabled]) .relationship-rich-text-button')
.first()
.click()
await wait(1000)
// open the list select menu
await page.locator('.list-drawer__select-collection-wrap .rs__control').click()
const menu = page.locator('.list-drawer__select-collection-wrap .rs__menu')
const regex = /\bUploads\b/
await expect(menu).not.toContainText(regex)
})
// TODO: Flaky test in CI. Flake: https://github.com/payloadcms/payload/actions/runs/8914532814/job/24482407114
test.skip('should respect customizing the default fields', async () => {
const linkText = 'link'
const value = 'test value'
await navigateToRichTextFields()
await wait(1000)
const field = page.locator('.rich-text', {
has: page.locator('#field-richTextCustomFields'),
})
// open link drawer
const button = field.locator('button.rich-text__button.link')
await button.click()
await wait(1000)
// fill link fields
const linkDrawer = page.locator('[id^=drawer_1_rich-text-link-]')
const fields = linkDrawer.locator('.render-fields > .field-type')
await fields.locator('#field-text').fill(linkText)
await fields.locator('#field-url').fill('https://payloadcms.com')
const input = fields.locator('#field-fields__customLinkField')
await input.fill(value)
await wait(1000)
// submit link closing drawer
await linkDrawer.locator('button[type="submit"]').click()
const linkInEditor = field.locator(`.rich-text-link >> text="${linkText}"`)
await wait(300)
await saveDocAndAssert(page)
// open modal again
await linkInEditor.click()
const popup = page.locator('.popup--active .rich-text-link__popup')
await expect(popup).toBeVisible()
await popup.locator('.rich-text-link__link-edit').click()
const linkDrawer2 = page.locator('[id^=drawer_1_rich-text-link-]')
const fields2 = linkDrawer2.locator('.render-fields > .field-type')
const input2 = fields2.locator('#field-fields__customLinkField')
await expect(input2).toHaveValue(value)
})
})
describe('editor', () => {
test('should populate url link', async () => {
await navigateToRichTextFields()
await wait(500)
// Open link popup
await page.locator('#field-richText span >> text="render links"').click()
const popup = page.locator('.popup--active .rich-text-link__popup')
await expect(popup).toBeVisible()
await expect(popup.locator('a')).toHaveAttribute('href', 'https://payloadcms.com')
// Open the drawer
await popup.locator('.rich-text-link__link-edit').click()
const editLinkModal = page.locator('[id^=drawer_1_rich-text-link-]')
await expect(editLinkModal).toBeVisible()
// Check the drawer values
const textField = editLinkModal.locator('#field-text')
await expect(textField).toHaveValue('render links')
await wait(1000)
// Close the drawer
await editLinkModal.locator('button[type="submit"]').click()
await expect(editLinkModal).toBeHidden()
})
test('should populate relationship link', async () => {
await navigateToRichTextFields()
// Open link popup
await page.locator('#field-richText span >> text="link to relationships"').click()
const popup = page.locator('.popup--active .rich-text-link__popup')
await expect(popup).toBeVisible()
await expect(popup.locator('a')).toHaveAttribute(
'href',
/\/admin\/collections\/array-fields\/.*/,
)
// Open the drawer
await popup.locator('.rich-text-link__link-edit').click()
const editLinkModal = page.locator('[id^=drawer_1_rich-text-link-]')
await expect(editLinkModal).toBeVisible()
// Check the drawer values
const textField = editLinkModal.locator('#field-text')
await expect(textField).toHaveValue('link to relationships')
})
test('should open upload drawer and render custom relationship fields', async () => {
await navigateToRichTextFields()
const field = page.locator('#field-richText')
const button = field.locator('button.rich-text-upload__upload-drawer-toggler')
await button.click()
const documentDrawer = page.locator('[id^=drawer_1_upload-drawer-]')
await expect(documentDrawer).toBeVisible()
const caption = documentDrawer.locator('#field-caption')
await expect(caption).toBeVisible()
})
test('should open upload document drawer from read-only field', async () => {
await navigateToRichTextFields()
const field = page.locator('#field-richTextReadOnly')
const button = field.locator(
'button.rich-text-upload__doc-drawer-toggler.doc-drawer__toggler',
)
await button.click()
const documentDrawer = page.locator('[id^=doc-drawer_uploads_1_]')
await expect(documentDrawer).toBeVisible()
})
test('should open relationship document drawer from read-only field', async () => {
await navigateToRichTextFields()
const field = page.locator('#field-richTextReadOnly')
const button = field.locator(
'button.rich-text-relationship__doc-drawer-toggler.doc-drawer__toggler',
)
await button.click()
const documentDrawer = page.locator('[id^=doc-drawer_text-fields_1_]')
await expect(documentDrawer).toBeVisible()
})
test('should populate new links', async () => {
await navigateToRichTextFields()
await wait(1000)
// Highlight existing text
const headingElement = page.locator(
'#field-richText h1 >> text="Hello, I\'m a rich text field."',
)
await headingElement.selectText()
await wait(500)
// click the toolbar link button
await page.locator('.rich-text__toolbar button:not([disabled]) .link').first().click()
// find the drawer and confirm the values
const editLinkModal = page.locator('[id^=drawer_1_rich-text-link-]')
await expect(editLinkModal).toBeVisible()
const textField = editLinkModal.locator('#field-text')
await expect(textField).toHaveValue("Hello, I'm a rich text field.")
})
test('should not take value from previous block', async () => {
await navigateToRichTextFields()
await page.locator('#field-blocks').scrollIntoViewIfNeeded()
await expect(page.locator('#field-blocks__0__text')).toBeVisible()
await expect(page.locator('#field-blocks__0__text')).toHaveValue('Regular text')
await wait(500)
const editBlock = page.locator('#blocks-row-0 .popup-button')
await editBlock.click()
const removeButton = page.locator('#blocks-row-0').getByRole('button', { name: 'Remove' })
await expect(removeButton).toBeVisible()
await wait(500)
await removeButton.click()
const richTextField = page.locator('#field-blocks__0__text')
await expect(richTextField).toBeVisible()
const richTextValue = await richTextField.innerText()
expect(richTextValue).toContain('Rich text')
})
})
})

View File

@@ -0,0 +1,318 @@
import { textFieldsSlug } from '../../slugs.js'
import { loremIpsum } from './loremIpsum.js'
export function generateLexicalRichText() {
return {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: "Hello, I'm a rich text field.",
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: 'center',
indent: 0,
type: 'heading',
version: 1,
tag: 'h1',
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'I can do all kinds of fun stuff like ',
type: 'text',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'render links',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
id: '665d10938106ab380c7f3730',
type: 'link',
version: 2,
fields: {
url: 'https://payloadcms.com',
newTab: true,
linkType: 'custom',
},
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ', ',
type: 'text',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'link to relationships',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
id: '665d10938106ab380c7f3730',
type: 'link',
version: 2,
fields: {
url: 'https://',
doc: {
value: '{{TEXT_DOC_ID}}',
relationTo: textFieldsSlug,
},
newTab: false,
linkType: 'internal',
},
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ', and store nested relationship fields:',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
{
format: '',
type: 'relationship',
version: 2,
value: '{{TEXT_DOC_ID}}',
relationTo: 'text-fields',
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'You can build your own elements, too.',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: "It's built with Lexical",
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'It stores content as JSON so you can use it wherever you need',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 2,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: "It's got a great editing experience for non-technical users",
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 3,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'list',
version: 1,
listType: 'bullet',
start: 1,
tag: 'ul',
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'And a whole lot more.',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
{
format: '',
type: 'upload',
version: 2,
id: '665d10938106ab380c7f372f',
relationTo: 'uploads',
value: '{{UPLOAD_DOC_ID}}',
fields: {
caption: {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
children: [
...[...Array(4)].map(() => ({
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: loremIpsum,
type: 'text',
version: 1,
},
],
})),
],
direction: 'ltr',
},
},
},
},
{
children: [],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam hendrerit nisi sed sollicitudin pellentesque. Nunc posuere purus rhoncus pulvinar aliquam. Ut aliquet tristique nisl vitae volutpat. Nulla aliquet porttitor venenatis. Donec a dui et dui fringilla consectetur id nec massa. Aliquam erat volutpat. Sed ut dui ut lacus dictum fermentum vel tincidunt neque. Sed sed lacinia lectus. Duis sit amet sodales felis. Duis nunc eros, mattis at dui ac, convallis semper risus. In adipiscing ultrices tellus, in suscipit massa vehicula eu.',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam hendrerit nisi sed sollicitudin pellentesque. Nunc posuere purus rhoncus pulvinar aliquam. Ut aliquet tristique nisl vitae volutpat. Nulla aliquet porttitor venenatis. Donec a dui et dui fringilla consectetur id nec massa. Aliquam erat volutpat. Sed ut dui ut lacus dictum fermentum vel tincidunt neque. Sed sed lacinia lectus. Duis sit amet sodales felis. Duis nunc eros, mattis at dui ac, convallis semper risus. In adipiscing ultrices tellus, in suscipit massa vehicula eu.',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
],
direction: 'ltr',
},
}
}

View File

@@ -0,0 +1,148 @@
import { loremIpsum } from './loremIpsum.js'
export function generateSlateRichText() {
return [
{
children: [
{
text: "Hello, I'm a rich text field.",
},
],
type: 'h1',
textAlign: 'center',
},
{
children: [
{
text: 'I can do all kinds of fun stuff like ',
},
{
type: 'link',
url: 'https://payloadcms.com',
newTab: true,
children: [
{
text: 'render links',
},
],
},
{
text: ', ',
},
{
type: 'link',
linkType: 'internal',
doc: {
value: '{{ARRAY_DOC_ID}}',
relationTo: 'array-fields',
},
fields: {},
children: [
{
text: 'link to relationships',
},
],
},
{
text: ', and store nested relationship fields:',
},
],
},
{
children: [
{
text: '',
},
],
type: 'relationship',
value: {
id: '{{TEXT_DOC_ID}}',
},
relationTo: 'text-fields',
},
{
children: [
{
text: 'You can build your own elements, too.',
},
],
},
{
type: 'ul',
children: [
{
children: [
{
text: "It's built with SlateJS",
},
],
type: 'li',
},
{
type: 'li',
children: [
{
text: 'It stores content as JSON so you can use it wherever you need',
},
],
},
{
type: 'li',
children: [
{
text: "It's got a great editing experience for non-technical users",
},
],
},
],
},
{
children: [
{
text: 'And a whole lot more.',
},
],
},
{
children: [
{
text: '',
},
],
type: 'upload',
value: {
id: '{{UPLOAD_DOC_ID}}',
},
relationTo: 'uploads',
fields: {
caption: [
...[...Array(4)].map(() => {
return {
children: [
{
text: loremIpsum,
},
],
}
}),
],
},
},
{
children: [
{
text: '',
},
],
},
...[...Array(2)].map(() => {
return {
children: [
{
text: loremIpsum,
},
],
}
}),
]
}

View File

@@ -0,0 +1,324 @@
import type { CollectionConfig } from 'payload'
import {
BlocksFeature,
HTMLConverterFeature,
lexicalEditor,
lexicalHTML,
LinkFeature,
TreeViewFeature,
UploadFeature,
} from '@payloadcms/richtext-lexical'
import { slateEditor } from '@payloadcms/richtext-slate'
import { richTextFieldsSlug } from '../../slugs.js'
import { RelationshipBlock, SelectFieldBlock, TextBlock, UploadAndRichTextBlock } from './blocks.js'
const RichTextFields: CollectionConfig = {
slug: richTextFieldsSlug,
admin: {
useAsTitle: 'title',
},
access: {
read: () => true,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'lexicalCustomFields',
type: 'richText',
required: true,
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
TreeViewFeature(),
HTMLConverterFeature({}),
LinkFeature({
fields: ({ defaultFields }) => [
...defaultFields,
{
name: 'rel',
label: 'Rel Attribute',
type: 'select',
hasMany: true,
options: ['noopener', 'noreferrer', 'nofollow'],
admin: {
description:
'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.',
},
},
],
}),
UploadFeature({
collections: {
uploads: {
fields: [
{
name: 'caption',
type: 'richText',
editor: lexicalEditor(),
},
],
},
},
}),
BlocksFeature({
blocks: [TextBlock, UploadAndRichTextBlock, SelectFieldBlock, RelationshipBlock],
}),
],
}),
},
lexicalHTML('lexicalCustomFields', { name: 'lexicalCustomFields_html' }),
{
name: 'lexical',
type: 'richText',
admin: {
description: 'This rich text field uses the lexical editor.',
},
defaultValue: {
root: {
children: [
{
children: [
{
text: 'This is a paragraph.',
type: 'text',
},
],
direction: null,
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
type: 'root',
version: 1,
},
},
editor: lexicalEditor({
features: ({ defaultFeatures }) => [...defaultFeatures, TreeViewFeature()],
}),
},
{
name: 'selectHasMany',
hasMany: true,
type: 'select',
admin: {
description:
'This select field is rendered here to ensure its options dropdown renders above the rich text toolbar.',
},
options: [
{
label: 'Value One',
value: 'one',
},
{
label: 'Value Two',
value: 'two',
},
{
label: 'Value Three',
value: 'three',
},
{
label: 'Value Four',
value: 'four',
},
{
label: 'Value Five',
value: 'five',
},
{
label: 'Value Six',
value: 'six',
},
],
},
{
name: 'richText',
type: 'richText',
editor: slateEditor({
admin: {
elements: [
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'ul',
'ol',
'textAlign',
'indent',
'link',
'relationship',
'upload',
],
link: {
fields: [
{
name: 'rel',
label: 'Rel Attribute',
type: 'select',
hasMany: true,
options: ['noopener', 'noreferrer', 'nofollow'],
admin: {
description:
'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.',
},
},
],
},
upload: {
collections: {
uploads: {
fields: [
{
name: 'caption',
type: 'richText',
editor: slateEditor({}),
},
],
},
},
},
},
}),
required: true,
},
{
name: 'richTextCustomFields',
type: 'richText',
editor: slateEditor({
admin: {
elements: [
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'ul',
'ol',
'indent',
'link',
'relationship',
'upload',
],
link: {
fields: ({ defaultFields }) => {
return [
...defaultFields,
{
label: 'Custom',
name: 'customLinkField',
type: 'text',
},
]
},
},
upload: {
collections: {
uploads: {
fields: [
{
name: 'caption',
type: 'richText',
editor: slateEditor({}),
},
],
},
},
},
},
}),
},
{
name: 'richTextReadOnly',
type: 'richText',
admin: {
readOnly: true,
},
editor: slateEditor({
admin: {
elements: [
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'ul',
'ol',
'indent',
'link',
'relationship',
'upload',
],
link: {
fields: [
{
name: 'rel',
label: 'Rel Attribute',
type: 'select',
hasMany: true,
options: ['noopener', 'noreferrer', 'nofollow'],
admin: {
description:
'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.',
},
},
],
},
upload: {
collections: {
uploads: {
fields: [
{
name: 'caption',
type: 'richText',
},
],
},
},
},
},
}),
},
{
name: 'blocks',
type: 'blocks',
blocks: [
{
slug: 'textBlock',
fields: [
{
name: 'text',
type: 'text',
},
],
},
{
slug: 'richTextBlockSlate',
fields: [
{
editor: slateEditor({}),
name: 'text',
type: 'richText',
},
],
},
],
},
],
}
export default RichTextFields

View File

@@ -0,0 +1,2 @@
export const loremIpsum =
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam hendrerit nisi sed sollicitudin pellentesque. Nunc posuere purus rhoncus pulvinar aliquam. Ut aliquet tristique nisl vitae volutpat. Nulla aliquet porttitor venenatis. Donec a dui et dui fringilla consectetur id nec massa. Aliquam erat volutpat. Sed ut dui ut lacus dictum fermentum vel tincidunt neque. Sed sed lacinia lectus. Duis sit amet sodales felis. Duis nunc eros, mattis at dui ac, convallis semper risus. In adipiscing ultrices tellus, in suscipit massa vehicula eu.'

View File

@@ -0,0 +1,5 @@
import React from 'react'
export default function CustomDescription() {
return <div>Custom Description</div>
}

View File

@@ -0,0 +1,200 @@
import type { CollectionConfig } from 'payload'
import { defaultText, textFieldsSlug } from './shared.js'
const TextFields: CollectionConfig = {
slug: textFieldsSlug,
admin: {
useAsTitle: 'text',
},
defaultSort: 'id',
fields: [
{
name: 'text',
type: 'text',
required: true,
hooks: {
beforeDuplicate: [({ value }) => `${value} - duplicate`],
},
},
{
name: 'hiddenTextField',
type: 'text',
hidden: true,
},
{
name: 'adminHiddenTextField',
type: 'text',
admin: {
hidden: true,
description: 'This field should be hidden',
},
},
{
name: 'disabledTextField',
type: 'text',
admin: {
disabled: true,
description: 'This field should be disabled',
},
},
{
type: 'row',
admin: {
components: {
Field: './components/CustomField.tsx#CustomField',
},
},
fields: [],
},
{
name: 'localizedText',
type: 'text',
localized: true,
},
{
name: 'i18nText',
type: 'text',
admin: {
description: {
en: 'en description',
es: 'es description',
},
placeholder: {
en: 'en placeholder',
es: 'es placeholder',
},
},
label: {
en: 'Text en',
es: 'Text es',
},
},
{
name: 'defaultString',
type: 'text',
defaultValue: defaultText,
},
{
name: 'defaultEmptyString',
type: 'text',
defaultValue: '',
},
{
name: 'defaultFunction',
type: 'text',
defaultValue: () => defaultText,
},
{
name: 'defaultAsync',
type: 'text',
defaultValue: async (): Promise<string> => {
return new Promise((resolve) =>
setTimeout(() => {
resolve(defaultText)
}, 1),
)
},
},
{
name: 'overrideLength',
type: 'text',
label: 'Override the 40k text length default',
maxLength: 50000,
},
{
name: 'fieldWithDefaultValue',
type: 'text',
defaultValue: async () => {
const defaultValue = new Promise((resolve) => setTimeout(() => resolve('some-value'), 1000))
return defaultValue
},
},
{
name: 'dependentOnFieldWithDefaultValue',
type: 'text',
hooks: {
beforeChange: [
({ data }) => {
return data?.fieldWithDefaultValue || ''
},
],
},
},
{
name: 'hasMany',
type: 'text',
hasMany: true,
},
{
name: 'readOnlyHasMany',
type: 'text',
hasMany: true,
admin: {
readOnly: true,
},
defaultValue: ['default'],
},
{
name: 'validatesHasMany',
type: 'text',
hasMany: true,
minLength: 3,
},
{
name: 'localizedHasMany',
type: 'text',
hasMany: true,
localized: true,
},
{
name: 'withMinRows',
type: 'text',
hasMany: true,
minRows: 2,
},
{
name: 'withMaxRows',
type: 'text',
hasMany: true,
maxRows: 4,
},
{
name: 'defaultValueFromReq',
type: 'text',
defaultValue: async ({ req }) => {
return Promise.resolve(req.context.defaultValue)
},
},
{
name: 'array',
type: 'array',
fields: [
{
name: 'texts',
type: 'text',
hasMany: true,
},
],
},
{
name: 'blocks',
type: 'blocks',
blocks: [
{
slug: 'blockWithText',
fields: [
{
name: 'texts',
type: 'text',
hasMany: true,
},
],
},
],
},
],
}
export default TextFields

View File

@@ -0,0 +1,15 @@
import type { RequiredDataFromCollection } from 'payload/types'
import type { TextField } from '../../payload-types.js'
export const defaultText = 'default-text'
export const textFieldsSlug = 'text-fields'
export const textDoc: RequiredDataFromCollection<TextField> = {
text: 'Seeded text document',
localizedText: 'Localized text',
}
export const anotherTextDoc: RequiredDataFromCollection<TextField> = {
text: 'Another text document',
}

View File

@@ -0,0 +1 @@
uploads

View File

@@ -0,0 +1,37 @@
import type { CollectionConfig } from 'payload'
import path from 'path'
import { fileURLToPath } from 'url'
import { uploadsSlug } from '../../slugs.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const Uploads: CollectionConfig = {
slug: uploadsSlug,
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'media',
type: 'upload',
filterOptions: {
mimeType: {
equals: 'image/png',
},
},
relationTo: uploadsSlug,
},
// {
// name: 'richText',
// type: 'richText',
// },
],
upload: {
staticDir: path.resolve(dirname, './uploads'),
},
}
export default Uploads

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

View File

@@ -0,0 +1,5 @@
import type { Upload } from '../../payload-types.js'
export const uploadsDoc: Partial<Upload> = {
text: 'An upload here',
}

View File

@@ -0,0 +1,9 @@
'use client'
import type { TextFieldClientComponent } from 'payload'
import React from 'react'
export const CustomField: TextFieldClientComponent = ({ schemaPath }) => {
return <div id="custom-field-schema-path">{schemaPath}</div>
}

View File

@@ -0,0 +1,36 @@
/* eslint-disable no-restricted-exports */
import type { BlockSlug } from 'payload'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { autoDedupeBlocksPlugin } from '../helpers/autoDedupeBlocksPlugin/index.js'
import { baseConfig } from './baseConfig.js'
import {
getLexicalFieldsCollection,
lexicalBlocks,
lexicalInlineBlocks,
} from './collections/Lexical/index.js'
import { lexicalFieldsSlug } from './slugs.js'
export default buildConfigWithDefaults({
...baseConfig,
blocks: [
...(baseConfig.blocks ?? []),
...lexicalBlocks.filter((block) => typeof block !== 'string'),
...lexicalInlineBlocks.filter((block) => typeof block !== 'string'),
],
collections: baseConfig.collections?.map((collection) => {
if (collection.slug === lexicalFieldsSlug) {
return getLexicalFieldsCollection({
blocks: lexicalBlocks.map((block) =>
typeof block === 'string' ? block : block.slug,
) as BlockSlug[],
inlineBlocks: lexicalInlineBlocks.map((block) =>
typeof block === 'string' ? block : block.slug,
) as BlockSlug[],
})
}
return collection
}),
plugins: [autoDedupeBlocksPlugin({ silent: false })],
})

5
test/lexical/config.ts Normal file
View File

@@ -0,0 +1,5 @@
/* eslint-disable no-restricted-exports */
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { baseConfig } from './baseConfig.js'
export default buildConfigWithDefaults(baseConfig)

View File

@@ -0,0 +1,53 @@
/**
* IMPORTANT: Do not change this style. This specific configuration is needed to reproduce this issue before it was fixed (https://github.com/payloadcms/payload/issues/4282):
* - lexicalEditor initialized on the outside and then shared between two richText fields
* - tabs field with two tabs, each with a richText field
* - each tab has a different label in each language. Needs to be a LOCALIZED label, not a single label for all languages. Only then can it be reproduced
*/
import type { GlobalConfig } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
const initializedEditor = lexicalEditor()
const TabsWithRichText: GlobalConfig = {
slug: 'tabsWithRichText',
fields: [
{
type: 'tabs',
tabs: [
{
name: 'tab1',
label: {
en: 'en tab1',
es: 'es tab1',
},
fields: [
{
name: 'rt1',
type: 'richText',
editor: initializedEditor,
},
],
},
{
name: 'tab2',
label: {
en: 'en tab2',
es: 'es tab2',
},
fields: [
{
name: 'rt2',
type: 'richText',
editor: initializedEditor,
},
],
},
],
},
],
}
export default TabsWithRichText

View File

@@ -0,0 +1,750 @@
/* eslint-disable jest/no-conditional-in-test */
import type {
SerializedBlockNode,
SerializedLinkNode,
SerializedRelationshipNode,
SerializedUploadNode,
} from '@payloadcms/richtext-lexical'
import type {
SerializedEditorState,
SerializedParagraphNode,
} from '@payloadcms/richtext-lexical/lexical'
import type { PaginatedDocs, Payload } from 'payload'
import path from 'path'
import { fileURLToPath } from 'url'
import type { LexicalField, LexicalMigrateField, RichTextField } from './payload-types.js'
import { devUser } from '../credentials.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { NextRESTClient } from '../helpers/NextRESTClient.js'
import { lexicalDocData } from './collections/Lexical/data.js'
import { generateLexicalLocalizedRichText } from './collections/LexicalLocalized/generateLexicalRichText.js'
import { textToLexicalJSON } from './collections/LexicalLocalized/textToLexicalJSON.js'
import { lexicalMigrateDocData } from './collections/LexicalMigrate/data.js'
import { richTextDocData } from './collections/RichText/data.js'
import { generateLexicalRichText } from './collections/RichText/generateLexicalRichText.js'
import { textDoc } from './collections/Text/shared.js'
import { uploadsDoc } from './collections/Upload/shared.js'
import { clearAndSeedEverything } from './seed.js'
import {
arrayFieldsSlug,
lexicalFieldsSlug,
lexicalMigrateFieldsSlug,
richTextFieldsSlug,
textFieldsSlug,
uploadsSlug,
} from './slugs.js'
let payload: Payload
let restClient: NextRESTClient
let createdArrayDocID: number | string = null
let createdJPGDocID: number | string = null
let createdTextDocID: number | string = null
let createdRichTextDocID: number | string = null
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
describe('Lexical', () => {
beforeAll(async () => {
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, restClient } = await initPayloadInt(dirname))
})
beforeEach(async () => {
await clearAndSeedEverything(payload)
restClient = new NextRESTClient(payload.config)
await restClient.login({
slug: 'users',
credentials: devUser,
})
createdArrayDocID = (
await payload.find({
collection: arrayFieldsSlug,
depth: 0,
where: {
title: {
equals: 'array doc 1',
},
},
})
).docs[0].id
createdJPGDocID = (
await payload.find({
collection: uploadsSlug,
depth: 0,
where: {
filename: {
equals: 'payload.jpg',
},
},
})
).docs[0].id
createdTextDocID = (
await payload.find({
collection: textFieldsSlug,
depth: 0,
where: {
text: {
equals: 'Seeded text document',
},
},
})
).docs[0].id
createdRichTextDocID = (
await payload.find({
collection: richTextFieldsSlug,
depth: 0,
where: {
title: {
equals: 'Rich Text',
},
},
})
).docs[0].id
})
describe('basic', () => {
it('should allow querying on lexical content', async () => {
const richTextDoc: RichTextField = (
await payload.find({
collection: richTextFieldsSlug,
depth: 0,
where: {
title: {
equals: richTextDocData.title,
},
},
})
).docs[0] as never
expect(richTextDoc?.lexicalCustomFields).toStrictEqual(
JSON.parse(
JSON.stringify(generateLexicalRichText())
.replace(
/"\{\{ARRAY_DOC_ID\}\}"/g,
payload.db.defaultIDType === 'number'
? `${createdArrayDocID}`
: `"${createdArrayDocID}"`,
)
.replace(
/"\{\{UPLOAD_DOC_ID\}\}"/g,
payload.db.defaultIDType === 'number' ? `${createdJPGDocID}` : `"${createdJPGDocID}"`,
)
.replace(
/"\{\{TEXT_DOC_ID\}\}"/g,
payload.db.defaultIDType === 'number'
? `${createdTextDocID}`
: `"${createdTextDocID}"`,
),
),
)
})
it('should populate respect depth parameter and populate link node relationship', async () => {
const richTextDoc: RichTextField = (
await payload.find({
collection: richTextFieldsSlug,
depth: 1,
where: {
title: {
equals: richTextDocData.title,
},
},
})
).docs[0] as never
const seededDocument = JSON.parse(
JSON.stringify(generateLexicalRichText())
.replace(
/"\{\{ARRAY_DOC_ID\}\}"/g,
payload.db.defaultIDType === 'number'
? `${createdArrayDocID}`
: `"${createdArrayDocID}"`,
)
.replace(
/"\{\{UPLOAD_DOC_ID\}\}"/g,
payload.db.defaultIDType === 'number' ? `${createdJPGDocID}` : `"${createdJPGDocID}"`,
)
.replace(
/"\{\{TEXT_DOC_ID\}\}"/g,
payload.db.defaultIDType === 'number' ? `${createdTextDocID}` : `"${createdTextDocID}"`,
),
)
expect(richTextDoc?.lexicalCustomFields).not.toStrictEqual(seededDocument) // The whole seededDocument should not match, as richTextDoc should now contain populated documents not present in the seeded document
const lexical: SerializedEditorState = richTextDoc?.lexicalCustomFields
const linkNode: SerializedLinkNode = (lexical.root.children[1] as SerializedParagraphNode)
.children[3] as SerializedLinkNode
expect(linkNode.fields.doc.value.text).toStrictEqual(textDoc.text)
})
it('should populate relationship node', async () => {
const richTextDoc: RichTextField = (
await payload.find({
collection: richTextFieldsSlug,
depth: 1,
where: {
title: {
equals: richTextDocData.title,
},
},
})
).docs[0] as never
const relationshipNode: SerializedRelationshipNode =
richTextDoc.lexicalCustomFields.root.children.find(
(node) => node.type === 'relationship',
) as SerializedRelationshipNode
expect(relationshipNode.value.text).toStrictEqual(textDoc.text)
})
it('should respect GraphQL rich text depth parameter and populate upload node', async () => {
const query = `query {
RichTextFields {
docs {
lexicalCustomFields(depth: 2)
}
}
}`
const response: {
data: { RichTextFields: PaginatedDocs<RichTextField> }
} = await restClient
.GRAPHQL_POST({
body: JSON.stringify({ query }),
})
.then((res) => res.json())
const { docs } = response.data.RichTextFields
const uploadNode: SerializedUploadNode = docs[0].lexicalCustomFields.root.children.find(
(node) => node.type === 'upload',
) as SerializedUploadNode
expect((uploadNode.value.media as any).filename).toStrictEqual('payload.png')
})
})
it('ensure link nodes convert to markdown', async () => {
const newLexicalDoc = await payload.create({
collection: lexicalFieldsSlug,
depth: 0,
data: {
title: 'Lexical Markdown Test',
lexicalWithBlocks: {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
children: [
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'link to payload',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'autolink',
version: 2,
fields: {
linkType: 'custom',
url: 'https://payloadcms.com',
},
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
],
direction: 'ltr',
},
},
},
})
expect(newLexicalDoc.lexicalWithBlocks_markdown).toEqual(
'[link to payload](https://payloadcms.com)',
)
})
describe('converters and migrations', () => {
it('htmlConverter: should output correct HTML for top-level lexical field', async () => {
const lexicalDoc: LexicalMigrateField = (
await payload.find({
collection: lexicalMigrateFieldsSlug,
depth: 0,
where: {
title: {
equals: lexicalMigrateDocData.title,
},
},
})
).docs[0] as never
const htmlField = lexicalDoc?.lexicalSimple_html
expect(htmlField).toStrictEqual('<div class="payload-richtext"><p>simple</p></div>')
})
it('htmlConverter: should output correct HTML for lexical field nested in group', async () => {
const lexicalDoc: LexicalMigrateField = (
await payload.find({
collection: lexicalMigrateFieldsSlug,
depth: 0,
where: {
title: {
equals: lexicalMigrateDocData.title,
},
},
})
).docs[0] as never
const htmlField = lexicalDoc?.groupWithLexicalField?.lexicalInGroupField_html
expect(htmlField).toStrictEqual('<div class="payload-richtext"><p>group</p></div>')
})
it('htmlConverter: should output correct HTML for lexical field nested in array', async () => {
const lexicalDoc: LexicalMigrateField = (
await payload.find({
collection: lexicalMigrateFieldsSlug,
depth: 0,
where: {
title: {
equals: lexicalMigrateDocData.title,
},
},
})
).docs[0] as never
const htmlField1 = lexicalDoc?.arrayWithLexicalField?.[0]?.lexicalInArrayField_html
const htmlField2 = lexicalDoc?.arrayWithLexicalField?.[1]?.lexicalInArrayField_html
expect(htmlField1).toStrictEqual('<div class="payload-richtext"><p>array 1</p></div>')
expect(htmlField2).toStrictEqual('<div class="payload-richtext"><p>array 2</p></div>')
})
})
describe('advanced - blocks', () => {
it('should not populate relationships in blocks if depth is 0', async () => {
const lexicalDoc: LexicalField = (
await payload.find({
collection: lexicalFieldsSlug,
depth: 0,
where: {
title: {
equals: lexicalDocData.title,
},
},
})
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc?.lexicalWithBlocks
const relationshipBlockNode: SerializedBlockNode = lexicalField.root
.children[2] as SerializedBlockNode
/**
* Depth 1 population:
*/
expect(relationshipBlockNode.fields.rel).toStrictEqual(createdJPGDocID)
})
it('should populate relationships in blocks with depth=1', async () => {
const lexicalDoc: LexicalField = (
await payload.find({
collection: lexicalFieldsSlug,
depth: 1,
where: {
title: {
equals: lexicalDocData.title,
},
},
})
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc?.lexicalWithBlocks
const relationshipBlockNode: SerializedBlockNode = lexicalField.root
.children[2] as SerializedBlockNode
/**
* Depth 1 population:
*/
expect(relationshipBlockNode.fields.rel.filename).toStrictEqual('payload.jpg')
})
it('should correctly populate polymorphic hasMany relationships in blocks with depth=0', async () => {
const lexicalDoc: LexicalField = (
await payload.find({
collection: lexicalFieldsSlug,
depth: 0,
where: {
title: {
equals: lexicalDocData.title,
},
},
})
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc?.lexicalWithBlocks
const relationshipBlockNode: SerializedBlockNode = lexicalField.root
.children[3] as SerializedBlockNode
/**
* Depth 0 population:
*/
expect(Object.keys(relationshipBlockNode.fields.rel[0])).toHaveLength(2)
expect(relationshipBlockNode.fields.rel[0].relationTo).toStrictEqual('text-fields')
expect(relationshipBlockNode.fields.rel[0].value).toStrictEqual(createdTextDocID)
expect(Object.keys(relationshipBlockNode.fields.rel[1])).toHaveLength(2)
expect(relationshipBlockNode.fields.rel[1].relationTo).toStrictEqual('uploads')
expect(relationshipBlockNode.fields.rel[1].value).toStrictEqual(createdJPGDocID)
})
it('should correctly populate polymorphic hasMany relationships in blocks with depth=1', async () => {
// Related issue: https://github.com/payloadcms/payload/issues/4277
const lexicalDoc: LexicalField = (
await payload.find({
collection: lexicalFieldsSlug,
depth: 1,
where: {
title: {
equals: lexicalDocData.title,
},
},
})
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc?.lexicalWithBlocks
const relationshipBlockNode: SerializedBlockNode = lexicalField.root
.children[3] as SerializedBlockNode
/**
* Depth 1 population:
*/
expect(Object.keys(relationshipBlockNode.fields.rel[0])).toHaveLength(2)
expect(relationshipBlockNode.fields.rel[0].relationTo).toStrictEqual('text-fields')
expect(relationshipBlockNode.fields.rel[0].value.id).toStrictEqual(createdTextDocID)
expect(relationshipBlockNode.fields.rel[0].value.text).toStrictEqual(textDoc.text)
expect(relationshipBlockNode.fields.rel[0].value.localizedText).toStrictEqual(
textDoc.localizedText,
)
expect(Object.keys(relationshipBlockNode.fields.rel[1])).toHaveLength(2)
expect(relationshipBlockNode.fields.rel[1].relationTo).toStrictEqual('uploads')
expect(relationshipBlockNode.fields.rel[1].value.id).toStrictEqual(createdJPGDocID)
expect(relationshipBlockNode.fields.rel[1].value.text).toStrictEqual(uploadsDoc.text)
expect(relationshipBlockNode.fields.rel[1].value.filename).toStrictEqual('payload.jpg')
})
it('should not populate relationship nodes inside of a sub-editor from a blocks node with 0 depth', async () => {
const lexicalDoc: LexicalField = (
await payload.find({
collection: lexicalFieldsSlug,
depth: 0,
where: {
title: {
equals: lexicalDocData.title,
},
},
})
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc?.lexicalWithBlocks
const subEditorBlockNode: SerializedBlockNode = lexicalField.root
.children[4] as SerializedBlockNode
const subEditor: SerializedEditorState = subEditorBlockNode.fields.richTextField
const subEditorRelationshipNode: SerializedRelationshipNode = subEditor.root
.children[0] as SerializedRelationshipNode
/**
* Depth 1 population:
*/
expect(subEditorRelationshipNode.value).toStrictEqual(createdRichTextDocID)
// But the value should not be populated and only have the id field:
expect(typeof subEditorRelationshipNode.value).not.toStrictEqual('object')
})
it('should populate relationship nodes inside of a sub-editor from a blocks node with 1 depth', async () => {
const lexicalDoc: LexicalField = (
await payload.find({
collection: lexicalFieldsSlug,
depth: 1,
where: {
title: {
equals: lexicalDocData.title,
},
},
})
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc?.lexicalWithBlocks
const subEditorBlockNode: SerializedBlockNode = lexicalField.root
.children[4] as SerializedBlockNode
const subEditor: SerializedEditorState = subEditorBlockNode.fields.richTextField
const subEditorRelationshipNode: SerializedRelationshipNode = subEditor.root
.children[0] as SerializedRelationshipNode
/**
* Depth 1 population:
*/
expect(subEditorRelationshipNode.value.id).toStrictEqual(createdRichTextDocID)
expect(subEditorRelationshipNode.value.title).toStrictEqual(richTextDocData.title)
// Make sure that the referenced, popular document is NOT populated (that would require depth > 2):
const populatedDocEditorState: SerializedEditorState = subEditorRelationshipNode.value
.lexicalCustomFields as SerializedEditorState
const populatedDocEditorRelationshipNode: SerializedRelationshipNode = populatedDocEditorState
.root.children[2] as SerializedRelationshipNode
//console.log('populatedDocEditorRelatonshipNode:', populatedDocEditorRelationshipNode)
/**
* Depth 2 population:
*/
expect(populatedDocEditorRelationshipNode.value).toStrictEqual(createdTextDocID)
// But the value should not be populated and only have the id field - that's because it would require a depth of 2
expect(populatedDocEditorRelationshipNode.value).not.toStrictEqual('object')
})
it('should populate relationship nodes inside of a sub-editor from a blocks node with depth 2', async () => {
const lexicalDoc: LexicalField = (
await payload.find({
collection: lexicalFieldsSlug,
depth: 2,
where: {
title: {
equals: lexicalDocData.title,
},
},
})
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc?.lexicalWithBlocks
const subEditorBlockNode: SerializedBlockNode = lexicalField.root
.children[4] as SerializedBlockNode
const subEditor: SerializedEditorState = subEditorBlockNode.fields.richTextField
const subEditorRelationshipNode: SerializedRelationshipNode = subEditor.root
.children[0] as SerializedRelationshipNode
/**
* Depth 1 population:
*/
expect(subEditorRelationshipNode.value.id).toStrictEqual(createdRichTextDocID)
expect(subEditorRelationshipNode.value.title).toStrictEqual(richTextDocData.title)
// Make sure that the referenced, popular document is NOT populated (that would require depth > 2):
const populatedDocEditorState: SerializedEditorState = subEditorRelationshipNode.value
.lexicalCustomFields as SerializedEditorState
const populatedDocEditorRelationshipNode: SerializedRelationshipNode = populatedDocEditorState
.root.children[2] as SerializedRelationshipNode
/**
* Depth 2 population:
*/
expect(populatedDocEditorRelationshipNode.value.id).toStrictEqual(createdTextDocID)
// Should now be populated (length 12)
expect(populatedDocEditorRelationshipNode.value.text).toStrictEqual(textDoc.text)
})
})
describe('Localization', () => {
it('ensure localized lexical field is different across locales', async () => {
const lexicalDocEN = await payload.find({
collection: 'lexical-localized-fields',
locale: 'en',
where: {
title: {
equals: 'Localized Lexical en',
},
},
})
expect(lexicalDocEN.docs[0].lexicalBlocksLocalized.root.children[0].children[0].text).toEqual(
'English text',
)
const lexicalDocES = await payload.findByID({
collection: 'lexical-localized-fields',
locale: 'es',
id: lexicalDocEN.docs[0].id,
})
expect(lexicalDocES.lexicalBlocksLocalized.root.children[0].children[0].text).toEqual(
'Spanish text',
)
})
it('ensure localized text field within blocks field within unlocalized lexical field is different across locales', async () => {
const lexicalDocEN = await payload.find({
collection: 'lexical-localized-fields',
locale: 'en',
where: {
title: {
equals: 'Localized Lexical en',
},
},
})
expect(
lexicalDocEN.docs[0].lexicalBlocksSubLocalized.root.children[0].children[0].text,
).toEqual('Shared text')
expect(
(lexicalDocEN.docs[0].lexicalBlocksSubLocalized.root.children[1].fields as any)
.textLocalized,
).toEqual('English text in block')
const lexicalDocES = await payload.findByID({
collection: 'lexical-localized-fields',
locale: 'es',
id: lexicalDocEN.docs[0].id,
})
expect(lexicalDocES.lexicalBlocksSubLocalized.root.children[0].children[0].text).toEqual(
'Shared text',
)
expect(
(lexicalDocES.lexicalBlocksSubLocalized.root.children[1].fields as any).textLocalized,
).toEqual('Spanish text in block')
})
})
describe('Hooks', () => {
it('ensure hook within number field within lexical block runs', async () => {
const lexicalDocEN = await payload.create({
collection: 'lexical-localized-fields',
locale: 'en',
data: {
title: 'Localized Lexical hooks',
lexicalBlocksLocalized: textToLexicalJSON({ text: 'some text' }),
lexicalBlocksSubLocalized: generateLexicalLocalizedRichText(
'Shared text',
'English text in block',
) as any,
},
})
expect(
(lexicalDocEN.lexicalBlocksSubLocalized.root.children[1].fields as any).counter,
).toEqual(20) // Initial: 1. BeforeChange: +1 (2). AfterRead: *10 (20)
// update document with same data
const lexicalDocENUpdated = await payload.update({
collection: 'lexical-localized-fields',
locale: 'en',
id: lexicalDocEN.id,
data: lexicalDocEN,
})
expect(
(lexicalDocENUpdated.lexicalBlocksSubLocalized.root.children[1].fields as any).counter,
).toEqual(210) // Initial: 20. BeforeChange: +1 (21). AfterRead: *10 (210)
})
})
describe('richText', () => {
it('should allow querying on rich text content', async () => {
const emptyRichTextQuery = await payload.find({
collection: 'rich-text-fields',
where: {
'richText.children.text': {
like: 'doesnt exist',
},
},
})
expect(emptyRichTextQuery.docs).toHaveLength(0)
const workingRichTextQuery = await payload.find({
collection: 'rich-text-fields',
where: {
'richText.children.text': {
like: 'hello',
},
},
})
expect(workingRichTextQuery.docs).toHaveLength(1)
})
it('should show center alignment', async () => {
const query = await payload.find({
collection: 'rich-text-fields',
where: {
'richText.children.text': {
like: 'hello',
},
},
})
expect(query.docs[0]?.richText[0]?.textAlign).toEqual('center')
})
it('should populate link relationship', async () => {
const query = await payload.find({
collection: 'rich-text-fields',
where: {
'richText.children.linkType': {
equals: 'internal',
},
},
})
const nodes = query.docs[0]?.richText
expect(nodes).toBeDefined()
const child = nodes?.flatMap((n) => n.children).find((c) => c?.doc)
expect(child).toMatchObject({
type: 'link',
linkType: 'internal',
})
expect(child.doc.relationTo).toEqual('array-fields')
if (payload.db.defaultIDType === 'number') {
expect(typeof child.doc.value.id).toBe('number')
} else {
expect(typeof child.doc.value.id).toBe('string')
}
expect(child.doc.value.items).toHaveLength(6)
})
})
})

File diff suppressed because it is too large Load Diff

452
test/lexical/seed.ts Normal file
View File

@@ -0,0 +1,452 @@
import type { Payload } from 'payload'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { lexicalDocData } from './collections/Lexical/data.js'
import { generateLexicalLocalizedRichText } from './collections/LexicalLocalized/generateLexicalRichText.js'
import { textToLexicalJSON } from './collections/LexicalLocalized/textToLexicalJSON.js'
import { lexicalMigrateDocData } from './collections/LexicalMigrate/data.js'
import { richTextBulletsDocData, richTextDocData } from './collections/RichText/data.js'
import {
arrayFieldsSlug,
collectionSlugs,
lexicalFieldsSlug,
lexicalLocalizedFieldsSlug,
lexicalMigrateFieldsSlug,
lexicalRelationshipFieldsSlug,
richTextFieldsSlug,
textFieldsSlug,
uploadsSlug,
usersSlug,
} from './slugs.js'
// import type { Payload } from 'payload'
import { getFileByPath } from 'payload'
import { devUser } from '../credentials.js'
import { seedDB } from '../helpers/seed.js'
import { arrayDoc } from './collections/Array/shared.js'
import { anotherTextDoc, textDoc } from './collections/Text/shared.js'
import { uploadsDoc } from './collections/Upload/shared.js'
// import { blocksDoc } from './collections/Blocks/shared.js'
// import { codeDoc } from './collections/Code/shared.js'
// import { collapsibleDoc } from './collections/Collapsible/shared.js'
// import { conditionalLogicDoc } from './collections/ConditionalLogic/shared.js'
// import { customRowID, customTabID, nonStandardID } from './collections/CustomID/shared.js'
// import { dateDoc } from './collections/Date/shared.js'
// import { anotherEmailDoc, emailDoc } from './collections/Email/shared.js'
// import { groupDoc } from './collections/Group/shared.js'
// import { jsonDoc } from './collections/JSON/shared.js'
// import { lexicalDocData } from './collections/Lexical/data.js'
// import { generateLexicalLocalizedRichText } from './collections/LexicalLocalized/generateLexicalRichText.js'
// import { textToLexicalJSON } from './collections/LexicalLocalized/textToLexicalJSON.js'
// import { lexicalMigrateDocData } from './collections/LexicalMigrate/data.js'
// import { numberDoc } from './collections/Number/shared.js'
// import { pointDoc } from './collections/Point/shared.js'
// import { radiosDoc } from './collections/Radio/shared.js'
// import { richTextBulletsDocData, richTextDocData } from './collections/RichText/data.js'
// import { selectsDoc } from './collections/Select/shared.js'
// import { tabsDoc } from './collections/Tabs/shared.js'
// import { anotherTextDoc, textDoc } from './collections/Text/shared.js'
// import { uploadsDoc } from './collections/Upload/shared.js'
// import {
// arrayFieldsSlug,
// blockFieldsSlug,
// checkboxFieldsSlug,
// codeFieldsSlug,
// collapsibleFieldsSlug,
// collectionSlugs,
// conditionalLogicSlug,
// customIDSlug,
// customRowIDSlug,
// customTabIDSlug,
// dateFieldsSlug,
// emailFieldsSlug,
// groupFieldsSlug,
// jsonFieldsSlug,
// lexicalFieldsSlug,
// lexicalLocalizedFieldsSlug,
// lexicalMigrateFieldsSlug,
// lexicalRelationshipFieldsSlug,
// numberFieldsSlug,
// pointFieldsSlug,
// radioFieldsSlug,
// relationshipFieldsSlug,
// richTextFieldsSlug,
// selectFieldsSlug,
// tabsFieldsSlug,
// textFieldsSlug,
// uiSlug,
// uploads2Slug,
// uploadsMulti,
// uploadsMultiPoly,
// uploadsPoly,
// uploadsSlug,
// usersSlug,
// } from './slugs.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export const seed = async (_payload: Payload) => {
const jpgPath = path.resolve(dirname, './collections/Upload/payload.jpg')
const pngPath = path.resolve(dirname, './uploads/payload.png')
// Get both files in parallel
const [jpgFile, pngFile] = await Promise.all([getFileByPath(jpgPath), getFileByPath(pngPath)])
const createdArrayDoc = await _payload.create({
collection: arrayFieldsSlug,
data: arrayDoc,
depth: 0,
overrideAccess: true,
})
const createdTextDoc = await _payload.create({
collection: textFieldsSlug,
data: textDoc,
depth: 0,
overrideAccess: true,
})
await _payload.create({
collection: textFieldsSlug,
data: anotherTextDoc,
depth: 0,
overrideAccess: true,
})
const createdPNGDoc = await _payload.create({
collection: uploadsSlug,
data: {},
file: pngFile,
depth: 0,
overrideAccess: true,
})
const createdJPGDoc = await _payload.create({
collection: uploadsSlug,
data: {
...uploadsDoc,
media: createdPNGDoc.id,
},
file: jpgFile,
depth: 0,
overrideAccess: true,
})
const formattedID =
_payload.db.defaultIDType === 'number' ? createdArrayDoc.id : `"${createdArrayDoc.id}"`
const formattedJPGID =
_payload.db.defaultIDType === 'number' ? createdJPGDoc.id : `"${createdJPGDoc.id}"`
const formattedTextID =
_payload.db.defaultIDType === 'number' ? createdTextDoc.id : `"${createdTextDoc.id}"`
const richTextDocWithRelId = JSON.parse(
JSON.stringify(richTextDocData)
.replace(/"\{\{ARRAY_DOC_ID\}\}"/g, `${formattedID}`)
.replace(/"\{\{UPLOAD_DOC_ID\}\}"/g, `${formattedJPGID}`)
.replace(/"\{\{TEXT_DOC_ID\}\}"/g, `${formattedTextID}`),
)
const richTextBulletsDocWithRelId = JSON.parse(
JSON.stringify(richTextBulletsDocData)
.replace(/"\{\{ARRAY_DOC_ID\}\}"/g, `${formattedID}`)
.replace(/"\{\{UPLOAD_DOC_ID\}\}"/g, `${formattedJPGID}`)
.replace(/"\{\{TEXT_DOC_ID\}\}"/g, `${formattedTextID}`),
)
const richTextDocWithRelationship = { ...richTextDocWithRelId }
await _payload.create({
collection: richTextFieldsSlug,
data: richTextBulletsDocWithRelId,
depth: 0,
overrideAccess: true,
})
const createdRichTextDoc = await _payload.create({
collection: richTextFieldsSlug,
data: richTextDocWithRelationship,
depth: 0,
overrideAccess: true,
})
const formattedRichTextDocID =
_payload.db.defaultIDType === 'number' ? createdRichTextDoc.id : `"${createdRichTextDoc.id}"`
const lexicalDocWithRelId = JSON.parse(
JSON.stringify(lexicalDocData)
.replace(/"\{\{ARRAY_DOC_ID\}\}"/g, `${formattedID}`)
.replace(/"\{\{UPLOAD_DOC_ID\}\}"/g, `${formattedJPGID}`)
.replace(/"\{\{TEXT_DOC_ID\}\}"/g, `${formattedTextID}`)
.replace(/"\{\{RICH_TEXT_DOC_ID\}\}"/g, `${formattedRichTextDocID}`),
)
const lexicalMigrateDocWithRelId = JSON.parse(
JSON.stringify(lexicalMigrateDocData)
.replace(/"\{\{ARRAY_DOC_ID\}\}"/g, `${formattedID}`)
.replace(/"\{\{UPLOAD_DOC_ID\}\}"/g, `${formattedJPGID}`)
.replace(/"\{\{TEXT_DOC_ID\}\}"/g, `${formattedTextID}`)
.replace(/"\{\{RICH_TEXT_DOC_ID\}\}"/g, `${formattedRichTextDocID}`),
)
await _payload.create({
collection: usersSlug,
depth: 0,
data: {
email: devUser.email,
password: devUser.password,
},
overrideAccess: true,
})
await _payload.create({
collection: lexicalFieldsSlug,
data: lexicalDocWithRelId,
depth: 0,
overrideAccess: true,
})
const lexicalLocalizedDoc1 = await _payload.create({
collection: lexicalLocalizedFieldsSlug,
data: {
title: 'Localized Lexical en',
lexicalBlocksLocalized: textToLexicalJSON({ text: 'English text' }),
lexicalBlocksSubLocalized: generateLexicalLocalizedRichText(
'Shared text',
'English text in block',
) as any,
},
locale: 'en',
depth: 0,
overrideAccess: true,
})
await _payload.create({
collection: lexicalRelationshipFieldsSlug,
data: {
richText: textToLexicalJSON({ text: 'English text' }),
},
depth: 0,
overrideAccess: true,
})
await _payload.update({
collection: lexicalLocalizedFieldsSlug,
id: lexicalLocalizedDoc1.id,
data: {
title: 'Localized Lexical es',
lexicalBlocksLocalized: textToLexicalJSON({ text: 'Spanish text' }),
lexicalBlocksSubLocalized: generateLexicalLocalizedRichText(
'Shared text',
'Spanish text in block',
(lexicalLocalizedDoc1.lexicalBlocksSubLocalized.root.children[1].fields as any).id,
) as any,
},
locale: 'es',
depth: 0,
overrideAccess: true,
})
const lexicalLocalizedDoc2 = await _payload.create({
collection: lexicalLocalizedFieldsSlug,
data: {
title: 'Localized Lexical en 2',
lexicalBlocksLocalized: textToLexicalJSON({
text: 'English text 2',
lexicalLocalizedRelID: lexicalLocalizedDoc1.id,
}),
lexicalBlocksSubLocalized: textToLexicalJSON({
text: 'English text 2',
lexicalLocalizedRelID: lexicalLocalizedDoc1.id,
}),
},
locale: 'en',
depth: 0,
overrideAccess: true,
})
await _payload.update({
collection: lexicalLocalizedFieldsSlug,
id: lexicalLocalizedDoc2.id,
data: {
title: 'Localized Lexical es 2',
lexicalBlocksLocalized: textToLexicalJSON({
text: 'Spanish text 2',
lexicalLocalizedRelID: lexicalLocalizedDoc1.id,
}),
},
locale: 'es',
depth: 0,
overrideAccess: true,
})
await _payload.create({
collection: lexicalMigrateFieldsSlug,
data: lexicalMigrateDocWithRelId,
depth: 0,
overrideAccess: true,
})
const getInlineBlock = () => ({
type: 'inlineBlock',
fields: {
id: Math.random().toString(36).substring(2, 15),
text: 'text',
blockType: 'inlineBlockInLexical',
},
version: 1,
})
await _payload.create({
collection: 'LexicalInBlock',
depth: 0,
data: {
content: {
root: {
children: [
{
format: '',
type: 'block',
version: 2,
fields: {
id: '6773773284be8978db7a498d',
lexicalInBlock: textToLexicalJSON({ text: 'text' }),
blockName: '',
blockType: 'blockInLexical',
},
},
],
direction: null,
format: '',
indent: 0,
type: 'root',
version: 1,
},
},
blocks: [
{
blockType: 'lexicalInBlock2',
blockName: '1',
lexical: textToLexicalJSON({ text: '1' }),
},
{
blockType: 'lexicalInBlock2',
blockName: '2',
lexical: textToLexicalJSON({ text: '2' }),
},
{
blockType: 'lexicalInBlock2',
lexical: {
root: {
children: [
{
children: [...Array.from({ length: 20 }, () => getInlineBlock())],
direction: null,
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
textStyle: '',
},
],
direction: null,
format: '',
indent: 0,
type: 'root',
version: 1,
},
},
id: '67e1af0b78de3228e23ef1d5',
blockName: '1',
},
],
},
})
await _payload.create({
collection: 'lexical-access-control',
data: {
richText: {
root: {
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'text ',
type: 'text',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'link',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'link',
version: 3,
fields: {
url: 'https://',
newTab: false,
linkType: 'custom',
blocks: [
{
id: '67e45673cbd5181ca8cbeef7',
blockType: 'block',
},
],
},
id: '67e4566fcbd5181ca8cbeef5',
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
textStyle: '',
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'root',
version: 1,
},
},
title: 'title',
},
depth: 0,
})
}
export async function clearAndSeedEverything(_payload: Payload) {
return await seedDB({
_payload,
collectionSlugs,
seedFunction: seed,
snapshotKey: 'fieldsTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})
}

24
test/lexical/slugs.ts Normal file
View File

@@ -0,0 +1,24 @@
export const usersSlug = 'users'
export const lexicalFieldsSlug = 'lexical-fields'
export const lexicalLocalizedFieldsSlug = 'lexical-localized-fields'
export const lexicalMigrateFieldsSlug = 'lexical-migrate-fields'
export const lexicalRelationshipFieldsSlug = 'lexical-relationship-fields'
export const lexicalAccessControlSlug = 'lexical-access-control'
export const richTextFieldsSlug = 'rich-text-fields'
// Auxiliary slugs
export const textFieldsSlug = 'text-fields'
export const uploadsSlug = 'uploads'
export const arrayFieldsSlug = 'array-fields'
export const collectionSlugs = [
lexicalFieldsSlug,
lexicalLocalizedFieldsSlug,
lexicalMigrateFieldsSlug,
lexicalRelationshipFieldsSlug,
lexicalAccessControlSlug,
richTextFieldsSlug,
textFieldsSlug,
uploadsSlug,
]

View File

@@ -0,0 +1,13 @@
{
// extend your base config to share compilerOptions, etc
//"extends": "./tsconfig.json",
"compilerOptions": {
// ensure that nobody can accidentally use this config for a build
"noEmit": true
},
"include": [
// whatever paths you intend to lint
"./**/*.ts",
"./**/*.tsx"
]
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../tsconfig.json"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB