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:
committed by
GitHub
parent
0be700f376
commit
704b18af30
@ -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;
|
||||
};
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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}}`,
|
||||
});
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user