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:
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user