diff --git a/packages/twenty-front/package.json b/packages/twenty-front/package.json index 80eb86a7cf..e95bbba5e4 100644 --- a/packages/twenty-front/package.json +++ b/packages/twenty-front/package.json @@ -33,6 +33,12 @@ "@nivo/calendar": "^0.87.0", "@nivo/core": "^0.87.0", "@nivo/line": "^0.87.0", + "@tiptap/extension-document": "^2.9.0", + "@tiptap/extension-paragraph": "^2.9.0", + "@tiptap/extension-placeholder": "^2.9.0", + "@tiptap/extension-text": "^2.9.0", + "@tiptap/extension-text-style": "^2.8.0", + "@tiptap/react": "^2.8.0", "@xyflow/react": "^12.0.4", "transliteration": "^2.3.5" } diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/phone/components/PhoneCountryPickerDropdownButton.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/phone/components/PhoneCountryPickerDropdownButton.tsx index 55d36556cb..0618202504 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/phone/components/PhoneCountryPickerDropdownButton.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/phone/components/PhoneCountryPickerDropdownButton.tsx @@ -69,7 +69,9 @@ export const PhoneCountryPickerDropdownButton = ({ const [selectedCountry, setSelectedCountry] = useState(); - const { isDropdownOpen, closeDropdown } = useDropdown('country-picker'); + const { isDropdownOpen, closeDropdown } = useDropdown( + CountryPickerHotkeyScope.CountryPicker, + ); const handleChange = (countryCode: string) => { onChange(countryCode); diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx index f7d3255c12..764da25a81 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx @@ -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 }) => ( - ( - 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 ( + + + + } + dropdownComponents={ + + + {selectedStep ? ( + + ) : ( + + )} + + + } + dropdownPlacement="bottom-end" + dropdownOffset={{ x: 0, y: 4 }} + /> + ); +}; + +export default SearchVariablesDropdown; diff --git a/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownStepItem.tsx b/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownStepItem.tsx new file mode 100644 index 0000000000..ea0d7cbe5a --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownStepItem.tsx @@ -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) => ( + onSelect(item.id)} + text={item.name} + LeftIcon={undefined} + hasSubMenu + /> + ))} + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownStepSubItem.tsx b/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownStepSubItem.tsx new file mode 100644 index 0000000000..2055912892 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownStepSubItem.tsx @@ -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([]); + + 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 ( + <> + + {headerLabel} + + {Object.entries(getSelectedObject()).map(([key, value]) => ( + handleSelect(key)} + text={key} + hasSubMenu={isObject(value)} + LeftIcon={undefined} + /> + ))} + + ); +}; + +export default SearchVariablesDropdownStepSubItem; diff --git a/packages/twenty-front/src/modules/workflow/search-variables/components/VariableTagInput.tsx b/packages/twenty-front/src/modules/workflow/search-variables/components/VariableTagInput.tsx new file mode 100644 index 0000000000..8b347ecfb6 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/search-variables/components/VariableTagInput.tsx @@ -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 ( + + {label && {label}} + + + + + + + + + + ); +}; + +export default VariableTagInput; diff --git a/packages/twenty-front/src/modules/workflow/search-variables/constants/AvailableVariablesMock.ts b/packages/twenty-front/src/modules/workflow/search-variables/constants/AvailableVariablesMock.ts new file mode 100644 index 0000000000..bb98363608 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/search-variables/constants/AvailableVariablesMock.ts @@ -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, + }, + }, +]; diff --git a/packages/twenty-front/src/modules/workflow/search-variables/constants/SearchVariablesDropdownId.ts b/packages/twenty-front/src/modules/workflow/search-variables/constants/SearchVariablesDropdownId.ts new file mode 100644 index 0000000000..72ba97f811 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/search-variables/constants/SearchVariablesDropdownId.ts @@ -0,0 +1 @@ +export const SEARCH_VARIABLES_DROPDOWN_ID = 'search-variables'; diff --git a/packages/twenty-front/src/modules/workflow/search-variables/types/WorkflowStepMock.ts b/packages/twenty-front/src/modules/workflow/search-variables/types/WorkflowStepMock.ts new file mode 100644 index 0000000000..7f6d1813a3 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/search-variables/types/WorkflowStepMock.ts @@ -0,0 +1,5 @@ +export type WorkflowStepMock = { + id: string; + name: string; + output: Record; +}; diff --git a/packages/twenty-front/src/modules/workflow/search-variables/utils/__tests__/initializeEditorContent.test.ts b/packages/twenty-front/src/modules/workflow/search-variables/utils/__tests__/initializeEditorContent.test.ts new file mode 100644 index 0000000000..58220e3374 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/search-variables/utils/__tests__/initializeEditorContent.test.ts @@ -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, '!'); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/search-variables/utils/__tests__/parseEditorContent.test.ts b/packages/twenty-front/src/modules/workflow/search-variables/utils/__tests__/parseEditorContent.test.ts new file mode 100644 index 0000000000..b3ea1e5c34 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/search-variables/utils/__tests__/parseEditorContent.test.ts @@ -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}}', + ); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/search-variables/utils/initializeEditorContent.ts b/packages/twenty-front/src/modules/workflow/search-variables/utils/initializeEditorContent.ts new file mode 100644 index 0000000000..5128bc06ec --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/search-variables/utils/initializeEditorContent.ts @@ -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); + } + }); +}; diff --git a/packages/twenty-front/src/modules/workflow/search-variables/utils/parseEditorContent.ts b/packages/twenty-front/src/modules/workflow/search-variables/utils/parseEditorContent.ts new file mode 100644 index 0000000000..e1d77f3108 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/search-variables/utils/parseEditorContent.ts @@ -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); +}; diff --git a/packages/twenty-front/src/modules/workflow/search-variables/utils/variableTag.ts b/packages/twenty-front/src/modules/workflow/search-variables/utils/variableTag.ts new file mode 100644 index 0000000000..ec2361ee22 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/search-variables/utils/variableTag.ts @@ -0,0 +1,64 @@ +import { Node } from '@tiptap/core'; +import { mergeAttributes } from '@tiptap/react'; + +declare module '@tiptap/core' { + interface Commands { + 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; + }, + }), +}); diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index f85046affd..76bd4bc2a4 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -223,6 +223,7 @@ export { IconUser, IconUserCircle, IconUsers, + IconVariable, IconVideo, IconWand, IconWorld, diff --git a/yarn.lock b/yarn.lock index 615c20e4f3..962484b69c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14861,6 +14861,18 @@ __metadata: languageName: node linkType: hard +"@tiptap/extension-bubble-menu@npm:^2.8.0": + version: 2.8.0 + resolution: "@tiptap/extension-bubble-menu@npm:2.8.0" + dependencies: + tippy.js: "npm:^6.3.7" + peerDependencies: + "@tiptap/core": ^2.7.0 + "@tiptap/pm": ^2.7.0 + checksum: 10c0/8c05bf1a1ea3a72c290e69f64b5e165e1af740a5b1434d8da2ab457def27793ece75680f5ab7c6c5f264d69be75a2f42c104acb07f4338fd55a70028cd8a4ad1 + languageName: node + linkType: hard + "@tiptap/extension-code@npm:^2.5.0": version: 2.5.9 resolution: "@tiptap/extension-code@npm:2.5.9" @@ -14891,6 +14903,15 @@ __metadata: languageName: node linkType: hard +"@tiptap/extension-document@npm:^2.9.0": + version: 2.9.0 + resolution: "@tiptap/extension-document@npm:2.9.0" + peerDependencies: + "@tiptap/core": ^2.7.0 + checksum: 10c0/2cc551050f0d4507b0c8be93c2d17a11cb9649d9b667e9d0923d197ed686e16b7dedd9582538dd7e4d04c33a3ba91145809623fcda63cfdbc3ddf7f5066dca6e + languageName: node + linkType: hard + "@tiptap/extension-dropcursor@npm:^2.5.0": version: 2.5.9 resolution: "@tiptap/extension-dropcursor@npm:2.5.9" @@ -14913,6 +14934,18 @@ __metadata: languageName: node linkType: hard +"@tiptap/extension-floating-menu@npm:^2.8.0": + version: 2.8.0 + resolution: "@tiptap/extension-floating-menu@npm:2.8.0" + dependencies: + tippy.js: "npm:^6.3.7" + peerDependencies: + "@tiptap/core": ^2.7.0 + "@tiptap/pm": ^2.7.0 + checksum: 10c0/d9895b0c78d40dca295fe17bf2d3c1a181a2aeb1e9fec958ef7df8bac1fe59345f4f22a1bc3a5f7cfe54ff472c6ebea725c71b8db8f5082ec3e350e5da7f4a7d + languageName: node + linkType: hard + "@tiptap/extension-gapcursor@npm:^2.5.0": version: 2.5.9 resolution: "@tiptap/extension-gapcursor@npm:2.5.9" @@ -14982,6 +15015,25 @@ __metadata: languageName: node linkType: hard +"@tiptap/extension-paragraph@npm:^2.9.0": + version: 2.9.0 + resolution: "@tiptap/extension-paragraph@npm:2.9.0" + peerDependencies: + "@tiptap/core": ^2.7.0 + checksum: 10c0/23c36c28d76356a139fd113119d17df11dacda03e9f5b926d623bb2c0267e14505a4ba9eaa674094d38a766535abefa14cd2542797ad44f313a53587bd8893e6 + languageName: node + linkType: hard + +"@tiptap/extension-placeholder@npm:^2.9.0": + version: 2.9.0 + resolution: "@tiptap/extension-placeholder@npm:2.9.0" + peerDependencies: + "@tiptap/core": ^2.7.0 + "@tiptap/pm": ^2.7.0 + checksum: 10c0/e8e978a50af1d89e302e3086990f48a1d2fd8754a178faa42444788a4208d72e6f09ccd529eaa37705c1e3dfd15ffd54d063f5cc023a3533dadb34e9babf1cec + languageName: node + linkType: hard + "@tiptap/extension-strike@npm:^2.5.0": version: 2.5.9 resolution: "@tiptap/extension-strike@npm:2.5.9" @@ -15018,6 +15070,15 @@ __metadata: languageName: node linkType: hard +"@tiptap/extension-text-style@npm:^2.8.0": + version: 2.8.0 + resolution: "@tiptap/extension-text-style@npm:2.8.0" + peerDependencies: + "@tiptap/core": ^2.7.0 + checksum: 10c0/92abcb01139331aee8ed41170450ae6327017fe654b7e057394bbac2624a38351114de811f996b65a362fca6835015b160a32ea2a80efd175384b76f951ac181 + languageName: node + linkType: hard + "@tiptap/extension-text@npm:^2.5.0": version: 2.5.9 resolution: "@tiptap/extension-text@npm:2.5.9" @@ -15027,6 +15088,15 @@ __metadata: languageName: node linkType: hard +"@tiptap/extension-text@npm:^2.9.0": + version: 2.9.0 + resolution: "@tiptap/extension-text@npm:2.9.0" + peerDependencies: + "@tiptap/core": ^2.7.0 + checksum: 10c0/049a1ce42df566de647632461344414c59a52930cf6a530b987f51857df4373d41f83d8feea304f95a077617fd605b62503adc4cbcd28e688c564e24d4139391 + languageName: node + linkType: hard + "@tiptap/extension-underline@npm:^2.5.0": version: 2.5.9 resolution: "@tiptap/extension-underline@npm:2.5.9" @@ -15079,6 +15149,24 @@ __metadata: languageName: node linkType: hard +"@tiptap/react@npm:^2.8.0": + version: 2.8.0 + resolution: "@tiptap/react@npm:2.8.0" + dependencies: + "@tiptap/extension-bubble-menu": "npm:^2.8.0" + "@tiptap/extension-floating-menu": "npm:^2.8.0" + "@types/use-sync-external-store": "npm:^0.0.6" + fast-deep-equal: "npm:^3" + use-sync-external-store: "npm:^1.2.2" + peerDependencies: + "@tiptap/core": ^2.7.0 + "@tiptap/pm": ^2.7.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + checksum: 10c0/a925761dd9fa778fc7a3f32a502ee9874fa785c167ad6d37e2744d0c5b7d1e72bc0c7fafbf1c7f50f04a65d01d00435361a9aa2a44110d67836fbc43e8cd0f9e + languageName: node + linkType: hard + "@tokenizer/token@npm:^0.3.0": version: 0.3.0 resolution: "@tokenizer/token@npm:0.3.0" @@ -26521,7 +26609,7 @@ __metadata: languageName: node linkType: hard -"fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": +"fast-deep-equal@npm:^3, fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" checksum: 10c0/40dedc862eb8992c54579c66d914635afbec43350afbbe991235fdcb4e3a8d5af1b23ae7e79bef7d4882d0ecee06c3197488026998fb19f72dc95acff1d1b1d0 @@ -44086,6 +44174,12 @@ __metadata: "@nivo/calendar": "npm:^0.87.0" "@nivo/core": "npm:^0.87.0" "@nivo/line": "npm:^0.87.0" + "@tiptap/extension-document": "npm:^2.9.0" + "@tiptap/extension-paragraph": "npm:^2.9.0" + "@tiptap/extension-placeholder": "npm:^2.9.0" + "@tiptap/extension-text": "npm:^2.9.0" + "@tiptap/extension-text-style": "npm:^2.8.0" + "@tiptap/react": "npm:^2.8.0" "@xyflow/react": "npm:^12.0.4" transliteration: "npm:^2.3.5" languageName: unknown