## 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.
The #1 Open-Source CRM
Website ·
Documentation ·
Roadmap ·
Discord ·
Figma
Why Twenty
Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves. Twenty is the CRM you build, ship, and version like the rest of your stack.
Learn more about why we built Twenty
Installation
Cloud
The fastest way to get started. Sign up at twenty.com and spin up a workspace in under a minute, with no infrastructure to manage and always up to date.
Build an app
Scaffold a new app with the Twenty CLI:
npx create-twenty-app my-app
Define objects, fields, and views as code:
import { defineObject, FieldType } from 'twenty-sdk/define';
export default defineObject({
nameSingular: 'deal',
namePlural: 'deals',
labelSingular: 'Deal',
labelPlural: 'Deals',
fields: [
{ name: 'name', label: 'Name', type: FieldType.TEXT },
{ name: 'amount', label: 'Amount', type: FieldType.CURRENCY },
{ name: 'closeDate', label: 'Close Date', type: FieldType.DATE_TIME },
],
});
Then ship it to your workspace:
npx twenty app:publish --private
See the app development guide for objects, views, agents, and logic functions.
Self-hosting
Run Twenty on your own infrastructure with Docker Compose, or contribute locally via the local setup guide.
Everything you need
Twenty gives you the building blocks of a modern CRM (objects, views, workflows, and agents) and lets you extend them as code. Here's a tour of what's in the box.
Want to go deeper? Read the User Guide for product walkthroughs, or the
Documentation for developer reference.
|
|
|
|
|
|
Stack
TypeScript
Nx
NestJS, with BullMQ,
PostgreSQL,
Redis
React, with Jotai, Linaria and Lingui
Thanks
Thanks to these amazing services that we use and recommend for code review (Greptile), catching bugs (Sentry) and translating (Crowdin).
Join the Community
Star the repo ·
Discord ·
Feature requests ·
Releases ·
X ·
LinkedIn ·
Crowdin ·
Contribute





