Make variable nodes undeletable in a readonly tiptap editor (#9950)

In this PR:

- Refactor how we initialize the content of the tiptap editor; providing
a default value for the editor makes node appear instantly
- Hide the button to remove a variable tag when the editor is readonly

| Editable | Readonly |
|--------|--------|
| ![CleanShot 2025-01-31 at 15 04
25@2x](https://github.com/user-attachments/assets/54b90c80-aab1-4ff0-93f9-a0550f031d82)
| ![CleanShot 2025-01-31 at 15 05
51@2x](https://github.com/user-attachments/assets/0480a7dc-9d7a-4e3f-b1a5-0550548622c6)
|

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Baptiste Devessier
2025-01-31 18:00:40 +01:00
committed by GitHub
parent fa3ea4bb68
commit f00e7cc670
9 changed files with 514 additions and 256 deletions

View File

@ -9,7 +9,6 @@ const StyledChip = styled.div<{ deletable: boolean }>`
border-radius: 4px;
height: 20px;
box-sizing: border-box;
cursor: pointer;
display: inline-flex;
align-items: center;
flex-direction: row;
@ -20,10 +19,13 @@ const StyledChip = styled.div<{ deletable: boolean }>`
white-space: nowrap;
${({ theme, deletable }) =>
!deletable &&
css`
padding-right: ${theme.spacing(1)};
`}
!deletable
? css`
padding-right: ${theme.spacing(1)};
`
: css`
cursor: pointer;
`}
`;
const StyledLabel = styled.span`
@ -70,7 +72,7 @@ export const VariableChip = ({
<StyledLabel>{extractVariableLabel(rawVariableName)}</StyledLabel>
{onRemove ? (
<StyledDelete onClick={onRemove}>
<StyledDelete onClick={onRemove} aria-label="Remove variable">
<IconX size={theme.icon.size.sm} stroke={theme.icon.stroke.sm} />
</StyledDelete>
) : null}

View File

@ -83,6 +83,45 @@ export const SaveValidJson: Story = {
},
};
export const SaveValidMultilineJson: 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,
'{{{Enter} "a": {{{Enter} "b" : "d"{Enter} }{Enter}}',
);
await waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(
'{\n "a": {\n "b" : "d"\n }\n}',
);
});
},
};
export const MultilineWithDefaultValue: Story = {
args: {
placeholder: 'Enter valid json',
defaultValue: '{\n "a": {\n "b" : "d"\n }\n}',
},
play: async ({ canvasElement }) => {
const editor = canvasElement.querySelector('.ProseMirror > p');
expect(editor).toBeVisible();
await waitFor(() => {
expect((editor as HTMLElement).innerText).toBe(
'{\n "a": {\n "b" : "d"\n }\n}',
);
});
},
};
export const DoesNotIgnoreInvalidJson: Story = {
args: {
placeholder: 'Enter valid json',

View File

@ -1,5 +1,12 @@
import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
import {
expect,
fn,
userEvent,
waitFor,
waitForElementToBeRemoved,
within,
} from '@storybook/test';
import { getUserDevice } from 'twenty-ui';
import { FormTextFieldInput } from '../FormTextFieldInput';
@ -45,18 +52,92 @@ export const Multiline: Story = {
},
};
export const WithVariablePicker: Story = {
export const MultilineWithDefaultValue: Story = {
args: {
label: 'Text',
defaultValue: 'Line 1\nLine 2\n\nLine 4',
placeholder: 'Text field...',
VariablePicker: () => <div>VariablePicker</div>,
multiline: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const variablePicker = await canvas.findByText('VariablePicker');
await canvas.findByText(/^Text$/);
expect(variablePicker).toBeVisible();
const editor = canvasElement.querySelector('.ProseMirror > p');
expect((editor as HTMLElement).innerText).toBe('Line 1\nLine 2\n\nLine 4');
},
};
export const WithVariable: Story = {
args: {
label: 'Text',
placeholder: 'Text field...',
VariablePicker: ({ onVariableSelect }) => {
return (
<button
onClick={() => {
onVariableSelect('{{test}}');
}}
>
Add variable
</button>
);
},
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const addVariableButton = await canvas.findByRole('button', {
name: 'Add variable',
});
await userEvent.click(addVariableButton);
const variable = await canvas.findByText('test');
expect(variable).toBeVisible();
await waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith('{{test}}');
});
expect(args.onPersist).toHaveBeenCalledTimes(1);
},
};
export const WithDeletableVariable: Story = {
args: {
label: 'Text',
placeholder: 'Text field...',
defaultValue: 'test {{a.b.variable}} test',
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const editor = canvasElement.querySelector('.ProseMirror > p');
expect(editor).toBeVisible();
const variable = await canvas.findByText('variable');
expect(variable).toBeVisible();
const deleteVariableButton = await canvas.findByRole('button', {
name: 'Remove variable',
});
await Promise.all([
waitForElementToBeRemoved(variable),
deleteVariableButton.click(),
]);
expect(editor).toHaveTextContent('test test');
await waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith('test test');
});
expect(args.onPersist).toHaveBeenCalledTimes(1);
},
};
@ -89,6 +170,28 @@ export const Disabled: Story = {
},
};
export const DisabledWithVariable: Story = {
args: {
label: 'Text',
defaultValue: 'test {{a.b.variable}} test',
readonly: true,
},
play: async ({ canvasElement }) => {
const editor = canvasElement.querySelector('.ProseMirror > p');
expect(editor).toBeVisible();
await waitFor(() => {
expect(editor).toHaveTextContent('test variable test');
});
const deleteVariableButton = within(editor as HTMLElement).queryByRole(
'button',
);
expect(deleteVariableButton).not.toBeInTheDocument();
},
};
export const HasHistory: Story = {
args: {
label: 'Text',

View File

@ -1,4 +1,4 @@
import { initializeEditorContent } from '@/workflow/workflow-variables/utils/initializeEditorContent';
import { getInitialEditorContent } from '@/workflow/workflow-variables/utils/getInitialEditorContent';
import { VariableTag } from '@/workflow/workflow-variables/utils/variableTag';
import Document from '@tiptap/extension-document';
import HardBreak from '@tiptap/extension-hard-break';
@ -7,7 +7,6 @@ import Paragraph from '@tiptap/extension-paragraph';
import { default as Placeholder } from '@tiptap/extension-placeholder';
import Text from '@tiptap/extension-text';
import { Editor, useEditor } from '@tiptap/react';
import { useState } from 'react';
import { isDefined } from 'twenty-ui';
type UseTextVariableEditorProps = {
@ -25,8 +24,6 @@ export const useTextVariableEditor = ({
defaultValue,
onUpdate,
}: UseTextVariableEditorProps) => {
const [isInitializing, setIsInitializing] = useState(true);
const editor = useEditor({
extensions: [
Document,
@ -45,17 +42,11 @@ export const useTextVariableEditor = ({
: []),
History,
],
content: isDefined(defaultValue)
? getInitialEditorContent(defaultValue)
: undefined,
editable: !readonly,
onCreate: ({ editor }) => {
if (isDefined(defaultValue)) {
initializeEditorContent(editor, defaultValue);
}
setIsInitializing(false);
},
onUpdate: ({ editor }) => {
if (isInitializing) {
return;
}
onUpdate(editor);
},
editorProps: {

View File

@ -12,6 +12,7 @@ type WorkflowTextEditorVariableChipProps = NodeViewProps;
export const WorkflowTextEditorVariableChip = ({
deleteNode,
node,
editor,
}: WorkflowTextEditorVariableChipProps) => {
const attrs = node.attrs as {
variable: string;
@ -19,7 +20,10 @@ export const WorkflowTextEditorVariableChip = ({
return (
<NodeViewWrapper as={StyledWrapper} style={{ whiteSpace: 'nowrap' }}>
<VariableChip rawVariableName={attrs.variable} onRemove={deleteNode} />
<VariableChip
rawVariableName={attrs.variable}
onRemove={editor.isEditable ? deleteNode : undefined}
/>
</NodeViewWrapper>
);
};

View File

@ -0,0 +1,306 @@
import { getInitialEditorContent } from '../getInitialEditorContent';
describe('getInitialEditorContent', () => {
it('should handle single line text', () => {
expect(getInitialEditorContent('Hello world')).toMatchInlineSnapshot(`
{
"content": [
{
"content": [
{
"text": "Hello world",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "doc",
}
`);
});
it('should handle text with newlines', () => {
expect(getInitialEditorContent('Line 1\nLine 2')).toMatchInlineSnapshot(`
{
"content": [
{
"content": [
{
"text": "Line 1",
"type": "text",
},
{
"type": "hardBreak",
},
{
"text": "Line 2",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "doc",
}
`);
});
it('should handle single variable', () => {
expect(getInitialEditorContent('{{user.name}}')).toMatchInlineSnapshot(`
{
"content": [
{
"content": [
{
"attrs": {
"variable": "{{user.name}}",
},
"type": "variableTag",
},
],
"type": "paragraph",
},
],
"type": "doc",
}
`);
});
it('should handle text with variables', () => {
expect(getInitialEditorContent('Hello {{user.name}}, welcome!'))
.toMatchInlineSnapshot(`
{
"content": [
{
"content": [
{
"text": "Hello ",
"type": "text",
},
{
"attrs": {
"variable": "{{user.name}}",
},
"type": "variableTag",
},
{
"text": ", welcome!",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "doc",
}
`);
});
it('should handle text with multiple variables', () => {
expect(
getInitialEditorContent('Hello {{user.firstName}} {{user.lastName}}!'),
).toMatchInlineSnapshot(`
{
"content": [
{
"content": [
{
"text": "Hello ",
"type": "text",
},
{
"attrs": {
"variable": "{{user.firstName}}",
},
"type": "variableTag",
},
{
"text": " ",
"type": "text",
},
{
"attrs": {
"variable": "{{user.lastName}}",
},
"type": "variableTag",
},
{
"text": "!",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "doc",
}
`);
});
it('should handle newlines with variables', () => {
expect(
getInitialEditorContent('Hello {{user.name}}\nWelcome to {{app.name}}'),
).toMatchInlineSnapshot(`
{
"content": [
{
"content": [
{
"text": "Hello ",
"type": "text",
},
{
"attrs": {
"variable": "{{user.name}}",
},
"type": "variableTag",
},
{
"type": "hardBreak",
},
{
"text": "Welcome to ",
"type": "text",
},
{
"attrs": {
"variable": "{{app.name}}",
},
"type": "variableTag",
},
],
"type": "paragraph",
},
],
"type": "doc",
}
`);
});
it('should handle empty strings', () => {
expect(getInitialEditorContent('')).toMatchInlineSnapshot(`
{
"content": [
{
"content": [],
"type": "paragraph",
},
],
"type": "doc",
}
`);
});
it('should handle multiple empty parts', () => {
expect(getInitialEditorContent('Hello {{user.name}} !'))
.toMatchInlineSnapshot(`
{
"content": [
{
"content": [
{
"text": "Hello ",
"type": "text",
},
{
"attrs": {
"variable": "{{user.name}}",
},
"type": "variableTag",
},
{
"text": " !",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "doc",
}
`);
});
it('should handle multiple newlines', () => {
expect(getInitialEditorContent('Line1\n\nLine3')).toMatchInlineSnapshot(`
{
"content": [
{
"content": [
{
"text": "Line1",
"type": "text",
},
{
"type": "hardBreak",
},
{
"type": "hardBreak",
},
{
"text": "Line3",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "doc",
}
`);
});
it('should ignore malformed variable tags', () => {
expect(
getInitialEditorContent('Hello {{user.name}} and {{invalid}more}} text'),
).toMatchInlineSnapshot(`
{
"content": [
{
"content": [
{
"text": "Hello ",
"type": "text",
},
{
"attrs": {
"variable": "{{user.name}}",
},
"type": "variableTag",
},
{
"text": " and {{invalid}more}} text",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "doc",
}
`);
});
it('should handle trailing newlines', () => {
expect(getInitialEditorContent('Hello\n')).toMatchInlineSnapshot(`
{
"content": [
{
"content": [
{
"text": "Hello",
"type": "text",
},
{
"type": "hardBreak",
},
],
"type": "paragraph",
},
],
"type": "doc",
}
`);
});
});

View File

@ -1,195 +0,0 @@
import { Editor } from '@tiptap/react';
import { initializeEditorContent } from '../initializeEditorContent';
describe('initializeEditorContent', () => {
let mockEditor: Editor;
beforeEach(() => {
mockEditor = {
commands: {
insertContent: jest.fn(),
},
} as unknown as Editor;
});
it('should handle single line text', () => {
initializeEditorContent(mockEditor, 'Hello world');
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(1);
expect(mockEditor.commands.insertContent).toHaveBeenCalledWith(
'Hello world',
);
});
it('should handle text with newlines', () => {
initializeEditorContent(mockEditor, 'Line 1\nLine 2');
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(3);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
1,
'Line 1',
);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
type: 'hardBreak',
});
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
3,
'Line 2',
);
});
it('should handle single variable', () => {
initializeEditorContent(mockEditor, '{{user.name}}');
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(1);
expect(mockEditor.commands.insertContent).toHaveBeenCalledWith({
type: 'variableTag',
attrs: { variable: '{{user.name}}' },
});
});
it('should handle text with variables', () => {
initializeEditorContent(mockEditor, 'Hello {{user.name}}, welcome!');
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(3);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
1,
'Hello ',
);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
type: 'variableTag',
attrs: { variable: '{{user.name}}' },
});
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
3,
', welcome!',
);
});
it('should handle text with multiple variables', () => {
initializeEditorContent(
mockEditor,
'Hello {{user.firstName}} {{user.lastName}}!',
);
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(5);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
1,
'Hello ',
);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
type: 'variableTag',
attrs: { variable: '{{user.firstName}}' },
});
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(3, ' ');
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(4, {
type: 'variableTag',
attrs: { variable: '{{user.lastName}}' },
});
});
it('should handle newlines with variables', () => {
initializeEditorContent(
mockEditor,
'Hello {{user.name}}\nWelcome to {{app.name}}',
);
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(5);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
1,
'Hello ',
);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
type: 'variableTag',
attrs: { variable: '{{user.name}}' },
});
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(3, {
type: 'hardBreak',
});
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
4,
'Welcome to ',
);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(5, {
type: 'variableTag',
attrs: { variable: '{{app.name}}' },
});
});
it('should handle empty strings', () => {
initializeEditorContent(mockEditor, '');
expect(mockEditor.commands.insertContent).not.toHaveBeenCalled();
});
it('should handle multiple empty parts', () => {
initializeEditorContent(mockEditor, 'Hello {{user.name}} !');
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(3);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
1,
'Hello ',
);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
type: 'variableTag',
attrs: { variable: '{{user.name}}' },
});
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
3,
' !',
);
});
it('should handle multiple newlines', () => {
initializeEditorContent(mockEditor, 'Line1\n\nLine3');
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(4);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
1,
'Line1',
);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
type: 'hardBreak',
});
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(3, {
type: 'hardBreak',
});
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
4,
'Line3',
);
});
it('should ignore malformed variable tags', () => {
initializeEditorContent(
mockEditor,
'Hello {{user.name}} and {{invalid}more}} text',
);
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(3);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
1,
'Hello ',
);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
type: 'variableTag',
attrs: { variable: '{{user.name}}' },
});
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
3,
' and {{invalid}more}} text',
);
});
it('should handle trailing newlines', () => {
initializeEditorContent(mockEditor, 'Hello\n');
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(2);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
1,
'Hello',
);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
type: 'hardBreak',
});
});
});

View File

@ -0,0 +1,44 @@
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import { isNonEmptyString } from '@sniptt/guards';
import { JSONContent } from '@tiptap/react';
export const CAPTURE_VARIABLE_TAG_REGEX = /({{[^{}]+}})/;
export const getInitialEditorContent = (rawContent: string): JSONContent => {
const paragraphContent: JSONContent[] = [];
const lines = rawContent.split(/\n/);
lines.forEach((line, index) => {
const parts = line.split(CAPTURE_VARIABLE_TAG_REGEX);
parts.forEach((part) => {
if (isStandaloneVariableString(part)) {
paragraphContent.push({
type: 'variableTag',
attrs: { variable: part },
});
} else if (isNonEmptyString(part)) {
paragraphContent.push({
type: 'text',
text: part,
});
}
});
if (index < lines.length - 1) {
paragraphContent.push({
type: 'hardBreak',
});
}
});
return {
type: 'doc',
content: [
{
type: 'paragraph',
content: paragraphContent,
},
],
};
};

View File

@ -1,36 +0,0 @@
import { isNonEmptyString } from '@sniptt/guards';
import { Editor } from '@tiptap/react';
export const CAPTURE_VARIABLE_TAG_REGEX = /({{[^{}]+}})/;
export const initializeEditorContent = (editor: Editor, content: string) => {
const lines = content.split(/\n/);
lines.forEach((line, index) => {
const parts = line.split(CAPTURE_VARIABLE_TAG_REGEX);
parts.forEach((part) => {
if (part.length === 0) {
return;
}
if (part.startsWith('{{') && part.endsWith('}}')) {
editor.commands.insertContent({
type: 'variableTag',
attrs: { variable: part },
});
return;
}
if (isNonEmptyString(part)) {
editor.commands.insertContent(part);
}
});
// Add hard break if it's not the last line
if (index < lines.length - 1) {
editor.commands.insertContent({
type: 'hardBreak',
});
}
});
};