diff --git a/packages/web/package.json b/packages/web/package.json index cd34f29c..ab2349bf 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -18,6 +18,7 @@ "@testing-library/jest-dom": "^5.11.4", "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", + "@xyflow/react": "^12.4.4", "axios": "^1.6.0", "clipboard-copy": "^4.0.1", "compare-versions": "^4.1.3", @@ -33,7 +34,6 @@ "react-router-dom": "^6.0.2", "react-scripts": "5.0.0", "react-window": "^1.8.9", - "reactflow": "^11.11.2", "slate": "^0.94.1", "slate-history": "^0.93.0", "slate-react": "^0.94.2", diff --git a/packages/web/src/components/ChooseAppAndEventSubstep/index.jsx b/packages/web/src/components/ChooseAppAndEventSubstep/index.jsx index 64d1eabc..2932c8b3 100644 --- a/packages/web/src/components/ChooseAppAndEventSubstep/index.jsx +++ b/packages/web/src/components/ChooseAppAndEventSubstep/index.jsx @@ -18,6 +18,8 @@ import { StepPropType, SubstepPropType } from 'propTypes/propTypes'; import useTriggers from 'hooks/useTriggers'; import useActions from 'hooks/useActions'; +const useNewFlowEditor = process.env.REACT_APP_USE_NEW_FLOW_EDITOR === 'true'; + const optionGenerator = (app) => ({ label: app.name, value: app.key, @@ -145,7 +147,11 @@ function ChooseAppAndEventSubstep(props) { title={name} valid={valid} /> - + - + onAddStep(source)} + onClick={() => onStepAdd(source)} color="primary" sx={{ position: 'absolute', @@ -36,7 +36,7 @@ export default function Edge({ pointerEvents: 'all', visibility: laidOut ? 'visible' : 'hidden', }} - disabled={stepCreationInProgress || flowActive} + disabled={isCreateStepPending || flowActive} > diff --git a/packages/web/src/components/EditorNew/EditorNew.jsx b/packages/web/src/components/EditorNew/EditorNew.jsx index 2ee938cd..8403f1d4 100644 --- a/packages/web/src/components/EditorNew/EditorNew.jsx +++ b/packages/web/src/components/EditorNew/EditorNew.jsx @@ -1,26 +1,28 @@ -import { useEffect, useCallback, createContext, useRef, useState } from 'react'; +import { + useEffect, + useCallback, + createContext, + useRef, + useState, + useMemo, +} from 'react'; import { useQueryClient } from '@tanstack/react-query'; -import { FlowPropType } from 'propTypes/propTypes'; -import ReactFlow, { useNodesState, useEdgesState } from 'reactflow'; -import 'reactflow/dist/style.css'; +import { ReactFlow, useEdgesState, applyNodeChanges } from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; +import { v4 as uuidv4 } from 'uuid'; import { debounce } from 'lodash'; -import useCreateStep from 'hooks/useCreateStep'; import useUpdateStep from 'hooks/useUpdateStep'; +import useCreateStep from 'hooks/useCreateStep'; +import { FlowPropType } from 'propTypes/propTypes'; -import { useAutoLayout } from './useAutoLayout'; import { useScrollBoundaries } from './useScrollBoundaries'; import FlowStepNode from './FlowStepNode/FlowStepNode'; import Edge from './Edge/Edge'; import InvisibleNode from './InvisibleNode/InvisibleNode'; -import { EditorWrapper } from './style'; -import { - generateEdgeId, - generateInitialEdges, - generateInitialNodes, - updatedCollapsedNodes, -} from './utils'; +import { getLaidOutElements, updatedCollapsedNodes } from './utils'; import { EDGE_TYPES, INVISIBLE_NODE_ID, NODE_TYPES } from './constants'; +import { EditorWrapper } from './style'; export const EdgesContext = createContext(); export const NodesContext = createContext(); @@ -38,22 +40,122 @@ const EditorNew = ({ flow }) => { const { mutateAsync: updateStep } = useUpdateStep(); const queryClient = useQueryClient(); + const [nodes, setNodes] = useState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState(); + const [containerHeight, setContainerHeight] = useState(null); + const containerRef = useRef(null); const { mutateAsync: createStep, isPending: isCreateStepPending } = useCreateStep(flow?.id); - const [nodes, setNodes, onNodesChange] = useNodesState( - generateInitialNodes(flow), - ); - const [edges, setEdges, onEdgesChange] = useEdgesState( - generateInitialEdges(flow), - ); - const [containerHeight, setContainerHeight] = useState(null); - - useAutoLayout(); useScrollBoundaries(containerHeight); - const createdStepIdRef = useRef(null); - const containerRef = useRef(null); + const onStepDelete = useCallback( + (nodeId) => { + const prevEdge = edges.find((edge) => edge.target === nodeId); + const edgeToDelete = edges.find((edge) => edge.source === nodeId); + + const newEdges = edges + .map((edge) => { + if ( + edge.id === edgeToDelete?.id || + (edge.id === prevEdge?.id && !edgeToDelete) + ) { + return null; + } else if (edge.id === prevEdge?.id) { + return { + ...prevEdge, + target: edgeToDelete?.target, + }; + } + return edge; + }) + .filter((edge) => !!edge); + + setNodes((nodes) => { + const newNodes = nodes.filter((node) => node.id !== nodeId); + const laidOutElements = getLaidOutElements(newNodes, newEdges); + setEdges([...laidOutElements.edges]); + return [...laidOutElements.nodes]; + }); + }, + [edges, setEdges], + ); + + const onStepAdd = useCallback( + async (previousStepId) => { + const { data: createdStep } = await createStep({ previousStepId }); + + setNodes((nodes) => { + const newNode = { + id: createdStep.id, + type: NODE_TYPES.FLOW_STEP, + position: { + x: 0, + y: 0, + }, + data: { + laidOut: false, + }, + }; + + const newNodes = nodes.flatMap((node) => { + if (node.id === previousStepId) { + return [node, newNode]; + } + return node; + }); + return updatedCollapsedNodes(newNodes, createdStep.id); + }); + + setEdges((edges) => { + const newEdges = edges + .map((edge) => { + if (edge.source === previousStepId) { + const previousTarget = edge.target; + return [ + { ...edge, target: createdStep.id }, + { + id: uuidv4(), + source: createdStep.id, + target: previousTarget, + type: EDGE_TYPES.ADD_NODE_EDGE, + data: { + laidOut: false, + }, + }, + ]; + } + return edge; + }) + .flat(); + + return newEdges; + }); + }, + [createStep, setEdges], + ); + + const onStepAddDebounced = useMemo( + () => debounce(onStepAdd, 300), + [onStepAdd], + ); + + const onNodesChange = useCallback( + (changes) => { + setNodes((oldNodes) => { + const newNodes = applyNodeChanges(changes, oldNodes); + + if (changes?.some((change) => change.type === 'dimensions')) { + const laidOutElements = getLaidOutElements(newNodes, edges); + setEdges([...laidOutElements.edges]); + return [...laidOutElements.nodes]; + } else { + return newNodes; + } + }); + }, + [setNodes, setEdges, edges], + ); const openNextStep = useCallback( (currentStepId) => { @@ -108,111 +210,54 @@ const EditorNew = ({ flow }) => { [updateStep, queryClient], ); - const onAddStep = useCallback( - debounce(async (previousStepId) => { - const { data: createdStep } = await createStep({ previousStepId }); - createdStepIdRef.current = createdStep.id; - }, 300), - [createStep], - ); + useEffect(function initiateNodesAndEdges() { + const newNodes = flow?.steps.map((step, index) => { + return { + id: step.id, + type: NODE_TYPES.FLOW_STEP, + position: { + x: 0, + y: 0, + }, + zIndex: index !== 0 ? 0 : 1, + data: { + collapsed: index !== 0, + laidOut: false, + }, + }; + }); - useEffect(() => { - if (flow.steps.length + 1 !== nodes.length) { - setNodes((nodes) => { - const newNodes = flow.steps.map((step) => { - const createdStepId = createdStepIdRef.current; - const prevNode = nodes.find(({ id }) => id === step.id); - if (prevNode) { - return { - ...prevNode, - zIndex: createdStepId ? 0 : prevNode.zIndex, - data: { - ...prevNode.data, - collapsed: createdStepId ? true : prevNode.data.collapsed, - }, - }; - } else { - return { - id: step.id, - type: NODE_TYPES.FLOW_STEP, - position: { - x: 0, - y: 0, - }, - zIndex: 1, - data: { - collapsed: false, - laidOut: false, - }, - }; - } - }); + newNodes.push({ + id: INVISIBLE_NODE_ID, + type: NODE_TYPES.INVISIBLE, + position: { + x: 0, + y: 0, + }, + }); - const prevInvisible = nodes.find(({ id }) => id === INVISIBLE_NODE_ID); - return [ - ...newNodes, - { - id: INVISIBLE_NODE_ID, - type: NODE_TYPES.INVISIBLE, - position: { - x: prevInvisible?.position.x || 0, - y: prevInvisible?.position.y || 0, + const newEdges = newNodes + .map((node, i) => { + const sourceId = node.id; + const targetId = newNodes[i + 1]?.id; + if (targetId) { + return { + id: uuidv4(), + source: sourceId, + target: targetId, + type: 'addNodeEdge', + data: { + laidOut: false, }, - }, - ]; - }); + }; + } + return null; + }) + .filter((edge) => !!edge); - setEdges((edges) => { - const newEdges = flow.steps - .map((step, i) => { - const sourceId = step.id; - const targetId = flow.steps[i + 1]?.id; - const edge = edges?.find( - (edge) => edge.id === generateEdgeId(sourceId, targetId), - ); - if (targetId) { - return { - id: generateEdgeId(sourceId, targetId), - source: sourceId, - target: targetId, - type: 'addNodeEdge', - data: { - laidOut: edge ? edge?.data.laidOut : false, - }, - }; - } - return null; - }) - .filter((edge) => !!edge); - - const lastStep = flow.steps[flow.steps.length - 1]; - const lastEdge = edges[edges.length - 1]; - - return lastStep - ? [ - ...newEdges, - { - id: generateEdgeId(lastStep.id, INVISIBLE_NODE_ID), - source: lastStep.id, - target: INVISIBLE_NODE_ID, - type: 'addNodeEdge', - data: { - laidOut: - lastEdge?.id === - generateEdgeId(lastStep.id, INVISIBLE_NODE_ID) - ? lastEdge?.data.laidOut - : false, - }, - }, - ] - : newEdges; - }); - - if (createdStepIdRef.current) { - createdStepIdRef.current = null; - } - } - }, [flow.steps]); + setNodes(newNodes); + setEdges(newEdges); + }, []); useEffect(function updateContainerHeightOnResize() { const updateHeight = () => { @@ -237,15 +282,16 @@ const EditorNew = ({ flow }) => { onStepOpen, onStepClose, onStepChange, - flowId: flow.id, - steps: flow.steps, + onStepDelete, + flowId: flow?.id, + steps: flow?.steps, }} > @@ -264,6 +310,7 @@ const EditorNew = ({ flow }) => { zoomOnDoubleClick={false} panActivationKeyCode={null} proOptions={{ hideAttribution: true }} + elementsSelectable={false} /> diff --git a/packages/web/src/components/EditorNew/FlowStepNode/FlowStepNode.jsx b/packages/web/src/components/EditorNew/FlowStepNode/FlowStepNode.jsx index 545331c9..e82a1e53 100644 --- a/packages/web/src/components/EditorNew/FlowStepNode/FlowStepNode.jsx +++ b/packages/web/src/components/EditorNew/FlowStepNode/FlowStepNode.jsx @@ -1,15 +1,22 @@ -import { Handle, Position } from 'reactflow'; +import { useContext } from 'react'; +import { Handle, Position } from '@xyflow/react'; import PropTypes from 'prop-types'; import FlowStep from 'components/FlowStep'; -import { NodeWrapper, NodeInnerWrapper } from './style.js'; -import { useContext } from 'react'; import { NodesContext } from '../EditorNew.jsx'; +import { NodeWrapper, NodeInnerWrapper } from './style.js'; function FlowStepNode({ data: { collapsed, laidOut }, id }) { - const { openNextStep, onStepOpen, onStepClose, onStepChange, flowId, steps } = - useContext(NodesContext); + const { + openNextStep, + onStepOpen, + onStepClose, + onStepChange, + onStepDelete, + flowId, + steps, + } = useContext(NodesContext); const step = steps.find(({ id: stepId }) => stepId === id); @@ -36,6 +43,7 @@ function FlowStepNode({ data: { collapsed, laidOut }, id }) { onChange={onStepChange} flowId={flowId} onContinue={() => openNextStep(step.id)} + onDelete={onStepDelete} /> )} { - const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); - graph.setGraph({ - rankdir: 'TB', - marginy: 60, - 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 onLayout = useCallback( - (nodes, edges) => { - const laidOutElements = getLaidOutElements(nodes, edges); - - setNodes([ - ...laidOutElements.nodes.map((node) => ({ - ...node, - data: { ...node.data, laidOut: true }, - })), - ]); - setEdges([ - ...laidOutElements.edges.map((edge) => ({ - ...edge, - data: { ...edge.data, laidOut: true }, - })), - ]); - }, - [setEdges, setNodes], - ); - - useEffect(() => { - const shouldAutoLayout = - nodesInitialized && - !isEqual( - nodes.map(({ width, height }) => ({ width, height })), - prevNodes.map(({ width, height }) => ({ width, height })), - ); - - if (shouldAutoLayout) { - onLayout(nodes, getEdges()); - } - }, [nodes]); -}; diff --git a/packages/web/src/components/EditorNew/useScrollBoundaries.js b/packages/web/src/components/EditorNew/useScrollBoundaries.js index a94a87c9..fdc00c05 100644 --- a/packages/web/src/components/EditorNew/useScrollBoundaries.js +++ b/packages/web/src/components/EditorNew/useScrollBoundaries.js @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { useViewport, useReactFlow, useNodes } from 'reactflow'; +import { useViewport, useReactFlow, useNodes } from '@xyflow/react'; const scrollYMargin = 100; @@ -24,7 +24,7 @@ export const useScrollBoundaries = (containerHeight) => { function updateMaxYScroll() { if (nodes?.length && containerHeight) { const maxY = - containerHeight - nodes[nodes.length - 1].y - scrollYMargin; + containerHeight - nodes[nodes.length - 1].position.y - scrollYMargin; setMaxYScroll(maxY >= 0 ? 0 : maxY); } }, diff --git a/packages/web/src/components/EditorNew/utils.js b/packages/web/src/components/EditorNew/utils.js index cbaede60..073e29e8 100644 --- a/packages/web/src/components/EditorNew/utils.js +++ b/packages/web/src/components/EditorNew/utils.js @@ -1,6 +1,5 @@ -import { INVISIBLE_NODE_ID, NODE_TYPES } from './constants'; - -export const generateEdgeId = (sourceId, targetId) => `${sourceId}-${targetId}`; +import Dagre from '@dagrejs/dagre'; +import { NODE_TYPES } from './constants'; export const updatedCollapsedNodes = (nodes, openStepId) => { return nodes.map((node) => { @@ -17,72 +16,50 @@ export const updatedCollapsedNodes = (nodes, openStepId) => { }); }; -export const generateInitialNodes = (flow) => { - const newNodes = flow.steps.map((step, index) => { - const collapsed = index !== 0; +const edgeLaidOut = (edge, nodes) => { + const sourceNode = nodes.find((node) => node.id === edge.source); + const targetNodeNode = nodes.find((node) => node.id === edge.target); - return { - id: step.id, - type: NODE_TYPES.FLOW_STEP, - position: { - x: 0, - y: 0, - }, - zIndex: collapsed ? 0 : 1, - data: { - collapsed, - laidOut: false, - }, - }; + return Boolean(sourceNode?.measured && targetNodeNode?.measured); +}; + +export const getLaidOutElements = (nodes, edges) => { + const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); + graph.setGraph({ + rankdir: 'TB', + marginy: 60, + ranksep: 64, }); + edges.forEach((edge) => graph.setEdge(edge.source, edge.target)); + nodes.forEach((node) => + graph.setNode(node.id, { + ...node, + width: node.measured?.width ?? 0, + height: node.measured?.height ?? 0, + }), + ); - return [ - ...newNodes, - { - id: INVISIBLE_NODE_ID, - type: NODE_TYPES.INVISIBLE, - position: { - x: 0, - y: 0, - }, - }, - ]; -}; - -export const generateInitialEdges = (flow) => { - const newEdges = flow.steps - .map((step, i) => { - const sourceId = step.id; - const targetId = flow.steps[i + 1]?.id; - if (targetId) { - return { - id: generateEdgeId(sourceId, targetId), - source: sourceId, - target: targetId, - type: 'addNodeEdge', - data: { - laidOut: false, - }, - }; - } - return null; - }) - .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: { - laidOut: false, - }, - }, - ] - : newEdges; + Dagre.layout(graph); + + return { + nodes: 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 }, + ...(node.type === NODE_TYPES.FLOW_STEP + ? { data: { ...node.data, laidOut: node.measured ? true : false } } + : {}), + }; + }), + edges: edges.map((edge) => ({ + ...edge, + data: { ...edge.data, laidOut: edgeLaidOut(edge, nodes) }, + })), + }; }; diff --git a/packages/web/src/components/FlowStep/index.jsx b/packages/web/src/components/FlowStep/index.jsx index 92adeb1c..5f54a2b2 100644 --- a/packages/web/src/components/FlowStep/index.jsx +++ b/packages/web/src/components/FlowStep/index.jsx @@ -44,6 +44,8 @@ import useTriggerSubsteps from 'hooks/useTriggerSubsteps'; import useActionSubsteps from 'hooks/useActionSubsteps'; import useStepWithTestExecutions from 'hooks/useStepWithTestExecutions'; +const useNewFlowEditor = process.env.REACT_APP_USE_NEW_FLOW_EDITOR === 'true'; + const validIcon = ; const errorIcon = ; @@ -108,7 +110,7 @@ function generateValidationSchema(substeps) { } function FlowStep(props) { - const { collapsed, onChange, onContinue, flowId, step } = props; + const { collapsed, onChange, onContinue, onDelete, flowId, step } = props; const editorContext = React.useContext(EditorContext); const contextButtonRef = React.useRef(null); const [anchorEl, setAnchorEl] = React.useState(null); @@ -290,7 +292,11 @@ function FlowStep(props) { - + @@ -378,6 +384,7 @@ function FlowStep(props) { stepId={step.id} deletable={!isTrigger} onClose={onContextMenuClose} + onDelete={onDelete} anchorEl={anchorEl} flowId={flowId} /> @@ -393,6 +400,7 @@ FlowStep.propTypes = { onClose: PropTypes.func, onChange: PropTypes.func.isRequired, onContinue: PropTypes.func, + onDelete: PropTypes.func, flowId: PropTypes.string.isRequired, }; diff --git a/packages/web/src/components/FlowStepContextMenu/index.jsx b/packages/web/src/components/FlowStepContextMenu/index.jsx index c67c9bba..0f9ff78c 100644 --- a/packages/web/src/components/FlowStepContextMenu/index.jsx +++ b/packages/web/src/components/FlowStepContextMenu/index.jsx @@ -9,7 +9,7 @@ import useFormatMessage from 'hooks/useFormatMessage'; import { useQueryClient } from '@tanstack/react-query'; function FlowStepContextMenu(props) { - const { stepId, onClose, anchorEl, deletable, flowId } = props; + const { stepId, onClose, onDelete, anchorEl, deletable, flowId } = props; const formatMessage = useFormatMessage(); const queryClient = useQueryClient(); const { mutateAsync: deleteStep } = useDeleteStep(); @@ -21,8 +21,9 @@ function FlowStepContextMenu(props) { await deleteStep(stepId); await queryClient.invalidateQueries({ queryKey: ['flows', flowId] }); + onDelete?.(stepId); }, - [deleteStep, stepId, queryClient, flowId], + [deleteStep, onDelete, stepId, queryClient, flowId], ); return ( @@ -44,6 +45,7 @@ function FlowStepContextMenu(props) { FlowStepContextMenu.propTypes = { stepId: PropTypes.string.isRequired, onClose: PropTypes.func.isRequired, + onDelete: PropTypes.func, anchorEl: PropTypes.oneOfType([ PropTypes.func, PropTypes.shape({ current: PropTypes.instanceOf(Element) }), diff --git a/packages/web/src/components/FlowSubstep/index.jsx b/packages/web/src/components/FlowSubstep/index.jsx index 55bb5cd6..ddbad995 100644 --- a/packages/web/src/components/FlowSubstep/index.jsx +++ b/packages/web/src/components/FlowSubstep/index.jsx @@ -11,6 +11,8 @@ import InputCreator from 'components/InputCreator'; import FilterConditions from './FilterConditions'; import { StepPropType, SubstepPropType } from 'propTypes/propTypes'; +const useNewFlowEditor = process.env.REACT_APP_USE_NEW_FLOW_EDITOR === 'true'; + function FlowSubstep(props) { const { substep, @@ -34,7 +36,11 @@ function FlowSubstep(props) { title={name} valid={validationStatus} /> - + - +