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
@@ -1180,6 +1180,13 @@ export type DomainValidRecords = {
records: Array<DomainRecord>;
};
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<typeof useDeleteWorkflowVersionStepMutation>;
export type DeleteWorkflowVersionStepMutationResult = Apollo.MutationResult<DeleteWorkflowVersionStepMutation>;
export type DeleteWorkflowVersionStepMutationOptions = Apollo.BaseMutationOptions<DeleteWorkflowVersionStepMutation, DeleteWorkflowVersionStepMutationVariables>;
export const DuplicateWorkflowDocument = gql`
mutation DuplicateWorkflow($input: DuplicateWorkflowInput!) {
duplicateWorkflow(input: $input) {
id
name
status
trigger
steps
createdAt
updatedAt
workflowId
}
}
`;
export type DuplicateWorkflowMutationFn = Apollo.MutationFunction<DuplicateWorkflowMutation, DuplicateWorkflowMutationVariables>;
/**
* __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<DuplicateWorkflowMutation, DuplicateWorkflowMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<DuplicateWorkflowMutation, DuplicateWorkflowMutationVariables>(DuplicateWorkflowDocument, options);
}
export type DuplicateWorkflowMutationHookResult = ReturnType<typeof useDuplicateWorkflowMutation>;
export type DuplicateWorkflowMutationResult = Apollo.MutationResult<DuplicateWorkflowMutation>;
export type DuplicateWorkflowMutationOptions = Apollo.BaseMutationOptions<DuplicateWorkflowMutation, DuplicateWorkflowMutationVariables>;
export const DuplicateWorkflowVersionStepDocument = gql`
mutation DuplicateWorkflowVersionStep($input: DuplicateWorkflowVersionStepInput!) {
duplicateWorkflowVersionStep(input: $input) {
@@ -1121,6 +1121,13 @@ export type DomainValidRecords = {
records: Array<DomainRecord>;
};
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;
};
@@ -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: <DiscardDraftWorkflowSingleRecordAction />,
},
[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: <DuplicateWorkflowSingleRecordAction />,
},
[WorkflowSingleRecordActionKeys.SEE_ACTIVE_VERSION]: {
key: WorkflowSingleRecordActionKeys.SEE_ACTIVE_VERSION,
label: msg`See active version`,
@@ -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) ? <Action onClick={handleClick} /> : null;
};
@@ -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',
@@ -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
}
}
`;
@@ -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,
};
};
@@ -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;
}
@@ -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<WorkflowVersionDTO> {
return this.workflowVersionWorkspaceService.duplicateWorkflow({
workspaceId,
workflowIdToDuplicate,
workflowVersionIdToCopy,
});
}
@Mutation(() => Boolean)
async updateWorkflowVersionPositions(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@@ -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,