feat(richtext-lexical): more powerful custom Block RSCs, improved selection handling (#9422)
Now, custom Lexical block & inline block components are re-rendered if the fields drawer is saved. This ensures that RSCs receive the updated values, without having to resort to a client component that utilizes the `useForm` hook. Additionally, this PRs fixes the lexical selection jumping around after opening a Block or InlineBlock drawer and clicking inside of it.
This commit is contained in:
@@ -67,7 +67,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
|
||||
slug: `lexical-blocks-create-${uuidFromContext}-${formData.id}`,
|
||||
depth: editDepth,
|
||||
})
|
||||
const { toggleDrawer } = useLexicalDrawer(drawerSlug, true)
|
||||
const { toggleDrawer } = useLexicalDrawer(drawerSlug)
|
||||
|
||||
// Used for saving collapsed to preferences (and gettin' it from there again)
|
||||
// Remember, these preferences are scoped to the whole document, not just this form. This
|
||||
@@ -92,6 +92,16 @@ export const BlockComponent: React.FC<Props> = (props) => {
|
||||
: false,
|
||||
)
|
||||
|
||||
const [CustomLabel, setCustomLabel] = React.useState<React.ReactNode | undefined>(
|
||||
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
|
||||
initialState?.['_components']?.customComponents?.BlockLabel,
|
||||
)
|
||||
|
||||
const [CustomBlock, setCustomBlock] = React.useState<React.ReactNode | undefined>(
|
||||
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
|
||||
initialState?.['_components']?.customComponents?.Block,
|
||||
)
|
||||
|
||||
// Initial state for newly created blocks
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController()
|
||||
@@ -124,6 +134,8 @@ export const BlockComponent: React.FC<Props> = (props) => {
|
||||
}
|
||||
|
||||
setInitialState(state)
|
||||
setCustomLabel(state._components?.customComponents?.BlockLabel)
|
||||
setCustomBlock(state._components?.customComponents?.Block)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +190,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
|
||||
formState: prevFormState,
|
||||
globalSlug,
|
||||
operation: 'update',
|
||||
renderAllFields: submit ? true : false,
|
||||
schemaPath: schemaFieldsPath,
|
||||
signal: controller.signal,
|
||||
})
|
||||
@@ -209,6 +222,9 @@ export const BlockComponent: React.FC<Props> = (props) => {
|
||||
}, 0)
|
||||
|
||||
if (submit) {
|
||||
setCustomLabel(newFormState._components?.customComponents?.BlockLabel)
|
||||
setCustomBlock(newFormState._components?.customComponents?.Block)
|
||||
|
||||
let rowErrorCount = 0
|
||||
for (const formField of Object.values(newFormState)) {
|
||||
if (formField?.valid === false) {
|
||||
@@ -246,11 +262,6 @@ export const BlockComponent: React.FC<Props> = (props) => {
|
||||
})
|
||||
}, [editor, nodeKey])
|
||||
|
||||
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
|
||||
const CustomLabel = initialState?.['_components']?.customComponents?.BlockLabel
|
||||
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
|
||||
const CustomBlock = initialState?.['_components']?.customComponents?.Block
|
||||
|
||||
const blockDisplayName = clientBlock?.labels?.singular
|
||||
? getTranslation(clientBlock.labels.singular, i18n)
|
||||
: clientBlock?.slug
|
||||
@@ -291,10 +302,18 @@ export const BlockComponent: React.FC<Props> = (props) => {
|
||||
buttonStyle="icon-label"
|
||||
className={`${baseClass}__editButton`}
|
||||
disabled={readOnly}
|
||||
el="div"
|
||||
el="button"
|
||||
icon="edit"
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
toggleDrawer()
|
||||
return false
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
// Needed to preserve lexical selection for toggleDrawer lexical selection restore.
|
||||
// I believe this is needed due to this button (usually) being inside of a collapsible.
|
||||
e.preventDefault()
|
||||
}}
|
||||
round
|
||||
size="small"
|
||||
@@ -453,6 +472,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
|
||||
<Form
|
||||
beforeSubmit={[
|
||||
async ({ formState }) => {
|
||||
// This is only called when form is submitted from drawer - usually only the case if the block has a custom Block component
|
||||
return await onChange({ formState, submit: true })
|
||||
},
|
||||
]}
|
||||
@@ -460,7 +480,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
|
||||
initialState={initialState}
|
||||
onChange={[onChange]}
|
||||
onSubmit={(formState) => {
|
||||
// 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
|
||||
editor.update(() => {
|
||||
|
||||
@@ -86,6 +86,16 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
|
||||
initialLexicalFormState?.[formData.id]?.formState,
|
||||
)
|
||||
|
||||
const [CustomLabel, setCustomLabel] = React.useState<React.ReactNode | undefined>(
|
||||
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
|
||||
initialState?.['_components']?.customComponents?.BlockLabel,
|
||||
)
|
||||
|
||||
const [CustomBlock, setCustomBlock] = React.useState<React.ReactNode | undefined>(
|
||||
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
|
||||
initialState?.['_components']?.customComponents?.Block,
|
||||
)
|
||||
|
||||
const drawerSlug = formatDrawerSlug({
|
||||
slug: `lexical-inlineBlocks-create-` + uuidFromContext,
|
||||
depth: editDepth,
|
||||
@@ -194,6 +204,8 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
|
||||
|
||||
if (state) {
|
||||
setInitialState(state)
|
||||
setCustomLabel(state['_components']?.customComponents?.BlockLabel)
|
||||
setCustomBlock(state['_components']?.customComponents?.Block)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,7 +231,7 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
|
||||
* HANDLE ONCHANGE
|
||||
*/
|
||||
const onChange = useCallback(
|
||||
async ({ formState: prevFormState }: { formState: FormState }) => {
|
||||
async ({ formState: prevFormState, submit }: { formState: FormState; submit?: boolean }) => {
|
||||
abortAndIgnore(onChangeAbortControllerRef.current)
|
||||
|
||||
const controller = new AbortController()
|
||||
@@ -235,6 +247,7 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
|
||||
formState: prevFormState,
|
||||
globalSlug,
|
||||
operation: 'update',
|
||||
renderAllFields: submit ? true : false,
|
||||
schemaPath: schemaFieldsPath,
|
||||
signal: controller.signal,
|
||||
})
|
||||
@@ -243,6 +256,11 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
|
||||
return prevFormState
|
||||
}
|
||||
|
||||
if (submit) {
|
||||
setCustomLabel(state['_components']?.customComponents?.BlockLabel)
|
||||
setCustomBlock(state['_components']?.customComponents?.Block)
|
||||
}
|
||||
|
||||
return state
|
||||
},
|
||||
[getFormState, id, collectionSlug, getDocPreferences, globalSlug, schemaFieldsPath],
|
||||
@@ -270,10 +288,6 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
|
||||
},
|
||||
[editor, nodeKey, formData],
|
||||
)
|
||||
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
|
||||
const CustomLabel = initialState?.['_components']?.customComponents?.BlockLabel
|
||||
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
|
||||
const CustomBlock = initialState?.['_components']?.customComponents?.Block
|
||||
|
||||
const RemoveButton = useMemo(
|
||||
() => () => (
|
||||
@@ -300,7 +314,7 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
|
||||
buttonStyle="icon-label"
|
||||
className={`${baseClass}__editButton`}
|
||||
disabled={readOnly}
|
||||
el="div"
|
||||
el="button"
|
||||
icon="edit"
|
||||
onClick={() => {
|
||||
toggleDrawer()
|
||||
@@ -342,7 +356,12 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
|
||||
|
||||
return (
|
||||
<Form
|
||||
beforeSubmit={[onChange]}
|
||||
beforeSubmit={[
|
||||
async ({ formState }) => {
|
||||
// This is only called when form is submitted from drawer
|
||||
return await onChange({ formState, submit: true })
|
||||
},
|
||||
]}
|
||||
disableValidationOnSubmit
|
||||
fields={clientBlock.fields}
|
||||
initialState={initialState || {}}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { BlocksFieldServerComponent } from 'payload'
|
||||
|
||||
import { BlockCollapsible } from '@payloadcms/richtext-lexical/client'
|
||||
import React from 'react'
|
||||
|
||||
export const BlockComponentRSC: BlocksFieldServerComponent = (props) => {
|
||||
const { data } = props
|
||||
|
||||
return <BlockCollapsible>Data: {data?.key ?? ''}</BlockCollapsible>
|
||||
}
|
||||
@@ -98,6 +98,112 @@ describe('lexicalBlocks', () => {
|
||||
await client.login()
|
||||
})
|
||||
|
||||
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 = 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()
|
||||
|
||||
await lastParagraph.click()
|
||||
await page.keyboard.press('1')
|
||||
await page.keyboard.press('2')
|
||||
await page.keyboard.press('3')
|
||||
|
||||
await page.keyboard.press('Enter')
|
||||
await page.keyboard.press('/')
|
||||
await page.keyboard.type('RSC')
|
||||
|
||||
// CreateBlock
|
||||
const slashMenuPopover = page.locator('#slash-menu .slash-menu-popup')
|
||||
await expect(slashMenuPopover).toBeVisible()
|
||||
|
||||
// Click 1. Button and ensure it's the RSC block creation button (it should be! Otherwise, sorting wouldn't work)
|
||||
const rscBlockSelectButton = slashMenuPopover.locator('button').first()
|
||||
await expect(rscBlockSelectButton).toBeVisible()
|
||||
await expect(rscBlockSelectButton).toContainText('Block R S C')
|
||||
await rscBlockSelectButton.click()
|
||||
await expect(slashMenuPopover).toBeHidden()
|
||||
|
||||
const newRSCBlock = 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 newRSCBlock.scrollIntoViewIfNeeded()
|
||||
await expect(newRSCBlock.locator('.collapsible__content')).toHaveText('Data:')
|
||||
|
||||
// Select paragraph with text "123"
|
||||
// Now double-click to select entire line
|
||||
await richTextField.locator('p').getByText('123').first().click({ clickCount: 2 })
|
||||
|
||||
const editButton = newRSCBlock.locator('.lexical-block__editButton').first()
|
||||
await editButton.click()
|
||||
|
||||
await wait(500)
|
||||
const editDrawer = page.locator('dialog[id^=drawer_1_lexical-blocks-create-]').first() // IDs starting with list-drawer_1_ (there's some other symbol after the underscore)
|
||||
await expect(editDrawer).toBeVisible()
|
||||
await wait(500)
|
||||
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
|
||||
|
||||
await editDrawer.locator('.rs__control .value-container').first().click()
|
||||
await wait(500)
|
||||
await expect(editDrawer.locator('.rs__option').nth(1)).toBeVisible()
|
||||
await expect(editDrawer.locator('.rs__option').nth(1)).toContainText('value2')
|
||||
await editDrawer.locator('.rs__option').nth(1).click()
|
||||
|
||||
// Click button with text Save changes
|
||||
await editDrawer.locator('button').getByText('Save changes').click()
|
||||
await expect(editDrawer).toBeHidden()
|
||||
|
||||
await expect(newRSCBlock.locator('.collapsible__content')).toHaveText('Data: value2')
|
||||
|
||||
// press ctrl+B to bold the text previously selected (assuming it is still selected now, which it should be)
|
||||
await page.keyboard.press('Meta+B')
|
||||
// In case this is mac or windows
|
||||
await page.keyboard.press('Control+B')
|
||||
|
||||
await wait(300)
|
||||
|
||||
// save document and assert
|
||||
await saveDocAndAssert(page)
|
||||
await wait(300)
|
||||
await expect(newRSCBlock.locator('.collapsible__content')).toHaveText('Data: value2')
|
||||
|
||||
// Check if the API result is correct
|
||||
|
||||
// TODO:
|
||||
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 rscBlock: SerializedBlockNode = lexicalField.root.children[14] as SerializedBlockNode
|
||||
const paragraphBlock: SerializedBlockNode = lexicalField.root
|
||||
.children[12] as SerializedBlockNode
|
||||
|
||||
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)
|
||||
}).toPass({
|
||||
timeout: POLL_TOPASS_TIMEOUT,
|
||||
})
|
||||
})
|
||||
|
||||
describe('nested lexical editor in block', () => {
|
||||
test('should type and save typed text', async () => {
|
||||
await navigateToLexicalFields()
|
||||
|
||||
@@ -134,6 +134,25 @@ const editorConfig: ServerEditorConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'BlockRSC',
|
||||
|
||||
admin: {
|
||||
components: {
|
||||
Block: '/collections/Lexical/blockComponents/BlockComponentRSC.js#BlockComponentRSC',
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'key',
|
||||
label: () => {
|
||||
return 'Key'
|
||||
},
|
||||
type: 'select',
|
||||
options: ['value1', 'value2', 'value3'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'myBlockWithBlockAndLabel',
|
||||
admin: {
|
||||
|
||||
Reference in New Issue
Block a user