mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-23 12:02:10 +03:00
Add available variables dropdown (#7964)
- Add variable dropdown - Insert variables on click - Save variable as `{{stepName.object.myVar}}` and display only `myVar` https://github.com/user-attachments/assets/9b49e32c-15e6-4b64-9901-0e63664bc3e8
This commit is contained in:
parent
18778c55ac
commit
2e8b8452c1
@ -33,6 +33,12 @@
|
||||
"@nivo/calendar": "^0.87.0",
|
||||
"@nivo/core": "^0.87.0",
|
||||
"@nivo/line": "^0.87.0",
|
||||
"@tiptap/extension-document": "^2.9.0",
|
||||
"@tiptap/extension-paragraph": "^2.9.0",
|
||||
"@tiptap/extension-placeholder": "^2.9.0",
|
||||
"@tiptap/extension-text": "^2.9.0",
|
||||
"@tiptap/extension-text-style": "^2.8.0",
|
||||
"@tiptap/react": "^2.8.0",
|
||||
"@xyflow/react": "^12.0.4",
|
||||
"transliteration": "^2.3.5"
|
||||
}
|
||||
|
@ -69,7 +69,9 @@ export const PhoneCountryPickerDropdownButton = ({
|
||||
|
||||
const [selectedCountry, setSelectedCountry] = useState<Country>();
|
||||
|
||||
const { isDropdownOpen, closeDropdown } = useDropdown('country-picker');
|
||||
const { isDropdownOpen, closeDropdown } = useDropdown(
|
||||
CountryPickerHotkeyScope.CountryPicker,
|
||||
);
|
||||
|
||||
const handleChange = (countryCode: string) => {
|
||||
onChange(countryCode);
|
||||
|
@ -5,8 +5,8 @@ import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||
import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth';
|
||||
import { Select, SelectOption } from '@/ui/input/components/Select';
|
||||
import { TextArea } from '@/ui/input/components/TextArea';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { WorkflowEditActionFormBase } from '@/workflow/components/WorkflowEditActionFormBase';
|
||||
import { VariableTagInput } from '@/workflow/search-variables/components/VariableTagInput';
|
||||
import { workflowIdState } from '@/workflow/states/workflowIdState';
|
||||
import { WorkflowSendEmailStep } from '@/workflow/types/Workflow';
|
||||
import { useTheme } from '@emotion/react';
|
||||
@ -208,7 +208,8 @@ export const WorkflowEditActionFormSendEmail = (
|
||||
name="email"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
<VariableTagInput
|
||||
inputId="email-input"
|
||||
label="Email"
|
||||
placeholder="Enter receiver email (use {{variable}} for dynamic content)"
|
||||
value={field.value}
|
||||
@ -223,7 +224,8 @@ export const WorkflowEditActionFormSendEmail = (
|
||||
name="subject"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
<VariableTagInput
|
||||
inputId="email-subject-input"
|
||||
label="Subject"
|
||||
placeholder="Enter email subject (use {{variable}} for dynamic content)"
|
||||
value={field.value}
|
||||
|
@ -0,0 +1,94 @@
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { StyledDropdownButtonContainer } from '@/ui/layout/dropdown/components/StyledDropdownButtonContainer';
|
||||
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
|
||||
import { SearchVariablesDropdownStepItem } from '@/workflow/search-variables/components/SearchVariablesDropdownStepItem';
|
||||
import SearchVariablesDropdownStepSubItem from '@/workflow/search-variables/components/SearchVariablesDropdownStepSubItem';
|
||||
import { AVAILABLE_VARIABLES_MOCK } from '@/workflow/search-variables/constants/AvailableVariablesMock';
|
||||
import { SEARCH_VARIABLES_DROPDOWN_ID } from '@/workflow/search-variables/constants/SearchVariablesDropdownId';
|
||||
import { WorkflowStepMock } from '@/workflow/search-variables/types/WorkflowStepMock';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { Editor } from '@tiptap/react';
|
||||
import { useState } from 'react';
|
||||
import { IconVariable } from 'twenty-ui';
|
||||
|
||||
const StyledDropdownVariableButtonContainer = styled(
|
||||
StyledDropdownButtonContainer,
|
||||
)`
|
||||
background-color: ${({ theme }) => theme.background.transparent.lighter};
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
padding: ${({ theme }) => theme.spacing(0)};
|
||||
margin: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const SearchVariablesDropdown = ({
|
||||
inputId,
|
||||
editor,
|
||||
}: {
|
||||
inputId: string;
|
||||
editor: Editor;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const dropdownId = `${SEARCH_VARIABLES_DROPDOWN_ID}-${inputId}`;
|
||||
const { isDropdownOpen } = useDropdown(dropdownId);
|
||||
const [selectedStep, setSelectedStep] = useState<
|
||||
WorkflowStepMock | undefined
|
||||
>(undefined);
|
||||
|
||||
const insertVariableTag = (variable: string) => {
|
||||
editor.commands.insertVariableTag(variable);
|
||||
};
|
||||
|
||||
const handleStepSelect = (stepId: string) => {
|
||||
setSelectedStep(
|
||||
AVAILABLE_VARIABLES_MOCK.find((step) => step.id === stepId),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubItemSelect = (subItem: string) => {
|
||||
insertVariableTag(subItem);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setSelectedStep(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
dropdownId={dropdownId}
|
||||
dropdownHotkeyScope={{
|
||||
scope: dropdownId,
|
||||
}}
|
||||
clickableComponent={
|
||||
<StyledDropdownVariableButtonContainer isUnfolded={isDropdownOpen}>
|
||||
<IconVariable size={theme.icon.size.sm} />
|
||||
</StyledDropdownVariableButtonContainer>
|
||||
}
|
||||
dropdownComponents={
|
||||
<DropdownMenu>
|
||||
<DropdownMenuItemsContainer>
|
||||
{selectedStep ? (
|
||||
<SearchVariablesDropdownStepSubItem
|
||||
step={selectedStep}
|
||||
onSelect={handleSubItemSelect}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
) : (
|
||||
<SearchVariablesDropdownStepItem
|
||||
steps={AVAILABLE_VARIABLES_MOCK}
|
||||
onSelect={handleStepSelect}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownMenu>
|
||||
}
|
||||
dropdownPlacement="bottom-end"
|
||||
dropdownOffset={{ x: 0, y: 4 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchVariablesDropdown;
|
@ -0,0 +1,28 @@
|
||||
import { MenuItemSelect } from '@/ui/navigation/menu-item/components/MenuItemSelect';
|
||||
import { WorkflowStepMock } from '@/workflow/search-variables/types/WorkflowStepMock';
|
||||
|
||||
type SearchVariablesDropdownStepItemProps = {
|
||||
steps: WorkflowStepMock[];
|
||||
onSelect: (value: string) => void;
|
||||
};
|
||||
|
||||
export const SearchVariablesDropdownStepItem = ({
|
||||
steps,
|
||||
onSelect,
|
||||
}: SearchVariablesDropdownStepItemProps) => {
|
||||
return (
|
||||
<>
|
||||
{steps.map((item, _index) => (
|
||||
<MenuItemSelect
|
||||
key={`step-${item.id}`}
|
||||
selected={false}
|
||||
hovered={false}
|
||||
onClick={() => onSelect(item.id)}
|
||||
text={item.name}
|
||||
LeftIcon={undefined}
|
||||
hasSubMenu
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,68 @@
|
||||
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
|
||||
import { MenuItemSelect } from '@/ui/navigation/menu-item/components/MenuItemSelect';
|
||||
import { WorkflowStepMock } from '@/workflow/search-variables/types/WorkflowStepMock';
|
||||
import { isObject } from '@sniptt/guards';
|
||||
import { useState } from 'react';
|
||||
import { IconChevronLeft } from 'twenty-ui';
|
||||
|
||||
type SearchVariablesDropdownStepSubItemProps = {
|
||||
step: WorkflowStepMock;
|
||||
onSelect: (value: string) => void;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
const SearchVariablesDropdownStepSubItem = ({
|
||||
step,
|
||||
onSelect,
|
||||
onBack,
|
||||
}: SearchVariablesDropdownStepSubItemProps) => {
|
||||
const [currentPath, setCurrentPath] = useState<string[]>([]);
|
||||
|
||||
const getSelectedObject = () => {
|
||||
let selected = step.output;
|
||||
for (const key of currentPath) {
|
||||
selected = selected[key];
|
||||
}
|
||||
return selected;
|
||||
};
|
||||
|
||||
const handleSelect = (key: string) => {
|
||||
const selectedObject = getSelectedObject();
|
||||
if (isObject(selectedObject[key])) {
|
||||
setCurrentPath([...currentPath, key]);
|
||||
} else {
|
||||
onSelect(`{{${step.id}.${[...currentPath, key].join('.')}}}`);
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (currentPath.length === 0) {
|
||||
onBack();
|
||||
} else {
|
||||
setCurrentPath(currentPath.slice(0, -1));
|
||||
}
|
||||
};
|
||||
|
||||
const headerLabel = currentPath.length === 0 ? step.name : currentPath.at(-1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={goBack}>
|
||||
{headerLabel}
|
||||
</DropdownMenuHeader>
|
||||
{Object.entries(getSelectedObject()).map(([key, value]) => (
|
||||
<MenuItemSelect
|
||||
key={key}
|
||||
selected={false}
|
||||
hovered={false}
|
||||
onClick={() => handleSelect(key)}
|
||||
text={key}
|
||||
hasSubMenu={isObject(value)}
|
||||
LeftIcon={undefined}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchVariablesDropdownStepSubItem;
|
@ -0,0 +1,154 @@
|
||||
import SearchVariablesDropdown from '@/workflow/search-variables/components/SearchVariablesDropdown';
|
||||
import { initializeEditorContent } from '@/workflow/search-variables/utils/initializeEditorContent';
|
||||
import { parseEditorContent } from '@/workflow/search-variables/utils/parseEditorContent';
|
||||
import { VariableTag } from '@/workflow/search-variables/utils/variableTag';
|
||||
import styled from '@emotion/styled';
|
||||
import Document from '@tiptap/extension-document';
|
||||
import Paragraph from '@tiptap/extension-paragraph';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import Text from '@tiptap/extension-text';
|
||||
import { EditorContent, useEditor } from '@tiptap/react';
|
||||
import { isDefined } from 'twenty-ui';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const StyledLabel = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
font-size: ${({ theme }) => theme.font.size.xs};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
margin-bottom: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledInputContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
const StyledSearchVariablesDropdownContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: ${({ theme }) => theme.background.transparent.lighter};
|
||||
border-top-right-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
border-bottom-right-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
`;
|
||||
|
||||
const StyledEditor = styled.div`
|
||||
display: flex;
|
||||
height: 32px;
|
||||
width: 100%;
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-bottom-left-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
border-top-left-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
border-right: none;
|
||||
box-sizing: border-box;
|
||||
background-color: ${({ theme }) => theme.background.transparent.lighter};
|
||||
overflow: hidden;
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
|
||||
.editor-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tiptap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-family: ${({ theme }) => theme.font.family};
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
border: none !important;
|
||||
white-space: nowrap;
|
||||
|
||||
p.is-editor-empty:first-of-type::before {
|
||||
content: attr(data-placeholder);
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.variable-tag {
|
||||
color: ${({ theme }) => theme.color.blue};
|
||||
background-color: ${({ theme }) => theme.color.blue10};
|
||||
padding: ${({ theme }) => theme.spacing(1)};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-focused {
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
interface VariableTagInputProps {
|
||||
inputId: string;
|
||||
label?: string;
|
||||
value?: string;
|
||||
onChange?: (content: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const VariableTagInput = ({
|
||||
inputId,
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: VariableTagInputProps) => {
|
||||
const deboucedOnUpdate = useDebouncedCallback((editor) => {
|
||||
const jsonContent = editor.getJSON();
|
||||
const parsedContent = parseEditorContent(jsonContent);
|
||||
onChange?.(parsedContent);
|
||||
}, 500);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
VariableTag,
|
||||
],
|
||||
editable: true,
|
||||
onCreate: ({ editor }) => {
|
||||
if (isDefined(value)) {
|
||||
initializeEditorContent(editor, value);
|
||||
}
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
deboucedOnUpdate(editor);
|
||||
},
|
||||
});
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
{label && <StyledLabel>{label}</StyledLabel>}
|
||||
<StyledInputContainer>
|
||||
<StyledEditor>
|
||||
<EditorContent className="editor-content" editor={editor} />
|
||||
</StyledEditor>
|
||||
<StyledSearchVariablesDropdownContainer>
|
||||
<SearchVariablesDropdown inputId={inputId} editor={editor} />
|
||||
</StyledSearchVariablesDropdownContainer>
|
||||
</StyledInputContainer>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default VariableTagInput;
|
@ -0,0 +1,30 @@
|
||||
import { WorkflowStepMock } from '@/workflow/search-variables/types/WorkflowStepMock';
|
||||
|
||||
export const AVAILABLE_VARIABLES_MOCK: WorkflowStepMock[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Person is Created',
|
||||
output: {
|
||||
userId: '1',
|
||||
recordId: '123',
|
||||
objectMetadataItem: {
|
||||
id: '1234',
|
||||
nameSingular: 'person',
|
||||
namePlural: 'people',
|
||||
},
|
||||
properties: {
|
||||
after: {
|
||||
name: 'John Doe',
|
||||
email: 'john.doe@email.com',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Send Email',
|
||||
output: {
|
||||
success: true,
|
||||
},
|
||||
},
|
||||
];
|
@ -0,0 +1 @@
|
||||
export const SEARCH_VARIABLES_DROPDOWN_ID = 'search-variables';
|
@ -0,0 +1,5 @@
|
||||
export type WorkflowStepMock = {
|
||||
id: string;
|
||||
name: string;
|
||||
output: Record<string, any>;
|
||||
};
|
@ -0,0 +1,153 @@
|
||||
import { Editor } from '@tiptap/react';
|
||||
import { initializeEditorContent } from '../initializeEditorContent';
|
||||
|
||||
describe('initializeEditorContent', () => {
|
||||
const mockEditor = {
|
||||
commands: {
|
||||
insertContent: jest.fn(),
|
||||
},
|
||||
} as unknown as Editor;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
initializeEditorContent(mockEditor, '');
|
||||
expect(mockEditor.commands.insertContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should insert plain text correctly', () => {
|
||||
initializeEditorContent(mockEditor, 'Hello world');
|
||||
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(1);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenCalledWith(
|
||||
'Hello world',
|
||||
);
|
||||
});
|
||||
|
||||
it('should insert single variable correctly', () => {
|
||||
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 variable in the middle', () => {
|
||||
initializeEditorContent(mockEditor, 'Hello {{user.name}} world');
|
||||
|
||||
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,
|
||||
' world',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple variables', () => {
|
||||
initializeEditorContent(
|
||||
mockEditor,
|
||||
'Hello {{user.firstName}} {{user.lastName}}, welcome to {{app.name}}',
|
||||
);
|
||||
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(6);
|
||||
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}}' },
|
||||
});
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
', welcome to ',
|
||||
);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(6, {
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{app.name}}' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle variables at the start and end', () => {
|
||||
initializeEditorContent(mockEditor, '{{start.var}} middle {{end.var}}');
|
||||
|
||||
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', () => {
|
||||
initializeEditorContent(mockEditor, '{{var1}}{{var2}}{{var3}}');
|
||||
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(3);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(1, {
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{var1}}' },
|
||||
});
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{var2}}' },
|
||||
});
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(3, {
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{var3}}' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle whitespace between variables', () => {
|
||||
initializeEditorContent(mockEditor, '{{var1}} {{var2}} ');
|
||||
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(4);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(1, {
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{var1}}' },
|
||||
});
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, ' ');
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(3, {
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{var2}}' },
|
||||
});
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(4, ' ');
|
||||
});
|
||||
|
||||
it('should handle nested variable syntax', () => {
|
||||
initializeEditorContent(mockEditor, 'Hello {{user.address.city}}!');
|
||||
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(3);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Hello ',
|
||||
);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{user.address.city}}' },
|
||||
});
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(3, '!');
|
||||
});
|
||||
});
|
@ -0,0 +1,239 @@
|
||||
import { JSONContent } from '@tiptap/react';
|
||||
import { parseEditorContent } from '../parseEditorContent';
|
||||
|
||||
describe('parseEditorContent', () => {
|
||||
it('should parse empty doc', () => {
|
||||
const input: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [],
|
||||
};
|
||||
|
||||
expect(parseEditorContent(input)).toBe('');
|
||||
});
|
||||
|
||||
it('should parse simple text node', () => {
|
||||
const input: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Hello world',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(parseEditorContent(input)).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('should parse variable tag node', () => {
|
||||
const input: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'variableTag',
|
||||
attrs: {
|
||||
variable: '{{user.name}}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(parseEditorContent(input)).toBe('{{user.name}}');
|
||||
});
|
||||
|
||||
it('should parse mixed content with text and variables', () => {
|
||||
const input: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Hello ',
|
||||
},
|
||||
{
|
||||
type: 'variableTag',
|
||||
attrs: {
|
||||
variable: '{{user.name}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: ', welcome to ',
|
||||
},
|
||||
{
|
||||
type: 'variableTag',
|
||||
attrs: {
|
||||
variable: '{{app.name}}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(parseEditorContent(input)).toBe(
|
||||
'Hello {{user.name}}, welcome to {{app.name}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse multiple paragraphs', () => {
|
||||
const input: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'First line',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Second line',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(parseEditorContent(input)).toBe('First lineSecond line');
|
||||
});
|
||||
|
||||
it('should handle missing content array', () => {
|
||||
const input: JSONContent = {
|
||||
type: 'doc',
|
||||
};
|
||||
|
||||
expect(parseEditorContent(input)).toBe('');
|
||||
});
|
||||
|
||||
it('should handle missing text in text node', () => {
|
||||
const input: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(parseEditorContent(input)).toBe('');
|
||||
});
|
||||
|
||||
it('should handle missing variable in variableTag node', () => {
|
||||
const input: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'variableTag',
|
||||
attrs: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(parseEditorContent(input)).toBe('');
|
||||
});
|
||||
|
||||
it('should handle unknown node types', () => {
|
||||
const input: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'unknownType',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'This should be ignored',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(parseEditorContent(input)).toBe('');
|
||||
});
|
||||
|
||||
it('should parse complex nested structure', () => {
|
||||
const input: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Hello ',
|
||||
},
|
||||
{
|
||||
type: 'variableTag',
|
||||
attrs: {
|
||||
variable: '{{user.firstName}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: ' ',
|
||||
},
|
||||
{
|
||||
type: 'variableTag',
|
||||
attrs: {
|
||||
variable: '{{user.lastName}}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Your ID is: ',
|
||||
},
|
||||
{
|
||||
type: 'variableTag',
|
||||
attrs: {
|
||||
variable: '{{user.id}}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(parseEditorContent(input)).toBe(
|
||||
'Hello {{user.firstName}} {{user.lastName}}Your ID is: {{user.id}}',
|
||||
);
|
||||
});
|
||||
});
|
@ -0,0 +1,26 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { Editor } from '@tiptap/react';
|
||||
|
||||
const REGEX_VARIABLE_TAG = /(\{\{[^}]+\}\})/;
|
||||
|
||||
export const initializeEditorContent = (editor: Editor, content: string) => {
|
||||
const parts = content.split(REGEX_VARIABLE_TAG);
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
};
|
@ -0,0 +1,25 @@
|
||||
import { JSONContent } from '@tiptap/react';
|
||||
import { isDefined } from 'twenty-ui';
|
||||
|
||||
export const parseEditorContent = (json: JSONContent): string => {
|
||||
const parseNode = (node: JSONContent): string => {
|
||||
if (
|
||||
(node.type === 'paragraph' || node.type === 'doc') &&
|
||||
isDefined(node.content)
|
||||
) {
|
||||
return node.content.map(parseNode).join('');
|
||||
}
|
||||
|
||||
if (node.type === 'text') {
|
||||
return node.text || '';
|
||||
}
|
||||
|
||||
if (node.type === 'variableTag') {
|
||||
return node.attrs?.variable || '';
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
return parseNode(json);
|
||||
};
|
@ -0,0 +1,64 @@
|
||||
import { Node } from '@tiptap/core';
|
||||
import { mergeAttributes } from '@tiptap/react';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
variableTag: {
|
||||
insertVariableTag: (variable: string) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const VariableTag = Node.create({
|
||||
name: 'variableTag',
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
atom: true,
|
||||
|
||||
addAttributes: () => ({
|
||||
variable: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-variable'),
|
||||
renderHTML: (attributes) => {
|
||||
return {
|
||||
'data-variable': attributes.variable,
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
renderHTML: ({ node, HTMLAttributes }) => {
|
||||
const variable = node.attrs.variable as string;
|
||||
const variableWithoutBrackets = variable.replace(
|
||||
/\{\{([^}]+)\}\}/g,
|
||||
(_, variable) => {
|
||||
return variable;
|
||||
},
|
||||
);
|
||||
|
||||
const parts = variableWithoutBrackets.split('.');
|
||||
const displayText = parts[parts.length - 1];
|
||||
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-type': 'variableTag',
|
||||
class: 'variable-tag',
|
||||
}),
|
||||
displayText,
|
||||
];
|
||||
},
|
||||
|
||||
addCommands: () => ({
|
||||
insertVariableTag:
|
||||
(variable: string) =>
|
||||
({ commands }) => {
|
||||
commands.insertContent?.({
|
||||
type: 'variableTag',
|
||||
attrs: { variable },
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
});
|
@ -223,6 +223,7 @@ export {
|
||||
IconUser,
|
||||
IconUserCircle,
|
||||
IconUsers,
|
||||
IconVariable,
|
||||
IconVideo,
|
||||
IconWand,
|
||||
IconWorld,
|
||||
|
96
yarn.lock
96
yarn.lock
@ -14861,6 +14861,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-bubble-menu@npm:^2.8.0":
|
||||
version: 2.8.0
|
||||
resolution: "@tiptap/extension-bubble-menu@npm:2.8.0"
|
||||
dependencies:
|
||||
tippy.js: "npm:^6.3.7"
|
||||
peerDependencies:
|
||||
"@tiptap/core": ^2.7.0
|
||||
"@tiptap/pm": ^2.7.0
|
||||
checksum: 10c0/8c05bf1a1ea3a72c290e69f64b5e165e1af740a5b1434d8da2ab457def27793ece75680f5ab7c6c5f264d69be75a2f42c104acb07f4338fd55a70028cd8a4ad1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-code@npm:^2.5.0":
|
||||
version: 2.5.9
|
||||
resolution: "@tiptap/extension-code@npm:2.5.9"
|
||||
@ -14891,6 +14903,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-document@npm:^2.9.0":
|
||||
version: 2.9.0
|
||||
resolution: "@tiptap/extension-document@npm:2.9.0"
|
||||
peerDependencies:
|
||||
"@tiptap/core": ^2.7.0
|
||||
checksum: 10c0/2cc551050f0d4507b0c8be93c2d17a11cb9649d9b667e9d0923d197ed686e16b7dedd9582538dd7e4d04c33a3ba91145809623fcda63cfdbc3ddf7f5066dca6e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-dropcursor@npm:^2.5.0":
|
||||
version: 2.5.9
|
||||
resolution: "@tiptap/extension-dropcursor@npm:2.5.9"
|
||||
@ -14913,6 +14934,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-floating-menu@npm:^2.8.0":
|
||||
version: 2.8.0
|
||||
resolution: "@tiptap/extension-floating-menu@npm:2.8.0"
|
||||
dependencies:
|
||||
tippy.js: "npm:^6.3.7"
|
||||
peerDependencies:
|
||||
"@tiptap/core": ^2.7.0
|
||||
"@tiptap/pm": ^2.7.0
|
||||
checksum: 10c0/d9895b0c78d40dca295fe17bf2d3c1a181a2aeb1e9fec958ef7df8bac1fe59345f4f22a1bc3a5f7cfe54ff472c6ebea725c71b8db8f5082ec3e350e5da7f4a7d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-gapcursor@npm:^2.5.0":
|
||||
version: 2.5.9
|
||||
resolution: "@tiptap/extension-gapcursor@npm:2.5.9"
|
||||
@ -14982,6 +15015,25 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-paragraph@npm:^2.9.0":
|
||||
version: 2.9.0
|
||||
resolution: "@tiptap/extension-paragraph@npm:2.9.0"
|
||||
peerDependencies:
|
||||
"@tiptap/core": ^2.7.0
|
||||
checksum: 10c0/23c36c28d76356a139fd113119d17df11dacda03e9f5b926d623bb2c0267e14505a4ba9eaa674094d38a766535abefa14cd2542797ad44f313a53587bd8893e6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-placeholder@npm:^2.9.0":
|
||||
version: 2.9.0
|
||||
resolution: "@tiptap/extension-placeholder@npm:2.9.0"
|
||||
peerDependencies:
|
||||
"@tiptap/core": ^2.7.0
|
||||
"@tiptap/pm": ^2.7.0
|
||||
checksum: 10c0/e8e978a50af1d89e302e3086990f48a1d2fd8754a178faa42444788a4208d72e6f09ccd529eaa37705c1e3dfd15ffd54d063f5cc023a3533dadb34e9babf1cec
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-strike@npm:^2.5.0":
|
||||
version: 2.5.9
|
||||
resolution: "@tiptap/extension-strike@npm:2.5.9"
|
||||
@ -15018,6 +15070,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-text-style@npm:^2.8.0":
|
||||
version: 2.8.0
|
||||
resolution: "@tiptap/extension-text-style@npm:2.8.0"
|
||||
peerDependencies:
|
||||
"@tiptap/core": ^2.7.0
|
||||
checksum: 10c0/92abcb01139331aee8ed41170450ae6327017fe654b7e057394bbac2624a38351114de811f996b65a362fca6835015b160a32ea2a80efd175384b76f951ac181
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-text@npm:^2.5.0":
|
||||
version: 2.5.9
|
||||
resolution: "@tiptap/extension-text@npm:2.5.9"
|
||||
@ -15027,6 +15088,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-text@npm:^2.9.0":
|
||||
version: 2.9.0
|
||||
resolution: "@tiptap/extension-text@npm:2.9.0"
|
||||
peerDependencies:
|
||||
"@tiptap/core": ^2.7.0
|
||||
checksum: 10c0/049a1ce42df566de647632461344414c59a52930cf6a530b987f51857df4373d41f83d8feea304f95a077617fd605b62503adc4cbcd28e688c564e24d4139391
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-underline@npm:^2.5.0":
|
||||
version: 2.5.9
|
||||
resolution: "@tiptap/extension-underline@npm:2.5.9"
|
||||
@ -15079,6 +15149,24 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/react@npm:^2.8.0":
|
||||
version: 2.8.0
|
||||
resolution: "@tiptap/react@npm:2.8.0"
|
||||
dependencies:
|
||||
"@tiptap/extension-bubble-menu": "npm:^2.8.0"
|
||||
"@tiptap/extension-floating-menu": "npm:^2.8.0"
|
||||
"@types/use-sync-external-store": "npm:^0.0.6"
|
||||
fast-deep-equal: "npm:^3"
|
||||
use-sync-external-store: "npm:^1.2.2"
|
||||
peerDependencies:
|
||||
"@tiptap/core": ^2.7.0
|
||||
"@tiptap/pm": ^2.7.0
|
||||
react: ^17.0.0 || ^18.0.0
|
||||
react-dom: ^17.0.0 || ^18.0.0
|
||||
checksum: 10c0/a925761dd9fa778fc7a3f32a502ee9874fa785c167ad6d37e2744d0c5b7d1e72bc0c7fafbf1c7f50f04a65d01d00435361a9aa2a44110d67836fbc43e8cd0f9e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tokenizer/token@npm:^0.3.0":
|
||||
version: 0.3.0
|
||||
resolution: "@tokenizer/token@npm:0.3.0"
|
||||
@ -26521,7 +26609,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3":
|
||||
"fast-deep-equal@npm:^3, fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3":
|
||||
version: 3.1.3
|
||||
resolution: "fast-deep-equal@npm:3.1.3"
|
||||
checksum: 10c0/40dedc862eb8992c54579c66d914635afbec43350afbbe991235fdcb4e3a8d5af1b23ae7e79bef7d4882d0ecee06c3197488026998fb19f72dc95acff1d1b1d0
|
||||
@ -44086,6 +44174,12 @@ __metadata:
|
||||
"@nivo/calendar": "npm:^0.87.0"
|
||||
"@nivo/core": "npm:^0.87.0"
|
||||
"@nivo/line": "npm:^0.87.0"
|
||||
"@tiptap/extension-document": "npm:^2.9.0"
|
||||
"@tiptap/extension-paragraph": "npm:^2.9.0"
|
||||
"@tiptap/extension-placeholder": "npm:^2.9.0"
|
||||
"@tiptap/extension-text": "npm:^2.9.0"
|
||||
"@tiptap/extension-text-style": "npm:^2.8.0"
|
||||
"@tiptap/react": "npm:^2.8.0"
|
||||
"@xyflow/react": "npm:^12.0.4"
|
||||
transliteration: "npm:^2.3.5"
|
||||
languageName: unknown
|
||||
|
Loading…
Reference in New Issue
Block a user