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,130 @@
|
||||
import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
import styled from '@emotion/styled';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import React from 'react';
|
||||
import { isDefined, OverflowingTextWithTooltip } from 'twenty-ui';
|
||||
import { capitalize } from '~/utils/string/capitalize';
|
||||
|
||||
type Variant = 'placeholder';
|
||||
|
||||
const StyledStepNodeContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
padding-bottom: 12px;
|
||||
padding-top: 6px;
|
||||
`;
|
||||
|
||||
const StyledStepNodeType = styled.div`
|
||||
background-color: ${({ theme }) => theme.background.tertiary};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm}
|
||||
${({ theme }) => theme.border.radius.sm} 0 0;
|
||||
|
||||
color: ${({ theme }) => theme.color.gray50};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
|
||||
margin-left: ${({ theme }) => theme.spacing(2)};
|
||||
padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)};
|
||||
align-self: flex-start;
|
||||
|
||||
.selectable.selected &,
|
||||
.selectable:focus &,
|
||||
.selectable:focus-visible & {
|
||||
background-color: ${({ theme }) => theme.color.blue};
|
||||
color: ${({ theme }) => theme.font.color.inverted};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledStepNodeInnerContainer = styled.div<{ variant?: Variant }>`
|
||||
background-color: ${({ theme }) => theme.background.secondary};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-style: ${({ variant }) =>
|
||||
variant === 'placeholder' ? 'dashed' : null};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
|
||||
position: relative;
|
||||
box-shadow: ${({ variant, theme }) =>
|
||||
variant === 'placeholder' ? 'none' : theme.boxShadow.superHeavy};
|
||||
|
||||
.selectable.selected &,
|
||||
.selectable:focus &,
|
||||
.selectable:focus-visible & {
|
||||
background-color: ${({ theme }) => theme.color.blue10};
|
||||
border-color: ${({ theme }) => theme.color.blue};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledStepNodeLabel = styled.div<{ variant?: Variant }>`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-size: ${({ theme }) => theme.font.size.lg};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
column-gap: ${({ theme }) => theme.spacing(3)};
|
||||
color: ${({ variant, theme }) =>
|
||||
variant === 'placeholder'
|
||||
? theme.font.color.extraLight
|
||||
: theme.font.color.primary};
|
||||
max-width: 200px;
|
||||
`;
|
||||
|
||||
const StyledSourceHandle = styled(Handle)`
|
||||
background-color: ${({ theme }) => theme.color.gray50};
|
||||
`;
|
||||
|
||||
export const StyledTargetHandle = styled(Handle)`
|
||||
visibility: hidden;
|
||||
`;
|
||||
|
||||
const StyledRightFloatingElementContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
right: ${({ theme }) => theme.spacing(-3)};
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
transform: translateX(100%);
|
||||
`;
|
||||
|
||||
export const WorkflowDiagramBaseStepNode = ({
|
||||
nodeType,
|
||||
name,
|
||||
variant,
|
||||
Icon,
|
||||
RightFloatingElement,
|
||||
}: {
|
||||
nodeType: WorkflowDiagramStepNodeData['nodeType'];
|
||||
name: string;
|
||||
variant?: Variant;
|
||||
Icon?: React.ReactNode;
|
||||
RightFloatingElement?: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<StyledStepNodeContainer>
|
||||
{nodeType !== 'trigger' ? (
|
||||
<StyledTargetHandle type="target" position={Position.Top} />
|
||||
) : null}
|
||||
|
||||
<StyledStepNodeType>{capitalize(nodeType)}</StyledStepNodeType>
|
||||
|
||||
<StyledStepNodeInnerContainer variant={variant}>
|
||||
<StyledStepNodeLabel variant={variant}>
|
||||
{Icon}
|
||||
|
||||
<OverflowingTextWithTooltip text={name} />
|
||||
</StyledStepNodeLabel>
|
||||
|
||||
{isDefined(RightFloatingElement) ? (
|
||||
<StyledRightFloatingElementContainer>
|
||||
{RightFloatingElement}
|
||||
</StyledRightFloatingElementContainer>
|
||||
) : null}
|
||||
</StyledStepNodeInnerContainer>
|
||||
|
||||
<StyledSourceHandle type="source" position={Position.Bottom} />
|
||||
</StyledStepNodeContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,232 @@
|
||||
import { useListenRightDrawerClose } from '@/ui/layout/right-drawer/hooks/useListenRightDrawerClose';
|
||||
import { isRightDrawerMinimizedState } from '@/ui/layout/right-drawer/states/isRightDrawerMinimizedState';
|
||||
import { isRightDrawerOpenState } from '@/ui/layout/right-drawer/states/isRightDrawerOpenState';
|
||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
import { workflowReactFlowRefState } from '@/workflow/states/workflowReactFlowRefState';
|
||||
import { WorkflowVersionStatus } from '@/workflow/types/Workflow';
|
||||
import { WorkflowVersionStatusTag } from '@/workflow/workflow-diagram/components/WorkflowVersionStatusTag';
|
||||
import { workflowDiagramState } from '@/workflow/workflow-diagram/states/workflowDiagramState';
|
||||
import {
|
||||
WorkflowDiagram,
|
||||
WorkflowDiagramEdge,
|
||||
WorkflowDiagramNode,
|
||||
WorkflowDiagramNodeType,
|
||||
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
import { getOrganizedDiagram } from '@/workflow/workflow-diagram/utils/getOrganizedDiagram';
|
||||
import styled from '@emotion/styled';
|
||||
import {
|
||||
applyEdgeChanges,
|
||||
applyNodeChanges,
|
||||
Background,
|
||||
EdgeChange,
|
||||
FitViewOptions,
|
||||
getNodesBounds,
|
||||
NodeChange,
|
||||
NodeProps,
|
||||
ReactFlow,
|
||||
useReactFlow,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { GRAY_SCALE, isDefined, THEME_COMMON } from 'twenty-ui';
|
||||
|
||||
const StyledResetReactflowStyles = styled.div`
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
/* Below we reset the default styling of Reactflow */
|
||||
.react-flow__node-input,
|
||||
.react-flow__node-default,
|
||||
.react-flow__node-output,
|
||||
.react-flow__node-group {
|
||||
padding: 0;
|
||||
width: auto;
|
||||
text-align: start;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
--xy-node-border-radius: none;
|
||||
--xy-node-border: none;
|
||||
--xy-node-background-color: none;
|
||||
--xy-node-boxshadow-hover: none;
|
||||
--xy-node-boxshadow-selected: none;
|
||||
`;
|
||||
|
||||
const StyledStatusTagContainer = styled.div`
|
||||
left: 0;
|
||||
top: 0;
|
||||
position: absolute;
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const defaultFitViewOptions = {
|
||||
minZoom: 1,
|
||||
maxZoom: 1,
|
||||
} satisfies FitViewOptions;
|
||||
|
||||
export const WorkflowDiagramCanvasBase = ({
|
||||
diagram,
|
||||
status,
|
||||
nodeTypes,
|
||||
children,
|
||||
}: {
|
||||
diagram: WorkflowDiagram;
|
||||
status: WorkflowVersionStatus;
|
||||
nodeTypes: Partial<
|
||||
Record<
|
||||
WorkflowDiagramNodeType,
|
||||
React.ComponentType<
|
||||
NodeProps & {
|
||||
data: any;
|
||||
type: any;
|
||||
}
|
||||
>
|
||||
>
|
||||
>;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
const reactflow = useReactFlow();
|
||||
const setWorkflowReactFlowRefState = useSetRecoilState(
|
||||
workflowReactFlowRefState,
|
||||
);
|
||||
|
||||
const { nodes, edges } = useMemo(
|
||||
() => getOrganizedDiagram(diagram),
|
||||
[diagram],
|
||||
);
|
||||
|
||||
const isRightDrawerOpen = useRecoilValue(isRightDrawerOpenState);
|
||||
const isRightDrawerMinimized = useRecoilValue(isRightDrawerMinimizedState);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const rightDrawerState = !isRightDrawerOpen
|
||||
? 'closed'
|
||||
: isRightDrawerMinimized
|
||||
? 'minimized'
|
||||
: isMobile
|
||||
? 'fullScreen'
|
||||
: 'normal';
|
||||
|
||||
const rightDrawerWidth = Number(
|
||||
THEME_COMMON.rightDrawerWidth.replace('px', ''),
|
||||
);
|
||||
|
||||
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
|
||||
|
||||
const handleNodesChange = (
|
||||
nodeChanges: Array<NodeChange<WorkflowDiagramNode>>,
|
||||
) => {
|
||||
setWorkflowDiagram((diagram) => {
|
||||
if (isDefined(diagram) === false) {
|
||||
throw new Error(
|
||||
'It must be impossible for the nodes to be updated if the diagram is not defined yet. Be sure the diagram is rendered only when defined.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...diagram,
|
||||
nodes: applyNodeChanges(nodeChanges, diagram.nodes),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdgesChange = (
|
||||
edgeChanges: Array<EdgeChange<WorkflowDiagramEdge>>,
|
||||
) => {
|
||||
setWorkflowDiagram((diagram) => {
|
||||
if (isDefined(diagram) === false) {
|
||||
throw new Error(
|
||||
'It must be impossible for the edges to be updated if the diagram is not defined yet. Be sure the diagram is rendered only when defined.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...diagram,
|
||||
edges: applyEdgeChanges(edgeChanges, diagram.edges),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
useListenRightDrawerClose(() => {
|
||||
reactflow.setNodes((nodes) =>
|
||||
nodes.map((node) => ({ ...node, selected: false })),
|
||||
);
|
||||
});
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDefined(containerRef.current) || !reactflow.viewportInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentViewport = reactflow.getViewport();
|
||||
|
||||
const flowBounds = getNodesBounds(reactflow.getNodes());
|
||||
|
||||
let visibleRightDrawerWidth = 0;
|
||||
if (rightDrawerState === 'normal') {
|
||||
visibleRightDrawerWidth = rightDrawerWidth;
|
||||
}
|
||||
|
||||
const viewportX =
|
||||
(containerRef.current.offsetWidth + visibleRightDrawerWidth) / 2 -
|
||||
flowBounds.width / 2;
|
||||
|
||||
reactflow.setViewport(
|
||||
{
|
||||
...currentViewport,
|
||||
x: viewportX - visibleRightDrawerWidth,
|
||||
},
|
||||
{ duration: 300 },
|
||||
);
|
||||
}, [reactflow, rightDrawerState, rightDrawerWidth]);
|
||||
|
||||
return (
|
||||
<StyledResetReactflowStyles ref={containerRef}>
|
||||
<ReactFlow
|
||||
ref={(node) => {
|
||||
if (isDefined(node)) {
|
||||
setWorkflowReactFlowRefState({ current: node });
|
||||
}
|
||||
}}
|
||||
onInit={() => {
|
||||
if (!isDefined(containerRef.current)) {
|
||||
throw new Error('Expect the container ref to be defined');
|
||||
}
|
||||
|
||||
const flowBounds = getNodesBounds(reactflow.getNodes());
|
||||
|
||||
reactflow.setViewport({
|
||||
x: containerRef.current.offsetWidth / 2 - flowBounds.width / 2,
|
||||
y: 150,
|
||||
zoom: defaultFitViewOptions.maxZoom,
|
||||
});
|
||||
}}
|
||||
minZoom={defaultFitViewOptions.minZoom}
|
||||
maxZoom={defaultFitViewOptions.maxZoom}
|
||||
nodeTypes={nodeTypes}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={handleEdgesChange}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
multiSelectionKeyCode={null}
|
||||
nodesFocusable={false}
|
||||
edgesFocusable={false}
|
||||
nodesDraggable={false}
|
||||
paneClickDistance={10} // Fix small unwanted user dragging does not select node
|
||||
>
|
||||
<Background color={GRAY_SCALE.gray25} size={2} />
|
||||
|
||||
{children}
|
||||
</ReactFlow>
|
||||
|
||||
<StyledStatusTagContainer>
|
||||
<WorkflowVersionStatusTag versionStatus={status} />
|
||||
</StyledStatusTagContainer>
|
||||
</StyledResetReactflowStyles>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,32 @@
|
||||
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
|
||||
import { WorkflowDiagramCanvasBase } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase';
|
||||
import { WorkflowDiagramCanvasEditableEffect } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditableEffect';
|
||||
import { WorkflowDiagramCreateStepNode } from '@/workflow/workflow-diagram/components/WorkflowDiagramCreateStepNode';
|
||||
import { WorkflowDiagramEmptyTrigger } from '@/workflow/workflow-diagram/components/WorkflowDiagramEmptyTrigger';
|
||||
import { WorkflowDiagramStepNodeEditable } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeEditable';
|
||||
import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
import { ReactFlowProvider } from '@xyflow/react';
|
||||
|
||||
export const WorkflowDiagramCanvasEditable = ({
|
||||
diagram,
|
||||
workflowWithCurrentVersion,
|
||||
}: {
|
||||
diagram: WorkflowDiagram;
|
||||
workflowWithCurrentVersion: WorkflowWithCurrentVersion;
|
||||
}) => {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<WorkflowDiagramCanvasBase
|
||||
diagram={diagram}
|
||||
status={workflowWithCurrentVersion.currentVersion.status}
|
||||
nodeTypes={{
|
||||
default: WorkflowDiagramStepNodeEditable,
|
||||
'create-step': WorkflowDiagramCreateStepNode,
|
||||
'empty-trigger': WorkflowDiagramEmptyTrigger,
|
||||
}}
|
||||
>
|
||||
<WorkflowDiagramCanvasEditableEffect />
|
||||
</WorkflowDiagramCanvasBase>
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,73 @@
|
||||
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
|
||||
import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope';
|
||||
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
|
||||
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
|
||||
import { useStartNodeCreation } from '@/workflow/hooks/useStartNodeCreation';
|
||||
import { useTriggerNodeSelection } from '@/workflow/hooks/useTriggerNodeSelection';
|
||||
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
|
||||
import { CREATE_STEP_STEP_ID } from '@/workflow/workflow-diagram/constants/CreateStepStepId';
|
||||
import { EMPTY_TRIGGER_STEP_ID } from '@/workflow/workflow-diagram/constants/EmptyTriggerStepId';
|
||||
import { WorkflowDiagramNode } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
import { OnSelectionChangeParams, useOnSelectionChange } from '@xyflow/react';
|
||||
import { useCallback } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { isDefined } from 'twenty-ui';
|
||||
|
||||
export const WorkflowDiagramCanvasEditableEffect = () => {
|
||||
const { startNodeCreation } = useStartNodeCreation();
|
||||
|
||||
const { openRightDrawer, closeRightDrawer } = useRightDrawer();
|
||||
const setHotkeyScope = useSetHotkeyScope();
|
||||
|
||||
const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState);
|
||||
|
||||
const handleSelectionChange = useCallback(
|
||||
({ nodes }: OnSelectionChangeParams) => {
|
||||
const selectedNode = nodes[0] as WorkflowDiagramNode;
|
||||
const isClosingStep = isDefined(selectedNode) === false;
|
||||
|
||||
if (isClosingStep) {
|
||||
closeRightDrawer();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const isEmptyTriggerNode = selectedNode.type === EMPTY_TRIGGER_STEP_ID;
|
||||
if (isEmptyTriggerNode) {
|
||||
openRightDrawer(RightDrawerPages.WorkflowStepSelectTriggerType);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const isCreateStepNode = selectedNode.type === CREATE_STEP_STEP_ID;
|
||||
if (isCreateStepNode) {
|
||||
if (selectedNode.data.nodeType !== 'create-step') {
|
||||
throw new Error('Expected selected node to be a create step node.');
|
||||
}
|
||||
|
||||
startNodeCreation(selectedNode.data.parentNodeId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkflowSelectedNode(selectedNode.id);
|
||||
setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false });
|
||||
openRightDrawer(RightDrawerPages.WorkflowStepEdit);
|
||||
},
|
||||
[
|
||||
setHotkeyScope,
|
||||
closeRightDrawer,
|
||||
openRightDrawer,
|
||||
setWorkflowSelectedNode,
|
||||
startNodeCreation,
|
||||
],
|
||||
);
|
||||
|
||||
useOnSelectionChange({
|
||||
onChange: handleSelectionChange,
|
||||
});
|
||||
|
||||
useTriggerNodeSelection();
|
||||
|
||||
return null;
|
||||
};
|
||||
@ -0,0 +1,30 @@
|
||||
import { WorkflowVersion } from '@/workflow/types/Workflow';
|
||||
import { WorkflowDiagramCanvasBase } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase';
|
||||
import { WorkflowDiagramCanvasReadonlyEffect } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasReadonlyEffect';
|
||||
import { WorkflowDiagramEmptyTrigger } from '@/workflow/workflow-diagram/components/WorkflowDiagramEmptyTrigger';
|
||||
import { WorkflowDiagramStepNodeReadonly } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeReadonly';
|
||||
import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
import { ReactFlowProvider } from '@xyflow/react';
|
||||
|
||||
export const WorkflowDiagramCanvasReadonly = ({
|
||||
diagram,
|
||||
workflowVersion,
|
||||
}: {
|
||||
diagram: WorkflowDiagram;
|
||||
workflowVersion: WorkflowVersion;
|
||||
}) => {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<WorkflowDiagramCanvasBase
|
||||
diagram={diagram}
|
||||
status={workflowVersion.status}
|
||||
nodeTypes={{
|
||||
default: WorkflowDiagramStepNodeReadonly,
|
||||
'empty-trigger': WorkflowDiagramEmptyTrigger,
|
||||
}}
|
||||
>
|
||||
<WorkflowDiagramCanvasReadonlyEffect />
|
||||
</WorkflowDiagramCanvasBase>
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,48 @@
|
||||
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
|
||||
import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope';
|
||||
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
|
||||
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
|
||||
import { useTriggerNodeSelection } from '@/workflow/hooks/useTriggerNodeSelection';
|
||||
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
|
||||
import { WorkflowDiagramNode } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
import { OnSelectionChangeParams, useOnSelectionChange } from '@xyflow/react';
|
||||
import { useCallback } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { isDefined } from 'twenty-ui';
|
||||
|
||||
export const WorkflowDiagramCanvasReadonlyEffect = () => {
|
||||
const { openRightDrawer, closeRightDrawer } = useRightDrawer();
|
||||
const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState);
|
||||
const setHotkeyScope = useSetHotkeyScope();
|
||||
|
||||
const handleSelectionChange = useCallback(
|
||||
({ nodes }: OnSelectionChangeParams) => {
|
||||
const selectedNode = nodes[0] as WorkflowDiagramNode;
|
||||
const isClosingStep = isDefined(selectedNode) === false;
|
||||
|
||||
if (isClosingStep) {
|
||||
closeRightDrawer();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkflowSelectedNode(selectedNode.id);
|
||||
setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false });
|
||||
openRightDrawer(RightDrawerPages.WorkflowStepView);
|
||||
},
|
||||
[
|
||||
closeRightDrawer,
|
||||
openRightDrawer,
|
||||
setWorkflowSelectedNode,
|
||||
setHotkeyScope,
|
||||
],
|
||||
);
|
||||
|
||||
useOnSelectionChange({
|
||||
onChange: handleSelectionChange,
|
||||
});
|
||||
|
||||
useTriggerNodeSelection();
|
||||
|
||||
return null;
|
||||
};
|
||||
@ -0,0 +1,17 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { IconButton, IconPlus } from 'twenty-ui';
|
||||
|
||||
export const StyledTargetHandle = styled(Handle)`
|
||||
visibility: hidden;
|
||||
`;
|
||||
|
||||
export const WorkflowDiagramCreateStepNode = () => {
|
||||
return (
|
||||
<>
|
||||
<StyledTargetHandle type="target" position={Position.Top} />
|
||||
|
||||
<IconButton Icon={IconPlus} size="medium" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,85 @@
|
||||
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
|
||||
import { workflowLastCreatedStepIdState } from '@/workflow/states/workflowLastCreatedStepIdState';
|
||||
import {
|
||||
WorkflowVersion,
|
||||
WorkflowWithCurrentVersion,
|
||||
} from '@/workflow/types/Workflow';
|
||||
import { workflowDiagramState } from '@/workflow/workflow-diagram/states/workflowDiagramState';
|
||||
|
||||
import { addCreateStepNodes } from '@/workflow/workflow-diagram/utils/addCreateStepNodes';
|
||||
import { getWorkflowVersionDiagram } from '@/workflow/workflow-diagram/utils/getWorkflowVersionDiagram';
|
||||
import { mergeWorkflowDiagrams } from '@/workflow/workflow-diagram/utils/mergeWorkflowDiagrams';
|
||||
import { useEffect } from 'react';
|
||||
import { useRecoilCallback, useSetRecoilState } from 'recoil';
|
||||
import { isDefined } from 'twenty-ui';
|
||||
|
||||
export const WorkflowDiagramEffect = ({
|
||||
workflowWithCurrentVersion,
|
||||
}: {
|
||||
workflowWithCurrentVersion: WorkflowWithCurrentVersion | undefined;
|
||||
}) => {
|
||||
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
|
||||
|
||||
const computeAndMergeNewWorkflowDiagram = useRecoilCallback(
|
||||
({ snapshot, set }) => {
|
||||
return (currentVersion: WorkflowVersion) => {
|
||||
const previousWorkflowDiagram = getSnapshotValue(
|
||||
snapshot,
|
||||
workflowDiagramState,
|
||||
);
|
||||
|
||||
const nextWorkflowDiagram = addCreateStepNodes(
|
||||
getWorkflowVersionDiagram(currentVersion),
|
||||
);
|
||||
|
||||
let mergedWorkflowDiagram = nextWorkflowDiagram;
|
||||
if (isDefined(previousWorkflowDiagram)) {
|
||||
mergedWorkflowDiagram = mergeWorkflowDiagrams(
|
||||
previousWorkflowDiagram,
|
||||
nextWorkflowDiagram,
|
||||
);
|
||||
}
|
||||
|
||||
const lastCreatedStepId = getSnapshotValue(
|
||||
snapshot,
|
||||
workflowLastCreatedStepIdState,
|
||||
);
|
||||
if (isDefined(lastCreatedStepId)) {
|
||||
mergedWorkflowDiagram.nodes = mergedWorkflowDiagram.nodes.map(
|
||||
(node) => {
|
||||
if (node.id === lastCreatedStepId) {
|
||||
return {
|
||||
...node,
|
||||
selected: true,
|
||||
};
|
||||
}
|
||||
return node;
|
||||
},
|
||||
);
|
||||
|
||||
set(workflowLastCreatedStepIdState, undefined);
|
||||
}
|
||||
|
||||
set(workflowDiagramState, mergedWorkflowDiagram);
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const currentVersion = workflowWithCurrentVersion?.currentVersion;
|
||||
if (!isDefined(currentVersion)) {
|
||||
setWorkflowDiagram(undefined);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
computeAndMergeNewWorkflowDiagram(currentVersion);
|
||||
}, [
|
||||
computeAndMergeNewWorkflowDiagram,
|
||||
setWorkflowDiagram,
|
||||
workflowWithCurrentVersion?.currentVersion,
|
||||
]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@ -0,0 +1,30 @@
|
||||
import { WorkflowDiagramBaseStepNode } from '@/workflow/workflow-diagram/components/WorkflowDiagramBaseStepNode';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { IconPlaylistAdd } from 'twenty-ui';
|
||||
|
||||
const StyledStepNodeLabelIconContainer = styled.div`
|
||||
align-items: center;
|
||||
background: ${({ theme }) => theme.background.transparent.light};
|
||||
border-radius: ${({ theme }) => theme.spacing(1)};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
export const WorkflowDiagramEmptyTrigger = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<WorkflowDiagramBaseStepNode
|
||||
name="Add a Trigger"
|
||||
nodeType="trigger"
|
||||
variant="placeholder"
|
||||
Icon={
|
||||
<StyledStepNodeLabelIconContainer>
|
||||
<IconPlaylistAdd size={16} color={theme.font.color.tertiary} />
|
||||
</StyledStepNodeLabelIconContainer>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,107 @@
|
||||
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
|
||||
import { WorkflowDiagramBaseStepNode } from '@/workflow/workflow-diagram/components/WorkflowDiagramBaseStepNode';
|
||||
import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import {
|
||||
IconAddressBook,
|
||||
IconCode,
|
||||
IconHandMove,
|
||||
IconMail,
|
||||
IconPlaylistAdd,
|
||||
} from 'twenty-ui';
|
||||
|
||||
const StyledStepNodeLabelIconContainer = styled.div`
|
||||
align-items: center;
|
||||
background: ${({ theme }) => theme.background.transparent.light};
|
||||
border-radius: ${({ theme }) => theme.spacing(1)};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
export const WorkflowDiagramStepNodeBase = ({
|
||||
data,
|
||||
RightFloatingElement,
|
||||
}: {
|
||||
data: WorkflowDiagramStepNodeData;
|
||||
RightFloatingElement?: React.ReactNode;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const renderStepIcon = () => {
|
||||
switch (data.nodeType) {
|
||||
case 'trigger': {
|
||||
switch (data.triggerType) {
|
||||
case 'DATABASE_EVENT': {
|
||||
return (
|
||||
<StyledStepNodeLabelIconContainer>
|
||||
<IconPlaylistAdd
|
||||
size={theme.icon.size.lg}
|
||||
color={theme.font.color.tertiary}
|
||||
/>
|
||||
</StyledStepNodeLabelIconContainer>
|
||||
);
|
||||
}
|
||||
case 'MANUAL': {
|
||||
return (
|
||||
<StyledStepNodeLabelIconContainer>
|
||||
<IconHandMove
|
||||
size={theme.icon.size.lg}
|
||||
color={theme.font.color.tertiary}
|
||||
/>
|
||||
</StyledStepNodeLabelIconContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return assertUnreachable(data.triggerType);
|
||||
}
|
||||
case 'action': {
|
||||
switch (data.actionType) {
|
||||
case 'CODE': {
|
||||
return (
|
||||
<StyledStepNodeLabelIconContainer>
|
||||
<IconCode
|
||||
size={theme.icon.size.lg}
|
||||
color={theme.color.orange}
|
||||
/>
|
||||
</StyledStepNodeLabelIconContainer>
|
||||
);
|
||||
}
|
||||
case 'SEND_EMAIL': {
|
||||
return (
|
||||
<StyledStepNodeLabelIconContainer>
|
||||
<IconMail size={theme.icon.size.lg} color={theme.color.blue} />
|
||||
</StyledStepNodeLabelIconContainer>
|
||||
);
|
||||
}
|
||||
case 'CREATE_RECORD':
|
||||
case 'UPDATE_RECORD':
|
||||
case 'DELETE_RECORD': {
|
||||
return (
|
||||
<StyledStepNodeLabelIconContainer>
|
||||
<IconAddressBook
|
||||
size={theme.icon.size.lg}
|
||||
color={theme.font.color.tertiary}
|
||||
stroke={theme.icon.stroke.sm}
|
||||
/>
|
||||
</StyledStepNodeLabelIconContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return assertUnreachable(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkflowDiagramBaseStepNode
|
||||
name={data.name}
|
||||
nodeType={data.nodeType}
|
||||
Icon={renderStepIcon()}
|
||||
RightFloatingElement={RightFloatingElement}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,44 @@
|
||||
import { useDeleteStep } from '@/workflow/hooks/useDeleteStep';
|
||||
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
|
||||
import { workflowIdState } from '@/workflow/states/workflowIdState';
|
||||
import { assertWorkflowWithCurrentVersionIsDefined } from '@/workflow/utils/assertWorkflowWithCurrentVersionIsDefined';
|
||||
import { WorkflowDiagramStepNodeBase } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeBase';
|
||||
import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { FloatingIconButton, IconTrash } from 'twenty-ui';
|
||||
|
||||
export const WorkflowDiagramStepNodeEditable = ({
|
||||
id,
|
||||
data,
|
||||
selected,
|
||||
}: {
|
||||
id: string;
|
||||
data: WorkflowDiagramStepNodeData;
|
||||
selected?: boolean;
|
||||
}) => {
|
||||
const workflowId = useRecoilValue(workflowIdState);
|
||||
|
||||
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
|
||||
assertWorkflowWithCurrentVersionIsDefined(workflowWithCurrentVersion);
|
||||
|
||||
const { deleteStep } = useDeleteStep({
|
||||
workflow: workflowWithCurrentVersion,
|
||||
});
|
||||
|
||||
return (
|
||||
<WorkflowDiagramStepNodeBase
|
||||
data={data}
|
||||
RightFloatingElement={
|
||||
selected ? (
|
||||
<FloatingIconButton
|
||||
size="medium"
|
||||
Icon={IconTrash}
|
||||
onClick={() => {
|
||||
deleteStep(id);
|
||||
}}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,10 @@
|
||||
import { WorkflowDiagramStepNodeBase } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeBase';
|
||||
import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
|
||||
export const WorkflowDiagramStepNodeReadonly = ({
|
||||
data,
|
||||
}: {
|
||||
data: WorkflowDiagramStepNodeData;
|
||||
}) => {
|
||||
return <WorkflowDiagramStepNodeBase data={data} />;
|
||||
};
|
||||
@ -0,0 +1,22 @@
|
||||
import { WorkflowVersionStatus } from '@/workflow/types/Workflow';
|
||||
import { Tag } from 'twenty-ui';
|
||||
|
||||
export const WorkflowVersionStatusTag = ({
|
||||
versionStatus,
|
||||
}: {
|
||||
versionStatus: WorkflowVersionStatus;
|
||||
}) => {
|
||||
if (versionStatus === 'ACTIVE') {
|
||||
return <Tag color="green" text="Active" />;
|
||||
}
|
||||
|
||||
if (versionStatus === 'DRAFT') {
|
||||
return <Tag color="yellow" text="Draft" />;
|
||||
}
|
||||
|
||||
if (versionStatus === 'ARCHIVED') {
|
||||
return <Tag color="gray" text="Archived" />;
|
||||
}
|
||||
|
||||
return <Tag color="gray" text="Deactivated" />;
|
||||
};
|
||||
@ -0,0 +1,23 @@
|
||||
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
|
||||
import { WorkflowDiagramCanvasReadonly } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasReadonly';
|
||||
import { workflowDiagramState } from '@/workflow/workflow-diagram/states/workflowDiagramState';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { isDefined } from 'twenty-ui';
|
||||
|
||||
export const WorkflowVersionVisualizer = ({
|
||||
workflowVersionId,
|
||||
}: {
|
||||
workflowVersionId: string;
|
||||
}) => {
|
||||
const workflowVersion = useWorkflowVersion(workflowVersionId);
|
||||
|
||||
const workflowDiagram = useRecoilValue(workflowDiagramState);
|
||||
|
||||
return isDefined(workflowDiagram) && isDefined(workflowVersion) ? (
|
||||
<WorkflowDiagramCanvasReadonly
|
||||
diagram={workflowDiagram}
|
||||
workflowVersion={workflowVersion}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
@ -0,0 +1,36 @@
|
||||
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
|
||||
import { workflowVersionIdState } from '@/workflow/states/workflowVersionIdState';
|
||||
import { workflowDiagramState } from '@/workflow/workflow-diagram/states/workflowDiagramState';
|
||||
import { getWorkflowVersionDiagram } from '@/workflow/workflow-diagram/utils/getWorkflowVersionDiagram';
|
||||
import { useEffect } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { isDefined } from 'twenty-ui';
|
||||
|
||||
export const WorkflowVersionVisualizerEffect = ({
|
||||
workflowVersionId,
|
||||
}: {
|
||||
workflowVersionId: string;
|
||||
}) => {
|
||||
const workflowVersion = useWorkflowVersion(workflowVersionId);
|
||||
|
||||
const setWorkflowVersionId = useSetRecoilState(workflowVersionIdState);
|
||||
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
|
||||
|
||||
useEffect(() => {
|
||||
setWorkflowVersionId(workflowVersionId);
|
||||
}, [setWorkflowVersionId, workflowVersionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDefined(workflowVersion)) {
|
||||
setWorkflowDiagram(undefined);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const nextWorkflowDiagram = getWorkflowVersionDiagram(workflowVersion);
|
||||
|
||||
setWorkflowDiagram(nextWorkflowDiagram);
|
||||
}, [setWorkflowDiagram, workflowVersion]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@ -0,0 +1,34 @@
|
||||
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
|
||||
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
|
||||
import { WorkflowDiagramCanvasEditable } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditable';
|
||||
import { WorkflowDiagramEffect } from '@/workflow/workflow-diagram/components/WorkflowDiagramEffect';
|
||||
import { workflowDiagramState } from '@/workflow/workflow-diagram/states/workflowDiagramState';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { isDefined } from 'twenty-ui';
|
||||
|
||||
export const WorkflowVisualizer = ({
|
||||
targetableObject,
|
||||
}: {
|
||||
targetableObject: ActivityTargetableObject;
|
||||
}) => {
|
||||
const workflowId = targetableObject.id;
|
||||
|
||||
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
|
||||
const workflowDiagram = useRecoilValue(workflowDiagramState);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkflowDiagramEffect
|
||||
workflowWithCurrentVersion={workflowWithCurrentVersion}
|
||||
/>
|
||||
|
||||
{isDefined(workflowDiagram) && isDefined(workflowWithCurrentVersion) ? (
|
||||
<WorkflowDiagramCanvasEditable
|
||||
diagram={workflowDiagram}
|
||||
workflowWithCurrentVersion={workflowWithCurrentVersion}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,17 @@
|
||||
import { workflowIdState } from '@/workflow/states/workflowIdState';
|
||||
import { useEffect } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
|
||||
export const WorkflowVisualizerEffect = ({
|
||||
workflowId,
|
||||
}: {
|
||||
workflowId: string;
|
||||
}) => {
|
||||
const setWorkflowId = useSetRecoilState(workflowIdState);
|
||||
|
||||
useEffect(() => {
|
||||
setWorkflowId(workflowId);
|
||||
}, [setWorkflowId, workflowId]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@ -0,0 +1,43 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { CatalogDecorator, CatalogStory, ComponentDecorator } from 'twenty-ui';
|
||||
|
||||
import { WorkflowVersionStatus } from '@/workflow/types/Workflow';
|
||||
import { WorkflowVersionStatusTag } from '../WorkflowVersionStatusTag';
|
||||
|
||||
const meta: Meta<typeof WorkflowVersionStatusTag> = {
|
||||
title: 'Modules/Workflow/WorkflowVersionStatusTag',
|
||||
component: WorkflowVersionStatusTag,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof WorkflowVersionStatusTag>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
versionStatus: 'DRAFT',
|
||||
},
|
||||
decorators: [ComponentDecorator],
|
||||
};
|
||||
|
||||
export const Catalog: CatalogStory<Story, typeof WorkflowVersionStatusTag> = {
|
||||
argTypes: {
|
||||
versionStatus: { table: { disable: true } },
|
||||
},
|
||||
parameters: {
|
||||
catalog: {
|
||||
dimensions: [
|
||||
{
|
||||
name: 'version status',
|
||||
values: [
|
||||
'DRAFT',
|
||||
'ACTIVE',
|
||||
'DEACTIVATED',
|
||||
'ARCHIVED',
|
||||
] satisfies WorkflowVersionStatus[],
|
||||
props: (versionStatus: WorkflowVersionStatus) => ({ versionStatus }),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [CatalogDecorator],
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export const CREATE_STEP_STEP_ID = 'create-step';
|
||||
@ -0,0 +1 @@
|
||||
export const EMPTY_TRIGGER_STEP_ID = 'empty-trigger';
|
||||
@ -0,0 +1,7 @@
|
||||
import { createState } from '@ui/utilities/state/utils/createState';
|
||||
import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
|
||||
export const workflowDiagramState = createState<WorkflowDiagram | undefined>({
|
||||
key: 'workflowDiagramState',
|
||||
defaultValue: undefined,
|
||||
});
|
||||
@ -0,0 +1,39 @@
|
||||
import {
|
||||
WorkflowActionType,
|
||||
WorkflowTriggerType,
|
||||
} from '@/workflow/types/Workflow';
|
||||
import { Edge, Node } from '@xyflow/react';
|
||||
|
||||
export type WorkflowDiagramNode = Node<WorkflowDiagramNodeData>;
|
||||
export type WorkflowDiagramEdge = Edge;
|
||||
|
||||
export type WorkflowDiagram = {
|
||||
nodes: Array<WorkflowDiagramNode>;
|
||||
edges: Array<WorkflowDiagramEdge>;
|
||||
};
|
||||
|
||||
export type WorkflowDiagramStepNodeData =
|
||||
| {
|
||||
nodeType: 'trigger';
|
||||
triggerType: WorkflowTriggerType;
|
||||
name: string;
|
||||
}
|
||||
| {
|
||||
nodeType: 'action';
|
||||
actionType: WorkflowActionType;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type WorkflowDiagramCreateStepNodeData = {
|
||||
nodeType: 'create-step';
|
||||
parentNodeId: string;
|
||||
};
|
||||
|
||||
export type WorkflowDiagramNodeData =
|
||||
| WorkflowDiagramStepNodeData
|
||||
| WorkflowDiagramCreateStepNodeData;
|
||||
|
||||
export type WorkflowDiagramNodeType =
|
||||
| 'default'
|
||||
| 'empty-trigger'
|
||||
| 'create-step';
|
||||
@ -0,0 +1,75 @@
|
||||
import { WorkflowStep, WorkflowTrigger } from '@/workflow/types/Workflow';
|
||||
import { generateWorkflowDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowDiagram';
|
||||
import { addCreateStepNodes } from '../addCreateStepNodes';
|
||||
|
||||
describe('addCreateStepNodes', () => {
|
||||
it("adds a create step node to the end of a single-branch flow and doesn't change the shape of other nodes", () => {
|
||||
const trigger: WorkflowTrigger = {
|
||||
name: 'Company created',
|
||||
type: 'DATABASE_EVENT',
|
||||
settings: {
|
||||
eventName: 'company.created',
|
||||
outputSchema: {},
|
||||
},
|
||||
};
|
||||
const steps: WorkflowStep[] = [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
type: 'CODE',
|
||||
valid: true,
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: true },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
input: {
|
||||
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||
serverlessFunctionVersion: '1',
|
||||
serverlessFunctionInput: {},
|
||||
},
|
||||
outputSchema: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
type: 'CODE',
|
||||
valid: true,
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: true },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
input: {
|
||||
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||
serverlessFunctionVersion: '1',
|
||||
serverlessFunctionInput: {},
|
||||
},
|
||||
outputSchema: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const diagramInitial = generateWorkflowDiagram({ trigger, steps });
|
||||
|
||||
expect(diagramInitial.nodes).toHaveLength(3);
|
||||
expect(diagramInitial.edges).toHaveLength(2);
|
||||
|
||||
const diagramWithCreateStepNodes = addCreateStepNodes(diagramInitial);
|
||||
|
||||
expect(diagramWithCreateStepNodes.nodes).toHaveLength(4);
|
||||
expect(diagramWithCreateStepNodes.edges).toHaveLength(3);
|
||||
|
||||
expect(diagramWithCreateStepNodes.nodes[0].type).toBe(undefined);
|
||||
expect(diagramWithCreateStepNodes.nodes[0].data.nodeType).toBe('trigger');
|
||||
|
||||
expect(diagramWithCreateStepNodes.nodes[1].type).toBe(undefined);
|
||||
expect(diagramWithCreateStepNodes.nodes[1].data.nodeType).toBe('action');
|
||||
|
||||
expect(diagramWithCreateStepNodes.nodes[2].type).toBe(undefined);
|
||||
expect(diagramWithCreateStepNodes.nodes[2].data.nodeType).toBe('action');
|
||||
|
||||
expect(diagramWithCreateStepNodes.nodes[3].type).toBe('create-step');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,150 @@
|
||||
import { WorkflowStep, WorkflowTrigger } from '@/workflow/types/Workflow';
|
||||
import { generateWorkflowDiagram } from '../generateWorkflowDiagram';
|
||||
|
||||
describe('generateWorkflowDiagram', () => {
|
||||
it('should generate a single trigger node when no step is provided', () => {
|
||||
const trigger: WorkflowTrigger = {
|
||||
name: 'Company created',
|
||||
type: 'DATABASE_EVENT',
|
||||
settings: {
|
||||
eventName: 'company.created',
|
||||
outputSchema: {},
|
||||
},
|
||||
};
|
||||
const steps: WorkflowStep[] = [];
|
||||
|
||||
const result = generateWorkflowDiagram({ trigger, steps });
|
||||
|
||||
expect(result.nodes).toHaveLength(1);
|
||||
expect(result.edges).toHaveLength(0);
|
||||
|
||||
expect(result.nodes[0]).toMatchObject({
|
||||
data: {
|
||||
nodeType: 'trigger',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate a diagram with nodes and edges corresponding to the steps', () => {
|
||||
const trigger: WorkflowTrigger = {
|
||||
name: 'Company created',
|
||||
type: 'DATABASE_EVENT',
|
||||
settings: {
|
||||
eventName: 'company.created',
|
||||
outputSchema: {},
|
||||
},
|
||||
};
|
||||
const steps: WorkflowStep[] = [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
type: 'CODE',
|
||||
valid: true,
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: true },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
input: {
|
||||
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||
serverlessFunctionVersion: '1',
|
||||
serverlessFunctionInput: {},
|
||||
},
|
||||
outputSchema: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
type: 'CODE',
|
||||
valid: true,
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: true },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
input: {
|
||||
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||
serverlessFunctionVersion: '1',
|
||||
serverlessFunctionInput: {},
|
||||
},
|
||||
outputSchema: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = generateWorkflowDiagram({ trigger, steps });
|
||||
|
||||
expect(result.nodes).toHaveLength(steps.length + 1); // All steps + trigger
|
||||
expect(result.edges).toHaveLength(steps.length - 1 + 1); // Edges are one less than nodes + the edge from the trigger to the first node
|
||||
|
||||
expect(result.nodes[0].data.nodeType).toBe('trigger');
|
||||
|
||||
const stepNodes = result.nodes.slice(1);
|
||||
|
||||
for (const [index, step] of steps.entries()) {
|
||||
expect(stepNodes[index].data).toEqual({
|
||||
nodeType: 'action',
|
||||
actionType: 'CODE',
|
||||
name: step.name,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should correctly link nodes with edges', () => {
|
||||
const trigger: WorkflowTrigger = {
|
||||
name: 'Company created',
|
||||
type: 'DATABASE_EVENT',
|
||||
settings: {
|
||||
eventName: 'company.created',
|
||||
outputSchema: {},
|
||||
},
|
||||
};
|
||||
const steps: WorkflowStep[] = [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
type: 'CODE',
|
||||
valid: true,
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: true },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
input: {
|
||||
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||
serverlessFunctionVersion: '1',
|
||||
serverlessFunctionInput: {},
|
||||
},
|
||||
outputSchema: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
type: 'CODE',
|
||||
valid: true,
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: true },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
input: {
|
||||
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||
serverlessFunctionVersion: '1',
|
||||
serverlessFunctionInput: {},
|
||||
},
|
||||
outputSchema: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = generateWorkflowDiagram({ trigger, steps });
|
||||
|
||||
expect(result.edges[0].source).toEqual(result.nodes[0].id);
|
||||
expect(result.edges[0].target).toEqual(result.nodes[1].id);
|
||||
|
||||
expect(result.edges[1].source).toEqual(result.nodes[1].id);
|
||||
expect(result.edges[1].target).toEqual(result.nodes[2].id);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,109 @@
|
||||
import { getWorkflowVersionDiagram } from '../getWorkflowVersionDiagram';
|
||||
|
||||
describe('getWorkflowVersionDiagram', () => {
|
||||
it('returns an empty diagram if the provided workflow version', () => {
|
||||
const result = getWorkflowVersionDiagram(undefined);
|
||||
|
||||
expect(result).toEqual({ nodes: [], edges: [] });
|
||||
});
|
||||
|
||||
it('returns a diagram with an empty-trigger node if the provided workflow version has no trigger', () => {
|
||||
const result = getWorkflowVersionDiagram({
|
||||
__typename: 'WorkflowVersion',
|
||||
status: 'ACTIVE',
|
||||
createdAt: '',
|
||||
id: '1',
|
||||
name: '',
|
||||
steps: [],
|
||||
trigger: null,
|
||||
updatedAt: '',
|
||||
workflowId: '',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
nodes: [
|
||||
{
|
||||
data: {},
|
||||
id: 'trigger',
|
||||
position: { x: 0, y: 0 },
|
||||
type: 'empty-trigger',
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a diagram with an empty-trigger node if the provided workflow version has no steps', () => {
|
||||
const result = getWorkflowVersionDiagram({
|
||||
__typename: 'WorkflowVersion',
|
||||
status: 'ACTIVE',
|
||||
createdAt: '',
|
||||
id: '1',
|
||||
name: '',
|
||||
steps: null,
|
||||
trigger: {
|
||||
name: 'Company created',
|
||||
settings: { eventName: 'company.created', outputSchema: {} },
|
||||
type: 'DATABASE_EVENT',
|
||||
},
|
||||
updatedAt: '',
|
||||
workflowId: '',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
nodes: [
|
||||
{
|
||||
data: {
|
||||
name: 'Company created',
|
||||
nodeType: 'trigger',
|
||||
triggerType: 'DATABASE_EVENT',
|
||||
},
|
||||
id: 'trigger',
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the diagram for the last version', () => {
|
||||
const result = getWorkflowVersionDiagram({
|
||||
__typename: 'WorkflowVersion',
|
||||
status: 'ACTIVE',
|
||||
createdAt: '',
|
||||
id: '1',
|
||||
name: '',
|
||||
steps: [
|
||||
{
|
||||
id: 'step-1',
|
||||
name: '',
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: true },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
input: {
|
||||
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
|
||||
serverlessFunctionVersion: '1',
|
||||
serverlessFunctionInput: {},
|
||||
},
|
||||
outputSchema: {},
|
||||
},
|
||||
type: 'CODE',
|
||||
valid: true,
|
||||
},
|
||||
],
|
||||
trigger: {
|
||||
name: 'Company created',
|
||||
settings: { eventName: 'company.created', outputSchema: {} },
|
||||
type: 'DATABASE_EVENT',
|
||||
},
|
||||
updatedAt: '',
|
||||
workflowId: '',
|
||||
});
|
||||
|
||||
// Corresponds to the trigger + 1 step
|
||||
expect(result.nodes).toHaveLength(2);
|
||||
expect(result.edges).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,72 @@
|
||||
import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
import { mergeWorkflowDiagrams } from '../mergeWorkflowDiagrams';
|
||||
|
||||
it('Preserves the properties defined in the previous version but not in the next one', () => {
|
||||
const previousDiagram: WorkflowDiagram = {
|
||||
nodes: [
|
||||
{
|
||||
data: { nodeType: 'action', name: '', actionType: 'CODE' },
|
||||
id: '1',
|
||||
position: { x: 0, y: 0 },
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
const nextDiagram: WorkflowDiagram = {
|
||||
nodes: [
|
||||
{
|
||||
data: { nodeType: 'action', name: '', actionType: 'CODE' },
|
||||
id: '1',
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
|
||||
expect(mergeWorkflowDiagrams(previousDiagram, nextDiagram)).toEqual({
|
||||
nodes: [
|
||||
{
|
||||
data: { nodeType: 'action', name: '', actionType: 'CODE' },
|
||||
id: '1',
|
||||
position: { x: 0, y: 0 },
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('Replaces duplicated properties with the next value', () => {
|
||||
const previousDiagram: WorkflowDiagram = {
|
||||
nodes: [
|
||||
{
|
||||
data: { nodeType: 'action', name: '', actionType: 'CODE' },
|
||||
id: '1',
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
const nextDiagram: WorkflowDiagram = {
|
||||
nodes: [
|
||||
{
|
||||
data: { nodeType: 'action', name: '2', actionType: 'CODE' },
|
||||
id: '1',
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
|
||||
expect(mergeWorkflowDiagrams(previousDiagram, nextDiagram)).toEqual({
|
||||
nodes: [
|
||||
{
|
||||
data: { nodeType: 'action', name: '2', actionType: 'CODE' },
|
||||
id: '1',
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,47 @@
|
||||
import {
|
||||
WorkflowDiagram,
|
||||
WorkflowDiagramEdge,
|
||||
WorkflowDiagramNode,
|
||||
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
import { MarkerType } from '@xyflow/react';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
export const addCreateStepNodes = ({ nodes, edges }: WorkflowDiagram) => {
|
||||
const nodesWithoutTargets = nodes.filter((node) =>
|
||||
edges.every((edge) => edge.source !== node.id),
|
||||
);
|
||||
|
||||
const updatedNodes: Array<WorkflowDiagramNode> = nodes.slice();
|
||||
const updatedEdges: Array<WorkflowDiagramEdge> = edges.slice();
|
||||
|
||||
for (const node of nodesWithoutTargets) {
|
||||
const newCreateStepNode: WorkflowDiagramNode = {
|
||||
// FIXME: We need a stable id for create step nodes to be able to preserve their selected status.
|
||||
// FIXME: In the future, we'll have conditions and loops. We'll have to set an id to each branch so we can have this stable id.
|
||||
id: 'branch-1__create-step',
|
||||
type: 'create-step',
|
||||
data: {
|
||||
nodeType: 'create-step',
|
||||
parentNodeId: node.id,
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
};
|
||||
|
||||
updatedNodes.push(newCreateStepNode);
|
||||
|
||||
updatedEdges.push({
|
||||
id: v4(),
|
||||
source: node.id,
|
||||
target: newCreateStepNode.id,
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
},
|
||||
deletable: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: updatedNodes,
|
||||
edges: updatedEdges,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,126 @@
|
||||
import { WorkflowStep, WorkflowTrigger } from '@/workflow/types/Workflow';
|
||||
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
|
||||
import { splitWorkflowTriggerEventName } from '@/workflow/utils/splitWorkflowTriggerEventName';
|
||||
import {
|
||||
WorkflowDiagram,
|
||||
WorkflowDiagramEdge,
|
||||
WorkflowDiagramNode,
|
||||
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
|
||||
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
|
||||
import { MarkerType } from '@xyflow/react';
|
||||
import { isDefined } from 'twenty-ui';
|
||||
import { v4 } from 'uuid';
|
||||
import { capitalize } from '~/utils/string/capitalize';
|
||||
|
||||
export const generateWorkflowDiagram = ({
|
||||
trigger,
|
||||
steps,
|
||||
}: {
|
||||
trigger: WorkflowTrigger | undefined;
|
||||
steps: Array<WorkflowStep>;
|
||||
}): WorkflowDiagram => {
|
||||
const nodes: Array<WorkflowDiagramNode> = [];
|
||||
const edges: Array<WorkflowDiagramEdge> = [];
|
||||
|
||||
// Helper function to generate nodes and edges recursively
|
||||
const processNode = (
|
||||
step: WorkflowStep,
|
||||
parentNodeId: string,
|
||||
xPos: number,
|
||||
yPos: number,
|
||||
) => {
|
||||
const nodeId = step.id;
|
||||
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
data: {
|
||||
nodeType: 'action',
|
||||
actionType: step.type,
|
||||
name: step.name,
|
||||
},
|
||||
position: {
|
||||
x: xPos,
|
||||
y: yPos,
|
||||
},
|
||||
});
|
||||
|
||||
// Create an edge from the parent node to this node
|
||||
edges.push({
|
||||
id: v4(),
|
||||
source: parentNodeId,
|
||||
target: nodeId,
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
},
|
||||
deletable: false,
|
||||
selectable: false,
|
||||
});
|
||||
|
||||
return nodeId;
|
||||
};
|
||||
|
||||
// Start with the trigger node
|
||||
const triggerNodeId = TRIGGER_STEP_ID;
|
||||
|
||||
if (isDefined(trigger)) {
|
||||
let triggerLabel: string;
|
||||
|
||||
switch (trigger.type) {
|
||||
case 'MANUAL': {
|
||||
triggerLabel = 'Manual Trigger';
|
||||
|
||||
break;
|
||||
}
|
||||
case 'DATABASE_EVENT': {
|
||||
const triggerEvent = splitWorkflowTriggerEventName(
|
||||
trigger.settings.eventName,
|
||||
);
|
||||
|
||||
triggerLabel = `${capitalize(triggerEvent.objectType)} is ${capitalize(triggerEvent.event)}`;
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
return assertUnreachable(
|
||||
trigger,
|
||||
`Expected the trigger "${JSON.stringify(trigger)}" to be supported.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
id: triggerNodeId,
|
||||
data: {
|
||||
nodeType: 'trigger',
|
||||
triggerType: trigger.type,
|
||||
name: isDefined(trigger.name) ? trigger.name : triggerLabel,
|
||||
},
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
nodes.push({
|
||||
id: triggerNodeId,
|
||||
type: 'empty-trigger',
|
||||
data: {} as any,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let lastStepId = triggerNodeId;
|
||||
|
||||
for (const step of steps) {
|
||||
lastStepId = processNode(step, lastStepId, 150, 100);
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
import Dagre from '@dagrejs/dagre';
|
||||
|
||||
export const getOrganizedDiagram = (
|
||||
diagram: WorkflowDiagram,
|
||||
): WorkflowDiagram => {
|
||||
const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
||||
graph.setGraph({ rankdir: 'TB' });
|
||||
|
||||
diagram.edges.forEach((edge) => graph.setEdge(edge.source, edge.target));
|
||||
diagram.nodes.forEach((node) =>
|
||||
graph.setNode(node.id, {
|
||||
...node,
|
||||
width: node.measured?.width ?? 0,
|
||||
height: node.measured?.height ?? 0,
|
||||
}),
|
||||
);
|
||||
|
||||
Dagre.layout(graph);
|
||||
|
||||
return {
|
||||
nodes: diagram.nodes.map((node) => {
|
||||
const position = graph.node(node.id);
|
||||
// We are shifting the dagre node position (anchor=center center) to the top left
|
||||
// so it matches the React Flow node anchor point (top left).
|
||||
const x = position.x - (node.measured?.width ?? 0) / 2;
|
||||
const y = position.y - (node.measured?.height ?? 0) / 2;
|
||||
|
||||
return { ...node, position: { x, y } };
|
||||
}),
|
||||
edges: diagram.edges,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,22 @@
|
||||
import { WorkflowVersion } from '@/workflow/types/Workflow';
|
||||
import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
import { generateWorkflowDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowDiagram';
|
||||
import { isDefined } from 'twenty-ui';
|
||||
|
||||
const EMPTY_DIAGRAM: WorkflowDiagram = {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
};
|
||||
|
||||
export const getWorkflowVersionDiagram = (
|
||||
workflowVersion: WorkflowVersion | undefined,
|
||||
): WorkflowDiagram => {
|
||||
if (!isDefined(workflowVersion)) {
|
||||
return EMPTY_DIAGRAM;
|
||||
}
|
||||
|
||||
return generateWorkflowDiagram({
|
||||
trigger: workflowVersion.trigger ?? undefined,
|
||||
steps: workflowVersion.steps ?? [],
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
import {
|
||||
WorkflowDiagram,
|
||||
WorkflowDiagramNode,
|
||||
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
|
||||
const nodePropertiesToPreserve: Array<keyof WorkflowDiagramNode> = ['selected'];
|
||||
|
||||
export const mergeWorkflowDiagrams = (
|
||||
previousDiagram: WorkflowDiagram,
|
||||
nextDiagram: WorkflowDiagram,
|
||||
): WorkflowDiagram => {
|
||||
const lastNodes = nextDiagram.nodes.map((nextNode) => {
|
||||
const previousNode = previousDiagram.nodes.find(
|
||||
(previousNode) => previousNode.id === nextNode.id,
|
||||
);
|
||||
|
||||
const nodeWithPreservedProperties = nodePropertiesToPreserve.reduce(
|
||||
(nodeToSet, propertyToPreserve) => {
|
||||
return Object.assign(nodeToSet, {
|
||||
[propertyToPreserve]: previousNode?.[propertyToPreserve],
|
||||
});
|
||||
},
|
||||
{} as Partial<WorkflowDiagramNode>,
|
||||
);
|
||||
|
||||
return Object.assign(nodeWithPreservedProperties, nextNode);
|
||||
});
|
||||
|
||||
return {
|
||||
nodes: lastNodes,
|
||||
edges: nextDiagram.edges,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user