Compare commits
27 Commits
AUT-1011
...
AUT-157-AU
Author | SHA1 | Date | |
---|---|---|---|
![]() |
8f3ecb6d4d | ||
![]() |
47caa5aa37 | ||
![]() |
725b38c697 | ||
![]() |
402a0fdf3b | ||
![]() |
078364ffa1 | ||
![]() |
f64d5ec4fc | ||
![]() |
12194a50e1 | ||
![]() |
82ee592699 | ||
![]() |
1b4fb2ce6e | ||
![]() |
ebea8d12d1 | ||
![]() |
f842dd77df | ||
![]() |
a6ec7a6c99 | ||
![]() |
369c72282c | ||
![]() |
6f30c1a509 | ||
![]() |
abfd1116c7 | ||
![]() |
017854955d | ||
![]() |
1405cddea1 | ||
![]() |
00dd3164c9 | ||
![]() |
d5cbc0f611 | ||
![]() |
5d2e9ccc67 | ||
![]() |
017a881494 | ||
![]() |
52994970e6 | ||
![]() |
ebae629e5c | ||
![]() |
4d79220b0c | ||
![]() |
96fba7fbb8 | ||
![]() |
e0d610071d | ||
![]() |
ab0966c005 |
@@ -33,8 +33,8 @@ class User extends Base {
|
||||
fullName: { type: 'string', minLength: 1 },
|
||||
email: { type: 'string', format: 'email', minLength: 1, maxLength: 255 },
|
||||
password: { type: 'string' },
|
||||
resetPasswordToken: { type: 'string' },
|
||||
resetPasswordTokenSentAt: { type: 'string' },
|
||||
resetPasswordToken: { type: ['string', 'null'] },
|
||||
resetPasswordTokenSentAt: { type: ['string', 'null'], format: 'date-time' },
|
||||
trialExpiryDate: { type: 'string' },
|
||||
roleId: { type: 'string', format: 'uuid' },
|
||||
deletedAt: { type: 'string' },
|
||||
|
@@ -40,6 +40,7 @@ export const worker = new Worker(
|
||||
await user.$relatedQuery('usageData').withSoftDeleted().hardDelete();
|
||||
}
|
||||
|
||||
await user.$relatedQuery('accessTokens').withSoftDeleted().hardDelete();
|
||||
await user.$query().withSoftDeleted().hardDelete();
|
||||
},
|
||||
{ connection: redisConfig }
|
||||
|
@@ -6,16 +6,12 @@ We use `lerna` with `yarn workspaces` to manage the mono repository. We have the
|
||||
.
|
||||
├── packages
|
||||
│ ├── backend
|
||||
│ ├── cli
|
||||
│ ├── docs
|
||||
│ ├── e2e-tests
|
||||
│ ├── types
|
||||
│ └── web
|
||||
```
|
||||
|
||||
- `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.
|
||||
- `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.
|
||||
|
@@ -68,7 +68,10 @@ function AccountDropdownMenu(props) {
|
||||
AccountDropdownMenu.propTypes = {
|
||||
open: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
anchorEl: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
|
||||
anchorEl: PropTypes.oneOfType([
|
||||
PropTypes.func,
|
||||
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
|
||||
]),
|
||||
id: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
|
@@ -8,6 +8,7 @@ import * as URLS from 'config/urls';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import { ConnectionPropType } from 'propTypes/propTypes';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import Can from 'components/Can';
|
||||
|
||||
function ContextMenu(props) {
|
||||
const {
|
||||
@@ -44,34 +45,57 @@ function ContextMenu(props) {
|
||||
hideBackdrop={false}
|
||||
anchorEl={anchorEl}
|
||||
>
|
||||
<MenuItem
|
||||
component={Link}
|
||||
to={URLS.APP_FLOWS_FOR_CONNECTION(appKey, connection.id)}
|
||||
onClick={createActionHandler({ type: 'viewFlows' })}
|
||||
>
|
||||
{formatMessage('connection.viewFlows')}
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={createActionHandler({ type: 'test' })}>
|
||||
{formatMessage('connection.testConnection')}
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
component={Link}
|
||||
disabled={disableReconnection}
|
||||
to={URLS.APP_RECONNECT_CONNECTION(
|
||||
appKey,
|
||||
connection.id,
|
||||
connection.appAuthClientId,
|
||||
<Can I="read" a="Flow" passThrough>
|
||||
{(allowed) => (
|
||||
<MenuItem
|
||||
component={Link}
|
||||
to={URLS.APP_FLOWS_FOR_CONNECTION(appKey, connection.id)}
|
||||
onClick={createActionHandler({ type: 'viewFlows' })}
|
||||
disabled={!allowed}
|
||||
>
|
||||
{formatMessage('connection.viewFlows')}
|
||||
</MenuItem>
|
||||
)}
|
||||
onClick={createActionHandler({ type: 'reconnect' })}
|
||||
>
|
||||
{formatMessage('connection.reconnect')}
|
||||
</MenuItem>
|
||||
</Can>
|
||||
|
||||
<MenuItem onClick={createActionHandler({ type: 'delete' })}>
|
||||
{formatMessage('connection.delete')}
|
||||
</MenuItem>
|
||||
<Can I="update" a="Connection" passThrough>
|
||||
{(allowed) => (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import AppConnectionRow from 'components/AppConnectionRow';
|
||||
import NoResultFound from 'components/NoResultFound';
|
||||
import Can from 'components/Can';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import * as URLS from 'config/urls';
|
||||
import useAppConnections from 'hooks/useAppConnections';
|
||||
@@ -16,11 +17,15 @@ function AppConnections(props) {
|
||||
|
||||
if (!hasConnections) {
|
||||
return (
|
||||
<NoResultFound
|
||||
to={URLS.APP_ADD_CONNECTION(appKey)}
|
||||
text={formatMessage('app.noConnections')}
|
||||
data-test="connections-no-results"
|
||||
/>
|
||||
<Can I="create" a="Connection" passThrough>
|
||||
{(allowed) => (
|
||||
<NoResultFound
|
||||
text={formatMessage('app.noConnections')}
|
||||
data-test="connections-no-results"
|
||||
{...(allowed && { to: URLS.APP_ADD_CONNECTION(appKey) })}
|
||||
/>
|
||||
)}
|
||||
</Can>
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -5,6 +5,7 @@ import PaginationItem from '@mui/material/PaginationItem';
|
||||
|
||||
import * as URLS from 'config/urls';
|
||||
import AppFlowRow from 'components/FlowRow';
|
||||
import Can from 'components/Can';
|
||||
import NoResultFound from 'components/NoResultFound';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import useConnectionFlows from 'hooks/useConnectionFlows';
|
||||
@@ -36,11 +37,20 @@ function AppFlows(props) {
|
||||
|
||||
if (!hasFlows) {
|
||||
return (
|
||||
<NoResultFound
|
||||
to={URLS.CREATE_FLOW_WITH_APP_AND_CONNECTION(appKey, connectionId)}
|
||||
text={formatMessage('app.noFlows')}
|
||||
data-test="flows-no-results"
|
||||
/>
|
||||
<Can I="create" a="Flow" passThrough>
|
||||
{(allowed) => (
|
||||
<NoResultFound
|
||||
text={formatMessage('app.noFlows')}
|
||||
data-test="flows-no-results"
|
||||
{...(allowed && {
|
||||
to: URLS.CREATE_FLOW_WITH_APP_AND_CONNECTION(
|
||||
appKey,
|
||||
connectionId
|
||||
),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</Can>
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -21,7 +21,9 @@ const CustomOptions = (props) => {
|
||||
label,
|
||||
initialTabIndex,
|
||||
} = props;
|
||||
|
||||
const [activeTabIndex, setActiveTabIndex] = React.useState(undefined);
|
||||
|
||||
React.useEffect(
|
||||
function applyInitialActiveTabIndex() {
|
||||
setActiveTabIndex((currentActiveTabIndex) => {
|
||||
@@ -33,6 +35,7 @@ const CustomOptions = (props) => {
|
||||
},
|
||||
[initialTabIndex],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popper
|
||||
open={open}
|
||||
@@ -76,7 +79,10 @@ const CustomOptions = (props) => {
|
||||
|
||||
CustomOptions.propTypes = {
|
||||
open: PropTypes.bool.isRequired,
|
||||
anchorEl: PropTypes.oneOfType([PropTypes.element, PropTypes.func]).isRequired,
|
||||
anchorEl: PropTypes.oneOfType([
|
||||
PropTypes.func,
|
||||
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
|
||||
]),
|
||||
data: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
|
@@ -61,6 +61,7 @@ function ControlledCustomAutocomplete(props) {
|
||||
const [isSingleChoice, setSingleChoice] = React.useState(undefined);
|
||||
const priorStepsWithExecutions = React.useContext(StepExecutionsContext);
|
||||
const editorRef = React.useRef(null);
|
||||
const mountedRef = React.useRef(false);
|
||||
|
||||
const renderElement = React.useCallback(
|
||||
(props) => <Element {...props} disabled={disabled} />,
|
||||
@@ -94,10 +95,14 @@ function ControlledCustomAutocomplete(props) {
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const hasDependencies = dependsOnValues.length;
|
||||
if (hasDependencies) {
|
||||
// Reset the field when a dependent has been updated
|
||||
resetEditor(editor);
|
||||
if (mountedRef.current) {
|
||||
const hasDependencies = dependsOnValues.length;
|
||||
if (hasDependencies) {
|
||||
// Reset the field when a dependent has been updated
|
||||
resetEditor(editor);
|
||||
}
|
||||
} else {
|
||||
mountedRef.current = true;
|
||||
}
|
||||
}, dependsOnValues);
|
||||
|
||||
|
@@ -64,11 +64,19 @@ function DynamicField(props) {
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={{ xs: 2 }}
|
||||
sx={{ display: 'flex', flex: 1 }}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{fields.map((fieldSchema, fieldSchemaIndex) => (
|
||||
<Box
|
||||
sx={{ display: 'flex', flex: '1 0 0px' }}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flex: '1 0 0px',
|
||||
minWidth: 0,
|
||||
}}
|
||||
key={`field-${field.__id}-${fieldSchemaIndex}`}
|
||||
>
|
||||
<InputCreator
|
||||
|
@@ -59,23 +59,25 @@ export default function EditorLayout() {
|
||||
|
||||
const onFlowStatusUpdate = React.useCallback(
|
||||
async (active) => {
|
||||
await updateFlowStatus({
|
||||
variables: {
|
||||
input: {
|
||||
id: flowId,
|
||||
active,
|
||||
try {
|
||||
await updateFlowStatus({
|
||||
variables: {
|
||||
input: {
|
||||
id: flowId,
|
||||
active,
|
||||
},
|
||||
},
|
||||
},
|
||||
optimisticResponse: {
|
||||
updateFlowStatus: {
|
||||
__typename: 'Flow',
|
||||
id: flowId,
|
||||
active,
|
||||
optimisticResponse: {
|
||||
updateFlowStatus: {
|
||||
__typename: 'Flow',
|
||||
id: flowId,
|
||||
active,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ['flows', flowId] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['flows', flowId] });
|
||||
} catch (err) {}
|
||||
},
|
||||
[flowId, queryClient],
|
||||
);
|
||||
|
@@ -1,10 +1,10 @@
|
||||
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';
|
||||
import { useContext } from 'react';
|
||||
import { EdgesContext } from '../EditorNew';
|
||||
|
||||
export default function Edge({
|
||||
sourceX,
|
||||
@@ -12,11 +12,11 @@ export default function Edge({
|
||||
targetX,
|
||||
targetY,
|
||||
source,
|
||||
data: { flowId, setCurrentStepId, flowActive, layouted },
|
||||
data: { laidOut },
|
||||
}) {
|
||||
const [createStep, { loading: creationInProgress }] =
|
||||
useMutation(CREATE_STEP);
|
||||
const queryClient = useQueryClient();
|
||||
const { stepCreationInProgress, flowActive, onAddStep } =
|
||||
useContext(EdgesContext);
|
||||
|
||||
const [edgePath, labelX, labelY] = getStraightPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
@@ -24,38 +24,19 @@ export default function Edge({
|
||||
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)}
|
||||
onClick={() => onAddStep(source)}
|
||||
color="primary"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||
pointerEvents: 'all',
|
||||
visibility: layouted ? 'visible' : 'hidden',
|
||||
visibility: laidOut ? 'visible' : 'hidden',
|
||||
}}
|
||||
disabled={creationInProgress || flowActive}
|
||||
disabled={stepCreationInProgress || flowActive}
|
||||
>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
@@ -71,9 +52,6 @@ Edge.propTypes = {
|
||||
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,
|
||||
laidOut: PropTypes.bool,
|
||||
}).isRequired,
|
||||
};
|
||||
|
@@ -1,51 +1,81 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useCallback, createContext, useRef } from 'react';
|
||||
import { useMutation } from '@apollo/client';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { FlowPropType } from 'propTypes/propTypes';
|
||||
import ReactFlow, { useNodesState, useEdgesState, addEdge } from 'reactflow';
|
||||
import ReactFlow, { useNodesState, useEdgesState } from 'reactflow';
|
||||
import 'reactflow/dist/style.css';
|
||||
import { Stack } from '@mui/material';
|
||||
import { UPDATE_STEP } from 'graphql/mutations/update-step';
|
||||
import { CREATE_STEP } from 'graphql/mutations/create-step';
|
||||
|
||||
import { useAutoLayout } from './useAutoLayout';
|
||||
import { useScrollBoundries } from './useScrollBoundries';
|
||||
import { useScrollBoundaries } from './useScrollBoundaries';
|
||||
import FlowStepNode from './FlowStepNode/FlowStepNode';
|
||||
import Edge from './Edge/Edge';
|
||||
import InvisibleNode from './InvisibleNode/InvisibleNode';
|
||||
import { EditorWrapper } from './style';
|
||||
import {
|
||||
generateEdgeId,
|
||||
generateInitialEdges,
|
||||
generateInitialNodes,
|
||||
updatedCollapsedNodes,
|
||||
} from './utils';
|
||||
import { EDGE_TYPES, INVISIBLE_NODE_ID, NODE_TYPES } from './constants';
|
||||
|
||||
const nodeTypes = { flowStep: FlowStepNode, invisible: InvisibleNode };
|
||||
export const EdgesContext = createContext();
|
||||
export const NodesContext = createContext();
|
||||
|
||||
const edgeTypes = {
|
||||
addNodeEdge: Edge,
|
||||
const nodeTypes = {
|
||||
[NODE_TYPES.FLOW_STEP]: FlowStepNode,
|
||||
[NODE_TYPES.INVISIBLE]: InvisibleNode,
|
||||
};
|
||||
|
||||
const INVISIBLE_NODE_ID = 'invisible-node';
|
||||
|
||||
const generateEdgeId = (sourceId, targetId) => `${sourceId}-${targetId}`;
|
||||
const edgeTypes = {
|
||||
[EDGE_TYPES.ADD_NODE_EDGE]: Edge,
|
||||
};
|
||||
|
||||
const EditorNew = ({ flow }) => {
|
||||
const [triggerStep] = flow.steps;
|
||||
const [currentStepId, setCurrentStepId] = useState(triggerStep.id);
|
||||
|
||||
const [updateStep] = useMutation(UPDATE_STEP);
|
||||
const queryClient = useQueryClient();
|
||||
const [createStep, { loading: stepCreationInProgress }] =
|
||||
useMutation(CREATE_STEP);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||
useAutoLayout();
|
||||
useScrollBoundries();
|
||||
|
||||
const onConnect = useCallback(
|
||||
(params) => setEdges((eds) => addEdge(params, eds)),
|
||||
[setEdges],
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(
|
||||
generateInitialNodes(flow),
|
||||
);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(
|
||||
generateInitialEdges(flow),
|
||||
);
|
||||
|
||||
useAutoLayout();
|
||||
useScrollBoundaries();
|
||||
|
||||
const createdStepIdRef = useRef(null);
|
||||
|
||||
const openNextStep = useCallback(
|
||||
(nextStep) => () => {
|
||||
setCurrentStepId(nextStep?.id);
|
||||
(currentStepId) => {
|
||||
setNodes((nodes) => {
|
||||
const currentStepIndex = nodes.findIndex(
|
||||
(node) => node.id === currentStepId,
|
||||
);
|
||||
if (currentStepIndex >= 0) {
|
||||
const nextStep = nodes[currentStepIndex + 1];
|
||||
return updatedCollapsedNodes(nodes, nextStep.id);
|
||||
}
|
||||
return nodes;
|
||||
});
|
||||
},
|
||||
[],
|
||||
[setNodes],
|
||||
);
|
||||
|
||||
const onStepClose = useCallback(() => {
|
||||
setNodes((nodes) => updatedCollapsedNodes(nodes));
|
||||
}, [setNodes]);
|
||||
|
||||
const onStepOpen = useCallback(
|
||||
(stepId) => {
|
||||
setNodes((nodes) => updatedCollapsedNodes(nodes, stepId));
|
||||
},
|
||||
[setNodes],
|
||||
);
|
||||
|
||||
const onStepChange = useCallback(
|
||||
@@ -77,177 +107,166 @@ const EditorNew = ({ flow }) => {
|
||||
[flow.id, updateStep, queryClient],
|
||||
);
|
||||
|
||||
const generateEdges = useCallback((flow, prevEdges) => {
|
||||
const newEdges =
|
||||
flow.steps
|
||||
.map((step, i) => {
|
||||
const sourceId = step.id;
|
||||
const targetId = flow.steps[i + 1]?.id;
|
||||
const edge = prevEdges?.find(
|
||||
(edge) => edge.id === generateEdgeId(sourceId, targetId),
|
||||
);
|
||||
if (targetId) {
|
||||
return {
|
||||
id: generateEdgeId(sourceId, targetId),
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
type: 'addNodeEdge',
|
||||
data: {
|
||||
flowId: flow.id,
|
||||
flowActive: flow.active,
|
||||
setCurrentStepId,
|
||||
layouted: !!edge,
|
||||
},
|
||||
};
|
||||
}
|
||||
})
|
||||
.filter((edge) => !!edge) || [];
|
||||
const onAddStep = useCallback(
|
||||
async (previousStepId) => {
|
||||
const mutationInput = {
|
||||
previousStep: {
|
||||
id: previousStepId,
|
||||
},
|
||||
flow: {
|
||||
id: flow.id,
|
||||
},
|
||||
};
|
||||
|
||||
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 {
|
||||
data: { createStep: createdStep },
|
||||
} = await createStep({
|
||||
variables: { input: mutationInput },
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
];
|
||||
const createdStepId = createdStep.id;
|
||||
await queryClient.invalidateQueries({ queryKey: ['flows', flow.id] });
|
||||
createdStepIdRef.current = createdStepId;
|
||||
},
|
||||
[currentStepId, nodes, onStepChange, openNextStep],
|
||||
[flow.id, createStep, queryClient],
|
||||
);
|
||||
|
||||
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(() => {
|
||||
if (flow.steps.length + 1 !== nodes.length) {
|
||||
const newNodes = generateNodes(flow, nodes);
|
||||
const newEdges = generateEdges(flow, edges);
|
||||
setNodes((nodes) => {
|
||||
const newNodes = flow.steps.map((step) => {
|
||||
const createdStepId = createdStepIdRef.current;
|
||||
const prevNode = nodes.find(({ id }) => id === step.id);
|
||||
if (prevNode) {
|
||||
return {
|
||||
...prevNode,
|
||||
zIndex: createdStepId ? 0 : prevNode.zIndex,
|
||||
data: {
|
||||
...prevNode.data,
|
||||
collapsed: createdStepId ? true : prevNode.data.collapsed,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
id: step.id,
|
||||
type: NODE_TYPES.FLOW_STEP,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
zIndex: 1,
|
||||
data: {
|
||||
collapsed: false,
|
||||
laidOut: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
setNodes(newNodes);
|
||||
setEdges(newEdges);
|
||||
} else {
|
||||
updateNodesData(flow.steps);
|
||||
updateEdgesData(flow);
|
||||
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];
|
||||
const lastEdge = edges[edges.length - 1];
|
||||
|
||||
return lastStep
|
||||
? [
|
||||
...newEdges,
|
||||
{
|
||||
id: generateEdgeId(lastStep.id, INVISIBLE_NODE_ID),
|
||||
source: lastStep.id,
|
||||
target: INVISIBLE_NODE_ID,
|
||||
type: 'addNodeEdge',
|
||||
data: {
|
||||
laidOut:
|
||||
lastEdge?.id ===
|
||||
generateEdgeId(lastStep.id, INVISIBLE_NODE_ID)
|
||||
? lastEdge?.data.laidOut
|
||||
: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
: newEdges;
|
||||
});
|
||||
|
||||
if (createdStepIdRef.current) {
|
||||
createdStepIdRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [flow]);
|
||||
}, [flow.steps]);
|
||||
|
||||
return (
|
||||
<EditorWrapper direction="column">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
panOnScroll
|
||||
panOnScrollMode="vertical"
|
||||
panOnDrag={false}
|
||||
zoomOnScroll={false}
|
||||
zoomOnPinch={false}
|
||||
zoomOnDoubleClick={false}
|
||||
panActivationKeyCode={null}
|
||||
/>
|
||||
</EditorWrapper>
|
||||
<NodesContext.Provider
|
||||
value={{
|
||||
openNextStep,
|
||||
onStepOpen,
|
||||
onStepClose,
|
||||
onStepChange,
|
||||
flowId: flow.id,
|
||||
steps: flow.steps,
|
||||
}}
|
||||
>
|
||||
<EdgesContext.Provider
|
||||
value={{
|
||||
stepCreationInProgress,
|
||||
onAddStep,
|
||||
flowActive: flow.active,
|
||||
}}
|
||||
>
|
||||
<EditorWrapper direction="column">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
panOnScroll
|
||||
panOnScrollMode="vertical"
|
||||
panOnDrag={false}
|
||||
zoomOnScroll={false}
|
||||
zoomOnPinch={false}
|
||||
zoomOnDoubleClick={false}
|
||||
panActivationKeyCode={null}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
/>
|
||||
</EditorWrapper>
|
||||
</EdgesContext.Provider>
|
||||
</NodesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
@@ -1,30 +1,23 @@
|
||||
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';
|
||||
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);
|
||||
|
||||
function FlowStepNode({
|
||||
data: {
|
||||
step,
|
||||
index,
|
||||
flowId,
|
||||
collapsed,
|
||||
openNextStep,
|
||||
onOpen,
|
||||
onClose,
|
||||
onChange,
|
||||
layouted,
|
||||
},
|
||||
}) {
|
||||
return (
|
||||
<NodeWrapper
|
||||
className="nodrag"
|
||||
sx={{
|
||||
visibility: layouted ? 'visible' : 'hidden',
|
||||
visibility: laidOut ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
<NodeInnerWrapper>
|
||||
@@ -34,16 +27,17 @@ function FlowStepNode({
|
||||
isConnectable={false}
|
||||
style={{ visibility: 'hidden' }}
|
||||
/>
|
||||
<FlowStep
|
||||
step={step}
|
||||
index={index + 1}
|
||||
collapsed={collapsed}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
onChange={onChange}
|
||||
flowId={flowId}
|
||||
onContinue={openNextStep}
|
||||
/>
|
||||
{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}
|
||||
@@ -56,16 +50,10 @@ function FlowStepNode({
|
||||
}
|
||||
|
||||
FlowStepNode.propTypes = {
|
||||
id: PropTypes.string,
|
||||
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,
|
||||
laidOut: PropTypes.bool.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
|
10
packages/web/src/components/EditorNew/constants.js
Normal file
10
packages/web/src/components/EditorNew/constants.js
Normal file
@@ -0,0 +1,10 @@
|
||||
export const INVISIBLE_NODE_ID = 'invisible-node';
|
||||
|
||||
export const NODE_TYPES = {
|
||||
FLOW_STEP: 'flowStep',
|
||||
INVISIBLE: 'invisible',
|
||||
};
|
||||
|
||||
export const EDGE_TYPES = {
|
||||
ADD_NODE_EDGE: 'addNodeEdge',
|
||||
};
|
@@ -4,7 +4,7 @@ import { usePrevious } from 'hooks/usePrevious';
|
||||
import { isEqual } from 'lodash';
|
||||
import { useNodesInitialized, useNodes, useReactFlow } from 'reactflow';
|
||||
|
||||
const getLayoutedElements = (nodes, edges) => {
|
||||
const getLaidOutElements = (nodes, edges) => {
|
||||
const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
||||
graph.setGraph({
|
||||
rankdir: 'TB',
|
||||
@@ -36,18 +36,18 @@ export const useAutoLayout = () => {
|
||||
|
||||
const onLayout = useCallback(
|
||||
(nodes, edges) => {
|
||||
const layoutedElements = getLayoutedElements(nodes, edges);
|
||||
const laidOutElements = getLaidOutElements(nodes, edges);
|
||||
|
||||
setNodes([
|
||||
...layoutedElements.nodes.map((node) => ({
|
||||
...laidOutElements.nodes.map((node) => ({
|
||||
...node,
|
||||
data: { ...node.data, layouted: true },
|
||||
data: { ...node.data, laidOut: true },
|
||||
})),
|
||||
]);
|
||||
setEdges([
|
||||
...layoutedElements.edges.map((edge) => ({
|
||||
...laidOutElements.edges.map((edge) => ({
|
||||
...edge,
|
||||
data: { ...edge.data, layouted: true },
|
||||
data: { ...edge.data, laidOut: true },
|
||||
})),
|
||||
]);
|
||||
},
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useViewport, useReactFlow } from 'reactflow';
|
||||
|
||||
export const useScrollBoundries = () => {
|
||||
export const useScrollBoundaries = () => {
|
||||
const { setViewport } = useReactFlow();
|
||||
const { x, y, zoom } = useViewport();
|
||||
|
88
packages/web/src/components/EditorNew/utils.js
Normal file
88
packages/web/src/components/EditorNew/utils.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import { 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 generateInitialNodes = (flow) => {
|
||||
const newNodes = flow.steps.map((step, index) => {
|
||||
const collapsed = index !== 0;
|
||||
|
||||
return {
|
||||
id: step.id,
|
||||
type: NODE_TYPES.FLOW_STEP,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
zIndex: collapsed ? 0 : 1,
|
||||
data: {
|
||||
collapsed,
|
||||
laidOut: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
...newNodes,
|
||||
{
|
||||
id: INVISIBLE_NODE_ID,
|
||||
type: NODE_TYPES.INVISIBLE,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const generateInitialEdges = (flow) => {
|
||||
const newEdges = flow.steps
|
||||
.map((step, i) => {
|
||||
const sourceId = step.id;
|
||||
const targetId = flow.steps[i + 1]?.id;
|
||||
if (targetId) {
|
||||
return {
|
||||
id: generateEdgeId(sourceId, targetId),
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
type: 'addNodeEdge',
|
||||
data: {
|
||||
laidOut: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((edge) => !!edge);
|
||||
|
||||
const lastStep = flow.steps[flow.steps.length - 1];
|
||||
|
||||
return lastStep
|
||||
? [
|
||||
...newEdges,
|
||||
{
|
||||
id: generateEdgeId(lastStep.id, INVISIBLE_NODE_ID),
|
||||
source: lastStep.id,
|
||||
target: INVISIBLE_NODE_ID,
|
||||
type: 'addNodeEdge',
|
||||
data: {
|
||||
laidOut: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
: newEdges;
|
||||
};
|
@@ -11,9 +11,6 @@ import IconButton from '@mui/material/IconButton';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
|
||||
import { EditorContext } from 'contexts/Editor';
|
||||
import { StepExecutionsProvider } from 'contexts/StepExecutions';
|
||||
import TestSubstep from 'components/TestSubstep';
|
||||
@@ -33,77 +30,18 @@ import {
|
||||
Header,
|
||||
Wrapper,
|
||||
} from './style';
|
||||
import isEmpty from 'helpers/isEmpty';
|
||||
import { StepPropType } from 'propTypes/propTypes';
|
||||
import useTriggers from 'hooks/useTriggers';
|
||||
import useActions from 'hooks/useActions';
|
||||
import useTriggerSubsteps from 'hooks/useTriggerSubsteps';
|
||||
import useActionSubsteps from 'hooks/useActionSubsteps';
|
||||
import useStepWithTestExecutions from 'hooks/useStepWithTestExecutions';
|
||||
import { validationSchemaResolver } from './validation';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
const validIcon = <CheckCircleIcon color="success" />;
|
||||
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) {
|
||||
const { collapsed, onChange, onContinue, flowId } = props;
|
||||
const editorContext = React.useContext(EditorContext);
|
||||
@@ -114,6 +52,10 @@ function FlowStep(props) {
|
||||
const isAction = step.type === 'action';
|
||||
const formatMessage = useFormatMessage();
|
||||
const [currentSubstep, setCurrentSubstep] = React.useState(0);
|
||||
const [formResolverContext, setFormResolverContext] = React.useState({
|
||||
substeps: [],
|
||||
additionalFields: {},
|
||||
});
|
||||
const useAppsOptions = {};
|
||||
|
||||
if (isTrigger) {
|
||||
@@ -168,6 +110,12 @@ function FlowStep(props) {
|
||||
? triggerSubstepsData
|
||||
: actionSubstepsData || [];
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isEqual(substeps, formResolverContext.substeps)) {
|
||||
setFormResolverContext({ substeps, additionalFields: {} });
|
||||
}
|
||||
}, [substeps]);
|
||||
|
||||
const handleChange = React.useCallback(({ step }) => {
|
||||
onChange(step);
|
||||
}, []);
|
||||
@@ -180,11 +128,6 @@ function FlowStep(props) {
|
||||
handleChange({ step: val });
|
||||
};
|
||||
|
||||
const stepValidationSchema = React.useMemo(
|
||||
() => generateValidationSchema(substeps),
|
||||
[substeps],
|
||||
);
|
||||
|
||||
if (!apps?.data) {
|
||||
return (
|
||||
<CircularProgress
|
||||
@@ -213,6 +156,15 @@ function FlowStep(props) {
|
||||
value !== substepIndex ? substepIndex : null,
|
||||
);
|
||||
|
||||
const addAdditionalFieldsValidation = (additionalFields) => {
|
||||
if (additionalFields) {
|
||||
setFormResolverContext((prev) => ({
|
||||
...prev,
|
||||
additionalFields: { ...prev.additionalFields, ...additionalFields },
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const validationStatusIcon =
|
||||
step.status === 'completed' ? validIcon : errorIcon;
|
||||
|
||||
@@ -266,7 +218,8 @@ function FlowStep(props) {
|
||||
<Form
|
||||
defaultValues={step}
|
||||
onSubmit={handleSubmit}
|
||||
resolver={stepValidationSchema}
|
||||
resolver={validationSchemaResolver}
|
||||
context={formResolverContext}
|
||||
>
|
||||
<ChooseAppAndEventSubstep
|
||||
expanded={currentSubstep === 0}
|
||||
@@ -330,6 +283,9 @@ function FlowStep(props) {
|
||||
onSubmit={expandNextStep}
|
||||
onChange={handleChange}
|
||||
step={step}
|
||||
addAdditionalFieldsValidation={
|
||||
addAdditionalFieldsValidation
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
@@ -360,7 +316,6 @@ function FlowStep(props) {
|
||||
FlowStep.propTypes = {
|
||||
collapsed: PropTypes.bool,
|
||||
step: StepPropType.isRequired,
|
||||
index: PropTypes.number,
|
||||
onOpen: PropTypes.func,
|
||||
onClose: PropTypes.func,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
|
120
packages/web/src/components/FlowStep/validation.js
Normal file
120
packages/web/src/components/FlowStep/validation.js
Normal file
@@ -0,0 +1,120 @@
|
||||
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);
|
||||
}
|
@@ -43,7 +43,10 @@ function FlowStepContextMenu(props) {
|
||||
FlowStepContextMenu.propTypes = {
|
||||
stepId: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
anchorEl: PropTypes.element.isRequired,
|
||||
anchorEl: PropTypes.oneOfType([
|
||||
PropTypes.func,
|
||||
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
|
||||
]).isRequired,
|
||||
deletable: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
|
@@ -19,7 +19,9 @@ function FlowSubstep(props) {
|
||||
onCollapse,
|
||||
onSubmit,
|
||||
step,
|
||||
addAdditionalFieldsValidation,
|
||||
} = props;
|
||||
|
||||
const { name, arguments: args } = substep;
|
||||
const editorContext = React.useContext(EditorContext);
|
||||
const formContext = useFormContext();
|
||||
@@ -54,6 +56,7 @@ function FlowSubstep(props) {
|
||||
stepId={step.id}
|
||||
disabled={editorContext.readOnly}
|
||||
showOptionValue={true}
|
||||
addAdditionalFieldsValidation={addAdditionalFieldsValidation}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
@@ -1,6 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
|
||||
const noop = () => null;
|
||||
|
||||
export default function Form(props) {
|
||||
const {
|
||||
children,
|
||||
@@ -9,24 +11,31 @@ export default function Form(props) {
|
||||
resolver,
|
||||
render,
|
||||
mode = 'all',
|
||||
context,
|
||||
...formProps
|
||||
} = props;
|
||||
|
||||
const methods = useForm({
|
||||
defaultValues,
|
||||
reValidateMode: 'onBlur',
|
||||
resolver,
|
||||
mode,
|
||||
context,
|
||||
});
|
||||
|
||||
const form = useWatch({ control: methods.control });
|
||||
|
||||
/**
|
||||
* For fields having `dependsOn` fields, we need to re-validate the form.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
methods.trigger();
|
||||
}, [methods.trigger, form]);
|
||||
|
||||
React.useEffect(() => {
|
||||
methods.reset(defaultValues);
|
||||
}, [defaultValues]);
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={methods.handleSubmit(onSubmit)} {...formProps}>
|
||||
|
@@ -23,7 +23,9 @@ export default function InputCreator(props) {
|
||||
disabled,
|
||||
showOptionValue,
|
||||
shouldUnregister,
|
||||
addAdditionalFieldsValidation,
|
||||
} = props;
|
||||
|
||||
const {
|
||||
key: name,
|
||||
label,
|
||||
@@ -33,6 +35,7 @@ export default function InputCreator(props) {
|
||||
description,
|
||||
type,
|
||||
} = schema;
|
||||
|
||||
const { data, loading } = useDynamicData(stepId, schema);
|
||||
const { data: additionalFieldsData, isLoading: isDynamicFieldsLoading } =
|
||||
useDynamicFields(stepId, schema);
|
||||
@@ -40,6 +43,10 @@ export default function InputCreator(props) {
|
||||
|
||||
const computedName = namePrefix ? `${namePrefix}.${name}` : name;
|
||||
|
||||
React.useEffect(() => {
|
||||
addAdditionalFieldsValidation?.({ [name]: additionalFields });
|
||||
}, [additionalFields]);
|
||||
|
||||
if (type === 'dynamic') {
|
||||
return (
|
||||
<DynamicField
|
||||
|
@@ -5,8 +5,10 @@ import AddCircleIcon from '@mui/icons-material/AddCircle';
|
||||
import CardActionArea from '@mui/material/CardActionArea';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { CardContent } from './style';
|
||||
|
||||
export default function NoResultFound(props) {
|
||||
const { text, to } = props;
|
||||
|
||||
const ActionAreaLink = React.useMemo(
|
||||
() =>
|
||||
React.forwardRef(function InlineLink(linkProps, ref) {
|
||||
@@ -15,12 +17,12 @@ export default function NoResultFound(props) {
|
||||
}),
|
||||
[to],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card elevation={0}>
|
||||
<CardActionArea component={ActionAreaLink} {...props}>
|
||||
<CardContent>
|
||||
{!!to && <AddCircleIcon color="primary" />}
|
||||
|
||||
<Typography variant="body1">{text}</Typography>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
|
@@ -7,6 +7,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import api from 'helpers/api';
|
||||
|
||||
const variableRegExp = /({.*?})/;
|
||||
|
||||
// TODO: extract this function to a separate file
|
||||
function computeArguments(args, getValues) {
|
||||
const initialValue = {};
|
||||
|
@@ -1,4 +1,6 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Settings } from 'luxon';
|
||||
|
||||
import ThemeProvider from 'components/ThemeProvider';
|
||||
import IntlProvider from 'components/IntlProvider';
|
||||
import ApolloProvider from 'components/ApolloProvider';
|
||||
@@ -10,6 +12,9 @@ import Router from 'components/Router';
|
||||
import routes from 'routes';
|
||||
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 root = createRoot(container);
|
||||
|
||||
|
@@ -30,6 +30,7 @@ import AppIcon from 'components/AppIcon';
|
||||
import Container from 'components/Container';
|
||||
import PageTitle from 'components/PageTitle';
|
||||
import useApp from 'hooks/useApp';
|
||||
import Can from 'components/Can';
|
||||
|
||||
const ReconnectConnection = (props) => {
|
||||
const { application, onClose } = props;
|
||||
@@ -92,7 +93,7 @@ export default function Application() {
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [appKey, appConfig?.data, currentUserAbility]);
|
||||
}, [appKey, appConfig?.data, currentUserAbility, formatMessage]);
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
@@ -118,37 +119,46 @@ export default function Application() {
|
||||
<Route
|
||||
path={`${URLS.FLOWS}/*`}
|
||||
element={
|
||||
<ConditionalIconButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
component={Link}
|
||||
to={URLS.CREATE_FLOW_WITH_APP_AND_CONNECTION(
|
||||
appKey,
|
||||
connectionId,
|
||||
<Can I="create" a="Flow" passThrough>
|
||||
{(allowed) => (
|
||||
<ConditionalIconButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
component={Link}
|
||||
to={URLS.CREATE_FLOW_WITH_APP_AND_CONNECTION(
|
||||
appKey,
|
||||
connectionId,
|
||||
)}
|
||||
fullWidth
|
||||
icon={<AddIcon />}
|
||||
disabled={!allowed}
|
||||
>
|
||||
{formatMessage('app.createFlow')}
|
||||
</ConditionalIconButton>
|
||||
)}
|
||||
fullWidth
|
||||
icon={<AddIcon />}
|
||||
disabled={!currentUserAbility.can('create', 'Flow')}
|
||||
>
|
||||
{formatMessage('app.createFlow')}
|
||||
</ConditionalIconButton>
|
||||
</Can>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path={`${URLS.CONNECTIONS}/*`}
|
||||
element={
|
||||
<SplitButton
|
||||
disabled={
|
||||
(appConfig?.data &&
|
||||
!appConfig?.data?.canConnect &&
|
||||
!appConfig?.data?.canCustomConnect) ||
|
||||
connectionOptions.every(({ disabled }) => disabled)
|
||||
}
|
||||
options={connectionOptions}
|
||||
/>
|
||||
<Can I="create" a="Connection" passThrough>
|
||||
{(allowed) => (
|
||||
<SplitButton
|
||||
disabled={
|
||||
!allowed ||
|
||||
(appConfig?.data &&
|
||||
!appConfig?.data?.canConnect &&
|
||||
!appConfig?.data?.canCustomConnect) ||
|
||||
connectionOptions.every(({ disabled }) => disabled)
|
||||
}
|
||||
options={connectionOptions}
|
||||
/>
|
||||
)}
|
||||
</Can>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
@@ -169,17 +179,20 @@ export default function Application() {
|
||||
label={formatMessage('app.connections')}
|
||||
to={URLS.APP_CONNECTIONS(appKey)}
|
||||
value={URLS.APP_CONNECTIONS_PATTERN}
|
||||
disabled={!app.supportsConnections}
|
||||
disabled={
|
||||
!currentUserAbility.can('read', 'Connection') ||
|
||||
!app.supportsConnections
|
||||
}
|
||||
component={Link}
|
||||
data-test="connections-tab"
|
||||
/>
|
||||
|
||||
<Tab
|
||||
label={formatMessage('app.flows')}
|
||||
to={URLS.APP_FLOWS(appKey)}
|
||||
value={URLS.APP_FLOWS_PATTERN}
|
||||
component={Link}
|
||||
data-test="flows-tab"
|
||||
disabled={!currentUserAbility.can('read', 'Flow')}
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
@@ -187,14 +200,20 @@ export default function Application() {
|
||||
<Routes>
|
||||
<Route
|
||||
path={`${URLS.FLOWS}/*`}
|
||||
element={<AppFlows appKey={appKey} />}
|
||||
element={
|
||||
<Can I="read" a="Flow">
|
||||
<AppFlows appKey={appKey} />
|
||||
</Can>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path={`${URLS.CONNECTIONS}/*`}
|
||||
element={<AppConnections appKey={appKey} />}
|
||||
element={
|
||||
<Can I="read" a="Connection">
|
||||
<AppConnections appKey={appKey} />
|
||||
</Can>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
@@ -218,17 +237,24 @@ export default function Application() {
|
||||
<Route
|
||||
path="/connections/add"
|
||||
element={
|
||||
<AddAppConnection onClose={goToApplicationPage} application={app} />
|
||||
<Can I="create" a="Connection">
|
||||
<AddAppConnection
|
||||
onClose={goToApplicationPage}
|
||||
application={app}
|
||||
/>
|
||||
</Can>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/connections/:connectionId/reconnect"
|
||||
element={
|
||||
<ReconnectConnection
|
||||
application={app}
|
||||
onClose={goToApplicationPage}
|
||||
/>
|
||||
<Can I="create" a="Connection">
|
||||
<ReconnectConnection
|
||||
application={app}
|
||||
onClose={goToApplicationPage}
|
||||
/>
|
||||
</Can>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
|
@@ -84,10 +84,14 @@ export default function Applications() {
|
||||
)}
|
||||
|
||||
{!isLoading && !hasApps && (
|
||||
<NoResultFound
|
||||
text={formatMessage('apps.noConnections')}
|
||||
to={URLS.NEW_APP_CONNECTION}
|
||||
/>
|
||||
<Can I="create" a="Connection" passThrough>
|
||||
{(allowed) => (
|
||||
<NoResultFound
|
||||
text={formatMessage('apps.noConnections')}
|
||||
{...(allowed && { to: URLS.NEW_APP_CONNECTION })}
|
||||
/>
|
||||
)}
|
||||
</Can>
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
|
@@ -7,13 +7,15 @@ import * as URLS from 'config/urls';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import { CREATE_FLOW } from 'graphql/mutations/create-flow';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
export default function CreateFlow() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const formatMessage = useFormatMessage();
|
||||
const [createFlow] = useMutation(CREATE_FLOW);
|
||||
const [createFlow, { error }] = useMutation(CREATE_FLOW);
|
||||
const appKey = searchParams.get('appKey');
|
||||
const connectionId = searchParams.get('connectionId');
|
||||
|
||||
React.useEffect(() => {
|
||||
async function initiate() {
|
||||
const variables = {};
|
||||
@@ -33,6 +35,11 @@ export default function CreateFlow() {
|
||||
}
|
||||
initiate();
|
||||
}, [createFlow, navigate, appKey, connectionId]);
|
||||
|
||||
if (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -45,7 +52,6 @@ export default function CreateFlow() {
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={16} thickness={7.5} />
|
||||
|
||||
<Typography variant="body2">
|
||||
{formatMessage('createFlow.creating')}
|
||||
</Typography>
|
||||
|
@@ -17,6 +17,7 @@ import Container from 'components/Container';
|
||||
import PageTitle from 'components/PageTitle';
|
||||
import SearchInput from 'components/SearchInput';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import useCurrentUserAbility from 'hooks/useCurrentUserAbility';
|
||||
import * as URLS from 'config/urls';
|
||||
import useLazyFlows from 'hooks/useLazyFlows';
|
||||
|
||||
@@ -26,6 +27,7 @@ export default function Flows() {
|
||||
const page = parseInt(searchParams.get('page') || '', 10) || 1;
|
||||
const [flowName, setFlowName] = React.useState('');
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const currentUserAbility = useCurrentUserAbility();
|
||||
|
||||
const { data, mutate: fetchFlows } = useLazyFlows(
|
||||
{ flowName, page },
|
||||
@@ -124,7 +126,9 @@ export default function Flows() {
|
||||
{!isLoading && !hasFlows && (
|
||||
<NoResultFound
|
||||
text={formatMessage('flows.noFlows')}
|
||||
to={URLS.CREATE_FLOW}
|
||||
{...(currentUserAbility.can('create', 'Flow') && {
|
||||
to: URLS.CREATE_FLOW,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && pageInfo && pageInfo.totalPages > 1 && (
|
||||
|
Reference in New Issue
Block a user