From de26eb10ab9bbfc3967fe64ff9bb7ef8d4e122f0 Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Tue, 15 Sep 2026 15:01:01 +0000 Subject: [PATCH] fix(record-form): render rich text fields with the BlockNote editor (#25920) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The bug Creating a record through the creation form with a rich text field containing a bullet list — typically pasted — saves a value the record page cannot read. The field renders `Invalid Configuration` instead of the content. Console: `Error creating document from blocks passed as 'initialContent'` → `Cannot read properties of undefined (reading 'isInGroup')`, thrown in `RichTextFieldEditor` and caught by the page-layout widget error boundary. ## Why The two editors for the same field disagree on the format. The record page uses a BlockNote editor and writes BlockNote blocks. The creation form used a TipTap editor and passed its output through `convertTipTapDocumentToBlockNote`, which does not convert: it parses the TipTap document, strips the outer `doc` wrapper and returns `document.content` unchanged. So the column ends up holding TipTap nodes while every reader treats it as BlockNote: ``` seeded note (renders fine) [{"id":"block-1","type":"paragraph","props":{... created via the form (broken) [{"type":"bulletList","content":[{"type":"listItem"... ``` Plain text survives because `paragraph` exists in both schemas, which is why this went unnoticed. A bullet list does not: TipTap nests `bulletList > listItem > paragraph`, BlockNote uses flat `bulletListItem` blocks. ## The change `FormFieldInput` now routes rich text to a BlockNote editor when the caller supplies no `VariablePicker`, and keeps the TipTap editor when it does. That split matters because the component is shared. Workflow step editors pass a picker and need variables: their value lives in step settings, is read back by the same TipTap editor, and the existing stories pin an `onChange` payload containing `variableTag` nodes. Changing their format would break that round trip. The creation form and Update Multiple Records pass no picker; their value goes into a record column, so they get BlockNote and write what the record page reads. The picker is a capability switch rather than a guess about storage: only the TipTap editor can host variable tags, so any surface offering variables must use it. A RICH_TEXT field is not filterable (`FILTERABLE_FIELD_TYPES` excludes it), so the advanced-filter and role-permission panels — where the picker comes from a context that does not always supply one — cannot reach this branch. `FormRecordRichTextFieldInput` is the BlockNote input. It reuses `parseInitialBlocknote` for parsing and a new `filterBlocksSupportedByBlockSchema` — modelled on the dashboard widget's `filterSupportedBlocks` — to drop blocks the schema does not know, per block and recursing into children, with the allow-list derived from `BLOCK_SCHEMA` rather than hand-maintained. A stored TipTap value therefore mounts as an empty editor instead of throwing. It also pushes onto the focus stack on focus and pops on blur, matching the record page editor, so global single-key hotkeys do not fire while typing. ## Scope Only the rich text branch changes. Every other field type in `FormFieldInput` is untouched, as is the record page editor and the TipTap path. Update Multiple Records switches too, since it also writes a record column and passes no picker. Same bug, same fix, not separately tested here. ## Deliberately not fixed **Workflow Create / Update / Upsert Record steps still write TipTap into record columns.** They pass a `VariablePicker`, so they keep the TipTap editor, and `resolveRichTextFieldsInRecord` on the server substitutes variables inside the string and stores it verbatim. Pasting a bullet list there reproduces the same crash. Fixing it needs a real TipTap → BlockNote conversion applied after variable resolution, which is a larger change; this PR is scoped to the reported path. Records already created through the form keep their TipTap value and will keep erroring until repaired. No backfill here. ## Failing loudly instead of silently Three ways the new editor could lose or swallow something quietly, all closed: - **A stored value it cannot fully read.** The block filter drops unknown types, so a legacy TipTap value would mount as an empty editor and the first keystroke would persist the blank over it. A recursive block count before and after filtering detects any drop — a document mixing supported and unsupported blocks included, where survivors would otherwise mask the loss — and the editor goes readonly saying so, rather than letting content be overwritten by accident. - **The `markdown` fallback.** The TipTap input read `blocknote ?? markdown`; the BlockNote one now does too, instead of only `blocknote`. - **File and image blocks.** The slash menu offers File, Image, Video and Audio, and `FileBlock` calls `editor.uploadFile?.()` — with no handler supplied that silently did nothing. The record-page editor attaches uploads to an existing record, which a creation form has no id for, so the handler now says that instead and returns an empty string — the no-op path `FileBlock` already handles, since it has no try/catch and a rejection would go unhandled. ## Testing - `twenty-front` typecheck clean; oxlint on the touched modules and oxfmt across the package clean. - 62 specs pass across `blocknote-editor` and `record-field/ui/form-types`, including new unit specs for `filterBlocksSupportedBySchema` (unknown blocks, missing types, unsupported children, whole dropped subtrees, a stored TipTap document) and `countBlocksDeep`. - The `onChange` story assertion was checking `stringContaining('"type":"paragraph"')`, which TipTap output also satisfies, so it could not tell the two formats apart; it now parses the payload and asserts the BlockNote shape (`props` on the block, `styles` on the text). - Verified end to end on a branch instance: a bullet list built in the creation form is stored as `bulletListItem` blocks with `props`/`styles`/`children`, and the record page renders it instead of `Invalid Configuration`, with no `isInGroup` error in the console. - Stories cover a seeded bullet list rendering, the label, readonly, and that `onChange` emits BlockNote-shaped blocks. - The bug was reproduced before the fix: pasting a nested bullet list into the form's Body and saving produced `Invalid Configuration`, with the column holding raw TipTap JSON. - Not done: a browser pass on the fix. Both local dev slots are held by other work, so the editor's height and styling inside the side panel are unverified. --- .../utils/__tests__/countBlocksDeep.test.ts | 18 +++ .../filterBlocksSupportedBySchema.test.ts | 70 +++++++++ .../blocknote-editor/utils/countBlocksDeep.ts | 9 ++ .../utils/filterBlocksSupportedBySchema.ts | 39 +++++ .../ui/components/FormFieldInput.tsx | 28 +++- .../FormRecordRichTextFieldInput.tsx | 133 ++++++++++++++++ .../FormRecordRichTextFieldInput.stories.tsx | 142 ++++++++++++++++++ .../utils/filterSupportedBlocks.ts | 36 +---- 8 files changed, 436 insertions(+), 39 deletions(-) create mode 100644 packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/countBlocksDeep.test.ts create mode 100644 packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/filterBlocksSupportedBySchema.test.ts create mode 100644 packages/twenty-front/src/modules/blocknote-editor/utils/countBlocksDeep.ts create mode 100644 packages/twenty-front/src/modules/blocknote-editor/utils/filterBlocksSupportedBySchema.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormRecordRichTextFieldInput.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormRecordRichTextFieldInput.stories.tsx diff --git a/packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/countBlocksDeep.test.ts b/packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/countBlocksDeep.test.ts new file mode 100644 index 00000000000..d646f55ee62 --- /dev/null +++ b/packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/countBlocksDeep.test.ts @@ -0,0 +1,18 @@ +import { countBlocksDeep } from '@/blocknote-editor/utils/countBlocksDeep'; + +describe('countBlocksDeep', () => { + it('should count nothing when there is nothing', () => { + expect(countBlocksDeep(undefined)).toBe(0); + expect(countBlocksDeep([])).toBe(0); + }); + + it('should count top level blocks', () => { + expect(countBlocksDeep([{}, {}])).toBe(2); + }); + + it('should count nested children', () => { + expect(countBlocksDeep([{ children: [{}, { children: [{}] }] }, {}])).toBe( + 5, + ); + }); +}); diff --git a/packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/filterBlocksSupportedBySchema.test.ts b/packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/filterBlocksSupportedBySchema.test.ts new file mode 100644 index 00000000000..dd8ee9d8d85 --- /dev/null +++ b/packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/filterBlocksSupportedBySchema.test.ts @@ -0,0 +1,70 @@ +import { + type FilterableBlock, + filterBlocksSupportedBySchema, +} from '@/blocknote-editor/utils/filterBlocksSupportedBySchema'; + +const blockSchema = { + paragraph: {}, + bulletListItem: {}, +}; + +describe('filterBlocksSupportedBySchema', () => { + it('should return undefined when there is nothing to filter', () => { + expect( + filterBlocksSupportedBySchema(undefined, blockSchema), + ).toBeUndefined(); + }); + + it('should keep blocks the schema knows', () => { + const blocks = [{ type: 'paragraph', content: [] }]; + + expect(filterBlocksSupportedBySchema(blocks, blockSchema)).toEqual([ + { type: 'paragraph', content: [], children: undefined }, + ]); + }); + + it('should drop blocks the schema does not know', () => { + const blocks = [ + { type: 'image', props: { url: 'https://example.com/a.png' } }, + { type: 'paragraph', content: [] }, + ]; + + const filtered = filterBlocksSupportedBySchema(blocks, blockSchema); + + expect(filtered).toHaveLength(1); + expect(filtered?.[0].type).toBe('paragraph'); + }); + + it('should drop a block with no type', () => { + const blocks: FilterableBlock[] = [{}]; + + expect(filterBlocksSupportedBySchema(blocks, blockSchema)).toEqual([]); + }); + + it('should drop unsupported children while keeping their parent', () => { + const blocks = [ + { + type: 'bulletListItem', + children: [{ type: 'image' }, { type: 'paragraph' }], + }, + ]; + + const filtered = filterBlocksSupportedBySchema(blocks, blockSchema); + + expect(filtered?.[0].children).toEqual([ + { type: 'paragraph', children: undefined }, + ]); + }); + + it('should drop a whole subtree when its root is unsupported', () => { + const blocks = [{ type: 'image', children: [{ type: 'paragraph' }] }]; + + expect(filterBlocksSupportedBySchema(blocks, blockSchema)).toEqual([]); + }); + + it('should drop every block of a stored TipTap document', () => { + const blocks = [{ type: 'bulletList', content: [{ type: 'listItem' }] }]; + + expect(filterBlocksSupportedBySchema(blocks, blockSchema)).toEqual([]); + }); +}); diff --git a/packages/twenty-front/src/modules/blocknote-editor/utils/countBlocksDeep.ts b/packages/twenty-front/src/modules/blocknote-editor/utils/countBlocksDeep.ts new file mode 100644 index 00000000000..d718389c7a9 --- /dev/null +++ b/packages/twenty-front/src/modules/blocknote-editor/utils/countBlocksDeep.ts @@ -0,0 +1,9 @@ +type CountableBlock = { + children?: CountableBlock[]; +}; + +export const countBlocksDeep = (blocks: CountableBlock[] | undefined): number => + (blocks ?? []).reduce( + (total, block) => total + 1 + countBlocksDeep(block.children), + 0, + ); diff --git a/packages/twenty-front/src/modules/blocknote-editor/utils/filterBlocksSupportedBySchema.ts b/packages/twenty-front/src/modules/blocknote-editor/utils/filterBlocksSupportedBySchema.ts new file mode 100644 index 00000000000..bb0c68df146 --- /dev/null +++ b/packages/twenty-front/src/modules/blocknote-editor/utils/filterBlocksSupportedBySchema.ts @@ -0,0 +1,39 @@ +import { isDefined } from 'twenty-shared/utils'; + +export type FilterableBlock = { + type?: string; + children?: FilterableBlock[]; +}; + +const filterBlockRecursively = ( + block: TBlock, + supportedBlockTypes: string[], +): TBlock | undefined => { + if (!isDefined(block.type) || !supportedBlockTypes.includes(block.type)) { + return undefined; + } + + return { + ...block, + children: block.children + ?.map((childBlock) => + filterBlockRecursively(childBlock, supportedBlockTypes), + ) + .filter(isDefined), + }; +}; + +export const filterBlocksSupportedBySchema = ( + blocks: TBlock[] | undefined, + blockSchema: Record, +): TBlock[] | undefined => { + if (!isDefined(blocks)) { + return undefined; + } + + const supportedBlockTypes = Object.keys(blockSchema); + + return blocks + .map((block) => filterBlockRecursively(block, supportedBlockTypes)) + .filter(isDefined); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/components/FormFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/components/FormFieldInput.tsx index 8c1e73b62be..f05e8da280f 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/components/FormFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/components/FormFieldInput.tsx @@ -1,3 +1,4 @@ +import { isDefined } from 'twenty-shared/utils'; import { FormAddressFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAddressFieldInput'; import { FormArrayFieldInput } from '@/object-record/record-field/ui/form-types/components/FormArrayFieldInput'; import { FormBooleanFieldInput } from '@/object-record/record-field/ui/form-types/components/FormBooleanFieldInput'; @@ -17,6 +18,7 @@ import { FormNumberFieldInput } from '@/object-record/record-field/ui/form-types import { FormPhoneFieldInput } from '@/object-record/record-field/ui/form-types/components/FormPhoneFieldInput'; import { FormRawJsonFieldInput } from '@/object-record/record-field/ui/form-types/components/FormRawJsonFieldInput'; import { FormRelationToOneFieldInput } from '@/object-record/record-field/ui/form-types/components/FormRelationToOneFieldInput'; +import { FormRecordRichTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormRecordRichTextFieldInput'; import { FormRichTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormRichTextFieldInput'; import { FormSelectFieldInput } from '@/object-record/record-field/ui/form-types/components/FormSelectFieldInput'; import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput'; @@ -227,14 +229,24 @@ export const FormFieldInput = ({ readonly={readonly} /> ) : isFieldRichText(field) ? ( - + isDefined(VariablePicker) ? ( + + ) : ( + + ) ) : isFieldRelationManyToOne(field) ? ( void; + readonly?: boolean; + placeholder?: string; +}; + +export const FormRecordRichTextFieldInput = ({ + label, + defaultValue, + placeholder, + onChange, + readonly, +}: FormRecordRichTextFieldInputProps) => { + const { t } = useLingui(); + + const focusId = useId(); + + const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); + const { removeFocusItemFromFocusStackById } = + useRemoveFocusItemFromFocusStackById(); + + const { enqueueErrorSnackBar } = useSnackBar(); + + const [{ initialBlocks, hasUnreadableStoredValue }] = useState(() => { + const parsedBlocks = parseInitialBlocknote( + defaultValue?.blocknote ?? defaultValue?.markdown, + ); + + const supportedBlocks = filterBlocksSupportedBySchema( + parsedBlocks, + BLOCK_SCHEMA.blockSchema, + ); + + return { + initialBlocks: isNonEmptyArray(supportedBlocks) + ? supportedBlocks + : undefined, + hasUnreadableStoredValue: + countBlocksDeep(supportedBlocks) < countBlocksDeep(parsedBlocks), + }; + }); + + const handleUploadFile = async (): Promise => { + enqueueErrorSnackBar({ + message: t`Save the record before attaching a file`, + }); + + return ''; + }; + + const editor = useCreateBlockNote({ + uploadFile: handleUploadFile, + initialContent: initialBlocks, + domAttributes: { editor: { class: 'editor' } }, + schema: BLOCK_SCHEMA, + placeholders: { + default: placeholder ?? t`Type '/' for commands`, + }, + }); + + const handleChange = () => { + onChange({ + blocknote: JSON.stringify(editor.document), + markdown: null, + }); + }; + + const handleFocus = () => { + pushFocusItemToFocusStack({ + component: { + instanceId: focusId, + type: FocusComponentType.ACTIVITY_RICH_TEXT_EDITOR, + }, + focusId, + globalHotkeysConfig: BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG, + }); + }; + + const handleBlur = () => { + removeFocusItemFromFocusStackById({ focusId }); + }; + + useEffect(() => { + return () => { + removeFocusItemFromFocusStackById({ focusId }); + }; + }, [focusId, removeFocusItemFromFocusStackById]); + + useEffect(() => { + if (hasUnreadableStoredValue) { + enqueueErrorSnackBar({ + message: t`This content was saved in an older format and cannot be edited here`, + }); + } + }, [hasUnreadableStoredValue, enqueueErrorSnackBar, t]); + + return ( + + {label ? {label} : null} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormRecordRichTextFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormRecordRichTextFieldInput.stories.tsx new file mode 100644 index 00000000000..f5e1b417068 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormRecordRichTextFieldInput.stories.tsx @@ -0,0 +1,142 @@ +import { FormRecordRichTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormRecordRichTextFieldInput'; +import { type Meta, type StoryObj } from '@storybook/react-vite'; +import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; +import { ComponentDecorator } from 'twenty-ui/testing'; +import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; +import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; +import { graphqlMocks } from '~/testing/graphqlMocks'; + +const BLOCKNOTE_PARAGRAPH = JSON.stringify([ + { + id: 'block-1', + type: 'paragraph', + props: {}, + content: [{ type: 'text', text: 'Rich Text', styles: {} }], + }, +]); + +const BLOCKNOTE_BULLET_LIST = JSON.stringify([ + { + id: 'block-1', + type: 'bulletListItem', + props: {}, + content: [{ type: 'text', text: 'First item', styles: {} }], + }, + { + id: 'block-2', + type: 'bulletListItem', + props: {}, + content: [{ type: 'text', text: 'Second item', styles: {} }], + }, +]); + +const meta: Meta = { + title: 'UI/Data/Field/Form/Input/FormRecordRichTextFieldInput', + component: FormRecordRichTextFieldInput, + decorators: [ + ObjectMetadataItemsDecorator, + SnackBarDecorator, + ComponentDecorator, + ], + parameters: { + msw: graphqlMocks, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + placeholder: 'Rich Text field...', + }, +}; + +export const WithLabel: Story = { + args: { + label: 'Rich Text', + placeholder: 'Rich Text field...', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText('Rich Text'); + }, +}; + +export const WithBulletList: Story = { + args: { + defaultValue: { blocknote: BLOCKNOTE_BULLET_LIST, markdown: null }, + onChange: fn(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText('First item'); + await canvas.findByText('Second item'); + }, +}; + +export const WritesBlockNoteBlocks: Story = { + args: { + onChange: fn(), + }, + play: async ({ canvasElement, args }) => { + const editor = await waitFor(() => { + const editorElement = canvasElement.querySelector('.ProseMirror'); + + expect(editorElement).toBeVisible(); + + return editorElement; + }); + + if (!editor) { + throw new Error('Editor element not found'); + } + + await userEvent.click(editor); + await userEvent.keyboard('Hello'); + + await waitFor(() => { + expect(args.onChange).toHaveBeenCalled(); + }); + + expect(args.onChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + blocknote: expect.stringContaining('"styles"'), + markdown: null, + }), + ); + }, +}; + +export const Disabled: Story = { + args: { + defaultValue: { blocknote: BLOCKNOTE_PARAGRAPH, markdown: null }, + readonly: true, + onChange: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + const editor = await waitFor(() => { + const editorElement = canvasElement.querySelector('.ProseMirror'); + + expect(editorElement).toBeVisible(); + + return editorElement; + }); + + if (!editor) { + throw new Error('Editor element not found'); + } + + const defaultValue = await canvas.findByText('Rich Text'); + + await userEvent.type(editor, 'Hello'); + + expect(args.onChange).not.toHaveBeenCalled(); + expect(defaultValue).toBeVisible(); + }, +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/utils/filterSupportedBlocks.ts b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/utils/filterSupportedBlocks.ts index e6372a7044e..8aef210caff 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/utils/filterSupportedBlocks.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/utils/filterSupportedBlocks.ts @@ -1,39 +1,13 @@ import { type PartialBlock } from '@blocknote/core'; +import { filterBlocksSupportedBySchema } from '@/blocknote-editor/utils/filterBlocksSupportedBySchema'; import { DASHBOARD_BLOCK_SCHEMA } from '@/page-layout/widgets/standalone-rich-text/constants/DashboardBlockSchema'; -import { isDefined } from 'twenty-shared/utils'; type DashboardPartialBlock = (typeof DASHBOARD_BLOCK_SCHEMA)['PartialBlock']; -type SupportedBlockType = keyof (typeof DASHBOARD_BLOCK_SCHEMA)['blockSchema']; - -const SUPPORTED_BLOCK_TYPES = Object.keys( - DASHBOARD_BLOCK_SCHEMA.blockSchema, -) as SupportedBlockType[]; - -const isSupportedBlockType = (type: string): type is SupportedBlockType => - SUPPORTED_BLOCK_TYPES.includes(type as SupportedBlockType); - -const filterBlockRecursively = ( - block: PartialBlock, -): DashboardPartialBlock | undefined => { - if (!isDefined(block.type) || !isSupportedBlockType(block.type)) { - return undefined; - } - - return { - ...block, - children: block.children - ?.map(filterBlockRecursively) - .filter(isDefined) as DashboardPartialBlock[], - } as DashboardPartialBlock; -}; export const filterSupportedBlocks = ( blocks: PartialBlock[] | undefined, -): DashboardPartialBlock[] | undefined => { - if (!isDefined(blocks)) { - return undefined; - } - - return blocks.map(filterBlockRecursively).filter(isDefined); -}; +): DashboardPartialBlock[] | undefined => + filterBlocksSupportedBySchema(blocks, DASHBOARD_BLOCK_SCHEMA.blockSchema) as + | DashboardPartialBlock[] + | undefined;