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

View File

@ -5,8 +5,8 @@ import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { TextArea } from '@/ui/input/components/TextArea';
import { TextInput } from '@/ui/input/components/TextInput';
import { WorkflowEditActionFormBase } from '@/workflow/components/WorkflowEditActionFormBase';
import { VariableTagInput } from '@/workflow/search-variables/components/VariableTagInput';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { WorkflowSendEmailStep } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
@ -208,7 +208,8 @@ export const WorkflowEditActionFormSendEmail = (
name="email"
control={form.control}
render={({ field }) => (
<TextInput
<VariableTagInput
inputId="email-input"
label="Email"
placeholder="Enter receiver email (use {{variable}} for dynamic content)"
value={field.value}
@ -223,7 +224,8 @@ export const WorkflowEditActionFormSendEmail = (
name="subject"
control={form.control}
render={({ field }) => (
<TextInput
<VariableTagInput
inputId="email-subject-input"
label="Subject"
placeholder="Enter email subject (use {{variable}} for dynamic content)"
value={field.value}

View File

@ -0,0 +1,94 @@
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { StyledDropdownButtonContainer } from '@/ui/layout/dropdown/components/StyledDropdownButtonContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { SearchVariablesDropdownStepItem } from '@/workflow/search-variables/components/SearchVariablesDropdownStepItem';
import SearchVariablesDropdownStepSubItem from '@/workflow/search-variables/components/SearchVariablesDropdownStepSubItem';
import { AVAILABLE_VARIABLES_MOCK } from '@/workflow/search-variables/constants/AvailableVariablesMock';
import { SEARCH_VARIABLES_DROPDOWN_ID } from '@/workflow/search-variables/constants/SearchVariablesDropdownId';
import { WorkflowStepMock } from '@/workflow/search-variables/types/WorkflowStepMock';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Editor } from '@tiptap/react';
import { useState } from 'react';
import { IconVariable } from 'twenty-ui';
const StyledDropdownVariableButtonContainer = styled(
StyledDropdownButtonContainer,
)`
background-color: ${({ theme }) => theme.background.transparent.lighter};
color: ${({ theme }) => theme.font.color.tertiary};
padding: ${({ theme }) => theme.spacing(0)};
margin: ${({ theme }) => theme.spacing(2)};
`;
const SearchVariablesDropdown = ({
inputId,
editor,
}: {
inputId: string;
editor: Editor;
}) => {
const theme = useTheme();
const dropdownId = `${SEARCH_VARIABLES_DROPDOWN_ID}-${inputId}`;
const { isDropdownOpen } = useDropdown(dropdownId);
const [selectedStep, setSelectedStep] = useState<
WorkflowStepMock | undefined
>(undefined);
const insertVariableTag = (variable: string) => {
editor.commands.insertVariableTag(variable);
};
const handleStepSelect = (stepId: string) => {
setSelectedStep(
AVAILABLE_VARIABLES_MOCK.find((step) => step.id === stepId),
);
};
const handleSubItemSelect = (subItem: string) => {
insertVariableTag(subItem);
};
const handleBack = () => {
setSelectedStep(undefined);
};
return (
<Dropdown
dropdownId={dropdownId}
dropdownHotkeyScope={{
scope: dropdownId,
}}
clickableComponent={
<StyledDropdownVariableButtonContainer isUnfolded={isDropdownOpen}>
<IconVariable size={theme.icon.size.sm} />
</StyledDropdownVariableButtonContainer>
}
dropdownComponents={
<DropdownMenu>
<DropdownMenuItemsContainer>
{selectedStep ? (
<SearchVariablesDropdownStepSubItem
step={selectedStep}
onSelect={handleSubItemSelect}
onBack={handleBack}
/>
) : (
<SearchVariablesDropdownStepItem
steps={AVAILABLE_VARIABLES_MOCK}
onSelect={handleStepSelect}
/>
)}
</DropdownMenuItemsContainer>
</DropdownMenu>
}
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 0, y: 4 }}
/>
);
};
export default SearchVariablesDropdown;

View File

@ -0,0 +1,28 @@
import { MenuItemSelect } from '@/ui/navigation/menu-item/components/MenuItemSelect';
import { WorkflowStepMock } from '@/workflow/search-variables/types/WorkflowStepMock';
type SearchVariablesDropdownStepItemProps = {
steps: WorkflowStepMock[];
onSelect: (value: string) => void;
};
export const SearchVariablesDropdownStepItem = ({
steps,
onSelect,
}: SearchVariablesDropdownStepItemProps) => {
return (
<>
{steps.map((item, _index) => (
<MenuItemSelect
key={`step-${item.id}`}
selected={false}
hovered={false}
onClick={() => onSelect(item.id)}
text={item.name}
LeftIcon={undefined}
hasSubMenu
/>
))}
</>
);
};

View File

@ -0,0 +1,68 @@
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
import { MenuItemSelect } from '@/ui/navigation/menu-item/components/MenuItemSelect';
import { WorkflowStepMock } from '@/workflow/search-variables/types/WorkflowStepMock';
import { isObject } from '@sniptt/guards';
import { useState } from 'react';
import { IconChevronLeft } from 'twenty-ui';
type SearchVariablesDropdownStepSubItemProps = {
step: WorkflowStepMock;
onSelect: (value: string) => void;
onBack: () => void;
};
const SearchVariablesDropdownStepSubItem = ({
step,
onSelect,
onBack,
}: SearchVariablesDropdownStepSubItemProps) => {
const [currentPath, setCurrentPath] = useState<string[]>([]);
const getSelectedObject = () => {
let selected = step.output;
for (const key of currentPath) {
selected = selected[key];
}
return selected;
};
const handleSelect = (key: string) => {
const selectedObject = getSelectedObject();
if (isObject(selectedObject[key])) {
setCurrentPath([...currentPath, key]);
} else {
onSelect(`{{${step.id}.${[...currentPath, key].join('.')}}}`);
}
};
const goBack = () => {
if (currentPath.length === 0) {
onBack();
} else {
setCurrentPath(currentPath.slice(0, -1));
}
};
const headerLabel = currentPath.length === 0 ? step.name : currentPath.at(-1);
return (
<>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={goBack}>
{headerLabel}
</DropdownMenuHeader>
{Object.entries(getSelectedObject()).map(([key, value]) => (
<MenuItemSelect
key={key}
selected={false}
hovered={false}
onClick={() => handleSelect(key)}
text={key}
hasSubMenu={isObject(value)}
LeftIcon={undefined}
/>
))}
</>
);
};
export default SearchVariablesDropdownStepSubItem;

View File

@ -0,0 +1,154 @@
import SearchVariablesDropdown from '@/workflow/search-variables/components/SearchVariablesDropdown';
import { initializeEditorContent } from '@/workflow/search-variables/utils/initializeEditorContent';
import { parseEditorContent } from '@/workflow/search-variables/utils/parseEditorContent';
import { VariableTag } from '@/workflow/search-variables/utils/variableTag';
import styled from '@emotion/styled';
import Document from '@tiptap/extension-document';
import Paragraph from '@tiptap/extension-paragraph';
import Placeholder from '@tiptap/extension-placeholder';
import Text from '@tiptap/extension-text';
import { EditorContent, useEditor } from '@tiptap/react';
import { isDefined } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce';
const StyledContainer = styled.div`
display: inline-flex;
flex-direction: column;
`;
const StyledLabel = styled.div`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(1)};
`;
const StyledInputContainer = styled.div`
display: flex;
flex-direction: row;
`;
const StyledSearchVariablesDropdownContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter};
border-top-right-radius: ${({ theme }) => theme.border.radius.sm};
border-bottom-right-radius: ${({ theme }) => theme.border.radius.sm};
border: 1px solid ${({ theme }) => theme.border.color.medium};
`;
const StyledEditor = styled.div`
display: flex;
height: 32px;
width: 100%;
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-bottom-left-radius: ${({ theme }) => theme.border.radius.sm};
border-top-left-radius: ${({ theme }) => theme.border.radius.sm};
border-right: none;
box-sizing: border-box;
background-color: ${({ theme }) => theme.background.transparent.lighter};
overflow: hidden;
padding: ${({ theme }) => theme.spacing(2)};
.editor-content {
width: 100%;
}
.tiptap {
display: flex;
align-items: center;
height: 100%;
color: ${({ theme }) => theme.font.color.primary};
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.regular};
border: none !important;
white-space: nowrap;
p.is-editor-empty:first-of-type::before {
content: attr(data-placeholder);
color: ${({ theme }) => theme.font.color.light};
float: left;
height: 0;
pointer-events: none;
}
p {
margin: 0;
}
.variable-tag {
color: ${({ theme }) => theme.color.blue};
background-color: ${({ theme }) => theme.color.blue10};
padding: ${({ theme }) => theme.spacing(1)};
border-radius: ${({ theme }) => theme.border.radius.sm};
}
}
.ProseMirror-focused {
outline: none;
}
`;
interface VariableTagInputProps {
inputId: string;
label?: string;
value?: string;
onChange?: (content: string) => void;
placeholder?: string;
}
export const VariableTagInput = ({
inputId,
label,
value,
placeholder,
onChange,
}: VariableTagInputProps) => {
const deboucedOnUpdate = useDebouncedCallback((editor) => {
const jsonContent = editor.getJSON();
const parsedContent = parseEditorContent(jsonContent);
onChange?.(parsedContent);
}, 500);
const editor = useEditor({
extensions: [
Document,
Paragraph,
Text,
Placeholder.configure({
placeholder,
}),
VariableTag,
],
editable: true,
onCreate: ({ editor }) => {
if (isDefined(value)) {
initializeEditorContent(editor, value);
}
},
onUpdate: ({ editor }) => {
deboucedOnUpdate(editor);
},
});
if (!editor) {
return null;
}
return (
<StyledContainer>
{label && <StyledLabel>{label}</StyledLabel>}
<StyledInputContainer>
<StyledEditor>
<EditorContent className="editor-content" editor={editor} />
</StyledEditor>
<StyledSearchVariablesDropdownContainer>
<SearchVariablesDropdown inputId={inputId} editor={editor} />
</StyledSearchVariablesDropdownContainer>
</StyledInputContainer>
</StyledContainer>
);
};
export default VariableTagInput;

View File

@ -0,0 +1,30 @@
import { WorkflowStepMock } from '@/workflow/search-variables/types/WorkflowStepMock';
export const AVAILABLE_VARIABLES_MOCK: WorkflowStepMock[] = [
{
id: '1',
name: 'Person is Created',
output: {
userId: '1',
recordId: '123',
objectMetadataItem: {
id: '1234',
nameSingular: 'person',
namePlural: 'people',
},
properties: {
after: {
name: 'John Doe',
email: 'john.doe@email.com',
},
},
},
},
{
id: '2',
name: 'Send Email',
output: {
success: true,
},
},
];

View File

@ -0,0 +1 @@
export const SEARCH_VARIABLES_DROPDOWN_ID = 'search-variables';

View File

@ -0,0 +1,5 @@
export type WorkflowStepMock = {
id: string;
name: string;
output: Record<string, any>;
};

View File

@ -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, '!');
});
});

View File

@ -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}}',
);
});
});

View File

@ -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);
}
});
};

View File

@ -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);
};

View File

@ -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;
},
}),
});