Refacto workflow folders (#9302)
- Create separated folders for sections - Add components - Add utils and clean old ones - Add constants - Rename search variables folder and components Next steps: - clean hooks - clean states
This commit is contained in:
@ -0,0 +1,57 @@
|
||||
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
|
||||
import { WorkflowVariablesDropdown } from '@/workflow/workflow-variables/components/WorkflowVariablesDropdown';
|
||||
import { css } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
export const StyledSearchVariablesDropdownContainer = styled.div<{
|
||||
multiline?: boolean;
|
||||
readonly?: boolean;
|
||||
}>`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
${({ theme, readonly }) =>
|
||||
!readonly &&
|
||||
css`
|
||||
:hover {
|
||||
background-color: ${theme.background.transparent.light};
|
||||
}
|
||||
`}
|
||||
|
||||
${({ theme, multiline }) =>
|
||||
multiline
|
||||
? css`
|
||||
border-radius: ${theme.border.radius.sm};
|
||||
padding: ${theme.spacing(0.5)} ${theme.spacing(0)};
|
||||
position: absolute;
|
||||
right: ${theme.spacing(0)};
|
||||
top: ${theme.spacing(0)};
|
||||
`
|
||||
: css`
|
||||
background-color: ${theme.background.transparent.lighter};
|
||||
border-top-right-radius: ${theme.border.radius.sm};
|
||||
border-bottom-right-radius: ${theme.border.radius.sm};
|
||||
border: 1px solid ${theme.border.color.medium};
|
||||
`}
|
||||
`;
|
||||
|
||||
export const WorkflowVariablePicker: VariablePickerComponent = ({
|
||||
inputId,
|
||||
disabled,
|
||||
multiline,
|
||||
onVariableSelect,
|
||||
}) => {
|
||||
return (
|
||||
<StyledSearchVariablesDropdownContainer
|
||||
multiline={multiline}
|
||||
readonly={disabled}
|
||||
>
|
||||
<WorkflowVariablesDropdown
|
||||
inputId={inputId}
|
||||
onVariableSelect={onVariableSelect}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</StyledSearchVariablesDropdownContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,131 @@
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { StyledDropdownButtonContainer } from '@/ui/layout/dropdown/components/StyledDropdownButtonContainer';
|
||||
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
|
||||
import { WorkflowVariablesDropdownFieldItems } from '@/workflow/workflow-variables/components/WorkflowVariablesDropdownFieldItems';
|
||||
import { WorkflowVariablesDropdownObjectItems } from '@/workflow/workflow-variables/components/WorkflowVariablesDropdownObjectItems';
|
||||
import { WorkflowVariablesDropdownWorkflowStepItems } from '@/workflow/workflow-variables/components/WorkflowVariablesDropdownWorkflowStepItems';
|
||||
import { SEARCH_VARIABLES_DROPDOWN_ID } from '@/workflow/workflow-variables/constants/SearchVariablesDropdownId';
|
||||
|
||||
import { useAvailableVariablesInWorkflowStep } from '@/workflow/workflow-variables/hooks/useAvailableVariablesInWorkflowStep';
|
||||
import { StepOutputSchema } from '@/workflow/workflow-variables/types/StepOutputSchema';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useState } from 'react';
|
||||
import { IconVariablePlus, isDefined } from 'twenty-ui';
|
||||
|
||||
const StyledDropdownVariableButtonContainer = styled(
|
||||
StyledDropdownButtonContainer,
|
||||
)<{ transparentBackground?: boolean; disabled?: boolean }>`
|
||||
background-color: ${({ theme, transparentBackground }) =>
|
||||
transparentBackground
|
||||
? 'transparent'
|
||||
: theme.background.transparent.lighter};
|
||||
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
:hover {
|
||||
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
|
||||
}
|
||||
`;
|
||||
|
||||
export const WorkflowVariablesDropdown = ({
|
||||
inputId,
|
||||
onVariableSelect,
|
||||
disabled,
|
||||
objectNameSingularToSelect,
|
||||
}: {
|
||||
inputId: string;
|
||||
onVariableSelect: (variableName: string) => void;
|
||||
disabled?: boolean;
|
||||
objectNameSingularToSelect?: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const dropdownId = `${SEARCH_VARIABLES_DROPDOWN_ID}-${inputId}`;
|
||||
const { isDropdownOpen, closeDropdown } = useDropdown(dropdownId);
|
||||
const availableVariablesInWorkflowStep = useAvailableVariablesInWorkflowStep({
|
||||
objectNameSingularToSelect,
|
||||
});
|
||||
|
||||
const initialStep =
|
||||
availableVariablesInWorkflowStep.length === 1
|
||||
? availableVariablesInWorkflowStep[0]
|
||||
: undefined;
|
||||
|
||||
const [selectedStep, setSelectedStep] = useState<
|
||||
StepOutputSchema | undefined
|
||||
>(initialStep);
|
||||
|
||||
const handleStepSelect = (stepId: string) => {
|
||||
setSelectedStep(
|
||||
availableVariablesInWorkflowStep.find((step) => step.id === stepId),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubItemSelect = (subItem: string) => {
|
||||
onVariableSelect(subItem);
|
||||
setSelectedStep(undefined);
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setSelectedStep(undefined);
|
||||
};
|
||||
|
||||
if (disabled === true) {
|
||||
return (
|
||||
<StyledDropdownVariableButtonContainer
|
||||
isUnfolded={isDropdownOpen}
|
||||
disabled={disabled}
|
||||
transparentBackground
|
||||
>
|
||||
<IconVariablePlus
|
||||
size={theme.icon.size.sm}
|
||||
color={theme.font.color.light}
|
||||
/>
|
||||
</StyledDropdownVariableButtonContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
dropdownMenuWidth={320}
|
||||
dropdownId={dropdownId}
|
||||
dropdownHotkeyScope={{
|
||||
scope: dropdownId,
|
||||
}}
|
||||
clickableComponent={
|
||||
<StyledDropdownVariableButtonContainer
|
||||
isUnfolded={isDropdownOpen}
|
||||
disabled={disabled}
|
||||
transparentBackground
|
||||
>
|
||||
<IconVariablePlus size={theme.icon.size.sm} />
|
||||
</StyledDropdownVariableButtonContainer>
|
||||
}
|
||||
dropdownComponents={
|
||||
!isDefined(selectedStep) ? (
|
||||
<WorkflowVariablesDropdownWorkflowStepItems
|
||||
dropdownId={dropdownId}
|
||||
steps={availableVariablesInWorkflowStep}
|
||||
onSelect={handleStepSelect}
|
||||
/>
|
||||
) : isDefined(objectNameSingularToSelect) ? (
|
||||
<WorkflowVariablesDropdownObjectItems
|
||||
step={selectedStep}
|
||||
onSelect={handleSubItemSelect}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
) : (
|
||||
<WorkflowVariablesDropdownFieldItems
|
||||
step={selectedStep}
|
||||
onSelect={handleSubItemSelect}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
)
|
||||
}
|
||||
dropdownPlacement="bottom-end"
|
||||
dropdownOffset={{ x: 2, y: 4 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,162 @@
|
||||
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import {
|
||||
BaseOutputSchema,
|
||||
LinkOutputSchema,
|
||||
OutputSchema,
|
||||
StepOutputSchema,
|
||||
} from '@/workflow/workflow-variables/types/StepOutputSchema';
|
||||
import { isBaseOutputSchema } from '@/workflow/workflow-variables/utils/isBaseOutputSchema';
|
||||
import { isRecordOutputSchema } from '@/workflow/workflow-variables/utils/isRecordOutputSchema';
|
||||
|
||||
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
|
||||
import { workflowDiagramTriggerNodeSelectionState } from '@/workflow/states/workflowDiagramTriggerNodeSelectionState';
|
||||
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
|
||||
import { WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-actions/constants/WorkflowServerlessFunctionTabListComponentId';
|
||||
import { isLinkOutputSchema } from '@/workflow/workflow-variables/utils/isLinkOutputSchema';
|
||||
import { useState } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import {
|
||||
IconChevronLeft,
|
||||
isDefined,
|
||||
MenuItemSelect,
|
||||
OverflowingTextWithTooltip,
|
||||
useIcons,
|
||||
} from 'twenty-ui';
|
||||
|
||||
type WorkflowVariablesDropdownFieldItemsProps = {
|
||||
step: StepOutputSchema;
|
||||
onSelect: (value: string) => void;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export const WorkflowVariablesDropdownFieldItems = ({
|
||||
step,
|
||||
onSelect,
|
||||
onBack,
|
||||
}: WorkflowVariablesDropdownFieldItemsProps) => {
|
||||
const [currentPath, setCurrentPath] = useState<string[]>([]);
|
||||
const [searchInputValue, setSearchInputValue] = useState('');
|
||||
const { getIcon } = useIcons();
|
||||
const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState);
|
||||
const { setActiveTabId } = useTabList(
|
||||
WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID,
|
||||
);
|
||||
const setWorkflowDiagramTriggerNodeSelection = useSetRecoilState(
|
||||
workflowDiagramTriggerNodeSelectionState,
|
||||
);
|
||||
|
||||
const getCurrentSubStep = (): OutputSchema => {
|
||||
let currentSubStep = step.outputSchema;
|
||||
|
||||
for (const key of currentPath) {
|
||||
if (isRecordOutputSchema(currentSubStep)) {
|
||||
currentSubStep = currentSubStep.fields[key]?.value;
|
||||
} else if (isBaseOutputSchema(currentSubStep)) {
|
||||
currentSubStep = currentSubStep[key]?.value;
|
||||
}
|
||||
}
|
||||
|
||||
return currentSubStep;
|
||||
};
|
||||
|
||||
const getDisplayedSubStepFields = () => {
|
||||
const currentSubStep = getCurrentSubStep();
|
||||
|
||||
if (isLinkOutputSchema(currentSubStep)) {
|
||||
return { link: currentSubStep.link };
|
||||
} else if (isRecordOutputSchema(currentSubStep)) {
|
||||
return currentSubStep.fields;
|
||||
} else if (isBaseOutputSchema(currentSubStep)) {
|
||||
return currentSubStep;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectField = (key: string) => {
|
||||
const currentSubStep = getCurrentSubStep();
|
||||
|
||||
const handleSelectBaseOutputSchema = (
|
||||
baseOutputSchema: BaseOutputSchema,
|
||||
) => {
|
||||
if (!baseOutputSchema[key]?.isLeaf) {
|
||||
setCurrentPath([...currentPath, key]);
|
||||
setSearchInputValue('');
|
||||
} else {
|
||||
onSelect(`{{${step.id}.${[...currentPath, key].join('.')}}}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectLinkOutputSchema = (
|
||||
linkOutputSchema: LinkOutputSchema,
|
||||
) => {
|
||||
setWorkflowSelectedNode(step.id);
|
||||
setWorkflowDiagramTriggerNodeSelection(step.id);
|
||||
if (isDefined(linkOutputSchema.link.tab)) {
|
||||
setActiveTabId(linkOutputSchema.link.tab);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLinkOutputSchema(currentSubStep)) {
|
||||
handleSelectLinkOutputSchema(currentSubStep);
|
||||
} else if (isRecordOutputSchema(currentSubStep)) {
|
||||
handleSelectBaseOutputSchema(currentSubStep.fields);
|
||||
} else if (isBaseOutputSchema(currentSubStep)) {
|
||||
handleSelectBaseOutputSchema(currentSubStep);
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (currentPath.length === 0) {
|
||||
onBack();
|
||||
} else {
|
||||
setCurrentPath(currentPath.slice(0, -1));
|
||||
}
|
||||
};
|
||||
|
||||
const headerLabel = currentPath.length === 0 ? step.name : currentPath.at(-1);
|
||||
const displayedObject = getDisplayedSubStepFields();
|
||||
const options = displayedObject ? Object.entries(displayedObject) : [];
|
||||
|
||||
const filteredOptions = searchInputValue
|
||||
? options.filter(
|
||||
([_, value]) =>
|
||||
value.label &&
|
||||
value.label.toLowerCase().includes(searchInputValue.toLowerCase()),
|
||||
)
|
||||
: options;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuHeader
|
||||
StartIcon={IconChevronLeft}
|
||||
onClick={goBack}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
}}
|
||||
>
|
||||
<OverflowingTextWithTooltip text={headerLabel} />
|
||||
</DropdownMenuHeader>
|
||||
<DropdownMenuSearchInput
|
||||
autoFocus
|
||||
value={searchInputValue}
|
||||
onChange={(event) => setSearchInputValue(event.target.value)}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemsContainer>
|
||||
{filteredOptions.map(([key, value]) => (
|
||||
<MenuItemSelect
|
||||
key={key}
|
||||
selected={false}
|
||||
hovered={false}
|
||||
onClick={() => handleSelectField(key)}
|
||||
text={value.label || key}
|
||||
hasSubMenu={!value.isLeaf}
|
||||
LeftIcon={value.icon ? getIcon(value.icon) : undefined}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuItemsContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,156 @@
|
||||
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import {
|
||||
OutputSchema,
|
||||
StepOutputSchema,
|
||||
} from '@/workflow/workflow-variables/types/StepOutputSchema';
|
||||
import { isBaseOutputSchema } from '@/workflow/workflow-variables/utils/isBaseOutputSchema';
|
||||
import { isRecordOutputSchema } from '@/workflow/workflow-variables/utils/isRecordOutputSchema';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
IconChevronLeft,
|
||||
MenuItemSelect,
|
||||
OverflowingTextWithTooltip,
|
||||
useIcons,
|
||||
} from 'twenty-ui';
|
||||
|
||||
type WorkflowVariablesDropdownObjectItemsProps = {
|
||||
step: StepOutputSchema;
|
||||
onSelect: (value: string) => void;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export const WorkflowVariablesDropdownObjectItems = ({
|
||||
step,
|
||||
onSelect,
|
||||
onBack,
|
||||
}: WorkflowVariablesDropdownObjectItemsProps) => {
|
||||
const [currentPath, setCurrentPath] = useState<string[]>([]);
|
||||
const [searchInputValue, setSearchInputValue] = useState('');
|
||||
const { getIcon } = useIcons();
|
||||
|
||||
const getCurrentSubStep = (): OutputSchema => {
|
||||
let currentSubStep = step.outputSchema;
|
||||
|
||||
for (const key of currentPath) {
|
||||
if (isRecordOutputSchema(currentSubStep)) {
|
||||
currentSubStep = currentSubStep.fields[key]?.value;
|
||||
} else if (isBaseOutputSchema(currentSubStep)) {
|
||||
currentSubStep = currentSubStep[key]?.value;
|
||||
}
|
||||
}
|
||||
|
||||
return currentSubStep;
|
||||
};
|
||||
|
||||
const getDisplayedSubStepFields = () => {
|
||||
const currentSubStep = getCurrentSubStep();
|
||||
|
||||
if (isRecordOutputSchema(currentSubStep)) {
|
||||
return currentSubStep.fields;
|
||||
} else if (isBaseOutputSchema(currentSubStep)) {
|
||||
return currentSubStep;
|
||||
}
|
||||
};
|
||||
|
||||
const getDisplayedSubStepObject = () => {
|
||||
const currentSubStep = getCurrentSubStep();
|
||||
|
||||
if (!isRecordOutputSchema(currentSubStep)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return currentSubStep.object;
|
||||
};
|
||||
|
||||
const handleSelectObject = () => {
|
||||
const currentSubStep = getCurrentSubStep();
|
||||
|
||||
if (!isRecordOutputSchema(currentSubStep)) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(
|
||||
`{{${step.id}.${[...currentPath, currentSubStep.object.fieldIdName].join('.')}}}`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectField = (key: string) => {
|
||||
setCurrentPath([...currentPath, key]);
|
||||
setSearchInputValue('');
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (currentPath.length === 0) {
|
||||
onBack();
|
||||
} else {
|
||||
setCurrentPath(currentPath.slice(0, -1));
|
||||
}
|
||||
};
|
||||
|
||||
const headerLabel = currentPath.length === 0 ? step.name : currentPath.at(-1);
|
||||
|
||||
const displayedSubStepObject = getDisplayedSubStepObject();
|
||||
|
||||
const shouldDisplaySubStepObject = searchInputValue
|
||||
? displayedSubStepObject?.label &&
|
||||
displayedSubStepObject.label
|
||||
.toLowerCase()
|
||||
.includes(searchInputValue.toLowerCase())
|
||||
: true;
|
||||
|
||||
const displayedFields = getDisplayedSubStepFields();
|
||||
const options = displayedFields ? Object.entries(displayedFields) : [];
|
||||
|
||||
const filteredOptions = searchInputValue
|
||||
? options.filter(
|
||||
([_, value]) =>
|
||||
value.label &&
|
||||
value.label.toLowerCase().includes(searchInputValue.toLowerCase()),
|
||||
)
|
||||
: options;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={goBack}>
|
||||
<OverflowingTextWithTooltip text={headerLabel} />
|
||||
</DropdownMenuHeader>
|
||||
<DropdownMenuSearchInput
|
||||
autoFocus
|
||||
value={searchInputValue}
|
||||
onChange={(event) => setSearchInputValue(event.target.value)}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemsContainer>
|
||||
{shouldDisplaySubStepObject && displayedSubStepObject?.label && (
|
||||
<MenuItemSelect
|
||||
selected={false}
|
||||
hovered={false}
|
||||
onClick={handleSelectObject}
|
||||
text={displayedSubStepObject.label}
|
||||
hasSubMenu={false}
|
||||
LeftIcon={
|
||||
displayedSubStepObject.icon
|
||||
? getIcon(displayedSubStepObject.icon)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{filteredOptions.map(([key, value]) => (
|
||||
<MenuItemSelect
|
||||
key={key}
|
||||
selected={false}
|
||||
hovered={false}
|
||||
onClick={() => handleSelectField(key)}
|
||||
text={value.label || key}
|
||||
hasSubMenu={!value.isLeaf}
|
||||
LeftIcon={value.icon ? getIcon(value.icon) : undefined}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuItemsContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,74 @@
|
||||
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
|
||||
import { StepOutputSchema } from '@/workflow/workflow-variables/types/StepOutputSchema';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
IconX,
|
||||
MenuItem,
|
||||
MenuItemSelect,
|
||||
OverflowingTextWithTooltip,
|
||||
useIcons,
|
||||
} from 'twenty-ui';
|
||||
|
||||
type WorkflowVariablesDropdownWorkflowStepItemsProps = {
|
||||
dropdownId: string;
|
||||
steps: StepOutputSchema[];
|
||||
onSelect: (value: string) => void;
|
||||
};
|
||||
|
||||
export const WorkflowVariablesDropdownWorkflowStepItems = ({
|
||||
dropdownId,
|
||||
steps,
|
||||
onSelect,
|
||||
}: WorkflowVariablesDropdownWorkflowStepItemsProps) => {
|
||||
const { getIcon } = useIcons();
|
||||
const [searchInputValue, setSearchInputValue] = useState('');
|
||||
|
||||
const { closeDropdown } = useDropdown(dropdownId);
|
||||
|
||||
const availableSteps = steps.filter((step) =>
|
||||
searchInputValue
|
||||
? step.name.toLowerCase().includes(searchInputValue)
|
||||
: true,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuHeader StartIcon={IconX} onClick={closeDropdown}>
|
||||
<OverflowingTextWithTooltip text={'Select Step'} />
|
||||
</DropdownMenuHeader>
|
||||
<DropdownMenuSearchInput
|
||||
autoFocus
|
||||
value={searchInputValue}
|
||||
onChange={(event) => setSearchInputValue(event.target.value)}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemsContainer>
|
||||
{availableSteps.length > 0 ? (
|
||||
availableSteps.map((item, _index) => (
|
||||
<MenuItemSelect
|
||||
key={`step-${item.id}`}
|
||||
selected={false}
|
||||
hovered={false}
|
||||
onClick={() => onSelect(item.id)}
|
||||
text={item.name}
|
||||
LeftIcon={item.icon ? getIcon(item.icon) : undefined}
|
||||
hasSubMenu
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<MenuItem
|
||||
key="no-steps"
|
||||
onClick={() => {}}
|
||||
text="No variables available"
|
||||
LeftIcon={undefined}
|
||||
hasSubMenu={false}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuItemsContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export const SEARCH_VARIABLES_DROPDOWN_ID = 'workflow-variables';
|
||||
@ -0,0 +1,91 @@
|
||||
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
|
||||
import { workflowIdState } from '@/workflow/states/workflowIdState';
|
||||
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
|
||||
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
|
||||
import {
|
||||
OutputSchema,
|
||||
StepOutputSchema,
|
||||
} from '@/workflow/workflow-variables/types/StepOutputSchema';
|
||||
import { filterOutputSchema } from '@/workflow/workflow-variables/utils/filterOutputSchema';
|
||||
import { getTriggerStepName } from '@/workflow/workflow-variables/utils/getTriggerStepName';
|
||||
import isEmpty from 'lodash.isempty';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { isDefined } from 'twenty-ui';
|
||||
import { isEmptyObject } from '~/utils/isEmptyObject';
|
||||
|
||||
export const useAvailableVariablesInWorkflowStep = ({
|
||||
objectNameSingularToSelect,
|
||||
}: {
|
||||
objectNameSingularToSelect?: string;
|
||||
}): StepOutputSchema[] => {
|
||||
const workflowId = useRecoilValue(workflowIdState);
|
||||
const workflow = useWorkflowWithCurrentVersion(workflowId);
|
||||
const workflowSelectedNode = useRecoilValue(workflowSelectedNodeState);
|
||||
|
||||
if (!isDefined(workflowSelectedNode) || !isDefined(workflow)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stepDefinition = getStepDefinitionOrThrow({
|
||||
stepId: workflowSelectedNode,
|
||||
workflowVersion: workflow.currentVersion,
|
||||
});
|
||||
|
||||
if (
|
||||
!isDefined(stepDefinition) ||
|
||||
stepDefinition.type === 'trigger' ||
|
||||
!isDefined(workflow.currentVersion.steps)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const previousSteps = [];
|
||||
|
||||
for (const step of workflow.currentVersion.steps) {
|
||||
if (step.id === workflowSelectedNode) {
|
||||
break;
|
||||
}
|
||||
previousSteps.push(step);
|
||||
}
|
||||
|
||||
const result = [];
|
||||
|
||||
const filteredTriggerOutputSchema = filterOutputSchema(
|
||||
workflow.currentVersion.trigger?.settings?.outputSchema as
|
||||
| OutputSchema
|
||||
| undefined,
|
||||
objectNameSingularToSelect,
|
||||
);
|
||||
|
||||
if (
|
||||
isDefined(workflow.currentVersion.trigger) &&
|
||||
isDefined(filteredTriggerOutputSchema) &&
|
||||
!isEmptyObject(filteredTriggerOutputSchema)
|
||||
) {
|
||||
result.push({
|
||||
id: 'trigger',
|
||||
name: isDefined(workflow.currentVersion.trigger.name)
|
||||
? workflow.currentVersion.trigger.name
|
||||
: getTriggerStepName(workflow.currentVersion.trigger),
|
||||
outputSchema: filteredTriggerOutputSchema,
|
||||
});
|
||||
}
|
||||
|
||||
previousSteps.forEach((previousStep) => {
|
||||
const filteredOutputSchema = filterOutputSchema(
|
||||
previousStep.settings.outputSchema as OutputSchema,
|
||||
objectNameSingularToSelect,
|
||||
);
|
||||
|
||||
if (isDefined(filteredOutputSchema) && !isEmpty(filteredOutputSchema)) {
|
||||
result.push({
|
||||
id: previousStep.id,
|
||||
name: previousStep.name,
|
||||
outputSchema: filteredOutputSchema,
|
||||
...(previousStep.type === 'CODE' ? { icon: 'IconCode' } : {}),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
@ -0,0 +1,48 @@
|
||||
import { InputSchemaPropertyType } from '@/workflow/types/InputSchema';
|
||||
|
||||
type Leaf = {
|
||||
isLeaf: true;
|
||||
type?: InputSchemaPropertyType;
|
||||
icon?: string;
|
||||
label?: string;
|
||||
value: any;
|
||||
};
|
||||
|
||||
type Node = {
|
||||
isLeaf: false;
|
||||
icon?: string;
|
||||
label?: string;
|
||||
value: OutputSchema;
|
||||
};
|
||||
|
||||
type Link = {
|
||||
isLeaf: true;
|
||||
tab?: string;
|
||||
icon?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type BaseOutputSchema = Record<string, Leaf | Node>;
|
||||
|
||||
export type RecordOutputSchema = {
|
||||
object: { nameSingular: string; fieldIdName: string } & Leaf;
|
||||
fields: BaseOutputSchema;
|
||||
_outputSchemaType: 'RECORD';
|
||||
};
|
||||
|
||||
export type LinkOutputSchema = {
|
||||
link: Link;
|
||||
_outputSchemaType: 'LINK';
|
||||
};
|
||||
|
||||
export type OutputSchema =
|
||||
| BaseOutputSchema
|
||||
| RecordOutputSchema
|
||||
| LinkOutputSchema;
|
||||
|
||||
export type StepOutputSchema = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
outputSchema: OutputSchema;
|
||||
};
|
||||
@ -0,0 +1,13 @@
|
||||
import { extractVariableLabel } from '../extractVariableLabel';
|
||||
|
||||
it('returns the last part of a properly formatted variable', () => {
|
||||
const rawVariable = '{{a.b.c}}';
|
||||
|
||||
expect(extractVariableLabel(rawVariable)).toBe('c');
|
||||
});
|
||||
|
||||
it('stops on unclosed variables', () => {
|
||||
const rawVariable = '{{ test {{a.b.c}}';
|
||||
|
||||
expect(extractVariableLabel(rawVariable)).toBe('c');
|
||||
});
|
||||
@ -0,0 +1,185 @@
|
||||
import { OutputSchema } from '@/workflow/workflow-variables/types/StepOutputSchema';
|
||||
import { filterOutputSchema } from '../filterOutputSchema';
|
||||
|
||||
describe('filterOutputSchema', () => {
|
||||
describe('edge cases', () => {
|
||||
it('should return the input schema when objectNameSingularToSelect is undefined', () => {
|
||||
const inputSchema: OutputSchema = {
|
||||
_outputSchemaType: 'RECORD',
|
||||
object: {
|
||||
nameSingular: 'person',
|
||||
fieldIdName: 'id',
|
||||
isLeaf: true,
|
||||
value: 'Fake value',
|
||||
},
|
||||
fields: {},
|
||||
};
|
||||
|
||||
expect(filterOutputSchema(inputSchema, undefined)).toBe(inputSchema);
|
||||
});
|
||||
|
||||
it('should return undefined when input schema is undefined', () => {
|
||||
expect(filterOutputSchema(undefined, 'person')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('record output schema', () => {
|
||||
const createRecordSchema = (
|
||||
nameSingular: string,
|
||||
fields = {},
|
||||
): OutputSchema => ({
|
||||
_outputSchemaType: 'RECORD',
|
||||
object: {
|
||||
nameSingular,
|
||||
fieldIdName: 'id',
|
||||
isLeaf: true,
|
||||
value: 'Fake value',
|
||||
},
|
||||
fields,
|
||||
});
|
||||
|
||||
it('should keep a matching record schema', () => {
|
||||
const inputSchema = createRecordSchema('person');
|
||||
|
||||
expect(filterOutputSchema(inputSchema, 'person')).toEqual(inputSchema);
|
||||
});
|
||||
|
||||
it('should filter out a non-matching record schema with no valid fields', () => {
|
||||
const inputSchema = createRecordSchema('company');
|
||||
|
||||
expect(filterOutputSchema(inputSchema, 'person')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should keep valid nested records while filtering out invalid ones', () => {
|
||||
const inputSchema = createRecordSchema('company', {
|
||||
employee: {
|
||||
isLeaf: false,
|
||||
value: createRecordSchema('person', {
|
||||
manager: {
|
||||
isLeaf: false,
|
||||
value: createRecordSchema('person'),
|
||||
},
|
||||
}),
|
||||
},
|
||||
department: {
|
||||
isLeaf: false,
|
||||
value: createRecordSchema('department'),
|
||||
},
|
||||
});
|
||||
|
||||
const expectedSchema = {
|
||||
_outputSchemaType: 'RECORD',
|
||||
fields: {
|
||||
employee: {
|
||||
isLeaf: false,
|
||||
value: createRecordSchema('person', {
|
||||
manager: {
|
||||
isLeaf: false,
|
||||
value: createRecordSchema('person'),
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(filterOutputSchema(inputSchema, 'person')).toEqual(expectedSchema);
|
||||
});
|
||||
|
||||
it('should ignore leaf fields', () => {
|
||||
const inputSchema = createRecordSchema('company', {
|
||||
name: { isLeaf: true, value: 'string' },
|
||||
employee: {
|
||||
isLeaf: false,
|
||||
value: createRecordSchema('person'),
|
||||
},
|
||||
});
|
||||
|
||||
const expectedSchema = {
|
||||
_outputSchemaType: 'RECORD',
|
||||
fields: {
|
||||
employee: {
|
||||
isLeaf: false,
|
||||
value: createRecordSchema('person'),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(filterOutputSchema(inputSchema, 'person')).toEqual(expectedSchema);
|
||||
});
|
||||
});
|
||||
|
||||
describe('base output schema', () => {
|
||||
const createBaseSchema = (fields = {}): OutputSchema => ({
|
||||
...fields,
|
||||
});
|
||||
|
||||
it('should filter out base schema with no valid records', () => {
|
||||
const inputSchema = createBaseSchema({
|
||||
field1: {
|
||||
isLeaf: true,
|
||||
value: 'string',
|
||||
},
|
||||
});
|
||||
|
||||
expect(filterOutputSchema(inputSchema, 'person')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should keep base schema with valid nested records', () => {
|
||||
const inputSchema = createBaseSchema({
|
||||
field1: {
|
||||
isLeaf: false,
|
||||
value: {
|
||||
_outputSchemaType: 'RECORD',
|
||||
object: { nameSingular: 'person' },
|
||||
fields: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(filterOutputSchema(inputSchema, 'person')).toEqual({
|
||||
field1: {
|
||||
isLeaf: false,
|
||||
value: {
|
||||
_outputSchemaType: 'RECORD',
|
||||
object: { nameSingular: 'person' },
|
||||
fields: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle deeply nested valid records', () => {
|
||||
const inputSchema = createBaseSchema({
|
||||
level1: {
|
||||
isLeaf: false,
|
||||
value: createBaseSchema({
|
||||
level2: {
|
||||
isLeaf: false,
|
||||
value: {
|
||||
_outputSchemaType: 'RECORD',
|
||||
object: { nameSingular: 'person' },
|
||||
fields: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(filterOutputSchema(inputSchema, 'person')).toEqual({
|
||||
level1: {
|
||||
isLeaf: false,
|
||||
value: {
|
||||
level2: {
|
||||
isLeaf: false,
|
||||
value: {
|
||||
_outputSchemaType: 'RECORD',
|
||||
object: { nameSingular: 'person' },
|
||||
fields: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,53 @@
|
||||
import { getTriggerStepName } from '../getTriggerStepName';
|
||||
|
||||
it('returns the expected name for a DATABASE_EVENT trigger', () => {
|
||||
expect(
|
||||
getTriggerStepName({
|
||||
type: 'DATABASE_EVENT',
|
||||
name: '',
|
||||
settings: {
|
||||
eventName: 'company.created',
|
||||
outputSchema: {},
|
||||
},
|
||||
}),
|
||||
).toBe('Company is Created');
|
||||
});
|
||||
|
||||
it('returns the expected name for a MANUAL trigger without a defined objectType', () => {
|
||||
expect(
|
||||
getTriggerStepName({
|
||||
type: 'MANUAL',
|
||||
name: '',
|
||||
settings: {
|
||||
objectType: undefined,
|
||||
outputSchema: {},
|
||||
},
|
||||
}),
|
||||
).toBe('Manual trigger');
|
||||
});
|
||||
|
||||
it('returns the expected name for a MANUAL trigger with a defined objectType', () => {
|
||||
expect(
|
||||
getTriggerStepName({
|
||||
type: 'MANUAL',
|
||||
name: '',
|
||||
settings: {
|
||||
objectType: 'company',
|
||||
outputSchema: {},
|
||||
},
|
||||
}),
|
||||
).toBe('Manual trigger for Company');
|
||||
});
|
||||
|
||||
it('throws when an unknown trigger type is provided', () => {
|
||||
expect(() => {
|
||||
getTriggerStepName({
|
||||
type: 'unknown' as any,
|
||||
name: '',
|
||||
settings: {
|
||||
objectType: 'company',
|
||||
outputSchema: {},
|
||||
},
|
||||
});
|
||||
}).toThrow();
|
||||
});
|
||||
@ -0,0 +1,195 @@
|
||||
import { Editor } from '@tiptap/react';
|
||||
import { initializeEditorContent } from '../initializeEditorContent';
|
||||
|
||||
describe('initializeEditorContent', () => {
|
||||
let mockEditor: Editor;
|
||||
|
||||
beforeEach(() => {
|
||||
mockEditor = {
|
||||
commands: {
|
||||
insertContent: jest.fn(),
|
||||
},
|
||||
} as unknown as Editor;
|
||||
});
|
||||
|
||||
it('should handle single line text', () => {
|
||||
initializeEditorContent(mockEditor, 'Hello world');
|
||||
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(1);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenCalledWith(
|
||||
'Hello world',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle text with newlines', () => {
|
||||
initializeEditorContent(mockEditor, 'Line 1\nLine 2');
|
||||
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(3);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Line 1',
|
||||
);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
|
||||
type: 'hardBreak',
|
||||
});
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'Line 2',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle single variable', () => {
|
||||
initializeEditorContent(mockEditor, '{{user.name}}');
|
||||
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(1);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenCalledWith({
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{user.name}}' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle text with variables', () => {
|
||||
initializeEditorContent(mockEditor, 'Hello {{user.name}}, welcome!');
|
||||
|
||||
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,
|
||||
', welcome!',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle text with multiple variables', () => {
|
||||
initializeEditorContent(
|
||||
mockEditor,
|
||||
'Hello {{user.firstName}} {{user.lastName}}!',
|
||||
);
|
||||
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(5);
|
||||
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}}' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle newlines with variables', () => {
|
||||
initializeEditorContent(
|
||||
mockEditor,
|
||||
'Hello {{user.name}}\nWelcome to {{app.name}}',
|
||||
);
|
||||
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(5);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Hello ',
|
||||
);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{user.name}}' },
|
||||
});
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(3, {
|
||||
type: 'hardBreak',
|
||||
});
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'Welcome to ',
|
||||
);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(5, {
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{app.name}}' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
initializeEditorContent(mockEditor, '');
|
||||
expect(mockEditor.commands.insertContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle multiple empty parts', () => {
|
||||
initializeEditorContent(mockEditor, 'Hello {{user.name}} !');
|
||||
|
||||
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,
|
||||
' !',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple newlines', () => {
|
||||
initializeEditorContent(mockEditor, 'Line1\n\nLine3');
|
||||
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(4);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Line1',
|
||||
);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
|
||||
type: 'hardBreak',
|
||||
});
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(3, {
|
||||
type: 'hardBreak',
|
||||
});
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'Line3',
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore malformed variable tags', () => {
|
||||
initializeEditorContent(
|
||||
mockEditor,
|
||||
'Hello {{user.name}} and {{invalid}more}} text',
|
||||
);
|
||||
|
||||
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,
|
||||
' and {{invalid}more}} text',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle trailing newlines', () => {
|
||||
initializeEditorContent(mockEditor, 'Hello\n');
|
||||
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(2);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Hello',
|
||||
);
|
||||
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
|
||||
type: 'hardBreak',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,299 @@
|
||||
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}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle hard breaks', () => {
|
||||
const input: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'First line',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'hardBreak',
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Second line',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(parseEditorContent(input)).toBe('First line\nSecond line');
|
||||
});
|
||||
|
||||
it('should handle spaces between variables correctly', () => {
|
||||
const input: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{user.firstName}}' },
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: '\u00A0', // NBSP character
|
||||
},
|
||||
{
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{user.lastName}}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(parseEditorContent(input)).toBe(
|
||||
'{{user.firstName}} {{user.lastName}}',
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,21 @@
|
||||
import { isDefined } from 'twenty-ui';
|
||||
|
||||
const CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX = /{{([^{}]+)}}/g;
|
||||
|
||||
export const extractVariableLabel = (rawVariableName: string) => {
|
||||
const variableWithoutBrackets = rawVariableName.replace(
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
(_, variableName) => {
|
||||
return variableName;
|
||||
},
|
||||
);
|
||||
|
||||
const parts = variableWithoutBrackets.split('.');
|
||||
const displayText = parts.at(-1);
|
||||
|
||||
if (!isDefined(displayText)) {
|
||||
throw new Error('Expected to find at least one splitted chunk.');
|
||||
}
|
||||
|
||||
return displayText;
|
||||
};
|
||||
@ -0,0 +1,118 @@
|
||||
import {
|
||||
BaseOutputSchema,
|
||||
OutputSchema,
|
||||
RecordOutputSchema,
|
||||
} from '@/workflow/workflow-variables/types/StepOutputSchema';
|
||||
import { isBaseOutputSchema } from '@/workflow/workflow-variables/utils/isBaseOutputSchema';
|
||||
import { isLinkOutputSchema } from '@/workflow/workflow-variables/utils/isLinkOutputSchema';
|
||||
import { isRecordOutputSchema } from '@/workflow/workflow-variables/utils/isRecordOutputSchema';
|
||||
import { isDefined } from 'twenty-ui';
|
||||
|
||||
const isValidRecordOutputSchema = (
|
||||
outputSchema: RecordOutputSchema,
|
||||
objectNameSingularToSelect?: string,
|
||||
): boolean => {
|
||||
if (isDefined(objectNameSingularToSelect)) {
|
||||
return (
|
||||
isDefined(outputSchema.object) &&
|
||||
outputSchema.object.nameSingular === objectNameSingularToSelect
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const filterRecordOutputSchema = (
|
||||
outputSchema: RecordOutputSchema,
|
||||
objectNameSingularToSelect: string,
|
||||
): RecordOutputSchema | undefined => {
|
||||
const filteredFields: BaseOutputSchema = {};
|
||||
let hasValidFields = false;
|
||||
|
||||
for (const key in outputSchema.fields) {
|
||||
const field = outputSchema.fields[key];
|
||||
|
||||
if (field.isLeaf) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const validSubSchema = filterOutputSchema(
|
||||
field.value,
|
||||
objectNameSingularToSelect,
|
||||
);
|
||||
if (isDefined(validSubSchema)) {
|
||||
filteredFields[key] = {
|
||||
...field,
|
||||
value: validSubSchema,
|
||||
};
|
||||
hasValidFields = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isValidRecordOutputSchema(outputSchema, objectNameSingularToSelect)) {
|
||||
return {
|
||||
...outputSchema,
|
||||
fields: filteredFields,
|
||||
};
|
||||
} else if (hasValidFields) {
|
||||
return {
|
||||
_outputSchemaType: 'RECORD',
|
||||
fields: filteredFields,
|
||||
} as RecordOutputSchema;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const filterBaseOutputSchema = (
|
||||
outputSchema: BaseOutputSchema,
|
||||
objectNameSingularToSelect: string,
|
||||
): BaseOutputSchema | undefined => {
|
||||
const filteredSchema: BaseOutputSchema = {};
|
||||
let hasValidFields = false;
|
||||
|
||||
for (const key in outputSchema) {
|
||||
const field = outputSchema[key];
|
||||
|
||||
if (field.isLeaf) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const validSubSchema = filterOutputSchema(
|
||||
field.value,
|
||||
objectNameSingularToSelect,
|
||||
);
|
||||
if (isDefined(validSubSchema)) {
|
||||
filteredSchema[key] = {
|
||||
...field,
|
||||
value: validSubSchema,
|
||||
};
|
||||
hasValidFields = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasValidFields) {
|
||||
return filteredSchema;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const filterOutputSchema = (
|
||||
outputSchema?: OutputSchema,
|
||||
objectNameSingularToSelect?: string,
|
||||
): OutputSchema | undefined => {
|
||||
if (!objectNameSingularToSelect || !outputSchema) {
|
||||
return outputSchema;
|
||||
}
|
||||
|
||||
if (isLinkOutputSchema(outputSchema)) {
|
||||
return outputSchema;
|
||||
} else if (isRecordOutputSchema(outputSchema)) {
|
||||
return filterRecordOutputSchema(outputSchema, objectNameSingularToSelect);
|
||||
} else if (isBaseOutputSchema(outputSchema)) {
|
||||
return filterBaseOutputSchema(outputSchema, objectNameSingularToSelect);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@ -0,0 +1,30 @@
|
||||
import {
|
||||
WorkflowDatabaseEventTrigger,
|
||||
WorkflowTrigger,
|
||||
} from '@/workflow/types/Workflow';
|
||||
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
|
||||
import { isDefined } from 'twenty-ui';
|
||||
import { capitalize } from '~/utils/string/capitalize';
|
||||
|
||||
export const getTriggerStepName = (trigger: WorkflowTrigger): string => {
|
||||
switch (trigger.type) {
|
||||
case 'DATABASE_EVENT':
|
||||
return getDatabaseEventTriggerStepName(trigger);
|
||||
case 'MANUAL':
|
||||
if (!isDefined(trigger.settings.objectType)) {
|
||||
return 'Manual trigger';
|
||||
}
|
||||
|
||||
return 'Manual trigger for ' + capitalize(trigger.settings.objectType);
|
||||
}
|
||||
|
||||
return assertUnreachable(trigger);
|
||||
};
|
||||
|
||||
const getDatabaseEventTriggerStepName = (
|
||||
trigger: WorkflowDatabaseEventTrigger,
|
||||
): string => {
|
||||
const [object, action] = trigger.settings.eventName.split('.');
|
||||
|
||||
return `${capitalize(object)} is ${capitalize(action)}`;
|
||||
};
|
||||
@ -0,0 +1,36 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { Editor } from '@tiptap/react';
|
||||
|
||||
export const CAPTURE_VARIABLE_TAG_REGEX = /({{[^{}]+}})/;
|
||||
|
||||
export const initializeEditorContent = (editor: Editor, content: string) => {
|
||||
const lines = content.split(/\n/);
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const parts = line.split(CAPTURE_VARIABLE_TAG_REGEX);
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// Add hard break if it's not the last line
|
||||
if (index < lines.length - 1) {
|
||||
editor.commands.insertContent({
|
||||
type: 'hardBreak',
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,10 @@
|
||||
import {
|
||||
BaseOutputSchema,
|
||||
OutputSchema,
|
||||
} from '@/workflow/workflow-variables/types/StepOutputSchema';
|
||||
|
||||
export const isBaseOutputSchema = (
|
||||
outputSchema: OutputSchema,
|
||||
): outputSchema is BaseOutputSchema => {
|
||||
return !outputSchema._outputSchemaType;
|
||||
};
|
||||
@ -0,0 +1,10 @@
|
||||
import {
|
||||
LinkOutputSchema,
|
||||
OutputSchema,
|
||||
} from '@/workflow/workflow-variables/types/StepOutputSchema';
|
||||
|
||||
export const isLinkOutputSchema = (
|
||||
outputSchema: OutputSchema,
|
||||
): outputSchema is LinkOutputSchema => {
|
||||
return outputSchema._outputSchemaType === 'LINK';
|
||||
};
|
||||
@ -0,0 +1,10 @@
|
||||
import {
|
||||
OutputSchema,
|
||||
RecordOutputSchema,
|
||||
} from '@/workflow/workflow-variables/types/StepOutputSchema';
|
||||
|
||||
export const isRecordOutputSchema = (
|
||||
outputSchema: OutputSchema,
|
||||
): outputSchema is RecordOutputSchema => {
|
||||
return outputSchema._outputSchemaType === 'RECORD';
|
||||
};
|
||||
@ -0,0 +1,30 @@
|
||||
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') {
|
||||
// Replace with regular space
|
||||
return node?.text?.replace(/\u00A0/g, ' ') ?? '';
|
||||
}
|
||||
|
||||
if (node.type === 'hardBreak') {
|
||||
return '\n';
|
||||
}
|
||||
|
||||
if (node.type === 'variableTag') {
|
||||
return node.attrs?.variable || '';
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
return parseNode(json);
|
||||
};
|
||||
@ -0,0 +1,60 @@
|
||||
import { extractVariableLabel } from '@/workflow/workflow-variables/utils/extractVariableLabel';
|
||||
import { Node } from '@tiptap/core';
|
||||
import { mergeAttributes } from '@tiptap/react';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
variableTag: {
|
||||
insertVariableTag: (variableName: 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;
|
||||
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-type': 'variableTag',
|
||||
class: 'variable-tag',
|
||||
}),
|
||||
extractVariableLabel(variable),
|
||||
];
|
||||
},
|
||||
|
||||
renderText: ({ node }) => {
|
||||
return node.attrs.variable;
|
||||
},
|
||||
|
||||
addCommands: () => ({
|
||||
insertVariableTag:
|
||||
(variableName: string) =>
|
||||
({ commands }) => {
|
||||
commands.insertContent({
|
||||
type: 'variableTag',
|
||||
attrs: { variable: variableName },
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user