feat: introduce custom edges, auto layout improvements and node data updates

This commit is contained in:
kasia.oczkowska
2024-05-22 14:19:56 +01:00
committed by Ali BARIN
parent d6abf283bc
commit 737eb31776
6 changed files with 359 additions and 120 deletions

View 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,
};

View File

@@ -5,12 +5,22 @@ import { FlowPropType } from 'propTypes/propTypes';
import ReactFlow, { useNodesState, useEdgesState, addEdge } from 'reactflow';
import 'reactflow/dist/style.css';
import { Stack } from '@mui/material';
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 [triggerStep] = flow.steps;
@@ -64,53 +74,138 @@ const EditorNew = ({ flow }) => {
[flow.id, updateStep, queryClient],
);
const generateEdges = useCallback((flow, prevEdges) => {
const newEdges =
flow.steps
.map((step, i) => {
const sourceId = step.id;
const targetId = flow.steps[i + 1]?.id;
const edge = prevEdges?.find(
(edge) => edge.id === generateEdgeId(sourceId, targetId),
);
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) || [];
const lastStep = flow.steps[flow.steps.length - 1];
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,
type: 'flowStep',
position: {
x: node ? node.position.x : 0,
y: node ? node.position.y : 0,
},
data: {
step,
index: index,
flowId: flow.id,
collapsed: currentStepId !== step.id,
openNextStep: openNextStep(flow.steps[index + 1]),
onOpen: () => setCurrentStepId(step.id),
onClose: () => setCurrentStepId(null),
onChange: onStepChange,
layouted: !!node,
},
};
});
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) => ({
...node,
data: { ...node.data, collapsed: currentStepId !== node.data.step.id },
})),
nodes.map((node) => {
if (node.type === 'flowStep') {
return {
...node,
data: {
...node.data,
collapsed: currentStepId !== node.data.step.id,
},
};
}
return node;
}),
);
}, [currentStepId]);
useEffect(() => {
const getInitialNodes = () => {
return flow?.steps?.map((step, index) => ({
id: step.id,
position: { x: 0, y: 0 },
data: {
step,
index: index,
flowId: flow.id,
collapsed: currentStepId !== step.id,
openNextStep: openNextStep(flow?.steps[index + 1]),
onOpen: () => setCurrentStepId(step.id),
onClose: () => setCurrentStepId(null),
onChange: onStepChange,
},
type: 'flowStep',
}));
};
if (flow.steps.length + 1 !== nodes.length) {
const newNodes = generateNodes(flow, nodes);
const newEdges = generateEdges(flow, edges);
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 nodes = getInitialNodes();
const edges = getInitialEdges();
setNodes(nodes);
setEdges(edges);
}, []);
setNodes(newNodes);
setEdges(newEdges);
} else {
updateNodesData(flow.steps);
}
}, [flow]);
return (
<Stack
@@ -129,6 +224,7 @@ const EditorNew = ({ flow }) => {
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
panOnScroll
panOnScrollMode="vertical"
panOnDrag={false}
@@ -136,7 +232,7 @@ const EditorNew = ({ flow }) => {
zoomOnPinch={false}
zoomOnDoubleClick={false}
panActivationKeyCode={null}
></ReactFlow>
/>
</Stack>
);
};

View File

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

View File

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

View File

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

View File

@@ -1,56 +1,59 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect } from 'react';
import Dagre from '@dagrejs/dagre';
import { usePrevious } from 'hooks/usePrevious';
import { isEqual } from 'lodash';
import { useNodesInitialized, useNodes, useReactFlow } from 'reactflow';
const getLayoutedElements = (nodes, edges) => {
const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
graph.setGraph({
rankdir: 'TB',
marginy: 60,
marginx: 60,
universalSep: true,
ranksep: 64,
});
edges.forEach((edge) => graph.setEdge(edge.source, edge.target));
nodes.forEach((node) => graph.setNode(node.id, node));
Dagre.layout(graph);
return {
nodes: nodes.map((node) => {
const { x, y, width, height } = graph.node(node.id);
return {
...node,
position: { x: x - width / 2, y: y - height / 2 },
};
}),
edges,
};
};
export const useAutoLayout = () => {
const nodes = useNodes();
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({
rankdir: 'TB',
marginy: 60,
marginx: 60,
universalSep: true,
});
edges.forEach((edge) => graph.setEdge(edge.source, edge.target));
nodes.forEach((node) => graph.setNode(node.id, node));
Dagre.layout(graph);
return {
nodes: nodes.map((node) => {
const { x, y, width, height } = graph.node(node.id);
return {
...node,
position: { x: x - width / 2, y: y - height / 2 },
};
}),
edges,
};
},
[graph],
);
const onLayout = useCallback(
(nodes, edges) => {
const layouted = getLayoutedElements(nodes, edges);
const layoutedElements = getLayoutedElements(nodes, edges);
setNodes([...layouted.nodes]);
setEdges([...layouted.edges]);
setNodes([
...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(() => {