Skip to content

Commit d6c2835

Browse files
harshit078Devessier
authored andcommitted
feat: Add Workflow duplicate step (twentyhq#15622)
## Description - This PR address issue twentyhq/core-team-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 <[email protected]>
1 parent b9fc2a4 commit d6c2835

File tree

13 files changed

+515
-22
lines changed

13 files changed

+515
-22
lines changed

packages/twenty-front/src/generated-metadata/graphql.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1180,6 +1180,13 @@ export type DomainValidRecords = {
11801180
records: Array<DomainRecord>;
11811181
};
11821182

1183+
export type DuplicateWorkflowInput = {
1184+
/** Workflow ID to duplicate */
1185+
workflowIdToDuplicate: Scalars['UUID'];
1186+
/** Workflow version ID to copy */
1187+
workflowVersionIdToCopy: Scalars['UUID'];
1188+
};
1189+
11831190
export type DuplicateWorkflowVersionStepInput = {
11841191
stepId: Scalars['String'];
11851192
workflowVersionId: Scalars['String'];
@@ -1833,6 +1840,7 @@ export type Mutation = {
18331840
destroyPageLayoutTab: Scalars['Boolean'];
18341841
destroyPageLayoutWidget: Scalars['Boolean'];
18351842
disablePostgresProxy: PostgresCredentials;
1843+
duplicateWorkflow: WorkflowVersionDto;
18361844
duplicateWorkflowVersionStep: WorkflowVersionStepChanges;
18371845
editSSOIdentityProvider: EditSsoOutput;
18381846
emailPasswordResetLink: EmailPasswordResetLinkOutput;
@@ -2371,6 +2379,11 @@ export type MutationDestroyPageLayoutWidgetArgs = {
23712379
};
23722380

23732381

2382+
export type MutationDuplicateWorkflowArgs = {
2383+
input: DuplicateWorkflowInput;
2384+
};
2385+
2386+
23742387
export type MutationDuplicateWorkflowVersionStepArgs = {
23752388
input: DuplicateWorkflowVersionStepInput;
23762389
};
@@ -6306,6 +6319,13 @@ export type DeleteWorkflowVersionStepMutationVariables = Exact<{
63066319

63076320
export type DeleteWorkflowVersionStepMutation = { __typename?: 'Mutation', deleteWorkflowVersionStep: { __typename?: 'WorkflowVersionStepChanges', triggerDiff?: any | null, stepsDiff?: any | null } };
63086321

6322+
export type DuplicateWorkflowMutationVariables = Exact<{
6323+
input: DuplicateWorkflowInput;
6324+
}>;
6325+
6326+
6327+
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 } };
6328+
63096329
export type DuplicateWorkflowVersionStepMutationVariables = Exact<{
63106330
input: DuplicateWorkflowVersionStepInput;
63116331
}>;
@@ -14093,6 +14113,46 @@ export function useDeleteWorkflowVersionStepMutation(baseOptions?: Apollo.Mutati
1409314113
export type DeleteWorkflowVersionStepMutationHookResult = ReturnType<typeof useDeleteWorkflowVersionStepMutation>;
1409414114
export type DeleteWorkflowVersionStepMutationResult = Apollo.MutationResult<DeleteWorkflowVersionStepMutation>;
1409514115
export type DeleteWorkflowVersionStepMutationOptions = Apollo.BaseMutationOptions<DeleteWorkflowVersionStepMutation, DeleteWorkflowVersionStepMutationVariables>;
14116+
export const DuplicateWorkflowDocument = gql`
14117+
mutation DuplicateWorkflow($input: DuplicateWorkflowInput!) {
14118+
duplicateWorkflow(input: $input) {
14119+
id
14120+
name
14121+
status
14122+
trigger
14123+
steps
14124+
createdAt
14125+
updatedAt
14126+
workflowId
14127+
}
14128+
}
14129+
`;
14130+
export type DuplicateWorkflowMutationFn = Apollo.MutationFunction<DuplicateWorkflowMutation, DuplicateWorkflowMutationVariables>;
14131+
14132+
/**
14133+
* __useDuplicateWorkflowMutation__
14134+
*
14135+
* To run a mutation, you first call `useDuplicateWorkflowMutation` within a React component and pass it any options that fit your needs.
14136+
* When your component renders, `useDuplicateWorkflowMutation` returns a tuple that includes:
14137+
* - A mutate function that you can call at any time to execute the mutation
14138+
* - An object with fields that represent the current status of the mutation's execution
14139+
*
14140+
* @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;
14141+
*
14142+
* @example
14143+
* const [duplicateWorkflowMutation, { data, loading, error }] = useDuplicateWorkflowMutation({
14144+
* variables: {
14145+
* input: // value for 'input'
14146+
* },
14147+
* });
14148+
*/
14149+
export function useDuplicateWorkflowMutation(baseOptions?: Apollo.MutationHookOptions<DuplicateWorkflowMutation, DuplicateWorkflowMutationVariables>) {
14150+
const options = {...defaultOptions, ...baseOptions}
14151+
return Apollo.useMutation<DuplicateWorkflowMutation, DuplicateWorkflowMutationVariables>(DuplicateWorkflowDocument, options);
14152+
}
14153+
export type DuplicateWorkflowMutationHookResult = ReturnType<typeof useDuplicateWorkflowMutation>;
14154+
export type DuplicateWorkflowMutationResult = Apollo.MutationResult<DuplicateWorkflowMutation>;
14155+
export type DuplicateWorkflowMutationOptions = Apollo.BaseMutationOptions<DuplicateWorkflowMutation, DuplicateWorkflowMutationVariables>;
1409614156
export const DuplicateWorkflowVersionStepDocument = gql`
1409714157
mutation DuplicateWorkflowVersionStep($input: DuplicateWorkflowVersionStepInput!) {
1409814158
duplicateWorkflowVersionStep(input: $input) {

packages/twenty-front/src/generated/graphql.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1121,6 +1121,13 @@ export type DomainValidRecords = {
11211121
records: Array<DomainRecord>;
11221122
};
11231123

1124+
export type DuplicateWorkflowInput = {
1125+
/** Workflow ID to duplicate */
1126+
workflowIdToDuplicate: Scalars['UUID'];
1127+
/** Workflow version ID to copy */
1128+
workflowVersionIdToCopy: Scalars['UUID'];
1129+
};
1130+
11241131
export type DuplicateWorkflowVersionStepInput = {
11251132
stepId: Scalars['String'];
11261133
workflowVersionId: Scalars['String'];
@@ -1761,6 +1768,7 @@ export type Mutation = {
17611768
destroyPageLayoutTab: Scalars['Boolean'];
17621769
destroyPageLayoutWidget: Scalars['Boolean'];
17631770
disablePostgresProxy: PostgresCredentials;
1771+
duplicateWorkflow: WorkflowVersionDto;
17641772
duplicateWorkflowVersionStep: WorkflowVersionStepChanges;
17651773
editSSOIdentityProvider: EditSsoOutput;
17661774
emailPasswordResetLink: EmailPasswordResetLinkOutput;
@@ -2258,6 +2266,11 @@ export type MutationDestroyPageLayoutWidgetArgs = {
22582266
};
22592267

22602268

2269+
export type MutationDuplicateWorkflowArgs = {
2270+
input: DuplicateWorkflowInput;
2271+
};
2272+
2273+
22612274
export type MutationDuplicateWorkflowVersionStepArgs = {
22622275
input: DuplicateWorkflowVersionStepInput;
22632276
};

packages/twenty-front/src/modules/action-menu/actions/record-actions/constants/WorkflowActionsConfig.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ActivateWorkflowSingleRecordAction } from '@/action-menu/actions/record
77
import { AddNodeWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/components/AddNodeWorkflowSingleRecordAction';
88
import { DeactivateWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/components/DeactivateWorkflowSingleRecordAction';
99
import { DiscardDraftWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/components/DiscardDraftWorkflowSingleRecordAction';
10+
import { DuplicateWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/components/DuplicateWorkflowSingleRecordAction';
1011
import { SeeActiveVersionWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/components/SeeActiveVersionWorkflowSingleRecordAction';
1112
import { SeeRunsWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/components/SeeRunsWorkflowSingleRecordAction';
1213
import { SeeVersionsWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/components/SeeVersionsWorkflowSingleRecordAction';
@@ -35,6 +36,7 @@ import {
3536
IconPower,
3637
IconReorder,
3738
IconVersions,
39+
IconCopy,
3840
} from 'twenty-ui/display';
3941

4042
const areWorkflowTriggerAndStepsDefined = (
@@ -115,6 +117,25 @@ export const WORKFLOW_ACTIONS_CONFIG = inheritActionsFromDefaultConfig({
115117
],
116118
component: <DiscardDraftWorkflowSingleRecordAction />,
117119
},
120+
[WorkflowSingleRecordActionKeys.DUPLICATE_WORKFLOW]: {
121+
key: WorkflowSingleRecordActionKeys.DUPLICATE_WORKFLOW,
122+
label: msg`Duplicate Workflow`,
123+
shortLabel: msg`Duplicate`,
124+
isPinned: false,
125+
position: 10,
126+
Icon: IconCopy,
127+
type: ActionType.Standard,
128+
scope: ActionScope.RecordSelection,
129+
shouldBeRegistered: ({ selectedRecord, workflowWithCurrentVersion }) =>
130+
isDefined(workflowWithCurrentVersion) &&
131+
isDefined(workflowWithCurrentVersion.currentVersion) &&
132+
!isDefined(selectedRecord?.deletedAt),
133+
availableOn: [
134+
ActionViewType.SHOW_PAGE,
135+
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
136+
],
137+
component: <DuplicateWorkflowSingleRecordAction />,
138+
},
118139
[WorkflowSingleRecordActionKeys.SEE_ACTIVE_VERSION]: {
119140
key: WorkflowSingleRecordActionKeys.SEE_ACTIVE_VERSION,
120141
label: msg`See active version`,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { Action } from '@/action-menu/actions/components/Action';
2+
import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow';
3+
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
4+
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
5+
import { useDuplicateWorkflow } from '@/workflow/hooks/useDuplicateWorkflow';
6+
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
7+
import { useLingui } from '@lingui/react/macro';
8+
import { isNonEmptyString } from '@sniptt/guards';
9+
import { AppPath } from 'twenty-shared/types';
10+
import { isDefined } from 'twenty-shared/utils';
11+
import { useNavigateApp } from '~/hooks/useNavigateApp';
12+
13+
export const DuplicateWorkflowSingleRecordAction = () => {
14+
const recordId = useSelectedRecordIdOrThrow();
15+
const workflow = useWorkflowWithCurrentVersion(recordId);
16+
const { duplicateWorkflow } = useDuplicateWorkflow();
17+
const navigate = useNavigateApp();
18+
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
19+
const { t } = useLingui();
20+
21+
const handleClick = async () => {
22+
if (!isDefined(workflow) || !isDefined(workflow.currentVersion)) {
23+
return;
24+
}
25+
26+
const result = await duplicateWorkflow({
27+
workflowIdToDuplicate: workflow.id,
28+
workflowVersionIdToCopy: workflow.currentVersion.id,
29+
});
30+
31+
if (isDefined(result) && isNonEmptyString(result.workflowId)) {
32+
enqueueSuccessSnackBar({
33+
message: t`Workflow duplicated successfully`,
34+
});
35+
navigate(AppPath.RecordShowPage, {
36+
objectNameSingular: CoreObjectNameSingular.Workflow,
37+
objectRecordId: result.workflowId,
38+
});
39+
} else {
40+
enqueueErrorSnackBar({
41+
message: t`Failed to duplicate workflow`,
42+
});
43+
}
44+
};
45+
46+
return isDefined(workflow) ? <Action onClick={handleClick} /> : null;
47+
};

packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/types/WorkflowSingleRecordActionsKeys.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export enum WorkflowSingleRecordActionKeys {
22
ACTIVATE = 'activate-workflow-single-record',
33
DEACTIVATE = 'deactivate-workflow-single-record',
44
DISCARD_DRAFT = 'discard-draft-workflow-single-record',
5+
DUPLICATE_WORKFLOW = 'duplicate-workflow-single-record',
56
SEE_ACTIVE_VERSION = 'see-active-version-workflow-single-record',
67
SEE_RUNS = 'see-runs-workflow-single-record',
78
SEE_VERSIONS = 'see-versions-workflow-single-record',
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { gql } from '@apollo/client';
2+
3+
export const DUPLICATE_WORKFLOW = gql`
4+
mutation DuplicateWorkflow($input: DuplicateWorkflowInput!) {
5+
duplicateWorkflow(input: $input) {
6+
id
7+
name
8+
status
9+
trigger
10+
steps
11+
createdAt
12+
updatedAt
13+
workflowId
14+
}
15+
}
16+
`;
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
2+
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
3+
import { useFindManyRecordsQuery } from '@/object-record/hooks/useFindManyRecordsQuery';
4+
import { useMutation } from '@apollo/client';
5+
import {
6+
type DuplicateWorkflowInput,
7+
type WorkflowVersionDto,
8+
} from '~/generated/graphql';
9+
import { DUPLICATE_WORKFLOW } from '@/workflow/graphql/mutations/duplicateWorkflow';
10+
11+
export const useDuplicateWorkflow = () => {
12+
const apolloCoreClient = useApolloCoreClient();
13+
const [mutate] = useMutation<
14+
{ duplicateWorkflow: WorkflowVersionDto },
15+
{ input: DuplicateWorkflowInput }
16+
>(DUPLICATE_WORKFLOW, {
17+
client: apolloCoreClient,
18+
});
19+
20+
const { findManyRecordsQuery: findManyWorkflowsQuery } =
21+
useFindManyRecordsQuery({
22+
objectNameSingular: CoreObjectNameSingular.Workflow,
23+
recordGqlFields: {
24+
id: true,
25+
name: true,
26+
statuses: true,
27+
lastPublishedVersionId: true,
28+
versions: true,
29+
},
30+
});
31+
32+
const duplicateWorkflow = async (input: DuplicateWorkflowInput) => {
33+
const result = await mutate({
34+
variables: { input },
35+
awaitRefetchQueries: true,
36+
refetchQueries: [
37+
{
38+
query: findManyWorkflowsQuery,
39+
variables: {},
40+
},
41+
],
42+
});
43+
44+
return result?.data?.duplicateWorkflow;
45+
};
46+
47+
return {
48+
duplicateWorkflow,
49+
};
50+
};
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Field, InputType } from '@nestjs/graphql';
2+
3+
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
4+
5+
@InputType()
6+
export class DuplicateWorkflowInput {
7+
@Field(() => UUIDScalarType, {
8+
description: 'Workflow ID to duplicate',
9+
nullable: false,
10+
})
11+
workflowIdToDuplicate: string;
12+
13+
@Field(() => UUIDScalarType, {
14+
description: 'Workflow version ID to copy',
15+
nullable: false,
16+
})
17+
workflowVersionIdToCopy: string;
18+
}

packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version.resolver.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
1414
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
1515
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
1616
import { WorkflowVersionWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service';
17+
import { DuplicateWorkflowInput } from 'src/engine/core-modules/workflow/dtos/duplicate-workflow-input.dto';
1718

1819
@Resolver()
1920
@UsePipes(ResolverValidationPipe)
@@ -47,6 +48,19 @@ export class WorkflowVersionResolver {
4748
});
4849
}
4950

51+
@Mutation(() => WorkflowVersionDTO)
52+
async duplicateWorkflow(
53+
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
54+
@Args('input')
55+
{ workflowIdToDuplicate, workflowVersionIdToCopy }: DuplicateWorkflowInput,
56+
): Promise<WorkflowVersionDTO> {
57+
return this.workflowVersionWorkspaceService.duplicateWorkflow({
58+
workspaceId,
59+
workflowIdToDuplicate,
60+
workflowVersionIdToCopy,
61+
});
62+
}
63+
5064
@Mutation(() => Boolean)
5165
async updateWorkflowVersionPositions(
5266
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,

0 commit comments

Comments
 (0)