refactor: rewrite useFlow and useStepConnection with RQ

This commit is contained in:
Rıdvan Akca
2024-03-22 17:44:05 +03:00
parent c8147370de
commit fc04a357c8
16 changed files with 174 additions and 365 deletions

View File

@@ -1,19 +0,0 @@
import Flow from '../../models/flow.js';
const getFlow = async (_parent, params, context) => {
const conditions = context.currentUser.can('read', 'Flow');
const userFlows = context.currentUser.$relatedQuery('flows');
const allFlows = Flow.query();
const baseQuery = conditions.isCreator ? userFlows : allFlows;
const flow = await baseQuery
.clone()
.withGraphJoined('[steps.[connection]]')
.orderBy('steps.position', 'asc')
.findOne({ 'flows.id': params.id })
.throwIfNotFound();
return flow;
};
export default getFlow;

View File

@@ -1,240 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import app from '../../app';
import appConfig from '../../config/app';
import createAuthTokenByUserId from '../../helpers/create-auth-token-by-user-id';
import { createRole } from '../../../test/factories/role';
import { createPermission } from '../../../test/factories/permission';
import { createUser } from '../../../test/factories/user';
import { createFlow } from '../../../test/factories/flow';
import { createStep } from '../../../test/factories/step';
import { createConnection } from '../../../test/factories/connection';
describe('graphQL getFlow query', () => {
const query = (flowId) => {
return `
query {
getFlow(id: "${flowId}") {
id
name
active
status
steps {
id
type
key
appKey
iconUrl
webhookUrl
status
position
connection {
id
verified
createdAt
}
parameters
}
}
}
`;
};
describe('and without permissions', () => {
it('should throw not authorized error', async () => {
const userWithoutPermissions = await createUser();
const token = createAuthTokenByUserId(userWithoutPermissions.id);
const flow = await createFlow();
const response = await request(app)
.post('/graphql')
.set('Authorization', token)
.send({ query: query(flow.id) })
.expect(200);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toEqual('Not authorized!');
});
});
describe('and with correct permission', () => {
let currentUser, currentUserRole, currentUserFlow;
beforeEach(async () => {
currentUserRole = await createRole();
currentUser = await createUser({ roleId: currentUserRole.id });
currentUserFlow = await createFlow({ userId: currentUser.id });
});
describe('and with isCreator condition', () => {
it('should return executions data of the current user', async () => {
await createPermission({
action: 'read',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
const triggerStep = await createStep({
flowId: currentUserFlow.id,
type: 'trigger',
key: 'catchRawWebhook',
webhookPath: `/webhooks/flows/${currentUserFlow.id}`,
});
const actionConnection = await createConnection({
userId: currentUser.id,
formattedData: {
screenName: 'Test',
authenticationKey: 'test key',
},
});
const actionStep = await createStep({
flowId: currentUserFlow.id,
type: 'action',
connectionId: actionConnection.id,
key: 'translateText',
});
const token = createAuthTokenByUserId(currentUser.id);
const response = await request(app)
.post('/graphql')
.set('Authorization', token)
.send({ query: query(currentUserFlow.id) })
.expect(200);
const expectedResponsePayload = {
data: {
getFlow: {
active: currentUserFlow.active,
id: currentUserFlow.id,
name: currentUserFlow.name,
status: 'draft',
steps: [
{
appKey: triggerStep.appKey,
connection: null,
iconUrl: `${appConfig.baseUrl}/apps/${triggerStep.appKey}/assets/favicon.svg`,
id: triggerStep.id,
key: 'catchRawWebhook',
parameters: {},
position: 1,
status: triggerStep.status,
type: 'trigger',
webhookUrl: `${appConfig.baseUrl}/webhooks/flows/${currentUserFlow.id}`,
},
{
appKey: actionStep.appKey,
connection: {
createdAt: actionConnection.createdAt.getTime().toString(),
id: actionConnection.id,
verified: actionConnection.verified,
},
iconUrl: `${appConfig.baseUrl}/apps/${actionStep.appKey}/assets/favicon.svg`,
id: actionStep.id,
key: 'translateText',
parameters: {},
position: 2,
status: actionStep.status,
type: 'action',
webhookUrl: 'http://localhost:3000/null',
},
],
},
},
};
expect(response.body).toEqual(expectedResponsePayload);
});
});
describe('and without isCreator condition', () => {
it('should return executions data of all users', async () => {
await createPermission({
action: 'read',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: [],
});
const anotherUser = await createUser();
const anotherUserFlow = await createFlow({ userId: anotherUser.id });
const triggerStep = await createStep({
flowId: anotherUserFlow.id,
type: 'trigger',
key: 'catchRawWebhook',
webhookPath: `/webhooks/flows/${anotherUserFlow.id}`,
});
const actionConnection = await createConnection({
userId: anotherUser.id,
formattedData: {
screenName: 'Test',
authenticationKey: 'test key',
},
});
const actionStep = await createStep({
flowId: anotherUserFlow.id,
type: 'action',
connectionId: actionConnection.id,
key: 'translateText',
});
const token = createAuthTokenByUserId(currentUser.id);
const response = await request(app)
.post('/graphql')
.set('Authorization', token)
.send({ query: query(anotherUserFlow.id) })
.expect(200);
const expectedResponsePayload = {
data: {
getFlow: {
active: anotherUserFlow.active,
id: anotherUserFlow.id,
name: anotherUserFlow.name,
status: 'draft',
steps: [
{
appKey: triggerStep.appKey,
connection: null,
iconUrl: `${appConfig.baseUrl}/apps/${triggerStep.appKey}/assets/favicon.svg`,
id: triggerStep.id,
key: 'catchRawWebhook',
parameters: {},
position: 1,
status: triggerStep.status,
type: 'trigger',
webhookUrl: `${appConfig.baseUrl}/webhooks/flows/${anotherUserFlow.id}`,
},
{
appKey: actionStep.appKey,
connection: {
createdAt: actionConnection.createdAt.getTime().toString(),
id: actionConnection.id,
verified: actionConnection.verified,
},
iconUrl: `${appConfig.baseUrl}/apps/${actionStep.appKey}/assets/favicon.svg`,
id: actionStep.id,
key: 'translateText',
parameters: {},
position: 2,
status: actionStep.status,
type: 'action',
webhookUrl: 'http://localhost:3000/null',
},
],
},
},
};
expect(response.body).toEqual(expectedResponsePayload);
});
});
});
});

View File

@@ -4,7 +4,6 @@ import getAppAuthClients from './queries/get-app-auth-clients.ee.js';
import getBillingAndUsage from './queries/get-billing-and-usage.ee.js'; import getBillingAndUsage from './queries/get-billing-and-usage.ee.js';
import getConnectedApps from './queries/get-connected-apps.js'; import getConnectedApps from './queries/get-connected-apps.js';
import getDynamicData from './queries/get-dynamic-data.js'; import getDynamicData from './queries/get-dynamic-data.js';
import getFlow from './queries/get-flow.js';
import getStepWithTestExecutions from './queries/get-step-with-test-executions.js'; import getStepWithTestExecutions from './queries/get-step-with-test-executions.js';
import testConnection from './queries/test-connection.js'; import testConnection from './queries/test-connection.js';
@@ -15,7 +14,6 @@ const queryResolvers = {
getBillingAndUsage, getBillingAndUsage,
getConnectedApps, getConnectedApps,
getDynamicData, getDynamicData,
getFlow,
getStepWithTestExecutions, getStepWithTestExecutions,
testConnection, testConnection,
}; };

View File

@@ -4,7 +4,6 @@ type Query {
getAppAuthClients(appKey: String!, active: Boolean): [AppAuthClient] getAppAuthClients(appKey: String!, active: Boolean): [AppAuthClient]
getConnectedApps(name: String): [App] getConnectedApps(name: String): [App]
testConnection(id: String!): Connection testConnection(id: String!): Connection
getFlow(id: String!): Flow
getStepWithTestExecutions(stepId: String!): [Step] getStepWithTestExecutions(stepId: String!): [Step]
getDynamicData( getDynamicData(
stepId: String! stepId: String!

View File

@@ -21,6 +21,8 @@ import {
StepPropType, StepPropType,
SubstepPropType, SubstepPropType,
} from 'propTypes/propTypes'; } from 'propTypes/propTypes';
import useStepConnection from 'hooks/useStepConnection';
import { useQueryClient } from '@tanstack/react-query';
const ADD_CONNECTION_VALUE = 'ADD_CONNECTION'; const ADD_CONNECTION_VALUE = 'ADD_CONNECTION';
const ADD_SHARED_CONNECTION_VALUE = 'ADD_SHARED_CONNECTION'; const ADD_SHARED_CONNECTION_VALUE = 'ADD_SHARED_CONNECTION';
@@ -44,13 +46,14 @@ function ChooseConnectionSubstep(props) {
onChange, onChange,
application, application,
} = props; } = props;
const { connection, appKey } = step; const { appKey } = step;
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const editorContext = React.useContext(EditorContext); const editorContext = React.useContext(EditorContext);
const [showAddConnectionDialog, setShowAddConnectionDialog] = const [showAddConnectionDialog, setShowAddConnectionDialog] =
React.useState(false); React.useState(false);
const [showAddSharedConnectionDialog, setShowAddSharedConnectionDialog] = const [showAddSharedConnectionDialog, setShowAddSharedConnectionDialog] =
React.useState(false); React.useState(false);
const queryClient = useQueryClient();
const { authenticate } = useAuthenticateApp({ const { authenticate } = useAuthenticateApp({
appKey: application.key, appKey: application.key,
@@ -63,21 +66,24 @@ function ChooseConnectionSubstep(props) {
const { data: appConfig } = useAppConfig(application.key); const { data: appConfig } = useAppConfig(application.key);
const { data: stepConnectionData } = useStepConnection(step.id);
const stepConnection = stepConnectionData?.data;
// TODO: show detailed error when connection test/verification fails // TODO: show detailed error when connection test/verification fails
const [ const [
testConnection, testConnection,
{ loading: testResultLoading, refetch: retestConnection }, { loading: testResultLoading, refetch: retestConnection },
] = useLazyQuery(TEST_CONNECTION, { ] = useLazyQuery(TEST_CONNECTION, {
variables: { variables: {
id: connection?.id, id: stepConnection?.id,
}, },
}); });
React.useEffect(() => { React.useEffect(() => {
if (connection?.id) { if (stepConnection?.id) {
testConnection({ testConnection({
variables: { variables: {
id: connection.id, id: stepConnection.id,
}, },
}); });
} }
@@ -154,8 +160,9 @@ function ChooseConnectionSubstep(props) {
}, },
[onChange, refetch, step], [onChange, refetch, step],
); );
const handleChange = React.useCallback( const handleChange = React.useCallback(
(event, selectedOption) => { async (event, selectedOption) => {
if (typeof selectedOption === 'object') { if (typeof selectedOption === 'object') {
// TODO: try to simplify type casting below. // TODO: try to simplify type casting below.
const typedSelectedOption = selectedOption; const typedSelectedOption = selectedOption;
@@ -172,7 +179,7 @@ function ChooseConnectionSubstep(props) {
return; return;
} }
if (connectionId !== step.connection?.id) { if (connectionId !== stepConnection?.id) {
onChange({ onChange({
step: { step: {
...step, ...step,
@@ -181,19 +188,23 @@ function ChooseConnectionSubstep(props) {
}, },
}, },
}); });
await queryClient.invalidateQueries({
queryKey: ['stepConnection', step.id],
});
} }
} }
}, },
[step, onChange], [step, onChange, queryClient],
); );
React.useEffect(() => { React.useEffect(() => {
if (step.connection?.id) { if (stepConnection?.id) {
retestConnection({ retestConnection({
id: step.connection.id, id: stepConnection?.id,
}); });
} }
}, [step.connection?.id, retestConnection]); }, [stepConnection?.id, retestConnection]);
const onToggle = expanded ? onCollapse : onExpand; const onToggle = expanded ? onCollapse : onExpand;
@@ -203,7 +214,7 @@ function ChooseConnectionSubstep(props) {
expanded={expanded} expanded={expanded}
onClick={onToggle} onClick={onToggle}
title={name} title={name}
valid={testResultLoading ? null : connection?.verified} valid={testResultLoading ? null : stepConnection?.verified}
/> />
<Collapse in={expanded} timeout="auto" unmountOnExit> <Collapse in={expanded} timeout="auto" unmountOnExit>
<ListItem <ListItem
@@ -229,7 +240,7 @@ function ChooseConnectionSubstep(props) {
required required
/> />
)} )}
value={getOption(connectionOptions, connection?.id)} value={getOption(connectionOptions, stepConnection?.id)}
onChange={handleChange} onChange={handleChange}
loading={loading} loading={loading}
data-test="choose-connection-autocomplete" data-test="choose-connection-autocomplete"
@@ -242,7 +253,7 @@ function ChooseConnectionSubstep(props) {
sx={{ mt: 2 }} sx={{ mt: 2 }}
disabled={ disabled={
testResultLoading || testResultLoading ||
!connection?.verified || !stepConnection?.verified ||
editorContext.readOnly editorContext.readOnly
} }
data-test="flow-substep-continue-button" data-test="flow-substep-continue-button"

View File

@@ -3,47 +3,24 @@ import { useMutation } from '@apollo/client';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
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 { GET_FLOW } from 'graphql/queries/get-flow';
import { CREATE_STEP } from 'graphql/mutations/create-step'; import { CREATE_STEP } from 'graphql/mutations/create-step';
import { UPDATE_STEP } from 'graphql/mutations/update-step'; import { UPDATE_STEP } from 'graphql/mutations/update-step';
import FlowStep from 'components/FlowStep'; import FlowStep from 'components/FlowStep';
import { FlowPropType } from 'propTypes/propTypes'; import { FlowPropType } from 'propTypes/propTypes';
import { useQueryClient } from '@tanstack/react-query';
function updateHandlerFactory(flowId, previousStepId) {
return function createStepUpdateHandler(cache, mutationResult) {
const { data } = mutationResult;
const { createStep: createdStep } = data;
const { getFlow: flow } = cache.readQuery({
query: GET_FLOW,
variables: { id: flowId },
});
const steps = flow.steps.reduce((steps, currentStep) => {
if (currentStep.id === previousStepId) {
return [...steps, currentStep, createdStep];
}
return [...steps, currentStep];
}, []);
cache.writeQuery({
query: GET_FLOW,
variables: { id: flowId },
data: { getFlow: { ...flow, steps } },
});
};
}
function Editor(props) { function Editor(props) {
const [updateStep] = useMutation(UPDATE_STEP); const [updateStep] = useMutation(UPDATE_STEP);
const [createStep, { loading: creationInProgress }] = useMutation( const [createStep, { loading: creationInProgress }] =
CREATE_STEP, useMutation(CREATE_STEP);
{
refetchQueries: ['GetFlow'],
},
);
const { flow } = props; const { flow } = props;
const [triggerStep] = flow.steps; const [triggerStep] = flow.steps;
const [currentStepId, setCurrentStepId] = React.useState(triggerStep.id); const [currentStepId, setCurrentStepId] = React.useState(triggerStep.id);
const queryClient = useQueryClient();
const onStepChange = React.useCallback( const onStepChange = React.useCallback(
(step) => { async (step) => {
const mutationInput = { const mutationInput = {
id: step.id, id: step.id,
key: step.key, key: step.key,
@@ -55,13 +32,20 @@ function Editor(props) {
id: flow.id, id: flow.id,
}, },
}; };
if (step.appKey) { if (step.appKey) {
mutationInput.appKey = step.appKey; mutationInput.appKey = step.appKey;
} }
updateStep({ variables: { input: mutationInput } });
await updateStep({ variables: { input: mutationInput } });
await queryClient.invalidateQueries({
queryKey: ['stepConnection', step.id],
});
await queryClient.invalidateQueries({ queryKey: ['flow', flow.id] });
}, },
[updateStep, flow.id], [updateStep, flow.id, queryClient],
); );
const addStep = React.useCallback( const addStep = React.useCallback(
async (previousStepId) => { async (previousStepId) => {
const mutationInput = { const mutationInput = {
@@ -72,20 +56,24 @@ function Editor(props) {
id: flow.id, id: flow.id,
}, },
}; };
const createdStep = await createStep({ const createdStep = await createStep({
variables: { input: mutationInput }, variables: { input: mutationInput },
update: updateHandlerFactory(flow.id, previousStepId),
}); });
const createdStepId = createdStep.data.createStep.id; const createdStepId = createdStep.data.createStep.id;
setCurrentStepId(createdStepId); setCurrentStepId(createdStepId);
await queryClient.invalidateQueries({ queryKey: ['flow', flow.id] });
}, },
[createStep, flow.id], [createStep, flow.id, queryClient],
); );
const openNextStep = React.useCallback((nextStep) => { const openNextStep = React.useCallback((nextStep) => {
return () => { return () => {
setCurrentStepId(nextStep?.id); setCurrentStepId(nextStep?.id);
}; };
}, []); }, []);
return ( return (
<Box <Box
display="flex" display="flex"
@@ -106,6 +94,7 @@ function Editor(props) {
onOpen={() => setCurrentStepId(step.id)} onOpen={() => setCurrentStepId(step.id)}
onClose={() => setCurrentStepId(null)} onClose={() => setCurrentStepId(null)}
onChange={onStepChange} onChange={onStepChange}
flowId={flow.id}
onContinue={openNextStep(steps[index + 1])} onContinue={openNextStep(steps[index + 1])}
/> />

View File

@@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { useMutation, useQuery } from '@apollo/client'; import { useMutation } from '@apollo/client';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
@@ -8,6 +8,7 @@ import Tooltip from '@mui/material/Tooltip';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
import Snackbar from '@mui/material/Snackbar'; import Snackbar from '@mui/material/Snackbar';
import { EditorProvider } from 'contexts/Editor'; import { EditorProvider } from 'contexts/Editor';
import EditableTypography from 'components/EditableTypography'; import EditableTypography from 'components/EditableTypography';
import Container from 'components/Container'; import Container from 'components/Container';
@@ -15,17 +16,20 @@ import Editor from 'components/Editor';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import { UPDATE_FLOW_STATUS } from 'graphql/mutations/update-flow-status'; import { UPDATE_FLOW_STATUS } from 'graphql/mutations/update-flow-status';
import { UPDATE_FLOW } from 'graphql/mutations/update-flow'; import { UPDATE_FLOW } from 'graphql/mutations/update-flow';
import { GET_FLOW } from 'graphql/queries/get-flow';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
import { TopBar } from './style'; import { TopBar } from './style';
import useFlow from 'hooks/useFlow';
import { useQueryClient } from '@tanstack/react-query';
export default function EditorLayout() { export default function EditorLayout() {
const { flowId } = useParams(); const { flowId } = useParams();
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const [updateFlow] = useMutation(UPDATE_FLOW); const [updateFlow] = useMutation(UPDATE_FLOW);
const [updateFlowStatus] = useMutation(UPDATE_FLOW_STATUS); const [updateFlowStatus] = useMutation(UPDATE_FLOW_STATUS);
const { data, loading } = useQuery(GET_FLOW, { variables: { id: flowId } }); const { data, isLoading: isFlowLoading } = useFlow(flowId);
const flow = data?.getFlow; const flow = data?.data;
const queryClient = useQueryClient();
const onFlowNameUpdate = React.useCallback( const onFlowNameUpdate = React.useCallback(
async (name) => { async (name) => {
await updateFlow({ await updateFlow({
@@ -38,14 +42,17 @@ export default function EditorLayout() {
optimisticResponse: { optimisticResponse: {
updateFlow: { updateFlow: {
__typename: 'Flow', __typename: 'Flow',
id: flow?.id, id: flowId,
name, name,
}, },
}, },
}); });
await queryClient.invalidateQueries({ queryKey: ['flow', flowId] });
}, },
[flow?.id], [flowId, queryClient],
); );
const onFlowStatusUpdate = React.useCallback( const onFlowStatusUpdate = React.useCallback(
async (active) => { async (active) => {
await updateFlowStatus({ await updateFlowStatus({
@@ -58,14 +65,17 @@ export default function EditorLayout() {
optimisticResponse: { optimisticResponse: {
updateFlowStatus: { updateFlowStatus: {
__typename: 'Flow', __typename: 'Flow',
id: flow?.id, id: flowId,
active, active,
}, },
}, },
}); });
await queryClient.invalidateQueries({ queryKey: ['flow', flowId] });
}, },
[flow?.id], [flowId, queryClient],
); );
return ( return (
<> <>
<TopBar <TopBar
@@ -94,7 +104,7 @@ export default function EditorLayout() {
</IconButton> </IconButton>
</Tooltip> </Tooltip>
{!loading && ( {!isFlowLoading && (
<EditableTypography <EditableTypography
variant="body1" variant="body1"
onConfirm={onFlowNameUpdate} onConfirm={onFlowNameUpdate}
@@ -124,7 +134,7 @@ export default function EditorLayout() {
<Stack direction="column" height="100%"> <Stack direction="column" height="100%">
<Container maxWidth="md"> <Container maxWidth="md">
<EditorProvider value={{ readOnly: !!flow?.active }}> <EditorProvider value={{ readOnly: !!flow?.active }}>
{!flow && !loading && 'not found'} {!flow && !isFlowLoading && 'not found'}
{flow && <Editor flow={flow} />} {flow && <Editor flow={flow} />}
</EditorProvider> </EditorProvider>

View File

@@ -5,6 +5,7 @@ import MenuItem from '@mui/material/MenuItem';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
import * as React from 'react'; import * as React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import Can from 'components/Can'; import Can from 'components/Can';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
import { DELETE_FLOW } from 'graphql/mutations/delete-flow'; import { DELETE_FLOW } from 'graphql/mutations/delete-flow';
@@ -12,25 +13,28 @@ import { DUPLICATE_FLOW } from 'graphql/mutations/duplicate-flow';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
function ContextMenu(props) { function ContextMenu(props) {
const { flowId, onClose, anchorEl } = props; const { flowId, onClose, anchorEl, onDuplicateFlow, onDeleteFlow } = props;
const enqueueSnackbar = useEnqueueSnackbar(); const enqueueSnackbar = useEnqueueSnackbar();
const [deleteFlow] = useMutation(DELETE_FLOW); const [deleteFlow] = useMutation(DELETE_FLOW);
const [duplicateFlow] = useMutation(DUPLICATE_FLOW, { const [duplicateFlow] = useMutation(DUPLICATE_FLOW);
refetchQueries: ['GetFlows'],
});
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const onFlowDuplicate = React.useCallback(async () => { const onFlowDuplicate = React.useCallback(async () => {
await duplicateFlow({ await duplicateFlow({
variables: { input: { id: flowId } }, variables: { input: { id: flowId } },
}); });
enqueueSnackbar(formatMessage('flow.successfullyDuplicated'), { enqueueSnackbar(formatMessage('flow.successfullyDuplicated'), {
variant: 'success', variant: 'success',
SnackbarProps: { SnackbarProps: {
'data-test': 'snackbar-duplicate-flow-success', 'data-test': 'snackbar-duplicate-flow-success',
}, },
}); });
onDuplicateFlow?.();
onClose(); onClose();
}, [flowId, onClose, duplicateFlow]); }, [flowId, onClose, duplicateFlow, onDuplicateFlow]);
const onFlowDelete = React.useCallback(async () => { const onFlowDelete = React.useCallback(async () => {
await deleteFlow({ await deleteFlow({
variables: { input: { id: flowId } }, variables: { input: { id: flowId } },
@@ -44,11 +48,15 @@ function ContextMenu(props) {
}); });
}, },
}); });
enqueueSnackbar(formatMessage('flow.successfullyDeleted'), { enqueueSnackbar(formatMessage('flow.successfullyDeleted'), {
variant: 'success', variant: 'success',
}); });
onDeleteFlow?.();
onClose(); onClose();
}, [flowId, onClose, deleteFlow]); }, [flowId, onClose, deleteFlow, onDeleteFlow]);
return ( return (
<Menu <Menu
open={true} open={true}
@@ -90,6 +98,8 @@ ContextMenu.propTypes = {
PropTypes.func, PropTypes.func,
PropTypes.shape({ current: PropTypes.instanceOf(Element) }), PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]).isRequired, ]).isRequired,
onDeleteFlow: PropTypes.func,
onDuplicateFlow: PropTypes.func,
}; };
export default ContextMenu; export default ContextMenu;

View File

@@ -6,6 +6,8 @@ import CardActionArea from '@mui/material/CardActionArea';
import Chip from '@mui/material/Chip'; import Chip from '@mui/material/Chip';
import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import PropTypes from 'prop-types';
import FlowAppIcons from 'components/FlowAppIcons'; import FlowAppIcons from 'components/FlowAppIcons';
import FlowContextMenu from 'components/FlowContextMenu'; import FlowContextMenu from 'components/FlowContextMenu';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
@@ -35,7 +37,7 @@ function FlowRow(props) {
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const contextButtonRef = React.useRef(null); const contextButtonRef = React.useRef(null);
const [anchorEl, setAnchorEl] = React.useState(null); const [anchorEl, setAnchorEl] = React.useState(null);
const { flow } = props; const { flow, onDuplicateFlow, onDeleteFlow } = props;
const handleClose = () => { const handleClose = () => {
setAnchorEl(null); setAnchorEl(null);
}; };
@@ -112,6 +114,8 @@ function FlowRow(props) {
flowId={flow.id} flowId={flow.id}
onClose={handleClose} onClose={handleClose}
anchorEl={anchorEl} anchorEl={anchorEl}
onDeleteFlow={onDeleteFlow}
onDuplicateFlow={onDuplicateFlow}
/> />
)} )}
</> </>
@@ -120,6 +124,8 @@ function FlowRow(props) {
FlowRow.propTypes = { FlowRow.propTypes = {
flow: FlowPropType.isRequired, flow: FlowPropType.isRequired,
onDeleteFlow: PropTypes.func,
onDuplicateFlow: PropTypes.func,
}; };
export default FlowRow; export default FlowRow;

View File

@@ -105,7 +105,7 @@ function generateValidationSchema(substeps) {
} }
function FlowStep(props) { function FlowStep(props) {
const { collapsed, onChange, onContinue } = props; const { collapsed, onChange, onContinue, flowId } = props;
const editorContext = React.useContext(EditorContext); const editorContext = React.useContext(EditorContext);
const contextButtonRef = React.useRef(null); const contextButtonRef = React.useRef(null);
const step = props.step; const step = props.step;
@@ -328,6 +328,7 @@ function FlowStep(props) {
: false : false
} }
step={step} step={step}
flowId={flowId}
/> />
)} )}
@@ -363,6 +364,7 @@ function FlowStep(props) {
deletable={!isTrigger} deletable={!isTrigger}
onClose={onContextMenuClose} onClose={onContextMenuClose}
anchorEl={anchorEl} anchorEl={anchorEl}
flowId={flowId}
/> />
)} )}
</Wrapper> </Wrapper>

View File

@@ -1,24 +1,31 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import * as React from 'react'; import * as React from 'react';
import { useMutation } from '@apollo/client'; import { useMutation } from '@apollo/client';
import Menu from '@mui/material/Menu'; import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem'; import MenuItem from '@mui/material/MenuItem';
import { DELETE_STEP } from 'graphql/mutations/delete-step'; import { DELETE_STEP } from 'graphql/mutations/delete-step';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import { useQueryClient } from '@tanstack/react-query';
function FlowStepContextMenu(props) { function FlowStepContextMenu(props) {
const { stepId, onClose, anchorEl, deletable } = props; const { stepId, onClose, anchorEl, deletable, flowId } = props;
const [deleteStep] = useMutation(DELETE_STEP, {
refetchQueries: ['GetFlow', 'GetStepWithTestExecutions'],
});
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const queryClient = useQueryClient();
const [deleteStep] = useMutation(DELETE_STEP, {
refetchQueries: ['GetStepWithTestExecutions'],
});
const deleteActionHandler = React.useCallback( const deleteActionHandler = React.useCallback(
async (event) => { async (event) => {
event.stopPropagation(); event.stopPropagation();
await deleteStep({ variables: { input: { id: stepId } } }); await deleteStep({ variables: { input: { id: stepId } } });
await queryClient.invalidateQueries({ queryKey: ['flow', flowId] });
}, },
[stepId], [stepId, queryClient],
); );
return ( return (
<Menu <Menu
open={true} open={true}

View File

@@ -6,12 +6,15 @@ import ListItem from '@mui/material/ListItem';
import Alert from '@mui/material/Alert'; import Alert from '@mui/material/Alert';
import AlertTitle from '@mui/material/AlertTitle'; import AlertTitle from '@mui/material/AlertTitle';
import LoadingButton from '@mui/lab/LoadingButton'; import LoadingButton from '@mui/lab/LoadingButton';
import { EditorContext } from 'contexts/Editor'; import { EditorContext } from 'contexts/Editor';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import { EXECUTE_FLOW } from 'graphql/mutations/execute-flow'; import { EXECUTE_FLOW } from 'graphql/mutations/execute-flow';
import JSONViewer from 'components/JSONViewer'; import JSONViewer from 'components/JSONViewer';
import WebhookUrlInfo from 'components/WebhookUrlInfo'; import WebhookUrlInfo from 'components/WebhookUrlInfo';
import FlowSubstepTitle from 'components/FlowSubstepTitle'; import FlowSubstepTitle from 'components/FlowSubstepTitle';
import { useQueryClient } from '@tanstack/react-query';
function serializeErrors(graphQLErrors) { function serializeErrors(graphQLErrors) {
return graphQLErrors?.map((error) => { return graphQLErrors?.map((error) => {
try { try {
@@ -28,6 +31,7 @@ function serializeErrors(graphQLErrors) {
} }
}); });
} }
function TestSubstep(props) { function TestSubstep(props) {
const { const {
substep, substep,
@@ -38,6 +42,7 @@ function TestSubstep(props) {
onContinue, onContinue,
step, step,
showWebhookUrl = false, showWebhookUrl = false,
flowId,
} = props; } = props;
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const editorContext = React.useContext(EditorContext); const editorContext = React.useContext(EditorContext);
@@ -52,6 +57,8 @@ function TestSubstep(props) {
const isCompleted = !error && called && !loading; const isCompleted = !error && called && !loading;
const hasNoOutput = !response && isCompleted; const hasNoOutput = !response && isCompleted;
const { name } = substep; const { name } = substep;
const queryClient = useQueryClient();
React.useEffect( React.useEffect(
function resetTestDataOnSubstepToggle() { function resetTestDataOnSubstepToggle() {
if (!expanded) { if (!expanded) {
@@ -60,20 +67,28 @@ function TestSubstep(props) {
}, },
[expanded, reset], [expanded, reset],
); );
const handleSubmit = React.useCallback(() => {
const handleSubmit = React.useCallback(async () => {
if (isCompleted) { if (isCompleted) {
onContinue?.(); onContinue?.();
return; return;
} }
executeFlow({
await executeFlow({
variables: { variables: {
input: { input: {
stepId: step.id, stepId: step.id,
}, },
}, },
}); });
}, [onSubmit, onContinue, isCompleted, step.id]);
await queryClient.invalidateQueries({
queryKey: ['flow', flowId],
});
}, [onSubmit, onContinue, isCompleted, step.id, queryClient, flowId]);
const onToggle = expanded ? onCollapse : onExpand; const onToggle = expanded ? onCollapse : onExpand;
return ( return (
<React.Fragment> <React.Fragment>
<FlowSubstepTitle expanded={expanded} onClick={onToggle} title={name} /> <FlowSubstepTitle expanded={expanded} onClick={onToggle} title={name} />

View File

@@ -1,27 +0,0 @@
import { gql } from '@apollo/client';
export const GET_FLOW = gql`
query GetFlow($id: String!) {
getFlow(id: $id) {
id
name
active
status
steps {
id
type
key
appKey
iconUrl
webhookUrl
status
position
connection {
id
verified
createdAt
}
parameters
}
}
}
`;

View File

@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import api from 'helpers/api';
export default function useFlow(flowId) {
const query = useQuery({
queryKey: ['flow', flowId],
queryFn: async ({ signal }) => {
const { data } = await api.get(`/v1/flows/${flowId}`, {
signal,
});
return data;
},
enabled: !!flowId,
});
return query;
}

View File

@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import api from 'helpers/api';
export default function useStepConnection(stepId) {
const query = useQuery({
queryKey: ['stepConnection', stepId],
queryFn: async ({ signal }) => {
const { data } = await api.get(`/v1/steps/${stepId}/connection`, {
signal,
});
return data;
},
enabled: !!stepId,
});
return query;
}

View File

@@ -27,7 +27,7 @@ export default function Flows() {
const [flowName, setFlowName] = React.useState(''); const [flowName, setFlowName] = React.useState('');
const [isLoading, setIsLoading] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false);
const { data, mutate } = useLazyFlows( const { data, mutate: fetchFlows } = useLazyFlows(
{ flowName, page }, { flowName, page },
{ {
onSettled: () => { onSettled: () => {
@@ -36,7 +36,10 @@ export default function Flows() {
}, },
); );
const fetchData = React.useMemo(() => debounce(mutate, 300), [mutate]); const fetchData = React.useMemo(
() => debounce(fetchFlows, 300),
[fetchFlows],
);
React.useEffect(() => { React.useEffect(() => {
setIsLoading(true); setIsLoading(true);
@@ -110,7 +113,14 @@ export default function Flows() {
<CircularProgress sx={{ display: 'block', margin: '20px auto' }} /> <CircularProgress sx={{ display: 'block', margin: '20px auto' }} />
)} )}
{!isLoading && {!isLoading &&
flows?.map((flow) => <FlowRow key={flow.id} flow={flow} />)} flows?.map((flow) => (
<FlowRow
key={flow.id}
flow={flow}
onDuplicateFlow={fetchFlows}
onDeleteFlow={fetchFlows}
/>
))}
{!isLoading && !hasFlows && ( {!isLoading && !hasFlows && (
<NoResultFound <NoResultFound
text={formatMessage('flows.noFlows')} text={formatMessage('flows.noFlows')}