From 3bfb5bbd863634a597bd44e09ad9ef1468b78f17 Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Wed, 10 Sep 2025 17:25:40 +0200 Subject: [PATCH] Add duplicate step action (#14392) https://github.com/user-attachments/assets/ac89440c-6f84-4d2a-b88a-da7ddfb181eb --- .../src/generated-metadata/graphql.ts | 64 +++++++ .../twenty-front/src/generated/graphql.ts | 11 ++ .../CommandMenuWorkflowSelectAction.tsx | 9 +- ...CommandMenuWorkflowSelectActionContent.tsx | 15 +- .../mutations/duplicateWorkflowVersionStep.ts | 24 +++ .../WorkflowDiagramDefaultEdgeEditable.tsx | 10 +- .../WorkflowDiagramStepNodeEditable.tsx | 1 - .../components/WorkflowActionFooter.tsx | 71 ++++++++ .../hooks/__tests__/useCreateStep.test.tsx | 24 +-- .../hooks/__tests__/useDeleteStep.test.ts | 11 +- .../hooks/__tests__/useDuplicateStep.test.tsx | 70 ++++++++ .../workflow-steps/hooks/useCreateStep.ts | 17 +- .../workflow-steps/hooks/useDeleteStep.ts | 2 +- .../hooks/useDeleteWorkflowVersionStep.ts | 0 .../workflow-steps/hooks/useDuplicateStep.ts | 57 ++++++ .../hooks/useDuplicateWorkflowVersionStep.ts | 55 ++++++ .../components/WorkflowEditActionAiAgent.tsx | 2 + .../WorkflowEditActionServerlessFunction.tsx | 25 +-- .../WorkflowEditActionCreateRecord.tsx | 2 + .../WorkflowEditActionDeleteRecord.tsx | 2 + .../WorkflowEditActionSendEmail.tsx | 2 + .../WorkflowEditActionUpdateRecord.tsx | 2 + .../WorkflowEditActionFindRecords.tsx | 2 + .../WorkflowEditActionFormBuilder.tsx | 2 + .../WorkflowEditActionFormBuilder.stories.tsx | 4 + .../WorkflowEditActionHttpRequest.tsx | 25 +-- .../WorkflowEditActionHttpRequest.stories.tsx | 2 + ...plicate-workflow-version-step-input.dto.ts | 10 ++ .../workflow-version-step.resolver.ts | 16 ++ .../serverless-function.exception.ts | 1 + .../serverless-function.service.ts | 53 +++++- ...ion-graphql-api-exception-handler.utils.ts | 1 + ...workflow-version-step.workspace-service.ts | 166 +++++++++++++++--- .../workflow-version.workspace-service.ts | 2 +- 34 files changed, 642 insertions(+), 118 deletions(-) create mode 100644 packages/twenty-front/src/modules/workflow/graphql/mutations/duplicateWorkflowVersionStep.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowActionFooter.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useDuplicateStep.test.tsx rename packages/twenty-front/src/modules/workflow/{ => workflow-steps}/hooks/useDeleteWorkflowVersionStep.ts (100%) create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDuplicateStep.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDuplicateWorkflowVersionStep.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workflow/dtos/duplicate-workflow-version-step-input.dto.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 411fcce25d..ec491543ba 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -961,6 +961,11 @@ export enum DistantTableUpdate { TABLE_DELETED = 'TABLE_DELETED' } +export type DuplicateWorkflowVersionStepInput = { + stepId: Scalars['String']; + workflowVersionId: Scalars['String']; +}; + export type EditSsoInput = { id: Scalars['UUID']; status: SsoIdentityProviderStatus; @@ -1474,6 +1479,7 @@ export type Mutation = { destroyPageLayoutTab: Scalars['Boolean']; destroyPageLayoutWidget: Scalars['Boolean']; disablePostgresProxy: PostgresCredentials; + duplicateWorkflowVersionStep: WorkflowVersionStepChanges; editSSOIdentityProvider: EditSsoOutput; emailPasswordResetLink: EmailPasswordResetLink; enablePostgresProxy: PostgresCredentials; @@ -1918,6 +1924,11 @@ export type MutationDestroyPageLayoutWidgetArgs = { }; +export type MutationDuplicateWorkflowVersionStepArgs = { + input: DuplicateWorkflowVersionStepInput; +}; + + export type MutationEditSsoIdentityProviderArgs = { input: EditSsoInput; }; @@ -5289,6 +5300,13 @@ export type DeleteWorkflowVersionStepMutationVariables = Exact<{ export type DeleteWorkflowVersionStepMutation = { __typename?: 'Mutation', deleteWorkflowVersionStep: { __typename?: 'WorkflowVersionStepChanges', triggerNextStepIds?: Array | null, stepsNextStepIds?: any | null, deletedStepIds?: Array | null } }; +export type DuplicateWorkflowVersionStepMutationVariables = Exact<{ + input: DuplicateWorkflowVersionStepInput; +}>; + + +export type DuplicateWorkflowVersionStepMutation = { __typename?: 'Mutation', duplicateWorkflowVersionStep: { __typename?: 'WorkflowVersionStepChanges', triggerNextStepIds?: Array | null, stepsNextStepIds?: any | null, createdStep?: { __typename?: 'WorkflowAction', id: string, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array | null, position?: { __typename?: 'WorkflowStepPosition', x: number, y: number } | null } | null } }; + export type RunWorkflowVersionMutationVariables = Exact<{ input: RunWorkflowVersionInput; }>; @@ -12063,6 +12081,52 @@ export function useDeleteWorkflowVersionStepMutation(baseOptions?: Apollo.Mutati export type DeleteWorkflowVersionStepMutationHookResult = ReturnType; export type DeleteWorkflowVersionStepMutationResult = Apollo.MutationResult; export type DeleteWorkflowVersionStepMutationOptions = Apollo.BaseMutationOptions; +export const DuplicateWorkflowVersionStepDocument = gql` + mutation DuplicateWorkflowVersionStep($input: DuplicateWorkflowVersionStepInput!) { + duplicateWorkflowVersionStep(input: $input) { + triggerNextStepIds + stepsNextStepIds + createdStep { + id + name + type + settings + valid + nextStepIds + position { + x + y + } + } + } +} + `; +export type DuplicateWorkflowVersionStepMutationFn = Apollo.MutationFunction; + +/** + * __useDuplicateWorkflowVersionStepMutation__ + * + * To run a mutation, you first call `useDuplicateWorkflowVersionStepMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDuplicateWorkflowVersionStepMutation` 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 [duplicateWorkflowVersionStepMutation, { data, loading, error }] = useDuplicateWorkflowVersionStepMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useDuplicateWorkflowVersionStepMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(DuplicateWorkflowVersionStepDocument, options); + } +export type DuplicateWorkflowVersionStepMutationHookResult = ReturnType; +export type DuplicateWorkflowVersionStepMutationResult = Apollo.MutationResult; +export type DuplicateWorkflowVersionStepMutationOptions = Apollo.BaseMutationOptions; export const RunWorkflowVersionDocument = gql` mutation RunWorkflowVersion($input: RunWorkflowVersionInput!) { runWorkflowVersion(input: $input) { diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 11eaa4f554..5c82d3c754 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -925,6 +925,11 @@ export enum DistantTableUpdate { TABLE_DELETED = 'TABLE_DELETED' } +export type DuplicateWorkflowVersionStepInput = { + stepId: Scalars['String']; + workflowVersionId: Scalars['String']; +}; + export type EditSsoInput = { id: Scalars['UUID']; status: SsoIdentityProviderStatus; @@ -1429,6 +1434,7 @@ export type Mutation = { destroyPageLayoutTab: Scalars['Boolean']; destroyPageLayoutWidget: Scalars['Boolean']; disablePostgresProxy: PostgresCredentials; + duplicateWorkflowVersionStep: WorkflowVersionStepChanges; editSSOIdentityProvider: EditSsoOutput; emailPasswordResetLink: EmailPasswordResetLink; enablePostgresProxy: PostgresCredentials; @@ -1849,6 +1855,11 @@ export type MutationDestroyPageLayoutWidgetArgs = { }; +export type MutationDuplicateWorkflowVersionStepArgs = { + input: DuplicateWorkflowVersionStepInput; +}; + + export type MutationEditSsoIdentityProviderArgs = { input: EditSsoInput; }; diff --git a/packages/twenty-front/src/modules/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectAction.tsx b/packages/twenty-front/src/modules/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectAction.tsx index 1b9613a3bc..0b7bd7fa29 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectAction.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectAction.tsx @@ -1,17 +1,10 @@ import { CommandMenuWorkflowSelectActionContent } from '@/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectActionContent'; import { useCommandMenuWorkflowIdOrThrow } from '@/command-menu/pages/workflow/hooks/useCommandMenuWorkflowIdOrThrow'; -import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; import { getWorkflowVisualizerComponentInstanceId } from '@/workflow/utils/getWorkflowVisualizerComponentInstanceId'; import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext'; -import { isDefined } from 'twenty-shared/utils'; export const CommandMenuWorkflowSelectAction = () => { const workflowId = useCommandMenuWorkflowIdOrThrow(); - const workflow = useWorkflowWithCurrentVersion(workflowId); - - if (!isDefined(workflow)) { - return null; - } return ( { }), }} > - + ); }; diff --git a/packages/twenty-front/src/modules/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectActionContent.tsx b/packages/twenty-front/src/modules/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectActionContent.tsx index 711b456266..39dd631cb8 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectActionContent.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectActionContent.tsx @@ -4,10 +4,7 @@ import { commandMenuNavigationStackState } from '@/command-menu/states/commandMe import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState'; -import { - type WorkflowActionType, - type WorkflowWithCurrentVersion, -} from '@/workflow/types/Workflow'; +import { type WorkflowActionType } from '@/workflow/types/Workflow'; import { useCloseRightClickMenu } from '@/workflow/workflow-diagram/hooks/useCloseRightClickMenu'; import { RightDrawerStepListContainer } from '@/workflow/workflow-steps/components/RightDrawerWorkflowSelectStepContainer'; import { RightDrawerWorkflowSelectStepTitle } from '@/workflow/workflow-steps/components/RightDrawerWorkflowSelectStepTitle'; @@ -26,16 +23,10 @@ import { isDefined } from 'twenty-shared/utils'; import { useIcons } from 'twenty-ui/display'; import { FeatureFlagKey } from '~/generated/graphql'; -export const CommandMenuWorkflowSelectActionContent = ({ - workflow, -}: { - workflow: WorkflowWithCurrentVersion; -}) => { +export const CommandMenuWorkflowSelectActionContent = () => { const { getIcon } = useIcons(); - const { createStep } = useCreateStep({ - workflow, - }); + const { createStep } = useCreateStep(); const { closeRightClickMenu } = useCloseRightClickMenu(); diff --git a/packages/twenty-front/src/modules/workflow/graphql/mutations/duplicateWorkflowVersionStep.ts b/packages/twenty-front/src/modules/workflow/graphql/mutations/duplicateWorkflowVersionStep.ts new file mode 100644 index 0000000000..e400f945ee --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/graphql/mutations/duplicateWorkflowVersionStep.ts @@ -0,0 +1,24 @@ +import { gql } from '@apollo/client'; + +export const DUPLICATE_WORKFLOW_VERSION_STEP = gql` + mutation DuplicateWorkflowVersionStep( + $input: DuplicateWorkflowVersionStepInput! + ) { + duplicateWorkflowVersionStep(input: $input) { + triggerNextStepIds + stepsNextStepIds + createdStep { + id + name + type + settings + valid + nextStepIds + position { + x + y + } + } + } + } +`; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramDefaultEdgeEditable.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramDefaultEdgeEditable.tsx index b2d7bfa2af..6d0dbc39a6 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramDefaultEdgeEditable.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramDefaultEdgeEditable.tsx @@ -1,8 +1,5 @@ import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext'; import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState'; -import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; -import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; -import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState'; import { useOpenWorkflowEditFilterInCommandMenu } from '@/workflow/workflow-diagram/hooks/useOpenWorkflowEditFilterInCommandMenu'; import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation'; import { type WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; @@ -53,12 +50,7 @@ export const WorkflowDiagramDefaultEdgeEditable = ({ targetY, }); - const workflowVisualizerWorkflowId = useRecoilComponentValue( - workflowVisualizerWorkflowIdComponentState, - ); - const workflow = useWorkflowWithCurrentVersion(workflowVisualizerWorkflowId); - - const { createStep } = useCreateStep({ workflow }); + const { createStep } = useCreateStep(); const { deleteEdge } = useDeleteEdge(); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramStepNodeEditable.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramStepNodeEditable.tsx index 27110b1d69..b68a1df832 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramStepNodeEditable.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramStepNodeEditable.tsx @@ -30,7 +30,6 @@ export const WorkflowDiagramStepNodeEditable = ({ ); const { deleteStep } = useDeleteStep(); - const setWorkflowSelectedNode = useSetRecoilComponentState( workflowSelectedNodeComponentState, ); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowActionFooter.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowActionFooter.tsx new file mode 100644 index 0000000000..2808f68cf2 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowActionFooter.tsx @@ -0,0 +1,71 @@ +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; +import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter'; +import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; +import { useDuplicateStep } from '@/workflow/workflow-steps/hooks/useDuplicateStep'; +import { useTheme } from '@emotion/react'; +import { useId } from 'react'; +import { Button } from 'twenty-ui/input'; +import { MenuItem } from 'twenty-ui/navigation'; +import { getOsControlSymbol } from 'twenty-ui/utilities'; + +export const WorkflowActionFooter = ({ + stepId, + additionalActions, +}: { + stepId: string; + additionalActions?: React.ReactNode[]; +}) => { + const dropdownId = useId(); + const theme = useTheme(); + const { duplicateStep } = useDuplicateStep(); + const { closeDropdown } = useCloseDropdown(); + + const OptionsDropdown = () => { + return ( + + } + dropdownPlacement="top-end" + dropdownOffset={{ y: parseInt(theme.spacing(2), 10) }} + globalHotkeysConfig={{ + enableGlobalHotkeysWithModifiers: true, + enableGlobalHotkeysConflictingWithKeyboard: false, + }} + dropdownComponents={ + + + + { + closeDropdown(dropdownId); + duplicateStep({ stepId }); + }} + text="Duplicate" + /> + + + + } + /> + ); + }; + + return ( + , + ...(additionalActions ?? []), + ]} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useCreateStep.test.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useCreateStep.test.tsx index 234064845e..21c8533bbd 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useCreateStep.test.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useCreateStep.test.tsx @@ -1,4 +1,3 @@ -import { type WorkflowWithCurrentVersion } from '@/workflow/types/Workflow'; import { act, renderHook } from '@testing-library/react'; import { RecoilRoot } from 'recoil'; import { WorkflowVisualizerComponentInstanceContext } from '../../../workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext'; @@ -44,17 +43,6 @@ const wrapper = ({ children }: { children: React.ReactNode }) => { }; describe('useCreateStep', () => { - const mockWorkflow = { - id: '123', - currentVersion: { - id: '456', - status: 'DRAFT', - steps: [], - trigger: { type: 'manual' }, - }, - versions: [], - }; - beforeEach(() => { jest.clearAllMocks(); }); @@ -63,15 +51,9 @@ describe('useCreateStep', () => { const mockWorkflowVersionId = 'version-123'; mockGetUpdatableWorkflowVersion.mockResolvedValue(mockWorkflowVersionId); - const { result } = renderHook( - () => - useCreateStep({ - workflow: mockWorkflow as unknown as WorkflowWithCurrentVersion, - }), - { - wrapper, - }, - ); + const { result } = renderHook(() => useCreateStep(), { + wrapper, + }); await act(async () => { await result.current.createStep({ diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useDeleteStep.test.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useDeleteStep.test.ts index 3fb8f43f1f..2d55ea312b 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useDeleteStep.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useDeleteStep.test.ts @@ -6,11 +6,14 @@ const mockGetUpdatableWorkflowVersion = jest.fn(); const mockDeleteStepsOutputSchema = jest.fn(); const mockCloseCommandMenu = jest.fn(); -jest.mock('@/workflow/hooks/useDeleteWorkflowVersionStep', () => ({ - useDeleteWorkflowVersionStep: () => ({ - deleteWorkflowVersionStep: mockDeleteWorkflowVersionStep, +jest.mock( + '@/workflow/workflow-steps/hooks/useDeleteWorkflowVersionStep', + () => ({ + useDeleteWorkflowVersionStep: () => ({ + deleteWorkflowVersionStep: mockDeleteWorkflowVersionStep, + }), }), -})); +); jest.mock('@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow', () => ({ useGetUpdatableWorkflowVersionOrThrow: () => ({ diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useDuplicateStep.test.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useDuplicateStep.test.tsx new file mode 100644 index 0000000000..09525cba6d --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useDuplicateStep.test.tsx @@ -0,0 +1,70 @@ +import { useDuplicateStep } from '@/workflow/workflow-steps/hooks/useDuplicateStep'; +import { act, renderHook } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { WorkflowVisualizerComponentInstanceContext } from '../../../workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext'; + +const mockGetUpdatableWorkflowVersion = jest.fn(); +const mockDuplicateWorkflowVersionStep = jest.fn().mockResolvedValue({ + data: { + duplicateWorkflowVersionStep: { createdStep: { id: '2', type: 'CODE' } }, + }, +}); + +jest.mock( + '@/workflow/workflow-steps/hooks/useDuplicateWorkflowVersionStep', + () => ({ + useDuplicateWorkflowVersionStep: () => ({ + duplicateWorkflowVersionStep: mockDuplicateWorkflowVersionStep, + }), + }), +); + +jest.mock('@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow', () => ({ + useGetUpdatableWorkflowVersionOrThrow: () => ({ + getUpdatableWorkflowVersion: mockGetUpdatableWorkflowVersion, + }), +})); + +const wrapper = ({ children }: { children: React.ReactNode }) => { + const workflowVisualizerComponentInstanceId = + 'workflow-visualizer-instance-id'; + + return ( + + + {children} + + + ); +}; + +describe('useDuplicateStep', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should create step in workflow version', async () => { + const mockWorkflowVersionId = 'version-123'; + mockGetUpdatableWorkflowVersion.mockResolvedValue(mockWorkflowVersionId); + + const { result } = renderHook(() => useDuplicateStep(), { + wrapper, + }); + + await act(async () => { + await result.current.duplicateStep({ + stepId: 'step-1', + }); + }); + + expect(mockGetUpdatableWorkflowVersion).toHaveBeenCalled(); + expect(mockDuplicateWorkflowVersionStep).toHaveBeenCalledWith({ + stepId: 'step-1', + workflowVersionId: mockWorkflowVersionId, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateStep.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateStep.ts index 0cccd36021..37f58ba811 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateStep.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateStep.ts @@ -1,20 +1,13 @@ import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow'; import { workflowLastCreatedStepIdComponentState } from '@/workflow/states/workflowLastCreatedStepIdComponentState'; -import { - type WorkflowStepType, - type WorkflowWithCurrentVersion, -} from '@/workflow/types/Workflow'; +import { type WorkflowStepType } from '@/workflow/types/Workflow'; import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState'; import { useCreateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useCreateWorkflowVersionStep'; import { useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; -export const useCreateStep = ({ - workflow, -}: { - workflow: WorkflowWithCurrentVersion | undefined; -}) => { +export const useCreateStep = () => { const [isLoading, setIsLoading] = useState(false); const { createWorkflowVersionStep } = useCreateWorkflowVersionStep(); const setWorkflowSelectedNode = useSetRecoilComponentState( @@ -27,12 +20,6 @@ export const useCreateStep = ({ const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersionOrThrow(); - if (!isDefined(workflow)) { - return { - createStep: async () => undefined, - }; - } - const createStep = async ({ newStepType, parentStepId, diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteStep.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteStep.ts index 5c9e3d0f61..9489ccd2a1 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteStep.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteStep.ts @@ -1,7 +1,7 @@ import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; -import { useDeleteWorkflowVersionStep } from '@/workflow/hooks/useDeleteWorkflowVersionStep'; import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow'; import { useStepsOutputSchema } from '@/workflow/hooks/useStepsOutputSchema'; +import { useDeleteWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useDeleteWorkflowVersionStep'; export const useDeleteStep = () => { const { deleteWorkflowVersionStep } = useDeleteWorkflowVersionStep(); diff --git a/packages/twenty-front/src/modules/workflow/hooks/useDeleteWorkflowVersionStep.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteWorkflowVersionStep.ts similarity index 100% rename from packages/twenty-front/src/modules/workflow/hooks/useDeleteWorkflowVersionStep.ts rename to packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteWorkflowVersionStep.ts diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDuplicateStep.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDuplicateStep.ts new file mode 100644 index 0000000000..85a156c051 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDuplicateStep.ts @@ -0,0 +1,57 @@ +import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; +import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow'; +import { workflowLastCreatedStepIdComponentState } from '@/workflow/states/workflowLastCreatedStepIdComponentState'; +import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState'; +import { useDuplicateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useDuplicateWorkflowVersionStep'; +import { useState } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +export const useDuplicateStep = () => { + const [isLoading, setIsLoading] = useState(false); + const { duplicateWorkflowVersionStep } = useDuplicateWorkflowVersionStep(); + const setWorkflowSelectedNode = useSetRecoilComponentState( + workflowSelectedNodeComponentState, + ); + const setWorkflowLastCreatedStepId = useSetRecoilComponentState( + workflowLastCreatedStepIdComponentState, + ); + + const { getUpdatableWorkflowVersion } = + useGetUpdatableWorkflowVersionOrThrow(); + + const duplicateStep = async ({ stepId }: { stepId: string }) => { + if (isLoading === true) { + return; + } + + setIsLoading(true); + + try { + const workflowVersionId = await getUpdatableWorkflowVersion(); + + const workflowVersionStepChanges = ( + await duplicateWorkflowVersionStep({ + workflowVersionId, + stepId, + }) + )?.data?.duplicateWorkflowVersionStep; + + const createdStep = workflowVersionStepChanges?.createdStep; + + if (!isDefined(createdStep)) { + throw new Error("Couldn't duplicate step"); + } + + setWorkflowSelectedNode(createdStep.id); + setWorkflowLastCreatedStepId(createdStep.id); + + return createdStep; + } finally { + setIsLoading(false); + } + }; + + return { + duplicateStep, + }; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDuplicateWorkflowVersionStep.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDuplicateWorkflowVersionStep.ts new file mode 100644 index 0000000000..03e4da3022 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDuplicateWorkflowVersionStep.ts @@ -0,0 +1,55 @@ +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; +import { DUPLICATE_WORKFLOW_VERSION_STEP } from '@/workflow/graphql/mutations/duplicateWorkflowVersionStep'; +import { flowComponentState } from '@/workflow/states/flowComponentState'; +import { useUpdateWorkflowVersionCache } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache'; +import { useMutation } from '@apollo/client'; +import { isDefined } from 'twenty-shared/utils'; +import { + type DuplicateWorkflowVersionStepInput, + type DuplicateWorkflowVersionStepMutation, + type DuplicateWorkflowVersionStepMutationVariables, +} from '~/generated-metadata/graphql'; + +export const useDuplicateWorkflowVersionStep = () => { + const apolloCoreClient = useApolloCoreClient(); + + const { updateWorkflowVersionCache } = useUpdateWorkflowVersionCache(); + + const setFlow = useSetRecoilComponentState(flowComponentState); + + const [mutate] = useMutation< + DuplicateWorkflowVersionStepMutation, + DuplicateWorkflowVersionStepMutationVariables + >(DUPLICATE_WORKFLOW_VERSION_STEP, { + client: apolloCoreClient, + }); + + const duplicateWorkflowVersionStep = async ( + input: DuplicateWorkflowVersionStepInput, + ) => { + const result = await mutate({ + variables: { input }, + }); + + const workflowVersionStepChanges = + result?.data?.duplicateWorkflowVersionStep; + + const updatedWorkflowVersion = updateWorkflowVersionCache({ + workflowVersionStepChanges, + workflowVersionId: input.workflowVersionId, + }); + + if (isDefined(updatedWorkflowVersion)) { + setFlow({ + workflowVersionId: updatedWorkflowVersion.id, + trigger: updatedWorkflowVersion.trigger, + steps: updatedWorkflowVersion.steps, + }); + } + + return result; + }; + + return { duplicateWorkflowVersionStep }; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowEditActionAiAgent.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowEditActionAiAgent.tsx index e0c9b8fa36..8f5e5c4005 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowEditActionAiAgent.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowEditActionAiAgent.tsx @@ -3,6 +3,7 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput'; import { Select } from '@/ui/input/components/Select'; import { type WorkflowAiAgentAction } from '@/workflow/types/Workflow'; +import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter'; import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader'; import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader'; @@ -145,6 +146,7 @@ export const WorkflowEditActionAiAgent = ({ readonly={actionOptions.readonly} /> + {!actionOptions.readonly && } ); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionServerlessFunction.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionServerlessFunction.tsx index 2314b14ba7..5b2077ca6b 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionServerlessFunction.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionServerlessFunction.tsx @@ -24,7 +24,6 @@ import { getFunctionOutputSchema } from '@/serverless-functions/utils/getFunctio import { mergeDefaultFunctionInputAndFunctionInput } from '@/serverless-functions/utils/mergeDefaultFunctionInputAndFunctionInput'; import { InputLabel } from '@/ui/input/components/InputLabel'; import { TextArea } from '@/ui/input/components/TextArea'; -import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter'; import { TabList } from '@/ui/layout/tab-list/components/TabList'; import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState'; import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement'; @@ -44,6 +43,7 @@ import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components import styled from '@emotion/styled'; import { useLingui } from '@lingui/react/macro'; +import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter'; import { type Monaco } from '@monaco-editor/react'; import { type editor } from 'monaco-editor'; import { AutoTypings } from 'monaco-editor-auto-typings'; @@ -515,15 +515,20 @@ export const WorkflowEditActionServerlessFunction = ({ )} - {activeTabId === WorkflowServerlessFunctionTabId.TEST && ( - , - ]} + {!actionOptions.readonly && ( + , + ] + : [] + } /> )} {fullScreenOverlay} diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord.tsx index 92db0be137..5a6fcfd44b 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord.tsx @@ -7,6 +7,7 @@ import { Select } from '@/ui/input/components/Select'; import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; import { useViewOrDefaultViewFromPrefetchedViews } from '@/views/hooks/useViewOrDefaultViewFromPrefetchedViews'; import { type WorkflowCreateRecordAction } from '@/workflow/types/Workflow'; +import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter'; import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader'; import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader'; @@ -271,6 +272,7 @@ export const WorkflowEditActionCreateRecord = ({ ); })} + {!actionOptions.readonly && } ); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionDeleteRecord.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionDeleteRecord.tsx index a7904ca8c0..0a68b7fb94 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionDeleteRecord.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionDeleteRecord.tsx @@ -6,6 +6,7 @@ import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/Workflo import { useEffect, useState } from 'react'; import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; +import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter'; import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader'; import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker'; @@ -179,6 +180,7 @@ export const WorkflowEditActionDeleteRecord = ({ /> )} + {!actionOptions.readonly && } ); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx index d03272644a..e30d1bd363 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx @@ -11,6 +11,7 @@ import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/Gene import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState'; import { type WorkflowSendEmailAction } from '@/workflow/types/Workflow'; +import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter'; import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader'; import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader'; @@ -294,6 +295,7 @@ export const WorkflowEditActionSendEmail = ({ multiline /> + {!actionOptions.readonly && } ) ); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord.tsx index 2911197007..9c387aab95 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord.tsx @@ -9,6 +9,7 @@ import { FormSingleRecordPicker } from '@/object-record/record-field/ui/form-typ import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation'; import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; import { WorkflowFieldsMultiSelect } from '@/workflow/components/WorkflowEditUpdateEventFieldsMultiSelect'; +import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter'; import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader'; import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader'; @@ -265,6 +266,7 @@ export const WorkflowEditActionUpdateRecord = ({ ); })} + {!actionOptions.readonly && } ); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords.tsx index bb90b465d1..375db2a8ca 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords.tsx @@ -13,6 +13,7 @@ import { type RecordFilter } from '@/object-record/record-filter/types/RecordFil import { RecordIndexContextProvider } from '@/object-record/record-index/contexts/RecordIndexContext'; import { useRecordIndexFieldMetadataDerivedStates } from '@/object-record/record-index/hooks/useRecordIndexFieldMetadataDerivedStates'; import { InputLabel } from '@/ui/input/components/InputLabel'; +import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter'; import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; import { WorkflowFindRecordsFilters } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowFindRecordsFilters'; import { WorkflowFindRecordsFiltersEffect } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowFindRecordsFiltersEffect'; @@ -232,6 +233,7 @@ export const WorkflowEditActionFindRecords = ({ readonly /> + {!actionOptions.readonly && } ); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder.tsx index 3e70fc5db1..0a4d8aeebb 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder.tsx @@ -6,6 +6,7 @@ import { InputLabel } from '@/ui/input/components/InputLabel'; import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; import { type WorkflowFormAction } from '@/workflow/types/Workflow'; +import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter'; import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader'; import { WorkflowEditActionFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormFieldSettings'; @@ -398,6 +399,7 @@ export const WorkflowEditActionFormBuilder = ({ )} + {!actionOptions.readonly && } ); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/__stories__/WorkflowEditActionFormBuilder.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/__stories__/WorkflowEditActionFormBuilder.stories.tsx index 240044a63f..01e41ef03d 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/__stories__/WorkflowEditActionFormBuilder.stories.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/__stories__/WorkflowEditActionFormBuilder.stories.tsx @@ -5,7 +5,9 @@ import { expect, fn, userEvent, waitFor, within } from '@storybook/test'; import { FieldMetadataType } from 'twenty-shared/types'; import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing'; import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; +import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator'; +import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator'; import { graphqlMocks } from '~/testing/graphqlMocks'; import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow'; @@ -56,9 +58,11 @@ const meta: Meta = { }, decorators: [ WorkflowStepActionDrawerDecorator, + WorkflowStepDecorator, ComponentDecorator, RouterDecorator, I18nFrontDecorator, + ObjectMetadataItemsDecorator, ], }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/WorkflowEditActionHttpRequest.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/WorkflowEditActionHttpRequest.tsx index cf597b5fbb..cf0fdfce37 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/WorkflowEditActionHttpRequest.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/WorkflowEditActionHttpRequest.tsx @@ -8,10 +8,10 @@ import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-acti import { CmdEnterActionButton } from '@/action-menu/components/CmdEnterActionButton'; import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; -import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter'; import { TabList } from '@/ui/layout/tab-list/components/TabList'; import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter'; import { isMethodWithBody } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/isMethodWithBody'; import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker'; import { useTheme } from '@emotion/react'; @@ -217,15 +217,20 @@ export const WorkflowEditActionHttpRequest = ({ )} - {activeTabId === WorkflowHttpRequestTabId.TEST && ( - , - ]} + {!actionOptions.readonly && ( + , + ] + : [] + } /> )} diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/__stories__/WorkflowEditActionHttpRequest.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/__stories__/WorkflowEditActionHttpRequest.stories.tsx index 3969be0894..3760831b4d 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/__stories__/WorkflowEditActionHttpRequest.stories.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/__stories__/WorkflowEditActionHttpRequest.stories.tsx @@ -3,6 +3,7 @@ import { type Meta, type StoryObj } from '@storybook/react'; import { expect, fn, waitFor, within } from '@storybook/test'; import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator'; import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; +import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator'; import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator'; @@ -84,6 +85,7 @@ const meta: Meta = { SnackBarDecorator, WorkspaceDecorator, I18nFrontDecorator, + ObjectMetadataItemsDecorator, ], }; diff --git a/packages/twenty-server/src/engine/core-modules/workflow/dtos/duplicate-workflow-version-step-input.dto.ts b/packages/twenty-server/src/engine/core-modules/workflow/dtos/duplicate-workflow-version-step-input.dto.ts new file mode 100644 index 0000000000..d5571e0d4b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workflow/dtos/duplicate-workflow-version-step-input.dto.ts @@ -0,0 +1,10 @@ +import { Field, InputType } from '@nestjs/graphql'; + +@InputType() +export class DuplicateWorkflowVersionStepInput { + @Field(() => String) + stepId: string; + + @Field(() => String) + workflowVersionId: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version-step.resolver.ts b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version-step.resolver.ts index 4d334cbbca..fabb445af7 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version-step.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version-step.resolver.ts @@ -7,6 +7,7 @@ import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; import { CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto'; import { DeleteWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/delete-workflow-version-step-input.dto'; +import { DuplicateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/duplicate-workflow-version-step-input.dto'; import { SubmitFormStepInput } from 'src/engine/core-modules/workflow/dtos/submit-form-step-input.dto'; import { UpdateWorkflowRunStepInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-run-step-input.dto'; import { UpdateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-version-step-input.dto'; @@ -124,4 +125,19 @@ export class WorkflowVersionStepResolver { return step; } + + @Mutation(() => WorkflowVersionStepChangesDTO) + async duplicateWorkflowVersionStep( + @AuthWorkspace() { id: workspaceId }: Workspace, + @Args('input') + { stepId, workflowVersionId }: DuplicateWorkflowVersionStepInput, + ): Promise { + return this.workflowVersionStepWorkspaceService.duplicateWorkflowVersionStep( + { + workspaceId, + workflowVersionId, + stepId, + }, + ); + } } diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.exception.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.exception.ts index 1bd95e6836..34a8d0e784 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.exception.ts @@ -10,4 +10,5 @@ export enum ServerlessFunctionExceptionCode { SERVERLESS_FUNCTION_BUILDING = 'SERVERLESS_FUNCTION_BUILDING', SERVERLESS_FUNCTION_CODE_UNCHANGED = 'SERVERLESS_FUNCTION_CODE_UNCHANGED', SERVERLESS_FUNCTION_EXECUTION_LIMIT_REACHED = 'SERVERLESS_FUNCTION_EXECUTION_LIMIT_REACHED', + SERVERLESS_FUNCTION_CREATE_FAILED = 'SERVERLESS_FUNCTION_CREATE_FAILED', } diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.service.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.service.ts index cfc7288051..266dd57d84 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.service.ts @@ -363,7 +363,7 @@ export class ServerlessFunctionService { }); } - async usePublishedVersionAsDraft({ + async createDraftFromPublishedVersion({ id, version, workspaceId, @@ -400,6 +400,57 @@ export class ServerlessFunctionService { }); } + async duplicateServerlessFunction({ + id, + version, + workspaceId, + }: { + id: string; + version: string; + workspaceId: string; + }) { + const serverlessFunctionToDuplicate = + await this.serverlessFunctionRepository.findOneOrFail({ + where: { + id, + workspaceId, + }, + }); + + const newServerlessFunction = await this.createOneServerlessFunction( + { + name: serverlessFunctionToDuplicate.name, + description: serverlessFunctionToDuplicate.description, + timeoutSeconds: serverlessFunctionToDuplicate.timeoutSeconds, + }, + workspaceId, + ); + + if (!isDefined(newServerlessFunction)) { + throw new ServerlessFunctionException( + 'Failed to create new serverless function', + ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_CREATE_FAILED, + ); + } + + await this.fileStorageService.copy({ + from: { + folderPath: getServerlessFolder({ + serverlessFunction: serverlessFunctionToDuplicate, + version, + }), + }, + to: { + folderPath: getServerlessFolder({ + serverlessFunction: newServerlessFunction, + version: 'draft', + }), + }, + }); + + return newServerlessFunction; + } + private async throttleExecution(workspaceId: string) { try { await this.throttlerService.throttle( diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils.ts index 0138307b71..a94adda8b6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils.ts @@ -24,6 +24,7 @@ export const serverlessFunctionGraphQLApiExceptionHandler = (error: any) => { case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_EXECUTION_LIMIT_REACHED: throw new ForbiddenError(error); case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_CODE_UNCHANGED: + case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_CREATE_FAILED: throw error; default: { return assertUnreachable(error.code); diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service.ts index 479f96cc08..384405b612 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service.ts @@ -48,6 +48,8 @@ const BASE_STEP_DEFINITION: BaseWorkflowActionSettings = { }, }; +const DUPLICATED_STEP_POSITION_OFFSET = 50; + @Injectable() export class WorkflowVersionStepWorkspaceService { constructor( @@ -279,36 +281,69 @@ export class WorkflowVersionStepWorkspaceService { }); } - async duplicateStep({ - step, + async duplicateWorkflowVersionStep({ workspaceId, + workflowVersionId, + stepId, }: { - step: WorkflowAction; workspaceId: string; - }): Promise { - switch (step.type) { - case WorkflowActionType.CODE: { - await this.serverlessFunctionService.usePublishedVersionAsDraft({ - id: step.settings.input.serverlessFunctionId, - version: step.settings.input.serverlessFunctionVersion, - workspaceId, - }); + workflowVersionId: string; + stepId: string; + }): Promise { + const workflowVersionRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + 'workflowVersion', + { shouldBypassPermissionChecks: true }, + ); - return { - ...step, - settings: { - ...step.settings, - input: { - ...step.settings.input, - serverlessFunctionVersion: 'draft', - }, - }, - }; - } - default: { - return step; - } + const workflowVersion = await workflowVersionRepository.findOne({ + where: { + id: workflowVersionId, + }, + }); + + if (!isDefined(workflowVersion)) { + throw new WorkflowVersionStepException( + 'WorkflowVersion not found', + WorkflowVersionStepExceptionCode.NOT_FOUND, + ); } + + assertWorkflowVersionIsDraft(workflowVersion); + + const stepToDuplicate = workflowVersion.steps?.find( + (step) => step.id === stepId, + ); + + if (!isDefined(stepToDuplicate)) { + throw new WorkflowVersionStepException( + 'Step not found', + WorkflowVersionStepExceptionCode.NOT_FOUND, + ); + } + + const duplicatedStep = await this.createStepForDuplicate({ + step: stepToDuplicate, + workspaceId, + }); + + const { updatedSteps, updatedInsertedStep, updatedTrigger } = insertStep({ + existingSteps: workflowVersion.steps ?? [], + existingTrigger: workflowVersion.trigger, + insertedStep: duplicatedStep, + }); + + await workflowVersionRepository.update(workflowVersion.id, { + steps: updatedSteps, + trigger: updatedTrigger, + }); + + return computeWorkflowVersionStepChanges({ + createdStep: updatedInsertedStep, + trigger: updatedTrigger, + steps: updatedSteps, + }); } async submitFormStep({ @@ -372,6 +407,38 @@ export class WorkflowVersionStepWorkspaceService { }); } + async createDraftStep({ + step, + workspaceId, + }: { + step: WorkflowAction; + workspaceId: string; + }): Promise { + switch (step.type) { + case WorkflowActionType.CODE: { + await this.serverlessFunctionService.createDraftFromPublishedVersion({ + id: step.settings.input.serverlessFunctionId, + version: step.settings.input.serverlessFunctionVersion, + workspaceId, + }); + + return { + ...step, + settings: { + ...step.settings, + input: { + ...step.settings.input, + serverlessFunctionVersion: 'draft', + }, + }, + }; + } + default: { + return step; + } + } + } + private async enrichOutputSchema({ step, workspaceId, @@ -745,4 +812,53 @@ export class WorkflowVersionStepWorkspaceService { return acc; }, {}); } + + private async createStepForDuplicate({ + step, + workspaceId, + }: { + step: WorkflowAction; + workspaceId: string; + }): Promise { + const duplicatedStepPosition = { + x: (step.position?.x ?? 0) + DUPLICATED_STEP_POSITION_OFFSET, + y: (step.position?.y ?? 0) + DUPLICATED_STEP_POSITION_OFFSET, + }; + + switch (step.type) { + case WorkflowActionType.CODE: { + const newServerlessFunction = + await this.serverlessFunctionService.duplicateServerlessFunction({ + id: step.settings.input.serverlessFunctionId, + version: step.settings.input.serverlessFunctionVersion, + workspaceId, + }); + + return { + ...step, + id: v4(), + name: `${step.name} (Duplicate)`, + nextStepIds: [], + position: duplicatedStepPosition, + settings: { + ...step.settings, + input: { + ...step.settings.input, + serverlessFunctionId: newServerlessFunction.id, + serverlessFunctionVersion: 'draft', + }, + }, + }; + } + default: { + return { + ...step, + id: v4(), + name: `${step.name} (Duplicate)`, + nextStepIds: [], + position: duplicatedStepPosition, + }; + } + } + } } diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service.ts index 52b59536f4..d0522128ff 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service.ts @@ -102,7 +102,7 @@ export class WorkflowVersionWorkspaceService { for (const step of workflowVersionToCopy.steps) { const duplicatedStep = - await this.workflowVersionStepWorkspaceService.duplicateStep({ + await this.workflowVersionStepWorkspaceService.createDraftStep({ step, workspaceId, });