Add available variables dropdown (#7964)
- Add variable dropdown
- Insert variables on click
- Save variable as `{{stepName.object.myVar}}` and display only `myVar`
https://github.com/user-attachments/assets/9b49e32c-15e6-4b64-9901-0e63664bc3e8
This commit is contained in:
+153
@@ -0,0 +1,153 @@
|
||||
import { Editor } from '@tiptap/react';
|
||||
import { initializeEditorContent } from '../initializeEditorContent';
|
||||
|
||||
describe('initializeEditorContent', () => {
|
||||
const mockEditor = {
|
||||
commands: {
|
||||
insertContent: jest.fn(),
|
||||
},
|
||||
} as unknown as Editor;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
initializeEditorContent(mockEditor, '');
|
||||
expect(mockEditor.commands.insertContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should insert plain text correctly', () => {
|
||||
initializeEditorContent(mockEditor, 'Hello world');
|
||||
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(1);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenCalledWith(
|
||||
'Hello world',
|
||||
);
|
||||
});
|
||||
|
||||
it('should insert single variable correctly', () => {
|
||||
initializeEditorContent(mockEditor, '{{user.name}}');
|
||||
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(1);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenCalledWith({
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{user.name}}' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle text with variable in the middle', () => {
|
||||
initializeEditorContent(mockEditor, 'Hello {{user.name}} world');
|
||||
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(3);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Hello ',
|
||||
);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{user.name}}' },
|
||||
});
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
' world',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple variables', () => {
|
||||
initializeEditorContent(
|
||||
mockEditor,
|
||||
'Hello {{user.firstName}} {{user.lastName}}, welcome to {{app.name}}',
|
||||
);
|
||||
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(6);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Hello ',
|
||||
);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{user.firstName}}' },
|
||||
});
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(3, ' ');
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(4, {
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{user.lastName}}' },
|
||||
});
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
', welcome to ',
|
||||
);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(6, {
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{app.name}}' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle variables at the start and end', () => {
|
||||
initializeEditorContent(mockEditor, '{{start.var}} middle {{end.var}}');
|
||||
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(3);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(1, {
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{start.var}}' },
|
||||
});
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
' middle ',
|
||||
);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(3, {
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{end.var}}' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle consecutive variables', () => {
|
||||
initializeEditorContent(mockEditor, '{{var1}}{{var2}}{{var3}}');
|
||||
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(3);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(1, {
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{var1}}' },
|
||||
});
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{var2}}' },
|
||||
});
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(3, {
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{var3}}' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle whitespace between variables', () => {
|
||||
initializeEditorContent(mockEditor, '{{var1}} {{var2}} ');
|
||||
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(4);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(1, {
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{var1}}' },
|
||||
});
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, ' ');
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(3, {
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{var2}}' },
|
||||
});
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(4, ' ');
|
||||
});
|
||||
|
||||
it('should handle nested variable syntax', () => {
|
||||
initializeEditorContent(mockEditor, 'Hello {{user.address.city}}!');
|
||||
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(3);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Hello ',
|
||||
);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{user.address.city}}' },
|
||||
});
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(3, '!');
|
||||
});
|
||||
});
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
import { JSONContent } from '@tiptap/react';
|
||||
import { parseEditorContent } from '../parseEditorContent';
|
||||
|
||||
describe('parseEditorContent', () => {
|
||||
it('should parse empty doc', () => {
|
||||
const input: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [],
|
||||
};
|
||||
|
||||
expect(parseEditorContent(input)).toBe('');
|
||||
});
|
||||
|
||||
it('should parse simple text node', () => {
|
||||
const input: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Hello world',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(parseEditorContent(input)).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('should parse variable tag node', () => {
|
||||
const input: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'variableTag',
|
||||
attrs: {
|
||||
variable: '{{user.name}}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(parseEditorContent(input)).toBe('{{user.name}}');
|
||||
});
|
||||
|
||||
it('should parse mixed content with text and variables', () => {
|
||||
const input: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Hello ',
|
||||
},
|
||||
{
|
||||
type: 'variableTag',
|
||||
attrs: {
|
||||
variable: '{{user.name}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: ', welcome to ',
|
||||
},
|
||||
{
|
||||
type: 'variableTag',
|
||||
attrs: {
|
||||
variable: '{{app.name}}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(parseEditorContent(input)).toBe(
|
||||
'Hello {{user.name}}, welcome to {{app.name}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse multiple paragraphs', () => {
|
||||
const input: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'First line',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Second line',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(parseEditorContent(input)).toBe('First lineSecond line');
|
||||
});
|
||||
|
||||
it('should handle missing content array', () => {
|
||||
const input: JSONContent = {
|
||||
type: 'doc',
|
||||
};
|
||||
|
||||
expect(parseEditorContent(input)).toBe('');
|
||||
});
|
||||
|
||||
it('should handle missing text in text node', () => {
|
||||
const input: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(parseEditorContent(input)).toBe('');
|
||||
});
|
||||
|
||||
it('should handle missing variable in variableTag node', () => {
|
||||
const input: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'variableTag',
|
||||
attrs: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(parseEditorContent(input)).toBe('');
|
||||
});
|
||||
|
||||
it('should handle unknown node types', () => {
|
||||
const input: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'unknownType',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'This should be ignored',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(parseEditorContent(input)).toBe('');
|
||||
});
|
||||
|
||||
it('should parse complex nested structure', () => {
|
||||
const input: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Hello ',
|
||||
},
|
||||
{
|
||||
type: 'variableTag',
|
||||
attrs: {
|
||||
variable: '{{user.firstName}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: ' ',
|
||||
},
|
||||
{
|
||||
type: 'variableTag',
|
||||
attrs: {
|
||||
variable: '{{user.lastName}}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Your ID is: ',
|
||||
},
|
||||
{
|
||||
type: 'variableTag',
|
||||
attrs: {
|
||||
variable: '{{user.id}}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(parseEditorContent(input)).toBe(
|
||||
'Hello {{user.firstName}} {{user.lastName}}Your ID is: {{user.id}}',
|
||||
);
|
||||
});
|
||||
});
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { Editor } from '@tiptap/react';
|
||||
|
||||
const REGEX_VARIABLE_TAG = /(\{\{[^}]+\}\})/;
|
||||
|
||||
export const initializeEditorContent = (editor: Editor, content: string) => {
|
||||
const parts = content.split(REGEX_VARIABLE_TAG);
|
||||
|
||||
parts.forEach((part) => {
|
||||
if (part.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (part.startsWith('{{') && part.endsWith('}}')) {
|
||||
editor.commands.insertContent({
|
||||
type: 'variableTag',
|
||||
attrs: { variable: part },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNonEmptyString(part)) {
|
||||
editor.commands.insertContent(part);
|
||||
}
|
||||
});
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { JSONContent } from '@tiptap/react';
|
||||
import { isDefined } from 'twenty-ui';
|
||||
|
||||
export const parseEditorContent = (json: JSONContent): string => {
|
||||
const parseNode = (node: JSONContent): string => {
|
||||
if (
|
||||
(node.type === 'paragraph' || node.type === 'doc') &&
|
||||
isDefined(node.content)
|
||||
) {
|
||||
return node.content.map(parseNode).join('');
|
||||
}
|
||||
|
||||
if (node.type === 'text') {
|
||||
return node.text || '';
|
||||
}
|
||||
|
||||
if (node.type === 'variableTag') {
|
||||
return node.attrs?.variable || '';
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
return parseNode(json);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Node } from '@tiptap/core';
|
||||
import { mergeAttributes } from '@tiptap/react';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
variableTag: {
|
||||
insertVariableTag: (variable: string) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const VariableTag = Node.create({
|
||||
name: 'variableTag',
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
atom: true,
|
||||
|
||||
addAttributes: () => ({
|
||||
variable: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-variable'),
|
||||
renderHTML: (attributes) => {
|
||||
return {
|
||||
'data-variable': attributes.variable,
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
renderHTML: ({ node, HTMLAttributes }) => {
|
||||
const variable = node.attrs.variable as string;
|
||||
const variableWithoutBrackets = variable.replace(
|
||||
/\{\{([^}]+)\}\}/g,
|
||||
(_, variable) => {
|
||||
return variable;
|
||||
},
|
||||
);
|
||||
|
||||
const parts = variableWithoutBrackets.split('.');
|
||||
const displayText = parts[parts.length - 1];
|
||||
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-type': 'variableTag',
|
||||
class: 'variable-tag',
|
||||
}),
|
||||
displayText,
|
||||
];
|
||||
},
|
||||
|
||||
addCommands: () => ({
|
||||
insertVariableTag:
|
||||
(variable: string) =>
|
||||
({ commands }) => {
|
||||
commands.insertContent?.({
|
||||
type: 'variableTag',
|
||||
attrs: { variable },
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user