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 bfde9d454..681fec498 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
@@ -10,6 +10,7 @@ import { FormMultiSelectFieldInput } from '@/object-record/record-field/form-typ
import { FormNumberFieldInput } from '@/object-record/record-field/form-types/components/FormNumberFieldInput';
import { FormPhoneFieldInput } from '@/object-record/record-field/form-types/components/FormPhoneFieldInput';
import { FormRawJsonFieldInput } from '@/object-record/record-field/form-types/components/FormRawJsonFieldInput';
+import { FormRichTextV2FieldInput } from '@/object-record/record-field/form-types/components/FormRichTextV2FieldInput';
import { FormSelectFieldInput } from '@/object-record/record-field/form-types/components/FormSelectFieldInput';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { FormUuidFieldInput } from '@/object-record/record-field/form-types/components/FormUuidFieldInput';
@@ -23,6 +24,7 @@ import {
FieldMetadata,
FieldMultiSelectValue,
FieldPhonesValue,
+ FieldRichTextV2Value,
FormFieldCurrencyValue,
} from '@/object-record/record-field/types/FieldMetadata';
import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress';
@@ -37,6 +39,7 @@ import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/is
import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
import { isFieldPhones } from '@/object-record/record-field/types/guards/isFieldPhones';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
+import { isFieldRichTextV2 } from '@/object-record/record-field/types/guards/isFieldRichTextV2';
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
import { isFieldUuid } from '@/object-record/record-field/types/guards/isFieldUuid';
@@ -187,5 +190,14 @@ export const FormFieldInput = ({
VariablePicker={VariablePicker}
readonly={readonly}
/>
+ ) : isFieldRichTextV2(field) ? (
+
) : null;
};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormRichTextV2FieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormRichTextV2FieldInput.tsx
new file mode 100644
index 000000000..10f42c44c
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormRichTextV2FieldInput.tsx
@@ -0,0 +1,49 @@
+import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
+import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
+import { FieldRichTextV2Value } from '@/object-record/record-field/types/FieldMetadata';
+
+type FormRichTextV2FieldInputProps = {
+ label?: string;
+ error?: string;
+ hint?: string;
+ defaultValue: FieldRichTextV2Value | undefined;
+ onChange: (value: FieldRichTextV2Value) => void;
+ onBlur?: () => void;
+ readonly?: boolean;
+ placeholder?: string;
+ VariablePicker?: VariablePickerComponent;
+};
+
+export const FormRichTextV2FieldInput = ({
+ label,
+ error,
+ hint,
+ defaultValue,
+ placeholder,
+ onChange,
+ onBlur,
+ readonly,
+ VariablePicker,
+}: FormRichTextV2FieldInputProps) => {
+ const handleChange = (value: string) => {
+ onChange({
+ blocknote: null,
+ markdown: value,
+ });
+ };
+
+ return (
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormRichTextV2FieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormRichTextV2FieldInput.stories.tsx
new file mode 100644
index 000000000..321a3d6c9
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormRichTextV2FieldInput.stories.tsx
@@ -0,0 +1,266 @@
+import { Meta, StoryObj } from '@storybook/react';
+import {
+ expect,
+ fn,
+ userEvent,
+ waitFor,
+ waitForElementToBeRemoved,
+ within,
+} from '@storybook/test';
+import { getUserDevice } from 'twenty-ui/utilities';
+import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
+import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
+import { MOCKED_STEP_ID } from '~/testing/mock-data/workflow';
+import { FormRichTextV2FieldInput } from '../FormRichTextV2FieldInput';
+
+const meta: Meta = {
+ title: 'UI/Data/Field/Form/Input/FormRichTextV2FieldInput',
+ component: FormRichTextV2FieldInput,
+ args: {},
+ argTypes: {},
+ decorators: [WorkflowStepDecorator, I18nFrontDecorator],
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ placeholder: 'Rich Text field...',
+ },
+};
+
+export const WithLabel: Story = {
+ args: {
+ label: 'Rich Text',
+ placeholder: 'Rich Text field...',
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await canvas.findByText('Rich Text');
+ },
+};
+
+export const WithVariable: Story = {
+ args: {
+ label: 'Rich Text',
+ placeholder: 'Rich Text field...',
+ defaultValue: { blocknote: null, markdown: '## Title\nVariable: ' },
+ VariablePicker: ({ onVariableSelect }) => {
+ return (
+
+ );
+ },
+ onChange: fn(),
+ },
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement);
+
+ const editor = await waitFor(() => {
+ const editor = canvasElement.querySelector('.ProseMirror > p');
+
+ expect(editor).toBeVisible();
+
+ return editor;
+ });
+
+ await userEvent.click(editor);
+
+ const addVariableButton = await canvas.findByRole('button', {
+ name: 'Add variable',
+ });
+
+ await userEvent.click(addVariableButton);
+
+ const variable = await canvas.findByText('Name');
+ expect(variable).toBeVisible();
+
+ await waitFor(() => {
+ expect(args.onChange).toHaveBeenCalledWith({
+ blocknote: null,
+ markdown: `## Title\nVariable: {{${MOCKED_STEP_ID}.name}}`,
+ });
+ });
+ expect(args.onChange).toHaveBeenCalledTimes(1);
+ },
+};
+
+export const WithDeletableVariable: Story = {
+ args: {
+ label: 'Text',
+ placeholder: 'Text field...',
+ defaultValue: {
+ blocknote: null,
+ markdown: `test {{${MOCKED_STEP_ID}.name}} test`,
+ },
+ onChange: fn(),
+ },
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement);
+
+ const editor = await waitFor(() => {
+ const editor = canvasElement.querySelector('.ProseMirror > p');
+
+ expect(editor).toBeVisible();
+
+ return editor;
+ });
+
+ const variable = await canvas.findByText('Name');
+ expect(variable).toBeVisible();
+
+ const deleteVariableButton = await canvas.findByRole('button', {
+ name: 'Remove variable',
+ });
+
+ await Promise.all([
+ waitForElementToBeRemoved(variable),
+
+ deleteVariableButton.click(),
+ ]);
+
+ expect(editor).toHaveTextContent('test test');
+
+ await waitFor(() => {
+ expect(args.onChange).toHaveBeenCalledWith({
+ blocknote: null,
+ markdown: 'test test',
+ });
+ });
+ expect(args.onChange).toHaveBeenCalledTimes(1);
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ label: 'Text',
+ placeholder: 'Text field...',
+ defaultValue: {
+ blocknote: null,
+ markdown: 'Rich Text',
+ },
+ readonly: true,
+ VariablePicker: () => VariablePicker
,
+ onChange: fn(),
+ },
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement);
+
+ const editor = await waitFor(() => {
+ const editor = canvasElement.querySelector('.ProseMirror > p');
+ expect(editor).toBeVisible();
+ return editor;
+ });
+
+ const variablePicker = canvas.queryByText('VariablePicker');
+ expect(variablePicker).not.toBeInTheDocument();
+
+ const defaultValue = await canvas.findByText('Rich Text');
+ expect(defaultValue).toBeVisible();
+
+ await userEvent.type(editor, 'Hello');
+
+ expect(args.onChange).not.toHaveBeenCalled();
+ expect(canvas.queryByText('Hello')).not.toBeInTheDocument();
+ expect(defaultValue).toBeVisible();
+ },
+};
+
+export const DisabledWithVariable: Story = {
+ args: {
+ label: 'Text',
+ defaultValue: {
+ blocknote: null,
+ markdown: `test {{${MOCKED_STEP_ID}.name}} test`,
+ },
+ readonly: true,
+ },
+ play: async ({ canvasElement }) => {
+ const editor = await waitFor(() => {
+ const editor = canvasElement.querySelector('.ProseMirror > p');
+
+ expect(editor).toBeVisible();
+
+ return editor;
+ });
+
+ await waitFor(() => {
+ expect(editor).toHaveTextContent('test Name test');
+ });
+
+ const deleteVariableButton = within(editor as HTMLElement).queryByRole(
+ 'button',
+ );
+ expect(deleteVariableButton).not.toBeInTheDocument();
+ },
+};
+
+export const HasHistory: Story = {
+ args: {
+ label: 'Text',
+ placeholder: 'Text field...',
+ VariablePicker: ({ onVariableSelect }) => {
+ return (
+
+ );
+ },
+ onChange: fn(),
+ },
+ play: async ({ canvasElement, args }) => {
+ const controlKey = getUserDevice() === 'mac' ? 'Meta' : 'Control';
+
+ const canvas = within(canvasElement);
+
+ const editor = await waitFor(() => {
+ const editor = canvasElement.querySelector('.ProseMirror > p');
+ expect(editor).toBeVisible();
+ return editor;
+ });
+
+ const addVariableButton = await canvas.findByRole('button', {
+ name: 'Add variable',
+ });
+
+ await userEvent.type(editor, 'Hello World ');
+
+ await userEvent.click(addVariableButton);
+
+ expect(args.onChange).toHaveBeenLastCalledWith({
+ blocknote: null,
+ markdown: `Hello World {{${MOCKED_STEP_ID}.name}}`,
+ });
+
+ await userEvent.type(editor, `{${controlKey}>}z{/${controlKey}}`);
+
+ expect(editor).toHaveTextContent('');
+ expect(args.onChange).toHaveBeenLastCalledWith({
+ blocknote: null,
+ markdown: '',
+ });
+
+ await userEvent.type(
+ editor,
+ `{Shift>}{${controlKey}>}z{/${controlKey}}{/Shift}`,
+ );
+
+ expect(editor).toHaveTextContent(`Hello World Name`);
+ expect(args.onChange).toHaveBeenLastCalledWith({
+ blocknote: null,
+ markdown: `Hello World {{${MOCKED_STEP_ID}.name}}`,
+ });
+ },
+};