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 b6b97e45b3..79fa537c95 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
@@ -6,6 +6,7 @@ import { FormFullNameFieldInput } from '@/object-record/record-field/form-types/
import { FormLinksFieldInput } from '@/object-record/record-field/form-types/components/FormLinksFieldInput';
import { FormMultiSelectFieldInput } from '@/object-record/record-field/form-types/components/FormMultiSelectFieldInput';
import { FormNumberFieldInput } from '@/object-record/record-field/form-types/components/FormNumberFieldInput';
+import { FormRawJsonFieldInput } from '@/object-record/record-field/form-types/components/FormRawJsonFieldInput';
import { FormSelectFieldInput } from '@/object-record/record-field/form-types/components/FormSelectFieldInput';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
@@ -26,6 +27,7 @@ import { isFieldFullName } from '@/object-record/record-field/types/guards/isFie
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
+import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
import { JsonValue } from 'type-fest';
@@ -118,5 +120,13 @@ export const FormFieldInput = ({
VariablePicker={VariablePicker}
options={field.metadata.options}
/>
+ ) : isFieldRawJson(field) ? (
+
) : null;
};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormRawJsonFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormRawJsonFieldInput.tsx
new file mode 100644
index 0000000000..851b7c5615
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormRawJsonFieldInput.tsx
@@ -0,0 +1,85 @@
+import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
+import { FormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputInputContainer';
+import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
+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 { useId } from 'react';
+import { isDefined } from 'twenty-ui';
+import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
+
+type FormRawJsonFieldInputProps = {
+ label?: string;
+ defaultValue: string | null | undefined;
+ placeholder: string;
+ onPersist: (value: string | null) => void;
+ readonly?: boolean;
+ VariablePicker?: VariablePickerComponent;
+};
+
+export const FormRawJsonFieldInput = ({
+ label,
+ defaultValue,
+ placeholder,
+ onPersist,
+ readonly,
+ VariablePicker,
+}: FormRawJsonFieldInputProps) => {
+ const inputId = useId();
+
+ const editor = useTextVariableEditor({
+ placeholder,
+ multiline: true,
+ readonly,
+ defaultValue: defaultValue ?? undefined,
+ onUpdate: (editor) => {
+ const text = turnIntoEmptyStringIfWhitespacesOnly(editor.getText());
+
+ if (text === '') {
+ onPersist(null);
+
+ return;
+ }
+
+ onPersist(text);
+ },
+ });
+
+ 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/__stories__/FormRawJsonFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormRawJsonFieldInput.stories.tsx
new file mode 100644
index 0000000000..aac0d2b699
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormRawJsonFieldInput.stories.tsx
@@ -0,0 +1,244 @@
+import { expect } from '@storybook/jest';
+import { Meta, StoryObj } from '@storybook/react';
+import { fn, userEvent, waitFor, within } from '@storybook/test';
+import { FormRawJsonFieldInput } from '../FormRawJsonFieldInput';
+
+const meta: Meta = {
+ title: 'UI/Data/Field/Form/Input/FormRawJsonFieldInput',
+ component: FormRawJsonFieldInput,
+ args: {},
+ argTypes: {},
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ label: 'JSON field',
+ placeholder: 'Enter valid json',
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await canvas.findByText('JSON field');
+ },
+};
+
+export const Readonly: Story = {
+ args: {
+ label: 'JSON field',
+ placeholder: 'Enter valid json',
+ readonly: true,
+ onPersist: fn(),
+ },
+ play: async ({ canvasElement, args }) => {
+ const editor = canvasElement.querySelector('.ProseMirror > p');
+ expect(editor).toBeVisible();
+
+ await userEvent.type(editor, '{{ "a": {{ "b" : "d" } }');
+
+ await waitFor(() => {
+ const allParagraphs = canvasElement.querySelectorAll('.ProseMirror > p');
+ expect(allParagraphs).toHaveLength(1);
+ expect(allParagraphs[0]).toHaveTextContent('');
+ });
+
+ expect(args.onPersist).not.toHaveBeenCalled();
+ },
+};
+
+export const SaveValidJson: Story = {
+ args: {
+ placeholder: 'Enter valid json',
+ onPersist: fn(),
+ },
+ play: async ({ canvasElement, args }) => {
+ const editor = canvasElement.querySelector('.ProseMirror > p');
+ expect(editor).toBeVisible();
+
+ await userEvent.type(editor, '{{ "a": {{ "b" : "d" } }');
+
+ await waitFor(() => {
+ expect(args.onPersist).toHaveBeenCalledWith('{ "a": { "b" : "d" } }');
+ });
+ },
+};
+
+export const DoesNotIgnoreInvalidJson: Story = {
+ args: {
+ placeholder: 'Enter valid json',
+ onPersist: fn(),
+ },
+ play: async ({ canvasElement, args }) => {
+ const editor = canvasElement.querySelector('.ProseMirror > p');
+ expect(editor).toBeVisible();
+
+ await userEvent.type(editor, 'lol');
+
+ await userEvent.click(canvasElement);
+
+ expect(args.onPersist).toHaveBeenCalledWith('lol');
+ },
+};
+
+export const DisplayDefaultValueWithVariablesProperly: Story = {
+ args: {
+ placeholder: 'Enter valid json',
+ defaultValue: '{ "a": { "b" : {{var.test}} } }',
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await canvas.findByText(/{ "a": { "b" : /);
+
+ await waitFor(() => {
+ const variableTag = canvasElement.querySelector(
+ '[data-type="variableTag"]',
+ );
+
+ expect(variableTag).toBeVisible();
+ expect(variableTag).toHaveTextContent('test');
+ });
+
+ await canvas.findByText(/ } }/);
+ },
+};
+
+export const InsertVariableInTheMiddleOnTextInput: Story = {
+ args: {
+ placeholder: 'Enter valid json',
+ VariablePicker: ({ onVariableSelect }) => {
+ return (
+
+ );
+ },
+ },
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement);
+
+ const editor = canvasElement.querySelector('.ProseMirror > p');
+ expect(editor).toBeVisible();
+
+ const addVariableButton = await canvas.findByRole('button', {
+ name: 'Add variable',
+ });
+
+ await userEvent.type(editor, '{{ "a": {{ "b" : ');
+
+ await userEvent.click(addVariableButton);
+
+ await userEvent.type(editor, ' } }');
+
+ await Promise.all([
+ waitFor(() => {
+ expect(args.onPersist).toHaveBeenCalledWith(
+ '{ "a": { "b" : {{test}} } }',
+ );
+ }),
+ ]);
+ },
+};
+
+export const CanUseVariableAsObjectProperty: Story = {
+ args: {
+ placeholder: 'Enter valid json',
+ VariablePicker: ({ onVariableSelect }) => {
+ return (
+
+ );
+ },
+ },
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement);
+
+ const editor = canvasElement.querySelector('.ProseMirror > p');
+ expect(editor).toBeVisible();
+
+ const addVariableButton = await canvas.findByRole('button', {
+ name: 'Add variable',
+ });
+
+ await userEvent.type(editor, '{{ "');
+
+ await userEvent.click(addVariableButton);
+
+ await userEvent.type(editor, '": 2 }');
+
+ await waitFor(() => {
+ expect(args.onPersist).toHaveBeenCalledWith('{ "{{test}}": 2 }');
+ });
+ },
+};
+
+export const ClearField: Story = {
+ args: {
+ placeholder: 'Enter valid json',
+ defaultValue: '{ "a": 2 }',
+ },
+ play: async ({ canvasElement, args }) => {
+ const defaultValueStringLength = args.defaultValue!.length;
+
+ const editor = canvasElement.querySelector('.ProseMirror > p');
+ expect(editor).toBeVisible();
+
+ await Promise.all([
+ userEvent.type(editor, `{Backspace>${defaultValueStringLength}}`),
+
+ waitFor(() => {
+ expect(args.onPersist).toHaveBeenCalledWith(null);
+ }),
+ ]);
+ },
+};
+
+/**
+ * Line breaks are not authorized in JSON strings. Users should instead put newlines characters themselves.
+ * See https://stackoverflow.com/a/42073.
+ */
+export const DoesNotBreakWhenUserInsertsNewlineInJsonString: Story = {
+ args: {
+ placeholder: 'Enter valid json',
+ onPersist: fn(),
+ },
+ play: async ({ canvasElement, args }) => {
+ const editor = canvasElement.querySelector('.ProseMirror > p');
+ expect(editor).toBeVisible();
+
+ await userEvent.type(editor, '"a{Enter}b"');
+
+ await userEvent.click(canvasElement);
+
+ expect(args.onPersist).toHaveBeenCalled();
+ },
+};
+
+export const AcceptsJsonEncodedNewline: Story = {
+ args: {
+ placeholder: 'Enter valid json',
+ onPersist: fn(),
+ },
+ play: async ({ canvasElement, args }) => {
+ const editor = canvasElement.querySelector('.ProseMirror > p');
+ expect(editor).toBeVisible();
+
+ await userEvent.type(editor, '"a\\nb"');
+
+ await userEvent.click(canvasElement);
+
+ expect(args.onPersist).toHaveBeenCalledWith('"a\\nb"');
+ },
+};
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 e109ad5c16..a194de4caa 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,7 +1,7 @@
import { isNonEmptyString } from '@sniptt/guards';
import { Editor } from '@tiptap/react';
-const CAPTURE_VARIABLE_TAG_REGEX = /({{[^{}]+}})/;
+export const CAPTURE_VARIABLE_TAG_REGEX = /({{[^{}]+}})/;
export const initializeEditorContent = (editor: Editor, content: string) => {
const lines = content.split(/\n/);
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 9888097dd9..af6ba76986 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
@@ -41,6 +41,10 @@ export const VariableTag = Node.create({
];
},
+ renderText: ({ node }) => {
+ return node.attrs.variable;
+ },
+
addCommands: () => ({
insertVariableTag:
(variableName: string) =>