Compare commits

..

1 Commits

Author SHA1 Message Date
kasia.oczkowska
96c3c19a50 feat: introduce paths 2024-06-26 15:45:26 +01:00
35 changed files with 1097 additions and 640 deletions

View File

@@ -33,8 +33,8 @@ class User extends Base {
fullName: { type: 'string', minLength: 1 }, fullName: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email', minLength: 1, maxLength: 255 }, email: { type: 'string', format: 'email', minLength: 1, maxLength: 255 },
password: { type: 'string' }, password: { type: 'string' },
resetPasswordToken: { type: ['string', 'null'] }, resetPasswordToken: { type: 'string' },
resetPasswordTokenSentAt: { type: ['string', 'null'], format: 'date-time' }, resetPasswordTokenSentAt: { type: 'string' },
trialExpiryDate: { type: 'string' }, trialExpiryDate: { type: 'string' },
roleId: { type: 'string', format: 'uuid' }, roleId: { type: 'string', format: 'uuid' },
deletedAt: { type: 'string' }, deletedAt: { type: 'string' },

View File

@@ -40,7 +40,6 @@ export const worker = new Worker(
await user.$relatedQuery('usageData').withSoftDeleted().hardDelete(); await user.$relatedQuery('usageData').withSoftDeleted().hardDelete();
} }
await user.$relatedQuery('accessTokens').withSoftDeleted().hardDelete();
await user.$query().withSoftDeleted().hardDelete(); await user.$query().withSoftDeleted().hardDelete();
}, },
{ connection: redisConfig } { connection: redisConfig }

View File

@@ -6,12 +6,16 @@ We use `lerna` with `yarn workspaces` to manage the mono repository. We have the
. .
├── packages ├── packages
│   ├── backend │   ├── backend
│   ├── cli
│   ├── docs │   ├── docs
│   ├── e2e-tests │   ├── e2e-tests
│   ├── types
│   └── web │   └── web
``` ```
- `backend` - The backend package contains the backend application and all integrations. - `backend` - The backend package contains the backend application and all integrations.
- `cli` - The cli package contains the CLI application of Automatisch.
- `docs` - The docs package contains the documentation website. - `docs` - The docs package contains the documentation website.
- `e2e-tests` - The e2e-tests package contains the end-to-end tests for the internal usage. - `e2e-tests` - The e2e-tests package contains the end-to-end tests for the internal usage.
- `types` - The types package contains the shared types for both the backend and web packages.
- `web` - The web package contains the frontend application of Automatisch. - `web` - The web package contains the frontend application of Automatisch.

View File

@@ -68,10 +68,7 @@ function AccountDropdownMenu(props) {
AccountDropdownMenu.propTypes = { AccountDropdownMenu.propTypes = {
open: PropTypes.bool.isRequired, open: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
anchorEl: PropTypes.oneOfType([ anchorEl: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
PropTypes.func,
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]),
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
}; };

View File

@@ -21,9 +21,7 @@ const CustomOptions = (props) => {
label, label,
initialTabIndex, initialTabIndex,
} = props; } = props;
const [activeTabIndex, setActiveTabIndex] = React.useState(undefined); const [activeTabIndex, setActiveTabIndex] = React.useState(undefined);
React.useEffect( React.useEffect(
function applyInitialActiveTabIndex() { function applyInitialActiveTabIndex() {
setActiveTabIndex((currentActiveTabIndex) => { setActiveTabIndex((currentActiveTabIndex) => {
@@ -35,7 +33,6 @@ const CustomOptions = (props) => {
}, },
[initialTabIndex], [initialTabIndex],
); );
return ( return (
<Popper <Popper
open={open} open={open}
@@ -79,10 +76,7 @@ const CustomOptions = (props) => {
CustomOptions.propTypes = { CustomOptions.propTypes = {
open: PropTypes.bool.isRequired, open: PropTypes.bool.isRequired,
anchorEl: PropTypes.oneOfType([ anchorEl: PropTypes.oneOfType([PropTypes.element, PropTypes.func]).isRequired,
PropTypes.func,
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]),
data: PropTypes.arrayOf( data: PropTypes.arrayOf(
PropTypes.shape({ PropTypes.shape({
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,

View File

@@ -59,7 +59,6 @@ export default function EditorLayout() {
const onFlowStatusUpdate = React.useCallback( const onFlowStatusUpdate = React.useCallback(
async (active) => { async (active) => {
try {
await updateFlowStatus({ await updateFlowStatus({
variables: { variables: {
input: { input: {
@@ -77,7 +76,6 @@ export default function EditorLayout() {
}); });
await queryClient.invalidateQueries({ queryKey: ['flows', flowId] }); await queryClient.invalidateQueries({ queryKey: ['flows', flowId] });
} catch (err) {}
}, },
[flowId, queryClient], [flowId, queryClient],
); );

View File

@@ -1,57 +0,0 @@
import { EdgeLabelRenderer, getStraightPath } 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';
export default function Edge({
sourceX,
sourceY,
targetX,
targetY,
source,
data: { laidOut },
}) {
const { stepCreationInProgress, flowActive, onAddStep } =
useContext(EdgesContext);
const [edgePath, labelX, labelY] = getStraightPath({
sourceX,
sourceY,
targetX,
targetY,
});
return (
<>
<EdgeLabelRenderer>
<IconButton
onClick={() => onAddStep(source)}
color="primary"
sx={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'all',
visibility: laidOut ? 'visible' : 'hidden',
}}
disabled={stepCreationInProgress || 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({
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 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,25 +1,24 @@
import { useEffect, useCallback, createContext, useRef } 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 } from 'reactflow'; import ReactFlow, { useNodesState, useEdgesState } from 'reactflow';
import 'reactflow/dist/style.css'; import 'reactflow/dist/style.css';
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 { CREATE_STEP } from 'graphql/mutations/create-step';
import { useAutoLayout } from './useAutoLayout'; import { useAutoLayout } from './useAutoLayout';
import { useScrollBoundaries } from './useScrollBoundaries'; 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 { import { generateEdges, generateNodes, updatedCollapsedNodes } from './utils';
generateEdgeId, import { EDGE_TYPES, NODE_TYPES } from './constants';
generateInitialEdges, import { useFlow } from './temp/useFlow';
generateInitialNodes, import PathNode from './Nodes/PathNode/PathNode';
updatedCollapsedNodes, import PathsEdge from './Edges/PathsEdge/PathsEdge';
} from './utils'; import NodeEdge from './Edges/NodeEdge/NodeEdge';
import { EDGE_TYPES, INVISIBLE_NODE_ID, NODE_TYPES } from './constants';
export const EdgesContext = createContext(); export const EdgesContext = createContext();
export const NodesContext = createContext(); export const NodesContext = createContext();
@@ -27,27 +26,34 @@ export const NodesContext = createContext();
const nodeTypes = { const nodeTypes = {
[NODE_TYPES.FLOW_STEP]: FlowStepNode, [NODE_TYPES.FLOW_STEP]: FlowStepNode,
[NODE_TYPES.INVISIBLE]: InvisibleNode, [NODE_TYPES.INVISIBLE]: InvisibleNode,
[NODE_TYPES.PATHS]: PathsNode,
[NODE_TYPES.PATH]: PathNode,
}; };
const edgeTypes = { const edgeTypes = {
[EDGE_TYPES.ADD_NODE_EDGE]: Edge, [EDGE_TYPES.ADD_NODE_OR_PATHS_EDGE]: NodeOrPathsEdge,
[EDGE_TYPES.ADD_PATH_EDGE]: PathsEdge,
[EDGE_TYPES.ADD_NODE_EDGE]: NodeEdge,
}; };
const EditorNew = ({ flow }) => { const EditorNew = () =>
const [updateStep] = useMutation(UPDATE_STEP); // { flow }
const queryClient = useQueryClient(); {
const [createStep, { loading: stepCreationInProgress }] = const { flow, createStep, createPaths, createPath } = useFlow();
useMutation(CREATE_STEP); // const [updateStep] = useMutation(UPDATE_STEP);
// const queryClient = useQueryClient();
// const [createStep, { loading: stepCreationInProgress }] =
// useMutation(CREATE_STEP);
const stepCreationInProgress = false;
const [nodes, setNodes, onNodesChange] = useNodesState( const [nodes, setNodes, onNodesChange] = useNodesState(
generateInitialNodes(flow), generateNodes({ steps: flow.steps }),
); );
const [edges, setEdges, onEdgesChange] = useEdgesState( const [edges, setEdges, onEdgesChange] = useEdgesState(
generateInitialEdges(flow), generateEdges({ steps: flow.steps }),
); );
useAutoLayout(); useAutoLayout();
useScrollBoundaries();
const createdStepIdRef = useRef(null); const createdStepIdRef = useRef(null);
@@ -80,153 +86,56 @@ const EditorNew = ({ flow }) => {
const onStepChange = useCallback( const onStepChange = useCallback(
async (step) => { async (step) => {
const mutationInput = { // const mutationInput = {
id: step.id, // id: step.id,
key: step.key, // key: step.key,
parameters: step.parameters, // parameters: step.parameters,
connection: { // connection: {
id: step.connection?.id, // 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: { // [flow.id, updateStep, queryClient],
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 onAddStep = useCallback( const onAddStep = async (previousStepId) => {
async (previousStepId) => { const createdStepId = createStep(flow, previousStepId);
const mutationInput = {
previousStep: {
id: previousStepId,
},
flow: {
id: flow.id,
},
};
const {
data: { createStep: createdStep },
} = await createStep({
variables: { input: mutationInput },
});
const createdStepId = createdStep.id;
await queryClient.invalidateQueries({ queryKey: ['flows', flow.id] });
createdStepIdRef.current = createdStepId; createdStepIdRef.current = createdStepId;
}, };
[flow.id, createStep, queryClient],
); console.log({ flow });
useEffect(() => { useEffect(() => {
if (flow.steps.length + 1 !== nodes.length) { // if (flow.steps.length + 1 !== nodes.length) {
setNodes((nodes) => { setNodes((nodes) =>
const newNodes = flow.steps.map((step) => { generateNodes({
const createdStepId = createdStepIdRef.current; prevNodes: nodes,
const prevNode = nodes.find(({ id }) => id === step.id); steps: flow.steps,
if (prevNode) { createdStepId: createdStepIdRef.current,
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,
},
};
}
});
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,
},
},
];
});
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]; setEdges((edges) =>
const lastEdge = edges[edges.length - 1]; generateEdges({ prevEdges: edges, steps: flow.steps }),
);
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) { if (createdStepIdRef.current) {
createdStepIdRef.current = null; createdStepIdRef.current = null;
} }
} // }
}, [flow.steps]); }, [flow.steps]);
return ( return (
@@ -244,6 +153,8 @@ const EditorNew = ({ flow }) => {
value={{ value={{
stepCreationInProgress, stepCreationInProgress,
onAddStep, onAddStep,
onAddPaths: createPaths,
onAddPath: createPath,
flowActive: flow.active, flowActive: flow.active,
}} }}
> >
@@ -255,13 +166,9 @@ const EditorNew = ({ flow }) => {
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
edgeTypes={edgeTypes} edgeTypes={edgeTypes}
panOnScroll fitView
panOnScrollMode="vertical" maxZoom={1}
panOnDrag={false} minZoom={0.001}
zoomOnScroll={false}
zoomOnPinch={false}
zoomOnDoubleClick={false}
panActivationKeyCode={null}
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
/> />
</EditorWrapper> </EditorWrapper>

View File

@@ -1,60 +0,0 @@
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';
function FlowStepNode({ data: { collapsed, laidOut }, id }) {
const { openNextStep, onStepOpen, onStepClose, onStepChange, flowId, steps } =
useContext(NodesContext);
const step = steps.find(({ id: stepId }) => stepId === id);
return (
<NodeWrapper
className="nodrag"
sx={{
visibility: laidOut ? 'visible' : 'hidden',
}}
>
<NodeInnerWrapper>
<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)}
/>
)}
<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

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

@@ -3,8 +3,12 @@ export const INVISIBLE_NODE_ID = 'invisible-node';
export const NODE_TYPES = { export const NODE_TYPES = {
FLOW_STEP: 'flowStep', FLOW_STEP: 'flowStep',
INVISIBLE: 'invisible', INVISIBLE: 'invisible',
PATHS: 'parallelPaths',
PATH: 'path',
}; };
export const EDGE_TYPES = { export const EDGE_TYPES = {
ADD_NODE_OR_PATHS_EDGE: 'addNodeOrPathsEdge',
ADD_PATH_EDGE: 'addPathEdge',
ADD_NODE_EDGE: 'addNodeEdge', 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

@@ -32,7 +32,7 @@ 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) => {
@@ -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,6 +1,8 @@
import { INVISIBLE_NODE_ID, NODE_TYPES } from './constants'; 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 generateEdgeId = (sourceId, targetId) =>
`${sourceId}--${targetId}`;
export const updatedCollapsedNodes = (nodes, openStepId) => { export const updatedCollapsedNodes = (nodes, openStepId) => {
return nodes.map((node) => { return nodes.map((node) => {
@@ -17,11 +19,31 @@ export const updatedCollapsedNodes = (nodes, openStepId) => {
}); });
}; };
export const generateInitialNodes = (flow) => { export const generateNodes = ({ steps, prevNodes, createdStepId }) => {
const newNodes = flow.steps.map((step, index) => { const newNodes = steps.map((step, index) => {
const collapsed = index !== 0; const collapsed = index !== 0;
return { 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, id: step.id,
type: NODE_TYPES.FLOW_STEP, type: NODE_TYPES.FLOW_STEP,
position: { position: {
@@ -34,55 +56,190 @@ export const generateInitialNodes = (flow) => {
laidOut: false, laidOut: false,
}, },
}; };
}); }
break;
return [ }
...newNodes, case 'parallelPaths': {
{ if (prevNode) {
id: INVISIBLE_NODE_ID, newNode = prevNode;
type: NODE_TYPES.INVISIBLE, } else {
newNode = {
id: step.id,
type: NODE_TYPES.PATHS,
position: { position: {
x: 0, x: 0,
y: 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 generateInitialEdges = (flow) => { export const generateEdges = ({ steps }) => {
const newEdges = flow.steps const newEdges = steps.map((step, index) => {
.map((step, i) => { switch (step.type) {
case 'parallelPaths': {
const edges = step.steps.map((childStep) => {
const sourceId = step.id; const sourceId = step.id;
const targetId = flow.steps[i + 1]?.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) { if (targetId) {
return { return {
id: generateEdgeId(sourceId, targetId), id: generateEdgeId(sourceId, targetId),
source: sourceId, source: sourceId,
target: targetId, target: targetId,
type: 'addNodeEdge', type: EDGE_TYPES.ADD_NODE_OR_PATHS_EDGE,
data: { data: {
laidOut: false, laidOut: false,
}, },
}; };
} }
return null; return null;
}) }
.filter((edge) => !!edge); }
});
const lastStep = flow.steps[flow.steps.length - 1]; return newEdges.flat(Infinity).filter((edge) => !!edge);
return lastStep
? [
...newEdges,
{
id: generateEdgeId(lastStep.id, INVISIBLE_NODE_ID),
source: lastStep.id,
target: INVISIBLE_NODE_ID,
type: 'addNodeEdge',
data: {
laidOut: false,
},
},
]
: newEdges;
}; };
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

@@ -11,6 +11,9 @@ import IconButton from '@mui/material/IconButton';
import ErrorIcon from '@mui/icons-material/Error'; import ErrorIcon from '@mui/icons-material/Error';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import { EditorContext } from 'contexts/Editor'; import { EditorContext } from 'contexts/Editor';
import { StepExecutionsProvider } from 'contexts/StepExecutions'; import { StepExecutionsProvider } from 'contexts/StepExecutions';
import TestSubstep from 'components/TestSubstep'; import TestSubstep from 'components/TestSubstep';
@@ -30,20 +33,79 @@ import {
Header, Header,
Wrapper, Wrapper,
} from './style'; } from './style';
import isEmpty from 'helpers/isEmpty';
import { StepPropType } from 'propTypes/propTypes'; import { StepPropType } from 'propTypes/propTypes';
import useTriggers from 'hooks/useTriggers'; import useTriggers from 'hooks/useTriggers';
import useActions from 'hooks/useActions'; import useActions from 'hooks/useActions';
import useTriggerSubsteps from 'hooks/useTriggerSubsteps'; import useTriggerSubsteps from 'hooks/useTriggerSubsteps';
import useActionSubsteps from 'hooks/useActionSubsteps'; import useActionSubsteps from 'hooks/useActionSubsteps';
import useStepWithTestExecutions from 'hooks/useStepWithTestExecutions'; import useStepWithTestExecutions from 'hooks/useStepWithTestExecutions';
import { validationSchemaResolver } from './validation';
import { isEqual } from 'lodash';
const validIcon = <CheckCircleIcon color="success" />; const validIcon = <CheckCircleIcon color="success" />;
const errorIcon = <ErrorIcon color="error" />; const errorIcon = <ErrorIcon color="error" />;
function generateValidationSchema(substeps) {
const fieldValidations = substeps?.reduce(
(allValidations, { arguments: args }) => {
if (!args || !Array.isArray(args)) return allValidations;
const substepArgumentValidations = {};
for (const arg of args) {
const { key, required } = arg;
// base validation for the field if not exists
if (!substepArgumentValidations[key]) {
substepArgumentValidations[key] = yup.mixed();
}
if (
typeof substepArgumentValidations[key] === 'object' &&
(arg.type === 'string' || arg.type === 'dropdown')
) {
// if the field is required, add the required validation
if (required) {
substepArgumentValidations[key] = substepArgumentValidations[key]
.required(`${key} is required.`)
.test(
'empty-check',
`${key} must be not empty`,
(value) => !isEmpty(value),
);
}
// if the field depends on another field, add the dependsOn required validation
if (Array.isArray(arg.dependsOn) && arg.dependsOn.length > 0) {
for (const dependsOnKey of arg.dependsOn) {
const missingDependencyValueMessage = `We're having trouble loading '${key}' data as required field '${dependsOnKey}' is missing.`;
// TODO: make `dependsOnKey` agnostic to the field. However, nested validation schema is not supported.
// So the fields under the `parameters` key are subject to their siblings only and thus, `parameters.` is removed.
substepArgumentValidations[key] = substepArgumentValidations[
key
].when(`${dependsOnKey.replace('parameters.', '')}`, {
is: (value) => Boolean(value) === false,
then: (schema) =>
schema
.notOneOf([''], missingDependencyValueMessage)
.required(missingDependencyValueMessage),
});
}
}
}
}
return {
...allValidations,
...substepArgumentValidations,
};
},
{},
);
const validationSchema = yup.object({
parameters: yup.object(fieldValidations),
});
return yupResolver(validationSchema);
}
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;
@@ -52,10 +114,6 @@ function FlowStep(props) {
const isAction = step.type === 'action'; const isAction = step.type === 'action';
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const [currentSubstep, setCurrentSubstep] = React.useState(0); const [currentSubstep, setCurrentSubstep] = React.useState(0);
const [formResolverContext, setFormResolverContext] = React.useState({
substeps: [],
additionalFields: {},
});
const useAppsOptions = {}; const useAppsOptions = {};
if (isTrigger) { if (isTrigger) {
@@ -110,12 +168,6 @@ function FlowStep(props) {
? triggerSubstepsData ? triggerSubstepsData
: actionSubstepsData || []; : actionSubstepsData || [];
React.useEffect(() => {
if (!isEqual(substeps, formResolverContext.substeps)) {
setFormResolverContext({ substeps, additionalFields: {} });
}
}, [substeps]);
const handleChange = React.useCallback(({ step }) => { const handleChange = React.useCallback(({ step }) => {
onChange(step); onChange(step);
}, []); }, []);
@@ -128,6 +180,11 @@ function FlowStep(props) {
handleChange({ step: val }); handleChange({ step: val });
}; };
const stepValidationSchema = React.useMemo(
() => generateValidationSchema(substeps),
[substeps],
);
if (!apps?.data) { if (!apps?.data) {
return ( return (
<CircularProgress <CircularProgress
@@ -156,15 +213,6 @@ function FlowStep(props) {
value !== substepIndex ? substepIndex : null, value !== substepIndex ? substepIndex : null,
); );
const addAdditionalFieldsValidation = (additionalFields) => {
if (additionalFields) {
setFormResolverContext((prev) => ({
...prev,
additionalFields: { ...prev.additionalFields, ...additionalFields },
}));
}
};
const validationStatusIcon = const validationStatusIcon =
step.status === 'completed' ? validIcon : errorIcon; step.status === 'completed' ? validIcon : errorIcon;
@@ -211,15 +259,18 @@ 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}>
<Form <Form
defaultValues={step} defaultValues={step}
onSubmit={handleSubmit} onSubmit={handleSubmit}
resolver={validationSchemaResolver} resolver={stepValidationSchema}
context={formResolverContext}
> >
<ChooseAppAndEventSubstep <ChooseAppAndEventSubstep
expanded={currentSubstep === 0} expanded={currentSubstep === 0}
@@ -283,9 +334,6 @@ function FlowStep(props) {
onSubmit={expandNextStep} onSubmit={expandNextStep}
onChange={handleChange} onChange={handleChange}
step={step} step={step}
addAdditionalFieldsValidation={
addAdditionalFieldsValidation
}
/> />
)} )}
</React.Fragment> </React.Fragment>
@@ -320,6 +368,11 @@ FlowStep.propTypes = {
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

@@ -1,120 +0,0 @@
import * as yup from 'yup';
import { yupResolver } from '@hookform/resolvers/yup';
import isEmpty from 'helpers/isEmpty';
function addRequiredValidation({ required, schema, key }) {
// if the field is required, add the required validation
if (required) {
return schema
.required(`${key} is required.`)
.test(
'empty-check',
`${key} must be not empty`,
(value) => !isEmpty(value),
);
}
return schema;
}
function addDependsOnValidation({ schema, dependsOn, key, args }) {
// if the field depends on another field, add the dependsOn required validation
if (Array.isArray(dependsOn) && dependsOn.length > 0) {
for (const dependsOnKey of dependsOn) {
const dependsOnKeyShort = dependsOnKey.replace('parameters.', '');
const dependsOnField = args.find(({ key }) => key === dependsOnKeyShort);
if (dependsOnField?.required) {
const missingDependencyValueMessage = `We're having trouble loading '${key}' data as required field '${dependsOnKey}' is missing.`;
// TODO: make `dependsOnKey` agnostic to the field. However, nested validation schema is not supported.
// So the fields under the `parameters` key are subject to their siblings only and thus, `parameters.` is removed.
return schema.when(dependsOnKeyShort, {
is: (dependsOnValue) => Boolean(dependsOnValue) === false,
then: (schema) =>
schema
.notOneOf([''], missingDependencyValueMessage)
.required(missingDependencyValueMessage),
});
}
}
}
return schema;
}
export function validationSchemaResolver(data, context, options) {
const { substeps = [], additionalFields = {} } = context;
const fieldValidations = [
...substeps,
{
arguments: Object.values(additionalFields)
.filter((field) => !!field)
.flat(),
},
].reduce((allValidations, { arguments: args }) => {
if (!args || !Array.isArray(args)) return allValidations;
const substepArgumentValidations = {};
for (const arg of args) {
const { key, required } = arg;
// base validation for the field if not exists
if (!substepArgumentValidations[key]) {
substepArgumentValidations[key] = yup.mixed();
}
if (arg.type === 'dynamic') {
const fieldsSchema = {};
for (const field of arg.fields) {
fieldsSchema[field.key] = yup.mixed();
fieldsSchema[field.key] = addRequiredValidation({
required: field.required,
schema: fieldsSchema[field.key],
key: field.key,
});
fieldsSchema[field.key] = addDependsOnValidation({
schema: fieldsSchema[field.key],
dependsOn: field.dependsOn,
key: field.key,
args,
});
}
substepArgumentValidations[key] = yup
.array()
.of(yup.object(fieldsSchema));
} else if (
typeof substepArgumentValidations[key] === 'object' &&
(arg.type === 'string' || arg.type === 'dropdown')
) {
substepArgumentValidations[key] = addRequiredValidation({
required,
schema: substepArgumentValidations[key],
key,
});
substepArgumentValidations[key] = addDependsOnValidation({
schema: substepArgumentValidations[key],
dependsOn: arg.dependsOn,
key,
args,
});
}
}
return {
...allValidations,
...substepArgumentValidations,
};
}, {});
const validationSchema = yup.object({
parameters: yup.object(fieldValidations),
});
return yupResolver(validationSchema)(data, context, options);
}

View File

@@ -43,10 +43,7 @@ function FlowStepContextMenu(props) {
FlowStepContextMenu.propTypes = { FlowStepContextMenu.propTypes = {
stepId: PropTypes.string.isRequired, stepId: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
anchorEl: PropTypes.oneOfType([ anchorEl: PropTypes.element.isRequired,
PropTypes.func,
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]).isRequired,
deletable: PropTypes.bool.isRequired, deletable: PropTypes.bool.isRequired,
}; };

View File

@@ -19,9 +19,7 @@ function FlowSubstep(props) {
onCollapse, onCollapse,
onSubmit, onSubmit,
step, step,
addAdditionalFieldsValidation,
} = props; } = props;
const { name, arguments: args } = substep; const { name, arguments: args } = substep;
const editorContext = React.useContext(EditorContext); const editorContext = React.useContext(EditorContext);
const formContext = useFormContext(); const formContext = useFormContext();
@@ -56,7 +54,6 @@ function FlowSubstep(props) {
stepId={step.id} stepId={step.id}
disabled={editorContext.readOnly} disabled={editorContext.readOnly}
showOptionValue={true} showOptionValue={true}
addAdditionalFieldsValidation={addAdditionalFieldsValidation}
/> />
))} ))}
</Stack> </Stack>

View File

@@ -1,8 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { FormProvider, useForm, useWatch } from 'react-hook-form'; import { FormProvider, useForm, useWatch } from 'react-hook-form';
const noop = () => null; const noop = () => null;
export default function Form(props) { export default function Form(props) {
const { const {
children, children,
@@ -11,31 +9,24 @@ export default function Form(props) {
resolver, resolver,
render, render,
mode = 'all', mode = 'all',
context,
...formProps ...formProps
} = props; } = props;
const methods = useForm({ const methods = useForm({
defaultValues, defaultValues,
reValidateMode: 'onBlur', reValidateMode: 'onBlur',
resolver, resolver,
mode, mode,
context,
}); });
const form = useWatch({ control: methods.control }); const form = useWatch({ control: methods.control });
/** /**
* For fields having `dependsOn` fields, we need to re-validate the form. * For fields having `dependsOn` fields, we need to re-validate the form.
*/ */
React.useEffect(() => { React.useEffect(() => {
methods.trigger(); methods.trigger();
}, [methods.trigger, form]); }, [methods.trigger, form]);
React.useEffect(() => { React.useEffect(() => {
methods.reset(defaultValues); methods.reset(defaultValues);
}, [defaultValues]); }, [defaultValues]);
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)} {...formProps}> <form onSubmit={methods.handleSubmit(onSubmit)} {...formProps}>

View File

@@ -23,9 +23,7 @@ export default function InputCreator(props) {
disabled, disabled,
showOptionValue, showOptionValue,
shouldUnregister, shouldUnregister,
addAdditionalFieldsValidation,
} = props; } = props;
const { const {
key: name, key: name,
label, label,
@@ -35,7 +33,6 @@ export default function InputCreator(props) {
description, description,
type, type,
} = schema; } = schema;
const { data, loading } = useDynamicData(stepId, schema); const { data, loading } = useDynamicData(stepId, schema);
const { data: additionalFieldsData, isLoading: isDynamicFieldsLoading } = const { data: additionalFieldsData, isLoading: isDynamicFieldsLoading } =
useDynamicFields(stepId, schema); useDynamicFields(stepId, schema);
@@ -43,10 +40,6 @@ export default function InputCreator(props) {
const computedName = namePrefix ? `${namePrefix}.${name}` : name; const computedName = namePrefix ? `${namePrefix}.${name}` : name;
React.useEffect(() => {
addAdditionalFieldsValidation?.({ [name]: additionalFields });
}, [additionalFields]);
if (type === 'dynamic') { if (type === 'dynamic') {
return ( return (
<DynamicField <DynamicField

View File

@@ -5,10 +5,8 @@ import AddCircleIcon from '@mui/icons-material/AddCircle';
import CardActionArea from '@mui/material/CardActionArea'; import CardActionArea from '@mui/material/CardActionArea';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { CardContent } from './style'; import { CardContent } from './style';
export default function NoResultFound(props) { export default function NoResultFound(props) {
const { text, to } = props; const { text, to } = props;
const ActionAreaLink = React.useMemo( const ActionAreaLink = React.useMemo(
() => () =>
React.forwardRef(function InlineLink(linkProps, ref) { React.forwardRef(function InlineLink(linkProps, ref) {
@@ -17,12 +15,12 @@ export default function NoResultFound(props) {
}), }),
[to], [to],
); );
return ( return (
<Card elevation={0}> <Card elevation={0}>
<CardActionArea component={ActionAreaLink} {...props}> <CardActionArea component={ActionAreaLink} {...props}>
<CardContent> <CardContent>
{!!to && <AddCircleIcon color="primary" />} {!!to && <AddCircleIcon color="primary" />}
<Typography variant="body1">{text}</Typography> <Typography variant="body1">{text}</Typography>
</CardContent> </CardContent>
</CardActionArea> </CardActionArea>

View File

@@ -7,7 +7,6 @@ import { useQuery } from '@tanstack/react-query';
import api from 'helpers/api'; import api from 'helpers/api';
const variableRegExp = /({.*?})/; const variableRegExp = /({.*?})/;
// TODO: extract this function to a separate file // TODO: extract this function to a separate file
function computeArguments(args, getValues) { function computeArguments(args, getValues) {
const initialValue = {}; const initialValue = {};

View File

@@ -1,6 +1,4 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { Settings } from 'luxon';
import ThemeProvider from 'components/ThemeProvider'; import ThemeProvider from 'components/ThemeProvider';
import IntlProvider from 'components/IntlProvider'; import IntlProvider from 'components/IntlProvider';
import ApolloProvider from 'components/ApolloProvider'; import ApolloProvider from 'components/ApolloProvider';
@@ -12,9 +10,6 @@ import Router from 'components/Router';
import routes from 'routes'; import routes from 'routes';
import reportWebVitals from './reportWebVitals'; import reportWebVitals from './reportWebVitals';
// Sets the default locale to English for all luxon DateTime instances created afterwards.
Settings.defaultLocale = 'en';
const container = document.getElementById('root'); const container = document.getElementById('root');
const root = createRoot(container); const root = createRoot(container);

View File

@@ -7,15 +7,13 @@ import * as URLS from 'config/urls';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import { CREATE_FLOW } from 'graphql/mutations/create-flow'; import { CREATE_FLOW } from 'graphql/mutations/create-flow';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
export default function CreateFlow() { export default function CreateFlow() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const [createFlow, { error }] = useMutation(CREATE_FLOW); const [createFlow] = useMutation(CREATE_FLOW);
const appKey = searchParams.get('appKey'); const appKey = searchParams.get('appKey');
const connectionId = searchParams.get('connectionId'); const connectionId = searchParams.get('connectionId');
React.useEffect(() => { React.useEffect(() => {
async function initiate() { async function initiate() {
const variables = {}; const variables = {};
@@ -35,11 +33,6 @@ export default function CreateFlow() {
} }
initiate(); initiate();
}, [createFlow, navigate, appKey, connectionId]); }, [createFlow, navigate, appKey, connectionId]);
if (error) {
return null;
}
return ( return (
<Box <Box
sx={{ sx={{
@@ -52,6 +45,7 @@ export default function CreateFlow() {
}} }}
> >
<CircularProgress size={16} thickness={7.5} /> <CircularProgress size={16} thickness={7.5} />
<Typography variant="body2"> <Typography variant="body2">
{formatMessage('createFlow.creating')} {formatMessage('createFlow.creating')}
</Typography> </Typography>

View File

@@ -17,7 +17,6 @@ import Container from 'components/Container';
import PageTitle from 'components/PageTitle'; import PageTitle from 'components/PageTitle';
import SearchInput from 'components/SearchInput'; import SearchInput from 'components/SearchInput';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import useCurrentUserAbility from 'hooks/useCurrentUserAbility';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
import useLazyFlows from 'hooks/useLazyFlows'; import useLazyFlows from 'hooks/useLazyFlows';
@@ -27,7 +26,6 @@ export default function Flows() {
const page = parseInt(searchParams.get('page') || '', 10) || 1; const page = parseInt(searchParams.get('page') || '', 10) || 1;
const [flowName, setFlowName] = React.useState(''); const [flowName, setFlowName] = React.useState('');
const [isLoading, setIsLoading] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false);
const currentUserAbility = useCurrentUserAbility();
const { data, mutate: fetchFlows } = useLazyFlows( const { data, mutate: fetchFlows } = useLazyFlows(
{ flowName, page }, { flowName, page },
@@ -126,9 +124,7 @@ export default function Flows() {
{!isLoading && !hasFlows && ( {!isLoading && !hasFlows && (
<NoResultFound <NoResultFound
text={formatMessage('flows.noFlows')} text={formatMessage('flows.noFlows')}
{...(currentUserAbility.can('create', 'Flow') && { to={URLS.CREATE_FLOW}
to: URLS.CREATE_FLOW,
})}
/> />
)} )}
{!isLoading && pageInfo && pageInfo.totalPages > 1 && ( {!isLoading && pageInfo && pageInfo.totalPages > 1 && (