From 042972d7b27ae7f5307e51cc942f90c7dc92d7d0 Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Mon, 15 Dec 2025 17:54:31 +0530 Subject: [PATCH] fix(workflow): line break not supported by Send Email Nodes (#16561) Closes #16557 Tiptap Editor (which the Send Email Node uses) , creates a content json with type 'hardBreak' for line breaks. The was no rederer defined for this `hardBreak` node type, so the `renderNode` function was ignoring that node (returning null). **Fix :** Added a renderer for `hardBreak` node type. --- .../utils/email-renderer/nodes/hard-break.tsx | 6 + .../email-renderer/renderers/render-node.tsx | 8 +- .../send-email-body-rendering.spec.ts | 180 ++++++++++++++++++ .../utils/__test__/parse-email-body.spec.ts | 71 +++++++ .../src/utils/tiptap/tiptap-marks.ts | 1 + 5 files changed, 263 insertions(+), 3 deletions(-) create mode 100644 packages/twenty-emails/src/utils/email-renderer/nodes/hard-break.tsx create mode 100644 packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/__tests__/send-email-body-rendering.spec.ts create mode 100644 packages/twenty-server/src/utils/__test__/parse-email-body.spec.ts diff --git a/packages/twenty-emails/src/utils/email-renderer/nodes/hard-break.tsx b/packages/twenty-emails/src/utils/email-renderer/nodes/hard-break.tsx new file mode 100644 index 0000000000..65112ef9e9 --- /dev/null +++ b/packages/twenty-emails/src/utils/email-renderer/nodes/hard-break.tsx @@ -0,0 +1,6 @@ +import { type JSONContent } from '@tiptap/core'; +import { type ReactNode } from 'react'; + +export const hardBreak = (_node: JSONContent): ReactNode => { + return
; +}; 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 2954d3c42f..9d9dcded95 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 @@ -1,14 +1,15 @@ import { type JSONContent } from '@tiptap/core'; import { Fragment, type ReactNode } from 'react'; import { TIPTAP_NODE_TYPES, type TipTapNodeType } from 'twenty-shared/utils'; +import { bulletList } from '../nodes/bullet-list'; +import { hardBreak } from '../nodes/hard-break'; import { heading } from '../nodes/heading'; import { image } from '../nodes/image'; +import { listItem } from '../nodes/list-item'; +import { orderedList } from '../nodes/ordered-list'; 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, @@ -19,6 +20,7 @@ const NODE_RENDERERS = { [TIPTAP_NODE_TYPES.BULLET_LIST]: bulletList, [TIPTAP_NODE_TYPES.ORDERED_LIST]: orderedList, [TIPTAP_NODE_TYPES.LIST_ITEM]: listItem, + [TIPTAP_NODE_TYPES.HARD_BREAK]: hardBreak, }; const renderNode = (node: JSONContent): ReactNode => { diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/__tests__/send-email-body-rendering.spec.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/__tests__/send-email-body-rendering.spec.ts new file mode 100644 index 0000000000..0906b983e2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/__tests__/send-email-body-rendering.spec.ts @@ -0,0 +1,180 @@ +import { isValidElement, type ReactElement } from 'react'; +import { reactMarkupFromJSON } from 'twenty-emails'; + +describe('Send Email Body Rendering', () => { + describe('hardBreak node rendering', () => { + it('should return valid React element for content with hardBreak', () => { + const jsonWithHardBreak = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Hello' }, + { type: 'hardBreak' }, + { type: 'text', text: 'World' }, + ], + }, + ], + }; + + const reactMarkup = reactMarkupFromJSON(jsonWithHardBreak); + + expect(isValidElement(reactMarkup)).toBe(true); + }); + + it('should return valid React element for multiple hardBreak nodes', () => { + const jsonWithMultipleBreaks = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Line 1' }, + { type: 'hardBreak' }, + { type: 'text', text: 'Line 2' }, + { type: 'hardBreak' }, + { type: 'text', text: 'Line 3' }, + ], + }, + ], + }; + + const reactMarkup = reactMarkupFromJSON(jsonWithMultipleBreaks); + + expect(isValidElement(reactMarkup)).toBe(true); + }); + + it('should return valid React element for hardBreak at paragraph start', () => { + const jsonWithLeadingBreak = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'hardBreak' }, + { type: 'text', text: 'After break' }, + ], + }, + ], + }; + + const reactMarkup = reactMarkupFromJSON(jsonWithLeadingBreak); + + expect(isValidElement(reactMarkup)).toBe(true); + }); + + it('should return valid React element for hardBreak at paragraph end', () => { + const jsonWithTrailingBreak = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Before break' }, + { type: 'hardBreak' }, + ], + }, + ], + }; + + const reactMarkup = reactMarkupFromJSON(jsonWithTrailingBreak); + + expect(isValidElement(reactMarkup)).toBe(true); + }); + + it('should return valid React element for paragraph with only hardBreak', () => { + const jsonWithOnlyBreak = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'hardBreak' }], + }, + ], + }; + + const reactMarkup = reactMarkupFromJSON(jsonWithOnlyBreak); + + expect(isValidElement(reactMarkup)).toBe(true); + }); + + it('should include br element in the rendered output', () => { + const jsonWithHardBreak = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Hello' }, + { type: 'hardBreak' }, + { type: 'text', text: 'World' }, + ], + }, + ], + }; + + const reactMarkup = reactMarkupFromJSON( + jsonWithHardBreak, + ) as ReactElement; + + const findBrElements = (element: ReactElement): boolean => { + if (!element || typeof element !== 'object') return false; + if (element.type === 'br') return true; + + const children = element.props?.children; + + if (!children) return false; + if (Array.isArray(children)) { + return children.some( + (child) => isValidElement(child) && findBrElements(child), + ); + } + if (isValidElement(children)) { + return findBrElements(children); + } + + return false; + }; + + expect(findBrElements(reactMarkup)).toBe(true); + }); + }); + + describe('mixed content rendering', () => { + it('should return valid React element for hardBreak with other nodes', () => { + const mixedContent = { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'Title' }], + }, + { + type: 'paragraph', + content: [ + { type: 'text', text: 'First line' }, + { type: 'hardBreak' }, + { type: 'text', text: 'Second line' }, + ], + }, + ], + }; + + const reactMarkup = reactMarkupFromJSON(mixedContent); + + expect(isValidElement(reactMarkup)).toBe(true); + }); + }); + + describe('plain text fallback', () => { + it('should handle plain string input', () => { + const plainText = 'This is plain text'; + + const reactMarkup = reactMarkupFromJSON(plainText); + + expect(reactMarkup).toBe(plainText); + }); + }); +}); diff --git a/packages/twenty-server/src/utils/__test__/parse-email-body.spec.ts b/packages/twenty-server/src/utils/__test__/parse-email-body.spec.ts new file mode 100644 index 0000000000..7c6b5b5e58 --- /dev/null +++ b/packages/twenty-server/src/utils/__test__/parse-email-body.spec.ts @@ -0,0 +1,71 @@ +import { parseEmailBody } from 'src/utils/parse-email-body'; + +describe('parseEmailBody', () => { + it('should parse valid JSON content', () => { + const jsonContent = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Hello World' }], + }, + ], + }; + + const result = parseEmailBody(JSON.stringify(jsonContent)); + + expect(result).toEqual(jsonContent); + }); + + it('should return plain string when JSON parsing fails', () => { + const plainText = 'This is plain text, not JSON'; + + const result = parseEmailBody(plainText); + + expect(result).toBe(plainText); + }); + + it('should parse JSON content with hardBreak nodes', () => { + const jsonWithHardBreaks = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Line 1' }, + { type: 'hardBreak' }, + { type: 'text', text: 'Line 2' }, + ], + }, + ], + }; + + const result = parseEmailBody(JSON.stringify(jsonWithHardBreaks)); + + expect(result).toEqual(jsonWithHardBreaks); + expect( + (result as typeof jsonWithHardBreaks).content[0].content, + ).toContainEqual({ + type: 'hardBreak', + }); + }); + + it('should handle empty string', () => { + const result = parseEmailBody(''); + + expect(result).toBe(''); + }); + + it('should handle JSON array format', () => { + const arrayContent = [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Content' }], + }, + ]; + + const result = parseEmailBody(JSON.stringify(arrayContent)); + + expect(result).toEqual(arrayContent); + }); +}); diff --git a/packages/twenty-shared/src/utils/tiptap/tiptap-marks.ts b/packages/twenty-shared/src/utils/tiptap/tiptap-marks.ts index 970359fbb0..91c95e396c 100644 --- a/packages/twenty-shared/src/utils/tiptap/tiptap-marks.ts +++ b/packages/twenty-shared/src/utils/tiptap/tiptap-marks.ts @@ -17,6 +17,7 @@ export const TIPTAP_NODE_TYPES = { BULLET_LIST: 'bulletList', ORDERED_LIST: 'orderedList', LIST_ITEM: 'listItem', + HARD_BREAK: 'hardBreak', } as const; export type TipTapMarkType =