mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-22 19:41:53 +03:00
Add Raw JSON Form Field Input (#9078)
- Implemented the `renderText` method for `VariableTag`. This method returns the `variable` attribute of the node, i.e. `{{test}}`. - Used the `editor.getText()` function to simply get the textual representation of the field without relying on `editor.getJSON()` and `parseEditorContent`. - Implemented the RawJSON form field, which is heavily based on the `TextVariableEditor` component. - This component is inspired from the `RawJsonFieldInput` field, especially the JSON validation. Closes https://github.com/twentyhq/private-issues/issues/180
This commit is contained in:
parent
8623585106
commit
deb37edd7c
@ -6,6 +6,7 @@ import { FormFullNameFieldInput } from '@/object-record/record-field/form-types/
|
|||||||
import { FormLinksFieldInput } from '@/object-record/record-field/form-types/components/FormLinksFieldInput';
|
import { FormLinksFieldInput } from '@/object-record/record-field/form-types/components/FormLinksFieldInput';
|
||||||
import { FormMultiSelectFieldInput } from '@/object-record/record-field/form-types/components/FormMultiSelectFieldInput';
|
import { FormMultiSelectFieldInput } from '@/object-record/record-field/form-types/components/FormMultiSelectFieldInput';
|
||||||
import { FormNumberFieldInput } from '@/object-record/record-field/form-types/components/FormNumberFieldInput';
|
import { FormNumberFieldInput } from '@/object-record/record-field/form-types/components/FormNumberFieldInput';
|
||||||
|
import { FormRawJsonFieldInput } from '@/object-record/record-field/form-types/components/FormRawJsonFieldInput';
|
||||||
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 { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
|
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
|
||||||
@ -26,6 +27,7 @@ import { isFieldFullName } from '@/object-record/record-field/types/guards/isFie
|
|||||||
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
|
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
|
||||||
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
|
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
|
||||||
import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
|
import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
|
||||||
|
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
|
||||||
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 { JsonValue } from 'type-fest';
|
import { JsonValue } from 'type-fest';
|
||||||
@ -118,5 +120,13 @@ export const FormFieldInput = ({
|
|||||||
VariablePicker={VariablePicker}
|
VariablePicker={VariablePicker}
|
||||||
options={field.metadata.options}
|
options={field.metadata.options}
|
||||||
/>
|
/>
|
||||||
|
) : isFieldRawJson(field) ? (
|
||||||
|
<FormRawJsonFieldInput
|
||||||
|
label={field.label}
|
||||||
|
defaultValue={defaultValue as string | undefined}
|
||||||
|
onPersist={onPersist}
|
||||||
|
placeholder={field.label}
|
||||||
|
VariablePicker={VariablePicker}
|
||||||
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
};
|
};
|
||||||
|
@ -0,0 +1,85 @@
|
|||||||
|
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
|
||||||
|
import { FormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputInputContainer';
|
||||||
|
import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
|
||||||
|
import { TextVariableEditor } from '@/object-record/record-field/form-types/components/TextVariableEditor';
|
||||||
|
import { useTextVariableEditor } from '@/object-record/record-field/form-types/hooks/useTextVariableEditor';
|
||||||
|
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
|
||||||
|
import { InputLabel } from '@/ui/input/components/InputLabel';
|
||||||
|
import { useId } from 'react';
|
||||||
|
import { isDefined } from 'twenty-ui';
|
||||||
|
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
|
||||||
|
|
||||||
|
type FormRawJsonFieldInputProps = {
|
||||||
|
label?: string;
|
||||||
|
defaultValue: string | null | undefined;
|
||||||
|
placeholder: string;
|
||||||
|
onPersist: (value: string | null) => void;
|
||||||
|
readonly?: boolean;
|
||||||
|
VariablePicker?: VariablePickerComponent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FormRawJsonFieldInput = ({
|
||||||
|
label,
|
||||||
|
defaultValue,
|
||||||
|
placeholder,
|
||||||
|
onPersist,
|
||||||
|
readonly,
|
||||||
|
VariablePicker,
|
||||||
|
}: FormRawJsonFieldInputProps) => {
|
||||||
|
const inputId = useId();
|
||||||
|
|
||||||
|
const editor = useTextVariableEditor({
|
||||||
|
placeholder,
|
||||||
|
multiline: true,
|
||||||
|
readonly,
|
||||||
|
defaultValue: defaultValue ?? undefined,
|
||||||
|
onUpdate: (editor) => {
|
||||||
|
const text = turnIntoEmptyStringIfWhitespacesOnly(editor.getText());
|
||||||
|
|
||||||
|
if (text === '') {
|
||||||
|
onPersist(null);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onPersist(text);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleVariableTagInsert = (variableName: string) => {
|
||||||
|
if (!isDefined(editor)) {
|
||||||
|
throw new Error(
|
||||||
|
'Expected the editor to be defined when a variable is selected',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.commands.insertVariableTag(variableName);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isDefined(editor)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormFieldInputContainer>
|
||||||
|
{label ? <InputLabel>{label}</InputLabel> : null}
|
||||||
|
|
||||||
|
<FormFieldInputRowContainer multiline>
|
||||||
|
<FormFieldInputInputContainer
|
||||||
|
hasRightElement={isDefined(VariablePicker)}
|
||||||
|
multiline
|
||||||
|
>
|
||||||
|
<TextVariableEditor editor={editor} multiline readonly={readonly} />
|
||||||
|
</FormFieldInputInputContainer>
|
||||||
|
|
||||||
|
{VariablePicker ? (
|
||||||
|
<VariablePicker
|
||||||
|
inputId={inputId}
|
||||||
|
multiline
|
||||||
|
onVariableSelect={handleVariableTagInsert}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</FormFieldInputRowContainer>
|
||||||
|
</FormFieldInputContainer>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,244 @@
|
|||||||
|
import { expect } from '@storybook/jest';
|
||||||
|
import { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { fn, userEvent, waitFor, within } from '@storybook/test';
|
||||||
|
import { FormRawJsonFieldInput } from '../FormRawJsonFieldInput';
|
||||||
|
|
||||||
|
const meta: Meta<typeof FormRawJsonFieldInput> = {
|
||||||
|
title: 'UI/Data/Field/Form/Input/FormRawJsonFieldInput',
|
||||||
|
component: FormRawJsonFieldInput,
|
||||||
|
args: {},
|
||||||
|
argTypes: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof FormRawJsonFieldInput>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'JSON field',
|
||||||
|
placeholder: 'Enter valid json',
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
await canvas.findByText('JSON field');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Readonly: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'JSON field',
|
||||||
|
placeholder: 'Enter valid json',
|
||||||
|
readonly: true,
|
||||||
|
onPersist: fn(),
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement, args }) => {
|
||||||
|
const editor = canvasElement.querySelector('.ProseMirror > p');
|
||||||
|
expect(editor).toBeVisible();
|
||||||
|
|
||||||
|
await userEvent.type(editor, '{{ "a": {{ "b" : "d" } }');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const allParagraphs = canvasElement.querySelectorAll('.ProseMirror > p');
|
||||||
|
expect(allParagraphs).toHaveLength(1);
|
||||||
|
expect(allParagraphs[0]).toHaveTextContent('');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(args.onPersist).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SaveValidJson: Story = {
|
||||||
|
args: {
|
||||||
|
placeholder: 'Enter valid json',
|
||||||
|
onPersist: fn(),
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement, args }) => {
|
||||||
|
const editor = canvasElement.querySelector('.ProseMirror > p');
|
||||||
|
expect(editor).toBeVisible();
|
||||||
|
|
||||||
|
await userEvent.type(editor, '{{ "a": {{ "b" : "d" } }');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(args.onPersist).toHaveBeenCalledWith('{ "a": { "b" : "d" } }');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DoesNotIgnoreInvalidJson: Story = {
|
||||||
|
args: {
|
||||||
|
placeholder: 'Enter valid json',
|
||||||
|
onPersist: fn(),
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement, args }) => {
|
||||||
|
const editor = canvasElement.querySelector('.ProseMirror > p');
|
||||||
|
expect(editor).toBeVisible();
|
||||||
|
|
||||||
|
await userEvent.type(editor, 'lol');
|
||||||
|
|
||||||
|
await userEvent.click(canvasElement);
|
||||||
|
|
||||||
|
expect(args.onPersist).toHaveBeenCalledWith('lol');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DisplayDefaultValueWithVariablesProperly: Story = {
|
||||||
|
args: {
|
||||||
|
placeholder: 'Enter valid json',
|
||||||
|
defaultValue: '{ "a": { "b" : {{var.test}} } }',
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
await canvas.findByText(/{ "a": { "b" : /);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const variableTag = canvasElement.querySelector(
|
||||||
|
'[data-type="variableTag"]',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(variableTag).toBeVisible();
|
||||||
|
expect(variableTag).toHaveTextContent('test');
|
||||||
|
});
|
||||||
|
|
||||||
|
await canvas.findByText(/ } }/);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InsertVariableInTheMiddleOnTextInput: Story = {
|
||||||
|
args: {
|
||||||
|
placeholder: 'Enter valid json',
|
||||||
|
VariablePicker: ({ onVariableSelect }) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onVariableSelect('{{test}}');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add variable
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement, args }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
const editor = canvasElement.querySelector('.ProseMirror > p');
|
||||||
|
expect(editor).toBeVisible();
|
||||||
|
|
||||||
|
const addVariableButton = await canvas.findByRole('button', {
|
||||||
|
name: 'Add variable',
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.type(editor, '{{ "a": {{ "b" : ');
|
||||||
|
|
||||||
|
await userEvent.click(addVariableButton);
|
||||||
|
|
||||||
|
await userEvent.type(editor, ' } }');
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
waitFor(() => {
|
||||||
|
expect(args.onPersist).toHaveBeenCalledWith(
|
||||||
|
'{ "a": { "b" : {{test}} } }',
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CanUseVariableAsObjectProperty: Story = {
|
||||||
|
args: {
|
||||||
|
placeholder: 'Enter valid json',
|
||||||
|
VariablePicker: ({ onVariableSelect }) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onVariableSelect('{{test}}');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add variable
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement, args }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
const editor = canvasElement.querySelector('.ProseMirror > p');
|
||||||
|
expect(editor).toBeVisible();
|
||||||
|
|
||||||
|
const addVariableButton = await canvas.findByRole('button', {
|
||||||
|
name: 'Add variable',
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.type(editor, '{{ "');
|
||||||
|
|
||||||
|
await userEvent.click(addVariableButton);
|
||||||
|
|
||||||
|
await userEvent.type(editor, '": 2 }');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(args.onPersist).toHaveBeenCalledWith('{ "{{test}}": 2 }');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ClearField: Story = {
|
||||||
|
args: {
|
||||||
|
placeholder: 'Enter valid json',
|
||||||
|
defaultValue: '{ "a": 2 }',
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement, args }) => {
|
||||||
|
const defaultValueStringLength = args.defaultValue!.length;
|
||||||
|
|
||||||
|
const editor = canvasElement.querySelector('.ProseMirror > p');
|
||||||
|
expect(editor).toBeVisible();
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
userEvent.type(editor, `{Backspace>${defaultValueStringLength}}`),
|
||||||
|
|
||||||
|
waitFor(() => {
|
||||||
|
expect(args.onPersist).toHaveBeenCalledWith(null);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Line breaks are not authorized in JSON strings. Users should instead put newlines characters themselves.
|
||||||
|
* See https://stackoverflow.com/a/42073.
|
||||||
|
*/
|
||||||
|
export const DoesNotBreakWhenUserInsertsNewlineInJsonString: Story = {
|
||||||
|
args: {
|
||||||
|
placeholder: 'Enter valid json',
|
||||||
|
onPersist: fn(),
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement, args }) => {
|
||||||
|
const editor = canvasElement.querySelector('.ProseMirror > p');
|
||||||
|
expect(editor).toBeVisible();
|
||||||
|
|
||||||
|
await userEvent.type(editor, '"a{Enter}b"');
|
||||||
|
|
||||||
|
await userEvent.click(canvasElement);
|
||||||
|
|
||||||
|
expect(args.onPersist).toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AcceptsJsonEncodedNewline: Story = {
|
||||||
|
args: {
|
||||||
|
placeholder: 'Enter valid json',
|
||||||
|
onPersist: fn(),
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement, args }) => {
|
||||||
|
const editor = canvasElement.querySelector('.ProseMirror > p');
|
||||||
|
expect(editor).toBeVisible();
|
||||||
|
|
||||||
|
await userEvent.type(editor, '"a\\nb"');
|
||||||
|
|
||||||
|
await userEvent.click(canvasElement);
|
||||||
|
|
||||||
|
expect(args.onPersist).toHaveBeenCalledWith('"a\\nb"');
|
||||||
|
},
|
||||||
|
};
|
@ -1,7 +1,7 @@
|
|||||||
import { isNonEmptyString } from '@sniptt/guards';
|
import { isNonEmptyString } from '@sniptt/guards';
|
||||||
import { Editor } from '@tiptap/react';
|
import { Editor } from '@tiptap/react';
|
||||||
|
|
||||||
const CAPTURE_VARIABLE_TAG_REGEX = /({{[^{}]+}})/;
|
export const CAPTURE_VARIABLE_TAG_REGEX = /({{[^{}]+}})/;
|
||||||
|
|
||||||
export const initializeEditorContent = (editor: Editor, content: string) => {
|
export const initializeEditorContent = (editor: Editor, content: string) => {
|
||||||
const lines = content.split(/\n/);
|
const lines = content.split(/\n/);
|
||||||
|
@ -41,6 +41,10 @@ export const VariableTag = Node.create({
|
|||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
renderText: ({ node }) => {
|
||||||
|
return node.attrs.variable;
|
||||||
|
},
|
||||||
|
|
||||||
addCommands: () => ({
|
addCommands: () => ({
|
||||||
insertVariableTag:
|
insertVariableTag:
|
||||||
(variableName: string) =>
|
(variableName: string) =>
|
||||||
|
Loading…
Reference in New Issue
Block a user