Skip to content

Commit 9e31e17

Browse files
authored
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.
1 parent b9cc4d4 commit 9e31e17

File tree

5 files changed

+190
-16
lines changed

5 files changed

+190
-16
lines changed

packages/richtext-lexical/src/features/blocks/client/component/index.tsx

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
6767
slug: `lexical-blocks-create-${uuidFromContext}-${formData.id}`,
6868
depth: editDepth,
6969
})
70-
const { toggleDrawer } = useLexicalDrawer(drawerSlug, true)
70+
const { toggleDrawer } = useLexicalDrawer(drawerSlug)
7171

7272
// Used for saving collapsed to preferences (and gettin' it from there again)
7373
// 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) => {
9292
: false,
9393
)
9494

95+
const [CustomLabel, setCustomLabel] = React.useState<React.ReactNode | undefined>(
96+
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
97+
initialState?.['_components']?.customComponents?.BlockLabel,
98+
)
99+
100+
const [CustomBlock, setCustomBlock] = React.useState<React.ReactNode | undefined>(
101+
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
102+
initialState?.['_components']?.customComponents?.Block,
103+
)
104+
95105
// Initial state for newly created blocks
96106
useEffect(() => {
97107
const abortController = new AbortController()
@@ -124,6 +134,8 @@ export const BlockComponent: React.FC<Props> = (props) => {
124134
}
125135

126136
setInitialState(state)
137+
setCustomLabel(state._components?.customComponents?.BlockLabel)
138+
setCustomBlock(state._components?.customComponents?.Block)
127139
}
128140
}
129141

@@ -178,6 +190,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
178190
formState: prevFormState,
179191
globalSlug,
180192
operation: 'update',
193+
renderAllFields: submit ? true : false,
181194
schemaPath: schemaFieldsPath,
182195
signal: controller.signal,
183196
})
@@ -209,6 +222,9 @@ export const BlockComponent: React.FC<Props> = (props) => {
209222
}, 0)
210223

211224
if (submit) {
225+
setCustomLabel(newFormState._components?.customComponents?.BlockLabel)
226+
setCustomBlock(newFormState._components?.customComponents?.Block)
227+
212228
let rowErrorCount = 0
213229
for (const formField of Object.values(newFormState)) {
214230
if (formField?.valid === false) {
@@ -246,11 +262,6 @@ export const BlockComponent: React.FC<Props> = (props) => {
246262
})
247263
}, [editor, nodeKey])
248264

249-
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
250-
const CustomLabel = initialState?.['_components']?.customComponents?.BlockLabel
251-
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
252-
const CustomBlock = initialState?.['_components']?.customComponents?.Block
253-
254265
const blockDisplayName = clientBlock?.labels?.singular
255266
? getTranslation(clientBlock.labels.singular, i18n)
256267
: clientBlock?.slug
@@ -291,10 +302,18 @@ export const BlockComponent: React.FC<Props> = (props) => {
291302
buttonStyle="icon-label"
292303
className={`${baseClass}__editButton`}
293304
disabled={readOnly}
294-
el="div"
305+
el="button"
295306
icon="edit"
296-
onClick={() => {
307+
onClick={(e) => {
308+
e.preventDefault()
309+
e.stopPropagation()
297310
toggleDrawer()
311+
return false
312+
}}
313+
onMouseDown={(e) => {
314+
// Needed to preserve lexical selection for toggleDrawer lexical selection restore.
315+
// I believe this is needed due to this button (usually) being inside of a collapsible.
316+
e.preventDefault()
298317
}}
299318
round
300319
size="small"
@@ -453,14 +472,15 @@ export const BlockComponent: React.FC<Props> = (props) => {
453472
<Form
454473
beforeSubmit={[
455474
async ({ formState }) => {
475+
// This is only called when form is submitted from drawer - usually only the case if the block has a custom Block component
456476
return await onChange({ formState, submit: true })
457477
},
458478
]}
459479
fields={clientBlock.fields}
460480
initialState={initialState}
461481
onChange={[onChange]}
462482
onSubmit={(formState) => {
463-
// THis is only called when form is submitted from drawer - usually only the case if the block has a custom Block component
483+
// This is only called when form is submitted from drawer - usually only the case if the block has a custom Block component
464484
const newData: any = reduceFieldsToValues(formState)
465485
newData.blockType = formData.blockType
466486
editor.update(() => {

packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,16 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
8686
initialLexicalFormState?.[formData.id]?.formState,
8787
)
8888

89+
const [CustomLabel, setCustomLabel] = React.useState<React.ReactNode | undefined>(
90+
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
91+
initialState?.['_components']?.customComponents?.BlockLabel,
92+
)
93+
94+
const [CustomBlock, setCustomBlock] = React.useState<React.ReactNode | undefined>(
95+
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
96+
initialState?.['_components']?.customComponents?.Block,
97+
)
98+
8999
const drawerSlug = formatDrawerSlug({
90100
slug: `lexical-inlineBlocks-create-` + uuidFromContext,
91101
depth: editDepth,
@@ -194,6 +204,8 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
194204

195205
if (state) {
196206
setInitialState(state)
207+
setCustomLabel(state['_components']?.customComponents?.BlockLabel)
208+
setCustomBlock(state['_components']?.customComponents?.Block)
197209
}
198210
}
199211

@@ -219,7 +231,7 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
219231
* HANDLE ONCHANGE
220232
*/
221233
const onChange = useCallback(
222-
async ({ formState: prevFormState }: { formState: FormState }) => {
234+
async ({ formState: prevFormState, submit }: { formState: FormState; submit?: boolean }) => {
223235
abortAndIgnore(onChangeAbortControllerRef.current)
224236

225237
const controller = new AbortController()
@@ -235,6 +247,7 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
235247
formState: prevFormState,
236248
globalSlug,
237249
operation: 'update',
250+
renderAllFields: submit ? true : false,
238251
schemaPath: schemaFieldsPath,
239252
signal: controller.signal,
240253
})
@@ -243,6 +256,11 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
243256
return prevFormState
244257
}
245258

259+
if (submit) {
260+
setCustomLabel(state['_components']?.customComponents?.BlockLabel)
261+
setCustomBlock(state['_components']?.customComponents?.Block)
262+
}
263+
246264
return state
247265
},
248266
[getFormState, id, collectionSlug, getDocPreferences, globalSlug, schemaFieldsPath],
@@ -270,10 +288,6 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
270288
},
271289
[editor, nodeKey, formData],
272290
)
273-
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
274-
const CustomLabel = initialState?.['_components']?.customComponents?.BlockLabel
275-
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
276-
const CustomBlock = initialState?.['_components']?.customComponents?.Block
277291

278292
const RemoveButton = useMemo(
279293
() => () => (
@@ -300,7 +314,7 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
300314
buttonStyle="icon-label"
301315
className={`${baseClass}__editButton`}
302316
disabled={readOnly}
303-
el="div"
317+
el="button"
304318
icon="edit"
305319
onClick={() => {
306320
toggleDrawer()
@@ -342,7 +356,12 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
342356

343357
return (
344358
<Form
345-
beforeSubmit={[onChange]}
359+
beforeSubmit={[
360+
async ({ formState }) => {
361+
// This is only called when form is submitted from drawer
362+
return await onChange({ formState, submit: true })
363+
},
364+
]}
346365
disableValidationOnSubmit
347366
fields={clientBlock.fields}
348367
initialState={initialState || {}}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { BlocksFieldServerComponent } from 'payload'
2+
3+
import { BlockCollapsible } from '@payloadcms/richtext-lexical/client'
4+
import React from 'react'
5+
6+
export const BlockComponentRSC: BlocksFieldServerComponent = (props) => {
7+
const { data } = props
8+
9+
return <BlockCollapsible>Data: {data?.key ?? ''}</BlockCollapsible>
10+
}

test/fields/collections/Lexical/e2e/blocks/e2e.spec.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,112 @@ describe('lexicalBlocks', () => {
9898
await client.login()
9999
})
100100

101+
test('ensure block with custom Block RSC can be created, updates data when saving edit fields drawer, and maintains cursor position', async () => {
102+
await navigateToLexicalFields()
103+
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
104+
await richTextField.scrollIntoViewIfNeeded()
105+
await expect(richTextField).toBeVisible()
106+
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
107+
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
108+
109+
const lastParagraph = richTextField.locator('p').last()
110+
await lastParagraph.scrollIntoViewIfNeeded()
111+
await expect(lastParagraph).toBeVisible()
112+
113+
await lastParagraph.click()
114+
await page.keyboard.press('1')
115+
await page.keyboard.press('2')
116+
await page.keyboard.press('3')
117+
118+
await page.keyboard.press('Enter')
119+
await page.keyboard.press('/')
120+
await page.keyboard.type('RSC')
121+
122+
// CreateBlock
123+
const slashMenuPopover = page.locator('#slash-menu .slash-menu-popup')
124+
await expect(slashMenuPopover).toBeVisible()
125+
126+
// Click 1. Button and ensure it's the RSC block creation button (it should be! Otherwise, sorting wouldn't work)
127+
const rscBlockSelectButton = slashMenuPopover.locator('button').first()
128+
await expect(rscBlockSelectButton).toBeVisible()
129+
await expect(rscBlockSelectButton).toContainText('Block R S C')
130+
await rscBlockSelectButton.click()
131+
await expect(slashMenuPopover).toBeHidden()
132+
133+
const newRSCBlock = richTextField
134+
.locator('.lexical-block:not(.lexical-block .lexical-block)')
135+
.nth(8) // The :not(.lexical-block .lexical-block) makes sure this does not select sub-blocks
136+
await newRSCBlock.scrollIntoViewIfNeeded()
137+
await expect(newRSCBlock.locator('.collapsible__content')).toHaveText('Data:')
138+
139+
// Select paragraph with text "123"
140+
// Now double-click to select entire line
141+
await richTextField.locator('p').getByText('123').first().click({ clickCount: 2 })
142+
143+
const editButton = newRSCBlock.locator('.lexical-block__editButton').first()
144+
await editButton.click()
145+
146+
await wait(500)
147+
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)
148+
await expect(editDrawer).toBeVisible()
149+
await wait(500)
150+
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
151+
152+
await editDrawer.locator('.rs__control .value-container').first().click()
153+
await wait(500)
154+
await expect(editDrawer.locator('.rs__option').nth(1)).toBeVisible()
155+
await expect(editDrawer.locator('.rs__option').nth(1)).toContainText('value2')
156+
await editDrawer.locator('.rs__option').nth(1).click()
157+
158+
// Click button with text Save changes
159+
await editDrawer.locator('button').getByText('Save changes').click()
160+
await expect(editDrawer).toBeHidden()
161+
162+
await expect(newRSCBlock.locator('.collapsible__content')).toHaveText('Data: value2')
163+
164+
// press ctrl+B to bold the text previously selected (assuming it is still selected now, which it should be)
165+
await page.keyboard.press('Meta+B')
166+
// In case this is mac or windows
167+
await page.keyboard.press('Control+B')
168+
169+
await wait(300)
170+
171+
// save document and assert
172+
await saveDocAndAssert(page)
173+
await wait(300)
174+
await expect(newRSCBlock.locator('.collapsible__content')).toHaveText('Data: value2')
175+
176+
// Check if the API result is correct
177+
178+
// TODO:
179+
await expect(async () => {
180+
const lexicalDoc: LexicalField = (
181+
await payload.find({
182+
collection: lexicalFieldsSlug,
183+
depth: 0,
184+
overrideAccess: true,
185+
where: {
186+
title: {
187+
equals: lexicalDocData.title,
188+
},
189+
},
190+
})
191+
).docs[0] as never
192+
193+
const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks
194+
const rscBlock: SerializedBlockNode = lexicalField.root.children[14] as SerializedBlockNode
195+
const paragraphBlock: SerializedBlockNode = lexicalField.root
196+
.children[12] as SerializedBlockNode
197+
198+
expect(rscBlock.fields.blockType).toBe('BlockRSC')
199+
expect(rscBlock.fields.key).toBe('value2')
200+
expect((paragraphBlock.children[0] as SerializedTextNode).text).toBe('123')
201+
expect((paragraphBlock.children[0] as SerializedTextNode).format).toBe(1)
202+
}).toPass({
203+
timeout: POLL_TOPASS_TIMEOUT,
204+
})
205+
})
206+
101207
describe('nested lexical editor in block', () => {
102208
test('should type and save typed text', async () => {
103209
await navigateToLexicalFields()

test/fields/collections/Lexical/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,25 @@ const editorConfig: ServerEditorConfig = {
134134
},
135135
],
136136
},
137+
{
138+
slug: 'BlockRSC',
139+
140+
admin: {
141+
components: {
142+
Block: '/collections/Lexical/blockComponents/BlockComponentRSC.js#BlockComponentRSC',
143+
},
144+
},
145+
fields: [
146+
{
147+
name: 'key',
148+
label: () => {
149+
return 'Key'
150+
},
151+
type: 'select',
152+
options: ['value1', 'value2', 'value3'],
153+
},
154+
],
155+
},
137156
{
138157
slug: 'myBlockWithBlockAndLabel',
139158
admin: {

0 commit comments

Comments
 (0)