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:
@@ -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;
|
||||
};
|
||||
|
||||
+21
@@ -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`,
|
||||
|
||||
+47
@@ -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;
|
||||
};
|
||||
+1
@@ -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,
|
||||
};
|
||||
};
|
||||
+18
@@ -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
@@ -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,
|
||||
|
||||
+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