feat: introduce custom edges, auto layout improvements and node data updates
This commit is contained in:

committed by
Ali BARIN

parent
d6abf283bc
commit
737eb31776
79
packages/web/src/components/EditorNew/Edge/Edge.jsx
Normal file
79
packages/web/src/components/EditorNew/Edge/Edge.jsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { EdgeLabelRenderer, getStraightPath } from 'reactflow';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import { useMutation } from '@apollo/client';
|
||||||
|
import { CREATE_STEP } from 'graphql/mutations/create-step';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
export default function Edge({
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
source,
|
||||||
|
data: { flowId, setCurrentStepId, flowActive, layouted },
|
||||||
|
}) {
|
||||||
|
const [createStep, { loading: creationInProgress }] =
|
||||||
|
useMutation(CREATE_STEP);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [edgePath, labelX, labelY] = getStraightPath({
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
});
|
||||||
|
|
||||||
|
const addStep = async (previousStepId) => {
|
||||||
|
const mutationInput = {
|
||||||
|
previousStep: {
|
||||||
|
id: previousStepId,
|
||||||
|
},
|
||||||
|
flow: {
|
||||||
|
id: flowId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const createdStep = await createStep({
|
||||||
|
variables: { input: mutationInput },
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdStepId = createdStep.data.createStep.id;
|
||||||
|
setCurrentStepId(createdStepId);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['flows', flowId] });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EdgeLabelRenderer>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => addStep(source)}
|
||||||
|
color="primary"
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||||
|
pointerEvents: 'all',
|
||||||
|
visibility: layouted ? 'visible' : 'hidden',
|
||||||
|
}}
|
||||||
|
disabled={creationInProgress || flowActive}
|
||||||
|
>
|
||||||
|
<AddIcon />
|
||||||
|
</IconButton>
|
||||||
|
</EdgeLabelRenderer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Edge.propTypes = {
|
||||||
|
sourceX: PropTypes.number.isRequired,
|
||||||
|
sourceY: PropTypes.number.isRequired,
|
||||||
|
targetX: PropTypes.number.isRequired,
|
||||||
|
targetY: PropTypes.number.isRequired,
|
||||||
|
source: PropTypes.string.isRequired,
|
||||||
|
data: PropTypes.shape({
|
||||||
|
flowId: PropTypes.string.isRequired,
|
||||||
|
setCurrentStepId: PropTypes.func.isRequired,
|
||||||
|
flowActive: PropTypes.bool.isRequired,
|
||||||
|
layouted: PropTypes.bool,
|
||||||
|
}).isRequired,
|
||||||
|
};
|
@@ -5,12 +5,22 @@ import { FlowPropType } from 'propTypes/propTypes';
|
|||||||
import ReactFlow, { useNodesState, useEdgesState, addEdge } from 'reactflow';
|
import ReactFlow, { useNodesState, useEdgesState, addEdge } from 'reactflow';
|
||||||
import 'reactflow/dist/style.css';
|
import 'reactflow/dist/style.css';
|
||||||
import { Stack } from '@mui/material';
|
import { Stack } from '@mui/material';
|
||||||
|
|
||||||
import { UPDATE_STEP } from 'graphql/mutations/update-step';
|
import { UPDATE_STEP } from 'graphql/mutations/update-step';
|
||||||
import FlowStep from './FlowStep/FlowStep';
|
|
||||||
import { useAutoLayout } from './useAutoLayout';
|
|
||||||
|
|
||||||
const nodeTypes = { flowStep: FlowStep };
|
import { useAutoLayout } from './useAutoLayout';
|
||||||
|
import FlowStepNode from './FlowStepNode/FlowStepNode';
|
||||||
|
import Edge from './Edge/Edge';
|
||||||
|
import InvisibleNode from './InvisibleNode/InvisibleNode';
|
||||||
|
|
||||||
|
const nodeTypes = { flowStep: FlowStepNode, invisible: InvisibleNode };
|
||||||
|
|
||||||
|
const edgeTypes = {
|
||||||
|
addNodeEdge: Edge,
|
||||||
|
};
|
||||||
|
|
||||||
|
const INVISIBLE_NODE_ID = 'invisible-node';
|
||||||
|
|
||||||
|
const generateEdgeId = (sourceId, targetId) => `${sourceId}-${targetId}`;
|
||||||
|
|
||||||
const EditorNew = ({ flow }) => {
|
const EditorNew = ({ flow }) => {
|
||||||
const [triggerStep] = flow.steps;
|
const [triggerStep] = flow.steps;
|
||||||
@@ -64,53 +74,138 @@ const EditorNew = ({ flow }) => {
|
|||||||
[flow.id, updateStep, queryClient],
|
[flow.id, updateStep, queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const generateEdges = useCallback((flow, prevEdges) => {
|
||||||
setNodes(
|
const newEdges =
|
||||||
nodes.map((node) => ({
|
flow.steps
|
||||||
...node,
|
.map((step, i) => {
|
||||||
data: { ...node.data, collapsed: currentStepId !== node.data.step.id },
|
const sourceId = step.id;
|
||||||
})),
|
const targetId = flow.steps[i + 1]?.id;
|
||||||
|
const edge = prevEdges?.find(
|
||||||
|
(edge) => edge.id === generateEdgeId(sourceId, targetId),
|
||||||
);
|
);
|
||||||
}, [currentStepId]);
|
if (targetId) {
|
||||||
|
return {
|
||||||
|
id: generateEdgeId(sourceId, targetId),
|
||||||
|
source: sourceId,
|
||||||
|
target: targetId,
|
||||||
|
type: 'addNodeEdge',
|
||||||
|
data: {
|
||||||
|
flowId: flow.id,
|
||||||
|
flowActive: flow.active,
|
||||||
|
setCurrentStepId,
|
||||||
|
layouted: !!edge,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((edge) => !!edge) || [];
|
||||||
|
|
||||||
useEffect(() => {
|
const lastStep = flow.steps[flow.steps.length - 1];
|
||||||
const getInitialNodes = () => {
|
|
||||||
return flow?.steps?.map((step, index) => ({
|
return lastStep
|
||||||
|
? [
|
||||||
|
...newEdges,
|
||||||
|
{
|
||||||
|
id: generateEdgeId(lastStep.id, INVISIBLE_NODE_ID),
|
||||||
|
source: lastStep.id,
|
||||||
|
target: INVISIBLE_NODE_ID,
|
||||||
|
type: 'addNodeEdge',
|
||||||
|
data: {
|
||||||
|
flowId: flow.id,
|
||||||
|
flowActive: flow.active,
|
||||||
|
setCurrentStepId,
|
||||||
|
layouted: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: newEdges;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const generateNodes = useCallback(
|
||||||
|
(flow, prevNodes) => {
|
||||||
|
const newNodes = flow.steps.map((step, index) => {
|
||||||
|
const node = prevNodes?.find(({ id }) => id === step.id);
|
||||||
|
return {
|
||||||
id: step.id,
|
id: step.id,
|
||||||
position: { x: 0, y: 0 },
|
type: 'flowStep',
|
||||||
|
position: {
|
||||||
|
x: node ? node.position.x : 0,
|
||||||
|
y: node ? node.position.y : 0,
|
||||||
|
},
|
||||||
data: {
|
data: {
|
||||||
step,
|
step,
|
||||||
index: index,
|
index: index,
|
||||||
flowId: flow.id,
|
flowId: flow.id,
|
||||||
collapsed: currentStepId !== step.id,
|
collapsed: currentStepId !== step.id,
|
||||||
openNextStep: openNextStep(flow?.steps[index + 1]),
|
openNextStep: openNextStep(flow.steps[index + 1]),
|
||||||
onOpen: () => setCurrentStepId(step.id),
|
onOpen: () => setCurrentStepId(step.id),
|
||||||
onClose: () => setCurrentStepId(null),
|
onClose: () => setCurrentStepId(null),
|
||||||
onChange: onStepChange,
|
onChange: onStepChange,
|
||||||
|
layouted: !!node,
|
||||||
},
|
},
|
||||||
type: 'flowStep',
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const getInitialEdges = () => {
|
|
||||||
return flow?.steps?.map((step, i) => {
|
|
||||||
const sourceId = step.id;
|
|
||||||
const targetId = flow.steps[i + 1]?.id;
|
|
||||||
return {
|
|
||||||
id: i,
|
|
||||||
source: sourceId,
|
|
||||||
target: targetId,
|
|
||||||
animated: false,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const prevInvisibleNode = nodes.find((node) => node.type === 'invisible');
|
||||||
|
|
||||||
|
return [
|
||||||
|
...newNodes,
|
||||||
|
{
|
||||||
|
id: INVISIBLE_NODE_ID,
|
||||||
|
type: 'invisible',
|
||||||
|
position: {
|
||||||
|
x: prevInvisibleNode ? prevInvisibleNode.position.x : 0,
|
||||||
|
y: prevInvisibleNode ? prevInvisibleNode.position.y : 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
[currentStepId, nodes, onStepChange, openNextStep],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateNodesData = useCallback(
|
||||||
|
(steps) => {
|
||||||
|
setNodes((nodes) =>
|
||||||
|
nodes.map((node) => {
|
||||||
|
const step = steps.find((step) => step.id === node.id);
|
||||||
|
if (step) {
|
||||||
|
return { ...node, data: { ...node.data, step: { ...step } } };
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setNodes],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNodes(
|
||||||
|
nodes.map((node) => {
|
||||||
|
if (node.type === 'flowStep') {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...node.data,
|
||||||
|
collapsed: currentStepId !== node.data.step.id,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [currentStepId]);
|
||||||
|
|
||||||
const nodes = getInitialNodes();
|
useEffect(() => {
|
||||||
const edges = getInitialEdges();
|
if (flow.steps.length + 1 !== nodes.length) {
|
||||||
|
const newNodes = generateNodes(flow, nodes);
|
||||||
|
const newEdges = generateEdges(flow, edges);
|
||||||
|
|
||||||
setNodes(nodes);
|
setNodes(newNodes);
|
||||||
setEdges(edges);
|
setEdges(newEdges);
|
||||||
}, []);
|
} else {
|
||||||
|
updateNodesData(flow.steps);
|
||||||
|
}
|
||||||
|
}, [flow]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
@@ -129,6 +224,7 @@ const EditorNew = ({ flow }) => {
|
|||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
|
edgeTypes={edgeTypes}
|
||||||
panOnScroll
|
panOnScroll
|
||||||
panOnScrollMode="vertical"
|
panOnScrollMode="vertical"
|
||||||
panOnDrag={false}
|
panOnDrag={false}
|
||||||
@@ -136,7 +232,7 @@ const EditorNew = ({ flow }) => {
|
|||||||
zoomOnPinch={false}
|
zoomOnPinch={false}
|
||||||
zoomOnDoubleClick={false}
|
zoomOnDoubleClick={false}
|
||||||
panActivationKeyCode={null}
|
panActivationKeyCode={null}
|
||||||
></ReactFlow>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@@ -1,37 +0,0 @@
|
|||||||
import { Handle, Position } from 'reactflow';
|
|
||||||
import FlowStepBase from 'components/FlowStep';
|
|
||||||
import { Box } from '@mui/material';
|
|
||||||
|
|
||||||
function FlowStep({
|
|
||||||
data: {
|
|
||||||
step,
|
|
||||||
index,
|
|
||||||
flowId,
|
|
||||||
collapsed,
|
|
||||||
openNextStep,
|
|
||||||
onOpen,
|
|
||||||
onClose,
|
|
||||||
onChange,
|
|
||||||
currentStepId,
|
|
||||||
},
|
|
||||||
selected,
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Box maxWidth={900} width="100vw" className="nodrag">
|
|
||||||
<Handle type="target" position={Position.Top} />
|
|
||||||
<FlowStepBase
|
|
||||||
step={step}
|
|
||||||
index={index + 1}
|
|
||||||
collapsed={collapsed}
|
|
||||||
onOpen={onOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
onChange={onChange}
|
|
||||||
flowId={flowId}
|
|
||||||
onContinue={openNextStep}
|
|
||||||
/>
|
|
||||||
<Handle type="source" position={Position.Bottom} />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default FlowStep;
|
|
@@ -0,0 +1,68 @@
|
|||||||
|
import { Handle, Position } from 'reactflow';
|
||||||
|
import { Box } from '@mui/material';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import FlowStep from 'components/FlowStep';
|
||||||
|
import { StepPropType } from 'propTypes/propTypes';
|
||||||
|
|
||||||
|
function FlowStepNode({
|
||||||
|
data: {
|
||||||
|
step,
|
||||||
|
index,
|
||||||
|
flowId,
|
||||||
|
collapsed,
|
||||||
|
openNextStep,
|
||||||
|
onOpen,
|
||||||
|
onClose,
|
||||||
|
onChange,
|
||||||
|
layouted,
|
||||||
|
},
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
maxWidth={900}
|
||||||
|
width="100vw"
|
||||||
|
className="nodrag"
|
||||||
|
sx={{ visibility: layouted ? 'visible' : 'hidden' }}
|
||||||
|
>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Top}
|
||||||
|
isConnectable={false}
|
||||||
|
style={{ visibility: 'hidden' }}
|
||||||
|
/>
|
||||||
|
<FlowStep
|
||||||
|
step={step}
|
||||||
|
index={index + 1}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onOpen={onOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
onChange={onChange}
|
||||||
|
flowId={flowId}
|
||||||
|
onContinue={openNextStep}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
isConnectable={false}
|
||||||
|
style={{ visibility: 'hidden' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
FlowStepNode.propTypes = {
|
||||||
|
data: PropTypes.shape({
|
||||||
|
step: StepPropType.isRequired,
|
||||||
|
index: PropTypes.number.isRequired,
|
||||||
|
flowId: PropTypes.string.isRequired,
|
||||||
|
collapsed: PropTypes.bool.isRequired,
|
||||||
|
openNextStep: PropTypes.func.isRequired,
|
||||||
|
onOpen: PropTypes.func.isRequired,
|
||||||
|
onClose: PropTypes.func.isRequired,
|
||||||
|
onChange: PropTypes.func.isRequired,
|
||||||
|
layouted: PropTypes.bool.isRequired,
|
||||||
|
}).isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FlowStepNode;
|
@@ -0,0 +1,30 @@
|
|||||||
|
import { Handle, Position } from 'reactflow';
|
||||||
|
import { Box } from '@mui/material';
|
||||||
|
|
||||||
|
// This node is used for adding an edge with add node button after the last flow step node
|
||||||
|
function InvisibleNode() {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
maxWidth={900}
|
||||||
|
width="100vw"
|
||||||
|
className="nodrag"
|
||||||
|
sx={{ visibility: 'hidden' }}
|
||||||
|
>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Top}
|
||||||
|
isConnectable={false}
|
||||||
|
style={{ visibility: 'hidden' }}
|
||||||
|
/>
|
||||||
|
Invisible node
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
isConnectable={false}
|
||||||
|
style={{ visibility: 'hidden' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InvisibleNode;
|
@@ -1,29 +1,18 @@
|
|||||||
import { useCallback, useEffect, useMemo } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import Dagre from '@dagrejs/dagre';
|
import Dagre from '@dagrejs/dagre';
|
||||||
import { usePrevious } from 'hooks/usePrevious';
|
import { usePrevious } from 'hooks/usePrevious';
|
||||||
import { isEqual } from 'lodash';
|
import { isEqual } from 'lodash';
|
||||||
import { useNodesInitialized, useNodes, useReactFlow } from 'reactflow';
|
import { useNodesInitialized, useNodes, useReactFlow } from 'reactflow';
|
||||||
|
|
||||||
export const useAutoLayout = () => {
|
const getLayoutedElements = (nodes, edges) => {
|
||||||
const nodes = useNodes();
|
const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
||||||
const prevNodes = usePrevious(nodes);
|
|
||||||
const nodesInitialized = useNodesInitialized();
|
|
||||||
const { getEdges, setNodes, setEdges } = useReactFlow();
|
|
||||||
|
|
||||||
const graph = useMemo(
|
|
||||||
() => new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const getLayoutedElements = useCallback(
|
|
||||||
(nodes, edges) => {
|
|
||||||
graph.setGraph({
|
graph.setGraph({
|
||||||
rankdir: 'TB',
|
rankdir: 'TB',
|
||||||
marginy: 60,
|
marginy: 60,
|
||||||
marginx: 60,
|
marginx: 60,
|
||||||
universalSep: true,
|
universalSep: true,
|
||||||
|
ranksep: 64,
|
||||||
});
|
});
|
||||||
|
|
||||||
edges.forEach((edge) => graph.setEdge(edge.source, edge.target));
|
edges.forEach((edge) => graph.setEdge(edge.source, edge.target));
|
||||||
nodes.forEach((node) => graph.setNode(node.id, node));
|
nodes.forEach((node) => graph.setNode(node.id, node));
|
||||||
|
|
||||||
@@ -39,18 +28,32 @@ export const useAutoLayout = () => {
|
|||||||
}),
|
}),
|
||||||
edges,
|
edges,
|
||||||
};
|
};
|
||||||
},
|
};
|
||||||
[graph],
|
|
||||||
);
|
export const useAutoLayout = () => {
|
||||||
|
const nodes = useNodes();
|
||||||
|
const prevNodes = usePrevious(nodes);
|
||||||
|
const nodesInitialized = useNodesInitialized();
|
||||||
|
const { getEdges, setNodes, setEdges } = useReactFlow();
|
||||||
|
|
||||||
const onLayout = useCallback(
|
const onLayout = useCallback(
|
||||||
(nodes, edges) => {
|
(nodes, edges) => {
|
||||||
const layouted = getLayoutedElements(nodes, edges);
|
const layoutedElements = getLayoutedElements(nodes, edges);
|
||||||
|
|
||||||
setNodes([...layouted.nodes]);
|
setNodes([
|
||||||
setEdges([...layouted.edges]);
|
...layoutedElements.nodes.map((node) => ({
|
||||||
|
...node,
|
||||||
|
data: { ...node.data, layouted: true },
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
setEdges([
|
||||||
|
...layoutedElements.edges.map((edge) => ({
|
||||||
|
...edge,
|
||||||
|
data: { ...edge.data, layouted: true },
|
||||||
|
})),
|
||||||
|
]);
|
||||||
},
|
},
|
||||||
[setEdges, setNodes, getLayoutedElements],
|
[setEdges, setNodes],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
Reference in New Issue
Block a user