diff --git a/packages/backend/src/graphql/mutation-resolvers.ts b/packages/backend/src/graphql/mutation-resolvers.ts index c19411c1..f655f434 100644 --- a/packages/backend/src/graphql/mutation-resolvers.ts +++ b/packages/backend/src/graphql/mutation-resolvers.ts @@ -9,6 +9,7 @@ import updateFlow from './mutations/update-flow'; import updateFlowStatus from './mutations/update-flow-status'; import executeFlow from './mutations/execute-flow'; import deleteFlow from './mutations/delete-flow'; +import duplicateFlow from './mutations/duplicate-flow'; import createStep from './mutations/create-step'; import updateStep from './mutations/update-step'; import deleteStep from './mutations/delete-step'; @@ -31,6 +32,7 @@ const mutationResolvers = { updateFlowStatus, executeFlow, deleteFlow, + duplicateFlow, createStep, updateStep, deleteStep, diff --git a/packages/backend/src/graphql/mutations/duplicate-flow.ts b/packages/backend/src/graphql/mutations/duplicate-flow.ts new file mode 100644 index 00000000..b0120790 --- /dev/null +++ b/packages/backend/src/graphql/mutations/duplicate-flow.ts @@ -0,0 +1,88 @@ +import Context from '../../types/express/context'; +import Step from '../../models/step'; + +type Params = { + input: { + id: string; + }; +}; + +type NewStepIds = Record; + +function updateStepId(value: string, newStepIds: NewStepIds) { + let newValue = value; + + const stepIdEntries = Object.entries(newStepIds); + for (const stepIdEntry of stepIdEntries) { + const [oldStepId, newStepId] = stepIdEntry; + const partialOldVariable = `{{step.${oldStepId}.`; + const partialNewVariable = `{{step.${newStepId}.`; + + newValue = newValue.replace(partialOldVariable, partialNewVariable); + } + + return newValue; +} + +function updateStepVariables(parameters: Step['parameters'], newStepIds: NewStepIds): Step['parameters'] { + const entries = Object.entries(parameters); + return entries.reduce((result, [key, value]: [string, unknown]) => { + if (typeof value === 'string') { + return { + ...result, + [key]: updateStepId(value, newStepIds), + }; + } + + if (Array.isArray(value)) { + return { + ...result, + [key]: value.map(item => updateStepVariables(item, newStepIds)), + }; + } + + return { + ...result, + [key]: value, + }; + }, {}); +} + +const duplicateFlow = async ( + _parent: unknown, + params: Params, + context: Context +) => { + const flow = await context.currentUser + .$relatedQuery('flows') + .withGraphJoined('[steps]') + .orderBy('steps.position', 'asc') + .findOne({ 'flows.id': params.input.id }) + .throwIfNotFound(); + + const duplicatedFlow = await context.currentUser + .$relatedQuery('flows') + .insert({ + name: `Copy of ${flow.name}`, + active: false, + }); + + const newStepIds: NewStepIds = {}; + for (const step of flow.steps) { + const duplicatedStep = await duplicatedFlow.$relatedQuery('steps') + .insert({ + key: step.key, + appKey: step.appKey, + type: step.type, + connectionId: step.connectionId, + position: step.position, + parameters: updateStepVariables(step.parameters, newStepIds), + }); + + newStepIds[step.id] = duplicatedStep.id; + } + + return duplicatedFlow; +}; + +export default duplicateFlow; diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index 13e67fc6..0e7a24ea 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -56,6 +56,7 @@ type Mutation { updateFlowStatus(input: UpdateFlowStatusInput): Flow executeFlow(input: ExecuteFlowInput): executeFlowType deleteFlow(input: DeleteFlowInput): Boolean + duplicateFlow(input: DuplicateFlowInput): Flow createStep(input: CreateStepInput): Step updateStep(input: UpdateStepInput): Step deleteStep(input: DeleteStepInput): Step @@ -324,6 +325,10 @@ input DeleteFlowInput { id: String! } +input DuplicateFlowInput { + id: String! +} + input CreateStepInput { id: String previousStepId: String diff --git a/packages/web/src/components/FlowContextMenu/index.tsx b/packages/web/src/components/FlowContextMenu/index.tsx index b1e6a899..dbb45afc 100644 --- a/packages/web/src/components/FlowContextMenu/index.tsx +++ b/packages/web/src/components/FlowContextMenu/index.tsx @@ -7,6 +7,7 @@ import MenuItem from '@mui/material/MenuItem'; import { useSnackbar } from 'notistack'; import { DELETE_FLOW } from 'graphql/mutations/delete-flow'; +import { DUPLICATE_FLOW } from 'graphql/mutations/duplicate-flow'; import * as URLS from 'config/urls'; import useFormatMessage from 'hooks/useFormatMessage'; @@ -22,8 +23,26 @@ export default function ContextMenu( const { flowId, onClose, anchorEl } = props; const { enqueueSnackbar } = useSnackbar(); const [deleteFlow] = useMutation(DELETE_FLOW); + const [duplicateFlow] = useMutation( + DUPLICATE_FLOW, + { + refetchQueries: ['GetFlows'], + } + ); const formatMessage = useFormatMessage(); + const onFlowDuplicate = React.useCallback(async () => { + await duplicateFlow({ + variables: { input: { id: flowId } }, + }); + + enqueueSnackbar(formatMessage('flow.successfullyDuplicated'), { + variant: 'success', + }); + + onClose(); + }, [flowId, onClose, duplicateFlow]); + const onFlowDelete = React.useCallback(async () => { await deleteFlow({ variables: { input: { id: flowId } }, @@ -42,7 +61,9 @@ export default function ContextMenu( enqueueSnackbar(formatMessage('flow.successfullyDeleted'), { variant: 'success', }); - }, [flowId, deleteFlow]); + + onClose(); + }, [flowId, onClose, deleteFlow]); return ( + {formatMessage('flow.duplicate')} + {formatMessage('flow.delete')} ); diff --git a/packages/web/src/graphql/mutations/duplicate-flow.ts b/packages/web/src/graphql/mutations/duplicate-flow.ts new file mode 100644 index 00000000..30cf60c6 --- /dev/null +++ b/packages/web/src/graphql/mutations/duplicate-flow.ts @@ -0,0 +1,28 @@ +import { gql } from '@apollo/client'; + +export const DUPLICATE_FLOW = gql` + mutation DuplicateFlow($input: DuplicateFlowInput) { + duplicateFlow(input: $input) { + id + name + active + status + steps { + id + type + key + appKey + iconUrl + webhookUrl + status + position + connection { + id + verified + createdAt + } + parameters + } + } + } +`; diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index 75462da4..0d0cdfb3 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -46,6 +46,7 @@ "flow.paused": "Paused", "flow.draft": "Draft", "flow.successfullyDeleted": "The flow and associated executions have been deleted.", + "flow.successfullyDuplicated": "The flow has been successfully duplicated.", "flowEditor.publish": "PUBLISH", "flowEditor.unpublish": "UNPUBLISH", "flowEditor.publishedFlowCannotBeUpdated": "To edit this flow, you must first unpublish it.", @@ -68,6 +69,7 @@ "flow.createdAt": "created {datetime}", "flow.updatedAt": "updated {datetime}", "flow.view": "View", + "flow.duplicate": "Duplicate", "flow.delete": "Delete", "flowStep.triggerType": "Trigger", "flowStep.actionType": "Action",