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 { 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;
|
||||
};
|
||||
|
@ -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 { 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/);
|
||||
|
@ -41,6 +41,10 @@ export const VariableTag = Node.create({
|
||||
];
|
||||
},
|
||||
|
||||
renderText: ({ node }) => {
|
||||
return node.attrs.variable;
|
||||
},
|
||||
|
||||
addCommands: () => ({
|
||||
insertVariableTag:
|
||||
(variableName: string) =>
|
||||
|
Loading…
Reference in New Issue
Block a user