diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx
index 993dd2863..ba0aa8195 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx
@@ -1,26 +1,50 @@
-import VariableTagInput from '@/workflow/search-variables/components/VariableTagInput';
+import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
+import { FormBooleanFieldInput } from '@/object-record/record-field/form-types/components/FormBooleanFieldInput';
+import { FormNumberFieldInput } from '@/object-record/record-field/form-types/components/FormNumberFieldInput';
+import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
+import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
+import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean';
+import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
+import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
+import { JsonValue } from 'type-fest';
type FormFieldInputProps = {
- recordFieldInputdId: string;
- label: string;
- value: string;
- onChange: (value: string) => void;
- isReadOnly?: boolean;
+ field: FieldMetadataItem;
+ defaultValue: JsonValue;
+ onPersist: (value: JsonValue) => void;
+ VariablePicker?: VariablePickerComponent;
};
export const FormFieldInput = ({
- recordFieldInputdId,
- label,
- onChange,
- value,
+ field,
+ defaultValue,
+ onPersist,
+ VariablePicker,
}: FormFieldInputProps) => {
- return (
-
- );
+ ) : isFieldBoolean(field) ? (
+
+ ) : isFieldText(field) ? (
+
+ ) : null;
};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormBooleanFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormBooleanFieldInput.tsx
new file mode 100644
index 000000000..660c076c0
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormBooleanFieldInput.tsx
@@ -0,0 +1,115 @@
+import { StyledFormFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputContainer';
+import { StyledFormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputInputContainer';
+import { StyledFormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer';
+import { VariableChip } from '@/object-record/record-field/form-types/components/VariableChip';
+import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
+import { BooleanInput } from '@/ui/field/input/components/BooleanInput';
+import { InputLabel } from '@/ui/input/components/InputLabel';
+import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
+import styled from '@emotion/styled';
+import { useId, useState } from 'react';
+import { isDefined } from 'twenty-ui';
+
+const StyledBooleanInputContainer = styled.div`
+ padding-inline: ${({ theme }) => theme.spacing(2)};
+`;
+
+type FormBooleanFieldInputProps = {
+ label?: string;
+ defaultValue: boolean | string | undefined;
+ onPersist: (value: boolean | null | string) => void;
+ VariablePicker?: VariablePickerComponent;
+ readonly?: boolean;
+};
+
+export const FormBooleanFieldInput = ({
+ label,
+ defaultValue,
+ onPersist,
+ readonly,
+ VariablePicker,
+}: FormBooleanFieldInputProps) => {
+ const inputId = useId();
+
+ const [draftValue, setDraftValue] = useState<
+ | {
+ type: 'static';
+ value: boolean;
+ }
+ | {
+ type: 'variable';
+ value: string;
+ }
+ >(
+ isStandaloneVariableString(defaultValue)
+ ? {
+ type: 'variable',
+ value: defaultValue,
+ }
+ : {
+ type: 'static',
+ value: defaultValue ?? false,
+ },
+ );
+
+ const handleChange = (newValue: boolean) => {
+ setDraftValue({
+ type: 'static',
+ value: newValue,
+ });
+
+ onPersist(newValue);
+ };
+
+ const handleVariableTagInsert = (variableName: string) => {
+ setDraftValue({
+ type: 'variable',
+ value: variableName,
+ });
+
+ onPersist(variableName);
+ };
+
+ const handleUnlinkVariable = () => {
+ setDraftValue({
+ type: 'static',
+ value: false,
+ });
+
+ onPersist(false);
+ };
+
+ return (
+
+ {label ? {label} : null}
+
+
+
+ {draftValue.type === 'static' ? (
+
+
+
+ ) : (
+
+ )}
+
+
+ {VariablePicker ? (
+
+ ) : null}
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormNumberFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormNumberFieldInput.tsx
new file mode 100644
index 000000000..fdbb077ee
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormNumberFieldInput.tsx
@@ -0,0 +1,130 @@
+import { StyledFormFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputContainer';
+import { StyledFormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputInputContainer';
+import { StyledFormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer';
+import { VariableChip } from '@/object-record/record-field/form-types/components/VariableChip';
+import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
+import { TextInput } from '@/ui/field/input/components/TextInput';
+import { InputLabel } from '@/ui/input/components/InputLabel';
+import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
+import styled from '@emotion/styled';
+import { useId, useState } from 'react';
+import { isDefined } from 'twenty-ui';
+import {
+ canBeCastAsNumberOrNull,
+ castAsNumberOrNull,
+} from '~/utils/cast-as-number-or-null';
+
+const StyledInput = styled(TextInput)`
+ padding: ${({ theme }) => `${theme.spacing(1)} ${theme.spacing(2)}`};
+`;
+
+type FormNumberFieldInputProps = {
+ label?: string;
+ placeholder: string;
+ defaultValue: number | string | undefined;
+ onPersist: (value: number | null | string) => void;
+ VariablePicker?: VariablePickerComponent;
+};
+
+export const FormNumberFieldInput = ({
+ label,
+ placeholder,
+ defaultValue,
+ onPersist,
+ VariablePicker,
+}: FormNumberFieldInputProps) => {
+ const inputId = useId();
+
+ const [draftValue, setDraftValue] = useState<
+ | {
+ type: 'static';
+ value: string;
+ }
+ | {
+ type: 'variable';
+ value: string;
+ }
+ >(
+ isStandaloneVariableString(defaultValue)
+ ? {
+ type: 'variable',
+ value: defaultValue,
+ }
+ : {
+ type: 'static',
+ value: isDefined(defaultValue) ? String(defaultValue) : '',
+ },
+ );
+
+ const persistNumber = (newValue: string) => {
+ if (!canBeCastAsNumberOrNull(newValue)) {
+ return;
+ }
+
+ const castedValue = castAsNumberOrNull(newValue);
+
+ onPersist(castedValue);
+ };
+
+ const handleChange = (newText: string) => {
+ setDraftValue({
+ type: 'static',
+ value: newText,
+ });
+
+ persistNumber(newText.trim());
+ };
+
+ const handleUnlinkVariable = () => {
+ setDraftValue({
+ type: 'static',
+ value: '',
+ });
+
+ onPersist(null);
+ };
+
+ const handleVariableTagInsert = (variableName: string) => {
+ setDraftValue({
+ type: 'variable',
+ value: variableName,
+ });
+
+ onPersist(variableName);
+ };
+
+ return (
+
+ {label ? {label} : null}
+
+
+
+ {draftValue.type === 'static' ? (
+
+ ) : (
+
+ )}
+
+
+ {VariablePicker ? (
+
+ ) : null}
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormTextFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormTextFieldInput.tsx
new file mode 100644
index 000000000..8916a918b
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormTextFieldInput.tsx
@@ -0,0 +1,86 @@
+import { StyledFormFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputContainer';
+import { StyledFormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputInputContainer';
+import { StyledFormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer';
+import { TextVariableEditor } from '@/object-record/record-field/form-types/components/TextVariableEditor';
+import { useTextVariableEditor } from '@/object-record/record-field/form-types/hooks/useTextVariableEditor';
+import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
+import { InputLabel } from '@/ui/input/components/InputLabel';
+import { parseEditorContent } from '@/workflow/search-variables/utils/parseEditorContent';
+import { useId } from 'react';
+import { isDefined } from 'twenty-ui';
+
+type FormTextFieldInputProps = {
+ label?: string;
+ defaultValue: string | undefined;
+ placeholder: string;
+ onPersist: (value: string) => void;
+ multiline?: boolean;
+ readonly?: boolean;
+ VariablePicker?: VariablePickerComponent;
+};
+
+export const FormTextFieldInput = ({
+ label,
+ defaultValue,
+ placeholder,
+ onPersist,
+ multiline,
+ readonly,
+ VariablePicker,
+}: FormTextFieldInputProps) => {
+ const inputId = useId();
+
+ const editor = useTextVariableEditor({
+ placeholder,
+ multiline,
+ readonly,
+ defaultValue,
+ onUpdate: (editor) => {
+ const jsonContent = editor.getJSON();
+ const parsedContent = parseEditorContent(jsonContent);
+
+ onPersist(parsedContent);
+ },
+ });
+
+ const handleVariableTagInsert = (variableName: string) => {
+ if (!isDefined(editor)) {
+ throw new Error(
+ 'Expected the editor to be defined when a variable is selected',
+ );
+ }
+
+ editor.commands.insertVariableTag(variableName);
+ };
+
+ if (!isDefined(editor)) {
+ return null;
+ }
+
+ return (
+
+ {label ? {label} : null}
+
+
+
+
+
+
+ {VariablePicker ? (
+
+ ) : null}
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/StyledFormFieldInputContainer.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/StyledFormFieldInputContainer.tsx
new file mode 100644
index 000000000..5615ab80b
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/StyledFormFieldInputContainer.tsx
@@ -0,0 +1,6 @@
+import styled from '@emotion/styled';
+
+export const StyledFormFieldInputContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+`;
diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/StyledFormFieldInputInputContainer.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/StyledFormFieldInputInputContainer.tsx
new file mode 100644
index 000000000..43fb3a192
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/StyledFormFieldInputInputContainer.tsx
@@ -0,0 +1,31 @@
+import { css } from '@emotion/react';
+import styled from '@emotion/styled';
+
+export const StyledFormFieldInputInputContainer = styled.div<{
+ hasRightElement: boolean;
+ multiline?: boolean;
+ readonly?: boolean;
+}>`
+ background-color: ${({ theme }) => theme.background.transparent.lighter};
+ border: 1px solid ${({ theme }) => theme.border.color.medium};
+ border-top-left-radius: ${({ theme }) => theme.border.radius.sm};
+ border-bottom-left-radius: ${({ theme }) => theme.border.radius.sm};
+
+ ${({ multiline, hasRightElement, theme }) =>
+ multiline || !hasRightElement
+ ? css`
+ border-right: auto;
+ border-bottom-right-radius: ${theme.border.radius.sm};
+ border-top-right-radius: ${theme.border.radius.sm};
+ `
+ : css`
+ border-right: none;
+ border-bottom-right-radius: none;
+ border-top-right-radius: none;
+ `}
+
+ box-sizing: border-box;
+ display: flex;
+ overflow: ${({ multiline }) => (multiline ? 'auto' : 'hidden')};
+ width: 100%;
+`;
diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer.tsx
new file mode 100644
index 000000000..527e3d18f
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer.tsx
@@ -0,0 +1,23 @@
+import { css } from '@emotion/react';
+import styled from '@emotion/styled';
+
+export const LINE_HEIGHT = 24;
+
+export const StyledFormFieldInputRowContainer = styled.div<{
+ multiline?: boolean;
+}>`
+ display: flex;
+ flex-direction: row;
+ position: relative;
+
+ ${({ multiline }) =>
+ multiline
+ ? css`
+ line-height: ${LINE_HEIGHT}px;
+ min-height: ${3 * LINE_HEIGHT}px;
+ max-height: ${5 * LINE_HEIGHT}px;
+ `
+ : css`
+ height: 32px;
+ `}
+`;
diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/TextVariableEditor.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/TextVariableEditor.tsx
new file mode 100644
index 000000000..29df0894f
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/TextVariableEditor.tsx
@@ -0,0 +1,71 @@
+import styled from '@emotion/styled';
+import { Editor, EditorContent } from '@tiptap/react';
+
+const StyledEditor = styled.div<{ multiline?: boolean; readonly?: boolean }>`
+ width: 100%;
+ display: flex;
+ box-sizing: border-box;
+ padding-right: ${({ multiline, theme }) =>
+ multiline ? theme.spacing(4) : undefined};
+
+ .editor-content {
+ width: 100%;
+ }
+
+ .tiptap {
+ padding: ${({ theme }) => `${theme.spacing(1)} ${theme.spacing(2)}`};
+ box-sizing: border-box;
+ display: flex;
+ height: 100%;
+ overflow: ${({ multiline }) => (multiline ? 'auto' : 'hidden')};
+ color: ${({ theme, readonly }) =>
+ readonly ? theme.font.color.light : theme.font.color.primary};
+ font-family: ${({ theme }) => theme.font.family};
+ font-weight: ${({ theme }) => theme.font.weight.regular};
+ border: none !important;
+ align-items: ${({ multiline }) => (multiline ? 'top' : 'center')};
+ white-space: ${({ multiline }) => (multiline ? 'pre' : 'nowrap')};
+
+ p.is-editor-empty:first-of-type::before {
+ content: attr(data-placeholder);
+ color: ${({ theme }) => theme.font.color.light};
+ font-weight: ${({ theme }) => theme.font.weight.medium};
+ float: left;
+ height: 0;
+ pointer-events: none;
+ }
+
+ p {
+ margin: 0;
+ }
+
+ .variable-tag {
+ background-color: ${({ theme }) => theme.color.blue10};
+ border-radius: ${({ theme }) => theme.border.radius.sm};
+ color: ${({ theme }) => theme.color.blue};
+ padding: ${({ theme }) => theme.spacing(1)};
+ }
+ }
+
+ .ProseMirror-focused {
+ outline: none;
+ }
+`;
+
+type TextVariableEditorProps = {
+ multiline: boolean | undefined;
+ readonly: boolean | undefined;
+ editor: Editor;
+};
+
+export const TextVariableEditor = ({
+ multiline,
+ readonly,
+ editor,
+}: TextVariableEditorProps) => {
+ return (
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/VariableChip.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/VariableChip.tsx
new file mode 100644
index 000000000..15273f1af
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/VariableChip.tsx
@@ -0,0 +1,27 @@
+import { SortOrFilterChip } from '@/views/components/SortOrFilterChip';
+import { extractVariableLabel } from '@/workflow/search-variables/utils/extractVariableLabel';
+import styled from '@emotion/styled';
+
+export const StyledContainer = styled.div`
+ align-items: center;
+ display: flex;
+`;
+
+type VariableChipProps = {
+ rawVariableName: string;
+ onRemove: () => void;
+};
+
+export const VariableChip = ({
+ rawVariableName,
+ onRemove,
+}: VariableChipProps) => {
+ return (
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormBooleanFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormBooleanFieldInput.stories.tsx
new file mode 100644
index 000000000..8ae79ea62
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormBooleanFieldInput.stories.tsx
@@ -0,0 +1,56 @@
+import { Meta, StoryObj } from '@storybook/react';
+import { within } from '@storybook/test';
+import { FormBooleanFieldInput } from '../FormBooleanFieldInput';
+
+const meta: Meta = {
+ title: 'UI/Data/Field/Form/Input/FormBooleanFieldInput',
+ component: FormBooleanFieldInput,
+ args: {},
+ argTypes: {},
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {},
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await canvas.findByText('False');
+ },
+};
+
+export const WithLabel: Story = {
+ args: {
+ label: 'Boolean',
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await canvas.findByText('Boolean');
+ },
+};
+
+export const TrueByDefault: Story = {
+ args: {
+ defaultValue: true,
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await canvas.findByText('True');
+ },
+};
+
+export const FalseByDefault: Story = {
+ args: {
+ defaultValue: false,
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await canvas.findByText('False');
+ },
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormNumberFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormNumberFieldInput.stories.tsx
new file mode 100644
index 000000000..86fd52adc
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormNumberFieldInput.stories.tsx
@@ -0,0 +1,38 @@
+import { Meta, StoryObj } from '@storybook/react';
+import { within } from '@storybook/test';
+import { FormNumberFieldInput } from '../FormNumberFieldInput';
+
+const meta: Meta = {
+ title: 'UI/Data/Field/Form/Input/FormNumberFieldInput',
+ component: FormNumberFieldInput,
+ args: {},
+ argTypes: {},
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ placeholder: 'Number field...',
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await canvas.findByPlaceholderText('Number field...');
+ },
+};
+
+export const WithLabel: Story = {
+ args: {
+ label: 'Number',
+ placeholder: 'Number field...',
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await canvas.findByText('Number');
+ await canvas.findByPlaceholderText('Number field...');
+ },
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormTextFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormTextFieldInput.stories.tsx
new file mode 100644
index 000000000..ec0aada4c
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormTextFieldInput.stories.tsx
@@ -0,0 +1,45 @@
+import { Meta, StoryObj } from '@storybook/react';
+import { within } from '@storybook/test';
+import { FormTextFieldInput } from '../FormTextFieldInput';
+
+const meta: Meta = {
+ title: 'UI/Data/Field/Form/Input/FormTextFieldInput',
+ component: FormTextFieldInput,
+ args: {},
+ argTypes: {},
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ placeholder: 'Text field...',
+ },
+};
+
+export const WithLabel: Story = {
+ args: {
+ label: 'Text',
+ placeholder: 'Text field...',
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await canvas.findByText(/^Text$/);
+ },
+};
+
+export const Multiline: Story = {
+ args: {
+ label: 'Text',
+ placeholder: 'Text field...',
+ multiline: true,
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await canvas.findByText(/^Text$/);
+ },
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/hooks/useTextVariableEditor.ts b/packages/twenty-front/src/modules/object-record/record-field/form-types/hooks/useTextVariableEditor.ts
new file mode 100644
index 000000000..bbb938474
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/hooks/useTextVariableEditor.ts
@@ -0,0 +1,78 @@
+import { initializeEditorContent } from '@/workflow/search-variables/utils/initializeEditorContent';
+import { VariableTag } from '@/workflow/search-variables/utils/variableTag';
+import Document from '@tiptap/extension-document';
+import HardBreak from '@tiptap/extension-hard-break';
+import Paragraph from '@tiptap/extension-paragraph';
+import { default as Placeholder } from '@tiptap/extension-placeholder';
+import Text from '@tiptap/extension-text';
+import { Editor, useEditor } from '@tiptap/react';
+import { isDefined } from 'twenty-ui';
+
+type UseTextVariableEditorProps = {
+ placeholder: string | undefined;
+ multiline: boolean | undefined;
+ readonly: boolean | undefined;
+ defaultValue: string | undefined;
+ onUpdate: (editor: Editor) => void;
+};
+
+export const useTextVariableEditor = ({
+ placeholder,
+ multiline,
+ readonly,
+ defaultValue,
+ onUpdate,
+}: UseTextVariableEditorProps) => {
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Placeholder.configure({
+ placeholder,
+ }),
+ VariableTag,
+ ...(multiline
+ ? [
+ HardBreak.configure({
+ keepMarks: false,
+ }),
+ ]
+ : []),
+ ],
+ editable: !readonly,
+ onCreate: ({ editor }) => {
+ if (isDefined(defaultValue)) {
+ initializeEditorContent(editor, defaultValue);
+ }
+ },
+ onUpdate: ({ editor }) => {
+ onUpdate(editor);
+ },
+ editorProps: {
+ handleKeyDown: (view, event) => {
+ if (event.key === 'Enter' && !event.shiftKey) {
+ event.preventDefault();
+
+ const { state } = view;
+ const { tr } = state;
+
+ // Insert hard break using the view's state and dispatch
+ const transaction = tr.replaceSelectionWith(
+ state.schema.nodes.hardBreak.create(),
+ );
+
+ view.dispatch(transaction);
+
+ return true;
+ }
+ return false;
+ },
+ },
+ enableInputRules: false,
+ enablePasteRules: false,
+ injectCSS: false,
+ });
+
+ return editor;
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/types/EditingMode.ts b/packages/twenty-front/src/modules/object-record/record-field/form-types/types/EditingMode.ts
new file mode 100644
index 000000000..a80a00204
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/types/EditingMode.ts
@@ -0,0 +1 @@
+export type EditingMode = 'input' | 'variable';
diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/types/VariablePickerComponent.ts b/packages/twenty-front/src/modules/object-record/record-field/form-types/types/VariablePickerComponent.ts
new file mode 100644
index 000000000..8e4d4371e
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/types/VariablePickerComponent.ts
@@ -0,0 +1,6 @@
+export type VariablePickerComponent = React.FC<{
+ inputId: string;
+ disabled?: boolean;
+ multiline?: boolean;
+ onVariableSelect: (variableName: string) => void;
+}>;
diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents.ts
index c4ce8f03a..8e41832d2 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents.ts
+++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents.ts
@@ -18,8 +18,8 @@ export const useRegisterInputEvents = ({
inputRef: React.RefObject;
copyRef?: React.RefObject;
inputValue: T;
- onEscape: (inputValue: T) => void;
- onEnter: (inputValue: T) => void;
+ onEscape?: (inputValue: T) => void;
+ onEnter?: (inputValue: T) => void;
onTab?: (inputValue: T) => void;
onShiftTab?: (inputValue: T) => void;
onClickOutside?: (event: MouseEvent | TouchEvent, inputValue: T) => void;
diff --git a/packages/twenty-front/src/modules/ui/field/input/components/TextInput.tsx b/packages/twenty-front/src/modules/ui/field/input/components/TextInput.tsx
index a5afa4209..9699765b2 100644
--- a/packages/twenty-front/src/modules/ui/field/input/components/TextInput.tsx
+++ b/packages/twenty-front/src/modules/ui/field/input/components/TextInput.tsx
@@ -12,11 +12,12 @@ export const StyledTextInput = styled.input`
`;
type TextInputProps = {
+ inputId?: string;
placeholder?: string;
autoFocus?: boolean;
value: string;
- onEnter: (newText: string) => void;
- onEscape: (newText: string) => void;
+ onEnter?: (newText: string) => void;
+ onEscape?: (newText: string) => void;
onTab?: (newText: string) => void;
onShiftTab?: (newText: string) => void;
onClickOutside?: (event: MouseEvent | TouchEvent, inputValue: string) => void;
@@ -35,6 +36,7 @@ const getValue = (value: string, shouldTrim: boolean) => {
};
export const TextInput = ({
+ inputId,
placeholder,
autoFocus,
value,
@@ -76,6 +78,7 @@ export const TextInput = ({
return (
<>
theme.font.color.light};
+ font-size: ${({ theme }) => theme.font.size.xs};
+ font-weight: ${({ theme }) => theme.font.weight.semiBold};
+ margin-bottom: ${({ theme }) => theme.spacing(1)};
+`;
+
+export const InputLabel = StyledLabel;
diff --git a/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx b/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx
index 557e67ced..d770d8a1b 100644
--- a/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx
+++ b/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx
@@ -13,6 +13,7 @@ import {
import { IconComponent, IconEye, IconEyeOff, RGBA } from 'twenty-ui';
import { useCombinedRefs } from '~/hooks/useCombinedRefs';
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
+import { InputLabel } from './InputLabel';
const StyledContainer = styled.div<
Pick
@@ -22,13 +23,6 @@ const StyledContainer = styled.div<
width: ${({ fullWidth }) => (fullWidth ? `100%` : 'auto')};
`;
-const StyledLabel = styled.label`
- 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;
@@ -177,9 +171,9 @@ const TextInputV2Component = (
return (
{label && (
-
+
{label + (required ? '*' : '')}
-
+
)}
{!!LeftIcon && (
diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormRecordCreate.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormRecordCreate.tsx
index aa86a84a8..7408e2465 100644
--- a/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormRecordCreate.tsx
+++ b/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormRecordCreate.tsx
@@ -2,6 +2,7 @@ import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilte
import { FormFieldInput } from '@/object-record/record-field/components/FormFieldInput';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
+import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker';
import { WorkflowRecordCreateAction } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
import { useEffect, useState } from 'react';
@@ -11,6 +12,7 @@ import {
isDefined,
useIcons,
} from 'twenty-ui';
+import { JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { FieldMetadataType } from '~/generated/graphql';
@@ -55,7 +57,7 @@ export const WorkflowEditActionFormRecordCreate = ({
const handleFieldChange = (
fieldName: keyof SendEmailFormData,
- updatedValue: string,
+ updatedValue: JsonValue,
) => {
const newFormData: SendEmailFormData = {
...formData,
@@ -163,17 +165,21 @@ export const WorkflowEditActionFormRecordCreate = ({
- {editableFields.map((field) => (
- {
- handleFieldChange(field.name, value);
- }}
- />
- ))}
+ {editableFields.map((field) => {
+ const currentValue = formData[field.name] as JsonValue;
+
+ return (
+ {
+ handleFieldChange(field.name, value);
+ }}
+ VariablePicker={WorkflowVariablePicker}
+ />
+ );
+ })}
);
};
diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx
index c4b77b000..7e0608f33 100644
--- a/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx
+++ b/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx
@@ -2,10 +2,11 @@ import { GMAIL_SEND_SCOPE } from '@/accounts/constants/GmailSendScope';
import { ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
+import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
-import { VariableTagInput } from '@/workflow/search-variables/components/VariableTagInput';
+import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { WorkflowSendEmailAction } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
@@ -214,16 +215,16 @@ export const WorkflowEditActionFormSendEmail = ({
name="email"
control={form.control}
render={({ field }) => (
- {
- field.onChange(email);
+ readonly={actionOptions.readonly}
+ defaultValue={field.value}
+ onPersist={(value) => {
+ field.onChange(value);
handleSave();
}}
- readonly={actionOptions.readonly}
+ VariablePicker={WorkflowVariablePicker}
/>
)}
/>
@@ -231,16 +232,16 @@ export const WorkflowEditActionFormSendEmail = ({
name="subject"
control={form.control}
render={({ field }) => (
- {
- field.onChange(email);
+ readonly={actionOptions.readonly}
+ defaultValue={field.value}
+ onPersist={(value) => {
+ field.onChange(value);
handleSave();
}}
- readonly={actionOptions.readonly}
+ VariablePicker={WorkflowVariablePicker}
/>
)}
/>
@@ -248,17 +249,16 @@ export const WorkflowEditActionFormSendEmail = ({
name="body"
control={form.control}
render={({ field }) => (
- {
- field.onChange(email);
+ readonly={actionOptions.readonly}
+ defaultValue={field.value}
+ onPersist={(value) => {
+ field.onChange(value);
handleSave();
}}
- multiline
- readonly={actionOptions.readonly}
+ VariablePicker={WorkflowVariablePicker}
/>
)}
/>
diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormServerlessFunctionInner.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormServerlessFunctionInner.tsx
index 0ce875ed3..5e45a9555 100644
--- a/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormServerlessFunctionInner.tsx
+++ b/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormServerlessFunctionInner.tsx
@@ -1,7 +1,8 @@
+import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { useGetManyServerlessFunctions } from '@/settings/serverless-functions/hooks/useGetManyServerlessFunctions';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
-import VariableTagInput from '@/workflow/search-variables/components/VariableTagInput';
+import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker';
import { FunctionInput } from '@/workflow/types/FunctionInput';
import { WorkflowCodeAction } from '@/workflow/types/Workflow';
import { getDefaultFunctionInputFromInputSchema } from '@/workflow/utils/getDefaultFunctionInputFromInputSchema';
@@ -203,14 +204,16 @@ export const WorkflowEditActionFormServerlessFunctionInner = ({
);
} else {
return (
- handleInputChange(value, currentPath)}
+ onPersist={(value) => {
+ handleInputChange(value, currentPath);
+ }}
+ VariablePicker={WorkflowVariablePicker}
/>
);
}
diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowVariablePicker.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowVariablePicker.tsx
new file mode 100644
index 000000000..12bf787ac
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/components/WorkflowVariablePicker.tsx
@@ -0,0 +1,57 @@
+import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
+import SearchVariablesDropdown from '@/workflow/search-variables/components/SearchVariablesDropdown';
+import { css } from '@emotion/react';
+import styled from '@emotion/styled';
+
+export const StyledSearchVariablesDropdownContainer = styled.div<{
+ multiline?: boolean;
+ readonly?: boolean;
+}>`
+ align-items: center;
+ display: flex;
+ justify-content: center;
+
+ ${({ theme, readonly }) =>
+ !readonly &&
+ css`
+ :hover {
+ background-color: ${theme.background.transparent.light};
+ }
+ `}
+
+ ${({ theme, multiline }) =>
+ multiline
+ ? css`
+ border-radius: ${theme.border.radius.sm};
+ padding: ${theme.spacing(0.5)} ${theme.spacing(0)};
+ position: absolute;
+ right: ${theme.spacing(0)};
+ top: ${theme.spacing(0)};
+ `
+ : css`
+ background-color: ${theme.background.transparent.lighter};
+ border-top-right-radius: ${theme.border.radius.sm};
+ border-bottom-right-radius: ${theme.border.radius.sm};
+ border: 1px solid ${theme.border.color.medium};
+ `}
+`;
+
+export const WorkflowVariablePicker: VariablePickerComponent = ({
+ inputId,
+ disabled,
+ multiline,
+ onVariableSelect,
+}) => {
+ return (
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdown.tsx b/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdown.tsx
index 584161a41..b20d6695b 100644
--- a/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdown.tsx
+++ b/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdown.tsx
@@ -9,7 +9,6 @@ import { useAvailableVariablesInWorkflowStep } from '@/workflow/search-variables
import { StepOutputSchema } from '@/workflow/search-variables/types/StepOutputSchema';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
-import { Editor } from '@tiptap/react';
import { useState } from 'react';
import { IconVariablePlus } from 'twenty-ui';
@@ -29,11 +28,11 @@ const StyledDropdownVariableButtonContainer = styled(
const SearchVariablesDropdown = ({
inputId,
- editor,
+ onVariableSelect,
disabled,
}: {
inputId: string;
- editor: Editor;
+ onVariableSelect: (variableName: string) => void;
disabled?: boolean;
}) => {
const theme = useTheme();
@@ -52,10 +51,6 @@ const SearchVariablesDropdown = ({
StepOutputSchema | undefined
>(initialStep);
- const insertVariableTag = (variable: string) => {
- editor.commands.insertVariableTag(variable);
- };
-
const handleStepSelect = (stepId: string) => {
setSelectedStep(
availableVariablesInWorkflowStep.find((step) => step.id === stepId),
@@ -63,7 +58,7 @@ const SearchVariablesDropdown = ({
};
const handleSubItemSelect = (subItem: string) => {
- insertVariableTag(subItem);
+ onVariableSelect(subItem);
};
const handleBack = () => {
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
deleted file mode 100644
index 055f5e9b6..000000000
--- a/packages/twenty-front/src/modules/workflow/search-variables/components/VariableTagInput.tsx
+++ /dev/null
@@ -1,236 +0,0 @@
-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 HardBreak from '@tiptap/extension-hard-break';
-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 LINE_HEIGHT = 24;
-
-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.md};
- font-weight: ${({ theme }) => theme.font.weight.semiBold};
- margin-bottom: ${({ theme }) => theme.spacing(1)};
-`;
-
-const StyledInputContainer = styled.div<{
- multiline?: boolean;
-}>`
- display: flex;
- flex-direction: row;
- position: relative;
- line-height: ${({ multiline }) => (multiline ? `${LINE_HEIGHT}px` : 'auto')};
- min-height: ${({ multiline }) =>
- multiline ? `${3 * LINE_HEIGHT}px` : 'auto'};
- max-height: ${({ multiline }) =>
- multiline ? `${5 * LINE_HEIGHT}px` : 'auto'};
-`;
-
-const StyledSearchVariablesDropdownContainer = styled.div<{
- multiline?: boolean;
- readonly?: boolean;
-}>`
- align-items: center;
- display: flex;
- justify-content: center;
-
- ${({ theme, readonly }) =>
- !readonly &&
- `
- :hover {
- background-color: ${theme.background.transparent.light};
- }`}
-
- ${({ theme, multiline }) =>
- multiline
- ? `
- position: absolute;
- top: ${theme.spacing(0)};
- right: ${theme.spacing(0)};
- padding: ${theme.spacing(0.5)} ${theme.spacing(0)};
- border-radius: ${theme.border.radius.sm};
- `
- : `
- background-color: ${theme.background.transparent.lighter};
- border-top-right-radius: ${theme.border.radius.sm};
- border-bottom-right-radius: ${theme.border.radius.sm};
- border: 1px solid ${theme.border.color.medium};
- `}
-`;
-
-const StyledEditor = styled.div<{ multiline?: boolean; readonly?: boolean }>`
- display: flex;
- 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};
- box-sizing: border-box;
- background-color: ${({ theme }) => theme.background.transparent.lighter};
- padding: ${({ theme }) => `${theme.spacing(1)} ${theme.spacing(2)}`};
- border-bottom-right-radius: ${({ multiline, theme }) =>
- multiline ? theme.border.radius.sm : 'none'};
- border-top-right-radius: ${({ multiline, theme }) =>
- multiline ? theme.border.radius.sm : 'none'};
- border-right: ${({ multiline }) => (multiline ? 'auto' : 'none')};
- padding-right: ${({ multiline, theme }) =>
- multiline ? theme.spacing(6) : theme.spacing(2)};
- overflow: ${({ multiline }) => (multiline ? 'auto' : 'hidden')};
- height: ${({ multiline }) => (multiline ? 'auto' : `${1.5 * LINE_HEIGHT}px`)};
-
- .editor-content {
- width: 100%;
- }
-
- .tiptap {
- display: flex;
- height: 100%;
- color: ${({ theme, readonly }) =>
- readonly ? theme.font.color.light : theme.font.color.primary};
- font-family: ${({ theme }) => theme.font.family};
- font-weight: ${({ theme }) => theme.font.weight.regular};
- border: none !important;
- align-items: ${({ multiline }) => (multiline ? 'top' : 'center')};
- white-space: ${({ multiline }) => (multiline ? 'pre-wrap' : 'nowrap')};
- word-wrap: ${({ multiline }) => (multiline ? 'break-word' : 'normal')};
-
- 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;
- placeholder?: string;
- multiline?: boolean;
- onChange?: (content: string) => void;
- readonly?: boolean;
-}
-
-export const VariableTagInput = ({
- inputId,
- label,
- value,
- placeholder,
- multiline,
- onChange,
- readonly,
-}: 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,
- ...(multiline
- ? [
- HardBreak.configure({
- keepMarks: false,
- }),
- ]
- : []),
- ],
- editable: !readonly,
- onCreate: ({ editor }) => {
- if (isDefined(value)) {
- initializeEditorContent(editor, value);
- }
- },
- onUpdate: ({ editor }) => {
- deboucedOnUpdate(editor);
- },
- editorProps: {
- handleKeyDown: (view, event) => {
- if (event.key === 'Enter' && !event.shiftKey) {
- event.preventDefault();
-
- const { state } = view;
- const { tr } = state;
-
- // Insert hard break using the view's state and dispatch
- const transaction = tr.replaceSelectionWith(
- state.schema.nodes.hardBreak.create(),
- );
-
- view.dispatch(transaction);
-
- return true;
- }
- return false;
- },
- },
- enableInputRules: false,
- enablePasteRules: false,
- injectCSS: false,
- });
-
- if (!editor) {
- return null;
- }
-
- return (
-
- {label && {label}}
-
-
-
-
-
-
-
-
-
- );
-};
-
-export default VariableTagInput;
diff --git a/packages/twenty-front/src/modules/workflow/search-variables/utils/__tests__/extractVariableLabel.test.ts b/packages/twenty-front/src/modules/workflow/search-variables/utils/__tests__/extractVariableLabel.test.ts
new file mode 100644
index 000000000..e6e41bec0
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/search-variables/utils/__tests__/extractVariableLabel.test.ts
@@ -0,0 +1,13 @@
+import { extractVariableLabel } from '../extractVariableLabel';
+
+it('returns the last part of a properly formatted variable', () => {
+ const rawVariable = '{{a.b.c}}';
+
+ expect(extractVariableLabel(rawVariable)).toBe('c');
+});
+
+it('stops on unclosed variables', () => {
+ const rawVariable = '{{ test {{a.b.c}}';
+
+ expect(extractVariableLabel(rawVariable)).toBe('c');
+});
diff --git a/packages/twenty-front/src/modules/workflow/search-variables/utils/extractVariableLabel.ts b/packages/twenty-front/src/modules/workflow/search-variables/utils/extractVariableLabel.ts
new file mode 100644
index 000000000..4aff2c153
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/search-variables/utils/extractVariableLabel.ts
@@ -0,0 +1,21 @@
+import { isDefined } from 'twenty-ui';
+
+const CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX = /{{([^{}]+)}}/g;
+
+export const extractVariableLabel = (rawVariableName: string) => {
+ const variableWithoutBrackets = rawVariableName.replace(
+ CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
+ (_, variableName) => {
+ return variableName;
+ },
+ );
+
+ const parts = variableWithoutBrackets.split('.');
+ const displayText = parts.at(-1);
+
+ if (!isDefined(displayText)) {
+ throw new Error('Expected to find at least one splitted chunk.');
+ }
+
+ return displayText;
+};
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
index 2adec8f48..e109ad5c1 100644
--- a/packages/twenty-front/src/modules/workflow/search-variables/utils/initializeEditorContent.ts
+++ b/packages/twenty-front/src/modules/workflow/search-variables/utils/initializeEditorContent.ts
@@ -1,13 +1,13 @@
import { isNonEmptyString } from '@sniptt/guards';
import { Editor } from '@tiptap/react';
-const REGEX_VARIABLE_TAG = /(\{\{[^}]+\}\})/;
+const CAPTURE_VARIABLE_TAG_REGEX = /({{[^{}]+}})/;
export const initializeEditorContent = (editor: Editor, content: string) => {
const lines = content.split(/\n/);
lines.forEach((line, index) => {
- const parts = line.split(REGEX_VARIABLE_TAG);
+ const parts = line.split(CAPTURE_VARIABLE_TAG_REGEX);
parts.forEach((part) => {
if (part.length === 0) {
return;
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
index ec2361ee2..9888097dd 100644
--- a/packages/twenty-front/src/modules/workflow/search-variables/utils/variableTag.ts
+++ b/packages/twenty-front/src/modules/workflow/search-variables/utils/variableTag.ts
@@ -1,10 +1,11 @@
+import { extractVariableLabel } from '@/workflow/search-variables/utils/extractVariableLabel';
import { Node } from '@tiptap/core';
import { mergeAttributes } from '@tiptap/react';
declare module '@tiptap/core' {
interface Commands {
variableTag: {
- insertVariableTag: (variable: string) => ReturnType;
+ insertVariableTag: (variableName: string) => ReturnType;
};
}
}
@@ -29,15 +30,6 @@ export const VariableTag = Node.create({
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',
@@ -45,17 +37,17 @@ export const VariableTag = Node.create({
'data-type': 'variableTag',
class: 'variable-tag',
}),
- displayText,
+ extractVariableLabel(variable),
];
},
addCommands: () => ({
insertVariableTag:
- (variable: string) =>
+ (variableName: string) =>
({ commands }) => {
- commands.insertContent?.({
+ commands.insertContent({
type: 'variableTag',
- attrs: { variable },
+ attrs: { variable: variableName },
});
return true;
diff --git a/packages/twenty-front/src/modules/workflow/utils/__tests__/isStandaloneVariableString.test.ts b/packages/twenty-front/src/modules/workflow/utils/__tests__/isStandaloneVariableString.test.ts
new file mode 100644
index 000000000..9a4b816c5
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/utils/__tests__/isStandaloneVariableString.test.ts
@@ -0,0 +1,17 @@
+import { isStandaloneVariableString } from '../isStandaloneVariableString';
+
+it('returns false if the provided value is not a string', () => {
+ expect(isStandaloneVariableString(42)).toBe(false);
+});
+
+it('returns true if the provided value is a valid variable definition string', () => {
+ expect(isStandaloneVariableString('{{ test.a.b.c }}')).toBe(true);
+});
+
+it('returns false if the provided value starts with blank spaces', () => {
+ expect(isStandaloneVariableString(' {{ test.a.b.c }}')).toBe(false);
+});
+
+it('returns false if the provided value ends with blank spaces', () => {
+ expect(isStandaloneVariableString('{{ test.a.b.c }} ')).toBe(false);
+});
diff --git a/packages/twenty-front/src/modules/workflow/utils/isStandaloneVariableString.ts b/packages/twenty-front/src/modules/workflow/utils/isStandaloneVariableString.ts
new file mode 100644
index 000000000..829c3bd89
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/utils/isStandaloneVariableString.ts
@@ -0,0 +1,7 @@
+import { isString } from '@sniptt/guards';
+
+const STANDALONE_VARIABLE_REGEX = /^{{[^{}]+}}$/;
+
+export const isStandaloneVariableString = (value: unknown): value is string => {
+ return isString(value) && STANDALONE_VARIABLE_REGEX.test(value);
+};
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type.ts
index 314eef3c3..357ac5603 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type.ts
@@ -6,10 +6,10 @@ import {
type ObjectRecord = Record;
export enum WorkflowRecordCRUDType {
- CREATE = 'create',
- UPDATE = 'update',
- DELETE = 'delete',
- READ = 'read',
+ CREATE = 'CREATE',
+ UPDATE = 'UPDATE',
+ DELETE = 'DELETE',
+ READ = 'READ',
}
export type WorkflowCreateRecordActionInput = {