Create FormRichTextField (#11474)

This PR adds a new Form Field for Rich Text V2 fields. The
implementation was relatively simple, thanks to @thomtrp's job on the
back-end side.

## Demo

### Edit mode + insert in database


https://github.com/user-attachments/assets/f44a6442-fad3-4ad9-af50-a7d90e872c90

### Readonly mode


https://github.com/user-attachments/assets/66243ddf-d4a2-4187-894c-356d9c02b2b2

Closes https://github.com/twentyhq/private-issues/issues/229
This commit is contained in:
Baptiste Devessier
2025-04-14 18:07:09 +02:00
committed by GitHub
parent 0be700f376
commit 704b18af30
3 changed files with 327 additions and 0 deletions

View File

@ -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) ? (
<FormRichTextV2FieldInput
label={field.label}
defaultValue={defaultValue as FieldRichTextV2Value | undefined}
onChange={onChange}
VariablePicker={VariablePicker}
readonly={readonly}
placeholder={placeholder}
/>
) : null;
};

View File

@ -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 (
<FormTextFieldInput
label={label}
error={error}
hint={hint}
defaultValue={defaultValue?.markdown ?? ''}
placeholder={placeholder}
onChange={handleChange}
onBlur={onBlur}
multiline
readonly={readonly}
VariablePicker={VariablePicker}
/>
);
};

View File

@ -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<typeof FormRichTextV2FieldInput> = {
title: 'UI/Data/Field/Form/Input/FormRichTextV2FieldInput',
component: FormRichTextV2FieldInput,
args: {},
argTypes: {},
decorators: [WorkflowStepDecorator, I18nFrontDecorator],
};
export default meta;
type Story = StoryObj<typeof FormRichTextV2FieldInput>;
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 (
<button
onClick={() => {
onVariableSelect(`{{${MOCKED_STEP_ID}.name}}`);
}}
>
Add variable
</button>
);
},
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: () => <div>VariablePicker</div>,
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 (
<button
onClick={() => {
onVariableSelect(`{{${MOCKED_STEP_ID}.name}}`);
}}
>
Add variable
</button>
);
},
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}}`,
});
},
};