Add pattern for variable tag in tiptap (#16652)
Since we now store rich text value in blocknote rather than markdown,
variables need to be resolved accordingly.
Replacing the variable tag pattern
`{"type":"variableTag","attrs":\{"variable":"(\{\{[^{}]+\}\})"\}\}` by a
blocknote text `{"type":"text","text":"${escapedText}"}`
Fixes https://github.com/twentyhq/twenty/issues/16583
To test :
- build a workflow that creates a note/ sends an email with a variable
in the body
- make sure the result is properly formatted once run
---------
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
import { resolveRichTextVariables } from '../rich-text-variable-resolver';
|
||||
|
||||
describe('resolveRichTextVariables', () => {
|
||||
const context = {
|
||||
step1: {
|
||||
message: 'Hello World',
|
||||
name: 'John',
|
||||
},
|
||||
user: {
|
||||
email: 'john@example.com',
|
||||
},
|
||||
};
|
||||
|
||||
it('should resolve a single variableTag node', () => {
|
||||
const input =
|
||||
'[{"type":"paragraph","content":[{"type":"variableTag","attrs":{"variable":"{{step1.message}}"}}]}]';
|
||||
|
||||
const result = resolveRichTextVariables(input, context);
|
||||
|
||||
expect(result).toBe(
|
||||
'[{"type":"paragraph","content":[{"type":"text","text":"Hello World"}]}]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve variableTag nodes mixed with text', () => {
|
||||
const input =
|
||||
'[{"type":"paragraph","content":[{"type":"text","text":"Message: "},{"type":"variableTag","attrs":{"variable":"{{step1.message}}"}},{"type":"text","text":" from user"}]}]';
|
||||
|
||||
const result = resolveRichTextVariables(input, context);
|
||||
|
||||
expect(result).toBe(
|
||||
'[{"type":"paragraph","content":[{"type":"text","text":"Message: "},{"type":"text","text":"Hello World"},{"type":"text","text":" from user"}]}]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve multiple variableTag nodes', () => {
|
||||
const input =
|
||||
'[{"type":"paragraph","content":[{"type":"variableTag","attrs":{"variable":"{{step1.name}}"}},{"type":"text","text":" - "},{"type":"variableTag","attrs":{"variable":"{{user.email}}"}}]}]';
|
||||
|
||||
const result = resolveRichTextVariables(input, context);
|
||||
|
||||
expect(result).toBe(
|
||||
'[{"type":"paragraph","content":[{"type":"text","text":"John"},{"type":"text","text":" - "},{"type":"text","text":"john@example.com"}]}]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle undefined variables by replacing with empty string', () => {
|
||||
const input =
|
||||
'[{"type":"paragraph","content":[{"type":"variableTag","attrs":{"variable":"{{nonexistent.field}}"}}]}]';
|
||||
|
||||
const result = resolveRichTextVariables(input, context);
|
||||
|
||||
expect(result).toBe(
|
||||
'[{"type":"paragraph","content":[{"type":"text","text":""}]}]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should escape special characters in resolved values', () => {
|
||||
const contextWithSpecialChars = {
|
||||
step1: {
|
||||
message: 'Hello "World" with \\ backslash',
|
||||
},
|
||||
};
|
||||
|
||||
const input =
|
||||
'[{"type":"paragraph","content":[{"type":"variableTag","attrs":{"variable":"{{step1.message}}"}}]}]';
|
||||
|
||||
const result = resolveRichTextVariables(input, contextWithSpecialChars);
|
||||
|
||||
expect(result).toBe(
|
||||
'[{"type":"paragraph","content":[{"type":"text","text":"Hello \\"World\\" with \\\\ backslash"}]}]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not modify strings without variableTag nodes', () => {
|
||||
const input =
|
||||
'[{"type":"paragraph","content":[{"type":"text","text":"Plain text content"}]}]';
|
||||
|
||||
const result = resolveRichTextVariables(input, context);
|
||||
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
|
||||
it('should handle doc type structure', () => {
|
||||
const input =
|
||||
'{"type":"doc","content":[{"type":"paragraph","content":[{"type":"variableTag","attrs":{"variable":"{{step1.message}}"}}]}]}';
|
||||
|
||||
const result = resolveRichTextVariables(input, context);
|
||||
|
||||
expect(result).toBe(
|
||||
'{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Hello World"}]}]}',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle null context values by replacing with empty string', () => {
|
||||
const contextWithNull = {
|
||||
step1: {
|
||||
value: null,
|
||||
},
|
||||
};
|
||||
|
||||
const input =
|
||||
'[{"type":"paragraph","content":[{"type":"variableTag","attrs":{"variable":"{{step1.value}}"}}]}]';
|
||||
|
||||
const result = resolveRichTextVariables(input, contextWithNull);
|
||||
|
||||
expect(result).toBe(
|
||||
'[{"type":"paragraph","content":[{"type":"text","text":""}]}]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle numeric values', () => {
|
||||
const contextWithNumber = {
|
||||
step1: {
|
||||
count: 42,
|
||||
},
|
||||
};
|
||||
|
||||
const input =
|
||||
'[{"type":"paragraph","content":[{"type":"variableTag","attrs":{"variable":"{{step1.count}}"}}]}]';
|
||||
|
||||
const result = resolveRichTextVariables(input, contextWithNumber);
|
||||
|
||||
expect(result).toBe(
|
||||
'[{"type":"paragraph","content":[{"type":"text","text":"42"}]}]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve regular {{variable}} patterns in non-variableTag contexts', () => {
|
||||
const input =
|
||||
'[{"type":"paragraph","content":[{"type":"text","text":"Regular {{step1.message}} pattern"}]}]';
|
||||
|
||||
const result = resolveRichTextVariables(input, context);
|
||||
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
|
||||
it('should return null for null input', () => {
|
||||
const result = resolveRichTextVariables(null, context);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return undefined for undefined input', () => {
|
||||
const result = resolveRichTextVariables(undefined, context);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should resolve variableTag nodes with attrs before type (alternate JSON order)', () => {
|
||||
const input =
|
||||
'[{"type":"paragraph","content":[{"attrs":{"variable":"{{step1.message}}"},"type":"variableTag"}]}]';
|
||||
|
||||
const result = resolveRichTextVariables(input, context);
|
||||
|
||||
expect(result).toBe(
|
||||
'[{"type":"paragraph","content":[{"type":"text","text":"Hello World"}]}]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve mixed property order variableTag nodes', () => {
|
||||
const input =
|
||||
'[{"type":"paragraph","content":[{"type":"variableTag","attrs":{"variable":"{{step1.name}}"}},{"attrs":{"variable":"{{user.email}}"},"type":"variableTag"}]}]';
|
||||
|
||||
const result = resolveRichTextVariables(input, context);
|
||||
|
||||
expect(result).toBe(
|
||||
'[{"type":"paragraph","content":[{"type":"text","text":"John"},{"type":"text","text":"john@example.com"}]}]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should convert newlines to hardBreak nodes', () => {
|
||||
const contextWithNewlines = {
|
||||
step1: {
|
||||
message: 'Hello\nWorld',
|
||||
},
|
||||
};
|
||||
|
||||
const input =
|
||||
'[{"type":"paragraph","content":[{"type":"variableTag","attrs":{"variable":"{{step1.message}}"}}]}]';
|
||||
|
||||
const result = resolveRichTextVariables(input, contextWithNewlines);
|
||||
|
||||
expect(result).toBe(
|
||||
'[{"type":"paragraph","content":[{"type":"text","text":"Hello"},{"type":"hardBreak"},{"type":"text","text":"World"}]}]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple newlines', () => {
|
||||
const contextWithMultipleNewlines = {
|
||||
step1: {
|
||||
message: 'Line 1\nLine 2\nLine 3',
|
||||
},
|
||||
};
|
||||
|
||||
const input =
|
||||
'[{"type":"paragraph","content":[{"type":"variableTag","attrs":{"variable":"{{step1.message}}"}}]}]';
|
||||
|
||||
const result = resolveRichTextVariables(input, contextWithMultipleNewlines);
|
||||
|
||||
expect(result).toBe(
|
||||
'[{"type":"paragraph","content":[{"type":"text","text":"Line 1"},{"type":"hardBreak"},{"type":"text","text":"Line 2"},{"type":"hardBreak"},{"type":"text","text":"Line 3"}]}]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle newlines with special characters', () => {
|
||||
const contextWithNewlinesAndSpecialChars = {
|
||||
step1: {
|
||||
message: 'Hello "World"\nGoodbye',
|
||||
},
|
||||
};
|
||||
|
||||
const input =
|
||||
'[{"type":"paragraph","content":[{"type":"variableTag","attrs":{"variable":"{{step1.message}}"}}]}]';
|
||||
|
||||
const result = resolveRichTextVariables(
|
||||
input,
|
||||
contextWithNewlinesAndSpecialChars,
|
||||
);
|
||||
|
||||
expect(result).toBe(
|
||||
'[{"type":"paragraph","content":[{"type":"text","text":"Hello \\"World\\""},{"type":"hardBreak"},{"type":"text","text":"Goodbye"}]}]',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { resolveInput } from '../variable-resolver';
|
||||
|
||||
describe('resolveInput', () => {
|
||||
const context = {
|
||||
user: {
|
||||
name: 'John Doe',
|
||||
age: 30,
|
||||
},
|
||||
settings: {
|
||||
theme: 'dark',
|
||||
notifications: true,
|
||||
},
|
||||
specialValues: {
|
||||
nullValue: null,
|
||||
undefinedValue: undefined,
|
||||
emptyString: '',
|
||||
zero: 0,
|
||||
booleanFalse: false,
|
||||
booleanTrue: true,
|
||||
},
|
||||
};
|
||||
|
||||
it('should return null for null input', () => {
|
||||
expect(resolveInput(null, context)).toBeNull();
|
||||
});
|
||||
|
||||
it('should support special values', () => {
|
||||
expect(resolveInput('{{specialValues.nullValue}}', context)).toBeNull();
|
||||
expect(
|
||||
resolveInput('{{specialValues.undefinedValue}}', context),
|
||||
).toBeUndefined();
|
||||
expect(resolveInput('{{specialValues.emptyString}}', context)).toBe('');
|
||||
expect(resolveInput('{{specialValues.zero}}', context)).toBe(0);
|
||||
expect(resolveInput('{{specialValues.booleanFalse}}', context)).toBe(false);
|
||||
expect(resolveInput('{{specialValues.booleanTrue}}', context)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return undefined for undefined input', () => {
|
||||
expect(resolveInput(undefined, context)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should resolve a simple string variable', () => {
|
||||
expect(resolveInput('{{user.name}}', context)).toBe('John Doe');
|
||||
});
|
||||
|
||||
it('should resolve multiple variables in a string', () => {
|
||||
expect(
|
||||
resolveInput('Name: {{user.name}}, Age: {{user.age}}', context),
|
||||
).toBe('Name: John Doe, Age: 30');
|
||||
});
|
||||
|
||||
it('should handle non-existent variables', () => {
|
||||
expect(resolveInput('{{user.email}}', context)).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should resolve variables in an array', () => {
|
||||
const input = ['{{user.name}}', '{{settings.theme}}', 'static'];
|
||||
const expected = ['John Doe', 'dark', 'static'];
|
||||
|
||||
expect(resolveInput(input, context)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should resolve variables in an object', () => {
|
||||
const input = {
|
||||
name: '{{user.name}}',
|
||||
theme: '{{settings.theme}}',
|
||||
static: 'value',
|
||||
};
|
||||
const expected = {
|
||||
name: 'John Doe',
|
||||
theme: 'dark',
|
||||
static: 'value',
|
||||
};
|
||||
|
||||
expect(resolveInput(input, context)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle nested objects and arrays', () => {
|
||||
const input = {
|
||||
user: {
|
||||
displayName: '{{user.name}}',
|
||||
preferences: ['{{settings.theme}}', '{{settings.notifications}}'],
|
||||
},
|
||||
staticData: [1, 2, 3],
|
||||
};
|
||||
const expected = {
|
||||
user: {
|
||||
displayName: 'John Doe',
|
||||
preferences: ['dark', true],
|
||||
},
|
||||
staticData: [1, 2, 3],
|
||||
};
|
||||
|
||||
expect(resolveInput(input, context)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('does not wrap string variables with double quotes', () => {
|
||||
expect(
|
||||
resolveInput('{ {{test}}: 2 }', {
|
||||
test: 'prop',
|
||||
}),
|
||||
).toBe('{ prop: 2 }');
|
||||
});
|
||||
|
||||
it('does not modify static JSON', () => {
|
||||
expect(resolveInput('{ "a": 2 }', {})).toBe('{ "a": 2 }');
|
||||
});
|
||||
|
||||
it('supports variable as JSON object property name', () => {
|
||||
expect(
|
||||
resolveInput('{ "{{test}}": 2 }', {
|
||||
test: 'prop',
|
||||
}),
|
||||
).toBe('{ "prop": 2 }');
|
||||
});
|
||||
|
||||
it('supports variable as JSON number value', () => {
|
||||
expect(
|
||||
resolveInput('{ "a": {{test}} }', {
|
||||
test: 2,
|
||||
}),
|
||||
).toBe('{ "a": 2 }');
|
||||
});
|
||||
|
||||
it('supports variable as JSON string value', () => {
|
||||
expect(
|
||||
resolveInput('{ "a": "{{test}}" }', {
|
||||
test: 'str',
|
||||
}),
|
||||
).toBe('{ "a": "str" }');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user