From be3ceca0a37f593301f700dd4652ff71391751de Mon Sep 17 00:00:00 2001
From: Harshit Singh <73997189+harshit078@users.noreply.github.com>
Date: Wed, 29 Oct 2025 20:31:54 +0530
Subject: [PATCH] Add listkit to tiptap extension in workflow node (#15363)
## Description
- This PR address issue -
https://github.com/twentyhq/core-team-issues/issues/1768
- Added listkit bundle from tiptap which includes BulletList,
orderedList, ListItem and ListKeymap in one single import
- This bundle also includes keyboards shortcuts - `Cmd + Shift + 7` and
`Cmd + Shift + 8` for ordered and bullet list
## Visual Appearance
https://github.com/user-attachments/assets/7eff1233-8503-4854-bad2-2521898bc568
## Why this Approach
- our current version of tiptap is 3.4.2 while the latest is 3.8.0 hence
installing these versions manually would install the latest version of
3.8.0. The issue when downgrading to 3.4.2 was that Version 3.8.0 of
`@tiptap/extension-list` requires `renderNestedMarkdownContent` from
@tiptap/core
but our `@tiptap/core` version 3.4.2 doesn't export this function.
---
.../email-renderer/nodes/bullet-list.tsx | 16 ++
.../utils/email-renderer/nodes/list-item.tsx | 16 ++
.../email-renderer/nodes/ordered-list.tsx | 16 ++
.../email-renderer/renderers/render-node.tsx | 6 +
packages/twenty-front/package.json | 1 +
.../components/AdvancedTextEditor.tsx | 5 +
.../components/TextBubbleMenu.tsx | 12 ++
.../AdvancedTextEditor.stories.tsx | 147 +++++++++++++++++-
.../hooks/useAdvancedTextEditor.ts | 2 +
.../hooks/useTextBubbleState.ts | 2 +
.../src/utils/tiptap/tiptap-marks.ts | 3 +
yarn.lock | 11 ++
12 files changed, 236 insertions(+), 1 deletion(-)
create mode 100644 packages/twenty-emails/src/utils/email-renderer/nodes/bullet-list.tsx
create mode 100644 packages/twenty-emails/src/utils/email-renderer/nodes/list-item.tsx
create mode 100644 packages/twenty-emails/src/utils/email-renderer/nodes/ordered-list.tsx
diff --git a/packages/twenty-emails/src/utils/email-renderer/nodes/bullet-list.tsx b/packages/twenty-emails/src/utils/email-renderer/nodes/bullet-list.tsx
new file mode 100644
index 0000000000..a1dcf88579
--- /dev/null
+++ b/packages/twenty-emails/src/utils/email-renderer/nodes/bullet-list.tsx
@@ -0,0 +1,16 @@
+import { type JSONContent } from '@tiptap/core';
+import { type ReactNode } from 'react';
+import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
+
+export const bulletList = (node: JSONContent): ReactNode => {
+ return (
+
+ {mappedNodeContent(node)}
+
+ );
+};
diff --git a/packages/twenty-emails/src/utils/email-renderer/nodes/list-item.tsx b/packages/twenty-emails/src/utils/email-renderer/nodes/list-item.tsx
new file mode 100644
index 0000000000..1eeace2566
--- /dev/null
+++ b/packages/twenty-emails/src/utils/email-renderer/nodes/list-item.tsx
@@ -0,0 +1,16 @@
+import { type JSONContent } from '@tiptap/core';
+import { type ReactNode } from 'react';
+import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
+
+export const listItem = (node: JSONContent): ReactNode => {
+ return (
+
+ {mappedNodeContent(node)}
+
+ );
+};
diff --git a/packages/twenty-emails/src/utils/email-renderer/nodes/ordered-list.tsx b/packages/twenty-emails/src/utils/email-renderer/nodes/ordered-list.tsx
new file mode 100644
index 0000000000..27dc8d461a
--- /dev/null
+++ b/packages/twenty-emails/src/utils/email-renderer/nodes/ordered-list.tsx
@@ -0,0 +1,16 @@
+import { type JSONContent } from '@tiptap/core';
+import { type ReactNode } from 'react';
+import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
+
+export const orderedList = (node: JSONContent): ReactNode => {
+ return (
+
+ {mappedNodeContent(node)}
+
+ );
+};
diff --git a/packages/twenty-emails/src/utils/email-renderer/renderers/render-node.tsx b/packages/twenty-emails/src/utils/email-renderer/renderers/render-node.tsx
index 0b71d24a6b..2954d3c42f 100644
--- a/packages/twenty-emails/src/utils/email-renderer/renderers/render-node.tsx
+++ b/packages/twenty-emails/src/utils/email-renderer/renderers/render-node.tsx
@@ -6,6 +6,9 @@ import { image } from '../nodes/image';
import { paragraph } from '../nodes/paragraph';
import { text } from '../nodes/text';
import { variableTag } from '../nodes/variable-tag';
+import { bulletList } from '../nodes/bullet-list';
+import { listItem } from '../nodes/list-item';
+import { orderedList } from '../nodes/ordered-list';
const NODE_RENDERERS = {
[TIPTAP_NODE_TYPES.PARAGRAPH]: paragraph,
@@ -13,6 +16,9 @@ const NODE_RENDERERS = {
[TIPTAP_NODE_TYPES.HEADING]: heading,
[TIPTAP_NODE_TYPES.VARIABLE_TAG]: variableTag,
[TIPTAP_NODE_TYPES.IMAGE]: image,
+ [TIPTAP_NODE_TYPES.BULLET_LIST]: bulletList,
+ [TIPTAP_NODE_TYPES.ORDERED_LIST]: orderedList,
+ [TIPTAP_NODE_TYPES.LIST_ITEM]: listItem,
};
const renderNode = (node: JSONContent): ReactNode => {
diff --git a/packages/twenty-front/package.json b/packages/twenty-front/package.json
index 22dd02d827..10ab402a81 100644
--- a/packages/twenty-front/package.json
+++ b/packages/twenty-front/package.json
@@ -63,6 +63,7 @@
"@tiptap/extension-image": "^3.4.4",
"@tiptap/extension-italic": "^3.4.2",
"@tiptap/extension-link": "^3.4.2",
+ "@tiptap/extension-list": "3.4.2",
"@tiptap/extension-paragraph": "^3.4.2",
"@tiptap/extension-strike": "^3.4.2",
"@tiptap/extension-text": "^3.4.2",
diff --git a/packages/twenty-front/src/modules/advanced-text-editor/components/AdvancedTextEditor.tsx b/packages/twenty-front/src/modules/advanced-text-editor/components/AdvancedTextEditor.tsx
index da7e9f889d..34fe7eb610 100644
--- a/packages/twenty-front/src/modules/advanced-text-editor/components/AdvancedTextEditor.tsx
+++ b/packages/twenty-front/src/modules/advanced-text-editor/components/AdvancedTextEditor.tsx
@@ -66,6 +66,11 @@ const StyledEditorContainer = styled.div<{
h3 {
font-size: 24px;
}
+
+ li {
+ margin-bottom: ${({ theme }) => theme.spacing(2)};
+ line-height: 1.5;
+ }
}
.ProseMirror-focused {
diff --git a/packages/twenty-front/src/modules/advanced-text-editor/components/TextBubbleMenu.tsx b/packages/twenty-front/src/modules/advanced-text-editor/components/TextBubbleMenu.tsx
index 872d15a154..3aedcb9220 100644
--- a/packages/twenty-front/src/modules/advanced-text-editor/components/TextBubbleMenu.tsx
+++ b/packages/twenty-front/src/modules/advanced-text-editor/components/TextBubbleMenu.tsx
@@ -9,6 +9,8 @@ import { BubbleMenu } from '@tiptap/react/menus';
import {
IconBold,
IconItalic,
+ IconList,
+ IconListNumbers,
IconStrikethrough,
IconUnderline,
} from 'twenty-ui/display';
@@ -51,6 +53,16 @@ export const TextBubbleMenu = ({ editor }: TextBubbleMenuProps) => {
onClick: () => editor.chain().focus().toggleStrike().run(),
isActive: state.isStrike,
},
+ {
+ Icon: IconList,
+ onClick: () => editor.chain().focus().wrapInList('bulletList').run(),
+ isActive: state.isBulletList,
+ },
+ {
+ Icon: IconListNumbers,
+ onClick: () => editor.chain().focus().wrapInList('orderedList').run(),
+ isActive: state.isOrderedList,
+ },
];
const handleShouldShow = () => {
diff --git a/packages/twenty-front/src/modules/advanced-text-editor/components/__stories__/AdvancedTextEditor.stories.tsx b/packages/twenty-front/src/modules/advanced-text-editor/components/__stories__/AdvancedTextEditor.stories.tsx
index 914592d367..ae549fbd3f 100644
--- a/packages/twenty-front/src/modules/advanced-text-editor/components/__stories__/AdvancedTextEditor.stories.tsx
+++ b/packages/twenty-front/src/modules/advanced-text-editor/components/__stories__/AdvancedTextEditor.stories.tsx
@@ -1,7 +1,8 @@
import { AdvancedTextEditor } from '@/advanced-text-editor/components/AdvancedTextEditor';
import { useAdvancedTextEditor } from '@/advanced-text-editor/hooks/useAdvancedTextEditor';
import { type Meta, type StoryObj } from '@storybook/react';
-import { fn } from '@storybook/test';
+import { expect, fn, userEvent } from '@storybook/test';
+import { isDefined } from 'twenty-shared/utils';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
@@ -293,3 +294,147 @@ export const CustomSize: Story = {
placeholder: 'This editor has custom dimensions...',
},
};
+
+export const WithLists: Story = {
+ args: {
+ defaultValue: JSON.stringify({
+ type: 'doc',
+ content: [
+ {
+ type: 'heading',
+ attrs: { level: 2 },
+ content: [{ type: 'text', text: 'Project Requirements' }],
+ },
+ {
+ type: 'bulletList',
+ content: [
+ {
+ type: 'listItem',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ { type: 'text', text: 'User authentication system' },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'listItem',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Database integration' }],
+ },
+ ],
+ },
+ {
+ type: 'listItem',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'API endpoints' }],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'heading',
+ attrs: { level: 2 },
+ content: [{ type: 'text', text: 'Implementation Steps' }],
+ },
+ {
+ type: 'orderedList',
+ content: [
+ {
+ type: 'listItem',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ { type: 'text', text: 'Set up development environment' },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'listItem',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Create database schema' }],
+ },
+ ],
+ },
+ {
+ type: 'listItem',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Implement authentication' }],
+ },
+ ],
+ },
+ {
+ type: 'listItem',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Build API endpoints' }],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ }),
+ },
+ play: async ({ canvasElement, step }) => {
+ await step('Verify bullet list is rendered', async () => {
+ const listItems = canvasElement.querySelectorAll('ul li');
+ expect(listItems.length).toBeGreaterThan(0);
+
+ const firstItem = listItems[0];
+ expect(firstItem).toBeInTheDocument();
+ expect(firstItem).toHaveTextContent(/User authentication system/i);
+ });
+
+ await step('Verify ordered list is rendered', async () => {
+ const orderedListItems = canvasElement.querySelectorAll('ol li');
+ expect(orderedListItems.length).toBeGreaterThan(0);
+
+ const firstOrderedItem = orderedListItems[0];
+ expect(firstOrderedItem).toBeInTheDocument();
+ expect(firstOrderedItem).toHaveTextContent(
+ /Set up development environment/i,
+ );
+ });
+
+ await step('Test list interaction', async () => {
+ const editorContent = canvasElement.querySelector('.tiptap');
+ expect(editorContent).toBeInTheDocument();
+
+ const firstListItem = canvasElement.querySelector('ul li');
+ if (isDefined(firstListItem)) {
+ await userEvent.click(firstListItem);
+
+ expect(editorContent).toHaveFocus();
+ }
+ });
+
+ await step('Verify list structure', async () => {
+ const bulletList = canvasElement.querySelector('ul');
+ expect(bulletList).toBeInTheDocument();
+
+ const orderedList = canvasElement.querySelector('ol');
+ expect(orderedList).toBeInTheDocument();
+
+ const bulletListItems = canvasElement.querySelectorAll('ul li');
+ expect(bulletListItems.length).toBe(3);
+
+ const orderedListItems = canvasElement.querySelectorAll('ol li');
+ expect(orderedListItems.length).toBe(4);
+ });
+ },
+};
diff --git a/packages/twenty-front/src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts b/packages/twenty-front/src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
index b552c5fa5f..221d711136 100644
--- a/packages/twenty-front/src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
+++ b/packages/twenty-front/src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
@@ -8,6 +8,7 @@ import { HardBreak } from '@tiptap/extension-hard-break';
import { Heading } from '@tiptap/extension-heading';
import { Italic } from '@tiptap/extension-italic';
import { Link } from '@tiptap/extension-link';
+import { ListKit } from '@tiptap/extension-list';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Strike } from '@tiptap/extension-strike';
import { Text } from '@tiptap/extension-text';
@@ -66,6 +67,7 @@ export const useAdvancedTextEditor = (
}),
ResizableImage,
Dropcursor,
+ ListKit,
UploadImageExtension.configure({
onImageUpload,
onImageUploadError,
diff --git a/packages/twenty-front/src/modules/advanced-text-editor/hooks/useTextBubbleState.ts b/packages/twenty-front/src/modules/advanced-text-editor/hooks/useTextBubbleState.ts
index 5c456a30fe..3cdcdc14fb 100644
--- a/packages/twenty-front/src/modules/advanced-text-editor/hooks/useTextBubbleState.ts
+++ b/packages/twenty-front/src/modules/advanced-text-editor/hooks/useTextBubbleState.ts
@@ -12,6 +12,8 @@ export const useTextBubbleState = (editor: Editor) => {
isUnderline: ctx.editor.isActive('underline'),
isLink: ctx.editor.isActive('link'),
linkHref: ctx.editor.getAttributes('link').href || '',
+ isBulletList: ctx.editor.isActive('bulletList'),
+ isOrderedList: ctx.editor.isActive('orderedList'),
};
},
});
diff --git a/packages/twenty-shared/src/utils/tiptap/tiptap-marks.ts b/packages/twenty-shared/src/utils/tiptap/tiptap-marks.ts
index a236f74511..970359fbb0 100644
--- a/packages/twenty-shared/src/utils/tiptap/tiptap-marks.ts
+++ b/packages/twenty-shared/src/utils/tiptap/tiptap-marks.ts
@@ -14,6 +14,9 @@ export const TIPTAP_NODE_TYPES = {
HEADING: 'heading',
VARIABLE_TAG: 'variableTag',
IMAGE: 'image',
+ BULLET_LIST: 'bulletList',
+ ORDERED_LIST: 'orderedList',
+ LIST_ITEM: 'listItem',
} as const;
export type TipTapMarkType =
diff --git a/yarn.lock b/yarn.lock
index 619331165e..b4d3b77faa 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -21592,6 +21592,16 @@ __metadata:
languageName: node
linkType: hard
+"@tiptap/extension-list@npm:3.4.2":
+ version: 3.4.2
+ resolution: "@tiptap/extension-list@npm:3.4.2"
+ peerDependencies:
+ "@tiptap/core": ^3.4.2
+ "@tiptap/pm": ^3.4.2
+ checksum: 10c0/6090c5758411201dce90a0bf26e4d766cc74113a3284274d9903580d5295f2eaf7a682ba3e921111e6304fd86c42aead0ac23369296c709038aa15febdb8a900
+ languageName: node
+ linkType: hard
+
"@tiptap/extension-paragraph@npm:^2.11.5":
version: 2.12.0
resolution: "@tiptap/extension-paragraph@npm:2.12.0"
@@ -52576,6 +52586,7 @@ __metadata:
"@tiptap/extension-image": "npm:^3.4.4"
"@tiptap/extension-italic": "npm:^3.4.2"
"@tiptap/extension-link": "npm:^3.4.2"
+ "@tiptap/extension-list": "npm:3.4.2"
"@tiptap/extension-paragraph": "npm:^3.4.2"
"@tiptap/extension-strike": "npm:^3.4.2"
"@tiptap/extension-text": "npm:^3.4.2"