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: {