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:
@@ -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 => {
|
||||
|
||||
+180
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user