fix(richtext-lexical): Blocks: unnecessary saving node value when initially opening a document & new lexical tests (#4059)

* chore: new lexical int tests and working test structure

* chore: more int tests, and better lexical collection structure

* fix(richtext-lexical): Blocks: unnecessary saving node value when initially opening a document
This commit is contained in:
Alessio Gravili
2023-11-08 21:32:43 +01:00
committed by GitHub
parent a2cb946155
commit fff377ad22
15 changed files with 539 additions and 1067 deletions

View File

@@ -71,6 +71,25 @@ export const BlockContent: React.FC<Props> = (props) => {
const onFormChange = useCallback(
({ fields: formFields, formData }: { fields: Fields; formData: Data }) => {
// Recursively remove all undefined values from even being present in formData, as they will
// cause isDeepEqual to return false if, for example, formData has a key that fields.data
// does not have, even if it's undefined.
// Currently, this happens if a block has another sub-blocks field. Inside of formData, that sub-blocks field has an undefined blockName property.
// Inside of fields.data however, that sub-blocks blockName property does not exist at all.
function removeUndefinedRecursively(obj: any) {
Object.keys(obj).forEach((key) => {
if (obj[key] && typeof obj[key] === 'object') {
removeUndefinedRecursively(obj[key])
} else if (obj[key] === undefined) {
delete obj[key]
}
})
}
removeUndefinedRecursively(formData)
removeUndefinedRecursively(fields.data)
// Only update if the data has actually changed. Otherwise, we may be triggering an unnecessary value change,
// which would trigger the "Leave without saving" dialog unnecessarily
if (!isDeepEqual(fields.data, formData)) {
editor.update(() => {
const node: BlockNode = $getNodeByKey(nodeKey)

View File

@@ -1,8 +1,6 @@
import { generateLexicalRichText } from './generateLexicalRichText'
import { payloadPluginLexicalData } from './generatePayloadPluginLexicalData'
export const lexicalRichTextDoc = {
export const lexicalDocData = {
title: 'Rich Text',
richTextLexicalCustomFields: generateLexicalRichText(),
richTextLexicalWithLexicalPluginData: payloadPluginLexicalData,
lexicalWithBlocks: generateLexicalRichText(),
}

View File

@@ -104,9 +104,9 @@ export function generateLexicalRichText() {
format: '',
type: 'relationship',
version: 1,
relationTo: 'text-fields',
relationTo: 'rich-text-fields',
value: {
id: '{{TEXT_DOC_ID}}',
id: '{{RICH_TEXT_DOC_ID}}',
},
},
],

View File

@@ -1,958 +0,0 @@
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

@@ -19,8 +19,6 @@ import {
TextBlock,
UploadAndRichTextBlock,
} from './blocks'
import { generateLexicalRichText } from './generateLexicalRichText'
import { payloadPluginLexicalData } from './generatePayloadPluginLexicalData'
export const LexicalFields: CollectionConfig = {
slug: lexicalFieldsSlug,
@@ -38,12 +36,12 @@ export const LexicalFields: CollectionConfig = {
required: true,
},
{
name: 'richTextLexicalSimple',
name: 'lexicalSimple',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
TestRecorderFeature(),
//TestRecorderFeature(),
TreeviewFeature(),
BlocksFeature({
blocks: [
@@ -59,15 +57,15 @@ export const LexicalFields: CollectionConfig = {
}),
},
{
name: 'richTextLexicalCustomFields',
name: 'lexicalWithBlocks',
type: 'richText',
required: true,
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
TestRecorderFeature(),
//TestRecorderFeature(),
TreeviewFeature(),
HTMLConverterFeature(),
//HTMLConverterFeature(),
LinkFeature({
fields: [
{
@@ -109,44 +107,5 @@ export const LexicalFields: CollectionConfig = {
],
}),
},
{
name: 'richTextLexicalWithLexicalPluginData',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
LexicalPluginToLexicalFeature(),
TreeviewFeature(),
LinkFeature({
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.',
},
},
],
}),
UploadFeature({
collections: {
uploads: {
fields: [
{
name: 'caption',
type: 'richText',
editor: lexicalEditor(),
},
],
},
},
}),
],
}),
},
],
}

View File

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

View File

@@ -26,7 +26,7 @@ export const LexicalMigrateFields: CollectionConfig = {
required: true,
},
{
name: 'richTextLexicalWithLexicalPluginData',
name: 'lexicalWithLexicalPluginData',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
@@ -69,5 +69,5 @@ export const LexicalMigrateFields: CollectionConfig = {
export const LexicalRichTextDoc = {
title: 'Rich Text',
richTextLexicalWithLexicalPluginData: payloadPluginLexicalData,
lexicalWithLexicalPluginData: payloadPluginLexicalData,
}

View File

@@ -1,3 +1,4 @@
import { defaultRichTextValue } from '../../../../packages/richtext-lexical/src'
import { generateLexicalRichText } from './generateLexicalRichText'
import { generateSlateRichText } from './generateSlateRichText'
@@ -20,19 +21,19 @@ export const richTextBlocks = [
],
},
]
export const richTextDoc = {
export const richTextDocData = {
title: 'Rich Text',
selectHasMany: ['one', 'five'],
richText: generateSlateRichText(),
richTextReadOnly: generateSlateRichText(),
richTextCustomFields: generateSlateRichText(),
richTextLexicalCustomFields: generateLexicalRichText(),
lexicalCustomFields: generateLexicalRichText(),
blocks: richTextBlocks,
}
export const richTextBulletsDoc = {
export const richTextBulletsDocData = {
title: 'Bullets and Indentation',
richTextLexicalCustomFields: generateLexicalRichText(),
lexicalCustomFields: generateLexicalRichText(),
richText: [
{
type: 'ul',

View File

@@ -12,8 +12,6 @@ import { lexicalHTML } from '../../../../packages/richtext-lexical/src/field/fea
import { slateEditor } from '../../../../packages/richtext-slate/src'
import { richTextFieldsSlug } from '../../slugs'
import { RelationshipBlock, SelectFieldBlock, TextBlock, UploadAndRichTextBlock } from './blocks'
import { generateLexicalRichText } from './generateLexicalRichText'
import { generateSlateRichText } from './generateSlateRichText'
const RichTextFields: CollectionConfig = {
slug: richTextFieldsSlug,
@@ -30,7 +28,7 @@ const RichTextFields: CollectionConfig = {
required: true,
},
{
name: 'richTextLexicalCustomFields',
name: 'lexicalCustomFields',
type: 'richText',
required: true,
editor: lexicalEditor({
@@ -72,9 +70,9 @@ const RichTextFields: CollectionConfig = {
],
}),
},
lexicalHTML('richTextLexicalCustomFields', { name: 'richTextLexicalCustomFields_htmll' }),
lexicalHTML('lexicalCustomFields', { name: 'lexicalCustomFields_html' }),
{
name: 'richTextLexical',
name: 'lexical',
type: 'richText',
admin: {
description: 'This rich text field uses the lexical editor.',

View File

@@ -13,7 +13,6 @@ import { RESTClient } from '../helpers/rest'
import { jsonDoc } from './collections/JSON'
import { numberDoc } from './collections/Number'
import { textDoc } from './collections/Text/shared'
import { lexicalE2E } from './lexicalE2E'
import { clearAndSeedEverything } from './seed'
import {
collapsibleFieldsSlug,
@@ -195,6 +194,7 @@ describe('fields', () => {
url = new AdminUrlUtil(serverURL, 'indexed-fields')
})
// TODO: This test is flaky
test('should display unique constraint error in ui', async () => {
const uniqueText = 'uniqueText'
await payload.create({
@@ -796,7 +796,6 @@ describe('fields', () => {
)
})
})
describe('lexical', lexicalE2E(client, page, serverURL))
describe('richText', () => {
async function navigateToRichTextFields() {
const url: AdminUrlUtil = new AdminUrlUtil(serverURL, 'rich-text-fields')

View File

@@ -3,6 +3,7 @@ import type { IndexDirection, IndexOptions } from 'mongoose'
import { GraphQLClient } from 'graphql-request'
import type { MongooseAdapter } from '../../packages/db-mongodb/src/index'
import type { SanitizedConfig } from '../../packages/payload/src/config/types'
import type { PaginatedDocs } from '../../packages/payload/src/database/types'
import type { RichTextField } from './payload-types'
@@ -27,11 +28,11 @@ import { defaultText } from './collections/Text/shared'
import { clearAndSeedEverything } from './seed'
import { arrayFieldsSlug, groupFieldsSlug, relationshipFieldsSlug, tabsFieldsSlug } from './slugs'
let client
let client: RESTClient
let graphQLClient: GraphQLClient
let serverURL
let config
let token
let serverURL: string
let config: SanitizedConfig
let token: string
describe('Fields', () => {
beforeAll(async () => {

View File

@@ -0,0 +1,61 @@
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import payload from '../../packages/payload/src'
import { AdminUrlUtil } from '../helpers/adminUrlUtil'
import { initPayloadE2E } from '../helpers/configHelpers'
import { RESTClient } from '../helpers/rest'
import { clearAndSeedEverything } from './seed'
const { beforeAll, describe, beforeEach } = test
let client: RESTClient
let page: Page
let serverURL: string
async function navigateToRichTextFields() {
const url: AdminUrlUtil = new AdminUrlUtil(serverURL, 'rich-text-fields')
await page.goto(url.list)
await page.locator('.row-1 .cell-title a').click()
}
async function navigateToLexicalFields() {
const url: AdminUrlUtil = new AdminUrlUtil(serverURL, 'lexical-fields')
await page.goto(url.list)
await page.locator('.row-1 .cell-title a').click()
}
describe('lexical', () => {
beforeAll(async ({ browser }) => {
const config = await initPayloadE2E(__dirname)
serverURL = config.serverURL
client = new RESTClient(null, { serverURL, defaultSlug: 'rich-text-fields' })
await client.login()
const context = await browser.newContext()
page = await context.newPage()
})
beforeEach(async () => {
await clearAndSeedEverything(payload)
await client.logout()
client = new RESTClient(null, { serverURL, defaultSlug: 'rich-text-fields' })
await client.login()
})
test('should not warn about unsaved changes when navigating to lexical editor with blocks node and then leaving the page without actually changing anything', async () => {
// This used to be an issue in the past, due to the node.setFields function in the blocks node being called unnecessarily when it's initialized after opening the document
// Other than the annoying unsaved changed prompt, this can also cause unnecessary auto-saves, when drafts & autosave is enabled
await navigateToLexicalFields()
await expect(
page.locator('.rich-text-lexical').nth(1).locator('.lexical-block').first(),
).toBeVisible()
// Navigate to some different page, away from the current document
await page.locator('.app-header__step-nav').first().locator('a').first().click()
// Make sure .leave-without-saving__content (the "Leave without saving") is not visible
await expect(page.locator('.leave-without-saving__content').first()).not.toBeVisible()
})
})

View File

@@ -0,0 +1,395 @@
import type { SerializedEditorState } from 'lexical'
import { GraphQLClient } from 'graphql-request'
import type { SanitizedConfig } from '../../packages/payload/src/config/types'
import type { PaginatedDocs } from '../../packages/payload/src/database/types'
import type {
SerializedBlockNode,
SerializedLinkNode,
SerializedUploadNode,
} from '../../packages/richtext-lexical/src'
import type { SerializedRelationshipNode } from '../../packages/richtext-lexical/src'
import type { RichTextField } from './payload-types'
import payload from '../../packages/payload/src'
import { initPayloadTest } from '../helpers/configHelpers'
import { RESTClient } from '../helpers/rest'
import configPromise from '../uploads/config'
import { arrayDoc } from './collections/Array'
import { lexicalDocData } from './collections/Lexical/data'
import { richTextDocData } from './collections/RichText/data'
import { generateLexicalRichText } from './collections/RichText/generateLexicalRichText'
import { textDoc } from './collections/Text/shared'
import { clearAndSeedEverything } from './seed'
import {
arrayFieldsSlug,
lexicalFieldsSlug,
richTextFieldsSlug,
textFieldsSlug,
uploadsSlug,
} from './slugs'
let client: RESTClient
let graphQLClient: GraphQLClient
let serverURL: string
let config: SanitizedConfig
let token: string
let createdArrayDocID: number | string = null
let createdJPGDocID: number | string = null
let createdTextDocID: number | string = null
let createdRichTextDocID: number | string = null
describe('Lexical', () => {
beforeAll(async () => {
;({ serverURL } = await initPayloadTest({ __dirname, init: { local: false } }))
config = await configPromise
client = new RESTClient(config, { defaultSlug: richTextFieldsSlug, serverURL })
const graphQLURL = `${serverURL}${config.routes.api}${config.routes.graphQL}`
graphQLClient = new GraphQLClient(graphQLURL)
token = await client.login()
})
beforeEach(async () => {
await clearAndSeedEverything(payload)
client = new RESTClient(config, { defaultSlug: richTextFieldsSlug, serverURL })
await client.login()
createdArrayDocID = (
await payload.find({
collection: arrayFieldsSlug,
where: {
id: {
exists: true,
},
},
})
).docs[0].id
createdJPGDocID = (
await payload.find({
collection: uploadsSlug,
where: {
id: {
exists: true,
},
},
})
).docs[0].id
createdTextDocID = (
await payload.find({
collection: textFieldsSlug,
where: {
id: {
exists: true,
},
},
})
).docs[0].id
createdRichTextDocID = (
await payload.find({
collection: richTextFieldsSlug,
where: {
id: {
exists: true,
},
},
})
).docs[0].id
})
describe('basic', () => {
it('should allow querying on lexical content', async () => {
const richTextDoc: RichTextField = (
await payload.find({
collection: richTextFieldsSlug,
where: {
title: {
equals: richTextDocData.title,
},
},
depth: 0,
})
).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,
where: {
title: {
equals: richTextDocData.title,
},
},
depth: 1,
})
).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
expect(richTextDoc?.lexicalCustomFields).toMatchObject(seededDocument) // subset of seededDocument should match
const lexical: SerializedEditorState = richTextDoc?.lexicalCustomFields as never
const linkNode: SerializedLinkNode = lexical.root.children[1].children[3]
expect(linkNode.fields.doc.value.items[1].text).toStrictEqual(arrayDoc.items[1].text)
})
it('should populate relationship node', async () => {
const richTextDoc: RichTextField = (
await payload.find({
collection: richTextFieldsSlug,
where: {
title: {
equals: richTextDocData.title,
},
},
depth: 1,
})
).docs[0] as never
const relationshipNode: SerializedRelationshipNode =
richTextDoc.lexicalCustomFields.root.children.find((node) => node.type === 'relationship')
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: {
RichTextFields: PaginatedDocs<RichTextField>
} = await graphQLClient.request(
query,
{},
{
Authorization: `JWT ${token}`,
},
)
const { docs } = response.RichTextFields
const uploadNode: SerializedUploadNode = docs[0].lexicalCustomFields.root.children.find(
(node) => node.type === 'upload',
)
expect(uploadNode.value.media.filename).toStrictEqual('payload.png')
})
})
describe('advanced - blocks', () => {
it('should not populate relationships in blocks if depth is 0', async () => {
const lexicalDoc: RichTextField = (
await payload.find({
collection: lexicalFieldsSlug,
where: {
title: {
equals: lexicalDocData.title,
},
},
depth: 0,
})
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc?.lexicalWithBlocks as never
const relationshipBlockNode: SerializedBlockNode = lexicalField.root.children[2] as never
/**
* Depth 1 population:
*/
expect(relationshipBlockNode.fields.data.rel).toStrictEqual(createdJPGDocID)
})
it('should populate relationships in blocks with depth=1', async () => {
const lexicalDoc: RichTextField = (
await payload.find({
collection: lexicalFieldsSlug,
where: {
title: {
equals: lexicalDocData.title,
},
},
depth: 1,
})
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc?.lexicalWithBlocks as never
const relationshipBlockNode: SerializedBlockNode = lexicalField.root.children[2] as never
/**
* Depth 1 population:
*/
expect(relationshipBlockNode.fields.data.rel.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: RichTextField = (
await payload.find({
collection: lexicalFieldsSlug,
where: {
title: {
equals: lexicalDocData.title,
},
},
depth: 0,
})
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc?.lexicalWithBlocks as never
const subEditorBlockNode: SerializedBlockNode = lexicalField.root.children[3] as never
const subEditor: SerializedEditorState = subEditorBlockNode.fields.data.richText
const subEditorRelationshipNode: SerializedRelationshipNode = subEditor.root
.children[0] as never
/**
* Depth 1 population:
*/
expect(subEditorRelationshipNode.value.id).toStrictEqual(createdRichTextDocID)
// But the value should not be populated and only have the id field:
expect(Object.keys(subEditorRelationshipNode.value)).toHaveLength(1)
})
it('should populate relationship nodes inside of a sub-editor from a blocks node with 1 depth', async () => {
const lexicalDoc: RichTextField = (
await payload.find({
collection: lexicalFieldsSlug,
where: {
title: {
equals: lexicalDocData.title,
},
},
depth: 1,
})
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc?.lexicalWithBlocks as never
const subEditorBlockNode: SerializedBlockNode = lexicalField.root.children[3] as never
const subEditor: SerializedEditorState = subEditorBlockNode.fields.data.richText
const subEditorRelationshipNode: SerializedRelationshipNode = subEditor.root
.children[0] as never
/**
* 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 never
const populatedDocEditorRelationshipNode: SerializedRelationshipNode = populatedDocEditorState
.root.children[2] as never
//console.log('populatedDocEditorRelatonshipNode:', populatedDocEditorRelationshipNode)
/**
* Depth 2 population:
*/
expect(populatedDocEditorRelationshipNode.value.id).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(Object.keys(populatedDocEditorRelationshipNode.value)).toHaveLength(1)
})
it('should populate relationship nodes inside of a sub-editor from a blocks node with depth 2', async () => {
const lexicalDoc: RichTextField = (
await payload.find({
collection: lexicalFieldsSlug,
where: {
title: {
equals: lexicalDocData.title,
},
},
depth: 2,
})
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc?.lexicalWithBlocks as never
const subEditorBlockNode: SerializedBlockNode = lexicalField.root.children[3] as never
const subEditor: SerializedEditorState = subEditorBlockNode.fields.data.richText
const subEditorRelationshipNode: SerializedRelationshipNode = subEditor.root
.children[0] as never
/**
* 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 never
const populatedDocEditorRelationshipNode: SerializedRelationshipNode = populatedDocEditorState
.root.children[2] as never
/**
* Depth 2 population:
*/
expect(populatedDocEditorRelationshipNode.value.id).toStrictEqual(createdTextDocID)
// Should now be populated (length 12)
expect(populatedDocEditorRelationshipNode.value.text).toStrictEqual(textDoc.text)
})
})
})

View File

@@ -1,27 +0,0 @@
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import type { RESTClient } from '../helpers/rest'
import { AdminUrlUtil } from '../helpers/adminUrlUtil'
const { describe } = test
export const lexicalE2E = (client: RESTClient, page: Page, serverURL: string) => {
async function navigateToRichTextFields() {
const url: AdminUrlUtil = new AdminUrlUtil(serverURL, 'rich-text-fields')
await page.goto(url.list)
await page.locator('.row-1 .cell-title a').click()
}
return () => {
describe('todo', () => {
test.skip('todo', async () => {
await navigateToRichTextFields()
await page.locator('todo').first().click()
})
})
}
}

View File

@@ -12,16 +12,18 @@ import { conditionalLogicDoc } from './collections/ConditionalLogic'
import { dateDoc } from './collections/Date'
import { groupDoc } from './collections/Group'
import { jsonDoc } from './collections/JSON'
import { lexicalRichTextDoc } from './collections/Lexical/data'
import { lexicalDocData } from './collections/Lexical/data'
import { lexicalMigrateDocData } from './collections/LexicalMigrate/data'
import { numberDoc } from './collections/Number'
import { pointDoc } from './collections/Point'
import { radiosDoc } from './collections/Radio'
import { richTextBulletsDoc, richTextDoc } from './collections/RichText/data'
import { richTextBulletsDocData, richTextDocData } from './collections/RichText/data'
import { selectsDoc } from './collections/Select'
import { tabsDoc } from './collections/Tabs'
import { textDoc } from './collections/Text/shared'
import { uploadsDoc } from './collections/Upload'
import {
arrayFieldsSlug,
blockFieldsSlug,
codeFieldsSlug,
collapsibleFieldsSlug,
@@ -55,7 +57,7 @@ export async function clearAndSeedEverything(_payload: Payload) {
const [jpgFile, pngFile] = await Promise.all([getFileByPath(jpgPath), getFileByPath(pngPath)])
const [createdArrayDoc, createdTextDoc, createdPNGDoc] = await Promise.all([
_payload.create({ collection: 'array-fields', data: arrayDoc }),
_payload.create({ collection: arrayFieldsSlug, data: arrayDoc }),
_payload.create({ collection: textFieldsSlug, data: textDoc }),
_payload.create({ collection: uploadsSlug, data: {}, file: pngFile }),
])
@@ -79,13 +81,13 @@ export async function clearAndSeedEverything(_payload: Payload) {
_payload.db.defaultIDType === 'number' ? createdTextDoc.id : `"${createdTextDoc.id}"`
const richTextDocWithRelId = JSON.parse(
JSON.stringify(richTextDoc)
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(richTextBulletsDoc)
JSON.stringify(richTextBulletsDocData)
.replace(/"\{\{ARRAY_DOC_ID\}\}"/g, `${formattedID}`)
.replace(/"\{\{UPLOAD_DOC_ID\}\}"/g, `${formattedJPGID}`)
.replace(/"\{\{TEXT_DOC_ID\}\}"/g, `${formattedTextID}`),
@@ -98,11 +100,32 @@ export async function clearAndSeedEverything(_payload: Payload) {
blocksDocWithRichText.blocks[0].richText = richTextDocWithRelationship.richText
blocksDocWithRichText.localizedBlocks[0].richText = richTextDocWithRelationship.richText
const lexicalRichTextDocWithRelId = JSON.parse(
JSON.stringify(lexicalRichTextDoc)
await _payload.create({ collection: richTextFieldsSlug, data: richTextBulletsDocWithRelId })
const createdRichTextDoc = await _payload.create({
collection: richTextFieldsSlug,
data: richTextDocWithRelationship,
})
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(/"\{\{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 Promise.all([
@@ -126,15 +149,12 @@ export async function clearAndSeedEverything(_payload: Payload) {
_payload.create({ collection: blockFieldsSlug, data: blocksDocWithRichText }),
_payload.create({ collection: lexicalFieldsSlug, data: lexicalRichTextDocWithRelId }),
_payload.create({ collection: lexicalFieldsSlug, data: lexicalDocWithRelId }),
_payload.create({
collection: lexicalMigrateFieldsSlug,
data: lexicalRichTextDocWithRelId,
data: lexicalMigrateDocWithRelId,
}),
_payload.create({ collection: richTextFieldsSlug, data: richTextBulletsDocWithRelId }),
_payload.create({ collection: richTextFieldsSlug, data: richTextDocWithRelationship }),
_payload.create({ collection: numberFieldsSlug, data: { number: 2 } }),
_payload.create({ collection: numberFieldsSlug, data: { number: 3 } }),
_payload.create({ collection: numberFieldsSlug, data: numberDoc }),