Skip to content

Commit

Permalink
feat(richtext-lexical): more powerful custom Block RSCs, improved sel…
Browse files Browse the repository at this point in the history
…ection handling (payloadcms#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.
  • Loading branch information
AlessioGr authored Nov 22, 2024
1 parent b9cc4d4 commit 9e31e17
Show file tree
Hide file tree
Showing 5 changed files with 190 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -124,6 +134,8 @@ export const BlockComponent: React.FC<Props> = (props) => {
}

setInitialState(state)
setCustomLabel(state._components?.customComponents?.BlockLabel)
setCustomBlock(state._components?.customComponents?.Block)
}
}

Expand Down Expand Up @@ -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,
})
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -453,14 +472,15 @@ 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 })
},
]}
fields={clientBlock.fields}
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(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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()
Expand All @@ -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,
})
Expand All @@ -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],
Expand Down Expand Up @@ -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(
() => () => (
Expand All @@ -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()
Expand Down Expand Up @@ -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 || {}}
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
}
106 changes: 106 additions & 0 deletions test/fields/collections/Lexical/e2e/blocks/e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
19 changes: 19 additions & 0 deletions test/fields/collections/Lexical/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down

0 comments on commit 9e31e17

Please sign in to comment.