fix(richtext-lexical): inline blocks did not store nested fields correctly (#10578)

Fixes https://github.com/payloadcms/payload/issues/10555

Form state with nested fields was not unflattened before saving field
data to the node
This commit is contained in:
Alessio Gravili
2025-01-14 14:17:25 -07:00
committed by GitHub
parent 05b03b2dcd
commit 61117ee5cb
29 changed files with 461 additions and 505 deletions

View File

@@ -491,14 +491,13 @@ export const BlockComponent: React.FC<Props> = (props) => {
fields={clientBlock?.fields} fields={clientBlock?.fields}
initialState={initialState} initialState={initialState}
onChange={[onChange]} onChange={[onChange]}
onSubmit={(formState) => { onSubmit={(formState, newData) => {
// This is only called when form is submitted from drawer - usually only the case if the block has a custom Block component // This is only called when form is submitted from drawer - usually only the case if the block has a custom Block component
const newData: any = reduceFieldsToValues(formState)
newData.blockType = formData.blockType newData.blockType = formData.blockType
editor.update(() => { editor.update(() => {
const node = $getNodeByKey(nodeKey) const node = $getNodeByKey(nodeKey)
if (node && $isBlockNode(node)) { if (node && $isBlockNode(node)) {
node.setFields(newData, true) node.setFields(newData as BlockFields, true)
} }
}) })
toggleDrawer() toggleDrawer()

View File

@@ -3,7 +3,7 @@
import React, { createContext, useCallback, useEffect, useMemo, useRef } from 'react' import React, { createContext, useCallback, useEffect, useMemo, useRef } from 'react'
const baseClass = 'inline-block' const baseClass = 'inline-block'
import type { BlocksFieldClient, FormState } from 'payload' import type { BlocksFieldClient, Data, FormState } from 'payload'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection' import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'
@@ -33,7 +33,6 @@ import {
KEY_BACKSPACE_COMMAND, KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND, KEY_DELETE_COMMAND,
} from 'lexical' } from 'lexical'
import { reduceFieldsToValues } from 'payload/shared'
import './index.scss' import './index.scss'
@@ -308,13 +307,13 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
* HANDLE FORM SUBMIT * HANDLE FORM SUBMIT
*/ */
const onFormSubmit = useCallback( const onFormSubmit = useCallback(
(formState: FormState) => { (formState: FormState, newData: Data) => {
const newData: any = reduceFieldsToValues(formState)
newData.blockType = formData.blockType newData.blockType = formData.blockType
editor.update(() => { editor.update(() => {
const node = $getNodeByKey(nodeKey) const node = $getNodeByKey(nodeKey)
if (node && $isInlineBlockNode(node)) { if (node && $isInlineBlockNode(node)) {
node.setFields(newData, true) node.setFields(newData as InlineBlockFields, true)
} }
}) })
}, },
@@ -414,8 +413,8 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
fields={clientBlock?.fields} fields={clientBlock?.fields}
initialState={initialState || {}} initialState={initialState || {}}
onChange={[onChange]} onChange={[onChange]}
onSubmit={(formState) => { onSubmit={(formState, data) => {
onFormSubmit(formState) onFormSubmit(formState, data)
toggleDrawer() toggleDrawer()
}} }}
uuid={uuid()} uuid={uuid()}

View File

@@ -36,7 +36,7 @@ describe('Array', () => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) 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 process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig({ ;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
dirname, dirname,
})) }))

View File

@@ -32,7 +32,7 @@ describe('Collapsibles', () => {
beforeAll(async ({ browser }, testInfo) => { beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) 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 process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig({ ;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
dirname, dirname,
// prebuild, // prebuild,
})) }))

View File

@@ -45,7 +45,7 @@ describe('Conditional Logic', () => {
beforeAll(async ({ browser }, testInfo) => { beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) 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 process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig({ ;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
dirname, dirname,
// prebuild, // prebuild,
})) }))

View File

@@ -35,7 +35,7 @@ describe('Custom IDs', () => {
beforeAll(async ({ browser }, testInfo) => { beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) 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 process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig({ ;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
dirname, dirname,
// prebuild, // prebuild,
})) }))

View File

@@ -36,7 +36,7 @@ describe('Date', () => {
beforeAll(async ({ browser }, testInfo) => { beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) 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 process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig({ ;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
dirname, dirname,
// prebuild, // prebuild,
})) }))

View File

@@ -35,7 +35,7 @@ describe('Email', () => {
beforeAll(async ({ browser }, testInfo) => { beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) 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 process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig({ ;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
dirname, dirname,
// prebuild, // prebuild,
})) }))

View File

@@ -32,7 +32,7 @@ describe('Radio', () => {
beforeAll(async ({ browser }, testInfo) => { beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) 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 process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig({ ;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
dirname, dirname,
// prebuild, // prebuild,
})) }))

View File

@@ -36,7 +36,7 @@ describe('JSON', () => {
beforeAll(async ({ browser }, testInfo) => { beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) 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 process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig({ ;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
dirname, dirname,
// prebuild, // prebuild,
})) }))

View File

@@ -8,7 +8,7 @@ import type {
SerializedParagraphNode, SerializedParagraphNode,
SerializedTextNode, SerializedTextNode,
} from '@payloadcms/richtext-lexical/lexical' } from '@payloadcms/richtext-lexical/lexical'
import type { BrowserContext, Page } from '@playwright/test' import type { BrowserContext, Locator, Page } from '@playwright/test'
import { expect, test } from '@playwright/test' import { expect, test } from '@playwright/test'
import path from 'path' import path from 'path'
@@ -49,7 +49,9 @@ let serverURL: string
async function navigateToLexicalFields( async function navigateToLexicalFields(
navigateToListView: boolean = true, navigateToListView: boolean = true,
localized: boolean = false, localized: boolean = false,
) { ): Promise<{
richTextField: Locator
}> {
if (navigateToListView) { if (navigateToListView) {
const url: AdminUrlUtil = new AdminUrlUtil( const url: AdminUrlUtil = new AdminUrlUtil(
serverURL, serverURL,
@@ -65,13 +67,135 @@ async function navigateToLexicalFields(
await linkToDoc.click() await linkToDoc.click()
await page.waitForURL(`**${linkDocHref}`) await page.waitForURL(`**${linkDocHref}`)
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
return {
richTextField,
}
}
async function createInlineBlock({
name,
richTextField,
}: {
name: string
richTextField: Locator
}): Promise<{
inlineBlockDrawer: Locator
saveDrawer: () => Promise<{
inlineBlock: Locator
openEditDrawer: () => Promise<{ editDrawer: Locator; saveEditDrawer: () => Promise<void> }>
}>
}> {
const lastParagraph = richTextField.locator('p').last()
await lastParagraph.scrollIntoViewIfNeeded()
await expect(lastParagraph).toBeVisible()
const spanInEditor = richTextField.locator('span').getByText('Upload Node:').first()
await expect(spanInEditor).toBeVisible()
await spanInEditor.click()
/**
* Create new sub-block
*/
await page.keyboard.press(' ')
await page.keyboard.press('/')
await page.keyboard.type(name.includes(' ') ? name.split(' ')[0] : name)
// Create Rich Text Block
const slashMenuPopover = page.locator('#slash-menu .slash-menu-popup')
await expect(slashMenuPopover).toBeVisible()
const richTextBlockSelectButton = slashMenuPopover.locator('button').getByText(name).first()
await expect(richTextBlockSelectButton).toBeVisible()
await expect(richTextBlockSelectButton).toHaveText(name)
await richTextBlockSelectButton.click()
await expect(slashMenuPopover).toBeHidden()
// Wait for inline block drawer to pop up. Drawer id starts with drawer_1_lexical-inlineBlocks-create-
const inlineBlockDrawer = page
.locator('dialog[id^=drawer_1_lexical-inlineBlocks-create-]')
.first()
await expect(inlineBlockDrawer).toBeVisible()
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
await wait(500)
return {
inlineBlockDrawer,
saveDrawer: async () => {
await wait(500)
await inlineBlockDrawer.locator('button').getByText('Save changes').click()
await expect(inlineBlockDrawer).toBeHidden()
const inlineBlock = richTextField.locator('.inline-block').nth(0)
const editButton = inlineBlock.locator('.inline-block__editButton').first()
return {
inlineBlock,
openEditDrawer: async () => {
await editButton.click()
const editDrawer = page
.locator('dialog[id^=drawer_1_lexical-inlineBlocks-create-]')
.first()
await expect(editDrawer).toBeVisible()
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
await wait(500)
return {
editDrawer,
saveEditDrawer: async () => {
await wait(500)
const saveButton = editDrawer.locator('button').getByText('Save changes').first()
await saveButton.click()
await expect(editDrawer).toBeHidden()
},
}
},
}
},
}
}
async function assertLexicalDoc({
fn,
depth = 0,
}: {
depth?: number
fn: (args: {
lexicalDoc: LexicalField
lexicalWithBlocks: SerializedEditorState
}) => Promise<void> | void
}) {
await expect(async () => {
const lexicalDoc: LexicalField = (
await payload.find({
collection: lexicalFieldsSlug,
depth,
overrideAccess: true,
where: {
title: {
equals: lexicalDocData.title,
},
},
})
).docs[0] as never
await fn({ lexicalDoc, lexicalWithBlocks: lexicalDoc.lexicalWithBlocks })
}).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
} }
describe('lexicalBlocks', () => { describe('lexicalBlocks', () => {
beforeAll(async ({ browser }, testInfo) => { beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) 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 process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname })) ;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
context = await browser.newContext() context = await browser.newContext()
page = await context.newPage() page = await context.newPage()
@@ -103,12 +227,7 @@ describe('lexicalBlocks', () => {
}) })
test('ensure block with custom Block RSC can be created, updates data when saving edit fields drawer, and maintains cursor position', async () => { test('ensure block with custom Block RSC can be created, updates data when saving edit fields drawer, and maintains cursor position', async () => {
await navigateToLexicalFields() const { richTextField } = await navigateToLexicalFields()
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
const lastParagraph = richTextField.locator('p').last() const lastParagraph = richTextField.locator('p').last()
await lastParagraph.scrollIntoViewIfNeeded() await lastParagraph.scrollIntoViewIfNeeded()
@@ -178,188 +297,24 @@ describe('lexicalBlocks', () => {
await expect(newRSCBlock.locator('.collapsible__content')).toHaveText('Data: value2') await expect(newRSCBlock.locator('.collapsible__content')).toHaveText('Data: value2')
// Check if the API result is correct // Check if the API result is correct
await assertLexicalDoc({
// TODO: fn: async ({ lexicalWithBlocks }) => {
await expect(async () => { const rscBlock: SerializedBlockNode = lexicalWithBlocks.root
const lexicalDoc: LexicalField = ( .children[14] as SerializedBlockNode
await payload.find({ const paragraphBlock: SerializedBlockNode = lexicalWithBlocks.root
collection: lexicalFieldsSlug,
depth: 0,
overrideAccess: true,
where: {
title: {
equals: lexicalDocData.title,
},
},
})
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks
const rscBlock: SerializedBlockNode = lexicalField.root.children[14] as SerializedBlockNode
const paragraphBlock: SerializedBlockNode = lexicalField.root
.children[12] as SerializedBlockNode .children[12] as SerializedBlockNode
expect(rscBlock.fields.blockType).toBe('BlockRSC') expect(rscBlock.fields.blockType).toBe('BlockRSC')
expect(rscBlock.fields.key).toBe('value2') expect(rscBlock.fields.key).toBe('value2')
expect((paragraphBlock.children[0] as SerializedTextNode).text).toBe('123') expect((paragraphBlock.children[0] as SerializedTextNode).text).toBe('123')
expect((paragraphBlock.children[0] as SerializedTextNode).format).toBe(1) expect((paragraphBlock.children[0] as SerializedTextNode).format).toBe(1)
}).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
})
test('ensure inline blocks can be created and its values can be mutated from outside their form', async () => {
await navigateToLexicalFields()
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
const lastParagraph = richTextField.locator('p').last()
await lastParagraph.scrollIntoViewIfNeeded()
await expect(lastParagraph).toBeVisible()
const spanInEditor = richTextField.locator('span').getByText('Upload Node:').first()
await expect(spanInEditor).toBeVisible()
await spanInEditor.click()
/**
* Create new sub-block
*/
await page.keyboard.press(' ')
await page.keyboard.press('/')
await page.keyboard.type('inline')
// Create Rich Text Block
const slashMenuPopover = page.locator('#slash-menu .slash-menu-popup')
await expect(slashMenuPopover).toBeVisible()
const richTextBlockSelectButton = slashMenuPopover.locator('button').first()
await expect(richTextBlockSelectButton).toBeVisible()
await expect(richTextBlockSelectButton).toHaveText('My Inline Block')
await richTextBlockSelectButton.click()
await expect(slashMenuPopover).toBeHidden()
// Wait for inline block drawer to pop up. Drawer id starts with drawer_1_lexical-inlineBlocks-create-
const inlineBlockDrawer = page
.locator('dialog[id^=drawer_1_lexical-inlineBlocks-create-]')
.first()
await expect(inlineBlockDrawer).toBeVisible()
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
await wait(500)
// Click on react select in drawer, select 'value1'
await inlineBlockDrawer.locator('.rs__control .value-container').first().click()
await wait(500)
await expect(inlineBlockDrawer.locator('.rs__option').first()).toBeVisible()
await expect(inlineBlockDrawer.locator('.rs__option').first()).toContainText('value1')
await inlineBlockDrawer.locator('.rs__option').first().click()
// Wait 500
await wait(500)
// Click on save changes button and close drawer
await inlineBlockDrawer.locator('button').getByText('Save changes').click()
await expect(inlineBlockDrawer).toBeHidden()
// Save document
await saveDocAndAssert(page)
// Check if the API result is correct
await expect(async () => {
const lexicalDoc: LexicalField = (
await payload.find({
collection: lexicalFieldsSlug,
depth: 0,
overrideAccess: true,
where: {
title: {
equals: lexicalDocData.title,
}, },
},
})
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks
const firstParagraph: SerializedParagraphNode = lexicalField.root
.children[0] as SerializedParagraphNode
const inlineBlock: SerializedInlineBlockNode = firstParagraph
.children[1] as SerializedInlineBlockNode
await expect(inlineBlock.fields.key).toBe('value1')
}).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
// Open drawer by clicking on edit button inline-block__editButton
const inlineBlock = richTextField.locator('.inline-block').nth(0)
const editButton = inlineBlock.locator('.inline-block__editButton').first()
await editButton.click()
const editDrawer = page.locator('dialog[id^=drawer_1_lexical-inlineBlocks-create-]').first()
await expect(editDrawer).toBeVisible()
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
await wait(500)
// Expect react select to have value 'value1'
await expect(editDrawer.locator('.rs__control .value-container')).toHaveText('value1')
// Close drawer by pressing escape
await page.keyboard.press('Escape')
await expect(editDrawer).toBeHidden()
// Select inline block again
await inlineBlock.click()
await wait(500)
// Press toolbar-popup__button-setKeyToDebug button of richtext editor
const toolbarPopup = richTextField.locator('.toolbar-popup__button-setKeyToDebug').first()
// Click it
await toolbarPopup.click()
await wait(3000)
// Open edit drawer, check if value is now value2, then exit
await inlineBlock.click()
await editButton.click()
await expect(editDrawer).toBeVisible()
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
await wait(500)
await expect(editDrawer.locator('.rs__control .value-container')).toHaveText('value2')
await page.keyboard.press('Escape')
await expect(editDrawer).toBeHidden()
// Save and check api result
await saveDocAndAssert(page)
await expect(async () => {
const lexicalDoc: LexicalField = (
await payload.find({
collection: lexicalFieldsSlug,
depth: 0,
overrideAccess: true,
where: {
title: {
equals: lexicalDocData.title,
},
},
})
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks
const firstParagraph: SerializedParagraphNode = lexicalField.root
.children[0] as SerializedParagraphNode
const inlineBlock: SerializedInlineBlockNode = firstParagraph
.children[1] as SerializedInlineBlockNode
await expect(inlineBlock.fields.key).toBe('value2')
}).toPass({
timeout: POLL_TOPASS_TIMEOUT,
}) })
}) })
describe('nested lexical editor in block', () => { describe('nested lexical editor in block', () => {
test('should type and save typed text', async () => { test('should type and save typed text', async () => {
await navigateToLexicalFields() const { richTextField } = await navigateToLexicalFields()
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
const lexicalBlock = richTextField.locator('.lexical-block').nth(2) // third: "Block Node, with RichText Field, with Relationship Node" const lexicalBlock = richTextField.locator('.lexical-block').nth(2) // third: "Block Node, with RichText Field, with Relationship Node"
await lexicalBlock.scrollIntoViewIfNeeded() await lexicalBlock.scrollIntoViewIfNeeded()
@@ -382,23 +337,10 @@ describe('lexicalBlocks', () => {
await expect(spanInSubEditor).toHaveText('Some text below relationship node 1 inserted text') await expect(spanInSubEditor).toHaveText('Some text below relationship node 1 inserted text')
await saveDocAndAssert(page) await saveDocAndAssert(page)
await expect(async () => { await assertLexicalDoc({
const lexicalDoc: LexicalField = ( fn: async ({ lexicalWithBlocks }) => {
await payload.find({ const blockNode: SerializedBlockNode = lexicalWithBlocks.root
collection: lexicalFieldsSlug, .children[4] as SerializedBlockNode
depth: 0,
overrideAccess: true,
where: {
title: {
equals: lexicalDocData.title,
},
},
})
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks
const blockNode: SerializedBlockNode = lexicalField.root.children[4] as SerializedBlockNode
const textNodeInBlockNodeRichText = const textNodeInBlockNodeRichText =
blockNode.fields.richTextField.root.children[1].children[0] blockNode.fields.richTextField.root.children[1].children[0]
@@ -406,18 +348,12 @@ describe('lexicalBlocks', () => {
expect(textNodeInBlockNodeRichText.text).toBe( expect(textNodeInBlockNodeRichText.text).toBe(
'Some text below relationship node 1 inserted text', 'Some text below relationship node 1 inserted text',
) )
}).toPass({ },
timeout: POLL_TOPASS_TIMEOUT,
}) })
}) })
test('should be able to bold text using floating select toolbar', async () => { test('should be able to bold text using floating select toolbar', async () => {
// Reproduces https://github.com/payloadcms/payload/issues/4025 // Reproduces https://github.com/payloadcms/payload/issues/4025
await navigateToLexicalFields() const { richTextField } = await navigateToLexicalFields()
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
const lexicalBlock = richTextField.locator('.lexical-block').nth(2) // third: "Block Node, with RichText Field, with Relationship Node" const lexicalBlock = richTextField.locator('.lexical-block').nth(2) // third: "Block Node, with RichText Field, with Relationship Node"
await lexicalBlock.scrollIntoViewIfNeeded() await lexicalBlock.scrollIntoViewIfNeeded()
@@ -461,22 +397,10 @@ describe('lexicalBlocks', () => {
await saveDocAndAssert(page) await saveDocAndAssert(page)
await expect(async () => { await assertLexicalDoc({
const lexicalDoc: LexicalField = ( fn: async ({ lexicalWithBlocks }) => {
await payload.find({ const blockNode: SerializedBlockNode = lexicalWithBlocks.root
collection: lexicalFieldsSlug, .children[4] as SerializedBlockNode
depth: 0,
overrideAccess: true,
where: {
title: {
equals: lexicalDocData.title,
},
},
})
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks
const blockNode: SerializedBlockNode = lexicalField.root.children[4] as SerializedBlockNode
const paragraphNodeInBlockNodeRichText = blockNode.fields.richTextField.root.children[1] const paragraphNodeInBlockNodeRichText = blockNode.fields.richTextField.root.children[1]
expect(paragraphNodeInBlockNodeRichText.children).toHaveLength(2) expect(paragraphNodeInBlockNodeRichText.children).toHaveLength(2)
@@ -489,19 +413,13 @@ describe('lexicalBlocks', () => {
expect(boldNode.text).toBe('elationship node 1') expect(boldNode.text).toBe('elationship node 1')
expect(boldNode.format).toBe(1) expect(boldNode.format).toBe(1)
}).toPass({ },
timeout: POLL_TOPASS_TIMEOUT,
}) })
}) })
test('should be able to select text, make it an external link and receive the updated link value', async () => { test('should be able to select text, make it an external link and receive the updated link value', async () => {
// Reproduces https://github.com/payloadcms/payload/issues/4025 // Reproduces https://github.com/payloadcms/payload/issues/4025
await navigateToLexicalFields() const { richTextField } = await navigateToLexicalFields()
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
// Find span in contentEditable with text "Some text below relationship node" // Find span in contentEditable with text "Some text below relationship node"
const spanInEditor = richTextField.locator('span').getByText('Upload Node:').first() const spanInEditor = richTextField.locator('span').getByText('Upload Node:').first()
@@ -554,41 +472,21 @@ describe('lexicalBlocks', () => {
await expect(linkInEditor).toHaveAttribute('href', 'https://www.payloadcms.com') await expect(linkInEditor).toHaveAttribute('href', 'https://www.payloadcms.com')
// Make sure it's being returned from the API as well // Make sure it's being returned from the API as well
await expect(async () => { await assertLexicalDoc({
const lexicalDoc: LexicalField = ( fn: async ({ lexicalWithBlocks }) => {
await payload.find({
collection: lexicalFieldsSlug,
depth: 0,
overrideAccess: true,
where: {
title: {
equals: lexicalDocData.title,
},
},
})
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks
expect( expect(
( (
(lexicalField.root.children[0] as SerializedParagraphNode) (lexicalWithBlocks.root.children[0] as SerializedParagraphNode)
.children[1] as SerializedLinkNode .children[1] as SerializedLinkNode
).fields.url, ).fields.url,
).toBe('https://www.payloadcms.com') ).toBe('https://www.payloadcms.com')
}).toPass({ },
timeout: POLL_TOPASS_TIMEOUT,
}) })
}) })
test('ensure slash menu is not hidden behind other blocks', async () => { test('ensure slash menu is not hidden behind other blocks', async () => {
// This test makes sure there are no z-index issues here // This test makes sure there are no z-index issues here
await navigateToLexicalFields() const { richTextField } = await navigateToLexicalFields()
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
const lexicalBlock = richTextField.locator('.lexical-block').nth(2) // third: "Block Node, with RichText Field, with Relationship Node" const lexicalBlock = richTextField.locator('.lexical-block').nth(2) // third: "Block Node, with RichText Field, with Relationship Node"
await lexicalBlock.scrollIntoViewIfNeeded() await lexicalBlock.scrollIntoViewIfNeeded()
@@ -658,12 +556,7 @@ describe('lexicalBlocks', () => {
}) })
}) })
test('should allow adding new blocks to a sub-blocks field, part of a parent lexical blocks field', async () => { test('should allow adding new blocks to a sub-blocks field, part of a parent lexical blocks field', async () => {
await navigateToLexicalFields() const { richTextField } = await navigateToLexicalFields()
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
const lexicalBlock = richTextField.locator('.lexical-block').nth(3) // third: "Block Node, with Blocks Field, With RichText Field, With Relationship Node" const lexicalBlock = richTextField.locator('.lexical-block').nth(3) // third: "Block Node, with Blocks Field, With RichText Field, With Relationship Node"
await lexicalBlock.scrollIntoViewIfNeeded() await lexicalBlock.scrollIntoViewIfNeeded()
@@ -700,27 +593,10 @@ describe('lexicalBlocks', () => {
await saveDocAndAssert(page) await saveDocAndAssert(page)
await expect(async () => { await assertLexicalDoc({
/** fn: async ({ lexicalWithBlocks }) => {
* Using the local API, check if the data was saved correctly and const blockNode: SerializedBlockNode = lexicalWithBlocks.root
* can be retrieved correctly .children[5] as SerializedBlockNode
*/
const lexicalDoc: LexicalField = (
await payload.find({
collection: lexicalFieldsSlug,
depth: 0,
overrideAccess: true,
where: {
title: {
equals: lexicalDocData.title,
},
},
})
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks
const blockNode: SerializedBlockNode = lexicalField.root.children[5] as SerializedBlockNode
const subBlocks = blockNode.fields.subBlocks const subBlocks = blockNode.fields.subBlocks
expect(subBlocks).toHaveLength(2) expect(subBlocks).toHaveLength(2)
@@ -728,19 +604,13 @@ describe('lexicalBlocks', () => {
const createdTextAreaBlock = subBlocks[1] const createdTextAreaBlock = subBlocks[1]
expect(createdTextAreaBlock.content).toBe('text123') expect(createdTextAreaBlock.content).toBe('text123')
}).toPass({ },
timeout: POLL_TOPASS_TIMEOUT,
}) })
}) })
// Big test which tests a bunch of things: Creation of blocks via slash commands, creation of deeply nested sub-lexical-block fields via slash commands, properly populated deeply nested fields within those // Big test which tests a bunch of things: Creation of blocks via slash commands, creation of deeply nested sub-lexical-block fields via slash commands, properly populated deeply nested fields within those
test('ensure creation of a lexical, lexical-field-block, which contains another lexical, lexical-and-upload-field-block, works and that the sub-upload field is properly populated', async () => { test('ensure creation of a lexical, lexical-field-block, which contains another lexical, lexical-and-upload-field-block, works and that the sub-upload field is properly populated', async () => {
await navigateToLexicalFields() const { richTextField } = await navigateToLexicalFields()
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
const lastParagraph = richTextField.locator('p').last() const lastParagraph = richTextField.locator('p').last()
await lastParagraph.scrollIntoViewIfNeeded() await lastParagraph.scrollIntoViewIfNeeded()
@@ -865,20 +735,8 @@ describe('lexicalBlocks', () => {
await expect(paragraphInSubEditor).toHaveText('Some subText') await expect(paragraphInSubEditor).toHaveText('Some subText')
// Check if the API result is populated correctly - Depth 0 // Check if the API result is populated correctly - Depth 0
await expect(async () => { await assertLexicalDoc({
const lexicalDoc: LexicalField = ( fn: async ({ lexicalWithBlocks }) => {
await payload.find({
collection: lexicalFieldsSlug,
depth: 0,
overrideAccess: true,
where: {
title: {
equals: lexicalDocData.title,
},
},
})
).docs[0] as never
const uploadDoc: Upload = ( const uploadDoc: Upload = (
await payload.find({ await payload.find({
collection: 'uploads', collection: 'uploads',
@@ -892,8 +750,7 @@ describe('lexicalBlocks', () => {
}) })
).docs[0] as never ).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks const richTextBlock: SerializedBlockNode = lexicalWithBlocks.root
const richTextBlock: SerializedBlockNode = lexicalField.root
.children[13] as SerializedBlockNode .children[13] as SerializedBlockNode
const subRichTextBlock: SerializedBlockNode = richTextBlock.fields.richTextField.root const subRichTextBlock: SerializedBlockNode = richTextBlock.fields.richTextField.root
.children[1] as SerializedBlockNode // index 0 and 2 are paragraphs created by default around the block node when a new block is added via slash command .children[1] as SerializedBlockNode // index 0 and 2 are paragraphs created by default around the block node when a new block is added via slash command
@@ -903,26 +760,13 @@ describe('lexicalBlocks', () => {
expect(subSubRichTextField.root.children[0].children[0].text).toBe('Some subText') expect(subSubRichTextField.root.children[0].children[0].text).toBe('Some subText')
expect(subSubUploadField).toBe(uploadDoc.id) expect(subSubUploadField).toBe(uploadDoc.id)
}).toPass({ },
timeout: POLL_TOPASS_TIMEOUT,
}) })
// Check if the API result is populated correctly - Depth 1 // Check if the API result is populated correctly - Depth 1
await expect(async () => { await assertLexicalDoc({
// Now with depth 1
const lexicalDocDepth1: LexicalField = (
await payload.find({
collection: lexicalFieldsSlug,
depth: 1, depth: 1,
overrideAccess: true, fn: async ({ lexicalWithBlocks }) => {
where: {
title: {
equals: lexicalDocData.title,
},
},
})
).docs[0] as never
const uploadDoc: Upload = ( const uploadDoc: Upload = (
await payload.find({ await payload.find({
collection: 'uploads', collection: 'uploads',
@@ -936,8 +780,7 @@ describe('lexicalBlocks', () => {
}) })
).docs[0] as never ).docs[0] as never
const lexicalField2: SerializedEditorState = lexicalDocDepth1.lexicalWithBlocks const richTextBlock2: SerializedBlockNode = lexicalWithBlocks.root
const richTextBlock2: SerializedBlockNode = lexicalField2.root
.children[13] as SerializedBlockNode .children[13] as SerializedBlockNode
const subRichTextBlock2: SerializedBlockNode = richTextBlock2.fields.richTextField.root const subRichTextBlock2: SerializedBlockNode = richTextBlock2.fields.richTextField.root
.children[1] as SerializedBlockNode // index 0 and 2 are paragraphs created by default around the block node when a new block is added via slash command .children[1] as SerializedBlockNode // index 0 and 2 are paragraphs created by default around the block node when a new block is added via slash command
@@ -948,20 +791,14 @@ describe('lexicalBlocks', () => {
expect(subSubRichTextField2.root.children[0].children[0].text).toBe('Some subText') expect(subSubRichTextField2.root.children[0].children[0].text).toBe('Some subText')
expect(subSubUploadField2.id).toBe(uploadDoc.id) expect(subSubUploadField2.id).toBe(uploadDoc.id)
expect(subSubUploadField2.filename).toBe(uploadDoc.filename) expect(subSubUploadField2.filename).toBe(uploadDoc.filename)
}).toPass({ },
timeout: POLL_TOPASS_TIMEOUT,
}) })
}) })
test('should allow changing values of two different radio button blocks independently', async () => { test('should allow changing values of two different radio button blocks independently', async () => {
// This test ensures that https://github.com/payloadcms/payload/issues/3911 does not happen again // This test ensures that https://github.com/payloadcms/payload/issues/3911 does not happen again
await navigateToLexicalFields() const { richTextField } = await navigateToLexicalFields()
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
await wait(1000) // Wait for form state requests to be done, to reduce flakes await wait(1000) // Wait for form state requests to be done, to reduce flakes
const radioButtonBlock1 = richTextField.locator('.lexical-block').nth(5) const radioButtonBlock1 = richTextField.locator('.lexical-block').nth(5)
@@ -999,28 +836,16 @@ describe('lexicalBlocks', () => {
await saveDocAndAssert(page) await saveDocAndAssert(page)
await expect(async () => { await assertLexicalDoc({
const lexicalDoc: LexicalField = ( fn: async ({ lexicalWithBlocks }) => {
await payload.find({ const radio1: SerializedBlockNode = lexicalWithBlocks.root
collection: lexicalFieldsSlug, .children[8] as SerializedBlockNode
depth: 0, const radio2: SerializedBlockNode = lexicalWithBlocks.root
overrideAccess: true, .children[9] as SerializedBlockNode
where: {
title: {
equals: lexicalDocData.title,
},
},
})
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks
const radio1: SerializedBlockNode = lexicalField.root.children[8] as SerializedBlockNode
const radio2: SerializedBlockNode = lexicalField.root.children[9] as SerializedBlockNode
expect(radio1.fields.radioButtons).toBe('option2') expect(radio1.fields.radioButtons).toBe('option2')
expect(radio2.fields.radioButtons).toBe('option3') expect(radio2.fields.radioButtons).toBe('option3')
}).toPass({ },
timeout: POLL_TOPASS_TIMEOUT,
}) })
}) })
@@ -1031,12 +856,7 @@ describe('lexicalBlocks', () => {
// 2. Focus nested editor and write something // 2. Focus nested editor and write something
// 3. In the issue, after writing one character, the cursor focuses back into the parent editor // 3. In the issue, after writing one character, the cursor focuses back into the parent editor
await navigateToLexicalFields() const { richTextField } = await navigateToLexicalFields()
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
/** /**
* 1. Focus parent editor * 1. Focus parent editor
@@ -1130,14 +950,7 @@ describe('lexicalBlocks', () => {
test('should respect row removal in nested array field after navigating away from lexical document, then navigating back', async () => { test('should respect row removal in nested array field after navigating away from lexical document, then navigating back', async () => {
// This test verifies an issue where a lexical editor with blocks disappears when navigating away from the lexical document, then navigating back, without a hard refresh // This test verifies an issue where a lexical editor with blocks disappears when navigating away from the lexical document, then navigating back, without a hard refresh
await navigateToLexicalFields() const { richTextField } = await navigateToLexicalFields()
// Wait for lexical to be loaded up fully
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
await wait(1000) // Wait for form state requests to be done, to reduce flakes await wait(1000) // Wait for form state requests to be done, to reduce flakes
const conditionalArrayBlock = richTextField.locator('.lexical-block').nth(7) const conditionalArrayBlock = richTextField.locator('.lexical-block').nth(7)
@@ -1158,12 +971,7 @@ describe('lexicalBlocks', () => {
test('ensure pre-seeded uploads node is visible', async () => { test('ensure pre-seeded uploads node is visible', async () => {
// Due to issues with the relationships condition, we had issues with that not being visible. Checking for visibility ensures there is no breakage there again // Due to issues with the relationships condition, we had issues with that not being visible. Checking for visibility ensures there is no breakage there again
await navigateToLexicalFields() const { richTextField } = await navigateToLexicalFields()
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
const uploadBlock = richTextField.locator('.ContentEditable__root > div').first() // Check for the first div, as we wanna make sure it's the first div in the editor (1. node is a paragraph, second node is a div which is the upload node) const uploadBlock = richTextField.locator('.ContentEditable__root > div').first() // Check for the first div, as we wanna make sure it's the first div in the editor (1. node is a paragraph, second node is a div which is the upload node)
await uploadBlock.scrollIntoViewIfNeeded() await uploadBlock.scrollIntoViewIfNeeded()
@@ -1175,12 +983,7 @@ describe('lexicalBlocks', () => {
}) })
test('should respect required error state in deeply nested text field', async () => { test('should respect required error state in deeply nested text field', async () => {
await navigateToLexicalFields() const { richTextField } = await navigateToLexicalFields()
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
await wait(300) await wait(300)
@@ -1226,13 +1029,7 @@ describe('lexicalBlocks', () => {
// Reproduces https://github.com/payloadcms/payload/issues/6631 // Reproduces https://github.com/payloadcms/payload/issues/6631
test('ensure tabs field within lexical block correctly loads and saves data', async () => { test('ensure tabs field within lexical block correctly loads and saves data', async () => {
await navigateToLexicalFields() const { richTextField } = await navigateToLexicalFields()
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
const tabsBlock = richTextField.locator('.lexical-block').nth(8) const tabsBlock = richTextField.locator('.lexical-block').nth(8)
await wait(300) await wait(300)
@@ -1276,28 +1073,14 @@ describe('lexicalBlocks', () => {
await tab2Button.click() await tab2Button.click()
await expect(tab2Text1Field).toHaveValue('Some text2 changed') await expect(tab2Text1Field).toHaveValue('Some text2 changed')
await expect(async () => { await assertLexicalDoc({
const lexicalDoc: LexicalField = ( fn: async ({ lexicalWithBlocks }) => {
await payload.find({ const tabBlockData: SerializedBlockNode = lexicalWithBlocks.root
collection: lexicalFieldsSlug,
depth: 0,
overrideAccess: true,
where: {
title: {
equals: lexicalDocData.title,
},
},
})
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks
const tabBlockData: SerializedBlockNode = lexicalField.root
.children[13] as SerializedBlockNode .children[13] as SerializedBlockNode
expect(tabBlockData.fields.tab1.text1).toBe('Some text1 changed') expect(tabBlockData.fields.tab1.text1).toBe('Some text1 changed')
expect(tabBlockData.fields.tab2.text2).toBe('Some text2 changed') expect(tabBlockData.fields.tab2.text2).toBe('Some text2 changed')
}).toPass({ },
timeout: POLL_TOPASS_TIMEOUT,
}) })
}) })
@@ -1343,4 +1126,145 @@ describe('lexicalBlocks', () => {
).toHaveText('Some Description') ).toHaveText('Some Description')
}) })
}) })
describe('inline blocks', () => {
test('ensure inline blocks can be created and its values can be mutated from outside their form', async () => {
const { richTextField } = await navigateToLexicalFields()
const { inlineBlockDrawer, saveDrawer } = await createInlineBlock({
richTextField,
name: 'My Inline Block',
})
// Click on react select in drawer, select 'value1'
await inlineBlockDrawer.locator('.rs__control .value-container').first().click()
await wait(500)
await expect(inlineBlockDrawer.locator('.rs__option').first()).toBeVisible()
await expect(inlineBlockDrawer.locator('.rs__option').first()).toContainText('value1')
await inlineBlockDrawer.locator('.rs__option').first().click()
const { inlineBlock, openEditDrawer } = await saveDrawer()
// Save document
await saveDocAndAssert(page)
// Check if the API result is correct
await assertLexicalDoc({
fn: async ({ lexicalWithBlocks }) => {
const firstParagraph: SerializedParagraphNode = lexicalWithBlocks.root
.children[0] as SerializedParagraphNode
const inlineBlock: SerializedInlineBlockNode = firstParagraph
.children[1] as SerializedInlineBlockNode
expect(inlineBlock.fields.key).toBe('value1')
},
})
const { editDrawer } = await openEditDrawer()
// Expect react select to have value 'value1'
await expect(editDrawer.locator('.rs__control .value-container')).toHaveText('value1')
// Close drawer by pressing escape
await page.keyboard.press('Escape')
await expect(editDrawer).toBeHidden()
// Select inline block again
await inlineBlock.click()
await wait(500)
// Press toolbar-popup__button-setKeyToDebug button of richtext editor
const toolbarPopup = richTextField.locator('.toolbar-popup__button-setKeyToDebug').first()
// Click it
await toolbarPopup.click()
await wait(1000)
// Open edit drawer, check if value is now value2, then exit
await inlineBlock.click()
await openEditDrawer()
await expect(editDrawer.locator('.rs__control .value-container')).toHaveText('value2')
await page.keyboard.press('Escape')
await expect(editDrawer).toBeHidden()
// Save and check api result
await saveDocAndAssert(page)
await assertLexicalDoc({
fn: async ({ lexicalWithBlocks }) => {
const firstParagraph: SerializedParagraphNode = lexicalWithBlocks.root
.children[0] as SerializedParagraphNode
const inlineBlock: SerializedInlineBlockNode = firstParagraph
.children[1] as SerializedInlineBlockNode
await expect(inlineBlock.fields.key).toBe('value2')
},
})
})
test('ensure upload fields within inline blocks store and populate correctly', async () => {
const { richTextField } = await navigateToLexicalFields()
const { inlineBlockDrawer, saveDrawer } = await createInlineBlock({
richTextField,
name: 'Avatar Group',
})
// Click button that says Add Avatar
await inlineBlockDrawer.getByRole('button', { name: 'Add Avatar' }).click()
await inlineBlockDrawer.getByRole('button', { name: 'Choose from existing' }).click()
const uploadDrawer = page.locator('dialog[id^=list-drawer_2_]').first()
await uploadDrawer.getByText('payload.jpg').click()
await expect(inlineBlockDrawer.locator('.upload-relationship-details__filename')).toHaveText(
'payload.jpg',
)
await saveDrawer()
await saveDocAndAssert(page)
await assertLexicalDoc({
fn: async ({ lexicalWithBlocks }) => {
const firstParagraph: SerializedParagraphNode = lexicalWithBlocks.root
.children[0] as SerializedParagraphNode
const inlineBlock: SerializedInlineBlockNode = firstParagraph
.children[1] as SerializedInlineBlockNode
const uploadDoc: Upload = (
await payload.find({
collection: 'uploads',
depth: 0,
overrideAccess: true,
where: {
filename: {
equals: 'payload.jpg',
},
},
})
).docs[0] as never
expect(inlineBlock.fields.blockType).toBe('AvatarGroup')
expect(inlineBlock.fields.avatars?.length).toBe(1)
expect(inlineBlock.fields.avatars[0].image).toBe(uploadDoc.id)
},
})
await assertLexicalDoc({
depth: 1,
fn: async ({ lexicalWithBlocks }) => {
const firstParagraph: SerializedParagraphNode = lexicalWithBlocks.root
.children[0] as SerializedParagraphNode
const inlineBlock: SerializedInlineBlockNode = firstParagraph
.children[1] as SerializedInlineBlockNode
const uploadDoc: Upload = (
await payload.find({
collection: 'uploads',
depth: 0,
overrideAccess: true,
where: {
filename: {
equals: 'payload.jpg',
},
},
})
).docs[0] as never
expect(inlineBlock.fields.blockType).toBe('AvatarGroup')
expect(inlineBlock.fields.avatars?.length).toBe(1)
expect(inlineBlock.fields.avatars[0].image.id).toBe(uploadDoc.id)
expect(inlineBlock.fields.avatars[0].image.text).toBe(uploadDoc.text)
},
})
})
})
}) })

View File

@@ -69,7 +69,7 @@ describe('lexicalMain', () => {
beforeAll(async ({ browser }, testInfo) => { beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) 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 process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname })) ;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
context = await browser.newContext() context = await browser.newContext()
page = await context.newPage() page = await context.newPage()

View File

@@ -176,6 +176,25 @@ const editorConfig: ServerEditorConfig = {
}, },
], ],
inlineBlocks: [ inlineBlocks: [
{
slug: 'AvatarGroup',
interfaceName: 'AvatarGroupBlock',
fields: [
{
name: 'avatars',
type: 'array',
minRows: 1,
maxRows: 6,
fields: [
{
name: 'image',
type: 'upload',
relationTo: 'uploads',
},
],
},
],
},
{ {
slug: 'myInlineBlock', slug: 'myInlineBlock',
admin: { admin: {

View File

@@ -38,7 +38,7 @@ describe('Number', () => {
beforeAll(async ({ browser }, testInfo) => { beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) 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 process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig({ ;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
dirname, dirname,
// prebuild, // prebuild,
})) }))

View File

@@ -37,7 +37,7 @@ describe('Point', () => {
beforeAll(async ({ browser }, testInfo) => { beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) 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 process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig({ ;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
dirname, dirname,
// prebuild, // prebuild,
})) }))

View File

@@ -31,7 +31,7 @@ describe('Radio', () => {
beforeAll(async ({ browser }, testInfo) => { beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) 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 process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig({ ;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
dirname, dirname,
// prebuild, // prebuild,
})) }))

View File

@@ -42,7 +42,7 @@ describe('relationship', () => {
beforeAll(async ({ browser }, testInfo) => { beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) 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 process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig({ ;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
dirname, dirname,
})) }))

View File

@@ -31,7 +31,7 @@ describe('Rich Text', () => {
beforeAll(async ({ browser }, testInfo) => { beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) 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 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({ ;({ serverURL } = await initPayloadE2ENoConfig<Config>({
dirname, dirname,
})) }))

View File

@@ -32,7 +32,7 @@ describe('Row', () => {
beforeAll(async ({ browser }, testInfo) => { beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) 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 process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig({ ;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
dirname, dirname,
// prebuild, // prebuild,
})) }))

View File

@@ -35,7 +35,7 @@ describe('Radio', () => {
beforeAll(async ({ browser }, testInfo) => { beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) 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 process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig({ ;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
dirname, dirname,
// prebuild, // prebuild,
})) }))

View File

@@ -39,7 +39,7 @@ describe('Tabs', () => {
beforeAll(async ({ browser }, testInfo) => { beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) 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 process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig({ ;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
dirname, dirname,
// prebuild, // prebuild,
})) }))

View File

@@ -36,7 +36,7 @@ describe('Tabs', () => {
beforeAll(async ({ browser }, testInfo) => { beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) 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 process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig({ ;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
dirname, dirname,
// prebuild, // prebuild,
})) }))

View File

@@ -43,7 +43,7 @@ describe('Text', () => {
beforeAll(async ({ browser }, testInfo) => { beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) 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 process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig({ ;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
dirname, dirname,
// prebuild, // prebuild,
})) }))

View File

@@ -31,7 +31,7 @@ describe('Radio', () => {
beforeAll(async ({ browser }, testInfo) => { beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) 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 process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig({ ;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
dirname, dirname,
// prebuild, // prebuild,
})) }))

View File

@@ -38,7 +38,7 @@ describe('Upload', () => {
beforeAll(async ({ browser }, testInfo) => { beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) 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 process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig({ ;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
dirname, dirname,
// prebuild, // prebuild,
})) }))

View File

@@ -33,7 +33,7 @@ describe('Upload with restrictions', () => {
beforeAll(async ({ browser }, testInfo) => { beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) 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 process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig({ ;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({
dirname, dirname,
// prebuild, // prebuild,
})) }))

View File

@@ -3374,6 +3374,21 @@ export interface LexicalBlocksRadioButtonsBlock {
blockName?: string | null; blockName?: string | null;
blockType: 'radioButtons'; blockType: 'radioButtons';
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "AvatarGroupBlock".
*/
export interface AvatarGroupBlock {
avatars?:
| {
image?: (string | null) | Upload;
id?: string | null;
}[]
| null;
id?: string | null;
blockName?: string | null;
blockType: 'AvatarGroup';
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth". * via the `definition` "auth".

View File

@@ -39,7 +39,7 @@ let serverURL: string
describe('Locked Documents', () => { describe('Locked Documents', () => {
beforeAll(async ({ browser }, testInfo) => { beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) testInfo.setTimeout(TEST_TIMEOUT_LONG)
;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname })) ;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
globalUrl = new AdminUrlUtil(serverURL, 'menu') globalUrl = new AdminUrlUtil(serverURL, 'menu')
postsUrl = new AdminUrlUtil(serverURL, 'posts') postsUrl = new AdminUrlUtil(serverURL, 'posts')

View File

@@ -8,7 +8,7 @@ const dirname = path.dirname(filename)
dotenv.config({ path: path.resolve(dirname, 'test.env') }) dotenv.config({ path: path.resolve(dirname, 'test.env') })
let multiplier = process.env.CI ? 3 : 0.5 let multiplier = process.env.CI ? 3 : 0.75
let smallMultiplier = process.env.CI ? 2 : 0.75 let smallMultiplier = process.env.CI ? 2 : 0.75
export const TEST_TIMEOUT_LONG = 640000 * multiplier // 8*3 minutes - used as timeOut for the beforeAll export const TEST_TIMEOUT_LONG = 640000 * multiplier // 8*3 minutes - used as timeOut for the beforeAll