Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ type FormFieldInputInnerContainerProps = {
const StyledFormFieldInputInnerContainer = styled.div<
Omit<FormFieldInputInnerContainerProps, 'formFieldInputInstanceId'>
>`
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};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')};
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof setup>[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');
Comment on lines +153 to +155
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This test doesn't actually verify insertion at the selected cursor position; it would still pass if the JSON were pasted at the start or end as long as the original text remained.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/twenty-front/src/modules/object-record/record-field/ui/form-types/hooks/__tests__/useTextVariableEditor.test.ts, line 153:

<comment>This test doesn't actually verify insertion at the selected cursor position; it would still pass if the JSON were pasted at the start or end as long as the original text remained.</comment>

<file context>
@@ -139,6 +139,22 @@ describe('useTextVariableEditor', () => {
+      paste(editor, '{"x":1}');
+
+      const c = content(editor);
+      expect(c).toContain('hello');
+      expect(c).toContain('orld');
+      expect(c).toContain('"x": 1');
</file context>
Suggested change
expect(c).toContain('hello');
expect(c).toContain('orld');
expect(c).toContain('"x": 1');
expect(c).toContain('hello w{');
expect(c).toContain('}orld');
expect(c).toContain('"x": 1');
Fix with Cubic

});

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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<JsonValue>(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<JsonValue>(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,
Expand Down
Loading