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:
Thomas Trompette
2024-10-23 18:32:10 +02:00
committed by GitHub
parent 18778c55ac
commit 2e8b8452c1
17 changed files with 997 additions and 5 deletions
@@ -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, '!');
});
});
@@ -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}}',
);
});
});
@@ -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);
}
});
};
@@ -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;
},
}),
});