From 065923d7245bc650a594b02505a53a328b3374f0 Mon Sep 17 00:00:00 2001 From: Harshit Singh <73997189+harshit078@users.noreply.github.com> Date: Wed, 12 Nov 2025 15:23:25 +0530 Subject: [PATCH] feat: Add Workflow duplicate step (#15622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description - This PR address issue https://github.com/twentyhq/core-team-issues/issues/1800 - Added workflow duplicate option in workflow sidepanel - Introduced DuplicateWorkflow.ts mutuation - Updated graphql generated metadata with new duplicate type ## Visual Appearanc Screenshot 2025-11-07 at 4 46
22 PM https://github.com/user-attachments/assets/3dbf73cf-f4dd-484c-94a3-29577cfbac25 --------- Co-authored-by: Devessier --- .../src/generated-metadata/graphql.ts | 60 ++++++ .../twenty-front/src/generated/graphql.ts | 13 ++ .../constants/WorkflowActionsConfig.tsx | 21 +++ .../DuplicateWorkflowSingleRecordAction.tsx | 47 +++++ .../types/WorkflowSingleRecordActionsKeys.ts | 1 + .../graphql/mutations/duplicateWorkflow.ts | 16 ++ .../workflow/hooks/useDuplicateWorkflow.ts | 50 +++++ .../dtos/duplicate-workflow-input.dto.ts | 18 ++ .../resolvers/workflow-version.resolver.ts | 14 ++ ...-step-operations.workspace-service.spec.ts | 75 +++++++- ...rsion-step-operations.workspace-service.ts | 34 +++- ...workflow-version-step.workspace-service.ts | 14 +- .../workflow-version.workspace-service.ts | 174 +++++++++++++++++- 13 files changed, 515 insertions(+), 22 deletions(-) create mode 100644 packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/components/DuplicateWorkflowSingleRecordAction.tsx create mode 100644 packages/twenty-front/src/modules/workflow/graphql/mutations/duplicateWorkflow.ts create mode 100644 packages/twenty-front/src/modules/workflow/hooks/useDuplicateWorkflow.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workflow/dtos/duplicate-workflow-input.dto.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 6bdd3d6721..bb73f8d621 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1180,6 +1180,13 @@ export type DomainValidRecords = { records: Array; }; +export type DuplicateWorkflowInput = { + /** Workflow ID to duplicate */ + workflowIdToDuplicate: Scalars['UUID']; + /** Workflow version ID to copy */ + workflowVersionIdToCopy: Scalars['UUID']; +}; + export type DuplicateWorkflowVersionStepInput = { stepId: Scalars['String']; workflowVersionId: Scalars['String']; @@ -1833,6 +1840,7 @@ export type Mutation = { destroyPageLayoutTab: Scalars['Boolean']; destroyPageLayoutWidget: Scalars['Boolean']; disablePostgresProxy: PostgresCredentials; + duplicateWorkflow: WorkflowVersionDto; duplicateWorkflowVersionStep: WorkflowVersionStepChanges; editSSOIdentityProvider: EditSsoOutput; emailPasswordResetLink: EmailPasswordResetLinkOutput; @@ -2371,6 +2379,11 @@ export type MutationDestroyPageLayoutWidgetArgs = { }; +export type MutationDuplicateWorkflowArgs = { + input: DuplicateWorkflowInput; +}; + + export type MutationDuplicateWorkflowVersionStepArgs = { input: DuplicateWorkflowVersionStepInput; }; @@ -6306,6 +6319,13 @@ export type DeleteWorkflowVersionStepMutationVariables = Exact<{ export type DeleteWorkflowVersionStepMutation = { __typename?: 'Mutation', deleteWorkflowVersionStep: { __typename?: 'WorkflowVersionStepChanges', triggerDiff?: any | null, stepsDiff?: any | null } }; +export type DuplicateWorkflowMutationVariables = Exact<{ + input: DuplicateWorkflowInput; +}>; + + +export type DuplicateWorkflowMutation = { __typename?: 'Mutation', duplicateWorkflow: { __typename?: 'WorkflowVersionDTO', id: string, name: string, status: string, trigger?: any | null, steps?: any | null, createdAt: string, updatedAt: string, workflowId: string } }; + export type DuplicateWorkflowVersionStepMutationVariables = Exact<{ input: DuplicateWorkflowVersionStepInput; }>; @@ -14093,6 +14113,46 @@ export function useDeleteWorkflowVersionStepMutation(baseOptions?: Apollo.Mutati export type DeleteWorkflowVersionStepMutationHookResult = ReturnType; export type DeleteWorkflowVersionStepMutationResult = Apollo.MutationResult; export type DeleteWorkflowVersionStepMutationOptions = Apollo.BaseMutationOptions; +export const DuplicateWorkflowDocument = gql` + mutation DuplicateWorkflow($input: DuplicateWorkflowInput!) { + duplicateWorkflow(input: $input) { + id + name + status + trigger + steps + createdAt + updatedAt + workflowId + } +} + `; +export type DuplicateWorkflowMutationFn = Apollo.MutationFunction; + +/** + * __useDuplicateWorkflowMutation__ + * + * To run a mutation, you first call `useDuplicateWorkflowMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDuplicateWorkflowMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [duplicateWorkflowMutation, { data, loading, error }] = useDuplicateWorkflowMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useDuplicateWorkflowMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(DuplicateWorkflowDocument, options); + } +export type DuplicateWorkflowMutationHookResult = ReturnType; +export type DuplicateWorkflowMutationResult = Apollo.MutationResult; +export type DuplicateWorkflowMutationOptions = Apollo.BaseMutationOptions; export const DuplicateWorkflowVersionStepDocument = gql` mutation DuplicateWorkflowVersionStep($input: DuplicateWorkflowVersionStepInput!) { duplicateWorkflowVersionStep(input: $input) { diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index ab98ba7754..4a42dd275a 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -1121,6 +1121,13 @@ export type DomainValidRecords = { records: Array; }; +export type DuplicateWorkflowInput = { + /** Workflow ID to duplicate */ + workflowIdToDuplicate: Scalars['UUID']; + /** Workflow version ID to copy */ + workflowVersionIdToCopy: Scalars['UUID']; +}; + export type DuplicateWorkflowVersionStepInput = { stepId: Scalars['String']; workflowVersionId: Scalars['String']; @@ -1761,6 +1768,7 @@ export type Mutation = { destroyPageLayoutTab: Scalars['Boolean']; destroyPageLayoutWidget: Scalars['Boolean']; disablePostgresProxy: PostgresCredentials; + duplicateWorkflow: WorkflowVersionDto; duplicateWorkflowVersionStep: WorkflowVersionStepChanges; editSSOIdentityProvider: EditSsoOutput; emailPasswordResetLink: EmailPasswordResetLinkOutput; @@ -2258,6 +2266,11 @@ export type MutationDestroyPageLayoutWidgetArgs = { }; +export type MutationDuplicateWorkflowArgs = { + input: DuplicateWorkflowInput; +}; + + export type MutationDuplicateWorkflowVersionStepArgs = { input: DuplicateWorkflowVersionStepInput; }; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/constants/WorkflowActionsConfig.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/constants/WorkflowActionsConfig.tsx index 4811b9c090..c9e9cd401d 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/constants/WorkflowActionsConfig.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/constants/WorkflowActionsConfig.tsx @@ -7,6 +7,7 @@ import { ActivateWorkflowSingleRecordAction } from '@/action-menu/actions/record import { AddNodeWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/components/AddNodeWorkflowSingleRecordAction'; import { DeactivateWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/components/DeactivateWorkflowSingleRecordAction'; import { DiscardDraftWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/components/DiscardDraftWorkflowSingleRecordAction'; +import { DuplicateWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/components/DuplicateWorkflowSingleRecordAction'; import { SeeActiveVersionWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/components/SeeActiveVersionWorkflowSingleRecordAction'; import { SeeRunsWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/components/SeeRunsWorkflowSingleRecordAction'; import { SeeVersionsWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/components/SeeVersionsWorkflowSingleRecordAction'; @@ -35,6 +36,7 @@ import { IconPower, IconReorder, IconVersions, + IconCopy, } from 'twenty-ui/display'; const areWorkflowTriggerAndStepsDefined = ( @@ -115,6 +117,25 @@ export const WORKFLOW_ACTIONS_CONFIG = inheritActionsFromDefaultConfig({ ], component: , }, + [WorkflowSingleRecordActionKeys.DUPLICATE_WORKFLOW]: { + key: WorkflowSingleRecordActionKeys.DUPLICATE_WORKFLOW, + label: msg`Duplicate Workflow`, + shortLabel: msg`Duplicate`, + isPinned: false, + position: 10, + Icon: IconCopy, + type: ActionType.Standard, + scope: ActionScope.RecordSelection, + shouldBeRegistered: ({ selectedRecord, workflowWithCurrentVersion }) => + isDefined(workflowWithCurrentVersion) && + isDefined(workflowWithCurrentVersion.currentVersion) && + !isDefined(selectedRecord?.deletedAt), + availableOn: [ + ActionViewType.SHOW_PAGE, + ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION, + ], + component: , + }, [WorkflowSingleRecordActionKeys.SEE_ACTIVE_VERSION]: { key: WorkflowSingleRecordActionKeys.SEE_ACTIVE_VERSION, label: msg`See active version`, diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/components/DuplicateWorkflowSingleRecordAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/components/DuplicateWorkflowSingleRecordAction.tsx new file mode 100644 index 0000000000..12488f6b34 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/components/DuplicateWorkflowSingleRecordAction.tsx @@ -0,0 +1,47 @@ +import { Action } from '@/action-menu/actions/components/Action'; +import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useDuplicateWorkflow } from '@/workflow/hooks/useDuplicateWorkflow'; +import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; +import { useLingui } from '@lingui/react/macro'; +import { isNonEmptyString } from '@sniptt/guards'; +import { AppPath } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { useNavigateApp } from '~/hooks/useNavigateApp'; + +export const DuplicateWorkflowSingleRecordAction = () => { + const recordId = useSelectedRecordIdOrThrow(); + const workflow = useWorkflowWithCurrentVersion(recordId); + const { duplicateWorkflow } = useDuplicateWorkflow(); + const navigate = useNavigateApp(); + const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar(); + const { t } = useLingui(); + + const handleClick = async () => { + if (!isDefined(workflow) || !isDefined(workflow.currentVersion)) { + return; + } + + const result = await duplicateWorkflow({ + workflowIdToDuplicate: workflow.id, + workflowVersionIdToCopy: workflow.currentVersion.id, + }); + + if (isDefined(result) && isNonEmptyString(result.workflowId)) { + enqueueSuccessSnackBar({ + message: t`Workflow duplicated successfully`, + }); + navigate(AppPath.RecordShowPage, { + objectNameSingular: CoreObjectNameSingular.Workflow, + objectRecordId: result.workflowId, + }); + } else { + enqueueErrorSnackBar({ + message: t`Failed to duplicate workflow`, + }); + } + }; + + return isDefined(workflow) ? : null; +}; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/types/WorkflowSingleRecordActionsKeys.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/types/WorkflowSingleRecordActionsKeys.ts index dd0030910e..d8d6f3490a 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/types/WorkflowSingleRecordActionsKeys.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/types/WorkflowSingleRecordActionsKeys.ts @@ -2,6 +2,7 @@ export enum WorkflowSingleRecordActionKeys { ACTIVATE = 'activate-workflow-single-record', DEACTIVATE = 'deactivate-workflow-single-record', DISCARD_DRAFT = 'discard-draft-workflow-single-record', + DUPLICATE_WORKFLOW = 'duplicate-workflow-single-record', SEE_ACTIVE_VERSION = 'see-active-version-workflow-single-record', SEE_RUNS = 'see-runs-workflow-single-record', SEE_VERSIONS = 'see-versions-workflow-single-record', diff --git a/packages/twenty-front/src/modules/workflow/graphql/mutations/duplicateWorkflow.ts b/packages/twenty-front/src/modules/workflow/graphql/mutations/duplicateWorkflow.ts new file mode 100644 index 0000000000..20e91ef918 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/graphql/mutations/duplicateWorkflow.ts @@ -0,0 +1,16 @@ +import { gql } from '@apollo/client'; + +export const DUPLICATE_WORKFLOW = gql` + mutation DuplicateWorkflow($input: DuplicateWorkflowInput!) { + duplicateWorkflow(input: $input) { + id + name + status + trigger + steps + createdAt + updatedAt + workflowId + } + } +`; diff --git a/packages/twenty-front/src/modules/workflow/hooks/useDuplicateWorkflow.ts b/packages/twenty-front/src/modules/workflow/hooks/useDuplicateWorkflow.ts new file mode 100644 index 0000000000..4b26d23031 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/hooks/useDuplicateWorkflow.ts @@ -0,0 +1,50 @@ +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useFindManyRecordsQuery } from '@/object-record/hooks/useFindManyRecordsQuery'; +import { useMutation } from '@apollo/client'; +import { + type DuplicateWorkflowInput, + type WorkflowVersionDto, +} from '~/generated/graphql'; +import { DUPLICATE_WORKFLOW } from '@/workflow/graphql/mutations/duplicateWorkflow'; + +export const useDuplicateWorkflow = () => { + const apolloCoreClient = useApolloCoreClient(); + const [mutate] = useMutation< + { duplicateWorkflow: WorkflowVersionDto }, + { input: DuplicateWorkflowInput } + >(DUPLICATE_WORKFLOW, { + client: apolloCoreClient, + }); + + const { findManyRecordsQuery: findManyWorkflowsQuery } = + useFindManyRecordsQuery({ + objectNameSingular: CoreObjectNameSingular.Workflow, + recordGqlFields: { + id: true, + name: true, + statuses: true, + lastPublishedVersionId: true, + versions: true, + }, + }); + + const duplicateWorkflow = async (input: DuplicateWorkflowInput) => { + const result = await mutate({ + variables: { input }, + awaitRefetchQueries: true, + refetchQueries: [ + { + query: findManyWorkflowsQuery, + variables: {}, + }, + ], + }); + + return result?.data?.duplicateWorkflow; + }; + + return { + duplicateWorkflow, + }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/workflow/dtos/duplicate-workflow-input.dto.ts b/packages/twenty-server/src/engine/core-modules/workflow/dtos/duplicate-workflow-input.dto.ts new file mode 100644 index 0000000000..dc6d6a6e22 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workflow/dtos/duplicate-workflow-input.dto.ts @@ -0,0 +1,18 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@InputType() +export class DuplicateWorkflowInput { + @Field(() => UUIDScalarType, { + description: 'Workflow ID to duplicate', + nullable: false, + }) + workflowIdToDuplicate: string; + + @Field(() => UUIDScalarType, { + description: 'Workflow version ID to copy', + nullable: false, + }) + workflowVersionIdToCopy: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version.resolver.ts b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version.resolver.ts index 4aebbb769f..6dac344d81 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version.resolver.ts @@ -14,6 +14,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; import { WorkflowVersionWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service'; +import { DuplicateWorkflowInput } from 'src/engine/core-modules/workflow/dtos/duplicate-workflow-input.dto'; @Resolver() @UsePipes(ResolverValidationPipe) @@ -47,6 +48,19 @@ export class WorkflowVersionResolver { }); } + @Mutation(() => WorkflowVersionDTO) + async duplicateWorkflow( + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + @Args('input') + { workflowIdToDuplicate, workflowVersionIdToCopy }: DuplicateWorkflowInput, + ): Promise { + return this.workflowVersionWorkspaceService.duplicateWorkflow({ + workspaceId, + workflowIdToDuplicate, + workflowVersionIdToCopy, + }); + } + @Mutation(() => Boolean) async updateWorkflowVersionPositions( @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step-operations.workspace-service.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step-operations.workspace-service.spec.ts index f87412e48d..f3615eb4af 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step-operations.workspace-service.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step-operations.workspace-service.spec.ts @@ -258,14 +258,17 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => { mockNewServerlessFunction, ); - const result = await service.createStepForDuplicate({ + const clonedStep = await service.cloneStep({ step: originalStep, workspaceId: mockWorkspaceId, }); + const duplicateStep = service.markStepAsDuplicate({ + step: clonedStep, + }); - expect(result.id).not.toBe('original-id'); - expect(result.name).toBe('Original Step (Duplicate)'); - const codeResult = result as unknown as { + expect(duplicateStep.id).not.toBe('original-id'); + expect(duplicateStep.name).toBe('Original Step (Duplicate)'); + const codeResult = duplicateStep as unknown as { settings: { input: { serverlessFunctionId: string; @@ -279,7 +282,7 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => { ); expect(codeResult.settings.input.serverlessFunctionVersion).toBe('draft'); - expect(result.nextStepIds).toEqual([]); + expect(duplicateStep.nextStepIds).toEqual([]); }); it('should duplicate non-code step', async () => { @@ -294,15 +297,67 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => { nextStepIds: ['next-step'], } as unknown as WorkflowAction; - const result = await service.createStepForDuplicate({ + const clonedStep = await service.cloneStep({ + step: originalStep, + workspaceId: mockWorkspaceId, + }); + const duplicateStep = service.markStepAsDuplicate({ + step: clonedStep, + }); + + expect(duplicateStep.id).not.toBe('original-id'); + expect(duplicateStep.name).toBe('Original Step (Duplicate)'); + expect(duplicateStep.settings).toEqual(originalStep.settings); + expect(duplicateStep.nextStepIds).toEqual([]); + }); + + it('should duplicate iterator step with cleared initialLoopStepIds', async () => { + const originalStep = { + id: 'original-iterator-id', + type: WorkflowActionType.ITERATOR, + name: 'Iterator Step', + valid: true, + position: { x: 100, y: 200 }, + settings: { + input: { + items: ['item1', 'item2', 'item3'], + initialLoopStepIds: ['loop-step-1', 'loop-step-2'], + }, + outputSchema: {}, + errorHandlingOptions: { + continueOnFailure: { value: false }, + retryOnFailure: { value: false }, + }, + }, + nextStepIds: ['next-step'], + } as unknown as WorkflowAction; + + const clonedStep = await service.cloneStep({ step: originalStep, workspaceId: mockWorkspaceId, }); - expect(result.id).not.toBe('original-id'); - expect(result.name).toBe('Original Step (Duplicate)'); - expect(result.settings).toEqual(originalStep.settings); - expect(result.nextStepIds).toEqual([]); + expect(clonedStep.id).not.toBe('original-iterator-id'); + expect(clonedStep.type).toBe(WorkflowActionType.ITERATOR); + expect(clonedStep.nextStepIds).toEqual([]); + expect(clonedStep.position).toEqual({ x: 100, y: 200 }); + + const iteratorResult = clonedStep as unknown as { + settings: { + input: { + items: string[]; + initialLoopStepIds: string[]; + }; + }; + }; + + expect(iteratorResult.settings.input.items).toEqual([ + 'item1', + 'item2', + 'item3', + ]); + + expect(iteratorResult.settings.input.initialLoopStepIds).toEqual([]); }); }); }); diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts index b3e3e85d1c..bca7814c54 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts @@ -474,7 +474,7 @@ export class WorkflowVersionStepOperationsWorkspaceService { }, {}); } - async createStepForDuplicate({ + async cloneStep({ step, workspaceId, }: { @@ -482,8 +482,8 @@ export class WorkflowVersionStepOperationsWorkspaceService { workspaceId: string; }): Promise { const duplicatedStepPosition = { - x: (step.position?.x ?? 0) + DUPLICATED_STEP_POSITION_OFFSET, - y: (step.position?.y ?? 0) + DUPLICATED_STEP_POSITION_OFFSET, + x: step.position?.x ?? 0, + y: step.position?.y ?? 0, }; switch (step.type) { @@ -498,7 +498,6 @@ export class WorkflowVersionStepOperationsWorkspaceService { return { ...step, id: v4(), - name: `${step.name} (Duplicate)`, nextStepIds: [], position: duplicatedStepPosition, settings: { @@ -511,11 +510,25 @@ export class WorkflowVersionStepOperationsWorkspaceService { }, }; } + case WorkflowActionType.ITERATOR: { + return { + ...step, + id: v4(), + nextStepIds: [], + position: duplicatedStepPosition, + settings: { + ...step.settings, + input: { + ...step.settings.input, + initialLoopStepIds: [], + }, + }, + }; + } default: { return { ...step, id: v4(), - name: `${step.name} (Duplicate)`, nextStepIds: [], position: duplicatedStepPosition, }; @@ -523,6 +536,17 @@ export class WorkflowVersionStepOperationsWorkspaceService { } } + markStepAsDuplicate({ step }: { step: WorkflowAction }): WorkflowAction { + return { + ...step, + name: `${step.name} (Duplicate)`, + position: { + x: (step.position?.x ?? 0) + DUPLICATED_STEP_POSITION_OFFSET, + y: (step.position?.y ?? 0) + DUPLICATED_STEP_POSITION_OFFSET, + }, + }; + } + async createEmptyNodeForIteratorStep({ iteratorStepId, workflowVersionId, diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service.ts index 155c1045b1..a37f17ae03 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service.ts @@ -297,13 +297,15 @@ export class WorkflowVersionStepWorkspaceService { ); } + const clonedStep = + await this.workflowVersionStepOperationsWorkspaceService.cloneStep({ + step: stepToDuplicate, + workspaceId, + }); const duplicatedStep = - await this.workflowVersionStepOperationsWorkspaceService.createStepForDuplicate( - { - step: stepToDuplicate, - workspaceId, - }, - ); + this.workflowVersionStepOperationsWorkspaceService.markStepAsDuplicate({ + step: clonedStep, + }); const { updatedSteps, updatedTrigger } = insertStep({ existingSteps: workflowVersion.steps ?? [], diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service.ts index 3025dfcd7c..160cad1c75 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service.ts @@ -14,17 +14,26 @@ import { WorkflowVersionStatus, type WorkflowVersionWorkspaceEntity, } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity'; +import { + WorkflowStatus, + WorkflowWorkspaceEntity, +} from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity'; import { assertWorkflowVersionHasSteps } from 'src/modules/workflow/common/utils/assert-workflow-version-has-steps'; import { assertWorkflowVersionIsDraft } from 'src/modules/workflow/common/utils/assert-workflow-version-is-draft.util'; import { assertWorkflowVersionTriggerIsDefined } from 'src/modules/workflow/common/utils/assert-workflow-version-trigger-is-defined.util'; +import { WorkflowVersionStepOperationsWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service'; import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service'; -import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { + WorkflowActionType, + type WorkflowAction, +} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; @Injectable() export class WorkflowVersionWorkspaceService { constructor( private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly workflowVersionStepWorkspaceService: WorkflowVersionStepWorkspaceService, + private readonly workflowVersionStepOperationsWorkspaceService: WorkflowVersionStepOperationsWorkspaceService, private readonly recordPositionService: RecordPositionService, ) {} @@ -122,6 +131,169 @@ export class WorkflowVersionWorkspaceService { }; } + async duplicateWorkflow({ + workspaceId, + workflowIdToDuplicate, + workflowVersionIdToCopy, + }: { + workspaceId: string; + workflowIdToDuplicate: string; + workflowVersionIdToCopy: string; + }) { + const workflowRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + 'workflow', + { shouldBypassPermissionChecks: true }, + ); + + const workflowVersionRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + 'workflowVersion', + { shouldBypassPermissionChecks: true }, + ); + + const sourceWorkflow = await workflowRepository.findOne({ + where: { + id: workflowIdToDuplicate, + }, + }); + + if (!isDefined(sourceWorkflow)) { + throw new WorkflowVersionStepException( + 'Source workflow not found', + WorkflowVersionStepExceptionCode.NOT_FOUND, + ); + } + + const sourceVersion = await workflowVersionRepository.findOne({ + where: { + id: workflowVersionIdToCopy, + workflowId: workflowIdToDuplicate, + }, + }); + + if (!isDefined(sourceVersion)) { + throw new WorkflowVersionStepException( + 'WorkflowVersion to copy not found', + WorkflowVersionStepExceptionCode.NOT_FOUND, + ); + } + + assertWorkflowVersionTriggerIsDefined(sourceVersion); + assertWorkflowVersionHasSteps(sourceVersion); + + const workflowPosition = + await this.recordPositionService.buildRecordPosition({ + value: 'first', + objectMetadata: { + isCustom: false, + nameSingular: 'workflow', + }, + workspaceId, + }); + + const insertWorkflowResult = await workflowRepository.insert({ + name: `${sourceWorkflow.name} (Duplicate)`, + statuses: [WorkflowStatus.DRAFT], + position: workflowPosition, + }); + + const newWorkflowId = ( + insertWorkflowResult.generatedMaps[0] as WorkflowWorkspaceEntity + ).id; + + const versionPosition = + await this.recordPositionService.buildRecordPosition({ + value: 'first', + objectMetadata: { + isCustom: false, + nameSingular: 'workflowVersion', + }, + workspaceId, + }); + + const insertVersionResult = await workflowVersionRepository.insert({ + workflowId: newWorkflowId, + name: 'v1', + status: WorkflowVersionStatus.DRAFT, + position: versionPosition, + }); + + const newDraftVersion = insertVersionResult + .generatedMaps[0] as WorkflowVersionWorkspaceEntity; + + const newTrigger = sourceVersion.trigger; + const sourceToClonedPairs: Array<{ + source: WorkflowAction; + duplicated: WorkflowAction; + }> = []; + const oldToNewIdMap = new Map(); + + for (const step of sourceVersion.steps ?? []) { + const clonedStep = + await this.workflowVersionStepOperationsWorkspaceService.cloneStep({ + step, + workspaceId, + }); + + sourceToClonedPairs.push({ + source: step, + duplicated: clonedStep, + }); + oldToNewIdMap.set(step.id, clonedStep.id); + } + + const remappedTrigger = isDefined(newTrigger) + ? { + ...newTrigger, + nextStepIds: (newTrigger.nextStepIds ?? []).map( + (oldId) => oldToNewIdMap.get(oldId) ?? oldId, + ), + } + : undefined; + + const remappedSteps: WorkflowAction[] = sourceToClonedPairs.map( + ({ source, duplicated }) => { + const remappedStep = { + ...duplicated, + nextStepIds: (source.nextStepIds ?? []).map( + (oldId) => oldToNewIdMap.get(oldId) ?? oldId, + ), + }; + + if ( + source.type === WorkflowActionType.ITERATOR && + isDefined(source.settings?.input?.initialLoopStepIds) + ) { + remappedStep.settings = { + ...remappedStep.settings, + input: { + ...remappedStep.settings.input, + initialLoopStepIds: source.settings.input.initialLoopStepIds.map( + (oldId) => oldToNewIdMap.get(oldId) ?? oldId, + ), + }, + }; + } + + return remappedStep; + }, + ); + + await workflowVersionRepository.update(newDraftVersion.id, { + steps: remappedSteps, + trigger: remappedTrigger, + }); + + return { + ...newDraftVersion, + steps: remappedSteps, + trigger: remappedTrigger ?? null, + }; + } + async updateWorkflowVersionPositions({ workflowVersionId, positions,