From 737eb31776b045f8499c936deb64f75ed6b22800 Mon Sep 17 00:00:00 2001 From: "kasia.oczkowska" Date: Wed, 22 May 2024 14:19:56 +0100 Subject: [PATCH] feat: introduce custom edges, auto layout improvements and node data updates --- .../src/components/EditorNew/Edge/Edge.jsx | 79 ++++++++ .../src/components/EditorNew/EditorNew.jsx | 186 +++++++++++++----- .../EditorNew/FlowStep/FlowStep.jsx | 37 ---- .../EditorNew/FlowStepNode/FlowStepNode.jsx | 68 +++++++ .../EditorNew/InvisibleNode/InvisibleNode.jsx | 30 +++ .../src/components/EditorNew/useAutoLayout.js | 79 ++++---- 6 files changed, 359 insertions(+), 120 deletions(-) create mode 100644 packages/web/src/components/EditorNew/Edge/Edge.jsx delete mode 100644 packages/web/src/components/EditorNew/FlowStep/FlowStep.jsx create mode 100644 packages/web/src/components/EditorNew/FlowStepNode/FlowStepNode.jsx create mode 100644 packages/web/src/components/EditorNew/InvisibleNode/InvisibleNode.jsx diff --git a/packages/web/src/components/EditorNew/Edge/Edge.jsx b/packages/web/src/components/EditorNew/Edge/Edge.jsx new file mode 100644 index 00000000..980e80db --- /dev/null +++ b/packages/web/src/components/EditorNew/Edge/Edge.jsx @@ -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 ( + <> + + 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} + > + + + + + ); +} + +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, +}; diff --git a/packages/web/src/components/EditorNew/EditorNew.jsx b/packages/web/src/components/EditorNew/EditorNew.jsx index 87a434a1..31220a31 100644 --- a/packages/web/src/components/EditorNew/EditorNew.jsx +++ b/packages/web/src/components/EditorNew/EditorNew.jsx @@ -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 ( { 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} - > + /> ); }; diff --git a/packages/web/src/components/EditorNew/FlowStep/FlowStep.jsx b/packages/web/src/components/EditorNew/FlowStep/FlowStep.jsx deleted file mode 100644 index 2f717a53..00000000 --- a/packages/web/src/components/EditorNew/FlowStep/FlowStep.jsx +++ /dev/null @@ -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 ( - - - - - - ); -} - -export default FlowStep; diff --git a/packages/web/src/components/EditorNew/FlowStepNode/FlowStepNode.jsx b/packages/web/src/components/EditorNew/FlowStepNode/FlowStepNode.jsx new file mode 100644 index 00000000..2b173dcb --- /dev/null +++ b/packages/web/src/components/EditorNew/FlowStepNode/FlowStepNode.jsx @@ -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 ( + + + + + + ); +} + +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; diff --git a/packages/web/src/components/EditorNew/InvisibleNode/InvisibleNode.jsx b/packages/web/src/components/EditorNew/InvisibleNode/InvisibleNode.jsx new file mode 100644 index 00000000..f9db694f --- /dev/null +++ b/packages/web/src/components/EditorNew/InvisibleNode/InvisibleNode.jsx @@ -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 ( + + + Invisible node + + + ); +} + +export default InvisibleNode; diff --git a/packages/web/src/components/EditorNew/useAutoLayout.js b/packages/web/src/components/EditorNew/useAutoLayout.js index b6c603a3..d97bab87 100644 --- a/packages/web/src/components/EditorNew/useAutoLayout.js +++ b/packages/web/src/components/EditorNew/useAutoLayout.js @@ -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(() => {