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:
@ -33,6 +33,12 @@
|
|||||||
"@nivo/calendar": "^0.87.0",
|
"@nivo/calendar": "^0.87.0",
|
||||||
"@nivo/core": "^0.87.0",
|
"@nivo/core": "^0.87.0",
|
||||||
"@nivo/line": "^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",
|
"@xyflow/react": "^12.0.4",
|
||||||
"transliteration": "^2.3.5"
|
"transliteration": "^2.3.5"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,7 +69,9 @@ export const PhoneCountryPickerDropdownButton = ({
|
|||||||
|
|
||||||
const [selectedCountry, setSelectedCountry] = useState<Country>();
|
const [selectedCountry, setSelectedCountry] = useState<Country>();
|
||||||
|
|
||||||
const { isDropdownOpen, closeDropdown } = useDropdown('country-picker');
|
const { isDropdownOpen, closeDropdown } = useDropdown(
|
||||||
|
CountryPickerHotkeyScope.CountryPicker,
|
||||||
|
);
|
||||||
|
|
||||||
const handleChange = (countryCode: string) => {
|
const handleChange = (countryCode: string) => {
|
||||||
onChange(countryCode);
|
onChange(countryCode);
|
||||||
|
|||||||
@ -5,8 +5,8 @@ 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 { TextArea } from '@/ui/input/components/TextArea';
|
||||||
import { TextInput } from '@/ui/input/components/TextInput';
|
|
||||||
import { WorkflowEditActionFormBase } from '@/workflow/components/WorkflowEditActionFormBase';
|
import { WorkflowEditActionFormBase } from '@/workflow/components/WorkflowEditActionFormBase';
|
||||||
|
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';
|
||||||
@ -208,7 +208,8 @@ export const WorkflowEditActionFormSendEmail = (
|
|||||||
name="email"
|
name="email"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<TextInput
|
<VariableTagInput
|
||||||
|
inputId="email-input"
|
||||||
label="Email"
|
label="Email"
|
||||||
placeholder="Enter receiver email (use {{variable}} for dynamic content)"
|
placeholder="Enter receiver email (use {{variable}} for dynamic content)"
|
||||||
value={field.value}
|
value={field.value}
|
||||||
@ -223,7 +224,8 @@ export const WorkflowEditActionFormSendEmail = (
|
|||||||
name="subject"
|
name="subject"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<TextInput
|
<VariableTagInput
|
||||||
|
inputId="email-subject-input"
|
||||||
label="Subject"
|
label="Subject"
|
||||||
placeholder="Enter email subject (use {{variable}} for dynamic content)"
|
placeholder="Enter email subject (use {{variable}} for dynamic content)"
|
||||||
value={field.value}
|
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,
|
IconUser,
|
||||||
IconUserCircle,
|
IconUserCircle,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
|
IconVariable,
|
||||||
IconVideo,
|
IconVideo,
|
||||||
IconWand,
|
IconWand,
|
||||||
IconWorld,
|
IconWorld,
|
||||||
|
|||||||
96
yarn.lock
96
yarn.lock
@ -14861,6 +14861,18 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@tiptap/extension-code@npm:^2.5.0":
|
||||||
version: 2.5.9
|
version: 2.5.9
|
||||||
resolution: "@tiptap/extension-code@npm:2.5.9"
|
resolution: "@tiptap/extension-code@npm:2.5.9"
|
||||||
@ -14891,6 +14903,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@tiptap/extension-dropcursor@npm:^2.5.0":
|
||||||
version: 2.5.9
|
version: 2.5.9
|
||||||
resolution: "@tiptap/extension-dropcursor@npm:2.5.9"
|
resolution: "@tiptap/extension-dropcursor@npm:2.5.9"
|
||||||
@ -14913,6 +14934,18 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@tiptap/extension-gapcursor@npm:^2.5.0":
|
||||||
version: 2.5.9
|
version: 2.5.9
|
||||||
resolution: "@tiptap/extension-gapcursor@npm:2.5.9"
|
resolution: "@tiptap/extension-gapcursor@npm:2.5.9"
|
||||||
@ -14982,6 +15015,25 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@tiptap/extension-strike@npm:^2.5.0":
|
||||||
version: 2.5.9
|
version: 2.5.9
|
||||||
resolution: "@tiptap/extension-strike@npm:2.5.9"
|
resolution: "@tiptap/extension-strike@npm:2.5.9"
|
||||||
@ -15018,6 +15070,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@tiptap/extension-text@npm:^2.5.0":
|
||||||
version: 2.5.9
|
version: 2.5.9
|
||||||
resolution: "@tiptap/extension-text@npm:2.5.9"
|
resolution: "@tiptap/extension-text@npm:2.5.9"
|
||||||
@ -15027,6 +15088,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@tiptap/extension-underline@npm:^2.5.0":
|
||||||
version: 2.5.9
|
version: 2.5.9
|
||||||
resolution: "@tiptap/extension-underline@npm:2.5.9"
|
resolution: "@tiptap/extension-underline@npm:2.5.9"
|
||||||
@ -15079,6 +15149,24 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@tokenizer/token@npm:^0.3.0":
|
||||||
version: 0.3.0
|
version: 0.3.0
|
||||||
resolution: "@tokenizer/token@npm:0.3.0"
|
resolution: "@tokenizer/token@npm:0.3.0"
|
||||||
@ -26521,7 +26609,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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
|
version: 3.1.3
|
||||||
resolution: "fast-deep-equal@npm:3.1.3"
|
resolution: "fast-deep-equal@npm:3.1.3"
|
||||||
checksum: 10c0/40dedc862eb8992c54579c66d914635afbec43350afbbe991235fdcb4e3a8d5af1b23ae7e79bef7d4882d0ecee06c3197488026998fb19f72dc95acff1d1b1d0
|
checksum: 10c0/40dedc862eb8992c54579c66d914635afbec43350afbbe991235fdcb4e3a8d5af1b23ae7e79bef7d4882d0ecee06c3197488026998fb19f72dc95acff1d1b1d0
|
||||||
@ -44086,6 +44174,12 @@ __metadata:
|
|||||||
"@nivo/calendar": "npm:^0.87.0"
|
"@nivo/calendar": "npm:^0.87.0"
|
||||||
"@nivo/core": "npm:^0.87.0"
|
"@nivo/core": "npm:^0.87.0"
|
||||||
"@nivo/line": "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"
|
"@xyflow/react": "npm:^12.0.4"
|
||||||
transliteration: "npm:^2.3.5"
|
transliteration: "npm:^2.3.5"
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
|
|||||||
Reference in New Issue
Block a user