Text area using variables (#8034)

- Adding multiline props to component
- Update design and logic accordingly 
- Fix hotkey scope for right drawer



https://github.com/user-attachments/assets/65ff9641-71a4-4828-a62b-e09327b63150
This commit is contained in:
Thomas Trompette
2024-10-25 14:55:56 +02:00
committed by GitHub
parent 0144553667
commit 2e73d020a3
11 changed files with 272 additions and 95 deletions

View File

@ -49,6 +49,7 @@
"@stoplight/elements": "^8.0.5", "@stoplight/elements": "^8.0.5",
"@swc/jest": "^0.2.29", "@swc/jest": "^0.2.29",
"@tabler/icons-react": "^2.44.0", "@tabler/icons-react": "^2.44.0",
"@tiptap/extension-hard-break": "^2.9.1",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@types/facepaint": "^1.2.5", "@types/facepaint": "^1.2.5",
"@types/lodash.camelcase": "^4.3.7", "@types/lodash.camelcase": "^4.3.7",

View File

@ -1,5 +1,7 @@
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope';
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { CREATE_STEP_STEP_ID } from '@/workflow/constants/CreateStepStepId'; import { CREATE_STEP_STEP_ID } from '@/workflow/constants/CreateStepStepId';
import { EMPTY_TRIGGER_STEP_ID } from '@/workflow/constants/EmptyTriggerStepId'; import { EMPTY_TRIGGER_STEP_ID } from '@/workflow/constants/EmptyTriggerStepId';
import { useStartNodeCreation } from '@/workflow/hooks/useStartNodeCreation'; import { useStartNodeCreation } from '@/workflow/hooks/useStartNodeCreation';
@ -15,6 +17,8 @@ export const WorkflowDiagramCanvasEditableEffect = () => {
const { startNodeCreation } = useStartNodeCreation(); const { startNodeCreation } = useStartNodeCreation();
const { openRightDrawer, closeRightDrawer } = useRightDrawer(); const { openRightDrawer, closeRightDrawer } = useRightDrawer();
const setHotkeyScope = useSetHotkeyScope();
const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState); const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState);
const handleSelectionChange = useCallback( const handleSelectionChange = useCallback(
@ -47,9 +51,11 @@ export const WorkflowDiagramCanvasEditableEffect = () => {
} }
setWorkflowSelectedNode(selectedNode.id); setWorkflowSelectedNode(selectedNode.id);
setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false });
openRightDrawer(RightDrawerPages.WorkflowStepEdit); openRightDrawer(RightDrawerPages.WorkflowStepEdit);
}, },
[ [
setHotkeyScope,
closeRightDrawer, closeRightDrawer,
openRightDrawer, openRightDrawer,
setWorkflowSelectedNode, setWorkflowSelectedNode,

View File

@ -1,5 +1,7 @@
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope';
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { useTriggerNodeSelection } from '@/workflow/hooks/useTriggerNodeSelection'; import { useTriggerNodeSelection } from '@/workflow/hooks/useTriggerNodeSelection';
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState'; import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
import { WorkflowDiagramNode } from '@/workflow/types/WorkflowDiagram'; import { WorkflowDiagramNode } from '@/workflow/types/WorkflowDiagram';
@ -11,6 +13,7 @@ import { isDefined } from 'twenty-ui';
export const WorkflowDiagramCanvasReadonlyEffect = () => { export const WorkflowDiagramCanvasReadonlyEffect = () => {
const { openRightDrawer, closeRightDrawer } = useRightDrawer(); const { openRightDrawer, closeRightDrawer } = useRightDrawer();
const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState); const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState);
const setHotkeyScope = useSetHotkeyScope();
const handleSelectionChange = useCallback( const handleSelectionChange = useCallback(
({ nodes }: OnSelectionChangeParams) => { ({ nodes }: OnSelectionChangeParams) => {
@ -24,9 +27,15 @@ export const WorkflowDiagramCanvasReadonlyEffect = () => {
} }
setWorkflowSelectedNode(selectedNode.id); setWorkflowSelectedNode(selectedNode.id);
setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false });
openRightDrawer(RightDrawerPages.WorkflowStepView); openRightDrawer(RightDrawerPages.WorkflowStepView);
}, },
[closeRightDrawer, openRightDrawer, setWorkflowSelectedNode], [
closeRightDrawer,
openRightDrawer,
setWorkflowSelectedNode,
setHotkeyScope,
],
); );
useOnSelectionChange({ useOnSelectionChange({

View File

@ -4,9 +4,8 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth'; import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth';
import { Select, SelectOption } from '@/ui/input/components/Select'; import { Select, SelectOption } from '@/ui/input/components/Select';
import { TextArea } from '@/ui/input/components/TextArea';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase'; import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import VariableTagInput from '@/workflow/search-variables/components/VariableTagInput'; import { VariableTagInput } from '@/workflow/search-variables/components/VariableTagInput';
import { workflowIdState } from '@/workflow/states/workflowIdState'; import { workflowIdState } from '@/workflow/states/workflowIdState';
import { WorkflowSendEmailStep } from '@/workflow/types/Workflow'; import { WorkflowSendEmailStep } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
@ -227,20 +226,20 @@ export const WorkflowEditActionFormSendEmail = (
/> />
)} )}
/> />
<Controller <Controller
name="body" name="body"
control={form.control} control={form.control}
render={({ field }) => ( render={({ field }) => (
<TextArea <VariableTagInput
inputId="email-body-input"
label="Body" label="Body"
placeholder="Enter email body (use {{variable}} for dynamic content)" placeholder="Enter email body (use {{variable}} for dynamic content)"
value={field.value} value={field.value}
minRows={4}
onChange={(email) => { onChange={(email) => {
field.onChange(email); field.onChange(email);
handleSave(); handleSave();
}} }}
multiline
/> />
)} )}
/> />

View File

@ -1,5 +1,7 @@
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope';
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { workflowCreateStepFromParentStepIdState } from '@/workflow/states/workflowCreateStepFromParentStepIdState'; import { workflowCreateStepFromParentStepIdState } from '@/workflow/states/workflowCreateStepFromParentStepIdState';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useSetRecoilState } from 'recoil'; import { useSetRecoilState } from 'recoil';
@ -9,6 +11,7 @@ export const useStartNodeCreation = () => {
const setWorkflowCreateStepFromParentStepId = useSetRecoilState( const setWorkflowCreateStepFromParentStepId = useSetRecoilState(
workflowCreateStepFromParentStepIdState, workflowCreateStepFromParentStepIdState,
); );
const setHotkeyScope = useSetHotkeyScope();
/** /**
* This function is used in a context where dependencies shouldn't change much. * This function is used in a context where dependencies shouldn't change much.
@ -18,9 +21,10 @@ export const useStartNodeCreation = () => {
(parentNodeId: string) => { (parentNodeId: string) => {
setWorkflowCreateStepFromParentStepId(parentNodeId); setWorkflowCreateStepFromParentStepId(parentNodeId);
setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false });
openRightDrawer(RightDrawerPages.WorkflowStepSelectAction); openRightDrawer(RightDrawerPages.WorkflowStepSelectAction);
}, },
[openRightDrawer, setWorkflowCreateStepFromParentStepId], [openRightDrawer, setWorkflowCreateStepFromParentStepId, setHotkeyScope],
); );
return { return {

View File

@ -4,6 +4,7 @@ import { parseEditorContent } from '@/workflow/search-variables/utils/parseEdito
import { VariableTag } from '@/workflow/search-variables/utils/variableTag'; import { VariableTag } from '@/workflow/search-variables/utils/variableTag';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import Document from '@tiptap/extension-document'; import Document from '@tiptap/extension-document';
import HardBreak from '@tiptap/extension-hard-break';
import Paragraph from '@tiptap/extension-paragraph'; import Paragraph from '@tiptap/extension-paragraph';
import Placeholder from '@tiptap/extension-placeholder'; import Placeholder from '@tiptap/extension-placeholder';
import Text from '@tiptap/extension-text'; import Text from '@tiptap/extension-text';
@ -11,6 +12,8 @@ import { EditorContent, useEditor } from '@tiptap/react';
import { isDefined } from 'twenty-ui'; import { isDefined } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
const LINE_HEIGHT = 24;
const StyledContainer = styled.div` const StyledContainer = styled.div`
display: inline-flex; display: inline-flex;
flex-direction: column; flex-direction: column;
@ -23,12 +26,18 @@ const StyledLabel = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(1)}; margin-bottom: ${({ theme }) => theme.spacing(1)};
`; `;
const StyledInputContainer = styled.div` const StyledInputContainer = styled.div<{ multiline: boolean }>`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
position: relative;
line-height: ${({ multiline }) => (multiline ? `${LINE_HEIGHT}px` : 'auto')};
min-height: ${({ multiline }) =>
multiline ? `${3 * LINE_HEIGHT}px` : 'auto'};
max-height: ${({ multiline }) =>
multiline ? `${5 * LINE_HEIGHT}px` : 'auto'};
`; `;
const StyledSearchVariablesDropdownContainer = styled.div` const StyledSearchVariablesDropdownOutsideContainer = styled.div`
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@ -38,18 +47,33 @@ const StyledSearchVariablesDropdownContainer = styled.div`
border: 1px solid ${({ theme }) => theme.border.color.medium}; border: 1px solid ${({ theme }) => theme.border.color.medium};
`; `;
const StyledEditor = styled.div` const StyledSearchVariablesDropdownInsideContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
position: absolute;
top: ${({ theme }) => theme.spacing(0.5)};
right: ${({ theme }) => theme.spacing(0.5)};
`;
const StyledEditor = styled.div<{ multiline: boolean }>`
display: flex; display: flex;
height: 32px;
width: 100%; width: 100%;
border: 1px solid ${({ theme }) => theme.border.color.medium}; border: 1px solid ${({ theme }) => theme.border.color.medium};
border-bottom-left-radius: ${({ theme }) => theme.border.radius.sm}; border-bottom-left-radius: ${({ theme }) => theme.border.radius.sm};
border-top-left-radius: ${({ theme }) => theme.border.radius.sm}; border-top-left-radius: ${({ theme }) => theme.border.radius.sm};
border-right: none;
box-sizing: border-box; box-sizing: border-box;
background-color: ${({ theme }) => theme.background.transparent.lighter}; background-color: ${({ theme }) => theme.background.transparent.lighter};
overflow: hidden; padding: ${({ theme }) => `${theme.spacing(1)} ${theme.spacing(2)}`};
padding: ${({ theme }) => theme.spacing(2)}; border-bottom-right-radius: ${({ multiline, theme }) =>
multiline ? theme.border.radius.sm : 'none'};
border-top-right-radius: ${({ multiline, theme }) =>
multiline ? theme.border.radius.sm : 'none'};
border-right: ${({ multiline }) => (multiline ? 'auto' : 'none')};
padding-right: ${({ multiline, theme }) =>
multiline ? theme.spacing(6) : theme.spacing(2)};
overflow: ${({ multiline }) => (multiline ? 'auto' : 'hidden')};
height: ${({ multiline }) => (multiline ? 'auto' : `${1.5 * LINE_HEIGHT}px`)};
.editor-content { .editor-content {
width: 100%; width: 100%;
@ -57,13 +81,14 @@ const StyledEditor = styled.div`
.tiptap { .tiptap {
display: flex; display: flex;
align-items: center;
height: 100%; height: 100%;
color: ${({ theme }) => theme.font.color.primary}; color: ${({ theme }) => theme.font.color.primary};
font-family: ${({ theme }) => theme.font.family}; font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.regular}; font-weight: ${({ theme }) => theme.font.weight.regular};
border: none !important; border: none !important;
white-space: nowrap; align-items: ${({ multiline }) => (multiline ? 'top' : 'center')};
white-space: ${({ multiline }) => (multiline ? 'pre-wrap' : 'nowrap')};
word-wrap: ${({ multiline }) => (multiline ? 'break-word' : 'normal')};
p.is-editor-empty:first-of-type::before { p.is-editor-empty:first-of-type::before {
content: attr(data-placeholder); content: attr(data-placeholder);
@ -94,8 +119,9 @@ interface VariableTagInputProps {
inputId: string; inputId: string;
label?: string; label?: string;
value?: string; value?: string;
onChange?: (content: string) => void;
placeholder?: string; placeholder?: string;
multiline?: boolean;
onChange?: (content: string) => void;
} }
export const VariableTagInput = ({ export const VariableTagInput = ({
@ -103,8 +129,13 @@ export const VariableTagInput = ({
label, label,
value, value,
placeholder, placeholder,
multiline,
onChange, onChange,
}: VariableTagInputProps) => { }: VariableTagInputProps) => {
const StyledSearchVariablesDropdownContainer = multiline
? StyledSearchVariablesDropdownInsideContainer
: StyledSearchVariablesDropdownOutsideContainer;
const deboucedOnUpdate = useDebouncedCallback((editor) => { const deboucedOnUpdate = useDebouncedCallback((editor) => {
const jsonContent = editor.getJSON(); const jsonContent = editor.getJSON();
const parsedContent = parseEditorContent(jsonContent); const parsedContent = parseEditorContent(jsonContent);
@ -120,6 +151,13 @@ export const VariableTagInput = ({
placeholder, placeholder,
}), }),
VariableTag, VariableTag,
...(multiline
? [
HardBreak.configure({
keepMarks: false,
}),
]
: []),
], ],
editable: true, editable: true,
onCreate: ({ editor }) => { onCreate: ({ editor }) => {
@ -130,6 +168,29 @@ export const VariableTagInput = ({
onUpdate: ({ editor }) => { onUpdate: ({ editor }) => {
deboucedOnUpdate(editor); deboucedOnUpdate(editor);
}, },
editorProps: {
handleKeyDown: (view, event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
const { state } = view;
const { tr } = state;
// Insert hard break using the view's state and dispatch
const transaction = tr.replaceSelectionWith(
state.schema.nodes.hardBreak.create(),
);
view.dispatch(transaction);
return true;
}
return false;
},
},
enableInputRules: false,
enablePasteRules: false,
injectCSS: false,
}); });
if (!editor) { if (!editor) {
@ -139,8 +200,8 @@ export const VariableTagInput = ({
return ( return (
<StyledContainer> <StyledContainer>
{label && <StyledLabel>{label}</StyledLabel>} {label && <StyledLabel>{label}</StyledLabel>}
<StyledInputContainer> <StyledInputContainer multiline={!!multiline}>
<StyledEditor> <StyledEditor multiline={!!multiline}>
<EditorContent className="editor-content" editor={editor} /> <EditorContent className="editor-content" editor={editor} />
</StyledEditor> </StyledEditor>
<StyledSearchVariablesDropdownContainer> <StyledSearchVariablesDropdownContainer>

View File

@ -2,22 +2,17 @@ import { Editor } from '@tiptap/react';
import { initializeEditorContent } from '../initializeEditorContent'; import { initializeEditorContent } from '../initializeEditorContent';
describe('initializeEditorContent', () => { describe('initializeEditorContent', () => {
const mockEditor = { let mockEditor: Editor;
commands: {
insertContent: jest.fn(),
},
} as unknown as Editor;
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); mockEditor = {
commands: {
insertContent: jest.fn(),
},
} as unknown as Editor;
}); });
it('should handle empty string', () => { it('should handle single line text', () => {
initializeEditorContent(mockEditor, '');
expect(mockEditor.commands.insertContent).not.toHaveBeenCalled();
});
it('should insert plain text correctly', () => {
initializeEditorContent(mockEditor, 'Hello world'); initializeEditorContent(mockEditor, 'Hello world');
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(1); expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(1);
@ -26,7 +21,24 @@ describe('initializeEditorContent', () => {
); );
}); });
it('should insert single variable correctly', () => { 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}}'); initializeEditorContent(mockEditor, '{{user.name}}');
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(1); expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(1);
@ -36,8 +48,8 @@ describe('initializeEditorContent', () => {
}); });
}); });
it('should handle text with variable in the middle', () => { it('should handle text with variables', () => {
initializeEditorContent(mockEditor, 'Hello {{user.name}} world'); initializeEditorContent(mockEditor, 'Hello {{user.name}}, welcome!');
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(3); expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(3);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith( expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
@ -50,17 +62,17 @@ describe('initializeEditorContent', () => {
}); });
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith( expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
3, 3,
' world', ', welcome!',
); );
}); });
it('should handle multiple variables', () => { it('should handle text with multiple variables', () => {
initializeEditorContent( initializeEditorContent(
mockEditor, mockEditor,
'Hello {{user.firstName}} {{user.lastName}}, welcome to {{app.name}}', 'Hello {{user.firstName}} {{user.lastName}}!',
); );
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(6); expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(5);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith( expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
1, 1,
'Hello ', 'Hello ',
@ -74,70 +86,84 @@ describe('initializeEditorContent', () => {
type: 'variableTag', type: 'variableTag',
attrs: { variable: '{{user.lastName}}' }, attrs: { variable: '{{user.lastName}}' },
}); });
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith( });
5,
', welcome to ', it('should handle newlines with variables', () => {
initializeEditorContent(
mockEditor,
'Hello {{user.name}}\nWelcome to {{app.name}}',
); );
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(6, {
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', type: 'variableTag',
attrs: { variable: '{{app.name}}' }, attrs: { variable: '{{app.name}}' },
}); });
}); });
it('should handle variables at the start and end', () => { it('should handle empty strings', () => {
initializeEditorContent(mockEditor, '{{start.var}} middle {{end.var}}'); initializeEditorContent(mockEditor, '');
expect(mockEditor.commands.insertContent).not.toHaveBeenCalled();
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(3);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(1, {
type: 'variableTag',
attrs: { variable: '{{start.var}}' },
});
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
2,
' middle ',
);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(3, {
type: 'variableTag',
attrs: { variable: '{{end.var}}' },
});
}); });
it('should handle consecutive variables', () => { it('should handle multiple empty parts', () => {
initializeEditorContent(mockEditor, '{{var1}}{{var2}}{{var3}}'); initializeEditorContent(mockEditor, 'Hello {{user.name}} !');
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(3); expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(3);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(1, { expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
type: 'variableTag', 1,
attrs: { variable: '{{var1}}' }, 'Hello ',
}); );
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, { expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
type: 'variableTag', type: 'variableTag',
attrs: { variable: '{{var2}}' }, attrs: { variable: '{{user.name}}' },
});
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(3, {
type: 'variableTag',
attrs: { variable: '{{var3}}' },
}); });
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
3,
' !',
);
}); });
it('should handle whitespace between variables', () => { it('should handle multiple newlines', () => {
initializeEditorContent(mockEditor, '{{var1}} {{var2}} '); initializeEditorContent(mockEditor, 'Line1\n\nLine3');
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(4); expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(4);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(1, { expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
type: 'variableTag', 1,
attrs: { variable: '{{var1}}' }, 'Line1',
);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
type: 'hardBreak',
}); });
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, ' ');
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(3, { expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(3, {
type: 'variableTag', type: 'hardBreak',
attrs: { variable: '{{var2}}' },
}); });
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(4, ' '); expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
4,
'Line3',
);
}); });
it('should handle nested variable syntax', () => { it('should ignore malformed variable tags', () => {
initializeEditorContent(mockEditor, 'Hello {{user.address.city}}!'); initializeEditorContent(
mockEditor,
'Hello {{user.name}} and {{invalid}more}} text',
);
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(3); expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(3);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith( expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
@ -146,8 +172,24 @@ describe('initializeEditorContent', () => {
); );
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, { expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
type: 'variableTag', type: 'variableTag',
attrs: { variable: '{{user.address.city}}' }, 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',
}); });
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(3, '!');
}); });
}); });

View File

@ -236,4 +236,35 @@ describe('parseEditorContent', () => {
'Hello {{user.firstName}} {{user.lastName}}Your ID is: {{user.id}}', 'Hello {{user.firstName}} {{user.lastName}}Your ID is: {{user.id}}',
); );
}); });
it('should handle hard breaks', () => {
const input: JSONContent = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'First line',
},
],
},
{
type: 'hardBreak',
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Second line',
},
],
},
],
};
expect(parseEditorContent(input)).toBe('First line\nSecond line');
});
}); });

View File

@ -4,23 +4,33 @@ import { Editor } from '@tiptap/react';
const REGEX_VARIABLE_TAG = /(\{\{[^}]+\}\})/; const REGEX_VARIABLE_TAG = /(\{\{[^}]+\}\})/;
export const initializeEditorContent = (editor: Editor, content: string) => { export const initializeEditorContent = (editor: Editor, content: string) => {
const parts = content.split(REGEX_VARIABLE_TAG); const lines = content.split(/\n/);
parts.forEach((part) => { lines.forEach((line, index) => {
if (part.length === 0) { const parts = line.split(REGEX_VARIABLE_TAG);
return; parts.forEach((part) => {
} if (part.length === 0) {
return;
}
if (part.startsWith('{{') && part.endsWith('}}')) { 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({ editor.commands.insertContent({
type: 'variableTag', type: 'hardBreak',
attrs: { variable: part },
}); });
return;
}
if (isNonEmptyString(part)) {
editor.commands.insertContent(part);
} }
}); });
}; };

View File

@ -14,6 +14,10 @@ export const parseEditorContent = (json: JSONContent): string => {
return node.text || ''; return node.text || '';
} }
if (node.type === 'hardBreak') {
return '\n';
}
if (node.type === 'variableTag') { if (node.type === 'variableTag') {
return node.attrs?.variable || ''; return node.attrs?.variable || '';
} }

View File

@ -14965,6 +14965,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@tiptap/extension-hard-break@npm:^2.9.1":
version: 2.9.1
resolution: "@tiptap/extension-hard-break@npm:2.9.1"
peerDependencies:
"@tiptap/core": ^2.7.0
checksum: 10c0/1a9beac209d3df229ac8db5364b34db54ca34c77db413fb5acfaa5380cb19bc7b322739bd8f975ec0fbbf15f69f1c6fdd85cee744f488595c07a0877fcf253da
languageName: node
linkType: hard
"@tiptap/extension-history@npm:^2.5.0": "@tiptap/extension-history@npm:^2.5.0":
version: 2.5.9 version: 2.5.9
resolution: "@tiptap/extension-history@npm:2.5.9" resolution: "@tiptap/extension-history@npm:2.5.9"
@ -44382,6 +44391,7 @@ __metadata:
"@tabler/icons-react": "npm:^2.44.0" "@tabler/icons-react": "npm:^2.44.0"
"@testing-library/jest-dom": "npm:^6.1.5" "@testing-library/jest-dom": "npm:^6.1.5"
"@testing-library/react": "npm:14.0.0" "@testing-library/react": "npm:14.0.0"
"@tiptap/extension-hard-break": "npm:^2.9.1"
"@types/addressparser": "npm:^1.0.3" "@types/addressparser": "npm:^1.0.3"
"@types/apollo-upload-client": "npm:^17.0.2" "@types/apollo-upload-client": "npm:^17.0.2"
"@types/bcrypt": "npm:^5.0.0" "@types/bcrypt": "npm:^5.0.0"