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.
This commit is contained in:
Abhishek Kumar
2025-12-15 17:54:31 +05:30
committed by GitHub
parent d7b34fc636
commit 042972d7b2
5 changed files with 263 additions and 3 deletions
@@ -0,0 +1,6 @@
import { type JSONContent } from '@tiptap/core';
import { type ReactNode } from 'react';
export const hardBreak = (_node: JSONContent): ReactNode => {
return <br />;
};
@@ -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 => {
@@ -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);
});
});
});
@@ -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);
});
});
@@ -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 =