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 ( + + ); +}; 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"