Compare commits

...

27 Commits

Author SHA1 Message Date
kasia.oczkowska
8f3ecb6d4d feat: include dynamic fields in the form validation 2024-07-10 14:56:07 +01:00
Kasia
47caa5aa37 feat: add dynamic fields to validation schema and debug problem with dependsOn field 2024-07-10 14:56:07 +01:00
Ömer Faruk Aydın
725b38c697 Merge pull request #1955 from automatisch/repository-structure
docs: Adjust repository to the new packages structure
2024-07-09 13:14:27 +02:00
Faruk AYDIN
402a0fdf3b docs: Adjust repository to the new packages structure 2024-07-08 21:11:25 +02:00
Ali BARIN
078364ffa1 Merge pull request #1951 from automatisch/AUT-1069
fix: set default locale for luxon's  DateTime
2024-07-08 15:19:14 +02:00
kasia.oczkowska
f64d5ec4fc fix: set default locale for luxon's DateTime 2024-07-04 15:14:28 +01:00
Ali BARIN
12194a50e1 Merge pull request #1948 from automatisch/AUT-1076
feat: block access to flow creation for users without permissions
2024-07-04 10:44:15 +02:00
kasia.oczkowska
82ee592699 feat: block access to flow creation for users without permissions 2024-07-04 08:57:03 +01:00
Ali BARIN
1b4fb2ce6e Merge pull request #1947 from automatisch/AUT-1070
feat: use try-catch block for updating flow status
2024-07-03 15:37:02 +02:00
kasia.oczkowska
ebea8d12d1 feat: use try-catch block for updating flow status 2024-07-03 14:04:11 +01:00
Ali BARIN
f842dd77df Merge pull request #1945 from automatisch/remove-access-tokens-in-perm-user-deletion
fix: delete access token before hard deleting user
2024-07-03 13:16:20 +02:00
Ali BARIN
a6ec7a6c99 Merge pull request #1944 from automatisch/fix-reset-password
fix(user): make resetPasswordToken relevant fields nullable
2024-07-03 13:16:12 +02:00
Ali BARIN
369c72282c fix: delete access token before hard deleting user 2024-07-01 11:41:11 +00:00
Ali BARIN
6f30c1a509 fix(user): make resetPasswordToken relevant fields nullable 2024-07-01 10:26:17 +00:00
Ali BARIN
abfd1116c7 Merge pull request #1941 from automatisch/AUT-1038
fix: persist value in ControlledCustomAutocomplete when it depends on other fields
2024-06-21 14:32:09 +02:00
kasia.oczkowska
017854955d fix: persist value in ControlledCustomAutocomplete when it depends on other fields 2024-06-21 10:37:39 +01:00
Ali BARIN
1405cddea1 Merge pull request #1940 from automatisch/AUT-1039
Revert "feat: persist parameters values in FlowSubstep (#1505)"
2024-06-20 15:57:47 +02:00
kasia.oczkowska
00dd3164c9 Revert "feat: persist parameters values in FlowSubstep (#1505)"
This reverts commit 017a881494.
2024-06-20 14:48:51 +01:00
Ali BARIN
d5cbc0f611 Merge pull request #1578 from automatisch/AUT-620
feat: improve UI display depending on user permissions
2024-06-20 12:04:31 +02:00
kasia.oczkowska
5d2e9ccc67 feat: improve UI display depending on user persmissions 2024-06-20 09:52:00 +00:00
kattoczko
017a881494 feat: persist parameters values in FlowSubstep (#1505)
* feat: persist parameters values in FlowSubstep

* feat: add missing import

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

Problem with rendering in DynamicField component with long variables #1933

* Fix issue #1933 (2)

* Fix issue #1933 (3)
2024-06-20 09:55:08 +02:00
Ali BARIN
ebae629e5c Merge pull request #1931 from automatisch/AUT-1010
feat: improve nodes and edges state update
2024-06-19 16:01:24 +02:00
kasia.oczkowska
4d79220b0c refactor: fix spelling and wording errors 2024-06-14 12:39:42 +01:00
kasia.oczkowska
96fba7fbb8 feat: improve nodes and edges state update 2024-06-14 12:39:42 +01:00
Ali BARIN
e0d610071d Merge pull request #1917 from automatisch/AUT-1009
feat: hide react-flow attribution
2024-06-05 15:35:09 +02:00
kasia.oczkowska
ab0966c005 feat: hide react-flow attribution 2024-06-05 13:39:49 +01:00
31 changed files with 725 additions and 437 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' }, resetPasswordToken: { type: ['string', 'null'] },
resetPasswordTokenSentAt: { type: 'string' }, resetPasswordTokenSentAt: { type: ['string', 'null'], format: 'date-time' },
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,6 +40,7 @@ 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,16 +6,12 @@ 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,7 +68,10 @@ 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([PropTypes.element, PropTypes.func]), anchorEl: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]),
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
}; };

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,9 @@ 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) => {
@@ -33,6 +35,7 @@ const CustomOptions = (props) => {
}, },
[initialTabIndex], [initialTabIndex],
); );
return ( return (
<Popper <Popper
open={open} open={open}
@@ -76,7 +79,10 @@ const CustomOptions = (props) => {
CustomOptions.propTypes = { CustomOptions.propTypes = {
open: PropTypes.bool.isRequired, 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( data: PropTypes.arrayOf(
PropTypes.shape({ PropTypes.shape({
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,

View File

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

View File

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

View File

@@ -59,23 +59,25 @@ export default function EditorLayout() {
const onFlowStatusUpdate = React.useCallback( const onFlowStatusUpdate = React.useCallback(
async (active) => { async (active) => {
await updateFlowStatus({ try {
variables: { await updateFlowStatus({
input: { variables: {
id: flowId, input: {
active, id: flowId,
active,
},
}, },
}, optimisticResponse: {
optimisticResponse: { updateFlowStatus: {
updateFlowStatus: { __typename: 'Flow',
__typename: 'Flow', id: flowId,
id: flowId, active,
active, },
}, },
}, });
});
await queryClient.invalidateQueries({ queryKey: ['flows', flowId] }); await queryClient.invalidateQueries({ queryKey: ['flows', flowId] });
} catch (err) {}
}, },
[flowId, queryClient], [flowId, queryClient],
); );

View File

@@ -1,10 +1,10 @@
import { EdgeLabelRenderer, getStraightPath } from 'reactflow'; import { EdgeLabelRenderer, getStraightPath } from 'reactflow';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import AddIcon from '@mui/icons-material/Add'; 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 PropTypes from 'prop-types';
import { useContext } from 'react';
import { EdgesContext } from '../EditorNew';
export default function Edge({ export default function Edge({
sourceX, sourceX,
@@ -12,11 +12,11 @@ export default function Edge({
targetX, targetX,
targetY, targetY,
source, source,
data: { flowId, setCurrentStepId, flowActive, layouted }, data: { laidOut },
}) { }) {
const [createStep, { loading: creationInProgress }] = const { stepCreationInProgress, flowActive, onAddStep } =
useMutation(CREATE_STEP); useContext(EdgesContext);
const queryClient = useQueryClient();
const [edgePath, labelX, labelY] = getStraightPath({ const [edgePath, labelX, labelY] = getStraightPath({
sourceX, sourceX,
sourceY, sourceY,
@@ -24,38 +24,19 @@ export default function Edge({
targetY, 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 ( return (
<> <>
<EdgeLabelRenderer> <EdgeLabelRenderer>
<IconButton <IconButton
onClick={() => addStep(source)} onClick={() => onAddStep(source)}
color="primary" color="primary"
sx={{ sx={{
position: 'absolute', position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`, transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'all', pointerEvents: 'all',
visibility: layouted ? 'visible' : 'hidden', visibility: laidOut ? 'visible' : 'hidden',
}} }}
disabled={creationInProgress || flowActive} disabled={stepCreationInProgress || flowActive}
> >
<AddIcon /> <AddIcon />
</IconButton> </IconButton>
@@ -71,9 +52,6 @@ Edge.propTypes = {
targetY: PropTypes.number.isRequired, targetY: PropTypes.number.isRequired,
source: PropTypes.string.isRequired, source: PropTypes.string.isRequired,
data: PropTypes.shape({ data: PropTypes.shape({
flowId: PropTypes.string.isRequired, laidOut: PropTypes.bool,
setCurrentStepId: PropTypes.func.isRequired,
flowActive: PropTypes.bool.isRequired,
layouted: PropTypes.bool,
}).isRequired, }).isRequired,
}; };

View File

@@ -1,51 +1,81 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useCallback, createContext, useRef } from 'react';
import { useMutation } from '@apollo/client'; import { useMutation } from '@apollo/client';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { FlowPropType } from 'propTypes/propTypes'; import { FlowPropType } from 'propTypes/propTypes';
import ReactFlow, { useNodesState, useEdgesState, addEdge } from 'reactflow'; import ReactFlow, { useNodesState, useEdgesState } from 'reactflow';
import 'reactflow/dist/style.css'; import 'reactflow/dist/style.css';
import { Stack } from '@mui/material';
import { UPDATE_STEP } from 'graphql/mutations/update-step'; import { UPDATE_STEP } from 'graphql/mutations/update-step';
import { CREATE_STEP } from 'graphql/mutations/create-step';
import { useAutoLayout } from './useAutoLayout'; import { useAutoLayout } from './useAutoLayout';
import { useScrollBoundries } from './useScrollBoundries'; import { useScrollBoundaries } from './useScrollBoundaries';
import FlowStepNode from './FlowStepNode/FlowStepNode'; import FlowStepNode from './FlowStepNode/FlowStepNode';
import Edge from './Edge/Edge'; import Edge from './Edge/Edge';
import InvisibleNode from './InvisibleNode/InvisibleNode'; import InvisibleNode from './InvisibleNode/InvisibleNode';
import { EditorWrapper } from './style'; 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 = { const nodeTypes = {
addNodeEdge: Edge, [NODE_TYPES.FLOW_STEP]: FlowStepNode,
[NODE_TYPES.INVISIBLE]: InvisibleNode,
}; };
const INVISIBLE_NODE_ID = 'invisible-node'; const edgeTypes = {
[EDGE_TYPES.ADD_NODE_EDGE]: Edge,
const generateEdgeId = (sourceId, targetId) => `${sourceId}-${targetId}`; };
const EditorNew = ({ flow }) => { const EditorNew = ({ flow }) => {
const [triggerStep] = flow.steps;
const [currentStepId, setCurrentStepId] = useState(triggerStep.id);
const [updateStep] = useMutation(UPDATE_STEP); const [updateStep] = useMutation(UPDATE_STEP);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [createStep, { loading: stepCreationInProgress }] =
useMutation(CREATE_STEP);
const [nodes, setNodes, onNodesChange] = useNodesState([]); const [nodes, setNodes, onNodesChange] = useNodesState(
const [edges, setEdges, onEdgesChange] = useEdgesState([]); generateInitialNodes(flow),
useAutoLayout(); );
useScrollBoundries(); const [edges, setEdges, onEdgesChange] = useEdgesState(
generateInitialEdges(flow),
const onConnect = useCallback(
(params) => setEdges((eds) => addEdge(params, eds)),
[setEdges],
); );
useAutoLayout();
useScrollBoundaries();
const createdStepIdRef = useRef(null);
const openNextStep = useCallback( const openNextStep = useCallback(
(nextStep) => () => { (currentStepId) => {
setCurrentStepId(nextStep?.id); 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( const onStepChange = useCallback(
@@ -77,177 +107,166 @@ const EditorNew = ({ flow }) => {
[flow.id, updateStep, queryClient], [flow.id, updateStep, queryClient],
); );
const generateEdges = useCallback((flow, prevEdges) => { const onAddStep = useCallback(
const newEdges = async (previousStepId) => {
flow.steps const mutationInput = {
.map((step, i) => { previousStep: {
const sourceId = step.id; id: previousStepId,
const targetId = flow.steps[i + 1]?.id; },
const edge = prevEdges?.find( flow: {
(edge) => edge.id === generateEdgeId(sourceId, targetId), id: flow.id,
); },
if (targetId) { };
return {
id: generateEdgeId(sourceId, targetId),
source: sourceId,
target: targetId,
type: 'addNodeEdge',
data: {
flowId: flow.id,
flowActive: flow.active,
setCurrentStepId,
layouted: !!edge,
},
};
}
})
.filter((edge) => !!edge) || [];
const lastStep = flow.steps[flow.steps.length - 1]; const {
data: { createStep: createdStep },
return lastStep } = await createStep({
? [ variables: { input: mutationInput },
...newEdges,
{
id: generateEdgeId(lastStep.id, INVISIBLE_NODE_ID),
source: lastStep.id,
target: INVISIBLE_NODE_ID,
type: 'addNodeEdge',
data: {
flowId: flow.id,
flowActive: flow.active,
setCurrentStepId,
layouted: false,
},
},
]
: newEdges;
}, []);
const generateNodes = useCallback(
(flow, prevNodes) => {
const newNodes = flow.steps.map((step, index) => {
const node = prevNodes?.find(({ id }) => id === step.id);
const collapsed = currentStepId !== step.id;
return {
id: step.id,
type: 'flowStep',
position: {
x: node ? node.position.x : 0,
y: node ? node.position.y : 0,
},
zIndex: collapsed ? 0 : 1,
data: {
step,
index: index,
flowId: flow.id,
collapsed,
openNextStep: openNextStep(flow.steps[index + 1]),
onOpen: () => setCurrentStepId(step.id),
onClose: () => setCurrentStepId(null),
onChange: onStepChange,
layouted: !!node,
},
};
}); });
const prevInvisibleNode = nodes.find((node) => node.type === 'invisible'); const createdStepId = createdStep.id;
await queryClient.invalidateQueries({ queryKey: ['flows', flow.id] });
return [ createdStepIdRef.current = createdStepId;
...newNodes,
{
id: INVISIBLE_NODE_ID,
type: 'invisible',
position: {
x: prevInvisibleNode ? prevInvisibleNode.position.x : 0,
y: prevInvisibleNode ? prevInvisibleNode.position.y : 0,
},
},
];
}, },
[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(() => { useEffect(() => {
if (flow.steps.length + 1 !== nodes.length) { if (flow.steps.length + 1 !== nodes.length) {
const newNodes = generateNodes(flow, nodes); setNodes((nodes) => {
const newEdges = generateEdges(flow, edges); 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); const prevInvisible = nodes.find(({ id }) => id === INVISIBLE_NODE_ID);
setEdges(newEdges); return [
} else { ...newNodes,
updateNodesData(flow.steps); {
updateEdgesData(flow); 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 ( return (
<EditorWrapper direction="column"> <NodesContext.Provider
<ReactFlow value={{
nodes={nodes} openNextStep,
edges={edges} onStepOpen,
onNodesChange={onNodesChange} onStepClose,
onEdgesChange={onEdgesChange} onStepChange,
onConnect={onConnect} flowId: flow.id,
nodeTypes={nodeTypes} steps: flow.steps,
edgeTypes={edgeTypes} }}
panOnScroll >
panOnScrollMode="vertical" <EdgesContext.Provider
panOnDrag={false} value={{
zoomOnScroll={false} stepCreationInProgress,
zoomOnPinch={false} onAddStep,
zoomOnDoubleClick={false} flowActive: flow.active,
panActivationKeyCode={null} }}
/> >
</EditorWrapper> <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>
); );
}; };

View File

@@ -1,30 +1,23 @@
import { Handle, Position } from 'reactflow'; import { Handle, Position } from 'reactflow';
import { Box } from '@mui/material';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import FlowStep from 'components/FlowStep'; import FlowStep from 'components/FlowStep';
import { StepPropType } from 'propTypes/propTypes';
import { NodeWrapper, NodeInnerWrapper } from './style.js'; 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 ( return (
<NodeWrapper <NodeWrapper
className="nodrag" className="nodrag"
sx={{ sx={{
visibility: layouted ? 'visible' : 'hidden', visibility: laidOut ? 'visible' : 'hidden',
}} }}
> >
<NodeInnerWrapper> <NodeInnerWrapper>
@@ -34,16 +27,17 @@ function FlowStepNode({
isConnectable={false} isConnectable={false}
style={{ visibility: 'hidden' }} style={{ visibility: 'hidden' }}
/> />
<FlowStep {step && (
step={step} <FlowStep
index={index + 1} step={step}
collapsed={collapsed} collapsed={collapsed}
onOpen={onOpen} onOpen={() => onStepOpen(step.id)}
onClose={onClose} onClose={onStepClose}
onChange={onChange} onChange={onStepChange}
flowId={flowId} flowId={flowId}
onContinue={openNextStep} onContinue={() => openNextStep(step.id)}
/> />
)}
<Handle <Handle
type="source" type="source"
position={Position.Bottom} position={Position.Bottom}
@@ -56,16 +50,10 @@ function FlowStepNode({
} }
FlowStepNode.propTypes = { FlowStepNode.propTypes = {
id: PropTypes.string,
data: PropTypes.shape({ data: PropTypes.shape({
step: StepPropType.isRequired,
index: PropTypes.number.isRequired,
flowId: PropTypes.string.isRequired,
collapsed: PropTypes.bool.isRequired, collapsed: PropTypes.bool.isRequired,
openNextStep: PropTypes.func.isRequired, laidOut: PropTypes.bool.isRequired,
onOpen: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
layouted: PropTypes.bool.isRequired,
}).isRequired, }).isRequired,
}; };

View 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',
};

View File

@@ -4,7 +4,7 @@ import { usePrevious } from 'hooks/usePrevious';
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import { useNodesInitialized, useNodes, useReactFlow } from 'reactflow'; import { useNodesInitialized, useNodes, useReactFlow } from 'reactflow';
const getLayoutedElements = (nodes, edges) => { const getLaidOutElements = (nodes, edges) => {
const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
graph.setGraph({ graph.setGraph({
rankdir: 'TB', rankdir: 'TB',
@@ -36,18 +36,18 @@ export const useAutoLayout = () => {
const onLayout = useCallback( const onLayout = useCallback(
(nodes, edges) => { (nodes, edges) => {
const layoutedElements = getLayoutedElements(nodes, edges); const laidOutElements = getLaidOutElements(nodes, edges);
setNodes([ setNodes([
...layoutedElements.nodes.map((node) => ({ ...laidOutElements.nodes.map((node) => ({
...node, ...node,
data: { ...node.data, layouted: true }, data: { ...node.data, laidOut: true },
})), })),
]); ]);
setEdges([ setEdges([
...layoutedElements.edges.map((edge) => ({ ...laidOutElements.edges.map((edge) => ({
...edge, ...edge,
data: { ...edge.data, layouted: true }, data: { ...edge.data, laidOut: true },
})), })),
]); ]);
}, },

View File

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

View File

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

View File

@@ -11,9 +11,6 @@ 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';
@@ -33,77 +30,18 @@ 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 } = props;
const editorContext = React.useContext(EditorContext); const editorContext = React.useContext(EditorContext);
@@ -114,6 +52,10 @@ 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) {
@@ -168,6 +110,12 @@ 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);
}, []); }, []);
@@ -180,11 +128,6 @@ 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
@@ -213,6 +156,15 @@ 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;
@@ -266,7 +218,8 @@ function FlowStep(props) {
<Form <Form
defaultValues={step} defaultValues={step}
onSubmit={handleSubmit} onSubmit={handleSubmit}
resolver={stepValidationSchema} resolver={validationSchemaResolver}
context={formResolverContext}
> >
<ChooseAppAndEventSubstep <ChooseAppAndEventSubstep
expanded={currentSubstep === 0} expanded={currentSubstep === 0}
@@ -330,6 +283,9 @@ function FlowStep(props) {
onSubmit={expandNextStep} onSubmit={expandNextStep}
onChange={handleChange} onChange={handleChange}
step={step} step={step}
addAdditionalFieldsValidation={
addAdditionalFieldsValidation
}
/> />
)} )}
</React.Fragment> </React.Fragment>
@@ -360,7 +316,6 @@ function FlowStep(props) {
FlowStep.propTypes = { FlowStep.propTypes = {
collapsed: PropTypes.bool, collapsed: PropTypes.bool,
step: StepPropType.isRequired, step: StepPropType.isRequired,
index: PropTypes.number,
onOpen: PropTypes.func, onOpen: PropTypes.func,
onClose: PropTypes.func, onClose: PropTypes.func,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,

View 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);
}

View File

@@ -43,7 +43,10 @@ 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.element.isRequired, anchorEl: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]).isRequired,
deletable: PropTypes.bool.isRequired, deletable: PropTypes.bool.isRequired,
}; };

View File

@@ -19,7 +19,9 @@ 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();
@@ -54,6 +56,7 @@ 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,6 +1,8 @@
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,
@@ -9,24 +11,31 @@ 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,7 +23,9 @@ export default function InputCreator(props) {
disabled, disabled,
showOptionValue, showOptionValue,
shouldUnregister, shouldUnregister,
addAdditionalFieldsValidation,
} = props; } = props;
const { const {
key: name, key: name,
label, label,
@@ -33,6 +35,7 @@ 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);
@@ -40,6 +43,10 @@ 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,8 +5,10 @@ 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) {
@@ -15,12 +17,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,6 +7,7 @@ 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,4 +1,6 @@
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';
@@ -10,6 +12,9 @@ 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

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

View File

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

View File

@@ -7,13 +7,15 @@ 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] = useMutation(CREATE_FLOW); const [createFlow, { error }] = 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 = {};
@@ -33,6 +35,11 @@ export default function CreateFlow() {
} }
initiate(); initiate();
}, [createFlow, navigate, appKey, connectionId]); }, [createFlow, navigate, appKey, connectionId]);
if (error) {
return null;
}
return ( return (
<Box <Box
sx={{ sx={{
@@ -45,7 +52,6 @@ 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,6 +17,7 @@ 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';
@@ -26,6 +27,7 @@ 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 },
@@ -124,7 +126,9 @@ export default function Flows() {
{!isLoading && !hasFlows && ( {!isLoading && !hasFlows && (
<NoResultFound <NoResultFound
text={formatMessage('flows.noFlows')} text={formatMessage('flows.noFlows')}
to={URLS.CREATE_FLOW} {...(currentUserAbility.can('create', 'Flow') && {
to: URLS.CREATE_FLOW,
})}
/> />
)} )}
{!isLoading && pageInfo && pageInfo.totalPages > 1 && ( {!isLoading && pageInfo && pageInfo.totalPages > 1 && (