diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx index b6b97e45b3..79fa537c95 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx @@ -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) ? ( + ) : null; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormRawJsonFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormRawJsonFieldInput.tsx new file mode 100644 index 0000000000..851b7c5615 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormRawJsonFieldInput.tsx @@ -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 ( + + {label ? {label} : null} + + + + + + + {VariablePicker ? ( + + ) : null} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormRawJsonFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormRawJsonFieldInput.stories.tsx new file mode 100644 index 0000000000..aac0d2b699 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormRawJsonFieldInput.stories.tsx @@ -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 = { + title: 'UI/Data/Field/Form/Input/FormRawJsonFieldInput', + component: FormRawJsonFieldInput, + args: {}, + argTypes: {}, +}; + +export default meta; + +type Story = StoryObj; + +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 ( + + ); + }, + }, + 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 ( + + ); + }, + }, + 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"'); + }, +}; diff --git a/packages/twenty-front/src/modules/workflow/search-variables/utils/initializeEditorContent.ts b/packages/twenty-front/src/modules/workflow/search-variables/utils/initializeEditorContent.ts index e109ad5c16..a194de4caa 100644 --- a/packages/twenty-front/src/modules/workflow/search-variables/utils/initializeEditorContent.ts +++ b/packages/twenty-front/src/modules/workflow/search-variables/utils/initializeEditorContent.ts @@ -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/); diff --git a/packages/twenty-front/src/modules/workflow/search-variables/utils/variableTag.ts b/packages/twenty-front/src/modules/workflow/search-variables/utils/variableTag.ts index 9888097dd9..af6ba76986 100644 --- a/packages/twenty-front/src/modules/workflow/search-variables/utils/variableTag.ts +++ b/packages/twenty-front/src/modules/workflow/search-variables/utils/variableTag.ts @@ -41,6 +41,10 @@ export const VariableTag = Node.create({ ]; }, + renderText: ({ node }) => { + return node.attrs.variable; + }, + addCommands: () => ({ insertVariableTag: (variableName: string) =>