From a4c1e2a4e88b5c5562414995c708961752052e2f Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Thu, 21 Nov 2024 18:25:34 -0700 Subject: [PATCH 1/4] fix(richtext-lexical): ensure block and inline block drawers preserve lexical selection --- .../src/features/blocks/client/component/index.tsx | 14 +++++++++++--- .../blocks/client/componentInline/index.tsx | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/richtext-lexical/src/features/blocks/client/component/index.tsx b/packages/richtext-lexical/src/features/blocks/client/component/index.tsx index fda48f9dd21..68ef538a05d 100644 --- a/packages/richtext-lexical/src/features/blocks/client/component/index.tsx +++ b/packages/richtext-lexical/src/features/blocks/client/component/index.tsx @@ -67,7 +67,7 @@ export const BlockComponent: React.FC = (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 @@ -291,10 +291,18 @@ export const BlockComponent: React.FC = (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" diff --git a/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx b/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx index 970aa575981..af17acf3960 100644 --- a/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx +++ b/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx @@ -300,7 +300,7 @@ export const InlineBlockComponent: React.FC = (props) => { buttonStyle="icon-label" className={`${baseClass}__editButton`} disabled={readOnly} - el="div" + el="button" icon="edit" onClick={() => { toggleDrawer() From 8f8a10a9e16b2373b7a90be71722719ea6a33eb6 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Thu, 21 Nov 2024 18:37:42 -0700 Subject: [PATCH 2/4] feat(richtext-lexical): re-render custom block and inlineblock components on submit from drawer, making RSCs more powerful, as they will now receive updated data --- .../blocks/client/component/index.tsx | 24 ++++++++++---- .../blocks/client/componentInline/index.tsx | 31 +++++++++++++++---- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/packages/richtext-lexical/src/features/blocks/client/component/index.tsx b/packages/richtext-lexical/src/features/blocks/client/component/index.tsx index 68ef538a05d..934c086d103 100644 --- a/packages/richtext-lexical/src/features/blocks/client/component/index.tsx +++ b/packages/richtext-lexical/src/features/blocks/client/component/index.tsx @@ -92,6 +92,16 @@ export const BlockComponent: React.FC = (props) => { : false, ) + const [CustomLabel, setCustomLabel] = React.useState( + // @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve + initialState?.['_components']?.customComponents?.BlockLabel, + ) + + const [CustomBlock, setCustomBlock] = React.useState( + // @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) => { } setInitialState(state) + setCustomLabel(state._components?.customComponents?.BlockLabel) + setCustomBlock(state._components?.customComponents?.Block) } } @@ -178,6 +190,7 @@ export const BlockComponent: React.FC = (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) => { }, 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) => { }) }, [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 @@ -461,6 +472,7 @@ export const BlockComponent: React.FC = (props) => {
{ + // 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 }) }, ]} @@ -468,7 +480,7 @@ export const BlockComponent: React.FC = (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(() => { diff --git a/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx b/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx index af17acf3960..749d5ff5a02 100644 --- a/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx +++ b/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx @@ -86,6 +86,16 @@ export const InlineBlockComponent: React.FC = (props) => { initialLexicalFormState?.[formData.id]?.formState, ) + const [CustomLabel, setCustomLabel] = React.useState( + // @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve + initialState?.['_components']?.customComponents?.BlockLabel, + ) + + const [CustomBlock, setCustomBlock] = React.useState( + // @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) => { if (state) { setInitialState(state) + setCustomLabel(state['_components']?.customComponents?.BlockLabel) + setCustomBlock(state['_components']?.customComponents?.Block) } } @@ -219,7 +231,7 @@ export const InlineBlockComponent: React.FC = (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) => { formState: prevFormState, globalSlug, operation: 'update', + renderAllFields: submit ? true : false, schemaPath: schemaFieldsPath, signal: controller.signal, }) @@ -243,6 +256,11 @@ export const InlineBlockComponent: React.FC = (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) => { }, [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( () => () => ( @@ -342,7 +356,12 @@ export const InlineBlockComponent: React.FC = (props) => { return ( { + // This is only called when form is submitted from drawer + return await onChange({ formState, submit: true }) + }, + ]} disableValidationOnSubmit fields={clientBlock.fields} initialState={initialState || {}} From d807512f505e1915f95f4da5c56a61b0a7eaa8b6 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Thu, 21 Nov 2024 18:56:05 -0700 Subject: [PATCH 3/4] add e2e test --- .../blockComponents/BlockComponentRSC.tsx | 10 ++ .../Lexical/e2e/blocks/e2e.spec.ts | 110 ++++++++++++++++++ test/fields/collections/Lexical/index.ts | 19 +++ 3 files changed, 139 insertions(+) create mode 100644 test/fields/collections/Lexical/blockComponents/BlockComponentRSC.tsx diff --git a/test/fields/collections/Lexical/blockComponents/BlockComponentRSC.tsx b/test/fields/collections/Lexical/blockComponents/BlockComponentRSC.tsx new file mode 100644 index 00000000000..5fe32cdc044 --- /dev/null +++ b/test/fields/collections/Lexical/blockComponents/BlockComponentRSC.tsx @@ -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 Data: {data?.key ?? ''} +} diff --git a/test/fields/collections/Lexical/e2e/blocks/e2e.spec.ts b/test/fields/collections/Lexical/e2e/blocks/e2e.spec.ts index 7d98c0d4662..f66f8b9f6ae 100644 --- a/test/fields/collections/Lexical/e2e/blocks/e2e.spec.ts +++ b/test/fields/collections/Lexical/e2e/blocks/e2e.spec.ts @@ -98,6 +98,116 @@ 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 "testtext" + await richTextField.locator('p').getByText('123').first().click() + await page.keyboard.press('Shift+ArrowLeft') + await page.keyboard.press('Shift+ArrowLeft') + await page.keyboard.press('Shift+ArrowLeft') + + 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('12') + expect((paragraphBlock.children[0] as SerializedTextNode).format).toBe(1) + expect((paragraphBlock.children[1] as SerializedTextNode).text).toBe('3') + expect((paragraphBlock.children[1] as SerializedTextNode).format).toBe(0) + }).toPass({ + timeout: POLL_TOPASS_TIMEOUT, + }) + }) + describe('nested lexical editor in block', () => { test('should type and save typed text', async () => { await navigateToLexicalFields() diff --git a/test/fields/collections/Lexical/index.ts b/test/fields/collections/Lexical/index.ts index 9b5bed913af..223b484cfed 100644 --- a/test/fields/collections/Lexical/index.ts +++ b/test/fields/collections/Lexical/index.ts @@ -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: { From a692ac39ebdfd1897876baa71f2b1de5a9391ba6 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Thu, 21 Nov 2024 19:21:16 -0700 Subject: [PATCH 4/4] more resilient test --- .../collections/Lexical/e2e/blocks/e2e.spec.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/test/fields/collections/Lexical/e2e/blocks/e2e.spec.ts b/test/fields/collections/Lexical/e2e/blocks/e2e.spec.ts index f66f8b9f6ae..4f2f770b807 100644 --- a/test/fields/collections/Lexical/e2e/blocks/e2e.spec.ts +++ b/test/fields/collections/Lexical/e2e/blocks/e2e.spec.ts @@ -136,11 +136,9 @@ describe('lexicalBlocks', () => { await newRSCBlock.scrollIntoViewIfNeeded() await expect(newRSCBlock.locator('.collapsible__content')).toHaveText('Data:') - // Select paragraph with text "testtext" - await richTextField.locator('p').getByText('123').first().click() - await page.keyboard.press('Shift+ArrowLeft') - await page.keyboard.press('Shift+ArrowLeft') - await page.keyboard.press('Shift+ArrowLeft') + // 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() @@ -199,10 +197,8 @@ describe('lexicalBlocks', () => { expect(rscBlock.fields.blockType).toBe('BlockRSC') expect(rscBlock.fields.key).toBe('value2') - expect((paragraphBlock.children[0] as SerializedTextNode).text).toBe('12') + expect((paragraphBlock.children[0] as SerializedTextNode).text).toBe('123') expect((paragraphBlock.children[0] as SerializedTextNode).format).toBe(1) - expect((paragraphBlock.children[1] as SerializedTextNode).text).toBe('3') - expect((paragraphBlock.children[1] as SerializedTextNode).format).toBe(0) }).toPass({ timeout: POLL_TOPASS_TIMEOUT, })