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:
Baptiste Devessier 2024-12-18 11:42:12 +01:00 committed by GitHub
parent 8623585106
commit deb37edd7c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 344 additions and 1 deletions

View File

@ -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 { FormMultiSelectFieldInput } from '@/object-record/record-field/form-types/components/FormMultiSelectFieldInput';
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 { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
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 { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
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 { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
import { JsonValue } from 'type-fest';
@ -118,5 +120,13 @@ export const FormFieldInput = ({
VariablePicker={VariablePicker}
options={field.metadata.options}
/>
) : isFieldRawJson(field) ? (
<FormRawJsonFieldInput
label={field.label}
defaultValue={defaultValue as string | undefined}
onPersist={onPersist}
placeholder={field.label}
VariablePicker={VariablePicker}
/>
) : null;
};

View File

@ -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>
);
};

View File

@ -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"');
},
};

View File

@ -1,7 +1,7 @@
import { isNonEmptyString } from '@sniptt/guards';
import { Editor } from '@tiptap/react';
const CAPTURE_VARIABLE_TAG_REGEX = /({{[^{}]+}})/;
export const CAPTURE_VARIABLE_TAG_REGEX = /({{[^{}]+}})/;
export const initializeEditorContent = (editor: Editor, content: string) => {
const lines = content.split(/\n/);

View File

@ -41,6 +41,10 @@ export const VariableTag = Node.create({
];
},
renderText: ({ node }) => {
return node.attrs.variable;
},
addCommands: () => ({
insertVariableTag:
(variableName: string) =>