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:
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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, '!');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 || '';
|
||||||
}
|
}
|
||||||
|
|||||||
10
yarn.lock
10
yarn.lock
@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user