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}}`, + }); + }, +};