fix(richtext-lexical): ensure sub-fields have access to full document data in form state (#9869)

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

This PR does the following:
- adds a `useDocumentForm` hook to access the document Form. Useful if
you are within a sub-Form
- ensure the `data` property passed to field conditions, read access
control, validation and filterOptions is always the top-level document
data. Previously, for fields within lexical blocks/links/upload, this
incorrectly was the lexical block-level data.
- adds a `blockData` property to hooks, field conditions,
read/update/create field access control, validation and filterOptions
for all fields. This allows you to access the data of the nearest parent
block, which is especially useful for lexical sub-fields. Users that
were previously depending on the incorrect behavior of the `data`
property in order to access the data of the lexical block can now switch
to the new `blockData` property
This commit is contained in:
Alessio Gravili
2025-02-06 11:49:17 -07:00
committed by GitHub
parent 8ed410456c
commit ae32c555ac
42 changed files with 869 additions and 70 deletions

View File

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

View File

@@ -1,4 +1,4 @@
import type { ArrayField, Block } from 'payload'
import type { ArrayField, Block, TextFieldSingleValidation } from 'payload'
import { BlocksFeature, FixedToolbarFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
@@ -11,6 +11,122 @@ async function asyncFunction(param: string) {
}, 1000)
})
}
export const FilterOptionsBlock: Block = {
slug: 'filterOptionsBlock',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'groupText',
type: 'text',
},
{
name: 'dependsOnDocData',
type: 'relationship',
relationTo: 'text-fields',
filterOptions: ({ data }) => {
if (!data.title) {
return true
}
return {
text: {
equals: data.title,
},
}
},
},
{
name: 'dependsOnSiblingData',
type: 'relationship',
relationTo: 'text-fields',
filterOptions: ({ siblingData }) => {
console.log('SD', siblingData)
// @ts-expect-error
if (!siblingData?.groupText) {
return true
}
return {
text: {
equals: (siblingData as any)?.groupText,
},
}
},
},
{
name: 'dependsOnBlockData',
type: 'relationship',
relationTo: 'text-fields',
filterOptions: ({ blockData }) => {
if (!blockData?.text) {
return true
}
return {
text: {
equals: blockData?.text,
},
}
},
},
],
},
],
}
export const ValidationBlock: Block = {
slug: 'validationBlock',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'groupText',
type: 'text',
},
{
name: 'textDependsOnDocData',
type: 'text',
validate: ((value, { data }) => {
if ((data as any)?.title === 'invalid') {
return 'doc title cannot be invalid'
}
return true
}) as TextFieldSingleValidation,
},
{
name: 'textDependsOnSiblingData',
type: 'text',
validate: ((value, { siblingData }) => {
if ((siblingData as any)?.groupText === 'invalid') {
return 'textDependsOnSiblingData sibling field cannot be invalid'
}
}) as TextFieldSingleValidation,
},
{
name: 'textDependsOnBlockData',
type: 'text',
validate: ((value, { blockData }) => {
if ((blockData as any)?.text === 'invalid') {
return 'textDependsOnBlockData sibling field cannot be invalid'
}
}) as TextFieldSingleValidation,
},
],
},
],
}
export const AsyncHooksBlock: Block = {
slug: 'asyncHooksBlock',
fields: [

View File

@@ -30,6 +30,7 @@ import { RESTClient } from '../../../../../helpers/rest.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../../../../../playwright.config.js'
import { lexicalFieldsSlug } from '../../../../slugs.js'
import { lexicalDocData } from '../../data.js'
import { trackNetworkRequests } from '../../../../../helpers/e2e/trackNetworkRequests.js'
const filename = fileURLToPath(import.meta.url)
const currentFolder = path.dirname(filename)
@@ -301,17 +302,335 @@ describe('lexicalBlocks', () => {
fn: async ({ lexicalWithBlocks }) => {
const rscBlock: SerializedBlockNode = lexicalWithBlocks.root
.children[14] as SerializedBlockNode
const paragraphBlock: SerializedBlockNode = lexicalWithBlocks.root
.children[12] as SerializedBlockNode
const paragraphNode: SerializedParagraphNode = lexicalWithBlocks.root
.children[12] as SerializedParagraphNode
expect(rscBlock.fields.blockType).toBe('BlockRSC')
expect(rscBlock.fields.key).toBe('value2')
expect((paragraphBlock.children[0] as SerializedTextNode).text).toBe('123')
expect((paragraphBlock.children[0] as SerializedTextNode).format).toBe(1)
expect((paragraphNode.children[0] as SerializedTextNode).text).toBe('123')
expect((paragraphNode.children[0] as SerializedTextNode).format).toBe(1)
},
})
})
describe('block filterOptions', () => {
async function setupFilterOptionsTests() {
const { richTextField } = await navigateToLexicalFields()
await payload.create({
collection: 'text-fields',
data: {
text: 'invalid',
},
depth: 0,
})
const lastParagraph = richTextField.locator('p').last()
await lastParagraph.scrollIntoViewIfNeeded()
await expect(lastParagraph).toBeVisible()
await lastParagraph.click()
await page.keyboard.press('Enter')
await page.keyboard.press('/')
await page.keyboard.type('filter')
// CreateBlock
const slashMenuPopover = page.locator('#slash-menu .slash-menu-popup')
await expect(slashMenuPopover).toBeVisible()
const blockSelectButton = slashMenuPopover.locator('button').first()
await expect(blockSelectButton).toBeVisible()
await expect(blockSelectButton).toContainText('Filter Options Block')
await blockSelectButton.click()
await expect(slashMenuPopover).toBeHidden()
const newBlock = richTextField
.locator('.lexical-block:not(.lexical-block .lexical-block)')
.nth(8) // The :not(.lexical-block .lexical-block) makes sure this does not select sub-blocks
await newBlock.scrollIntoViewIfNeeded()
await saveDocAndAssert(page)
const topLevelDocTextField = page.locator('#field-title').first()
const blockTextField = newBlock.locator('#field-text').first()
const blockGroupTextField = newBlock.locator('#field-group__groupText').first()
const dependsOnDocData = newBlock.locator('#field-group__dependsOnDocData').first()
const dependsOnSiblingData = newBlock.locator('#field-group__dependsOnSiblingData').first()
const dependsOnBlockData = newBlock.locator('#field-group__dependsOnBlockData').first()
return {
topLevelDocTextField,
blockTextField,
blockGroupTextField,
dependsOnDocData,
dependsOnSiblingData,
dependsOnBlockData,
newBlock,
}
}
test('ensure block fields with filter options have access to document-level data', async () => {
const {
blockGroupTextField,
blockTextField,
dependsOnBlockData,
dependsOnDocData,
dependsOnSiblingData,
newBlock,
topLevelDocTextField,
} = await setupFilterOptionsTests()
await dependsOnDocData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toHaveText('No options')
await dependsOnDocData.locator('.rs__control').click()
await dependsOnSiblingData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toContainText('Seeded text document')
await expect(newBlock.locator('.rs__menu')).toContainText('Another text document')
await dependsOnSiblingData.locator('.rs__control').click()
await dependsOnBlockData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toContainText('Seeded text document')
await expect(newBlock.locator('.rs__menu')).toContainText('Another text document')
await dependsOnBlockData.locator('.rs__control').click()
// Fill and wait for form state to come back
await trackNetworkRequests(page, '/admin/collections/lexical-fields', async () => {
await topLevelDocTextField.fill('invalid')
})
// Ensure block form state is updated and comes back (=> filter options are updated)
await trackNetworkRequests(
page,
'/admin/collections/lexical-fields',
async () => {
await blockTextField.fill('.')
await blockTextField.fill('')
},
{ allowedNumberOfRequests: 2 },
)
await dependsOnDocData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toHaveText('Text Fieldsinvalid')
await dependsOnDocData.locator('.rs__control').click()
await dependsOnSiblingData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toContainText('Seeded text document')
await expect(newBlock.locator('.rs__menu')).toContainText('Another text document')
await dependsOnSiblingData.locator('.rs__control').click()
await dependsOnBlockData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toContainText('Seeded text document')
await expect(newBlock.locator('.rs__menu')).toContainText('Another text document')
await dependsOnBlockData.locator('.rs__control').click()
await saveDocAndAssert(page)
})
test('ensure block fields with filter options have access to sibling data', async () => {
const {
blockGroupTextField,
blockTextField,
dependsOnBlockData,
dependsOnDocData,
dependsOnSiblingData,
newBlock,
topLevelDocTextField,
} = await setupFilterOptionsTests()
await trackNetworkRequests(
page,
'/admin/collections/lexical-fields',
async () => {
await blockGroupTextField.fill('invalid')
},
{ allowedNumberOfRequests: 2 },
)
await dependsOnDocData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toHaveText('No options')
await dependsOnDocData.locator('.rs__control').click()
await dependsOnSiblingData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toHaveText('Text Fieldsinvalid')
await dependsOnSiblingData.locator('.rs__control').click()
await dependsOnBlockData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toContainText('Seeded text document')
await expect(newBlock.locator('.rs__menu')).toContainText('Another text document')
await dependsOnBlockData.locator('.rs__control').click()
await saveDocAndAssert(page)
})
test('ensure block fields with filter options have access to block-level data', async () => {
const {
blockGroupTextField,
blockTextField,
dependsOnBlockData,
dependsOnDocData,
dependsOnSiblingData,
newBlock,
topLevelDocTextField,
} = await setupFilterOptionsTests()
await trackNetworkRequests(
page,
'/admin/collections/lexical-fields',
async () => {
await blockTextField.fill('invalid')
},
{ allowedNumberOfRequests: 2 },
)
await dependsOnDocData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toHaveText('No options')
await dependsOnDocData.locator('.rs__control').click()
await dependsOnSiblingData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toContainText('Seeded text document')
await expect(newBlock.locator('.rs__menu')).toContainText('Another text document')
await dependsOnSiblingData.locator('.rs__control').click()
await dependsOnBlockData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toHaveText('Text Fieldsinvalid')
await dependsOnBlockData.locator('.rs__control').click()
await saveDocAndAssert(page)
})
})
describe('block validation data', () => {
async function setupValidationTests() {
const { richTextField } = await navigateToLexicalFields()
await payload.create({
collection: 'text-fields',
data: {
text: 'invalid',
},
depth: 0,
})
const lastParagraph = richTextField.locator('p').last()
await lastParagraph.scrollIntoViewIfNeeded()
await expect(lastParagraph).toBeVisible()
await lastParagraph.click()
await page.keyboard.press('Enter')
await page.keyboard.press('/')
await page.keyboard.type('validation')
// CreateBlock
const slashMenuPopover = page.locator('#slash-menu .slash-menu-popup')
await expect(slashMenuPopover).toBeVisible()
const blockSelectButton = slashMenuPopover.locator('button').first()
await expect(blockSelectButton).toBeVisible()
await expect(blockSelectButton).toContainText('Validation Block')
await blockSelectButton.click()
await expect(slashMenuPopover).toBeHidden()
const newBlock = richTextField
.locator('.lexical-block:not(.lexical-block .lexical-block)')
.nth(8) // The :not(.lexical-block .lexical-block) makes sure this does not select sub-blocks
await newBlock.scrollIntoViewIfNeeded()
await saveDocAndAssert(page)
const topLevelDocTextField = page.locator('#field-title').first()
const blockTextField = newBlock.locator('#field-text').first()
const blockGroupTextField = newBlock.locator('#field-group__groupText').first()
const dependsOnDocData = newBlock.locator('#field-group__textDependsOnDocData').first()
const dependsOnSiblingData = newBlock
.locator('#field-group__textDependsOnSiblingData')
.first()
const dependsOnBlockData = newBlock.locator('#field-group__textDependsOnBlockData').first()
return {
topLevelDocTextField,
blockTextField,
blockGroupTextField,
dependsOnDocData,
dependsOnSiblingData,
dependsOnBlockData,
newBlock,
}
}
test('ensure block fields with validations have access to document-level data', async () => {
const { topLevelDocTextField } = await setupValidationTests()
await topLevelDocTextField.fill('invalid')
await saveDocAndAssert(page, '#action-save', 'error')
await expect(page.locator('.payload-toast-container')).toHaveText(
'The following fields are invalid: Lexical With Blocks, LexicalWithBlocks > Group > Text Depends On Doc Data',
)
await expect(page.locator('.payload-toast-container')).not.toBeVisible()
await trackNetworkRequests(
page,
'/admin/collections/lexical-fields',
async () => {
await topLevelDocTextField.fill('Rich Text') // Default value
},
{ allowedNumberOfRequests: 2 },
)
await saveDocAndAssert(page)
})
test('ensure block fields with validations have access to sibling data', async () => {
const { blockGroupTextField } = await setupValidationTests()
await blockGroupTextField.fill('invalid')
await saveDocAndAssert(page, '#action-save', 'error')
await expect(page.locator('.payload-toast-container')).toHaveText(
'The following fields are invalid: Lexical With Blocks, LexicalWithBlocks > Group > Text Depends On Sibling Data',
)
await trackNetworkRequests(
page,
'/admin/collections/lexical-fields',
async () => {
await blockGroupTextField.fill('')
},
{ allowedNumberOfRequests: 2 },
)
await saveDocAndAssert(page)
})
test('ensure block fields with validations have access to block-level data', async () => {
const { blockTextField } = await setupValidationTests()
await blockTextField.fill('invalid')
await saveDocAndAssert(page, '#action-save', 'error')
await expect(page.locator('.payload-toast-container')).toHaveText(
'The following fields are invalid: Lexical With Blocks, LexicalWithBlocks > Group > Text Depends On Block Data',
)
await expect(page.locator('.payload-toast-container')).not.toBeVisible()
await trackNetworkRequests(
page,
'/admin/collections/lexical-fields',
async () => {
await blockTextField.fill('')
},
{ allowedNumberOfRequests: 2 },
)
await saveDocAndAssert(page)
})
})
test('ensure async hooks are awaited properly', async () => {
const { richTextField } = await navigateToLexicalFields()

View File

@@ -23,6 +23,7 @@ import {
AsyncHooksBlock,
CodeBlock,
ConditionalLayoutBlock,
FilterOptionsBlock,
RadioButtonsBlock,
RelationshipBlock,
RelationshipHasManyBlock,
@@ -32,6 +33,7 @@ import {
TabBlock,
TextBlock,
UploadAndRichTextBlock,
ValidationBlock,
} from './blocks.js'
import { ModifyInlineBlockFeature } from './ModifyInlineBlockFeature/feature.server.js'
@@ -74,6 +76,8 @@ const editorConfig: ServerEditorConfig = {
ModifyInlineBlockFeature(),
BlocksFeature({
blocks: [
ValidationBlock,
FilterOptionsBlock,
AsyncHooksBlock,
RichTextBlock,
TextBlock,

View File

@@ -8,11 +8,11 @@ const dirname = path.dirname(filename)
dotenv.config({ path: path.resolve(dirname, 'test.env') })
let multiplier = process.env.CI ? 3 : 0.75
let smallMultiplier = process.env.CI ? 2 : 0.75
let multiplier = process.env.CI ? 3 : 1
let smallMultiplier = process.env.CI ? 2 : 1
export const TEST_TIMEOUT_LONG = 640000 * multiplier // 8*3 minutes - used as timeOut for the beforeAll
export const TEST_TIMEOUT = 30000 * multiplier
export const TEST_TIMEOUT = 40000 * multiplier
export const EXPECT_TIMEOUT = 6000 * smallMultiplier
export const POLL_TOPASS_TIMEOUT = EXPECT_TIMEOUT * 4 // That way expect.poll() or expect().toPass can retry 4 times. 4x higher than default expect timeout => can retry 4 times if retryable expects are used inside