feat: Add Workflow duplicate step (#15622)

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

<img width="1792" height="1031" alt="Screenshot 2025-11-07 at 4 46
22 PM"
src="https://github.com/user-attachments/assets/d75f99df-ae18-4b41-a5f4-21c50010cdbc"
/>


https://github.com/user-attachments/assets/3dbf73cf-f4dd-484c-94a3-29577cfbac25

---------

Co-authored-by: Devessier <baptiste@devessier.fr>
This commit is contained in:
Harshit Singh
2025-11-12 15:23:25 +05:30
committed by GitHub
parent 12e2820715
commit 065923d724
13 changed files with 515 additions and 22 deletions
@@ -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([]);
});
});
});
@@ -474,7 +474,7 @@ export class WorkflowVersionStepOperationsWorkspaceService {
}, {});
}
async createStepForDuplicate({
async cloneStep({
step,
workspaceId,
}: {
@@ -482,8 +482,8 @@ export class WorkflowVersionStepOperationsWorkspaceService {
workspaceId: string;
}): Promise<WorkflowAction> {
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,
@@ -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 ?? [],
@@ -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<WorkflowVersionWorkspaceEntity>(
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<string, string>();
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,