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:
Thomas Trompette 2024-10-23 18:32:10 +02:00 committed by GitHub
parent 18778c55ac
commit 2e8b8452c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 997 additions and 5 deletions

View File

@ -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"
}

View File

@ -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);

View File

@ -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}

View File

@ -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;

View File

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

View File

@ -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;

View File

@ -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;

View File

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

View File

@ -0,0 +1 @@
export const SEARCH_VARIABLES_DROPDOWN_ID = 'search-variables';

View File

@ -0,0 +1,5 @@
export type WorkflowStepMock = {
id: string;
name: string;
output: Record<string, any>;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -223,6 +223,7 @@ export {
IconUser,
IconUserCircle,
IconUsers,
IconVariable,
IconVideo,
IconWand,
IconWorld,

View File

@ -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