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..934c086d103 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 @@ -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 @@ -291,10 +302,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" @@ -453,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 }) }, ]} @@ -460,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 970aa575981..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( () => () => ( @@ -300,7 +314,7 @@ export const InlineBlockComponent: React.FC = (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) => { return ( { + // This is only called when form is submitted from drawer + return await onChange({ formState, submit: true }) + }, + ]} disableValidationOnSubmit fields={clientBlock.fields} initialState={initialState || {}} 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..4f2f770b807 100644 --- a/test/fields/collections/Lexical/e2e/blocks/e2e.spec.ts +++ b/test/fields/collections/Lexical/e2e/blocks/e2e.spec.ts @@ -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() 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: {