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

@ -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',
});
}
});
};