Merge pull request #2411 from automatisch/AUT-1481

feat: update react flow library and improve state management
This commit is contained in:
Ali BARIN
2025-03-28 15:01:33 +01:00
committed by GitHub
16 changed files with 323 additions and 558 deletions

View File

@@ -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}
/>
<Collapse in={expanded} timeout="auto" unmountOnExit>
<Collapse
in={expanded}
timeout={useNewFlowEditor ? 0 : 'auto'}
unmountOnExit
>
<ListItem
sx={{
pt: 2,

View File

@@ -25,6 +25,8 @@ import useTestConnection from 'hooks/useTestConnection';
import useOAuthClients from 'hooks/useOAuthClients';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
const useNewFlowEditor = process.env.REACT_APP_USE_NEW_FLOW_EDITOR === 'true';
const ADD_CONNECTION_VALUE = 'ADD_CONNECTION';
const ADD_SHARED_CONNECTION_VALUE = 'ADD_SHARED_CONNECTION';
@@ -240,7 +242,11 @@ function ChooseConnectionSubstep(props) {
title={name}
valid={isTestConnectionPending ? null : stepConnection?.verified}
/>
<Collapse in={expanded} timeout="auto" unmountOnExit>
<Collapse
in={expanded}
timeout={useNewFlowEditor ? 0 : 'auto'}
unmountOnExit
>
<ListItem
sx={{
pt: 2,

View File

@@ -9,7 +9,7 @@ import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import * as React from 'react';
import { Link, useLocation, useParams } from 'react-router-dom';
import { ReactFlowProvider } from 'reactflow';
import { ReactFlowProvider } from '@xyflow/react';
import Can from 'components/Can';
import Container from 'components/Container';

View File

@@ -1,9 +1,9 @@
import { EdgeLabelRenderer, getStraightPath } from 'reactflow';
import { EdgeLabelRenderer, getStraightPath } from '@xyflow/react';
import IconButton from '@mui/material/IconButton';
import AddIcon from '@mui/icons-material/Add';
import PropTypes from 'prop-types';
import { useContext } from 'react';
import { EdgesContext } from '../EditorNew';
export default function Edge({
@@ -14,7 +14,7 @@ export default function Edge({
source,
data: { laidOut },
}) {
const { stepCreationInProgress, flowActive, onAddStep } =
const { flowActive, onStepAdd, isCreateStepPending } =
useContext(EdgesContext);
const [edgePath, labelX, labelY] = getStraightPath({
@@ -28,7 +28,7 @@ export default function Edge({
<>
<EdgeLabelRenderer>
<IconButton
onClick={() => 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}
>
<AddIcon />
</IconButton>

View File

@@ -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,
}}
>
<EdgesContext.Provider
value={{
stepCreationInProgress: isCreateStepPending,
onAddStep,
flowActive: flow.active,
flowActive: flow?.active,
isCreateStepPending,
onStepAdd: onStepAddDebounced,
}}
>
<EditorWrapper direction="column" ref={containerRef}>
@@ -264,6 +310,7 @@ const EditorNew = ({ flow }) => {
zoomOnDoubleClick={false}
panActivationKeyCode={null}
proOptions={{ hideAttribution: true }}
elementsSelectable={false}
/>
</EditorWrapper>
</EdgesContext.Provider>

View File

@@ -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}
/>
)}
<Handle

View File

@@ -1,4 +1,4 @@
import { Handle, Position } from 'reactflow';
import { Handle, Position } from '@xyflow/react';
import { Box } from '@mui/material';
// This node is used for adding an edge with add node button after the last flow step node

View File

@@ -1,69 +0,0 @@
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 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));
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]);
};

View File

@@ -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);
}
},

View File

@@ -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) },
})),
};
};

View File

@@ -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 = <CheckCircleIcon color="success" />;
const errorIcon = <ErrorIcon color="error" />;
@@ -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) {
</Stack>
</Header>
<Collapse in={!collapsed} unmountOnExit>
<Collapse
in={!collapsed}
unmountOnExit
timeout={useNewFlowEditor ? 0 : 'auto'}
>
<Content>
<List>
<StepExecutionsProvider value={stepWithTestExecutionsData}>
@@ -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,
};

View File

@@ -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) }),

View File

@@ -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}
/>
<Collapse in={expanded} timeout="auto" unmountOnExit>
<Collapse
in={expanded}
timeout={useNewFlowEditor ? 0 : 'auto'}
unmountOnExit
>
<ListItem
sx={{
pt: 2,

View File

@@ -16,6 +16,8 @@ import FlowSubstepTitle from 'components/FlowSubstepTitle';
import { useQueryClient } from '@tanstack/react-query';
import { StepPropType, SubstepPropType } from 'propTypes/propTypes';
const useNewFlowEditor = process.env.REACT_APP_USE_NEW_FLOW_EDITOR === 'true';
function TestSubstep(props) {
const {
substep,
@@ -74,7 +76,11 @@ function TestSubstep(props) {
return (
<React.Fragment>
<FlowSubstepTitle expanded={expanded} onClick={onToggle} title={name} />
<Collapse in={expanded} timeout="auto" unmountOnExit>
<Collapse
in={expanded}
timeout={useNewFlowEditor ? 0 : 'auto'}
unmountOnExit
>
<ListItem
sx={{
pt: 2,