diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer.tsx index 860cf46f2bbf8..095d5f58b0ec2 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer.tsx @@ -17,7 +17,7 @@ type FormFieldInputInnerContainerProps = { const StyledFormFieldInputInnerContainer = styled.div< Omit >` - align-items: center; + align-items: ${({ multiline }) => (multiline ? 'flex-start' : 'center')}; background-color: ${themeCssVariables.background.transparent.lighter}; border: 1px solid ${themeCssVariables.border.color.medium}; border-bottom-left-radius: ${themeCssVariables.border.radius.sm}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/TextVariableEditor.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/TextVariableEditor.tsx index 49f85ae2b6621..29ae945659338 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/TextVariableEditor.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/TextVariableEditor.tsx @@ -31,9 +31,9 @@ const StyledEditor = styled.div<{ &::-webkit-scrollbar { display: none; } - height: 100%; + height: ${({ multiline }) => (multiline ? 'auto' : '100%')}; overflow-x: auto; - overflow-y: ${({ multiline }) => (multiline ? 'auto' : 'hidden')}; + overflow-y: hidden; padding: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[2]}; scrollbar-width: none; white-space: ${({ multiline }) => (multiline ? 'pre' : 'nowrap')}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/hooks/__tests__/useTextVariableEditor.test.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/hooks/__tests__/useTextVariableEditor.test.ts new file mode 100644 index 0000000000000..9b41d09fbc44f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/hooks/__tests__/useTextVariableEditor.test.ts @@ -0,0 +1,197 @@ +import { renderHook } from '@testing-library/react'; +import { Fragment, Slice } from '@tiptap/pm/model'; +import type { Editor } from '@tiptap/react'; + +import { parseEditorContent } from '@/workflow/workflow-variables/utils/parseEditorContent'; +import { useTextVariableEditor } from '@/object-record/record-field/ui/form-types/hooks/useTextVariableEditor'; + +const mockPasteEvent = (text: string) => + ({ + clipboardData: { + getData: (type: string) => (type === 'text/plain' ? text : ''), + }, + }) as unknown as ClipboardEvent; + +const paste = (editor: Editor, text: string): boolean => { + const handlePaste = editor.view.props.handlePaste!; + const { schema } = editor.view.state; + const slice = text + ? new Slice(Fragment.from(schema.text(text)), 0, 0) + : Slice.empty; + + return handlePaste(editor.view, mockPasteEvent(text), slice) as boolean; +}; + +const content = (editor: Editor) => parseEditorContent(editor.getJSON()); + +const countHardBreaks = (editor: Editor) => + editor + .getJSON() + .content?.[0]?.content?.filter( + (n: { type: string }) => n.type === 'hardBreak', + )?.length ?? 0; + +const setup = ( + opts: Partial<{ + multiline: boolean; + readonly: boolean; + defaultValue: string | null; + }> = {}, +) => { + const onUpdate = jest.fn(); + const { result, unmount } = renderHook(() => + useTextVariableEditor({ + placeholder: 'Enter text', + multiline: opts.multiline ?? false, + readonly: opts.readonly ?? false, + defaultValue: opts.defaultValue ?? undefined, + onUpdate, + }), + ); + + if (result.current === null || result.current === undefined) + throw new Error('Editor not created'); + return { editor: result.current, unmount }; +}; + +describe('useTextVariableEditor', () => { + let teardown: (() => void) | undefined; + afterEach(() => teardown?.()); + + const use = (opts: Parameters[0] = {}) => { + const { editor, unmount } = setup(opts); + teardown = unmount; + return editor; + }; + + describe('initialization', () => { + it('should set content from defaultValue', () => { + expect(content(use({ defaultValue: 'hello' }))).toBe('hello'); + }); + + it('should preserve line breaks in multiline defaultValue', () => { + const editor = use({ + multiline: true, + defaultValue: 'a\nb\nc', + }); + expect(countHardBreaks(editor)).toBe(2); + }); + + it('should respect readonly', () => { + expect(use({ readonly: true }).isEditable).toBe(false); + teardown?.(); + expect(use({ readonly: false }).isEditable).toBe(true); + }); + }); + + describe('Enter key', () => { + const pressEnter = (editor: Editor, shift = false) => { + const handler = editor.view.props.handleKeyDown!; + return handler( + editor.view, + new KeyboardEvent('keydown', { + key: 'Enter', + shiftKey: shift, + cancelable: true, + }), + ); + }; + + it('should insert hardBreak in multiline mode', () => { + const editor = use({ multiline: true, defaultValue: 'hi' }); + editor.commands.focus('end'); + pressEnter(editor); + expect(countHardBreaks(editor)).toBe(1); + }); + + it('should block Enter without inserting in non-multiline mode', () => { + const editor = use({ defaultValue: 'hi' }); + editor.commands.focus('end'); + expect(pressEnter(editor)).toBe(true); + expect(content(editor)).toBe('hi'); + }); + + it('should not intercept Shift+Enter', () => { + const editor = use({ multiline: true }); + expect(pressEnter(editor, true)).toBe(false); + }); + }); + + describe('paste — JSON', () => { + it('should pretty-print JSON in multiline mode', () => { + const editor = use({ multiline: true }); + paste(editor, '{"a":1,"b":2}'); + expect(content(editor)).toContain('"a": 1'); + }); + + it('should compact-print JSON in non-multiline mode', () => { + const editor = use({ multiline: false }); + paste(editor, '{"a":1,"b":2}'); + const c = content(editor); + expect(c).not.toContain('\n'); + expect(c).toContain('"a":'); + }); + + it('should not crash when cursor exceeds reformatted doc size', () => { + const editor = use({ multiline: true }); + editor.commands.insertContent('x'.repeat(100)); + editor.commands.focus('end'); + expect(() => paste(editor, '{"a":1}')).not.toThrow(); + }); + + it('should insert JSON at cursor without destroying existing content', () => { + const editor = use({ + multiline: true, + defaultValue: 'hello world', + }); + editor.commands.focus(); + editor.commands.setTextSelection(8); + + paste(editor, '{"x":1}'); + + const c = content(editor); + expect(c).toContain('hello'); + expect(c).toContain('orld'); + expect(c).toContain('"x": 1'); + }); + + it('should not treat primitives as JSON objects', () => { + for (const val of ['"str"', '42', 'true', 'null']) { + const { editor, unmount } = setup({ multiline: true }); + expect(paste(editor, val)).toBe(false); + unmount(); + } + }); + }); + + describe('paste — multiline text', () => { + it('should convert newlines to hardBreak nodes', () => { + const editor = use({ multiline: true }); + paste(editor, 'a\nb\nc'); + expect(countHardBreaks(editor)).toBe(2); + expect(content(editor)).toContain('a'); + expect(content(editor)).toContain('c'); + }); + + it('should handle consecutive newlines', () => { + const editor = use({ multiline: true }); + paste(editor, 'a\n\nc'); + expect(countHardBreaks(editor)).toBe(2); + }); + + it('should fall through in non-multiline mode', () => { + const editor = use({ multiline: false }); + expect(paste(editor, 'a\nb')).toBe(false); + }); + }); + + describe('paste — plain text', () => { + it('should fall through for text without newlines', () => { + expect(paste(use({ multiline: true }), 'hello')).toBe(false); + }); + + it('should fall through for empty clipboard', () => { + expect(paste(use({ multiline: true }), '')).toBe(false); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/hooks/useTextVariableEditor.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/hooks/useTextVariableEditor.ts index 93ebdbac02c4e..f956821dc9184 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/hooks/useTextVariableEditor.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/hooks/useTextVariableEditor.ts @@ -6,7 +6,8 @@ import Paragraph from '@tiptap/extension-paragraph'; import Text from '@tiptap/extension-text'; import { Placeholder } from '@tiptap/extensions/placeholder'; import { UndoRedo } from '@tiptap/extensions/undo-redo'; -import { AllSelection, TextSelection } from '@tiptap/pm/state'; +import { Slice } from '@tiptap/pm/model'; + import { type Editor, useEditor } from '@tiptap/react'; import { isDefined, parseJson } from 'twenty-shared/utils'; import { type JsonValue } from 'type-fest'; @@ -83,40 +84,46 @@ export const useTextVariableEditor = ({ } return false; }, - handlePaste: (view, _, slice) => { - try { - const { - state: { schema, tr }, - } = view; - const originalPos = tr.selection.from; - const pastedText = slice.content.firstChild?.textContent ?? ''; - - // Apply the clipboard text to the document without formatting - tr.replaceSelection(slice); - - const newPos = tr.selection.from; + handlePaste: (view, event) => { + const plainText = event.clipboardData?.getData('text/plain') ?? ''; + const { + state: { schema, tr }, + } = view; - // Parse the entire document content as JSON and create formatted document node - const parsedJson = parseJson(tr.doc.textContent); - const formattedJson = JSON.stringify(parsedJson, null, 2); - const formattedDocNode = schema.nodeFromJSON( + // Format pasted JSON content with pretty-printing + if (isJsonObject(plainText)) { + const parsedJson = parseJson(plainText); + const formattedJson = multiline + ? JSON.stringify(parsedJson, null, 2) + : JSON.stringify(parsedJson); + const docNode = schema.nodeFromJSON( getInitialEditorContent(formattedJson), ); + const inlineContent = docNode.firstChild?.content; - // Replace entire document with formatted JSON - const rootDocSelection = new AllSelection(tr.doc); - tr.setSelection(rootDocSelection); - tr.replaceSelectionWith(formattedDocNode); + if (inlineContent && inlineContent.size > 0) { + tr.replaceSelection(new Slice(inlineContent, 0, 0)); + view.dispatch(tr); + } + return true; + } - // Restore cursor position based on pasted content type - const finalPos = isJsonObject(pastedText) ? originalPos : newPos; - tr.setSelection(TextSelection.create(tr.doc, finalPos)); + // In multiline mode, convert newlines to hardBreak nodes + if (multiline && plainText.includes('\n')) { + const docNode = schema.nodeFromJSON( + getInitialEditorContent(plainText), + ); + const inlineContent = docNode.firstChild?.content; - view.dispatch(tr); - return true; - } catch { + if (inlineContent && inlineContent.size > 0) { + tr.replaceSelection(new Slice(inlineContent, 0, 0)); + view.dispatch(tr); + return true; + } return false; } + + return false; }, }, enableInputRules: false,