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
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,