Compare commits

...

14 Commits

Author SHA1 Message Date
kasia.oczkowska
96c3c19a50 feat: introduce paths 2024-06-26 15:45:26 +01:00
Ali BARIN
abfd1116c7 Merge pull request #1941 from automatisch/AUT-1038
fix: persist value in ControlledCustomAutocomplete when it depends on other fields
2024-06-21 14:32:09 +02:00
kasia.oczkowska
017854955d fix: persist value in ControlledCustomAutocomplete when it depends on other fields 2024-06-21 10:37:39 +01:00
Ali BARIN
1405cddea1 Merge pull request #1940 from automatisch/AUT-1039
Revert "feat: persist parameters values in FlowSubstep (#1505)"
2024-06-20 15:57:47 +02:00
kasia.oczkowska
00dd3164c9 Revert "feat: persist parameters values in FlowSubstep (#1505)"
This reverts commit 017a881494.
2024-06-20 14:48:51 +01:00
Ali BARIN
d5cbc0f611 Merge pull request #1578 from automatisch/AUT-620
feat: improve UI display depending on user permissions
2024-06-20 12:04:31 +02:00
kasia.oczkowska
5d2e9ccc67 feat: improve UI display depending on user persmissions 2024-06-20 09:52:00 +00:00
kattoczko
017a881494 feat: persist parameters values in FlowSubstep (#1505)
* feat: persist parameters values in FlowSubstep

* feat: add missing import

* feat: use usePrevious hook
2024-06-20 11:13:45 +02:00
Alex Maslakov
52994970e6 fix(DynamicField): display long variables better
* Fix issue #1933

Problem with rendering in DynamicField component with long variables #1933

* Fix issue #1933 (2)

* Fix issue #1933 (3)
2024-06-20 09:55:08 +02:00
Ali BARIN
ebae629e5c Merge pull request #1931 from automatisch/AUT-1010
feat: improve nodes and edges state update
2024-06-19 16:01:24 +02:00
kasia.oczkowska
4d79220b0c refactor: fix spelling and wording errors 2024-06-14 12:39:42 +01:00
kasia.oczkowska
96fba7fbb8 feat: improve nodes and edges state update 2024-06-14 12:39:42 +01:00
Ali BARIN
e0d610071d Merge pull request #1917 from automatisch/AUT-1009
feat: hide react-flow attribution
2024-06-05 15:35:09 +02:00
kasia.oczkowska
ab0966c005 feat: hide react-flow attribution 2024-06-05 13:39:49 +01:00
27 changed files with 1230 additions and 485 deletions

View File

@@ -8,6 +8,7 @@ import * as URLS from 'config/urls';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import { ConnectionPropType } from 'propTypes/propTypes'; import { ConnectionPropType } from 'propTypes/propTypes';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import Can from 'components/Can';
function ContextMenu(props) { function ContextMenu(props) {
const { const {
@@ -44,34 +45,57 @@ function ContextMenu(props) {
hideBackdrop={false} hideBackdrop={false}
anchorEl={anchorEl} anchorEl={anchorEl}
> >
<MenuItem <Can I="read" a="Flow" passThrough>
component={Link} {(allowed) => (
to={URLS.APP_FLOWS_FOR_CONNECTION(appKey, connection.id)} <MenuItem
onClick={createActionHandler({ type: 'viewFlows' })} component={Link}
> to={URLS.APP_FLOWS_FOR_CONNECTION(appKey, connection.id)}
{formatMessage('connection.viewFlows')} onClick={createActionHandler({ type: 'viewFlows' })}
</MenuItem> disabled={!allowed}
>
<MenuItem onClick={createActionHandler({ type: 'test' })}> {formatMessage('connection.viewFlows')}
{formatMessage('connection.testConnection')} </MenuItem>
</MenuItem>
<MenuItem
component={Link}
disabled={disableReconnection}
to={URLS.APP_RECONNECT_CONNECTION(
appKey,
connection.id,
connection.appAuthClientId,
)} )}
onClick={createActionHandler({ type: 'reconnect' })} </Can>
>
{formatMessage('connection.reconnect')}
</MenuItem>
<MenuItem onClick={createActionHandler({ type: 'delete' })}> <Can I="update" a="Connection" passThrough>
{formatMessage('connection.delete')} {(allowed) => (
</MenuItem> <MenuItem
onClick={createActionHandler({ type: 'test' })}
disabled={!allowed}
>
{formatMessage('connection.testConnection')}
</MenuItem>
)}
</Can>
<Can I="create" a="Connection" passThrough>
{(allowed) => (
<MenuItem
component={Link}
disabled={!allowed || disableReconnection}
to={URLS.APP_RECONNECT_CONNECTION(
appKey,
connection.id,
connection.appAuthClientId,
)}
onClick={createActionHandler({ type: 'reconnect' })}
>
{formatMessage('connection.reconnect')}
</MenuItem>
)}
</Can>
<Can I="delete" a="Connection" passThrough>
{(allowed) => (
<MenuItem
onClick={createActionHandler({ type: 'delete' })}
disabled={!allowed}
>
{formatMessage('connection.delete')}
</MenuItem>
)}
</Can>
</Menu> </Menu>
); );
} }

View File

@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import AppConnectionRow from 'components/AppConnectionRow'; import AppConnectionRow from 'components/AppConnectionRow';
import NoResultFound from 'components/NoResultFound'; import NoResultFound from 'components/NoResultFound';
import Can from 'components/Can';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
import useAppConnections from 'hooks/useAppConnections'; import useAppConnections from 'hooks/useAppConnections';
@@ -16,11 +17,15 @@ function AppConnections(props) {
if (!hasConnections) { if (!hasConnections) {
return ( return (
<NoResultFound <Can I="create" a="Connection" passThrough>
to={URLS.APP_ADD_CONNECTION(appKey)} {(allowed) => (
text={formatMessage('app.noConnections')} <NoResultFound
data-test="connections-no-results" text={formatMessage('app.noConnections')}
/> data-test="connections-no-results"
{...(allowed && { to: URLS.APP_ADD_CONNECTION(appKey) })}
/>
)}
</Can>
); );
} }

View File

@@ -5,6 +5,7 @@ import PaginationItem from '@mui/material/PaginationItem';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
import AppFlowRow from 'components/FlowRow'; import AppFlowRow from 'components/FlowRow';
import Can from 'components/Can';
import NoResultFound from 'components/NoResultFound'; import NoResultFound from 'components/NoResultFound';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import useConnectionFlows from 'hooks/useConnectionFlows'; import useConnectionFlows from 'hooks/useConnectionFlows';
@@ -36,11 +37,20 @@ function AppFlows(props) {
if (!hasFlows) { if (!hasFlows) {
return ( return (
<NoResultFound <Can I="create" a="Flow" passThrough>
to={URLS.CREATE_FLOW_WITH_APP_AND_CONNECTION(appKey, connectionId)} {(allowed) => (
text={formatMessage('app.noFlows')} <NoResultFound
data-test="flows-no-results" text={formatMessage('app.noFlows')}
/> data-test="flows-no-results"
{...(allowed && {
to: URLS.CREATE_FLOW_WITH_APP_AND_CONNECTION(
appKey,
connectionId
),
})}
/>
)}
</Can>
); );
} }

View File

@@ -61,6 +61,7 @@ function ControlledCustomAutocomplete(props) {
const [isSingleChoice, setSingleChoice] = React.useState(undefined); const [isSingleChoice, setSingleChoice] = React.useState(undefined);
const priorStepsWithExecutions = React.useContext(StepExecutionsContext); const priorStepsWithExecutions = React.useContext(StepExecutionsContext);
const editorRef = React.useRef(null); const editorRef = React.useRef(null);
const mountedRef = React.useRef(false);
const renderElement = React.useCallback( const renderElement = React.useCallback(
(props) => <Element {...props} disabled={disabled} />, (props) => <Element {...props} disabled={disabled} />,
@@ -94,10 +95,14 @@ function ControlledCustomAutocomplete(props) {
}, []); }, []);
React.useEffect(() => { React.useEffect(() => {
const hasDependencies = dependsOnValues.length; if (mountedRef.current) {
if (hasDependencies) { const hasDependencies = dependsOnValues.length;
// Reset the field when a dependent has been updated if (hasDependencies) {
resetEditor(editor); // Reset the field when a dependent has been updated
resetEditor(editor);
}
} else {
mountedRef.current = true;
} }
}, dependsOnValues); }, dependsOnValues);

View File

@@ -64,11 +64,19 @@ function DynamicField(props) {
<Stack <Stack
direction={{ xs: 'column', sm: 'row' }} direction={{ xs: 'column', sm: 'row' }}
spacing={{ xs: 2 }} spacing={{ xs: 2 }}
sx={{ display: 'flex', flex: 1 }} sx={{
display: 'flex',
flex: 1,
minWidth: 0,
}}
> >
{fields.map((fieldSchema, fieldSchemaIndex) => ( {fields.map((fieldSchema, fieldSchemaIndex) => (
<Box <Box
sx={{ display: 'flex', flex: '1 0 0px' }} sx={{
display: 'flex',
flex: '1 0 0px',
minWidth: 0,
}}
key={`field-${field.__id}-${fieldSchemaIndex}`} key={`field-${field.__id}-${fieldSchemaIndex}`}
> >
<InputCreator <InputCreator

View File

@@ -1,79 +0,0 @@
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

@@ -0,0 +1,69 @@
import { EdgeLabelRenderer, getStraightPath, BaseEdge } from 'reactflow';
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';
import { Tooltip } from '@mui/material';
export default function NodeEdge({
sourceX,
sourceY,
targetX,
targetY,
source,
data: { laidOut },
}) {
const { stepCreationInProgress, flowActive, onAddStep } =
useContext(EdgesContext);
const [edgePath, labelX, labelY] = getStraightPath({
sourceX,
sourceY,
targetX,
targetY,
});
const handleAddStep = () => {
onAddStep(source);
};
return (
<>
<BaseEdge path={edgePath} />
<EdgeLabelRenderer>
<Tooltip title="Add step">
<IconButton
onClick={handleAddStep}
color="primary"
sx={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'all',
backgroundColor: '#fafafa',
'&:hover': {
backgroundColor: '#f0f3fa',
},
// visibility: laidOut ? 'visible' : 'hidden',
}}
disabled={stepCreationInProgress || flowActive}
>
<AddIcon />
</IconButton>
</Tooltip>
</EdgeLabelRenderer>
</>
);
}
NodeEdge.propTypes = {
sourceX: PropTypes.number.isRequired,
sourceY: PropTypes.number.isRequired,
targetX: PropTypes.number.isRequired,
targetY: PropTypes.number.isRequired,
source: PropTypes.string.isRequired,
data: PropTypes.shape({
laidOut: PropTypes.bool,
}).isRequired,
};

View File

@@ -0,0 +1,95 @@
import { EdgeLabelRenderer, getStraightPath, BaseEdge } from 'reactflow';
import IconButton from '@mui/material/IconButton';
import AddIcon from '@mui/icons-material/Add';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import PropTypes from 'prop-types';
import { useContext, useState } from 'react';
import { EdgesContext } from '../../EditorNew';
import { Tooltip } from '@mui/material';
export default function NodeOrPathsEdge({
sourceX,
sourceY,
targetX,
targetY,
source,
data: { laidOut },
}) {
const [anchorEl, setAnchorEl] = useState(null);
const open = Boolean(anchorEl);
const handleClick = (event) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const { stepCreationInProgress, flowActive, onAddStep, onAddPaths } =
useContext(EdgesContext);
const [edgePath, labelX, labelY] = getStraightPath({
sourceX,
sourceY,
targetX,
targetY,
});
const handleAddStep = () => {
onAddStep(source);
handleClose();
};
const handleAddPaths = () => {
onAddPaths(source);
handleClose();
};
return (
<>
<BaseEdge path={edgePath} />
<EdgeLabelRenderer>
<Tooltip title="Add step or paths">
<IconButton
onClick={handleClick}
color="primary"
sx={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'all',
backgroundColor: '#fafafa',
'&:hover': {
backgroundColor: '#f0f3fa',
},
// visibility: laidOut ? 'visible' : 'hidden',
}}
disabled={stepCreationInProgress || flowActive}
>
<AddIcon />
</IconButton>
</Tooltip>
<Menu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
anchorOrigin={{ horizontal: 'right', vertical: 'top' }}
>
<MenuItem onClick={handleAddStep}>Step</MenuItem>
<MenuItem onClick={handleAddPaths}>Paths</MenuItem>
</Menu>
</EdgeLabelRenderer>
</>
);
}
NodeOrPathsEdge.propTypes = {
sourceX: PropTypes.number.isRequired,
sourceY: PropTypes.number.isRequired,
targetX: PropTypes.number.isRequired,
targetY: PropTypes.number.isRequired,
source: PropTypes.string.isRequired,
data: PropTypes.shape({
laidOut: PropTypes.bool,
}).isRequired,
};

View File

@@ -0,0 +1,69 @@
import { EdgeLabelRenderer, getStraightPath, BaseEdge } from 'reactflow';
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';
import { Tooltip } from '@mui/material';
export default function PathsEdge({
sourceX,
sourceY,
targetX,
targetY,
source,
data: { laidOut },
}) {
const { stepCreationInProgress, flowActive, onAddPath } =
useContext(EdgesContext);
const [edgePath, labelX, labelY] = getStraightPath({
sourceX,
sourceY,
targetX,
targetY,
});
const handleAddPath = () => {
onAddPath(source);
};
return (
<>
<BaseEdge path={edgePath} />
<EdgeLabelRenderer>
<Tooltip title="Add path">
<IconButton
onClick={handleAddPath}
color="primary"
sx={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'all',
backgroundColor: '#fafafa',
'&:hover': {
backgroundColor: '#f0f3fa',
},
// visibility: laidOut ? 'visible' : 'hidden',
}}
disabled={stepCreationInProgress || flowActive}
>
<AddIcon />
</IconButton>
</Tooltip>
</EdgeLabelRenderer>
</>
);
}
PathsEdge.propTypes = {
sourceX: PropTypes.number.isRequired,
sourceY: PropTypes.number.isRequired,
targetX: PropTypes.number.isRequired,
targetY: PropTypes.number.isRequired,
source: PropTypes.string.isRequired,
data: PropTypes.shape({
laidOut: PropTypes.bool,
}).isRequired,
};

View File

@@ -1,255 +1,181 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useCallback, createContext, useRef } from 'react';
import { useMutation } from '@apollo/client'; // import { useMutation } from '@apollo/client';
import { useQueryClient } from '@tanstack/react-query'; // import { useQueryClient } from '@tanstack/react-query';
import { FlowPropType } from 'propTypes/propTypes'; import { FlowPropType } from 'propTypes/propTypes';
import ReactFlow, { useNodesState, useEdgesState, addEdge } from 'reactflow'; import ReactFlow, { useNodesState, useEdgesState } from 'reactflow';
import 'reactflow/dist/style.css'; import 'reactflow/dist/style.css';
import { Stack } from '@mui/material'; // import { UPDATE_STEP } from 'graphql/mutations/update-step';
import { UPDATE_STEP } from 'graphql/mutations/update-step'; // import { CREATE_STEP } from 'graphql/mutations/create-step';
import { useAutoLayout } from './useAutoLayout'; import { useAutoLayout } from './useAutoLayout';
import { useScrollBoundries } from './useScrollBoundries'; import NodeOrPathsEdge from './Edges/NodeOrPathsEdge/NodeOrPathsEdge';
import FlowStepNode from './FlowStepNode/FlowStepNode'; import FlowStepNode from './Nodes/FlowStepNode/FlowStepNode';
import Edge from './Edge/Edge'; import InvisibleNode from './Nodes/InvisibleNode/InvisibleNode';
import InvisibleNode from './InvisibleNode/InvisibleNode'; import PathsNode from './Nodes/PathsNode/PathsNode';
import { EditorWrapper } from './style'; import { EditorWrapper } from './style';
import { generateEdges, generateNodes, updatedCollapsedNodes } from './utils';
import { EDGE_TYPES, NODE_TYPES } from './constants';
import { useFlow } from './temp/useFlow';
import PathNode from './Nodes/PathNode/PathNode';
import PathsEdge from './Edges/PathsEdge/PathsEdge';
import NodeEdge from './Edges/NodeEdge/NodeEdge';
const nodeTypes = { flowStep: FlowStepNode, invisible: InvisibleNode }; export const EdgesContext = createContext();
export const NodesContext = createContext();
const nodeTypes = {
[NODE_TYPES.FLOW_STEP]: FlowStepNode,
[NODE_TYPES.INVISIBLE]: InvisibleNode,
[NODE_TYPES.PATHS]: PathsNode,
[NODE_TYPES.PATH]: PathNode,
};
const edgeTypes = { const edgeTypes = {
addNodeEdge: Edge, [EDGE_TYPES.ADD_NODE_OR_PATHS_EDGE]: NodeOrPathsEdge,
[EDGE_TYPES.ADD_PATH_EDGE]: PathsEdge,
[EDGE_TYPES.ADD_NODE_EDGE]: NodeEdge,
}; };
const INVISIBLE_NODE_ID = 'invisible-node'; const EditorNew = () =>
// { flow }
{
const { flow, createStep, createPaths, createPath } = useFlow();
// const [updateStep] = useMutation(UPDATE_STEP);
// const queryClient = useQueryClient();
// const [createStep, { loading: stepCreationInProgress }] =
// useMutation(CREATE_STEP);
const stepCreationInProgress = false;
const generateEdgeId = (sourceId, targetId) => `${sourceId}-${targetId}`; const [nodes, setNodes, onNodesChange] = useNodesState(
generateNodes({ steps: flow.steps }),
const EditorNew = ({ flow }) => { );
const [triggerStep] = flow.steps; const [edges, setEdges, onEdgesChange] = useEdgesState(
const [currentStepId, setCurrentStepId] = useState(triggerStep.id); generateEdges({ steps: flow.steps }),
const [updateStep] = useMutation(UPDATE_STEP);
const queryClient = useQueryClient();
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
useAutoLayout();
useScrollBoundries();
const onConnect = useCallback(
(params) => setEdges((eds) => addEdge(params, eds)),
[setEdges],
);
const openNextStep = useCallback(
(nextStep) => () => {
setCurrentStepId(nextStep?.id);
},
[],
);
const onStepChange = useCallback(
async (step) => {
const mutationInput = {
id: step.id,
key: step.key,
parameters: step.parameters,
connection: {
id: step.connection?.id,
},
flow: {
id: flow.id,
},
};
if (step.appKey) {
mutationInput.appKey = step.appKey;
}
await updateStep({
variables: { input: mutationInput },
});
await queryClient.invalidateQueries({
queryKey: ['steps', step.id, 'connection'],
});
await queryClient.invalidateQueries({ queryKey: ['flows', flow.id] });
},
[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);
const collapsed = currentStepId !== step.id;
return {
id: step.id,
type: 'flowStep',
position: {
x: node ? node.position.x : 0,
y: node ? node.position.y : 0,
},
zIndex: collapsed ? 0 : 1,
data: {
step,
index: index,
flowId: flow.id,
collapsed,
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],
);
const updateEdgesData = useCallback(
(flow) => {
setEdges((edges) =>
edges.map((edge) => {
return {
...edge,
data: { ...edge.data, flowId: flow.id, flowActive: flow.active },
};
}),
);
},
[setEdges],
);
useEffect(() => {
setNodes(
nodes.map((node) => {
if (node.type === 'flowStep') {
const collapsed = currentStepId !== node.data.step.id;
return {
...node,
zIndex: collapsed ? 0 : 1,
data: {
...node.data,
collapsed,
},
};
}
return node;
}),
); );
}, [currentStepId]);
useEffect(() => { useAutoLayout();
if (flow.steps.length + 1 !== nodes.length) {
const newNodes = generateNodes(flow, nodes);
const newEdges = generateEdges(flow, edges);
setNodes(newNodes); const createdStepIdRef = useRef(null);
setEdges(newEdges);
} else {
updateNodesData(flow.steps);
updateEdgesData(flow);
}
}, [flow]);
return ( const openNextStep = useCallback(
<EditorWrapper direction="column"> (currentStepId) => {
<ReactFlow setNodes((nodes) => {
nodes={nodes} const currentStepIndex = nodes.findIndex(
edges={edges} (node) => node.id === currentStepId,
onNodesChange={onNodesChange} );
onEdgesChange={onEdgesChange} if (currentStepIndex >= 0) {
onConnect={onConnect} const nextStep = nodes[currentStepIndex + 1];
nodeTypes={nodeTypes} return updatedCollapsedNodes(nodes, nextStep.id);
edgeTypes={edgeTypes} }
panOnScroll return nodes;
panOnScrollMode="vertical" });
panOnDrag={false} },
zoomOnScroll={false} [setNodes],
zoomOnPinch={false} );
zoomOnDoubleClick={false}
panActivationKeyCode={null} const onStepClose = useCallback(() => {
/> setNodes((nodes) => updatedCollapsedNodes(nodes));
</EditorWrapper> }, [setNodes]);
);
}; const onStepOpen = useCallback(
(stepId) => {
setNodes((nodes) => updatedCollapsedNodes(nodes, stepId));
},
[setNodes],
);
const onStepChange = useCallback(
async (step) => {
// const mutationInput = {
// id: step.id,
// key: step.key,
// parameters: step.parameters,
// connection: {
// id: step.connection?.id,
// },
// flow: {
// id: flow.id,
// },
// };
// if (step.appKey) {
// mutationInput.appKey = step.appKey;
// }
// const updated = await updateStep({
// variables: { input: mutationInput },
// });
// await queryClient.invalidateQueries({
// queryKey: ['steps', step.id, 'connection'],
// });
// await queryClient.invalidateQueries({ queryKey: ['flows', flow.id] });
},
// [flow.id, updateStep, queryClient],
);
const onAddStep = async (previousStepId) => {
const createdStepId = createStep(flow, previousStepId);
createdStepIdRef.current = createdStepId;
};
console.log({ flow });
useEffect(() => {
// if (flow.steps.length + 1 !== nodes.length) {
setNodes((nodes) =>
generateNodes({
prevNodes: nodes,
steps: flow.steps,
createdStepId: createdStepIdRef.current,
}),
);
setEdges((edges) =>
generateEdges({ prevEdges: edges, steps: flow.steps }),
);
if (createdStepIdRef.current) {
createdStepIdRef.current = null;
}
// }
}, [flow.steps]);
return (
<NodesContext.Provider
value={{
openNextStep,
onStepOpen,
onStepClose,
onStepChange,
flowId: flow.id,
steps: flow.steps,
}}
>
<EdgesContext.Provider
value={{
stepCreationInProgress,
onAddStep,
onAddPaths: createPaths,
onAddPath: createPath,
flowActive: flow.active,
}}
>
<EditorWrapper direction="column">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
maxZoom={1}
minZoom={0.001}
proOptions={{ hideAttribution: true }}
/>
</EditorWrapper>
</EdgesContext.Provider>
</NodesContext.Provider>
);
};
EditorNew.propTypes = { EditorNew.propTypes = {
flow: FlowPropType.isRequired, flow: FlowPropType.isRequired,

View File

@@ -1,72 +0,0 @@
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';
import { NodeWrapper, NodeInnerWrapper } from './style.js';
function FlowStepNode({
data: {
step,
index,
flowId,
collapsed,
openNextStep,
onOpen,
onClose,
onChange,
layouted,
},
}) {
return (
<NodeWrapper
className="nodrag"
sx={{
visibility: layouted ? 'visible' : 'hidden',
}}
>
<NodeInnerWrapper>
<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' }}
/>
</NodeInnerWrapper>
</NodeWrapper>
);
}
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,69 @@
import { Handle, Position } from 'reactflow';
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 { findStepByStepId } from 'components/EditorNew/utils.js';
function FlowStepNode({ data: { collapsed, laidOut }, id }) {
const { openNextStep, onStepOpen, onStepClose, onStepChange, flowId, steps } =
useContext(NodesContext);
const step = findStepByStepId({ steps }, id);
return (
// <NodeWrapper
// sx={{
// visibility: laidOut ? 'visible' : 'hidden',
// }}
// >
<NodeInnerWrapper
sx={
{
// visibility: laidOut ? 'visible' : 'hidden',
}
}
id="flowStepId"
className="nodrag"
>
<Handle
type="target"
position={Position.Top}
isConnectable={false}
style={{ visibility: 'hidden' }}
/>
{step && (
<FlowStep
step={step}
collapsed={collapsed}
onOpen={() => onStepOpen(step.id)}
onClose={onStepClose}
onChange={onStepChange}
flowId={flowId}
onContinue={() => openNextStep(step.id)}
collapseAnimation={false}
/>
)}
<Handle
type="source"
position={Position.Bottom}
isConnectable={false}
style={{ visibility: 'hidden' }}
/>
</NodeInnerWrapper>
// </NodeWrapper>
);
}
FlowStepNode.propTypes = {
id: PropTypes.string,
data: PropTypes.shape({
collapsed: PropTypes.bool.isRequired,
laidOut: PropTypes.bool.isRequired,
}).isRequired,
};
export default FlowStepNode;

View File

@@ -9,6 +9,6 @@ export const NodeWrapper = styled(Box)(({ theme }) => ({
})); }));
export const NodeInnerWrapper = styled(Box)(({ theme }) => ({ export const NodeInnerWrapper = styled(Box)(({ theme }) => ({
maxWidth: 900, width: 900,
flex: 1, flex: 1,
})); }));

View File

@@ -0,0 +1,98 @@
import PropTypes from 'prop-types';
import { Handle, Position } from 'reactflow';
import { Box, Stack, Typography } from '@mui/material';
import { useRef, useState } from 'react';
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import IconButton from '@mui/material/IconButton';
import { Wrapper } from './style';
/* TODO
- add delete
- add rename
- add translations
- add collapsing?
*/
function PathNode({ data: { laidOut } }) {
const [anchorEl, setAnchorEl] = useState(null);
const contextButtonRef = useRef(null);
const onContextMenuClose = (event) => {
event.stopPropagation();
setAnchorEl(null);
};
const onContextMenuClick = (event) => {
event.stopPropagation();
setAnchorEl(contextButtonRef.current);
};
const deletePath = () => {
setAnchorEl(null);
onContextMenuClose();
};
return (
<>
<Box
className="nodrag"
sx={
{
// visibility: laidOut ? 'visible' : 'hidden',
}
}
>
<Handle
type="target"
position={Position.Top}
isConnectable={false}
style={{ visibility: 'hidden' }}
/>
<Wrapper>
<Stack
justifyContent="space-between"
alignItems="center"
direction="row"
>
<Typography sx={{ pr: 2 }}>Path</Typography>
<IconButton
color="primary"
onClick={onContextMenuClick}
ref={contextButtonRef}
>
<MoreHorizIcon />
</IconButton>
</Stack>
</Wrapper>
<Handle
type="source"
position={Position.Bottom}
isConnectable={false}
style={{ visibility: 'hidden' }}
/>
</Box>
{anchorEl && (
<Menu
open={true}
onClose={onContextMenuClose}
hideBackdrop={false}
anchorEl={anchorEl}
>
<MenuItem onClick={deletePath}>Delete</MenuItem>
</Menu>
)}
</>
);
}
PathNode.propTypes = {
data: PropTypes.shape({
laidOut: PropTypes.bool,
}).isRequired,
};
export default PathNode;

View File

@@ -0,0 +1,8 @@
import { styled } from '@mui/material/styles';
import Box from '@mui/material/Box';
export const Wrapper = styled(Box)(({ theme }) => ({
padding: theme.spacing(1, 2),
backgroundColor: '#0059f714',
borderRadius: 20,
}));

View File

@@ -0,0 +1,108 @@
import PropTypes from 'prop-types';
import { Handle, Position } from 'reactflow';
import { Avatar, Box, Stack, Typography } from '@mui/material';
import { useRef, useState } from 'react';
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import CallSplitIcon from '@mui/icons-material/CallSplit';
import IconButton from '@mui/material/IconButton';
import { Wrapper } from './style';
/* TODO
- add delete
- add rename
- add translations
- add collapsing?
*/
function PathsNode({ data: { laidOut } }) {
const [anchorEl, setAnchorEl] = useState(null);
const contextButtonRef = useRef(null);
const onContextMenuClose = (event) => {
event.stopPropagation();
setAnchorEl(null);
};
const onContextMenuClick = (event) => {
event.stopPropagation();
setAnchorEl(contextButtonRef.current);
};
const deletePaths = () => {
setAnchorEl(null);
onContextMenuClose();
};
return (
<>
<Box
width={900}
className="nodrag"
sx={
{
// visibility: laidOut ? 'visible' : 'hidden',
}
}
>
<Handle
type="target"
position={Position.Top}
isConnectable={false}
style={{ visibility: 'hidden' }}
/>
<Wrapper>
<Stack justifyContent="space-between" direction="row">
<Stack direction="row" alignItems="center" spacing={2}>
<Avatar
sx={{ display: 'flex', width: 50, height: 50 }}
variant="square"
>
<CallSplitIcon
fontSize="large"
sx={{ transform: 'rotate(180deg)' }}
/>
</Avatar>
{/* TODO name from path data */}
<Typography>Paths</Typography>
</Stack>
<IconButton
color="primary"
onClick={onContextMenuClick}
ref={contextButtonRef}
>
<MoreHorizIcon />
</IconButton>
</Stack>
</Wrapper>
<Handle
type="source"
position={Position.Bottom}
isConnectable={false}
style={{ visibility: 'hidden' }}
/>
</Box>
{anchorEl && (
<Menu
open={true}
onClose={onContextMenuClose}
hideBackdrop={false}
anchorEl={anchorEl}
>
<MenuItem onClick={deletePaths}>Delete</MenuItem>
</Menu>
)}
</>
);
}
PathsNode.propTypes = {
data: PropTypes.shape({
laidOut: PropTypes.bool,
}).isRequired,
};
export default PathsNode;

View File

@@ -0,0 +1,7 @@
import { styled } from '@mui/material/styles';
import Card from '@mui/material/Card';
export const Wrapper = styled(Card)`
width: 100%;
padding: ${({ theme }) => theme.spacing(2)};
`;

View File

@@ -0,0 +1,14 @@
export const INVISIBLE_NODE_ID = 'invisible-node';
export const NODE_TYPES = {
FLOW_STEP: 'flowStep',
INVISIBLE: 'invisible',
PATHS: 'parallelPaths',
PATH: 'path',
};
export const EDGE_TYPES = {
ADD_NODE_OR_PATHS_EDGE: 'addNodeOrPathsEdge',
ADD_PATH_EDGE: 'addPathEdge',
ADD_NODE_EDGE: 'addNodeEdge',
};

View File

@@ -7,7 +7,7 @@ export const EditorWrapper = styled(Stack)(({ theme }) => ({
flexGrow: 1, flexGrow: 1,
}, },
'& .react-flow__pane, & .react-flow__node': { // '& .react-flow__pane, & .react-flow__node': {
cursor: 'auto !important', // cursor: 'auto !important',
}, // },
})); }));

View File

@@ -0,0 +1,96 @@
import { useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { insertStep } from '../utils';
const initFlow = {
id: '7c55e6ce-a84a-46e3-ba31-211ec7b5c2cb',
name: 'Name your flow',
active: false,
status: 'draft',
createdAt: 1718264916266,
updatedAt: 1718264916266,
steps: [
{
id: '82ce34ab-7aab-4e6c-9f62-db5104aa81c6',
type: 'trigger',
key: null,
appKey: null,
iconUrl: null,
webhookUrl: 'http://localhost:3000/null',
status: 'incomplete',
position: 1,
parameters: {},
},
{
id: '41c60527-eb4f-4f2d-93ec-2fd37e336909',
type: 'action',
key: null,
appKey: null,
iconUrl: null,
webhookUrl: 'http://localhost:3000/null',
status: 'incomplete',
position: 2,
parameters: {},
},
],
};
const generateStep = () => {
return {
id: uuidv4(),
type: 'action',
key: null,
appKey: null,
parameters: {},
iconUrl: null,
webhookUrl: 'http://localhost:3000/null',
status: 'incomplete',
connection: null,
position: null,
};
};
const generatePath = (steps) => {
return {
id: uuidv4(),
type: 'path',
steps: steps?.length > 0 ? steps : [generateStep()],
};
};
export const generatePaths = (steps) => {
return {
id: uuidv4(),
type: 'parallelPaths',
steps: [generatePath(steps), generatePath()],
};
};
export const useFlow = () => {
const [flow, setFlow] = useState(initFlow);
const createStep = (flow, previousStepId) => {
const newStep = generateStep();
const newFlow = insertStep(flow, previousStepId, newStep);
setFlow(newFlow);
return newStep.id;
};
const createPaths = (previousStepId) => {
const newFlow = insertStep(flow, previousStepId, generatePaths());
setFlow(newFlow);
};
const createPath = (previousStepId) => {
const newFlow = insertStep(flow, previousStepId, generatePath());
setFlow(newFlow);
};
return {
flow,
createStep,
createPaths,
createPath,
};
};

View File

@@ -4,7 +4,7 @@ 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';
const getLayoutedElements = (nodes, edges) => { const getLaidOutElements = (nodes, edges) => {
const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
graph.setGraph({ graph.setGraph({
rankdir: 'TB', rankdir: 'TB',
@@ -32,22 +32,22 @@ export const useAutoLayout = () => {
const nodes = useNodes(); const nodes = useNodes();
const prevNodes = usePrevious(nodes); const prevNodes = usePrevious(nodes);
const nodesInitialized = useNodesInitialized(); const nodesInitialized = useNodesInitialized();
const { getEdges, setNodes, setEdges } = useReactFlow(); const { getEdges, setNodes, setEdges, fitView } = useReactFlow();
const onLayout = useCallback( const onLayout = useCallback(
(nodes, edges) => { (nodes, edges) => {
const layoutedElements = getLayoutedElements(nodes, edges); const laidOutElements = getLaidOutElements(nodes, edges);
setNodes([ setNodes([
...layoutedElements.nodes.map((node) => ({ ...laidOutElements.nodes.map((node) => ({
...node, ...node,
data: { ...node.data, layouted: true }, data: { ...node.data, laidOut: true },
})), })),
]); ]);
setEdges([ setEdges([
...layoutedElements.edges.map((edge) => ({ ...laidOutElements.edges.map((edge) => ({
...edge, ...edge,
data: { ...edge.data, layouted: true }, data: { ...edge.data, laidOut: true },
})), })),
]); ]);
}, },
@@ -62,6 +62,8 @@ export const useAutoLayout = () => {
prevNodes.map(({ width, height }) => ({ width, height })), prevNodes.map(({ width, height }) => ({ width, height })),
); );
fitView();
if (shouldAutoLayout) { if (shouldAutoLayout) {
onLayout(nodes, getEdges()); onLayout(nodes, getEdges());
} }

View File

@@ -1,7 +1,7 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useViewport, useReactFlow } from 'reactflow'; import { useViewport, useReactFlow } from 'reactflow';
export const useScrollBoundries = () => { export const useScrollBoundaries = () => {
const { setViewport } = useReactFlow(); const { setViewport } = useReactFlow();
const { x, y, zoom } = useViewport(); const { x, y, zoom } = useViewport();

View File

@@ -0,0 +1,245 @@
import cloneDeep from 'lodash/cloneDeep.js';
import { EDGE_TYPES, INVISIBLE_NODE_ID, NODE_TYPES } from './constants';
export const generateEdgeId = (sourceId, targetId) =>
`${sourceId}--${targetId}`;
export const updatedCollapsedNodes = (nodes, openStepId) => {
return nodes.map((node) => {
if (node.type !== NODE_TYPES.FLOW_STEP) {
return node;
}
const collapsed = node.id !== openStepId;
return {
...node,
zIndex: collapsed ? 0 : 1,
data: { ...node.data, collapsed },
};
});
};
export const generateNodes = ({ steps, prevNodes, createdStepId }) => {
const newNodes = steps.map((step, index) => {
const collapsed = index !== 0;
const prevNode = prevNodes?.find(({ id }) => id === step.id);
let newNode;
let childSteps = [];
switch (step.type) {
case 'trigger':
case 'action': {
if (prevNode) {
newNode = {
...prevNode,
zIndex: createdStepId ? 0 : prevNode?.zIndex || 0,
data: {
...prevNode.data,
collapsed: createdStepId
? true
: prevNode?.data?.collapsed || true,
},
};
} else {
newNode = {
id: step.id,
type: NODE_TYPES.FLOW_STEP,
position: {
x: 0,
y: 0,
},
zIndex: collapsed ? 0 : 1,
data: {
collapsed,
laidOut: false,
},
};
}
break;
}
case 'parallelPaths': {
if (prevNode) {
newNode = prevNode;
} else {
newNode = {
id: step.id,
type: NODE_TYPES.PATHS,
position: {
x: 0,
y: 0,
},
data: {
laidOut: false,
},
};
}
break;
}
case 'path': {
if (prevNode) {
newNode = prevNode;
} else {
newNode = {
id: step.id,
type: NODE_TYPES.PATH,
position: {
x: 0,
y: 0,
},
data: {
laidOut: false,
},
};
}
break;
}
default:
}
if (step?.steps?.length > 0) {
childSteps = generateNodes({
steps: step.steps,
prevNodes,
createdStepId,
});
}
return [newNode, ...childSteps];
});
return newNodes.flat(Infinity);
};
export const generateEdges = ({ steps }) => {
const newEdges = steps.map((step, index) => {
switch (step.type) {
case 'parallelPaths': {
const edges = step.steps.map((childStep) => {
const sourceId = step.id;
const targetId = childStep.id;
const newEdge = {
id: generateEdgeId(sourceId, targetId),
source: sourceId,
target: targetId,
type: EDGE_TYPES.ADD_PATH_EDGE,
data: {
laidOut: false,
},
};
return newEdge;
});
const childEdges = generateEdges({ steps: step.steps });
return [...edges, ...childEdges];
}
case 'path': {
console.log({ step });
const sourceId = step.id;
const targetId = step.steps?.[0]?.id;
if (targetId) {
const newEdge = {
id: generateEdgeId(sourceId, targetId),
source: sourceId,
target: targetId,
type: EDGE_TYPES.ADD_NODE_EDGE,
data: {
laidOut: false,
},
};
const childEdges = generateEdges({ steps: step.steps });
return [newEdge, ...childEdges];
}
return null;
}
default: {
const sourceId = step.id;
const targetId = steps[index + 1]?.id;
if (targetId) {
return {
id: generateEdgeId(sourceId, targetId),
source: sourceId,
target: targetId,
type: EDGE_TYPES.ADD_NODE_OR_PATHS_EDGE,
data: {
laidOut: false,
},
};
}
return null;
}
}
});
return newEdges.flat(Infinity).filter((edge) => !!edge);
};
export const findStepByStepId = (obj, id) => {
if (Array.isArray(obj.steps)) {
for (const step of obj.steps) {
if (step.id === id) {
return step;
}
const result = findStepByStepId(step, id);
if (result) {
return result;
}
}
}
return null;
};
export function insertStep(parentObj, id, newStep) {
function recursiveFindAndInsert(parentObj, id, newStep) {
if (parentObj.steps && Array.isArray(parentObj.steps)) {
for (let index = 0; index < parentObj.steps.length; index++) {
const step = parentObj.steps[index];
if (step.id === id) {
if (newStep.type === NODE_TYPES.PATHS) {
const stepsAfter = parentObj.steps.slice(
index + 1,
parentObj.steps.length,
);
parentObj.steps.splice(index + 1);
newStep.steps[0].steps = stepsAfter;
parentObj.steps.splice(index + 1, 0, newStep);
} else if (step.type === NODE_TYPES.PATHS) {
step.steps.push(newStep);
} else if (step.type === NODE_TYPES.PATH) {
step.steps.unshift(newStep);
} else {
const originalSteps = step.steps || [];
step.steps = [];
const newStepObject = {
...newStep,
steps: originalSteps,
};
parentObj.steps.splice(index + 1, 0, newStepObject);
}
return true;
}
const found = recursiveFindAndInsert(step, id, newStep);
if (found) {
return true;
}
}
}
return false;
}
// Clone the input object to avoid mutating the original
const newParentObj = cloneDeep(parentObj);
recursiveFindAndInsert(newParentObj, id, newStep);
return newParentObj;
}

View File

@@ -105,7 +105,7 @@ function generateValidationSchema(substeps) {
} }
function FlowStep(props) { function FlowStep(props) {
const { collapsed, onChange, onContinue, flowId } = props; const { collapsed, onChange, onContinue, flowId, collapseAnimation } = props;
const editorContext = React.useContext(EditorContext); const editorContext = React.useContext(EditorContext);
const contextButtonRef = React.useRef(null); const contextButtonRef = React.useRef(null);
const step = props.step; const step = props.step;
@@ -259,7 +259,11 @@ function FlowStep(props) {
</Stack> </Stack>
</Header> </Header>
<Collapse in={!collapsed} unmountOnExit> <Collapse
in={!collapsed}
unmountOnExit
{...(!collapseAnimation ? { timeout: 0 } : {})}
>
<Content> <Content>
<List> <List>
<StepExecutionsProvider value={stepWithTestExecutionsData}> <StepExecutionsProvider value={stepWithTestExecutionsData}>
@@ -360,11 +364,15 @@ function FlowStep(props) {
FlowStep.propTypes = { FlowStep.propTypes = {
collapsed: PropTypes.bool, collapsed: PropTypes.bool,
step: StepPropType.isRequired, step: StepPropType.isRequired,
index: PropTypes.number,
onOpen: PropTypes.func, onOpen: PropTypes.func,
onClose: PropTypes.func, onClose: PropTypes.func,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
onContinue: PropTypes.func, onContinue: PropTypes.func,
collapseAnimation: PropTypes.bool,
};
FlowStep.defaultProps = {
collapseAnimation: true,
}; };
export default FlowStep; export default FlowStep;

View File

@@ -30,6 +30,7 @@ import AppIcon from 'components/AppIcon';
import Container from 'components/Container'; import Container from 'components/Container';
import PageTitle from 'components/PageTitle'; import PageTitle from 'components/PageTitle';
import useApp from 'hooks/useApp'; import useApp from 'hooks/useApp';
import Can from 'components/Can';
const ReconnectConnection = (props) => { const ReconnectConnection = (props) => {
const { application, onClose } = props; const { application, onClose } = props;
@@ -92,7 +93,7 @@ export default function Application() {
} }
return options; return options;
}, [appKey, appConfig?.data, currentUserAbility]); }, [appKey, appConfig?.data, currentUserAbility, formatMessage]);
if (loading) return null; if (loading) return null;
@@ -118,37 +119,46 @@ export default function Application() {
<Route <Route
path={`${URLS.FLOWS}/*`} path={`${URLS.FLOWS}/*`}
element={ element={
<ConditionalIconButton <Can I="create" a="Flow" passThrough>
type="submit" {(allowed) => (
variant="contained" <ConditionalIconButton
color="primary" type="submit"
size="large" variant="contained"
component={Link} color="primary"
to={URLS.CREATE_FLOW_WITH_APP_AND_CONNECTION( size="large"
appKey, component={Link}
connectionId, to={URLS.CREATE_FLOW_WITH_APP_AND_CONNECTION(
appKey,
connectionId,
)}
fullWidth
icon={<AddIcon />}
disabled={!allowed}
>
{formatMessage('app.createFlow')}
</ConditionalIconButton>
)} )}
fullWidth </Can>
icon={<AddIcon />}
disabled={!currentUserAbility.can('create', 'Flow')}
>
{formatMessage('app.createFlow')}
</ConditionalIconButton>
} }
/> />
<Route <Route
path={`${URLS.CONNECTIONS}/*`} path={`${URLS.CONNECTIONS}/*`}
element={ element={
<SplitButton <Can I="create" a="Connection" passThrough>
disabled={ {(allowed) => (
(appConfig?.data && <SplitButton
!appConfig?.data?.canConnect && disabled={
!appConfig?.data?.canCustomConnect) || !allowed ||
connectionOptions.every(({ disabled }) => disabled) (appConfig?.data &&
} !appConfig?.data?.canConnect &&
options={connectionOptions} !appConfig?.data?.canCustomConnect) ||
/> connectionOptions.every(({ disabled }) => disabled)
}
options={connectionOptions}
/>
)}
</Can>
} }
/> />
</Routes> </Routes>
@@ -169,17 +179,20 @@ export default function Application() {
label={formatMessage('app.connections')} label={formatMessage('app.connections')}
to={URLS.APP_CONNECTIONS(appKey)} to={URLS.APP_CONNECTIONS(appKey)}
value={URLS.APP_CONNECTIONS_PATTERN} value={URLS.APP_CONNECTIONS_PATTERN}
disabled={!app.supportsConnections} disabled={
!currentUserAbility.can('read', 'Connection') ||
!app.supportsConnections
}
component={Link} component={Link}
data-test="connections-tab" data-test="connections-tab"
/> />
<Tab <Tab
label={formatMessage('app.flows')} label={formatMessage('app.flows')}
to={URLS.APP_FLOWS(appKey)} to={URLS.APP_FLOWS(appKey)}
value={URLS.APP_FLOWS_PATTERN} value={URLS.APP_FLOWS_PATTERN}
component={Link} component={Link}
data-test="flows-tab" data-test="flows-tab"
disabled={!currentUserAbility.can('read', 'Flow')}
/> />
</Tabs> </Tabs>
</Box> </Box>
@@ -187,14 +200,20 @@ export default function Application() {
<Routes> <Routes>
<Route <Route
path={`${URLS.FLOWS}/*`} path={`${URLS.FLOWS}/*`}
element={<AppFlows appKey={appKey} />} element={
<Can I="read" a="Flow">
<AppFlows appKey={appKey} />
</Can>
}
/> />
<Route <Route
path={`${URLS.CONNECTIONS}/*`} path={`${URLS.CONNECTIONS}/*`}
element={<AppConnections appKey={appKey} />} element={
<Can I="read" a="Connection">
<AppConnections appKey={appKey} />
</Can>
}
/> />
<Route <Route
path="/" path="/"
element={ element={
@@ -218,17 +237,24 @@ export default function Application() {
<Route <Route
path="/connections/add" path="/connections/add"
element={ element={
<AddAppConnection onClose={goToApplicationPage} application={app} /> <Can I="create" a="Connection">
<AddAppConnection
onClose={goToApplicationPage}
application={app}
/>
</Can>
} }
/> />
<Route <Route
path="/connections/:connectionId/reconnect" path="/connections/:connectionId/reconnect"
element={ element={
<ReconnectConnection <Can I="create" a="Connection">
application={app} <ReconnectConnection
onClose={goToApplicationPage} application={app}
/> onClose={goToApplicationPage}
/>
</Can>
} }
/> />
</Routes> </Routes>

View File

@@ -84,10 +84,14 @@ export default function Applications() {
)} )}
{!isLoading && !hasApps && ( {!isLoading && !hasApps && (
<NoResultFound <Can I="create" a="Connection" passThrough>
text={formatMessage('apps.noConnections')} {(allowed) => (
to={URLS.NEW_APP_CONNECTION} <NoResultFound
/> text={formatMessage('apps.noConnections')}
{...(allowed && { to: URLS.NEW_APP_CONNECTION })}
/>
)}
</Can>
)} )}
{!isLoading && {!isLoading &&