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 { FormNumberFieldInput } from '@/object-record/record-field/form-types/components/FormNumberFieldInput';
|
||||||
import { FormPhoneFieldInput } from '@/object-record/record-field/form-types/components/FormPhoneFieldInput';
|
import { FormPhoneFieldInput } from '@/object-record/record-field/form-types/components/FormPhoneFieldInput';
|
||||||
import { FormRawJsonFieldInput } from '@/object-record/record-field/form-types/components/FormRawJsonFieldInput';
|
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 { FormSelectFieldInput } from '@/object-record/record-field/form-types/components/FormSelectFieldInput';
|
||||||
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
|
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
|
||||||
import { FormUuidFieldInput } from '@/object-record/record-field/form-types/components/FormUuidFieldInput';
|
import { FormUuidFieldInput } from '@/object-record/record-field/form-types/components/FormUuidFieldInput';
|
||||||
@ -23,6 +24,7 @@ import {
|
|||||||
FieldMetadata,
|
FieldMetadata,
|
||||||
FieldMultiSelectValue,
|
FieldMultiSelectValue,
|
||||||
FieldPhonesValue,
|
FieldPhonesValue,
|
||||||
|
FieldRichTextV2Value,
|
||||||
FormFieldCurrencyValue,
|
FormFieldCurrencyValue,
|
||||||
} from '@/object-record/record-field/types/FieldMetadata';
|
} from '@/object-record/record-field/types/FieldMetadata';
|
||||||
import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress';
|
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 { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
|
||||||
import { isFieldPhones } from '@/object-record/record-field/types/guards/isFieldPhones';
|
import { isFieldPhones } from '@/object-record/record-field/types/guards/isFieldPhones';
|
||||||
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
|
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 { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
|
||||||
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
|
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
|
||||||
import { isFieldUuid } from '@/object-record/record-field/types/guards/isFieldUuid';
|
import { isFieldUuid } from '@/object-record/record-field/types/guards/isFieldUuid';
|
||||||
@ -187,5 +190,14 @@ export const FormFieldInput = ({
|
|||||||
VariablePicker={VariablePicker}
|
VariablePicker={VariablePicker}
|
||||||
readonly={readonly}
|
readonly={readonly}
|
||||||
/>
|
/>
|
||||||
|
) : isFieldRichTextV2(field) ? (
|
||||||
|
<FormRichTextV2FieldInput
|
||||||
|
label={field.label}
|
||||||
|
defaultValue={defaultValue as FieldRichTextV2Value | undefined}
|
||||||
|
onChange={onChange}
|
||||||
|
VariablePicker={VariablePicker}
|
||||||
|
readonly={readonly}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
) : null;
|
) : 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