From fff377ad22cce3b26142cde8f4125fcee95aa072 Mon Sep 17 00:00:00 2001 From: Alessio Gravili <70709113+AlessioGr@users.noreply.github.com> Date: Wed, 8 Nov 2023 21:32:43 +0100 Subject: [PATCH] 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 --- .../Blocks/component/BlockContent.tsx | 19 + test/fields/collections/Lexical/data.ts | 6 +- .../Lexical/generateLexicalRichText.ts | 4 +- .../generatePayloadPluginLexicalData.ts | 958 ------------------ test/fields/collections/Lexical/index.ts | 51 +- .../fields/collections/LexicalMigrate/data.ts | 6 + .../collections/LexicalMigrate/index.ts | 4 +- test/fields/collections/RichText/data.ts | 9 +- test/fields/collections/RichText/index.ts | 8 +- test/fields/e2e.spec.ts | 3 +- test/fields/int.spec.ts | 9 +- test/fields/lexical.e2e.spec.ts | 61 ++ test/fields/lexical.int.spec.ts | 395 ++++++++ test/fields/lexicalE2E.ts | 27 - test/fields/seed.ts | 46 +- 15 files changed, 539 insertions(+), 1067 deletions(-) delete mode 100644 test/fields/collections/Lexical/generatePayloadPluginLexicalData.ts create mode 100644 test/fields/collections/LexicalMigrate/data.ts create mode 100644 test/fields/lexical.e2e.spec.ts create mode 100644 test/fields/lexical.int.spec.ts delete mode 100644 test/fields/lexicalE2E.ts diff --git a/packages/richtext-lexical/src/field/features/Blocks/component/BlockContent.tsx b/packages/richtext-lexical/src/field/features/Blocks/component/BlockContent.tsx index c43d0b03cc..0856521f32 100644 --- a/packages/richtext-lexical/src/field/features/Blocks/component/BlockContent.tsx +++ b/packages/richtext-lexical/src/field/features/Blocks/component/BlockContent.tsx @@ -71,6 +71,25 @@ export const BlockContent: React.FC = (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) diff --git a/test/fields/collections/Lexical/data.ts b/test/fields/collections/Lexical/data.ts index ac76364509..4f74402ac0 100644 --- a/test/fields/collections/Lexical/data.ts +++ b/test/fields/collections/Lexical/data.ts @@ -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(), } diff --git a/test/fields/collections/Lexical/generateLexicalRichText.ts b/test/fields/collections/Lexical/generateLexicalRichText.ts index 4274abd48f..a63eed411b 100644 --- a/test/fields/collections/Lexical/generateLexicalRichText.ts +++ b/test/fields/collections/Lexical/generateLexicalRichText.ts @@ -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}}', }, }, ], diff --git a/test/fields/collections/Lexical/generatePayloadPluginLexicalData.ts b/test/fields/collections/Lexical/generatePayloadPluginLexicalData.ts deleted file mode 100644 index 31aacd6b40..0000000000 --- a/test/fields/collections/Lexical/generatePayloadPluginLexicalData.ts +++ /dev/null @@ -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', - }, - }, -} diff --git a/test/fields/collections/Lexical/index.ts b/test/fields/collections/Lexical/index.ts index a88780a092..c188f554f6 100644 --- a/test/fields/collections/Lexical/index.ts +++ b/test/fields/collections/Lexical/index.ts @@ -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(), - }, - ], - }, - }, - }), - ], - }), - }, ], } diff --git a/test/fields/collections/LexicalMigrate/data.ts b/test/fields/collections/LexicalMigrate/data.ts new file mode 100644 index 0000000000..9439fd821f --- /dev/null +++ b/test/fields/collections/LexicalMigrate/data.ts @@ -0,0 +1,6 @@ +import { payloadPluginLexicalData } from './generatePayloadPluginLexicalData' + +export const lexicalMigrateDocData = { + title: 'Rich Text', + lexicalWithLexicalPluginData: payloadPluginLexicalData, +} diff --git a/test/fields/collections/LexicalMigrate/index.ts b/test/fields/collections/LexicalMigrate/index.ts index 262ae976ec..6f760181bc 100644 --- a/test/fields/collections/LexicalMigrate/index.ts +++ b/test/fields/collections/LexicalMigrate/index.ts @@ -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, } diff --git a/test/fields/collections/RichText/data.ts b/test/fields/collections/RichText/data.ts index 03872b8f44..8d497e9b41 100644 --- a/test/fields/collections/RichText/data.ts +++ b/test/fields/collections/RichText/data.ts @@ -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', diff --git a/test/fields/collections/RichText/index.ts b/test/fields/collections/RichText/index.ts index d6cc89db9d..dba830cf6b 100644 --- a/test/fields/collections/RichText/index.ts +++ b/test/fields/collections/RichText/index.ts @@ -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.', diff --git a/test/fields/e2e.spec.ts b/test/fields/e2e.spec.ts index 4dca63f9c8..be92ad95fa 100644 --- a/test/fields/e2e.spec.ts +++ b/test/fields/e2e.spec.ts @@ -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') diff --git a/test/fields/int.spec.ts b/test/fields/int.spec.ts index 7aa586d917..6df6a78a41 100644 --- a/test/fields/int.spec.ts +++ b/test/fields/int.spec.ts @@ -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 () => { diff --git a/test/fields/lexical.e2e.spec.ts b/test/fields/lexical.e2e.spec.ts new file mode 100644 index 0000000000..54749ca658 --- /dev/null +++ b/test/fields/lexical.e2e.spec.ts @@ -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() + }) +}) diff --git a/test/fields/lexical.int.spec.ts b/test/fields/lexical.int.spec.ts new file mode 100644 index 0000000000..26c1875b48 --- /dev/null +++ b/test/fields/lexical.int.spec.ts @@ -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 + } = 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) + }) + }) +}) diff --git a/test/fields/lexicalE2E.ts b/test/fields/lexicalE2E.ts deleted file mode 100644 index a8d69a5cd5..0000000000 --- a/test/fields/lexicalE2E.ts +++ /dev/null @@ -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() - }) - }) - } -} diff --git a/test/fields/seed.ts b/test/fields/seed.ts index 02fe3755cf..2242327ed0 100644 --- a/test/fields/seed.ts +++ b/test/fields/seed.ts @@ -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 }),