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:
+65
-10
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+29
-5
@@ -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,
|
||||
|
||||
+8
-6
@@ -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 ?? [],
|
||||
|
||||
+173
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user