fix(record-form): render rich text fields with the BlockNote editor (#25920)

## 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.
This commit is contained in:
Thomas Trompette authored and GitHub committed 2026-09-15 15:01:01 +00:00
1 parent 78443d6561
commit de26eb10ab
8 files changed
+436 -39

No files matched your search

@@ -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,
);
});
});
@@ -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([]);
});
});
@@ -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,
);
@@ -0,0 +1,39 @@
import { isDefined } from 'twenty-shared/utils';
export type FilterableBlock = {
type?: string;
children?: FilterableBlock[];
};
const filterBlockRecursively = <TBlock extends FilterableBlock>(
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 = <TBlock extends FilterableBlock>(
blocks: TBlock[] | undefined,
blockSchema: Record<string, unknown>,
): TBlock[] | undefined => {
if (!isDefined(blocks)) {
return undefined;
}
const supportedBlockTypes = Object.keys(blockSchema);
return blocks
.map((block) => filterBlockRecursively(block, supportedBlockTypes))
.filter(isDefined);
};
@@ -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) ? (
<FormRichTextFieldInput
label={field.label}
defaultValue={defaultValue as FieldRichTextValue | undefined}
onChange={onChange}
VariablePicker={VariablePicker}
readonly={readonly}
placeholder={placeholder}
/>
isDefined(VariablePicker) ? (
<FormRichTextFieldInput
label={field.label}
defaultValue={defaultValue as FieldRichTextValue | undefined}
onChange={onChange}
VariablePicker={VariablePicker}
readonly={readonly}
placeholder={placeholder}
/>
) : (
<FormRecordRichTextFieldInput
label={field.label}
defaultValue={defaultValue as FieldRichTextValue | undefined}
onChange={onChange}
readonly={readonly}
placeholder={placeholder}
/>
)
) : isFieldRelationManyToOne(field) ? (
<FormRelationToOneFieldInput
label={field.label}
@@ -0,0 +1,133 @@
import { useCreateBlockNote } from '@blocknote/react';
import { useLingui } from '@lingui/react/macro';
import { useEffect, useId, useState } from 'react';
import { isNonEmptyArray } from 'twenty-shared/utils';
import { Field } from 'twenty-ui/primitives/input';
import { BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
import { BlockEditor } from '@/blocknote-editor/components/BlockEditor';
import { BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG } from '@/blocknote-editor/constants/BlockEditorGlobalHotkeysConfig';
import { countBlocksDeep } from '@/blocknote-editor/utils/countBlocksDeep';
import { filterBlocksSupportedBySchema } from '@/blocknote-editor/utils/filterBlocksSupportedBySchema';
import { parseInitialBlocknote } from '@/blocknote-editor/utils/parseInitialBlocknote';
import { type FieldRichTextValue } from '@/object-record/record-field/ui/types/FieldMetadata';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { FormFieldInputContainer } from '@/ui/input/components/FormFieldInputContainer';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import '@blocknote/mantine/style.css';
import '@blocknote/react/style.css';
type FormRecordRichTextFieldInputProps = {
label?: string;
defaultValue: FieldRichTextValue | undefined;
onChange: (value: FieldRichTextValue) => 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<string> => {
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 (
<FormFieldInputContainer>
{label ? <Field.Label>{label}</Field.Label> : null}
<BlockEditor
editor={editor}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
readonly={readonly || hasUnreadableStoredValue}
/>
</FormFieldInputContainer>
);
};
@@ -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<typeof FormRecordRichTextFieldInput> = {
title: 'UI/Data/Field/Form/Input/FormRecordRichTextFieldInput',
component: FormRecordRichTextFieldInput,
decorators: [
ObjectMetadataItemsDecorator,
SnackBarDecorator,
ComponentDecorator,
],
parameters: {
msw: graphqlMocks,
},
};
export default meta;
type Story = StoryObj<typeof FormRecordRichTextFieldInput>;
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();
},
};
@@ -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;