diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 701154e648..6e17e57a1b 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -608,11 +608,22 @@ export type CreateWebhookDto = { targetUrl: Scalars['String']; }; +export type CreateWorkflowVersionEdgeInput = { + /** Workflow version source step ID */ + source: Scalars['String']; + /** Workflow version target step ID */ + target: Scalars['String']; + /** Workflow version ID */ + workflowVersionId: Scalars['String']; +}; + export type CreateWorkflowVersionStepInput = { /** Next step ID */ nextStepId?: InputMaybe; /** Parent step ID */ - parentStepId?: InputMaybe; + parentStepId?: InputMaybe; + /** Step position */ + position?: InputMaybe; /** New step type */ stepType: Scalars['String']; /** Workflow version ID */ @@ -786,6 +797,7 @@ export enum FeatureFlagKey { IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED', IS_TWO_FACTOR_AUTHENTICATION_ENABLED = 'IS_TWO_FACTOR_AUTHENTICATION_ENABLED', IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED', + IS_WORKFLOW_BRANCH_ENABLED = 'IS_WORKFLOW_BRANCH_ENABLED', IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED', IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED = 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED', IS_WORKSPACE_MIGRATION_V2_ENABLED = 'IS_WORKSPACE_MIGRATION_V2_ENABLED' @@ -1171,7 +1183,8 @@ export type Mutation = { createOneServerlessFunction: ServerlessFunction; createSAMLIdentityProvider: SetupSsoOutput; createWebhook: Webhook; - createWorkflowVersionStep: WorkflowAction; + createWorkflowVersionEdge: WorkflowVersionStepChanges; + createWorkflowVersionStep: WorkflowVersionStepChanges; deactivateWorkflowVersion: Scalars['Boolean']; deleteApprovedAccessDomain: Scalars['Boolean']; deleteCurrentWorkspace: Workspace; @@ -1187,7 +1200,8 @@ export type Mutation = { deleteTwoFactorAuthenticationMethod: DeleteTwoFactorAuthenticationMethodOutput; deleteUser: User; deleteWebhook: Scalars['Boolean']; - deleteWorkflowVersionStep: WorkflowAction; + deleteWorkflowVersionEdge: WorkflowVersionStepChanges; + deleteWorkflowVersionStep: WorkflowVersionStepChanges; deleteWorkspaceInvitation: Scalars['String']; disablePostgresProxy: PostgresCredentials; editSSOIdentityProvider: EditSsoOutput; @@ -1241,6 +1255,7 @@ export type Mutation = { updatePasswordViaResetToken: InvalidatePassword; updateWebhook?: Maybe; updateWorkflowRunStep: WorkflowAction; + updateWorkflowVersionPositions: Scalars['Boolean']; updateWorkflowVersionStep: WorkflowAction; updateWorkspace: Workspace; updateWorkspaceFeatureFlag: Scalars['Boolean']; @@ -1394,6 +1409,11 @@ export type MutationCreateWebhookArgs = { }; +export type MutationCreateWorkflowVersionEdgeArgs = { + input: CreateWorkflowVersionEdgeInput; +}; + + export type MutationCreateWorkflowVersionStepArgs = { input: CreateWorkflowVersionStepInput; }; @@ -1464,6 +1484,11 @@ export type MutationDeleteWebhookArgs = { }; +export type MutationDeleteWorkflowVersionEdgeArgs = { + input: CreateWorkflowVersionEdgeInput; +}; + + export type MutationDeleteWorkflowVersionStepArgs = { input: DeleteWorkflowVersionStepInput; }; @@ -1726,6 +1751,11 @@ export type MutationUpdateWorkflowRunStepArgs = { }; +export type MutationUpdateWorkflowVersionPositionsArgs = { + input: UpdateWorkflowVersionPositionsInput; +}; + + export type MutationUpdateWorkflowVersionStepArgs = { input: UpdateWorkflowVersionStepInput; }; @@ -2860,6 +2890,13 @@ export type UpdateWorkflowRunStepInput = { workflowRunId: Scalars['UUID']; }; +export type UpdateWorkflowVersionPositionsInput = { + /** Workflow version updated positions */ + positions: Array; + /** Workflow version ID */ + workflowVersionId: Scalars['UUID']; +}; + export type UpdateWorkflowVersionStepInput = { /** Step to update in JSON format */ step: Scalars['JSON']; @@ -3034,6 +3071,7 @@ export type WorkflowAction = { id: Scalars['UUID']; name: Scalars['String']; nextStepIds?: Maybe>; + position?: Maybe; settings: Scalars['JSON']; type: Scalars['String']; valid: Scalars['Boolean']; @@ -3044,11 +3082,37 @@ export type WorkflowRun = { workflowRunId: Scalars['UUID']; }; +export type WorkflowStepPosition = { + __typename?: 'WorkflowStepPosition'; + x: Scalars['Float']; + y: Scalars['Float']; +}; + +export type WorkflowStepPositionInput = { + x: Scalars['Float']; + y: Scalars['Float']; +}; + +export type WorkflowStepPositionUpdateInput = { + /** Step or trigger ID */ + id: Scalars['String']; + /** Position of the step or trigger */ + position: WorkflowStepPositionInput; +}; + export type WorkflowVersion = { __typename?: 'WorkflowVersion'; id: Scalars['UUID']; }; +export type WorkflowVersionStepChanges = { + __typename?: 'WorkflowVersionStepChanges'; + createdStep?: Maybe; + deletedStepId?: Maybe; + stepsNextStepIds?: Maybe; + triggerNextStepIds?: Maybe>; +}; + export type Workspace = { __typename?: 'Workspace'; activationStatus: WorkspaceActivationStatus; @@ -4086,12 +4150,19 @@ export type CreateDraftFromWorkflowVersionMutationVariables = Exact<{ export type CreateDraftFromWorkflowVersionMutation = { __typename?: 'Mutation', createDraftFromWorkflowVersion: { __typename?: 'WorkflowVersion', id: any } }; +export type CreateWorkflowVersionEdgeMutationVariables = Exact<{ + input: CreateWorkflowVersionEdgeInput; +}>; + + +export type CreateWorkflowVersionEdgeMutation = { __typename?: 'Mutation', createWorkflowVersionEdge: { __typename?: 'WorkflowVersionStepChanges', triggerNextStepIds?: Array | null, stepsNextStepIds?: any | null } }; + export type CreateWorkflowVersionStepMutationVariables = Exact<{ input: CreateWorkflowVersionStepInput; }>; -export type CreateWorkflowVersionStepMutation = { __typename?: 'Mutation', createWorkflowVersionStep: { __typename?: 'WorkflowAction', id: any, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array | null } }; +export type CreateWorkflowVersionStepMutation = { __typename?: 'Mutation', createWorkflowVersionStep: { __typename?: 'WorkflowVersionStepChanges', triggerNextStepIds?: Array | null, stepsNextStepIds?: any | null, createdStep?: { __typename?: 'WorkflowAction', id: any, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array | null, position?: { __typename?: 'WorkflowStepPosition', x: number, y: number } | null } | null } }; export type DeactivateWorkflowVersionMutationVariables = Exact<{ workflowVersionId: Scalars['UUID']; @@ -4100,12 +4171,19 @@ export type DeactivateWorkflowVersionMutationVariables = Exact<{ export type DeactivateWorkflowVersionMutation = { __typename?: 'Mutation', deactivateWorkflowVersion: boolean }; +export type DeleteWorkflowVersionEdgeMutationVariables = Exact<{ + input: CreateWorkflowVersionEdgeInput; +}>; + + +export type DeleteWorkflowVersionEdgeMutation = { __typename?: 'Mutation', deleteWorkflowVersionEdge: { __typename?: 'WorkflowVersionStepChanges', triggerNextStepIds?: Array | null, stepsNextStepIds?: any | null } }; + export type DeleteWorkflowVersionStepMutationVariables = Exact<{ input: DeleteWorkflowVersionStepInput; }>; -export type DeleteWorkflowVersionStepMutation = { __typename?: 'Mutation', deleteWorkflowVersionStep: { __typename?: 'WorkflowAction', id: any, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array | null } }; +export type DeleteWorkflowVersionStepMutation = { __typename?: 'Mutation', deleteWorkflowVersionStep: { __typename?: 'WorkflowVersionStepChanges', triggerNextStepIds?: Array | null, stepsNextStepIds?: any | null, deletedStepId?: string | null } }; export type RunWorkflowVersionMutationVariables = Exact<{ input: RunWorkflowVersionInput; @@ -4119,14 +4197,14 @@ export type UpdateWorkflowRunStepMutationVariables = Exact<{ }>; -export type UpdateWorkflowRunStepMutation = { __typename?: 'Mutation', updateWorkflowRunStep: { __typename?: 'WorkflowAction', id: any, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array | null } }; +export type UpdateWorkflowRunStepMutation = { __typename?: 'Mutation', updateWorkflowRunStep: { __typename?: 'WorkflowAction', id: any, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array | null, position?: { __typename?: 'WorkflowStepPosition', x: number, y: number } | null } }; export type UpdateWorkflowVersionStepMutationVariables = Exact<{ input: UpdateWorkflowVersionStepInput; }>; -export type UpdateWorkflowVersionStepMutation = { __typename?: 'Mutation', updateWorkflowVersionStep: { __typename?: 'WorkflowAction', id: any, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array | null } }; +export type UpdateWorkflowVersionStepMutation = { __typename?: 'Mutation', updateWorkflowVersionStep: { __typename?: 'WorkflowAction', id: any, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array | null, position?: { __typename?: 'WorkflowStepPosition', x: number, y: number } | null } }; export type SubmitFormStepMutationVariables = Exact<{ input: SubmitFormStepInput; @@ -4135,6 +4213,13 @@ export type SubmitFormStepMutationVariables = Exact<{ export type SubmitFormStepMutation = { __typename?: 'Mutation', submitFormStep: boolean }; +export type UpdateWorkflowVersionPositionsMutationVariables = Exact<{ + input: UpdateWorkflowVersionPositionsInput; +}>; + + +export type UpdateWorkflowVersionPositionsMutation = { __typename?: 'Mutation', updateWorkflowVersionPositions: boolean }; + export type DeleteWorkspaceInvitationMutationVariables = Exact<{ appTokenId: Scalars['String']; }>; @@ -9226,15 +9311,57 @@ export function useCreateDraftFromWorkflowVersionMutation(baseOptions?: Apollo.M export type CreateDraftFromWorkflowVersionMutationHookResult = ReturnType; export type CreateDraftFromWorkflowVersionMutationResult = Apollo.MutationResult; export type CreateDraftFromWorkflowVersionMutationOptions = Apollo.BaseMutationOptions; +export const CreateWorkflowVersionEdgeDocument = gql` + mutation CreateWorkflowVersionEdge($input: CreateWorkflowVersionEdgeInput!) { + createWorkflowVersionEdge(input: $input) { + triggerNextStepIds + stepsNextStepIds + } +} + `; +export type CreateWorkflowVersionEdgeMutationFn = Apollo.MutationFunction; + +/** + * __useCreateWorkflowVersionEdgeMutation__ + * + * To run a mutation, you first call `useCreateWorkflowVersionEdgeMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateWorkflowVersionEdgeMutation` 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 [createWorkflowVersionEdgeMutation, { data, loading, error }] = useCreateWorkflowVersionEdgeMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useCreateWorkflowVersionEdgeMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateWorkflowVersionEdgeDocument, options); + } +export type CreateWorkflowVersionEdgeMutationHookResult = ReturnType; +export type CreateWorkflowVersionEdgeMutationResult = Apollo.MutationResult; +export type CreateWorkflowVersionEdgeMutationOptions = Apollo.BaseMutationOptions; export const CreateWorkflowVersionStepDocument = gql` mutation CreateWorkflowVersionStep($input: CreateWorkflowVersionStepInput!) { createWorkflowVersionStep(input: $input) { - id - name - type - settings - valid - nextStepIds + triggerNextStepIds + stepsNextStepIds + createdStep { + id + name + type + settings + valid + nextStepIds + position { + x + y + } + } } } `; @@ -9295,15 +9422,46 @@ export function useDeactivateWorkflowVersionMutation(baseOptions?: Apollo.Mutati export type DeactivateWorkflowVersionMutationHookResult = ReturnType; export type DeactivateWorkflowVersionMutationResult = Apollo.MutationResult; export type DeactivateWorkflowVersionMutationOptions = Apollo.BaseMutationOptions; +export const DeleteWorkflowVersionEdgeDocument = gql` + mutation DeleteWorkflowVersionEdge($input: CreateWorkflowVersionEdgeInput!) { + deleteWorkflowVersionEdge(input: $input) { + triggerNextStepIds + stepsNextStepIds + } +} + `; +export type DeleteWorkflowVersionEdgeMutationFn = Apollo.MutationFunction; + +/** + * __useDeleteWorkflowVersionEdgeMutation__ + * + * To run a mutation, you first call `useDeleteWorkflowVersionEdgeMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDeleteWorkflowVersionEdgeMutation` 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 [deleteWorkflowVersionEdgeMutation, { data, loading, error }] = useDeleteWorkflowVersionEdgeMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useDeleteWorkflowVersionEdgeMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(DeleteWorkflowVersionEdgeDocument, options); + } +export type DeleteWorkflowVersionEdgeMutationHookResult = ReturnType; +export type DeleteWorkflowVersionEdgeMutationResult = Apollo.MutationResult; +export type DeleteWorkflowVersionEdgeMutationOptions = Apollo.BaseMutationOptions; export const DeleteWorkflowVersionStepDocument = gql` mutation DeleteWorkflowVersionStep($input: DeleteWorkflowVersionStepInput!) { deleteWorkflowVersionStep(input: $input) { - id - name - type - settings - valid - nextStepIds + triggerNextStepIds + stepsNextStepIds + deletedStepId } } `; @@ -9375,6 +9533,10 @@ export const UpdateWorkflowRunStepDocument = gql` settings valid nextStepIds + position { + x + y + } } } `; @@ -9413,6 +9575,10 @@ export const UpdateWorkflowVersionStepDocument = gql` settings valid nextStepIds + position { + x + y + } } } `; @@ -9473,6 +9639,37 @@ export function useSubmitFormStepMutation(baseOptions?: Apollo.MutationHookOptio export type SubmitFormStepMutationHookResult = ReturnType; export type SubmitFormStepMutationResult = Apollo.MutationResult; export type SubmitFormStepMutationOptions = Apollo.BaseMutationOptions; +export const UpdateWorkflowVersionPositionsDocument = gql` + mutation UpdateWorkflowVersionPositions($input: UpdateWorkflowVersionPositionsInput!) { + updateWorkflowVersionPositions(input: $input) +} + `; +export type UpdateWorkflowVersionPositionsMutationFn = Apollo.MutationFunction; + +/** + * __useUpdateWorkflowVersionPositionsMutation__ + * + * To run a mutation, you first call `useUpdateWorkflowVersionPositionsMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpdateWorkflowVersionPositionsMutation` 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 [updateWorkflowVersionPositionsMutation, { data, loading, error }] = useUpdateWorkflowVersionPositionsMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useUpdateWorkflowVersionPositionsMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UpdateWorkflowVersionPositionsDocument, options); + } +export type UpdateWorkflowVersionPositionsMutationHookResult = ReturnType; +export type UpdateWorkflowVersionPositionsMutationResult = Apollo.MutationResult; +export type UpdateWorkflowVersionPositionsMutationOptions = Apollo.BaseMutationOptions; export const DeleteWorkspaceInvitationDocument = gql` mutation DeleteWorkspaceInvitation($appTokenId: String!) { deleteWorkspaceInvitation(appTokenId: $appTokenId) diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 0c1b84d021..e63b3029d6 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -572,11 +572,22 @@ export type CreateWebhookDto = { targetUrl: Scalars['String']; }; +export type CreateWorkflowVersionEdgeInput = { + /** Workflow version source step ID */ + source: Scalars['String']; + /** Workflow version target step ID */ + target: Scalars['String']; + /** Workflow version ID */ + workflowVersionId: Scalars['String']; +}; + export type CreateWorkflowVersionStepInput = { /** Next step ID */ nextStepId?: InputMaybe; /** Parent step ID */ - parentStepId?: InputMaybe; + parentStepId?: InputMaybe; + /** Step position */ + position?: InputMaybe; /** New step type */ stepType: Scalars['String']; /** Workflow version ID */ @@ -750,6 +761,7 @@ export enum FeatureFlagKey { IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED', IS_TWO_FACTOR_AUTHENTICATION_ENABLED = 'IS_TWO_FACTOR_AUTHENTICATION_ENABLED', IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED', + IS_WORKFLOW_BRANCH_ENABLED = 'IS_WORKFLOW_BRANCH_ENABLED', IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED', IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED = 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED', IS_WORKSPACE_MIGRATION_V2_ENABLED = 'IS_WORKSPACE_MIGRATION_V2_ENABLED' @@ -1127,7 +1139,8 @@ export type Mutation = { createOneServerlessFunction: ServerlessFunction; createSAMLIdentityProvider: SetupSsoOutput; createWebhook: Webhook; - createWorkflowVersionStep: WorkflowAction; + createWorkflowVersionEdge: WorkflowVersionStepChanges; + createWorkflowVersionStep: WorkflowVersionStepChanges; deactivateWorkflowVersion: Scalars['Boolean']; deleteApprovedAccessDomain: Scalars['Boolean']; deleteCurrentWorkspace: Workspace; @@ -1142,7 +1155,8 @@ export type Mutation = { deleteTwoFactorAuthenticationMethod: DeleteTwoFactorAuthenticationMethodOutput; deleteUser: User; deleteWebhook: Scalars['Boolean']; - deleteWorkflowVersionStep: WorkflowAction; + deleteWorkflowVersionEdge: WorkflowVersionStepChanges; + deleteWorkflowVersionStep: WorkflowVersionStepChanges; deleteWorkspaceInvitation: Scalars['String']; disablePostgresProxy: PostgresCredentials; editSSOIdentityProvider: EditSsoOutput; @@ -1192,6 +1206,7 @@ export type Mutation = { updatePasswordViaResetToken: InvalidatePassword; updateWebhook?: Maybe; updateWorkflowRunStep: WorkflowAction; + updateWorkflowVersionPositions: Scalars['Boolean']; updateWorkflowVersionStep: WorkflowAction; updateWorkspace: Workspace; updateWorkspaceFeatureFlag: Scalars['Boolean']; @@ -1330,6 +1345,11 @@ export type MutationCreateWebhookArgs = { }; +export type MutationCreateWorkflowVersionEdgeArgs = { + input: CreateWorkflowVersionEdgeInput; +}; + + export type MutationCreateWorkflowVersionStepArgs = { input: CreateWorkflowVersionStepInput; }; @@ -1395,6 +1415,11 @@ export type MutationDeleteWebhookArgs = { }; +export type MutationDeleteWorkflowVersionEdgeArgs = { + input: CreateWorkflowVersionEdgeInput; +}; + + export type MutationDeleteWorkflowVersionStepArgs = { input: DeleteWorkflowVersionStepInput; }; @@ -1637,6 +1662,11 @@ export type MutationUpdateWorkflowRunStepArgs = { }; +export type MutationUpdateWorkflowVersionPositionsArgs = { + input: UpdateWorkflowVersionPositionsInput; +}; + + export type MutationUpdateWorkflowVersionStepArgs = { input: UpdateWorkflowVersionStepInput; }; @@ -2698,6 +2728,13 @@ export type UpdateWorkflowRunStepInput = { workflowRunId: Scalars['UUID']; }; +export type UpdateWorkflowVersionPositionsInput = { + /** Workflow version updated positions */ + positions: Array; + /** Workflow version ID */ + workflowVersionId: Scalars['UUID']; +}; + export type UpdateWorkflowVersionStepInput = { /** Step to update in JSON format */ step: Scalars['JSON']; @@ -2862,6 +2899,7 @@ export type WorkflowAction = { id: Scalars['UUID']; name: Scalars['String']; nextStepIds?: Maybe>; + position?: Maybe; settings: Scalars['JSON']; type: Scalars['String']; valid: Scalars['Boolean']; @@ -2872,11 +2910,37 @@ export type WorkflowRun = { workflowRunId: Scalars['UUID']; }; +export type WorkflowStepPosition = { + __typename?: 'WorkflowStepPosition'; + x: Scalars['Float']; + y: Scalars['Float']; +}; + +export type WorkflowStepPositionInput = { + x: Scalars['Float']; + y: Scalars['Float']; +}; + +export type WorkflowStepPositionUpdateInput = { + /** Step or trigger ID */ + id: Scalars['String']; + /** Position of the step or trigger */ + position: WorkflowStepPositionInput; +}; + export type WorkflowVersion = { __typename?: 'WorkflowVersion'; id: Scalars['UUID']; }; +export type WorkflowVersionStepChanges = { + __typename?: 'WorkflowVersionStepChanges'; + createdStep?: Maybe; + deletedStepId?: Maybe; + stepsNextStepIds?: Maybe; + triggerNextStepIds?: Maybe>; +}; + export type Workspace = { __typename?: 'Workspace'; activationStatus: WorkspaceActivationStatus; 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 6704942874..1b6af8f9ca 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 @@ -9,9 +9,9 @@ import { useCreateStep } from '@/workflow/workflow-steps/hooks/useCreateStep'; import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState'; import { RECORD_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/RecordActions'; import { useFilteredOtherActions } from '@/workflow/workflow-steps/workflow-actions/hooks/useFilteredOtherActions'; -import { isDefined } from 'twenty-shared/utils'; import { useIcons } from 'twenty-ui/display'; import { MenuItemCommand } from 'twenty-ui/navigation'; +import { useCloseRightClickMenu } from '@/workflow/workflow-diagram/hooks/useCloseRightClickMenu'; export const CommandMenuWorkflowSelectActionContent = ({ workflow, @@ -25,28 +25,27 @@ export const CommandMenuWorkflowSelectActionContent = ({ }); const filteredOtherActions = useFilteredOtherActions(); + const { closeRightClickMenu } = useCloseRightClickMenu(); + const [workflowInsertStepIds, setWorkflowInsertStepIds] = useRecoilComponentStateV2(workflowInsertStepIdsComponentState); const handleCreateStep = async (actionType: WorkflowActionType) => { - const { parentStepId, nextStepId } = workflowInsertStepIds; - - if (!isDefined(parentStepId)) { - throw new Error( - 'No parentStepId. Please select a parent step to create from.', - ); - } + const { parentStepId, nextStepId, position } = workflowInsertStepIds; await createStep({ newStepType: actionType, parentStepId, nextStepId, + position, }); setWorkflowInsertStepIds({ parentStepId: undefined, nextStepId: undefined, + position: undefined, }); + closeRightClickMenu(); }; return ( diff --git a/packages/twenty-front/src/modules/command-menu/pages/workflow/trigger-type/components/CommandMenuWorkflowSelectTriggerTypeContent.tsx b/packages/twenty-front/src/modules/command-menu/pages/workflow/trigger-type/components/CommandMenuWorkflowSelectTriggerTypeContent.tsx index f6e8d934ad..23cca632e8 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/workflow/trigger-type/components/CommandMenuWorkflowSelectTriggerTypeContent.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/workflow/trigger-type/components/CommandMenuWorkflowSelectTriggerTypeContent.tsx @@ -15,6 +15,8 @@ import { useUpdateWorkflowVersionTrigger } from '@/workflow/workflow-trigger/hoo import { getTriggerDefaultDefinition } from '@/workflow/workflow-trigger/utils/getTriggerDefaultDefinition'; import { useIcons } from 'twenty-ui/display'; import { MenuItemCommand } from 'twenty-ui/navigation'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { FeatureFlagKey } from '~/generated/graphql'; export const CommandMenuWorkflowSelectTriggerTypeContent = ({ workflow, @@ -32,6 +34,10 @@ export const CommandMenuWorkflowSelectTriggerTypeContent = ({ ); const { openWorkflowEditStepInCommandMenu } = useWorkflowCommandMenu(); + const isWorkflowBranchEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_WORKFLOW_BRANCH_ENABLED, + ); + const handleTriggerTypeClick = ({ type, defaultLabel, @@ -47,6 +53,7 @@ export const CommandMenuWorkflowSelectTriggerTypeContent = ({ defaultLabel, type, activeNonSystemObjectMetadataItems, + steps: !isWorkflowBranchEnabled ? workflow.currentVersion.steps : [], }), ); diff --git a/packages/twenty-front/src/modules/workflow/graphql/mutations/createWorkflowVersionEdge.ts b/packages/twenty-front/src/modules/workflow/graphql/mutations/createWorkflowVersionEdge.ts new file mode 100644 index 0000000000..e63bd3d3db --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/graphql/mutations/createWorkflowVersionEdge.ts @@ -0,0 +1,10 @@ +import { gql } from '@apollo/client'; + +export const CREATE_WORKFLOW_VERSION_EDGE = gql` + mutation CreateWorkflowVersionEdge($input: CreateWorkflowVersionEdgeInput!) { + createWorkflowVersionEdge(input: $input) { + triggerNextStepIds + stepsNextStepIds + } + } +`; diff --git a/packages/twenty-front/src/modules/workflow/graphql/mutations/createWorkflowVersionStep.ts b/packages/twenty-front/src/modules/workflow/graphql/mutations/createWorkflowVersionStep.ts index 0bea0011e2..8e1922ea36 100644 --- a/packages/twenty-front/src/modules/workflow/graphql/mutations/createWorkflowVersionStep.ts +++ b/packages/twenty-front/src/modules/workflow/graphql/mutations/createWorkflowVersionStep.ts @@ -3,12 +3,20 @@ import { gql } from '@apollo/client'; export const CREATE_WORKFLOW_VERSION_STEP = gql` mutation CreateWorkflowVersionStep($input: CreateWorkflowVersionStepInput!) { createWorkflowVersionStep(input: $input) { - id - name - type - settings - valid - nextStepIds + triggerNextStepIds + stepsNextStepIds + createdStep { + id + name + type + settings + valid + nextStepIds + position { + x + y + } + } } } `; diff --git a/packages/twenty-front/src/modules/workflow/graphql/mutations/deleteWorkflowVersionEdge.ts b/packages/twenty-front/src/modules/workflow/graphql/mutations/deleteWorkflowVersionEdge.ts new file mode 100644 index 0000000000..1cd8c80ed3 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/graphql/mutations/deleteWorkflowVersionEdge.ts @@ -0,0 +1,10 @@ +import { gql } from '@apollo/client'; + +export const DELETE_WORKFLOW_VERSION_EDGE = gql` + mutation DeleteWorkflowVersionEdge($input: CreateWorkflowVersionEdgeInput!) { + deleteWorkflowVersionEdge(input: $input) { + triggerNextStepIds + stepsNextStepIds + } + } +`; diff --git a/packages/twenty-front/src/modules/workflow/graphql/mutations/deleteWorkflowVersionStep.ts b/packages/twenty-front/src/modules/workflow/graphql/mutations/deleteWorkflowVersionStep.ts index e324f88a92..af6bdcba18 100644 --- a/packages/twenty-front/src/modules/workflow/graphql/mutations/deleteWorkflowVersionStep.ts +++ b/packages/twenty-front/src/modules/workflow/graphql/mutations/deleteWorkflowVersionStep.ts @@ -3,12 +3,9 @@ import { gql } from '@apollo/client'; export const DELETE_WORKFLOW_VERSION_STEP = gql` mutation DeleteWorkflowVersionStep($input: DeleteWorkflowVersionStepInput!) { deleteWorkflowVersionStep(input: $input) { - id - name - type - settings - valid - nextStepIds + triggerNextStepIds + stepsNextStepIds + deletedStepId } } `; diff --git a/packages/twenty-front/src/modules/workflow/graphql/mutations/updateWorkflowRunStep.ts b/packages/twenty-front/src/modules/workflow/graphql/mutations/updateWorkflowRunStep.ts index a5d36a56e2..fee2363b96 100644 --- a/packages/twenty-front/src/modules/workflow/graphql/mutations/updateWorkflowRunStep.ts +++ b/packages/twenty-front/src/modules/workflow/graphql/mutations/updateWorkflowRunStep.ts @@ -9,6 +9,10 @@ export const UPDATE_WORKFLOW_RUN_STEP = gql` settings valid nextStepIds + position { + x + y + } } } `; diff --git a/packages/twenty-front/src/modules/workflow/graphql/mutations/updateWorkflowVersionStep.ts b/packages/twenty-front/src/modules/workflow/graphql/mutations/updateWorkflowVersionStep.ts index 78bf10a259..3f083ce25b 100644 --- a/packages/twenty-front/src/modules/workflow/graphql/mutations/updateWorkflowVersionStep.ts +++ b/packages/twenty-front/src/modules/workflow/graphql/mutations/updateWorkflowVersionStep.ts @@ -9,6 +9,10 @@ export const UPDATE_WORKFLOW_VERSION_STEP = gql` settings valid nextStepIds + position { + x + y + } } } `; diff --git a/packages/twenty-front/src/modules/workflow/hooks/useDeleteWorkflowVersionStep.ts b/packages/twenty-front/src/modules/workflow/hooks/useDeleteWorkflowVersionStep.ts index 20a5f81fee..7fe82e56e1 100644 --- a/packages/twenty-front/src/modules/workflow/hooks/useDeleteWorkflowVersionStep.ts +++ b/packages/twenty-front/src/modules/workflow/hooks/useDeleteWorkflowVersionStep.ts @@ -1,90 +1,35 @@ import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache'; -import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache'; -import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; import { DELETE_WORKFLOW_VERSION_STEP } from '@/workflow/graphql/mutations/deleteWorkflowVersionStep'; -import { WorkflowVersion } from '@/workflow/types/Workflow'; import { useMutation } from '@apollo/client'; -import { isDefined } from 'twenty-shared/utils'; import { DeleteWorkflowVersionStepInput, DeleteWorkflowVersionStepMutation, DeleteWorkflowVersionStepMutationVariables, - WorkflowAction, } from '~/generated-metadata/graphql'; +import { useUpdateWorkflowVersionCache } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache'; export const useDeleteWorkflowVersionStep = () => { const apolloCoreClient = useApolloCoreClient(); - const { objectMetadataItems } = useObjectMetadataItems(); - const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); - const { objectMetadataItem } = useObjectMetadataItem({ - objectNameSingular: CoreObjectNameSingular.WorkflowVersion, - }); - const getRecordFromCache = useGetRecordFromCache({ - objectNameSingular: CoreObjectNameSingular.WorkflowVersion, - }); + + const { updateWorkflowVersionCache } = useUpdateWorkflowVersionCache(); + const [mutate] = useMutation< DeleteWorkflowVersionStepMutation, DeleteWorkflowVersionStepMutationVariables >(DELETE_WORKFLOW_VERSION_STEP, { client: apolloCoreClient, }); + const deleteWorkflowVersionStep = async ( input: DeleteWorkflowVersionStepInput, ) => { const result = await mutate({ variables: { input } }); - const deletedStep = result?.data?.deleteWorkflowVersionStep; + const workflowVersionStepChanges = result?.data?.deleteWorkflowVersionStep; - if (!isDefined(deletedStep)) { - return; - } - - const cachedRecord = getRecordFromCache( - input.workflowVersionId, - ); - - if (!isDefined(cachedRecord)) { - return; - } - - const newCachedRecord = { - ...cachedRecord, - steps: (cachedRecord.steps || []) - .filter((step: WorkflowAction) => step.id !== deletedStep.id) - .map((step: WorkflowAction) => { - if (!step.nextStepIds?.includes(deletedStep.id)) { - return step; - } - - return { - ...step, - nextStepIds: [ - ...new Set([ - ...(step.nextStepIds?.filter( - (nextStepId) => nextStepId !== deletedStep.id, - ) || []), - ...(deletedStep.nextStepIds || []), - ]), - ], - }; - }), - }; - - const recordGqlFields = { - steps: true, - }; - - updateRecordFromCache({ - objectMetadataItems, - objectMetadataItem, - cache: apolloCoreClient.cache, - record: newCachedRecord, - recordGqlFields, - objectPermissionsByObjectMetadataId, + updateWorkflowVersionCache({ + workflowVersionStepChanges, + workflowVersionId: input.workflowVersionId, }); }; diff --git a/packages/twenty-front/src/modules/workflow/hooks/useStepsOutputSchema.ts b/packages/twenty-front/src/modules/workflow/hooks/useStepsOutputSchema.ts index 2682b91c0c..d728702401 100644 --- a/packages/twenty-front/src/modules/workflow/hooks/useStepsOutputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/hooks/useStepsOutputSchema.ts @@ -4,7 +4,7 @@ import { getStepOutputSchemaFamilyStateKey } from '@/workflow/utils/getStepOutpu import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon'; import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId'; import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon'; -import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerLabel'; +import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel'; import { OutputSchema, StepOutputSchema, diff --git a/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts b/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts index a63367b55b..070532713a 100644 --- a/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts +++ b/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts @@ -23,11 +23,14 @@ export const baseWorkflowActionSchema = z.object({ name: z.string(), valid: z.boolean(), nextStepIds: z.array(z.string()).optional().nullable(), + position: z.object({ x: z.number(), y: z.number() }).optional().nullable(), }); export const baseTriggerSchema = z.object({ name: z.string().optional(), type: z.string(), + position: z.object({ x: z.number(), y: z.number() }).optional().nullable(), + nextStepIds: z.array(z.string()).optional().nullable(), }); // Action settings schemas diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramBaseHandle.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramBaseHandle.ts deleted file mode 100644 index 79c271c494..0000000000 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramBaseHandle.ts +++ /dev/null @@ -1,14 +0,0 @@ -import styled from '@emotion/styled'; -import { Handle } from '@xyflow/react'; -import { NODE_HANDLE_HEIGHT_PX } from '@/workflow/workflow-diagram/constants/NodeHandleHeightPx'; -import { NODE_HANDLE_WIDTH_PX } from '@/workflow/workflow-diagram/constants/NodeHandleWidthPx'; -import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth'; - -export const StyledHandle = styled(Handle)` - height: ${NODE_HANDLE_HEIGHT_PX}px; - width: ${NODE_HANDLE_WIDTH_PX}px; - left: ${CREATE_STEP_NODE_WIDTH}px; - visibility: hidden; -`; - -export { StyledHandle as WorkflowDiagramBaseHandle }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramBaseHandle.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramBaseHandle.tsx new file mode 100644 index 0000000000..f96b75f881 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramBaseHandle.tsx @@ -0,0 +1,82 @@ +import { Handle, HandleProps } from '@xyflow/react'; +import styled from '@emotion/styled'; +import { NODE_HANDLE_HEIGHT_PX } from '@/workflow/workflow-diagram/constants/NodeHandleHeightPx'; +import { NODE_HANDLE_WIDTH_PX } from '@/workflow/workflow-diagram/constants/NodeHandleWidthPx'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { FeatureFlagKey } from '~/generated/graphql'; + +type WorkflowDiagramBaseHandleProps = HandleProps & { + isVisible?: boolean; + disableHoverEffect?: boolean; +}; + +const HANDLE_SCALE_ON_HOVER = 1.5; + +const TRANSLATE_PERCENT = 33.33; + +const StyledHandle = styled(Handle, { + shouldForwardProp: (prop) => !['disableHoverEffect'].includes(prop), +})` + // We need !important to avoid passing style with the Handle.style property + height: ${NODE_HANDLE_HEIGHT_PX}px !important; + width: ${NODE_HANDLE_WIDTH_PX}px !important; + border-color: ${({ theme }) => theme.border.color.strong} !important; + background: ${({ theme }) => theme.background.primary} !important; + transition: + transform 0.1s ease-out, + background 0.1s, + border-color 0.1s !important; + z-index: 1 !important; + + ${({ position }) => { + switch (position) { + case 'top': + return `top: 19px !important;`; + case 'bottom': + return `top: 47px !important;`; + default: + return ''; + } + }} + + ${({ disableHoverEffect, theme, position }) => { + if (disableHoverEffect === true) { + return ''; + } + + let transform = `transform: scale(${HANDLE_SCALE_ON_HOVER}) !important;`; + + if (position === 'top') { + transform = `transform: scale(${HANDLE_SCALE_ON_HOVER}) translate(${-TRANSLATE_PERCENT}%, ${-TRANSLATE_PERCENT}%) !important;`; + } else if (position === 'bottom') { + transform = `transform: scale(${HANDLE_SCALE_ON_HOVER}) translate(${-TRANSLATE_PERCENT}%, ${TRANSLATE_PERCENT * 2}%) !important;`; + } + + return ` + &:hover { + border-color: ${theme.font.color.light} !important; + background: ${theme.background.secondary} !important; + ${transform} + } + `; + }} +`; + +export const WorkflowDiagramBaseHandle = ({ + type, + position, + isVisible = true, +}: WorkflowDiagramBaseHandleProps) => { + const isWorkflowBranchEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_WORKFLOW_BRANCH_ENABLED, + ); + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramBlankEdge.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramBlankEdge.tsx index 7c832cd327..1b70e28884 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramBlankEdge.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramBlankEdge.tsx @@ -1,7 +1,6 @@ -import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth'; import { WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; import { useTheme } from '@emotion/react'; -import { BaseEdge, EdgeProps, getStraightPath } from '@xyflow/react'; +import { BaseEdge, EdgeProps, getBezierPath } from '@xyflow/react'; type WorkflowDiagramBlankEdgeProps = EdgeProps; @@ -9,14 +8,16 @@ export const WorkflowDiagramBlankEdge = ({ markerStart, markerEnd, sourceY, + sourceX, targetY, + targetX, }: WorkflowDiagramBlankEdgeProps) => { const theme = useTheme(); - const [edgePath] = getStraightPath({ - sourceX: CREATE_STEP_NODE_WIDTH, + const [edgePath] = getBezierPath({ + sourceX, sourceY, - targetX: CREATE_STEP_NODE_WIDTH, + targetX, targetY, }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase.tsx index 67c754ff9c..35089e074c 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase.tsx @@ -18,7 +18,6 @@ import { WorkflowDiagramNode, WorkflowDiagramNodeType, } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; -import { getOrganizedDiagram } from '@/workflow/workflow-diagram/utils/getOrganizedDiagram'; import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; @@ -33,13 +32,27 @@ import { applyEdgeChanges, applyNodeChanges, useReactFlow, + Connection, + OnNodeDrag, + OnBeforeDelete, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useRecoilCallback } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; import { Tag, TagColor } from 'twenty-ui/components'; import { THEME_COMMON } from 'twenty-ui/theme'; +import { WorkflowDiagramRightClickCommandMenu } from '@/workflow/workflow-diagram/components/WorkflowDiagramRightClickCommandMenu'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { FeatureFlagKey } from '~/generated/graphql'; +import { getOrganizedDiagram } from '@/workflow/workflow-diagram/utils/getOrganizedDiagram'; const StyledResetReactflowStyles = styled.div` height: 100%; @@ -98,6 +111,11 @@ export const WorkflowDiagramCanvasBase = ({ tagColor, tagText, onInit, + onConnect, + onDeleteEdge, + onNodeDragStop, + handlePaneContextMenu, + nodesConnectable = false, }: { nodeTypes: Partial< Record< @@ -126,6 +144,19 @@ export const WorkflowDiagramCanvasBase = ({ tagColor: TagColor; tagText: string; onInit?: () => void; + onConnect?: (params: Connection) => void; + onDeleteEdge?: (edge: WorkflowDiagramEdge) => void; + onNodeDragStop?: OnNodeDrag; + nodesConnectable?: boolean; + handlePaneContextMenu?: ({ + x, + y, + event, + }: { + x: number; + y: number; + event: MouseEvent | React.MouseEvent; + }) => void; }) => { const theme = useTheme(); @@ -157,16 +188,22 @@ export const WorkflowDiagramCanvasBase = ({ workflowDiagramWaitingNodesDimensionsComponentState, ); + const isWorkflowBranchEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_WORKFLOW_BRANCH_ENABLED, + ); + const [workflowDiagramFlowInitialized, setWorkflowDiagramFlowInitialized] = useState(false); - const { nodes, edges } = useMemo( - () => - isDefined(workflowDiagram) - ? getOrganizedDiagram(workflowDiagram) - : { nodes: [], edges: [] }, - [workflowDiagram], - ); + const { nodes, edges } = useMemo(() => { + if (isDefined(workflowDiagram)) { + if (isWorkflowBranchEnabled) { + return workflowDiagram; + } + return getOrganizedDiagram(workflowDiagram); + } + return { nodes: [], edges: [] }; + }, [workflowDiagram, isWorkflowBranchEnabled]); const { rightDrawerState } = useRightDrawerState(); const { isInRightDrawer } = useContext(ActionMenuContext); @@ -195,6 +232,7 @@ export const WorkflowDiagramCanvasBase = ({ setWorkflowInsertStepIds({ parentStepId: undefined, nextStepId: undefined, + position: undefined, }); setWorkflowSelectedNode(undefined); }); @@ -368,6 +406,36 @@ export const WorkflowDiagramCanvasBase = ({ ], ); + const onBeforeDelete: OnBeforeDelete< + WorkflowDiagramNode, + WorkflowDiagramEdge + > = async (diagram) => { + if ( + diagram.nodes.length === 0 // We don't call deleteEdge when node diagram deletion is called + ) { + for (const edge of diagram.edges) { + onDeleteEdge?.(edge); + } + return diagram; + } + return false; + }; + + const onPaneContextMenu = useCallback( + (event: MouseEvent | React.MouseEvent) => { + event.preventDefault(); + + const bounds = containerRef.current?.getBoundingClientRect(); + if (!bounds) return; + + const x = event.clientX - bounds.left; + const y = event.clientY - bounds.top; + + handlePaneContextMenu?.({ x, y, event }); + }, + [handlePaneContextMenu], + ); + return ( @@ -383,17 +451,23 @@ export const WorkflowDiagramCanvasBase = ({ edges={edges} onNodesChange={handleNodesChanges} onEdgesChange={handleEdgesChange} - onBeforeDelete={async () => { - // Abort all non-programmatic deletions - return false; - }} + onConnect={isWorkflowBranchEnabled ? onConnect : undefined} + onNodeDragStop={isWorkflowBranchEnabled ? onNodeDragStop : undefined} + onBeforeDelete={ + isWorkflowBranchEnabled ? onBeforeDelete : async () => false + } + selectNodesOnDrag={false} proOptions={{ hideAttribution: true }} multiSelectionKeyCode={null} nodesFocusable={false} - edgesFocusable={false} - nodesDraggable={false} + edgesFocusable={ + isWorkflowBranchEnabled ? isDefined(onDeleteEdge) : false + } panOnDrag={workflowDiagramPanOnDrag} - nodesConnectable={false} + onPaneContextMenu={ + isWorkflowBranchEnabled ? onPaneContextMenu : undefined + } + nodesConnectable={isWorkflowBranchEnabled ? nodesConnectable : false} paneClickDistance={10} // Fix small unwanted user dragging does not select node preventScrolling={false} > @@ -402,6 +476,10 @@ export const WorkflowDiagramCanvasBase = ({ {children} + {isDefined(handlePaneContextMenu) && isWorkflowBranchEnabled && ( + + )} + diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditable.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditable.tsx index e0b5a172ed..631ff162e0 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditable.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditable.tsx @@ -1,31 +1,123 @@ -import { WorkflowVersionStatus } from '@/workflow/types/Workflow'; +import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow'; import { WorkflowDiagramBlankEdge } from '@/workflow/workflow-diagram/components/WorkflowDiagramBlankEdge'; import { WorkflowDiagramCanvasBase } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase'; import { WorkflowDiagramCanvasEditableEffect } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditableEffect'; -import { WorkflowDiagramCreateStepNode } from '@/workflow/workflow-diagram/components/WorkflowDiagramCreateStepNode'; import { WorkflowDiagramDefaultEdgeEditable } from '@/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdgeEditable'; import { WorkflowDiagramEmptyTrigger } from '@/workflow/workflow-diagram/components/WorkflowDiagramEmptyTrigger'; import { WorkflowDiagramFilterEdgeEditable } from '@/workflow/workflow-diagram/components/WorkflowDiagramFilterEdgeEditable'; import { WorkflowDiagramFilteringDisabledEdgeEditable } from '@/workflow/workflow-diagram/components/WorkflowDiagramFilteringDisabledEdgeEditable'; import { WorkflowDiagramStepNodeEditable } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeEditable'; import { getWorkflowVersionStatusTagProps } from '@/workflow/workflow-diagram/utils/getWorkflowVersionStatusTagProps'; -import { ReactFlowProvider } from '@xyflow/react'; +import { addEdge, Connection, ReactFlowProvider } from '@xyflow/react'; +import { useCreateEdge } from '@/workflow/workflow-steps/hooks/useCreateEdge'; +import { isDefined } from 'twenty-shared/utils'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; +import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState'; +import React from 'react'; +import { useUpdateStep } from '@/workflow/workflow-steps/hooks/useUpdateStep'; +import { + WorkflowDiagramEdge, + WorkflowDiagramNode, +} from '@/workflow/workflow-diagram/types/WorkflowDiagram'; +import { useUpdateWorkflowVersionTrigger } from '@/workflow/workflow-trigger/hooks/useUpdateWorkflowVersionTrigger'; +import { workflowDiagramRightClickMenuPositionState } from '@/workflow/workflow-diagram/states/workflowDiagramRightClickMenuPositionState'; +import { useDeleteEdge } from '@/workflow/workflow-steps/hooks/useDeleteEdge'; export const WorkflowDiagramCanvasEditable = ({ - versionStatus, + workflowWithCurrentVersion, }: { - versionStatus: WorkflowVersionStatus; + workflowWithCurrentVersion: WorkflowWithCurrentVersion; }) => { const tagProps = getWorkflowVersionStatusTagProps({ - workflowVersionStatus: versionStatus, + workflowVersionStatus: workflowWithCurrentVersion.currentVersion.status, }); + const setWorkflowDiagram = useSetRecoilComponentStateV2( + workflowDiagramComponentState, + ); + + const setWorkflowDiagramRightClickMenuPosition = useSetRecoilComponentStateV2( + workflowDiagramRightClickMenuPositionState, + ); + + const { createEdge } = useCreateEdge({ + workflow: workflowWithCurrentVersion, + }); + + const { deleteEdge } = useDeleteEdge({ + workflow: workflowWithCurrentVersion, + }); + + const { updateStep } = useUpdateStep({ + workflow: workflowWithCurrentVersion, + }); + + const { updateTrigger } = useUpdateWorkflowVersionTrigger({ + workflow: workflowWithCurrentVersion, + }); + + const onConnect = (edgeConnect: Connection) => { + setWorkflowDiagram((diagram) => { + if (isDefined(diagram) === false) { + throw new Error( + 'It must be impossible for the edges to be updated if the diagram is not defined yet. Be sure the diagram is rendered only when defined.', + ); + } + + return { + ...diagram, + edges: addEdge(edgeConnect, diagram.edges), + }; + }); + createEdge?.(edgeConnect); + }; + + const onDeleteEdge = async (edge: WorkflowDiagramEdge) => { + await deleteEdge(edge); + }; + + const onNodeDragStop = async ( + _: React.MouseEvent, + node: WorkflowDiagramNode, + ) => { + const stepToUpdate = + workflowWithCurrentVersion?.currentVersion?.steps?.find( + (step) => step.id === node.id, + ); + + if (isDefined(stepToUpdate)) { + await updateStep({ + ...stepToUpdate, + position: node.position, + }); + + return; + } + + const triggerToUpdate = workflowWithCurrentVersion?.currentVersion?.trigger; + + if (isDefined(triggerToUpdate)) { + await updateTrigger({ + ...triggerToUpdate, + position: node.position, + }); + + return; + } + }; + + const handlePaneContextMenu = ({ x, y }: { x: number; y: number }) => { + setWorkflowDiagramRightClickMenuPosition({ + x, + y, + }); + }; + return ( diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditableEffect.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditableEffect.tsx index f3db904b44..42c74cec57 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditableEffect.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditableEffect.tsx @@ -9,7 +9,6 @@ import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/ import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState'; import { EMPTY_TRIGGER_STEP_ID } from '@/workflow/workflow-diagram/constants/EmptyTriggerStepId'; -import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation'; import { useTriggerNodeSelection } from '@/workflow/workflow-diagram/hooks/useTriggerNodeSelection'; import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState'; import { @@ -17,14 +16,12 @@ import { WorkflowDiagramStepNodeData, } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey'; -import { isCreateStepNode } from '@/workflow/workflow-diagram/utils/isCreateStepNode'; import { OnSelectionChangeParams, useOnSelectionChange } from '@xyflow/react'; import { isDefined } from 'twenty-shared/utils'; import { useIcons } from 'twenty-ui/display'; export const WorkflowDiagramCanvasEditableEffect = () => { const { getIcon } = useIcons(); - const { startNodeCreation } = useStartNodeCreation(); const { openWorkflowTriggerTypeInCommandMenu, @@ -67,15 +64,6 @@ export const WorkflowDiagramCanvasEditableEffect = () => { return; } - if (isCreateStepNode(selectedNode)) { - startNodeCreation({ - parentStepId: selectedNode.data.parentNodeId, - nextStepId: undefined, - }); - - return; - } - const selectedNodeData = selectedNode.data as WorkflowDiagramStepNodeData; setWorkflowSelectedNode(selectedNode.id); @@ -95,7 +83,6 @@ export const WorkflowDiagramCanvasEditableEffect = () => { setCommandMenuNavigationStack, workflowVisualizerWorkflowId, openWorkflowTriggerTypeInCommandMenu, - startNodeCreation, openWorkflowEditStepInCommandMenu, getIcon, setWorkflowSelectedNode, diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramConnector.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramConnector.tsx new file mode 100644 index 0000000000..03fa2128a6 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramConnector.tsx @@ -0,0 +1,21 @@ +import { useTheme } from '@emotion/react'; + +export const WorkflowDiagramConnector = () => { + const theme = useTheme(); + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCreateStepElement.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCreateStepElement.tsx new file mode 100644 index 0000000000..c8d96f7652 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCreateStepElement.tsx @@ -0,0 +1,59 @@ +import styled from '@emotion/styled'; +import { IconPlus } from 'twenty-ui/display'; +import { IconButton } from 'twenty-ui/input'; +import { WorkflowDiagramConnector } from '@/workflow/workflow-diagram/components/WorkflowDiagramConnector'; +import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation'; +import React from 'react'; +import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; +import { VERTICAL_DISTANCE_BETWEEN_TWO_NODES } from '@/workflow/workflow-diagram/constants/VerticalDistanceBetweenTwoNodes'; + +const StyledContainer = styled.div` + align-items: center; + display: flex; + flex-direction: column; +`; + +const StyledVerticalLineContainer = styled.div` + display: flex; + height: 56px; + justify-content: center; + width: 56px; +`; + +type WorkflowDiagramCreateStepElementProps = { + data: WorkflowDiagramStepNodeData; +}; + +export const WorkflowDiagramCreateStepElement = ({ + data, +}: WorkflowDiagramCreateStepElementProps) => { + const { startNodeCreation } = useStartNodeCreation(); + + const addNode = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + startNodeCreation({ + parentStepId: data.stepId, + nextStepId: undefined, + position: { + x: data.position.x, + y: data.position.y + VERTICAL_DISTANCE_BETWEEN_TWO_NODES, + }, + }); + }; + + return ( + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCreateStepNode.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCreateStepNode.tsx deleted file mode 100644 index dee203250a..0000000000 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCreateStepNode.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { WorkflowDiagramBaseHandle } from '@/workflow/workflow-diagram/components/WorkflowDiagramBaseHandle'; -import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth'; -import { WORKFLOW_DIAGRAM_CREATE_STEP_NODE_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramCreateStepNodeClickOutsideId'; -import styled from '@emotion/styled'; -import { Position } from '@xyflow/react'; -import { IconPlus } from 'twenty-ui/display'; -import { IconButton } from 'twenty-ui/input'; - -const StyledContainer = styled.div` - left: ${CREATE_STEP_NODE_WIDTH / 2}px; - padding-top: ${({ theme }) => theme.spacing(3)}; - position: relative; -`; - -export const WorkflowDiagramCreateStepNode = () => { - return ( - - - - - - ); -}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdgeEditable.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdgeEditable.tsx index 4d9d866068..d98351d51c 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdgeEditable.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdgeEditable.tsx @@ -3,7 +3,6 @@ import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithC import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState'; import { WorkflowDiagramEdgeV2Container } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2Container'; import { WorkflowDiagramEdgeV2VisibilityContainer } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2VisibilityContainer'; -import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth'; import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEdgeOptionsClickOutsideId'; import { useOpenWorkflowEditFilterInCommandMenu } from '@/workflow/workflow-diagram/hooks/useOpenWorkflowEditFilterInCommandMenu'; import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation'; @@ -16,7 +15,7 @@ import { BaseEdge, EdgeLabelRenderer, EdgeProps, - getStraightPath, + getBezierPath, } from '@xyflow/react'; import { useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; @@ -32,17 +31,19 @@ const StyledIconButtonGroup = styled(IconButtonGroup)` export const WorkflowDiagramDefaultEdgeEditable = ({ source, target, + sourceX, sourceY, + targetX, targetY, markerStart, markerEnd, }: WorkflowDiagramDefaultEdgeEditableProps) => { const theme = useTheme(); - const [edgePath, labelX, labelY] = getStraightPath({ - sourceX: CREATE_STEP_NODE_WIDTH, + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, sourceY, - targetX: CREATE_STEP_NODE_WIDTH, + targetX, targetY, }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdgeReadonly.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdgeReadonly.tsx index 19fd8458cc..f942ccc0d2 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdgeReadonly.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdgeReadonly.tsx @@ -1,22 +1,23 @@ -import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth'; import { WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; import { useTheme } from '@emotion/react'; -import { BaseEdge, EdgeProps, getStraightPath } from '@xyflow/react'; +import { BaseEdge, EdgeProps, getBezierPath } from '@xyflow/react'; type WorkflowDiagramDefaultEdgeReadonlyProps = EdgeProps; export const WorkflowDiagramDefaultEdgeReadonly = ({ + sourceX, sourceY, + targetX, targetY, markerStart, markerEnd, }: WorkflowDiagramDefaultEdgeReadonlyProps) => { const theme = useTheme(); - const [edgePath] = getStraightPath({ - sourceX: CREATE_STEP_NODE_WIDTH, + const [edgePath] = getBezierPath({ + sourceX, sourceY, - targetX: CREATE_STEP_NODE_WIDTH, + targetX, targetY, }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdgeRun.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdgeRun.tsx index da2b8182cf..2aee9d5932 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdgeRun.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdgeRun.tsx @@ -1,19 +1,20 @@ import { WorkflowRunDiagramBaseEdge } from '@/workflow/workflow-diagram/components/WorkflowRunDiagramBaseEdge'; -import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth'; import { WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; -import { EdgeProps, getStraightPath } from '@xyflow/react'; +import { EdgeProps, getBezierPath } from '@xyflow/react'; type WorkflowDiagramDefaultEdgeRunProps = EdgeProps; export const WorkflowDiagramDefaultEdgeRun = ({ + sourceX, sourceY, + targetX, targetY, data, }: WorkflowDiagramDefaultEdgeRunProps) => { - const [edgePath] = getStraightPath({ - sourceX: CREATE_STEP_NODE_WIDTH, + const [edgePath] = getBezierPath({ + sourceX, sourceY, - targetX: CREATE_STEP_NODE_WIDTH, + targetX, targetY, }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEffect.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEffect.tsx index 65af04fb89..9cd27213e3 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEffect.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEffect.tsx @@ -10,7 +10,6 @@ import { } from '@/workflow/types/Workflow'; import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState'; -import { addCreateStepNodes } from '@/workflow/workflow-diagram/utils/addCreateStepNodes'; import { getWorkflowVersionDiagram } from '@/workflow/workflow-diagram/utils/getWorkflowVersionDiagram'; import { mergeWorkflowDiagrams } from '@/workflow/workflow-diagram/utils/mergeWorkflowDiagrams'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; @@ -49,13 +48,11 @@ export const WorkflowDiagramEffect = ({ workflowDiagramState, ); - const nextWorkflowDiagram = addCreateStepNodes( - getWorkflowVersionDiagram({ - workflowVersion: currentVersion, - isWorkflowFilteringEnabled, - isEditable: true, - }), - ); + const nextWorkflowDiagram = getWorkflowVersionDiagram({ + workflowVersion: currentVersion, + isWorkflowFilteringEnabled, + isEditable: true, + }); let mergedWorkflowDiagram = nextWorkflowDiagram; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramFilterEdgeEditable.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramFilterEdgeEditable.tsx index 5b9a47ec2e..269dadcdcb 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramFilterEdgeEditable.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramFilterEdgeEditable.tsx @@ -11,7 +11,6 @@ import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithC import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState'; import { WorkflowDiagramEdgeV2Container } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2Container'; import { WorkflowDiagramEdgeV2VisibilityContainer } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2VisibilityContainer'; -import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth'; import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEdgeOptionsClickOutsideId'; import { useOpenWorkflowEditFilterInCommandMenu } from '@/workflow/workflow-diagram/hooks/useOpenWorkflowEditFilterInCommandMenu'; import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation'; @@ -23,7 +22,6 @@ import { } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; import { getWorkflowDiagramNodeSelectedColors } from '@/workflow/workflow-diagram/utils/getWorkflowDiagramNodeSelectedColors'; import { useDeleteStep } from '@/workflow/workflow-steps/hooks/useDeleteStep'; -import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState'; import { css, useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { isNonEmptyString } from '@sniptt/guards'; @@ -31,7 +29,7 @@ import { BaseEdge, EdgeLabelRenderer, EdgeProps, - getStraightPath, + getBezierPath, } from '@xyflow/react'; import { useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; @@ -78,7 +76,9 @@ export const WorkflowDiagramFilterEdgeEditable = ({ source, target, sourceY, + sourceX, targetY, + targetX, markerStart, markerEnd, data, @@ -87,10 +87,10 @@ export const WorkflowDiagramFilterEdgeEditable = ({ const theme = useTheme(); - const [edgePath, labelX, labelY] = getStraightPath({ - sourceX: CREATE_STEP_NODE_WIDTH, + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, sourceY, - targetX: CREATE_STEP_NODE_WIDTH, + targetX, targetY, }); @@ -100,7 +100,7 @@ export const WorkflowDiagramFilterEdgeEditable = ({ const workflow = useWorkflowWithCurrentVersion(workflowVisualizerWorkflowId); const { deleteStep } = useDeleteStep({ workflow }); - const { startNodeCreation } = useStartNodeCreation(); + const { startNodeCreation, isNodeCreationStarted } = useStartNodeCreation(); const { openDropdown } = useOpenDropdown(); const { closeDropdown } = useCloseDropdown(); @@ -111,15 +111,10 @@ export const WorkflowDiagramFilterEdgeEditable = ({ workflowDiagramPanOnDragComponentState, ); - const workflowInsertStepIds = useRecoilComponentValueV2( - workflowInsertStepIdsComponentState, - ); - - const isEdgeSelected = - (workflowInsertStepIds.nextStepId === source && - workflowInsertStepIds.parentStepId === target) || - (isNonEmptyString(data.stepId) && - workflowInsertStepIds.parentStepId === data.stepId); + const isEdgeSelected = isNodeCreationStarted({ + parentStepId: data.stepId, + nextStepId: target, + }); const workflowSelectedNode = useRecoilComponentValueV2( workflowSelectedNodeComponentState, diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramFilterEdgeReadonly.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramFilterEdgeReadonly.tsx index 45547e6489..ec751c8bd3 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramFilterEdgeReadonly.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramFilterEdgeReadonly.tsx @@ -1,7 +1,6 @@ import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { WorkflowDiagramEdgeV2Container } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2Container'; import { WorkflowDiagramEdgeV2VisibilityContainer } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2VisibilityContainer'; -import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth'; import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEdgeOptionsClickOutsideId'; import { useOpenWorkflowViewFilterInCommandMenu } from '@/workflow/workflow-diagram/hooks/useOpenWorkflowViewFilterInCommandMenu'; import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState'; @@ -17,7 +16,7 @@ import { BaseEdge, EdgeLabelRenderer, EdgeProps, - getStraightPath, + getBezierPath, } from '@xyflow/react'; import { IconFilter } from 'twenty-ui/display'; import { IconButtonGroup } from 'twenty-ui/input'; @@ -53,7 +52,9 @@ const StyledConfiguredFilterContainer = styled.div` `; export const WorkflowDiagramFilterEdgeReadonly = ({ + sourceX, sourceY, + targetX, targetY, markerStart, markerEnd, @@ -63,10 +64,10 @@ export const WorkflowDiagramFilterEdgeReadonly = ({ const theme = useTheme(); - const [edgePath, labelX, labelY] = getStraightPath({ - sourceX: CREATE_STEP_NODE_WIDTH, + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, sourceY, - targetX: CREATE_STEP_NODE_WIDTH, + targetX, targetY, }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramFilterEdgeRun.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramFilterEdgeRun.tsx index 93116b164f..5d104f2374 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramFilterEdgeRun.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramFilterEdgeRun.tsx @@ -2,7 +2,6 @@ import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/ import { WorkflowDiagramEdgeV2Container } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2Container'; import { WorkflowDiagramEdgeV2VisibilityContainer } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2VisibilityContainer'; import { WorkflowRunDiagramBaseEdge } from '@/workflow/workflow-diagram/components/WorkflowRunDiagramBaseEdge'; -import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth'; import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEdgeOptionsClickOutsideId'; import { useOpenWorkflowRunFilterInCommandMenu } from '@/workflow/workflow-diagram/hooks/useOpenWorkflowRunFilterInCommandMenu'; import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState'; @@ -16,7 +15,7 @@ import { getWorkflowDiagramNodeSelectedColors } from '@/workflow/workflow-diagra import { css } from '@emotion/react'; import styled from '@emotion/styled'; import { isNonEmptyString } from '@sniptt/guards'; -import { EdgeLabelRenderer, EdgeProps, getStraightPath } from '@xyflow/react'; +import { EdgeLabelRenderer, EdgeProps, getBezierPath } from '@xyflow/react'; import { isDefined } from 'twenty-shared/utils'; import { IconFilter } from 'twenty-ui/display'; import { IconButtonGroup } from 'twenty-ui/input'; @@ -55,16 +54,18 @@ const StyledConfiguredFilterContainer = styled.div` `; export const WorkflowDiagramFilterEdgeRun = ({ + sourceX, sourceY, + targetX, targetY, data, }: WorkflowDiagramFilterEdgeRunProps) => { assertFilterEdgeDataOrThrow(data); - const [edgePath, labelX, labelY] = getStraightPath({ - sourceX: CREATE_STEP_NODE_WIDTH, + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, sourceY, - targetX: CREATE_STEP_NODE_WIDTH, + targetX, targetY, }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramFilteringDisabledEdgeEditable.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramFilteringDisabledEdgeEditable.tsx index c3929b3276..dcbeaee3b2 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramFilteringDisabledEdgeEditable.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramFilteringDisabledEdgeEditable.tsx @@ -1,45 +1,24 @@ -import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; -import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth'; import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEdgeOptionsClickOutsideId'; import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation'; import { WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; -import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { BaseEdge, EdgeLabelRenderer, EdgeProps, - getStraightPath, + getBezierPath, } from '@xyflow/react'; import { useState } from 'react'; import { IconPlus } from 'twenty-ui/display'; import { IconButtonGroup } from 'twenty-ui/input'; +import { WorkflowDiagramEdgeV2Container } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2Container'; +import { WorkflowDiagramEdgeV2VisibilityContainer } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2VisibilityContainer'; const StyledIconButtonGroup = styled(IconButtonGroup)` pointer-events: all; `; -const StyledContainer = styled.div<{ - labelY?: number; -}>` - position: absolute; - transform: ${({ labelY }) => `translate(${21}px, ${(labelY || 0) - 14}px)`}; -`; - -const StyledHoverZone = styled.div` - position: absolute; - width: 48px; - height: 52px; - transform: translate(-13px, -16px); - background: transparent; -`; - -const StyledWrapper = styled.div` - pointer-events: all; - position: relative; -`; - type WorkflowDiagramFilteringDisabledEdgeEditableProps = EdgeProps; @@ -48,29 +27,36 @@ export const WorkflowDiagramFilteringDisabledEdgeEditable = ({ markerEnd, source, sourceY, + sourceX, target, + targetX, targetY, }: WorkflowDiagramFilteringDisabledEdgeEditableProps) => { const theme = useTheme(); - const [edgePath, , labelY] = getStraightPath({ - sourceX: CREATE_STEP_NODE_WIDTH, + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, sourceY, - targetX: CREATE_STEP_NODE_WIDTH, + targetX, targetY, }); const [hovered, setHovered] = useState(false); - const { startNodeCreation } = useStartNodeCreation(); + const handleMouseEnter = () => { + setHovered(true); + }; - const workflowInsertStepIds = useRecoilComponentValueV2( - workflowInsertStepIdsComponentState, - ); + const handleMouseLeave = () => { + setHovered(false); + }; - const isSelected = - workflowInsertStepIds.parentStepId === source && - workflowInsertStepIds.nextStepId === target; + const { startNodeCreation, isNodeCreationStarted } = useStartNodeCreation(); + + const forceDisplayAddButton = isNodeCreationStarted({ + parentStepId: source, + nextStepId: target, + }); return ( <> @@ -82,33 +68,32 @@ export const WorkflowDiagramFilteringDisabledEdgeEditable = ({ /> - - setHovered(true)} - onMouseLeave={() => setHovered(false)} + - - {(hovered || isSelected) && ( - { - startNodeCreation({ - parentStepId: source, - nextStepId: target, - }); - }, + { + startNodeCreation({ + parentStepId: source, + nextStepId: target, + }); }, - ]} - /> - )} - - + }, + ]} + /> + + ); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramFilteringDisabledEdgeReadonly.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramFilteringDisabledEdgeReadonly.tsx index 79f2284031..7acad8ad6e 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramFilteringDisabledEdgeReadonly.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramFilteringDisabledEdgeReadonly.tsx @@ -1,7 +1,6 @@ -import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth'; import { WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; import { useTheme } from '@emotion/react'; -import { BaseEdge, EdgeProps, getStraightPath } from '@xyflow/react'; +import { BaseEdge, EdgeProps, getBezierPath } from '@xyflow/react'; type WorkflowDiagramFilteringDisabledEdgeReadonlyProps = EdgeProps; @@ -9,15 +8,17 @@ type WorkflowDiagramFilteringDisabledEdgeReadonlyProps = export const WorkflowDiagramFilteringDisabledEdgeReadonly = ({ markerStart, markerEnd, + sourceX, sourceY, + targetX, targetY, }: WorkflowDiagramFilteringDisabledEdgeReadonlyProps) => { const theme = useTheme(); - const [edgePath] = getStraightPath({ - sourceX: CREATE_STEP_NODE_WIDTH, + const [edgePath] = getBezierPath({ + sourceX, sourceY, - targetX: CREATE_STEP_NODE_WIDTH, + targetX, targetY, }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramFilteringDisabledEdgeRun.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramFilteringDisabledEdgeRun.tsx index be691c9aa8..24c2cf4df9 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramFilteringDisabledEdgeRun.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramFilteringDisabledEdgeRun.tsx @@ -1,20 +1,21 @@ import { WorkflowRunDiagramBaseEdge } from '@/workflow/workflow-diagram/components/WorkflowRunDiagramBaseEdge'; -import { CREATE_STEP_NODE_WIDTH } from '@/workflow/workflow-diagram/constants/CreateStepNodeWidth'; import { WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; -import { EdgeProps, getStraightPath } from '@xyflow/react'; +import { EdgeProps, getBezierPath } from '@xyflow/react'; type WorkflowDiagramFilteringDisabledEdgeRunProps = EdgeProps; export const WorkflowDiagramFilteringDisabledEdgeRun = ({ + sourceX, sourceY, + targetX, targetY, data, }: WorkflowDiagramFilteringDisabledEdgeRunProps) => { - const [edgePath] = getStraightPath({ - sourceX: CREATE_STEP_NODE_WIDTH, + const [edgePath] = getBezierPath({ + sourceX, sourceY, - targetX: CREATE_STEP_NODE_WIDTH, + targetX, targetY, }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramRightClickCommandMenu.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramRightClickCommandMenu.tsx new file mode 100644 index 0000000000..1c07d277fd --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramRightClickCommandMenu.tsx @@ -0,0 +1,90 @@ +import styled from '@emotion/styled'; +import { workflowDiagramRightClickMenuPositionState } from '@/workflow/workflow-diagram/states/workflowDiagramRightClickMenuPositionState'; +import { isDefined } from 'twenty-shared/utils'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useRef } from 'react'; +import { WorkflowDiagramRightClickCommandMenuClickOutsideEffect } from './WorkflowDiagramRightClickCommandMenuClickOutsideEffect'; +import { IconPlus, IconReorder } from 'twenty-ui/display'; +import { MenuItem } from 'twenty-ui/navigation'; +import { useLingui } from '@lingui/react/macro'; +import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState'; +import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; +import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation'; +import { useCloseRightClickMenu } from '@/workflow/workflow-diagram/hooks/useCloseRightClickMenu'; +import { useTidyUpWorkflowVersion } from '@/workflow/workflow-version/hooks/useTidyUpWorkflowVersion'; + +const StyledContainer = styled.div<{ x: number; y: number }>` + background: ${({ theme }) => theme.background.primary}; + box-shadow: ${({ theme }) => theme.boxShadow.strong}; + border-radius: ${({ theme }) => theme.spacing(2)}; + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(0.5)}; + left: ${({ x }) => `${x}px`}; + padding: ${({ theme }) => theme.spacing(1)}; + position: absolute; + top: ${({ y }) => `${y}px`}; + width: 200px; +`; + +export const WorkflowDiagramRightClickCommandMenu = () => { + const { t } = useLingui(); + const rightClickCommandMenuRef = useRef(null); + + const { startNodeCreation } = useStartNodeCreation(); + + const { closeRightClickMenu } = useCloseRightClickMenu(); + + const workflowDiagramRightClickMenuPosition = useRecoilComponentValueV2( + workflowDiagramRightClickMenuPositionState, + ); + + const workflowVisualizerWorkflowId = useRecoilComponentValueV2( + workflowVisualizerWorkflowIdComponentState, + ); + + const workflowWithCurrentVersion = useWorkflowWithCurrentVersion( + workflowVisualizerWorkflowId, + ); + + const { tidyUpWorkflowVersion } = useTidyUpWorkflowVersion({ + workflow: workflowWithCurrentVersion, + }); + + const handleReorderWorkflowDiagram = async () => { + await tidyUpWorkflowVersion(); + closeRightClickMenu(); + }; + + const addNode = () => { + startNodeCreation({ + parentStepId: undefined, + nextStepId: undefined, + position: workflowDiagramRightClickMenuPosition, + }); + }; + + if (!isDefined(workflowDiagramRightClickMenuPosition)) { + return; + } + + return ( + <> + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramRightClickCommandMenuClickOutsideEffect.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramRightClickCommandMenuClickOutsideEffect.tsx new file mode 100644 index 0000000000..abdd13c846 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramRightClickCommandMenuClickOutsideEffect.tsx @@ -0,0 +1,30 @@ +import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; +import { WORKFLOW_DIAGRAM_RIGHT_CLICK_MENU_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramRightClickMenuClickOutsideId'; +import { workflowDiagramRightClickMenuPositionState } from '@/workflow/workflow-diagram/states/workflowDiagramRightClickMenuPositionState'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; +import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandMenuClickOutsideId'; + +export const WorkflowDiagramRightClickCommandMenuClickOutsideEffect = ({ + rightClickCommandMenuRef, +}: { + rightClickCommandMenuRef: React.RefObject; +}) => { + const setWorkflowDiagramRightClickMenuPosition = useSetRecoilComponentStateV2( + workflowDiagramRightClickMenuPositionState, + ); + + useListenClickOutside({ + refs: [rightClickCommandMenuRef], + excludedClickOutsideIds: [COMMAND_MENU_CLICK_OUTSIDE_ID], + callback: (event) => { + event.stopImmediatePropagation(); + event.stopPropagation(); + event.preventDefault(); + + setWorkflowDiagramRightClickMenuPosition(undefined); + }, + listenerId: WORKFLOW_DIAGRAM_RIGHT_CLICK_MENU_CLICK_OUTSIDE_ID, + }); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeBase.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeBase.tsx index d7e6722802..d2f3af508c 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeBase.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeBase.tsx @@ -1,4 +1,3 @@ -import { WorkflowDiagramBaseHandle } from '@/workflow/workflow-diagram/components/WorkflowDiagramBaseHandle'; import { NODE_BORDER_WIDTH } from '@/workflow/workflow-diagram/constants/NodeBorderWidth'; import { WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramStepNodeClickOutsideId'; import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; @@ -6,17 +5,16 @@ import { WorkflowDiagramNodeVariant } from '@/workflow/workflow-diagram/types/Wo import { getWorkflowDiagramNodeSelectedColors } from '@/workflow/workflow-diagram/utils/getWorkflowDiagramNodeSelectedColors'; import { css } from '@emotion/react'; import styled from '@emotion/styled'; -import { Position } from '@xyflow/react'; -import React from 'react'; -import { capitalize, isDefined } from 'twenty-shared/utils'; +import React, { MouseEvent } from 'react'; +import { capitalize } from 'twenty-shared/utils'; import { Label, OverflowingTextWithTooltip } from 'twenty-ui/display'; import { Loader } from 'twenty-ui/feedback'; +import { WorkflowDiagramBaseHandle } from '@/workflow/workflow-diagram/components/WorkflowDiagramBaseHandle'; +import { Position } from '@xyflow/react'; const StyledStepNodeContainer = styled.div` display: flex; flex-direction: column; - - padding-block: ${({ theme }) => theme.spacing(3)}; `; const StyledStepNodeType = styled.div<{ @@ -142,37 +140,41 @@ const StyledStepNodeLabel = styled.div<{ } `; -const StyledRightFloatingElementContainer = styled.div` - display: flex; - align-items: center; - position: absolute; - right: ${({ theme }) => theme.spacing(-4)}; - bottom: 0; - top: 0; - transform: translateX(100%); -`; - export const WorkflowDiagramStepNodeBase = ({ nodeType, name, variant, Icon, RightFloatingElement, + BottomHoverFloatingElement, + displayHandle = true, + onMouseEnter, + onMouseLeave, }: { nodeType: WorkflowDiagramStepNodeData['nodeType']; name: string; variant: WorkflowDiagramNodeVariant; Icon?: React.ReactNode; RightFloatingElement?: React.ReactNode; + BottomHoverFloatingElement?: React.ReactNode; + displayHandle?: boolean; + onMouseEnter?: (event: MouseEvent) => void; + onMouseLeave?: (event: MouseEvent) => void; }) => { return ( - {nodeType !== 'trigger' ? ( - - ) : null} + {nodeType !== 'trigger' && ( + + )} {capitalize(nodeType)} @@ -182,17 +184,19 @@ export const WorkflowDiagramStepNodeBase = ({ {variant === 'running' ? : Icon} - + - {isDefined(RightFloatingElement) ? ( - - {RightFloatingElement} - - ) : null} + {RightFloatingElement} + + {BottomHoverFloatingElement} - + ); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeEditableContent.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeEditableContent.tsx index 5059606a07..adae1f86d4 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeEditableContent.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeEditableContent.tsx @@ -4,6 +4,34 @@ import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/W import { WorkflowDiagramNodeVariant } from '@/workflow/workflow-diagram/types/WorkflowDiagramNodeVariant'; import { FloatingIconButton } from 'twenty-ui/input'; import { IconTrash } from 'twenty-ui/display'; +import { WorkflowDiagramCreateStepElement } from '@/workflow/workflow-diagram/components/WorkflowDiagramCreateStepElement'; +import React, { useState } from 'react'; +import styled from '@emotion/styled'; +import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation'; + +const StyledDeleteButtonContainer = styled.div` + display: flex; + align-items: center; + position: absolute; + right: ${({ theme }) => theme.spacing(-4)}; + bottom: 0; + top: 0; + transform: translateX(100%); +`; + +const StyledAddStepButtonContainer = styled.div<{ + shouldDisplay: boolean; +}>` + display: flex; + align-items: center; + position: absolute; + justify-content: center; + flex-direction: column; + opacity: ${({ shouldDisplay }) => (shouldDisplay ? 1 : 0)}; + left: 50%; + bottom: 0; + transform: translateX(-50%) translateY(100%); +`; export const WorkflowDiagramStepNodeEditableContent = ({ data, @@ -16,20 +44,49 @@ export const WorkflowDiagramStepNodeEditableContent = ({ selected: boolean; onDelete: () => void; }) => { + const [isHovered, setIsHovered] = useState(false); + + const handleMouseEnter = () => setIsHovered(true); + + const handleMouseLeave = () => { + setIsHovered(false); + }; + + const { isNodeCreationStarted } = useStartNodeCreation(); + return ( } RightFloatingElement={ - selected ? ( - - ) : undefined + selected && ( + + + + ) + } + BottomHoverFloatingElement={ + !data.hasNextStepIds && ( + + + + ) } /> ); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeReadonly.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeReadonly.tsx index 6d56234562..efa97912e1 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeReadonly.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeReadonly.tsx @@ -2,6 +2,7 @@ import { WorkflowDiagramStepNodeBase } from '@/workflow/workflow-diagram/compone import { WorkflowDiagramStepNodeIcon } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeIcon'; import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; import { getNodeVariantFromStepRunStatus } from '@/workflow/workflow-diagram/utils/getNodeVariantFromStepRunStatus'; +import React from 'react'; export const WorkflowDiagramStepNodeReadonly = ({ data, @@ -14,6 +15,7 @@ export const WorkflowDiagramStepNodeReadonly = ({ variant={getNodeVariantFromStepRunStatus(data.runStatus)} nodeType={data.nodeType} Icon={} + displayHandle={false} /> ); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowVisualizer.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowVisualizer.tsx index 70d9d4e299..16b1418b62 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowVisualizer.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowVisualizer.tsx @@ -17,7 +17,7 @@ export const WorkflowVisualizer = ({ workflowId }: { workflowId: string }) => { workflowWithCurrentVersion={workflowWithCurrentVersion} /> ); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/__stories__/WorkflowDiagramCreateStepNode.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/__stories__/WorkflowDiagramCreateStepNode.stories.tsx deleted file mode 100644 index 6b00f3fd7c..0000000000 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/__stories__/WorkflowDiagramCreateStepNode.stories.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react'; - -import { ReactFlowProvider } from '@xyflow/react'; -import '@xyflow/react/dist/style.css'; -import { WorkflowDiagramCreateStepNode } from '../WorkflowDiagramCreateStepNode'; -import { ComponentDecorator } from 'twenty-ui/testing'; - -const meta: Meta = { - title: 'Modules/Workflow/WorkflowDiagramCreateStepNode', - component: WorkflowDiagramCreateStepNode, - decorators: [ - ComponentDecorator, - (Story) => ( - - - - ), - ], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - decorators: [ - (Story) => ( -
- -
- ), - ], -}; - -export const Selected: Story = { - decorators: [ - (Story) => ( -
- -
- ), - ], -}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/__stories__/WorkflowDiagramStepNodeEditableContent.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/__stories__/WorkflowDiagramStepNodeEditableContent.stories.tsx index 7d0e63765b..41735ba3c8 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/__stories__/WorkflowDiagramStepNodeEditableContent.stories.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/__stories__/WorkflowDiagramStepNodeEditableContent.stories.tsx @@ -9,6 +9,8 @@ import { CatalogDecorator, CatalogStory } from 'twenty-ui/testing'; import { ReactflowDecorator } from '~/testing/decorators/ReactflowDecorator'; import { graphqlMocks } from '~/testing/graphqlMocks'; import { WorkflowDiagramStepNodeEditableContent } from '../WorkflowDiagramStepNodeEditableContent'; +import { RecoilRoot } from 'recoil'; +import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext'; type ComponentState = 'default' | 'hover' | 'selected'; @@ -37,41 +39,89 @@ const ALL_STEPS = [ nodeType: 'trigger', triggerType: 'DATABASE_EVENT', name: 'Record is Created', + hasNextStepIds: true, + stepId: 'trigger', + position: { + x: 0, + y: 0, + }, }, { nodeType: 'trigger', triggerType: 'MANUAL', name: 'Manual', + hasNextStepIds: true, + stepId: 'step1', + position: { + x: 0, + y: 150, + }, }, { nodeType: 'action', actionType: 'CREATE_RECORD', name: 'Create Record', + hasNextStepIds: true, + stepId: 'step2', + position: { + x: 0, + y: 300, + }, }, { nodeType: 'action', actionType: 'UPDATE_RECORD', name: 'Update Record', + hasNextStepIds: true, + stepId: 'step3', + position: { + x: 0, + y: 450, + }, }, { nodeType: 'action', actionType: 'DELETE_RECORD', name: 'Delete Record', + hasNextStepIds: true, + stepId: 'step4', + position: { + x: 0, + y: 600, + }, }, { nodeType: 'action', actionType: 'SEND_EMAIL', name: 'Send Email', + hasNextStepIds: true, + stepId: 'step5', + position: { + x: 0, + y: 750, + }, }, { nodeType: 'action', actionType: 'CODE', name: 'Code', + hasNextStepIds: true, + stepId: 'step6', + position: { + x: 0, + y: 900, + }, }, { nodeType: 'action', actionType: 'HTTP_REQUEST', name: 'HTTP Request', + hasNextStepIds: true, + stepId: 'step7', + position: { + x: 0, + y: 1050, + }, }, ] satisfies WorkflowDiagramStepNodeData[]; @@ -120,7 +170,13 @@ export const Catalog: CatalogStory = {
- + + + + +
); }, diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/constants/CreateStepStepId.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/constants/CreateStepStepId.ts deleted file mode 100644 index 5c1c154ada..0000000000 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/constants/CreateStepStepId.ts +++ /dev/null @@ -1 +0,0 @@ -export const CREATE_STEP_STEP_ID = 'create-step'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/constants/NodeHandleWidthPx.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/constants/NodeHandleWidthPx.ts index 81ce92f81a..6b499a0048 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/constants/NodeHandleWidthPx.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/constants/NodeHandleWidthPx.ts @@ -1 +1 @@ -export const NODE_HANDLE_WIDTH_PX = 4; +export const NODE_HANDLE_WIDTH_PX = 7; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/constants/WorkflowDiagramEmptyTriggerNodeDefinition.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/constants/WorkflowDiagramEmptyTriggerNodeDefinition.ts index f14241bcb0..67595092fb 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/constants/WorkflowDiagramEmptyTriggerNodeDefinition.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/constants/WorkflowDiagramEmptyTriggerNodeDefinition.ts @@ -7,6 +7,10 @@ export const WORKFLOW_DIAGRAM_EMPTY_TRIGGER_NODE_DEFINITION = { type: 'empty-trigger', data: { nodeType: 'empty-trigger', + position: { + x: 0, + y: 0, + }, } satisfies WorkflowDiagramEmptyTriggerNodeData, position: { x: 0, diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/constants/WorkflowDiagramRightClickMenuClickOutsideId.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/constants/WorkflowDiagramRightClickMenuClickOutsideId.ts new file mode 100644 index 0000000000..3e45fcd82a --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/constants/WorkflowDiagramRightClickMenuClickOutsideId.ts @@ -0,0 +1,2 @@ +export const WORKFLOW_DIAGRAM_RIGHT_CLICK_MENU_CLICK_OUTSIDE_ID = + 'workflow-diagram-right-click-menu-click-outside'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/hooks/__tests__/useTriggerNodeSelection.test.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/hooks/__tests__/useTriggerNodeSelection.test.tsx index bade0f15d5..3ca2ab122f 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/hooks/__tests__/useTriggerNodeSelection.test.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/hooks/__tests__/useTriggerNodeSelection.test.tsx @@ -64,6 +64,8 @@ describe('useTriggerNodeSelection', () => { }); expect(result.current.workflowDiagramTriggerNodeSelection).toBeUndefined(); + + expect(mockSetNodes).toHaveBeenCalled(); }); it('should not trigger update if state is not defined', () => { diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/hooks/useCloseRightClickMenu.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/hooks/useCloseRightClickMenu.ts new file mode 100644 index 0000000000..532affaafc --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/hooks/useCloseRightClickMenu.ts @@ -0,0 +1,14 @@ +import { workflowDiagramRightClickMenuPositionState } from '@/workflow/workflow-diagram/states/workflowDiagramRightClickMenuPositionState'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; + +export const useCloseRightClickMenu = () => { + const setWorkflowDiagramRightClickMenuPosition = useSetRecoilComponentStateV2( + workflowDiagramRightClickMenuPositionState, + ); + + const closeRightClickMenu = () => { + setWorkflowDiagramRightClickMenuPosition(undefined); + }; + + return { closeRightClickMenu }; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/hooks/useStartNodeCreation.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/hooks/useStartNodeCreation.ts index e73d4a07d7..2d0fbeeead 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/hooks/useStartNodeCreation.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/hooks/useStartNodeCreation.ts @@ -2,15 +2,14 @@ import { useCallback } from 'react'; import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandMenu'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; -import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState'; import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState'; import { isDefined } from 'twenty-shared/utils'; +import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; export const useStartNodeCreation = () => { - const setWorkflowInsertStepIds = useSetRecoilComponentStateV2( - workflowInsertStepIdsComponentState, - ); + const [workflowInsertStepIds, setWorkflowInsertStepIds] = + useRecoilComponentStateV2(workflowInsertStepIdsComponentState); const { openStepSelectInCommandMenu } = useWorkflowCommandMenu(); @@ -26,11 +25,13 @@ export const useStartNodeCreation = () => { ({ parentStepId, nextStepId, + position, }: { parentStepId: string | undefined; nextStepId: string | undefined; + position?: { x: number; y: number }; }) => { - setWorkflowInsertStepIds({ parentStepId, nextStepId }); + setWorkflowInsertStepIds({ parentStepId, nextStepId, position }); if (isDefined(workflowVisualizerWorkflowId)) { openStepSelectInCommandMenu(workflowVisualizerWorkflowId); @@ -44,7 +45,21 @@ export const useStartNodeCreation = () => { ], ); + const isNodeCreationStarted = ({ + parentStepId, + nextStepId, + }: { + parentStepId?: string; + nextStepId?: string; + }) => { + return ( + workflowInsertStepIds.parentStepId === parentStepId && + workflowInsertStepIds.nextStepId === nextStepId + ); + }; + return { startNodeCreation, + isNodeCreationStarted, }; }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/states/workflowDiagramRightClickMenuPositionState.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/states/workflowDiagramRightClickMenuPositionState.ts new file mode 100644 index 0000000000..1ea8bf998d --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/states/workflowDiagramRightClickMenuPositionState.ts @@ -0,0 +1,16 @@ +import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; +import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext'; + +type WorkflowDiagramRightClickMenuPositionState = + | { + x: number; + y: number; + } + | undefined; + +export const workflowDiagramRightClickMenuPositionState = + createComponentStateV2({ + key: 'workflowDiagramRightClickMenuPositionState', + defaultValue: undefined, + componentInstanceContext: WorkflowVisualizerComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/types/WorkflowDiagram.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/types/WorkflowDiagram.ts index 5a6ea9dc11..a90746989a 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/types/WorkflowDiagram.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/types/WorkflowDiagram.ts @@ -30,12 +30,24 @@ export type WorkflowDiagramStepNodeData = name: string; icon?: string; runStatus?: WorkflowRunStepStatus; + hasNextStepIds: boolean; + stepId: string; + position: { + x: number; + y: number; + }; } | { nodeType: 'action'; actionType: WorkflowActionType; name: string; runStatus?: WorkflowRunStepStatus; + hasNextStepIds: boolean; + stepId: string; + position: { + x: number; + y: number; + }; }; export type WorkflowRunDiagramStepNodeData = Exclude< @@ -45,18 +57,16 @@ export type WorkflowRunDiagramStepNodeData = Exclude< runStatus: WorkflowRunStepStatus; }; -export type WorkflowDiagramCreateStepNodeData = { - nodeType: 'create-step'; - parentNodeId: string; -}; - export type WorkflowDiagramEmptyTriggerNodeData = { nodeType: 'empty-trigger'; + position: { + x: number; + y: number; + }; }; export type WorkflowDiagramNodeData = | WorkflowDiagramStepNodeData - | WorkflowDiagramCreateStepNodeData | WorkflowDiagramEmptyTriggerNodeData; export type WorkflowRunDiagramNodeData = Exclude< @@ -82,10 +92,7 @@ export type WorkflowDiagramEdgeData = | WorkflowDiagramFilterEdgeData | WorkflowDiagramDefaultEdgeData; -export type WorkflowDiagramNodeType = - | 'default' - | 'empty-trigger' - | 'create-step'; +export type WorkflowDiagramNodeType = 'default' | 'empty-trigger'; export type WorkflowDiagramEdgeType = | 'blank' diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/addCreateStepNodes.test.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/addCreateStepNodes.test.ts deleted file mode 100644 index 11484fc675..0000000000 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/addCreateStepNodes.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { WorkflowStep, WorkflowTrigger } from '@/workflow/types/Workflow'; -import { generateWorkflowDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowDiagram'; -import { addCreateStepNodes } from '../addCreateStepNodes'; - -describe('addCreateStepNodes', () => { - it("adds a create step node to the end of a single-branch flow and doesn't change the shape of other nodes", () => { - const trigger: WorkflowTrigger = { - name: 'Company created', - type: 'DATABASE_EVENT', - settings: { - eventName: 'company.created', - outputSchema: {}, - }, - }; - const steps: WorkflowStep[] = [ - { - id: 'step1', - name: 'Step 1', - type: 'CODE', - valid: true, - settings: { - errorHandlingOptions: { - retryOnFailure: { value: true }, - continueOnFailure: { value: false }, - }, - input: { - serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', - serverlessFunctionVersion: '1', - serverlessFunctionInput: {}, - }, - outputSchema: {}, - }, - nextStepIds: ['step2'], - }, - { - id: 'step2', - name: 'Step 2', - type: 'CODE', - valid: true, - settings: { - errorHandlingOptions: { - retryOnFailure: { value: true }, - continueOnFailure: { value: false }, - }, - input: { - serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', - serverlessFunctionVersion: '1', - serverlessFunctionInput: {}, - }, - outputSchema: {}, - }, - nextStepIds: undefined, - }, - ]; - - const diagramInitial = generateWorkflowDiagram({ - trigger, - steps, - defaultEdgeType: 'empty-filter--editable', - }); - - expect(diagramInitial.nodes).toHaveLength(3); - expect(diagramInitial.edges).toHaveLength(2); - - const diagramWithCreateStepNodes = addCreateStepNodes(diagramInitial); - - expect(diagramWithCreateStepNodes.nodes).toHaveLength(4); - expect(diagramWithCreateStepNodes.edges).toHaveLength(3); - - expect(diagramWithCreateStepNodes.nodes[0].type).toBe(undefined); - expect(diagramWithCreateStepNodes.nodes[0].data.nodeType).toBe('trigger'); - - expect(diagramWithCreateStepNodes.nodes[1].type).toBe(undefined); - expect(diagramWithCreateStepNodes.nodes[1].data.nodeType).toBe('action'); - - expect(diagramWithCreateStepNodes.nodes[2].type).toBe(undefined); - expect(diagramWithCreateStepNodes.nodes[2].data.nodeType).toBe('action'); - - expect(diagramWithCreateStepNodes.nodes[3].type).toBe('create-step'); - }); -}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/generateWorkflowDiagram.test.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/generateWorkflowDiagram.test.ts index 1c127d41ea..418076a7a7 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/generateWorkflowDiagram.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/generateWorkflowDiagram.test.ts @@ -37,6 +37,7 @@ describe('generateWorkflowDiagram', () => { eventName: 'company.created', outputSchema: {}, }, + nextStepIds: ['step1'], }; const steps: WorkflowStep[] = [ { @@ -97,6 +98,12 @@ describe('generateWorkflowDiagram', () => { nodeType: 'action', actionType: 'CODE', name: step.name, + hasNextStepIds: step.id !== 'step2', + stepId: step.id, + position: { + x: 0, + y: 150 * (index + 1), + }, }); } }); @@ -109,6 +116,7 @@ describe('generateWorkflowDiagram', () => { eventName: 'company.created', outputSchema: {}, }, + nextStepIds: ['step1'], }; const steps: WorkflowStep[] = [ { @@ -175,6 +183,7 @@ describe('generateWorkflowDiagram', () => { eventName: 'company.created', outputSchema: {}, }, + nextStepIds: ['step2'], }; const steps: WorkflowStep[] = [ { @@ -241,6 +250,7 @@ describe('generateWorkflowDiagram', () => { eventName: 'company.created', outputSchema: {}, }, + nextStepIds: ['step2', 'step3'], }; const steps: WorkflowStep[] = [ { diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/generateWorkflowRunDiagram.test.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/generateWorkflowRunDiagram.test.ts index c8908c3aa1..0da6384afe 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/generateWorkflowRunDiagram.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/generateWorkflowRunDiagram.test.ts @@ -17,6 +17,7 @@ describe('generateWorkflowRunDiagram', () => { eventName: 'company.created', outputSchema: {}, }, + nextStepIds: ['step1'], }; const steps: WorkflowStep[] = [ @@ -153,10 +154,16 @@ describe('generateWorkflowRunDiagram', () => { "nodes": [ { "data": { + "hasNextStepIds": true, "icon": "IconPlaylistAdd", "name": "Company created", "nodeType": "trigger", + "position": { + "x": 0, + "y": 0, + }, "runStatus": "SUCCESS", + "stepId": "trigger", "triggerType": "DATABASE_EVENT", }, "id": "trigger", @@ -168,9 +175,15 @@ describe('generateWorkflowRunDiagram', () => { { "data": { "actionType": "CODE", + "hasNextStepIds": true, "name": "Step 1", "nodeType": "action", + "position": { + "x": 0, + "y": 150, + }, "runStatus": "FAILED", + "stepId": "step1", }, "id": "step1", "position": { @@ -181,9 +194,15 @@ describe('generateWorkflowRunDiagram', () => { { "data": { "actionType": "CODE", + "hasNextStepIds": true, "name": "Step 2", "nodeType": "action", + "position": { + "x": 0, + "y": 300, + }, "runStatus": "NOT_STARTED", + "stepId": "step2", }, "id": "step2", "position": { @@ -194,9 +213,15 @@ describe('generateWorkflowRunDiagram', () => { { "data": { "actionType": "CODE", + "hasNextStepIds": false, "name": "Step 3", "nodeType": "action", + "position": { + "x": 0, + "y": 450, + }, "runStatus": "NOT_STARTED", + "stepId": "step3", }, "id": "step3", "position": { @@ -219,6 +244,7 @@ describe('generateWorkflowRunDiagram', () => { eventName: 'company.created', outputSchema: {}, }, + nextStepIds: ['step1'], }; const steps: WorkflowStep[] = [ @@ -357,10 +383,16 @@ describe('generateWorkflowRunDiagram', () => { "nodes": [ { "data": { + "hasNextStepIds": true, "icon": "IconPlaylistAdd", "name": "Company created", "nodeType": "trigger", + "position": { + "x": 0, + "y": 0, + }, "runStatus": "SUCCESS", + "stepId": "trigger", "triggerType": "DATABASE_EVENT", }, "id": "trigger", @@ -372,9 +404,15 @@ describe('generateWorkflowRunDiagram', () => { { "data": { "actionType": "CODE", + "hasNextStepIds": true, "name": "Step 1", "nodeType": "action", + "position": { + "x": 0, + "y": 150, + }, "runStatus": "SUCCESS", + "stepId": "step1", }, "id": "step1", "position": { @@ -385,9 +423,15 @@ describe('generateWorkflowRunDiagram', () => { { "data": { "actionType": "CODE", + "hasNextStepIds": true, "name": "Step 2", "nodeType": "action", + "position": { + "x": 0, + "y": 300, + }, "runStatus": "SUCCESS", + "stepId": "step2", }, "id": "step2", "position": { @@ -398,9 +442,15 @@ describe('generateWorkflowRunDiagram', () => { { "data": { "actionType": "CODE", + "hasNextStepIds": false, "name": "Step 3", "nodeType": "action", + "position": { + "x": 0, + "y": 450, + }, "runStatus": "SUCCESS", + "stepId": "step3", }, "id": "step3", "position": { @@ -423,6 +473,7 @@ describe('generateWorkflowRunDiagram', () => { eventName: 'company.created', outputSchema: {}, }, + nextStepIds: ['step1'], }; const steps: WorkflowStep[] = [ @@ -561,10 +612,16 @@ describe('generateWorkflowRunDiagram', () => { "nodes": [ { "data": { + "hasNextStepIds": true, "icon": "IconPlaylistAdd", "name": "Company created", "nodeType": "trigger", + "position": { + "x": 0, + "y": 0, + }, "runStatus": "SUCCESS", + "stepId": "trigger", "triggerType": "DATABASE_EVENT", }, "id": "trigger", @@ -576,9 +633,15 @@ describe('generateWorkflowRunDiagram', () => { { "data": { "actionType": "CODE", + "hasNextStepIds": true, "name": "Step 1", "nodeType": "action", + "position": { + "x": 0, + "y": 150, + }, "runStatus": "RUNNING", + "stepId": "step1", }, "id": "step1", "position": { @@ -589,9 +652,15 @@ describe('generateWorkflowRunDiagram', () => { { "data": { "actionType": "CODE", + "hasNextStepIds": true, "name": "Step 2", "nodeType": "action", + "position": { + "x": 0, + "y": 300, + }, "runStatus": "NOT_STARTED", + "stepId": "step2", }, "id": "step2", "position": { @@ -602,9 +671,15 @@ describe('generateWorkflowRunDiagram', () => { { "data": { "actionType": "CODE", + "hasNextStepIds": false, "name": "Step 3", "nodeType": "action", + "position": { + "x": 0, + "y": 450, + }, "runStatus": "NOT_STARTED", + "stepId": "step3", }, "id": "step3", "position": { @@ -627,6 +702,7 @@ describe('generateWorkflowRunDiagram', () => { eventName: 'company.created', outputSchema: {}, }, + nextStepIds: ['step1'], }; const steps: WorkflowStep[] = [ @@ -798,10 +874,16 @@ describe('generateWorkflowRunDiagram', () => { "nodes": [ { "data": { + "hasNextStepIds": true, "icon": "IconPlaylistAdd", "name": "Company created", "nodeType": "trigger", + "position": { + "x": 0, + "y": 0, + }, "runStatus": "SUCCESS", + "stepId": "trigger", "triggerType": "DATABASE_EVENT", }, "id": "trigger", @@ -813,9 +895,15 @@ describe('generateWorkflowRunDiagram', () => { { "data": { "actionType": "CODE", + "hasNextStepIds": true, "name": "Step 1", "nodeType": "action", + "position": { + "x": 0, + "y": 150, + }, "runStatus": "SUCCESS", + "stepId": "step1", }, "id": "step1", "position": { @@ -826,9 +914,15 @@ describe('generateWorkflowRunDiagram', () => { { "data": { "actionType": "CODE", + "hasNextStepIds": true, "name": "Step 2", "nodeType": "action", + "position": { + "x": 0, + "y": 300, + }, "runStatus": "RUNNING", + "stepId": "step2", }, "id": "step2", "position": { @@ -839,9 +933,15 @@ describe('generateWorkflowRunDiagram', () => { { "data": { "actionType": "CODE", + "hasNextStepIds": true, "name": "Step 3", "nodeType": "action", + "position": { + "x": 0, + "y": 450, + }, "runStatus": "NOT_STARTED", + "stepId": "step3", }, "id": "step3", "position": { @@ -852,9 +952,15 @@ describe('generateWorkflowRunDiagram', () => { { "data": { "actionType": "CODE", + "hasNextStepIds": false, "name": "Step 4", "nodeType": "action", + "position": { + "x": 0, + "y": 600, + }, "runStatus": "NOT_STARTED", + "stepId": "step4", }, "id": "step4", "position": { @@ -877,6 +983,7 @@ describe('generateWorkflowRunDiagram', () => { eventName: 'company.created', outputSchema: {}, }, + nextStepIds: ['step1'], }; const steps: WorkflowStep[] = [ { @@ -953,10 +1060,16 @@ describe('generateWorkflowRunDiagram', () => { "nodes": [ { "data": { + "hasNextStepIds": true, "icon": "IconPlaylistAdd", "name": "Company created", "nodeType": "trigger", + "position": { + "x": 0, + "y": 0, + }, "runStatus": "SUCCESS", + "stepId": "trigger", "triggerType": "DATABASE_EVENT", }, "id": "trigger", @@ -968,9 +1081,15 @@ describe('generateWorkflowRunDiagram', () => { { "data": { "actionType": "FORM", + "hasNextStepIds": false, "name": "Step 1", "nodeType": "action", + "position": { + "x": 0, + "y": 150, + }, "runStatus": "PENDING", + "stepId": "step1", }, "id": "step1", "position": { @@ -983,9 +1102,15 @@ describe('generateWorkflowRunDiagram', () => { "stepToOpenByDefault": { "data": { "actionType": "FORM", + "hasNextStepIds": false, "name": "Step 1", "nodeType": "action", + "position": { + "x": 0, + "y": 150, + }, "runStatus": "PENDING", + "stepId": "step1", }, "id": "step1", }, diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/getNodeVariantFromStepRunStatus.test.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/getNodeVariantFromStepRunStatus.test.ts new file mode 100644 index 0000000000..554ed54e6d --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/getNodeVariantFromStepRunStatus.test.ts @@ -0,0 +1,16 @@ +import { getNodeVariantFromStepRunStatus } from '@/workflow/workflow-diagram/utils/getNodeVariantFromStepRunStatus'; +import { StepStatus } from 'twenty-shared/workflow'; + +describe('getNodeVariantFromRunStatus', () => { + it('should return proper variant', () => { + expect(getNodeVariantFromStepRunStatus(StepStatus.SUCCESS)).toBe('success'); + expect(getNodeVariantFromStepRunStatus(StepStatus.STOPPED)).toBe('success'); + expect(getNodeVariantFromStepRunStatus(StepStatus.FAILED)).toBe('failure'); + expect(getNodeVariantFromStepRunStatus(StepStatus.RUNNING)).toBe('running'); + expect(getNodeVariantFromStepRunStatus(StepStatus.PENDING)).toBe('running'); + expect(getNodeVariantFromStepRunStatus(StepStatus.NOT_STARTED)).toBe( + 'not-executed', + ); + expect(getNodeVariantFromStepRunStatus(undefined)).toBe('default'); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/getWorkflowDiagramTriggerNode.test.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/getWorkflowDiagramTriggerNode.test.ts index fc7f734f8b..2a3faaa191 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/getWorkflowDiagramTriggerNode.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/getWorkflowDiagramTriggerNode.test.ts @@ -18,9 +18,15 @@ describe('getWorkflowDiagramTriggerNode', () => { expect(result).toMatchInlineSnapshot(` { "data": { + "hasNextStepIds": false, "icon": "IconHandMove", "name": "Manual trigger", "nodeType": "trigger", + "position": { + "x": 0, + "y": 0, + }, + "stepId": "trigger", "triggerType": "MANUAL", }, "id": "trigger", @@ -53,9 +59,15 @@ describe('getWorkflowDiagramTriggerNode', () => { expect(result).toMatchInlineSnapshot(` { "data": { + "hasNextStepIds": false, "icon": "IconClock", "name": "On a schedule", "nodeType": "trigger", + "position": { + "x": 0, + "y": 0, + }, + "stepId": "trigger", "triggerType": "CRON", }, "id": "trigger", @@ -85,9 +97,15 @@ describe('getWorkflowDiagramTriggerNode', () => { expect(result).toMatchInlineSnapshot(` { "data": { + "hasNextStepIds": false, "icon": "IconWebhook", "name": "Webhook", "nodeType": "trigger", + "position": { + "x": 0, + "y": 0, + }, + "stepId": "trigger", "triggerType": "WEBHOOK", }, "id": "trigger", @@ -116,9 +134,15 @@ describe('getWorkflowDiagramTriggerNode', () => { expect(result).toMatchInlineSnapshot(` { "data": { + "hasNextStepIds": false, "icon": "IconPlaylistAdd", "name": "Record is created", "nodeType": "trigger", + "position": { + "x": 0, + "y": 0, + }, + "stepId": "trigger", "triggerType": "DATABASE_EVENT", }, "id": "trigger", @@ -145,9 +169,15 @@ describe('getWorkflowDiagramTriggerNode', () => { expect(result).toMatchInlineSnapshot(` { "data": { + "hasNextStepIds": false, "icon": undefined, "name": "", "nodeType": "trigger", + "position": { + "x": 0, + "y": 0, + }, + "stepId": "trigger", "triggerType": "DATABASE_EVENT", }, "id": "trigger", @@ -177,9 +207,15 @@ describe('getWorkflowDiagramTriggerNode', () => { expect(result).toMatchInlineSnapshot(` { "data": { + "hasNextStepIds": false, "icon": "IconHandMove", "name": "Custom Trigger Name", "nodeType": "trigger", + "position": { + "x": 0, + "y": 0, + }, + "stepId": "trigger", "triggerType": "MANUAL", }, "id": "trigger", diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/getWorkflowNodeIconKey.test.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/getWorkflowNodeIconKey.test.ts index 777597d999..93bd260599 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/getWorkflowNodeIconKey.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/getWorkflowNodeIconKey.test.ts @@ -18,10 +18,17 @@ jest.mock( }), ); +const baseNodeData = { + hasNextStepIds: false, + position: { x: 0, y: 0 }, + stepId: '', +}; + describe('getWorkflowNodeIconKey', () => { describe('trigger nodes', () => { it('should return the icon from trigger node data', () => { const triggerNodeData: WorkflowDiagramStepNodeData = { + ...baseNodeData, nodeType: 'trigger', triggerType: 'DATABASE_EVENT', name: 'Company Created', @@ -35,6 +42,7 @@ describe('getWorkflowNodeIconKey', () => { it('should return icon for manual trigger', () => { const manualTriggerData: WorkflowDiagramStepNodeData = { + ...baseNodeData, nodeType: 'trigger', triggerType: 'MANUAL', name: 'Manual Trigger', @@ -48,6 +56,7 @@ describe('getWorkflowNodeIconKey', () => { it('should return icon for webhook trigger', () => { const webhookTriggerData: WorkflowDiagramStepNodeData = { + ...baseNodeData, nodeType: 'trigger', triggerType: 'WEBHOOK', name: 'Webhook Trigger', @@ -63,6 +72,7 @@ describe('getWorkflowNodeIconKey', () => { describe('action nodes', () => { it('should return icon for CREATE_RECORD action', () => { const createActionData: WorkflowDiagramStepNodeData = { + ...baseNodeData, nodeType: 'action', actionType: 'CREATE_RECORD', name: 'Create Company', @@ -75,6 +85,7 @@ describe('getWorkflowNodeIconKey', () => { it('should return icon for UPDATE_RECORD action', () => { const updateActionData: WorkflowDiagramStepNodeData = { + ...baseNodeData, nodeType: 'action', actionType: 'UPDATE_RECORD', name: 'Update Company', @@ -87,6 +98,7 @@ describe('getWorkflowNodeIconKey', () => { it('should return icon for DELETE_RECORD action', () => { const deleteActionData: WorkflowDiagramStepNodeData = { + ...baseNodeData, nodeType: 'action', actionType: 'DELETE_RECORD', name: 'Delete Company', @@ -99,6 +111,7 @@ describe('getWorkflowNodeIconKey', () => { it('should return icon for SEND_EMAIL action', () => { const emailActionData: WorkflowDiagramStepNodeData = { + ...baseNodeData, nodeType: 'action', actionType: 'SEND_EMAIL', name: 'Send Email', @@ -111,6 +124,7 @@ describe('getWorkflowNodeIconKey', () => { it('should return icon for FILTER action', () => { const filterActionData: WorkflowDiagramStepNodeData = { + ...baseNodeData, nodeType: 'action', actionType: 'FILTER', name: 'Filter Records', @@ -123,6 +137,7 @@ describe('getWorkflowNodeIconKey', () => { it('should handle unknown action types', () => { const unknownActionData: WorkflowDiagramStepNodeData = { + ...baseNodeData, nodeType: 'action', actionType: 'UNKNOWN_ACTION' as any, name: 'Unknown Action', diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/getWorkflowVersionDiagram.test.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/getWorkflowVersionDiagram.test.ts index 2d104b95e7..d4f7e6183c 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/getWorkflowVersionDiagram.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/getWorkflowVersionDiagram.test.ts @@ -45,6 +45,10 @@ describe('getWorkflowVersionDiagram', () => { { "data": { "nodeType": "empty-trigger", + "position": { + "x": 0, + "y": 0, + }, }, "id": "trigger", "position": { @@ -85,9 +89,15 @@ describe('getWorkflowVersionDiagram', () => { "nodes": [ { "data": { + "hasNextStepIds": false, "icon": "IconPlaylistAdd", "name": "Record is created", "nodeType": "trigger", + "position": { + "x": 0, + "y": 0, + }, + "stepId": "trigger", "triggerType": "DATABASE_EVENT", }, "id": "trigger", @@ -133,6 +143,7 @@ describe('getWorkflowVersionDiagram', () => { name: 'Company created', settings: { eventName: 'company.created', outputSchema: {} }, type: 'DATABASE_EVENT', + nextStepIds: ['step-1'], }, updatedAt: '', workflowId: '', @@ -148,11 +159,11 @@ describe('getWorkflowVersionDiagram', () => { "data": { "edgeType": "default", }, - "deletable": false, + "deletable": true, "id": "8f3b2121-f194-4ba4-9fbf-0", "markerEnd": "workflow-edge-arrow-rounded", "markerStart": "workflow-edge-gray-circle", - "selectable": false, + "selectable": true, "source": "trigger", "target": "step-1", "type": "empty-filter--editable", @@ -161,9 +172,15 @@ describe('getWorkflowVersionDiagram', () => { "nodes": [ { "data": { + "hasNextStepIds": true, "icon": "IconPlaylistAdd", "name": "Company created", "nodeType": "trigger", + "position": { + "x": 0, + "y": 0, + }, + "stepId": "trigger", "triggerType": "DATABASE_EVENT", }, "id": "trigger", @@ -175,8 +192,14 @@ describe('getWorkflowVersionDiagram', () => { { "data": { "actionType": "CODE", + "hasNextStepIds": false, "name": "", "nodeType": "action", + "position": { + "x": 0, + "y": 150, + }, + "stepId": "step-1", }, "id": "step-1", "position": { diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/isCreateStepNode.test.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/isCreateStepNode.test.ts deleted file mode 100644 index 4925ecd408..0000000000 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/isCreateStepNode.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { WorkflowDiagramNode } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; -import { isCreateStepNode } from '../isCreateStepNode'; - -describe('isCreateStepNode', () => { - it('should return true for create-step node with correct type and nodeType', () => { - const createStepNode: WorkflowDiagramNode = { - id: 'create-step-1', - type: 'create-step', - position: { x: 0, y: 200 }, - data: { - nodeType: 'create-step', - parentNodeId: 'action-1', - }, - }; - - const result = isCreateStepNode(createStepNode); - - expect(result).toBe(true); - }); - - it('should return false for node with create-step type but wrong nodeType', () => { - const node: WorkflowDiagramNode = { - id: 'fake-create-step', - type: 'create-step', - position: { x: 0, y: 200 }, - data: { - nodeType: 'action', - actionType: 'CREATE_RECORD', - name: 'Create Company', - } as any, - }; - - const result = isCreateStepNode(node); - - expect(result).toBe(false); - }); - - it('should return false for node with correct nodeType but wrong type', () => { - const node: WorkflowDiagramNode = { - id: 'fake-create-step', - type: 'action', - position: { x: 0, y: 200 }, - data: { - nodeType: 'create-step', - parentNodeId: 'action-1', - } as any, - }; - - const result = isCreateStepNode(node); - - expect(result).toBe(false); - }); - - it('should return false for action node', () => { - const actionNode: WorkflowDiagramNode = { - id: 'action-1', - position: { x: 0, y: 100 }, - data: { - nodeType: 'action', - actionType: 'CREATE_RECORD', - name: 'Create Company', - }, - }; - - const result = isCreateStepNode(actionNode); - - expect(result).toBe(false); - }); - - it('should return false for trigger node', () => { - const triggerNode: WorkflowDiagramNode = { - id: 'trigger', - position: { x: 0, y: 0 }, - data: { - nodeType: 'trigger', - triggerType: 'DATABASE_EVENT', - name: 'Company Created', - icon: 'IconPlus', - }, - }; - - const result = isCreateStepNode(triggerNode); - - expect(result).toBe(false); - }); - - it('should return false for empty-trigger node', () => { - const emptyTriggerNode: WorkflowDiagramNode = { - id: 'empty-trigger', - position: { x: 0, y: 0 }, - data: { - nodeType: 'empty-trigger', - }, - }; - - const result = isCreateStepNode(emptyTriggerNode); - - expect(result).toBe(false); - }); - - it('should handle create-step node with additional properties', () => { - const createStepNodeWithExtras: WorkflowDiagramNode = { - id: 'create-step-with-extras', - type: 'create-step', - position: { x: 50, y: 250 }, - selected: true, - data: { - nodeType: 'create-step', - parentNodeId: 'action-2', - }, - }; - - const result = isCreateStepNode(createStepNodeWithExtras); - - expect(result).toBe(true); - }); - - it('should return false for node without type property', () => { - const nodeWithoutType: WorkflowDiagramNode = { - id: 'node-without-type', - position: { x: 0, y: 200 }, - data: { - nodeType: 'create-step', - parentNodeId: 'action-1', - } as any, - }; - - const result = isCreateStepNode(nodeWithoutType); - - expect(result).toBe(false); - }); -}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/isStepNode.test.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/isStepNode.test.ts index c1e96ed0d7..3101b28ef2 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/isStepNode.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/isStepNode.test.ts @@ -11,6 +11,9 @@ describe('isStepNode', () => { triggerType: 'DATABASE_EVENT', name: 'Company Created', icon: 'IconPlus', + stepId: 'trigger', + hasNextStepIds: false, + position: { x: 0, y: 0 }, }, }; @@ -27,6 +30,9 @@ describe('isStepNode', () => { nodeType: 'action', actionType: 'CREATE_RECORD', name: 'Create Company', + stepId: 'action-1', + hasNextStepIds: false, + position: { x: 0, y: 0 }, }, }; @@ -35,27 +41,13 @@ describe('isStepNode', () => { expect(result).toBe(true); }); - it('should return false for create-step node', () => { - const createStepNode: WorkflowDiagramNode = { - id: 'create-step-1', - position: { x: 0, y: 200 }, - data: { - nodeType: 'create-step', - parentNodeId: 'action-1', - }, - }; - - const result = isStepNode(createStepNode); - - expect(result).toBe(false); - }); - it('should return false for empty-trigger node', () => { const emptyTriggerNode: WorkflowDiagramNode = { id: 'empty-trigger', position: { x: 0, y: 0 }, data: { nodeType: 'empty-trigger', + position: { x: 0, y: 0 }, }, }; @@ -74,6 +66,9 @@ describe('isStepNode', () => { triggerType: 'MANUAL', name: 'Manual Trigger', icon: 'IconClick', + stepId: 'trigger-with-extra', + hasNextStepIds: false, + position: { x: 0, y: 0 }, }, }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/mergeWorkflowDiagrams.test.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/mergeWorkflowDiagrams.test.ts index 20d7fe45bb..ea248ebd3e 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/mergeWorkflowDiagrams.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/mergeWorkflowDiagrams.test.ts @@ -9,6 +9,9 @@ it('Preserves the properties defined in the previous version but not in the next nodeType: 'action', name: '', actionType: 'CODE', + hasNextStepIds: false, + position: { x: 0, y: 0 }, + stepId: '', }, id: '1', position: { x: 0, y: 0 }, @@ -24,6 +27,9 @@ it('Preserves the properties defined in the previous version but not in the next nodeType: 'action', name: '', actionType: 'CODE', + hasNextStepIds: false, + position: { x: 0, y: 0 }, + stepId: '', }, id: '1', position: { x: 0, y: 0 }, @@ -40,8 +46,14 @@ it('Preserves the properties defined in the previous version but not in the next { "data": { "actionType": "CODE", + "hasNextStepIds": false, "name": "", "nodeType": "action", + "position": { + "x": 0, + "y": 0, + }, + "stepId": "", }, "id": "1", "position": { @@ -63,6 +75,9 @@ it('Replaces duplicated properties with the next value', () => { nodeType: 'action', name: '', actionType: 'CODE', + hasNextStepIds: false, + position: { x: 0, y: 0 }, + stepId: '', }, id: '1', position: { x: 0, y: 0 }, @@ -77,6 +92,9 @@ it('Replaces duplicated properties with the next value', () => { nodeType: 'action', name: '2', actionType: 'CODE', + hasNextStepIds: false, + position: { x: 0, y: 0 }, + stepId: '', }, id: '1', position: { x: 0, y: 0 }, @@ -93,8 +111,14 @@ it('Replaces duplicated properties with the next value', () => { { "data": { "actionType": "CODE", + "hasNextStepIds": false, "name": "2", "nodeType": "action", + "position": { + "x": 0, + "y": 0, + }, + "stepId": "", }, "id": "1", "position": { diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/selectWorkflowDiagramNode.test.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/selectWorkflowDiagramNode.test.ts index 38bd8b80cc..8d029de922 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/selectWorkflowDiagramNode.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/selectWorkflowDiagramNode.test.ts @@ -13,6 +13,9 @@ describe('selectWorkflowDiagramNode', () => { name: 'Node 1', nodeType: 'action', actionType: 'CODE', + hasNextStepIds: false, + position: { x: 0, y: 0 }, + stepId: '1', }, }, { @@ -23,6 +26,9 @@ describe('selectWorkflowDiagramNode', () => { name: 'Node 2', nodeType: 'action', actionType: 'CODE', + hasNextStepIds: false, + position: { x: 0, y: 150 }, + stepId: '2', }, }, ], @@ -49,6 +55,9 @@ describe('selectWorkflowDiagramNode', () => { name: 'Node 1', nodeType: 'action', actionType: 'CODE', + hasNextStepIds: false, + position: { x: 0, y: 0 }, + stepId: '1', }, }, ], @@ -74,6 +83,9 @@ describe('selectWorkflowDiagramNode', () => { name: 'Node 1', nodeType: 'action', actionType: 'CODE', + hasNextStepIds: false, + position: { x: 0, y: 0 }, + stepId: '1', }, }, ], diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/transformFilterNodesAsEdges.test.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/transformFilterNodesAsEdges.test.ts index 5164d744d3..71814bb9e5 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/transformFilterNodesAsEdges.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/transformFilterNodesAsEdges.test.ts @@ -7,7 +7,14 @@ describe('transformFilterNodesAsEdges', () => { nodes: [ { id: 'A', - data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' }, + data: { + nodeType: 'action', + actionType: 'CODE', + name: 'Step A', + hasNextStepIds: false, + position: { x: 0, y: 0 }, + stepId: 'A', + }, position: { x: 0, y: 0 }, }, { @@ -16,6 +23,9 @@ describe('transformFilterNodesAsEdges', () => { nodeType: 'action', actionType: 'SEND_EMAIL', name: 'Step C', + hasNextStepIds: false, + position: { x: 0, y: 0 }, + stepId: 'C', }, position: { x: 0, y: 300 }, }, @@ -47,12 +57,26 @@ describe('transformFilterNodesAsEdges', () => { nodes: [ { id: 'A', - data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' }, + data: { + nodeType: 'action', + actionType: 'CODE', + name: 'Step A', + hasNextStepIds: true, + position: { x: 0, y: 0 }, + stepId: 'A', + }, position: { x: 0, y: 0 }, }, { id: 'B', - data: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B' }, + data: { + nodeType: 'action', + actionType: 'FILTER', + name: 'Filter B', + hasNextStepIds: true, + position: { x: 0, y: 150 }, + stepId: 'B', + }, position: { x: 0, y: 150 }, }, { @@ -61,6 +85,9 @@ describe('transformFilterNodesAsEdges', () => { nodeType: 'action', actionType: 'SEND_EMAIL', name: 'Step C', + hasNextStepIds: false, + position: { x: 0, y: 300 }, + stepId: 'C', }, position: { x: 0, y: 300 }, }, @@ -91,12 +118,26 @@ describe('transformFilterNodesAsEdges', () => { expect(result.nodes).toEqual([ { id: 'A', - data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' }, + data: { + nodeType: 'action', + actionType: 'CODE', + name: 'Step A', + hasNextStepIds: true, + position: { x: 0, y: 0 }, + stepId: 'A', + }, position: { x: 0, y: 0 }, }, { id: 'C', - data: { nodeType: 'action', actionType: 'SEND_EMAIL', name: 'Step C' }, + data: { + nodeType: 'action', + actionType: 'SEND_EMAIL', + name: 'Step C', + hasNextStepIds: false, + position: { x: 0, y: 300 }, + stepId: 'C', + }, position: { x: 0, y: 300 }, }, ]); @@ -123,12 +164,26 @@ describe('transformFilterNodesAsEdges', () => { nodes: [ { id: 'A', - data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' }, + data: { + nodeType: 'action', + actionType: 'CODE', + name: 'Step A', + hasNextStepIds: true, + position: { x: 0, y: 0 }, + stepId: 'A', + }, position: { x: 0, y: 0 }, }, { id: 'B1', - data: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B1' }, + data: { + nodeType: 'action', + actionType: 'FILTER', + name: 'Filter B1', + hasNextStepIds: true, + position: { x: 0, y: 150 }, + stepId: 'B1', + }, position: { x: 0, y: 150 }, }, { @@ -137,12 +192,22 @@ describe('transformFilterNodesAsEdges', () => { nodeType: 'action', actionType: 'SEND_EMAIL', name: 'Step C', + hasNextStepIds: true, + position: { x: 0, y: 300 }, + stepId: 'C', }, position: { x: 0, y: 300 }, }, { id: 'B2', - data: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B2' }, + data: { + nodeType: 'action', + actionType: 'FILTER', + name: 'Filter B2', + hasNextStepIds: true, + position: { x: 0, y: 450 }, + stepId: 'B2', + }, position: { x: 0, y: 450 }, }, { @@ -151,6 +216,9 @@ describe('transformFilterNodesAsEdges', () => { nodeType: 'action', actionType: 'CREATE_RECORD', name: 'Step D', + hasNextStepIds: true, + position: { x: 0, y: 600 }, + stepId: 'D', }, position: { x: 0, y: 600 }, }, @@ -238,12 +306,26 @@ describe('transformFilterNodesAsEdges', () => { nodes: [ { id: 'A', - data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' }, + data: { + nodeType: 'action', + actionType: 'CODE', + name: 'Step A', + hasNextStepIds: true, + position: { x: 0, y: 0 }, + stepId: 'A', + }, position: { x: 0, y: 0 }, }, { id: 'B', - data: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B' }, + data: { + nodeType: 'action', + actionType: 'FILTER', + name: 'Filter B', + hasNextStepIds: true, + position: { x: 0, y: 150 }, + stepId: 'B', + }, position: { x: 0, y: 150 }, }, ], @@ -267,7 +349,14 @@ describe('transformFilterNodesAsEdges', () => { expect(result.nodes).toEqual([ { id: 'A', - data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' }, + data: { + nodeType: 'action', + actionType: 'CODE', + name: 'Step A', + hasNextStepIds: true, + position: { x: 0, y: 0 }, + stepId: 'A', + }, position: { x: 0, y: 0 }, }, ]); @@ -285,12 +374,22 @@ describe('transformFilterNodesAsEdges', () => { nodeType: 'trigger', triggerType: 'DATABASE_EVENT', name: 'Trigger', + hasNextStepIds: true, + position: { x: 0, y: 0 }, + stepId: 'trigger', }, position: { x: 0, y: 0 }, }, { id: 'B', - data: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B' }, + data: { + nodeType: 'action', + actionType: 'FILTER', + name: 'Filter B', + hasNextStepIds: true, + position: { x: 0, y: 150 }, + stepId: 'B', + }, position: { x: 0, y: 150 }, }, { @@ -299,6 +398,9 @@ describe('transformFilterNodesAsEdges', () => { nodeType: 'action', actionType: 'SEND_EMAIL', name: 'Step C', + hasNextStepIds: true, + position: { x: 0, y: 300 }, + stepId: 'C', }, position: { x: 0, y: 300 }, }, @@ -333,12 +435,22 @@ describe('transformFilterNodesAsEdges', () => { nodeType: 'trigger', triggerType: 'DATABASE_EVENT', name: 'Trigger', + hasNextStepIds: true, + position: { x: 0, y: 0 }, + stepId: 'trigger', }, position: { x: 0, y: 0 }, }, { id: 'C', - data: { nodeType: 'action', actionType: 'SEND_EMAIL', name: 'Step C' }, + data: { + nodeType: 'action', + actionType: 'SEND_EMAIL', + name: 'Step C', + hasNextStepIds: true, + position: { x: 0, y: 300 }, + stepId: 'C', + }, position: { x: 0, y: 300 }, }, ]); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/addCreateStepNodes.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/addCreateStepNodes.ts deleted file mode 100644 index caa4005b3f..0000000000 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/addCreateStepNodes.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION } from '@/workflow/workflow-diagram/constants/WorkflowVisualizerEdgeDefaultConfiguration'; -import { - WorkflowDiagram, - WorkflowDiagramEdge, - WorkflowDiagramEdgeType, - WorkflowDiagramNode, -} from '@/workflow/workflow-diagram/types/WorkflowDiagram'; -import { v4 } from 'uuid'; - -export const addCreateStepNodes = ({ nodes, edges }: WorkflowDiagram) => { - const nodesWithoutTargets = nodes.filter((node) => - edges.every((edge) => edge.source !== node.id), - ); - - const updatedNodes: Array = nodes.slice(); - const updatedEdges: Array = edges.slice(); - - for (const node of nodesWithoutTargets) { - const newCreateStepNode: WorkflowDiagramNode = { - // FIXME: We need a stable id for create step nodes to be able to preserve their selected status. - // FIXME: In the future, we'll have conditions and loops. We'll have to set an id to each branch so we can have this stable id. - id: 'branch-1__create-step', - type: 'create-step', - data: { - nodeType: 'create-step', - parentNodeId: node.id, - }, - position: { x: 0, y: 0 }, - }; - - updatedNodes.push(newCreateStepNode); - - updatedEdges.push({ - ...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION, - type: 'blank' as WorkflowDiagramEdgeType, - id: v4(), - source: node.id, - target: newCreateStepNode.id, - }); - } - - return { - nodes: updatedNodes, - edges: updatedEdges, - }; -}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateWorkflowDiagram.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateWorkflowDiagram.ts index ba6d23f481..25ed398381 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateWorkflowDiagram.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateWorkflowDiagram.ts @@ -1,7 +1,6 @@ import { WorkflowStep, WorkflowTrigger } from '@/workflow/types/Workflow'; import { FIRST_NODE_POSITION } from '@/workflow/workflow-diagram/constants/FirstNodePosition'; import { VERTICAL_DISTANCE_BETWEEN_TWO_NODES } from '@/workflow/workflow-diagram/constants/VerticalDistanceBetweenTwoNodes'; -import { WORKFLOW_DIAGRAM_EMPTY_TRIGGER_NODE_DEFINITION } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEmptyTriggerNodeDefinition'; import { WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION } from '@/workflow/workflow-diagram/constants/WorkflowVisualizerEdgeDefaultConfiguration'; import { WorkflowDiagram, @@ -15,58 +14,8 @@ import { getWorkflowDiagramTriggerNode } from '@/workflow/workflow-diagram/utils import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId'; import { isDefined } from 'twenty-shared/utils'; import { v4 } from 'uuid'; - -/** - * Groups workflow steps into levels based on their distance from root nodes. - * - * A root node is one that is not referenced as a `nextStepId` by any other step. - * The function performs a breadth-first traversal from all roots and assigns - * each step to a level indicating its depth in the graph. - * - * Returns an array where each sub-array contains all steps at the same level. - */ -const groupStepsByLevel = (steps: WorkflowStep[]): WorkflowStep[][] => { - const stepMap = new Map(); - - const childIds = new Set(); - - for (const step of steps) { - stepMap.set(step.id, step); - step.nextStepIds?.forEach((id) => childIds.add(id)); - } - - const rootSteps = steps.filter((step) => !childIds.has(step.id)); - - const stepsByLevel: WorkflowStep[][] = []; - - const visited = new Set(); - - const visit = ({ step, level }: { step: WorkflowStep; level: number }) => { - if (visited.has(step.id)) { - return; - } - - visited.add(step.id); - - if (!isDefined(stepsByLevel[level])) { - stepsByLevel[level] = []; - } - - stepsByLevel[level].push(step); - - step.nextStepIds?.forEach((childId) => { - const child = stepMap.get(childId); - - if (isDefined(child)) { - visit({ step: child, level: level + 1 }); - } - }); - }; - - rootSteps.forEach((root) => visit({ step: root, level: 0 })); - - return stepsByLevel; -}; +import { WORKFLOW_DIAGRAM_EMPTY_TRIGGER_NODE_DEFINITION } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEmptyTriggerNodeDefinition'; +import { getRootStepIds } from '@/workflow/workflow-trigger/utils/getRootStepIds'; export const generateWorkflowDiagram = ({ trigger, @@ -84,40 +33,61 @@ export const generateWorkflowDiagram = ({ nodes.push(getWorkflowDiagramTriggerNode({ trigger })); } else { nodes.push(WORKFLOW_DIAGRAM_EMPTY_TRIGGER_NODE_DEFINITION); - } - const stepsGroupedByLevel = groupStepsByLevel(steps); + const triggerNextStepIds = isDefined(steps) ? getRootStepIds(steps) : []; + + triggerNextStepIds.forEach((stepId) => { + edges.push({ + ...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION, + type: defaultEdgeType, + ...(defaultEdgeType.includes('editable') + ? { deletable: true, selectable: true } + : {}), + id: v4(), + source: 'trigger', + target: stepId, + }); + }); + } let levelYPos = FIRST_NODE_POSITION.y; const xPos = FIRST_NODE_POSITION.x; - for (const stepsByLevel of stepsGroupedByLevel) { + for (const step of steps) { levelYPos += VERTICAL_DISTANCE_BETWEEN_TWO_NODES; - for (const step of stepsByLevel) { - nodes.push({ - id: step.id, - data: { - nodeType: 'action', - actionType: step.type, - name: step.name, - } satisfies WorkflowDiagramStepNodeData, - position: { + nodes.push({ + id: step.id, + data: { + nodeType: 'action', + actionType: step.type, + name: step.name, + hasNextStepIds: + isDefined(step.nextStepIds) && step.nextStepIds.length > 0, + stepId: step.id, + position: step.position ?? { x: xPos, y: levelYPos, }, - }); - } + } satisfies WorkflowDiagramStepNodeData, + position: step.position ?? { + x: xPos, + y: levelYPos, + }, + }); } - for (const firstLevelStep of stepsGroupedByLevel[0] || []) { + for (const stepLinkToTriggerId of trigger?.nextStepIds ?? []) { edges.push({ ...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION, type: defaultEdgeType, + ...(defaultEdgeType.includes('editable') + ? { deletable: true, selectable: true } + : {}), id: v4(), source: TRIGGER_STEP_ID, - target: firstLevelStep.id, + target: stepLinkToTriggerId, }); } @@ -126,6 +96,9 @@ export const generateWorkflowDiagram = ({ edges.push({ ...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION, type: defaultEdgeType, + ...(defaultEdgeType.includes('editable') + ? { deletable: true, selectable: true } + : {}), id: v4(), source: step.id, target: child, diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getOrganizedDiagram.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getOrganizedDiagram.ts index 1813bdaea9..831f27a397 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getOrganizedDiagram.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getOrganizedDiagram.ts @@ -5,17 +5,16 @@ export const getOrganizedDiagram = ( diagram: WorkflowDiagram, ): WorkflowDiagram => { const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); - graph.setGraph({ rankdir: 'TB' }); - - const biggestNodeWidth = diagram.nodes.reduce( - (acc, node) => Math.max(acc, node.measured?.width ?? 0), - 0, - ); + graph.setGraph({ + ranksep: 80, // Vertical distance between 2 nodes + nodesep: 200, // Horizontal distance between 2 nodes + rankdir: 'TB', + }); diagram.edges.forEach((edge) => graph.setEdge(edge.source, edge.target)); diagram.nodes.forEach((node) => graph.setNode(node.id, { - width: biggestNodeWidth, + width: node.measured?.width ?? 0, height: node.measured?.height ?? 0, }), ); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getWorkflowDiagramTriggerNode.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getWorkflowDiagramTriggerNode.ts index d28b516cb6..3826807d68 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getWorkflowDiagramTriggerNode.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getWorkflowDiagramTriggerNode.ts @@ -63,8 +63,15 @@ export const getWorkflowDiagramTriggerNode = ({ triggerType: trigger.type, name: isDefined(trigger.name) ? trigger.name : triggerDefaultLabel, icon: triggerIcon, + stepId: 'trigger', + hasNextStepIds: + isDefined(trigger.nextStepIds) && trigger.nextStepIds.length > 0, + position: trigger.position ?? { + x: 0, + y: 0, + }, } satisfies WorkflowDiagramStepNodeData, - position: { + position: trigger.position ?? { x: 0, y: 0, }, diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getWorkflowVersionDiagram.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getWorkflowVersionDiagram.ts index e28c1fb186..62c54bd9ec 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getWorkflowVersionDiagram.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getWorkflowVersionDiagram.ts @@ -4,8 +4,8 @@ import { WorkflowDiagramEdgeType, } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; import { generateWorkflowDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowDiagram'; -import { transformFilterNodesAsEdges } from '@/workflow/workflow-diagram/utils/transformFilterNodesAsEdges'; import { isDefined } from 'twenty-shared/utils'; +import { transformFilterNodesAsEdges } from '@/workflow/workflow-diagram/utils/transformFilterNodesAsEdges'; const EMPTY_DIAGRAM: WorkflowDiagram = { nodes: [], diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/isCreateStepNode.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/isCreateStepNode.ts deleted file mode 100644 index aba8e17f8e..0000000000 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/isCreateStepNode.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { CREATE_STEP_STEP_ID } from '@/workflow/workflow-diagram/constants/CreateStepStepId'; -import { - WorkflowDiagramCreateStepNodeData, - WorkflowDiagramNode, -} from '@/workflow/workflow-diagram/types/WorkflowDiagram'; - -export const isCreateStepNode = ( - node: WorkflowDiagramNode, -): node is WorkflowDiagramNode & { - data: WorkflowDiagramCreateStepNodeData; -} => { - return ( - node.type === CREATE_STEP_STEP_ID && node.data.nodeType === 'create-step' - ); -}; 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 47ba9f99df..5155315154 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 @@ -6,7 +6,9 @@ import { useCreateStep } from '../useCreateStep'; const mockCreateDraftFromWorkflowVersion = jest.fn().mockResolvedValue('457'); const mockCreateWorkflowVersionStep = jest.fn().mockResolvedValue({ - data: { createWorkflowVersionStep: { id: '1', type: 'CODE' } }, + data: { + createWorkflowVersionStep: { createdStep: { id: '1', type: 'CODE' } }, + }, }); jest.mock( diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateEdge.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateEdge.ts new file mode 100644 index 0000000000..d7550c2b6a --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateEdge.ts @@ -0,0 +1,50 @@ +import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow'; +import { useCreateWorkflowVersionEdge } from '@/workflow/workflow-steps/hooks/useCreateWorkflowVersionEdge'; +import { useState } from 'react'; +import { useGetUpdatableWorkflowVersion } from '@/workflow/hooks/useGetUpdatableWorkflowVersion'; + +export const useCreateEdge = ({ + workflow, +}: { + workflow: WorkflowWithCurrentVersion; +}) => { + const [isLoading, setIsLoading] = useState(false); + + const { createWorkflowVersionEdge } = useCreateWorkflowVersionEdge(); + + const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion(); + + const createEdge = async ({ + source, + target, + }: { + source: string; + target: string; + }) => { + if (isLoading === true) { + return; + } + + setIsLoading(true); + + try { + const workflowVersionId = await getUpdatableWorkflowVersion(workflow); + + const createdEdge = ( + await createWorkflowVersionEdge({ + workflowVersionId, + source, + target, + }) + )?.data?.createWorkflowVersionEdge; + + if (!createdEdge) { + return; + } + } finally { + setIsLoading(false); + } + }; + + return { createEdge }; +}; 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 b0f3a18910..ab8c5e1c4d 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 @@ -36,10 +36,12 @@ export const useCreateStep = ({ newStepType, parentStepId, nextStepId, + position, }: { newStepType: WorkflowStepType; - parentStepId: string; + parentStepId: string | undefined; nextStepId: string | undefined; + position?: { x: number; y: number }; }) => { if (isLoading === true) { return; @@ -50,15 +52,18 @@ export const useCreateStep = ({ try { const workflowVersionId = await getUpdatableWorkflowVersion(workflow); - const createdStep = ( + const workflowVersionStepChanges = ( await createWorkflowVersionStep({ workflowVersionId, stepType: newStepType, parentStepId, nextStepId, + position, }) )?.data?.createWorkflowVersionStep; + const createdStep = workflowVersionStepChanges?.createdStep; + if (!isDefined(createdStep)) { throw new Error("Couldn't create step"); } diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateWorkflowVersionEdge.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateWorkflowVersionEdge.ts new file mode 100644 index 0000000000..08d6c4a2ee --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateWorkflowVersionEdge.ts @@ -0,0 +1,37 @@ +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { useMutation } from '@apollo/client'; +import { + CreateWorkflowVersionEdgeMutation, + CreateWorkflowVersionEdgeMutationVariables, +} from '~/generated-metadata/graphql'; +import { CREATE_WORKFLOW_VERSION_EDGE } from '@/workflow/graphql/mutations/createWorkflowVersionEdge'; +import { CreateWorkflowVersionEdgeInput } from '~/generated/graphql'; +import { useUpdateWorkflowVersionCache } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache'; + +export const useCreateWorkflowVersionEdge = () => { + const apolloCoreClient = useApolloCoreClient(); + + const { updateWorkflowVersionCache } = useUpdateWorkflowVersionCache(); + + const [mutate] = useMutation< + CreateWorkflowVersionEdgeMutation, + CreateWorkflowVersionEdgeMutationVariables + >(CREATE_WORKFLOW_VERSION_EDGE, { client: apolloCoreClient }); + + const createWorkflowVersionEdge = async ( + input: CreateWorkflowVersionEdgeInput, + ) => { + const result = await mutate({ variables: { input } }); + + const workflowVersionStepChanges = result?.data?.createWorkflowVersionEdge; + + updateWorkflowVersionCache({ + workflowVersionStepChanges, + workflowVersionId: input.workflowVersionId, + }); + + return result; + }; + + return { createWorkflowVersionEdge }; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateWorkflowVersionStep.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateWorkflowVersionStep.ts index 95d3d0fba0..9473789431 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateWorkflowVersionStep.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateWorkflowVersionStep.ts @@ -1,36 +1,25 @@ import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache'; -import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache'; -import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; import { CREATE_WORKFLOW_VERSION_STEP } from '@/workflow/graphql/mutations/createWorkflowVersionStep'; -import { WorkflowVersion } from '@/workflow/types/Workflow'; import { useMutation } from '@apollo/client'; -import { isDefined } from 'twenty-shared/utils'; import { CreateWorkflowVersionStepInput, CreateWorkflowVersionStepMutation, CreateWorkflowVersionStepMutationVariables, } from '~/generated-metadata/graphql'; +import { useUpdateWorkflowVersionCache } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache'; export const useCreateWorkflowVersionStep = () => { const apolloCoreClient = useApolloCoreClient(); - const { objectMetadataItems } = useObjectMetadataItems(); - const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); - const { objectMetadataItem } = useObjectMetadataItem({ - objectNameSingular: CoreObjectNameSingular.WorkflowVersion, - }); - const getRecordFromCache = useGetRecordFromCache({ - objectNameSingular: CoreObjectNameSingular.WorkflowVersion, - }); + + const { updateWorkflowVersionCache } = useUpdateWorkflowVersionCache(); + const [mutate] = useMutation< CreateWorkflowVersionStepMutation, CreateWorkflowVersionStepMutationVariables >(CREATE_WORKFLOW_VERSION_STEP, { client: apolloCoreClient, }); + const createWorkflowVersionStep = async ( input: CreateWorkflowVersionStepInput, ) => { @@ -38,57 +27,13 @@ export const useCreateWorkflowVersionStep = () => { variables: { input }, }); - const insertedStep = result?.data?.createWorkflowVersionStep; + const workflowVersionStepChanges = result?.data?.createWorkflowVersionStep; - if (!isDefined(insertedStep)) { - return; - } - - const cachedRecord = getRecordFromCache( - input.workflowVersionId, - ); - - if (!isDefined(cachedRecord)) { - return; - } - - const { parentStepId, nextStepId } = input; - - const updatedExistingSteps = - cachedRecord.steps?.map((existingStep) => { - if (existingStep.id === parentStepId) { - return { - ...existingStep, - nextStepIds: [ - ...new Set([ - ...(existingStep.nextStepIds?.filter( - (id) => id !== nextStepId, - ) || []), - insertedStep.id, - ]), - ], - }; - } - return existingStep; - }) ?? []; - - const newCachedRecord = { - ...cachedRecord, - steps: [...updatedExistingSteps, insertedStep], - }; - - const recordGqlFields = { - steps: true, - }; - - updateRecordFromCache({ - objectMetadataItems, - objectMetadataItem, - cache: apolloCoreClient.cache, - record: newCachedRecord, - recordGqlFields, - objectPermissionsByObjectMetadataId, + updateWorkflowVersionCache({ + workflowVersionStepChanges, + workflowVersionId: input.workflowVersionId, }); + return result; }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteEdge.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteEdge.ts new file mode 100644 index 0000000000..ddcad4faf1 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteEdge.ts @@ -0,0 +1,50 @@ +import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow'; +import { useDeleteWorkflowVersionEdge } from '@/workflow/workflow-steps/hooks/useDeleteWorkflowVersionEdge'; +import { useState } from 'react'; +import { useGetUpdatableWorkflowVersion } from '@/workflow/hooks/useGetUpdatableWorkflowVersion'; + +export const useDeleteEdge = ({ + workflow, +}: { + workflow: WorkflowWithCurrentVersion; +}) => { + const [isLoading, setIsLoading] = useState(false); + + const { deleteWorkflowVersionEdge } = useDeleteWorkflowVersionEdge(); + + const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion(); + + const deleteEdge = async ({ + source, + target, + }: { + source: string; + target: string; + }) => { + if (isLoading === true) { + return; + } + + setIsLoading(true); + + try { + const workflowVersionId = await getUpdatableWorkflowVersion(workflow); + + const deletedEdge = ( + await deleteWorkflowVersionEdge({ + workflowVersionId, + source, + target, + }) + )?.data?.deleteWorkflowVersionEdge; + + if (!deletedEdge) { + return; + } + } finally { + setIsLoading(false); + } + }; + + return { deleteEdge }; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteWorkflowVersionEdge.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteWorkflowVersionEdge.ts new file mode 100644 index 0000000000..a576e3062e --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteWorkflowVersionEdge.ts @@ -0,0 +1,37 @@ +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { useMutation } from '@apollo/client'; +import { CreateWorkflowVersionEdgeInput } from '~/generated/graphql'; +import { DELETE_WORKFLOW_VERSION_EDGE } from '@/workflow/graphql/mutations/deleteWorkflowVersionEdge'; +import { + DeleteWorkflowVersionEdgeMutation, + DeleteWorkflowVersionEdgeMutationVariables, +} from '~/generated-metadata/graphql'; +import { useUpdateWorkflowVersionCache } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache'; + +export const useDeleteWorkflowVersionEdge = () => { + const apolloCoreClient = useApolloCoreClient(); + + const { updateWorkflowVersionCache } = useUpdateWorkflowVersionCache(); + + const [mutate] = useMutation< + DeleteWorkflowVersionEdgeMutation, + DeleteWorkflowVersionEdgeMutationVariables + >(DELETE_WORKFLOW_VERSION_EDGE, { client: apolloCoreClient }); + + const deleteWorkflowVersionEdge = async ( + input: CreateWorkflowVersionEdgeInput, + ) => { + const result = await mutate({ variables: { input } }); + + const workflowVersionStepChanges = result?.data?.deleteWorkflowVersionEdge; + + updateWorkflowVersionCache({ + workflowVersionStepChanges, + workflowVersionId: input.workflowVersionId, + }); + + return result; + }; + + return { deleteWorkflowVersionEdge }; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache.ts new file mode 100644 index 0000000000..1873a075b0 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache.ts @@ -0,0 +1,95 @@ +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache'; +import { isDefined } from 'twenty-shared/utils'; +import { WorkflowVersion } from '@/workflow/types/Workflow'; +import { + WorkflowAction, + WorkflowVersionStepChanges, +} from '~/generated/graphql'; +import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache'; + +export const useUpdateWorkflowVersionCache = () => { + const apolloCoreClient = useApolloCoreClient(); + + const { objectMetadataItems } = useObjectMetadataItems(); + + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular: CoreObjectNameSingular.WorkflowVersion, + }); + + const getRecordFromCache = useGetRecordFromCache({ + objectNameSingular: CoreObjectNameSingular.WorkflowVersion, + }); + + const updateWorkflowVersionCache = ({ + workflowVersionStepChanges, + workflowVersionId, + }: { + workflowVersionStepChanges: WorkflowVersionStepChanges | undefined; + workflowVersionId: string; + }) => { + if (!isDefined(workflowVersionStepChanges)) { + return; + } + + const cachedRecord = getRecordFromCache(workflowVersionId); + + if (!isDefined(cachedRecord)) { + return; + } + + const { triggerNextStepIds, stepsNextStepIds, createdStep, deletedStepId } = + workflowVersionStepChanges; + + const newCachedRecord = { + ...cachedRecord, + trigger: isDefined(cachedRecord.trigger) + ? { + ...cachedRecord.trigger, + nextStepIds: triggerNextStepIds, + } + : cachedRecord.trigger, + steps: (cachedRecord.steps || []).map((step: WorkflowAction) => ({ + ...step, + nextStepIds: stepsNextStepIds[step.id] ?? step.nextStepIds, + })), + }; + + if (isDefined(createdStep)) { + const formattedCreatedStep = { + ...createdStep, + nextStepIds: createdStep.nextStepIds || [], + }; + + newCachedRecord.steps.push(formattedCreatedStep); + } + + if (isDefined(deletedStepId)) { + newCachedRecord.steps = newCachedRecord.steps.filter( + (step) => step.id !== deletedStepId, + ); + } + + const recordGqlFields = { + steps: true, + trigger: true, + }; + + updateRecordFromCache({ + objectMetadataItems, + objectMetadataItem, + cache: apolloCoreClient.cache, + record: newCachedRecord, + recordGqlFields, + objectPermissionsByObjectMetadataId, + }); + }; + + return { updateWorkflowVersionCache }; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/states/workflowInsertStepIdsComponentState.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/states/workflowInsertStepIdsComponentState.ts index 73f9453751..10d0a79b6e 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/states/workflowInsertStepIdsComponentState.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/states/workflowInsertStepIdsComponentState.ts @@ -4,11 +4,16 @@ import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow- type WorkflowInsertStepIdsState = { parentStepId: string | undefined; nextStepId: string | undefined; + position?: { x: number; y: number }; }; export const workflowInsertStepIdsComponentState = createComponentStateV2({ key: 'workflowInsertStepIdsComponentState', - defaultValue: { parentStepId: undefined, nextStepId: undefined }, + defaultValue: { + parentStepId: undefined, + nextStepId: undefined, + position: undefined, + }, componentInstanceContext: WorkflowVisualizerComponentInstanceContext, }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm.tsx b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm.tsx index 5221273523..5b690833bb 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm.tsx @@ -9,7 +9,7 @@ import { CRON_TRIGGER_INTERVAL_OPTIONS } from '@/workflow/workflow-trigger/const import { getCronTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getCronTriggerDefaultSettings'; import { getTriggerHeaderType } from '@/workflow/workflow-trigger/utils/getTriggerHeaderType'; import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon'; -import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerLabel'; +import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel'; import { useTheme } from '@emotion/react'; import { t } from '@lingui/core/macro'; import { isNumber } from '@sniptt/guards'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm.tsx b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm.tsx index 5e90aff56f..11fe3d1725 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm.tsx @@ -17,7 +17,7 @@ import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowS import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader'; import { getTriggerHeaderType } from '@/workflow/workflow-trigger/utils/getTriggerHeaderType'; import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon'; -import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerLabel'; +import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { Trans } from '@lingui/react/macro'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerManualForm.tsx b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerManualForm.tsx index a78cc10068..72899ace16 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerManualForm.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerManualForm.tsx @@ -10,7 +10,7 @@ import { MANUAL_TRIGGER_AVAILABILITY_OPTIONS } from '@/workflow/workflow-trigger import { getManualTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getManualTriggerDefaultSettings'; import { getTriggerHeaderType } from '@/workflow/workflow-trigger/utils/getTriggerHeaderType'; import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon'; -import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerLabel'; +import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel'; import { useTheme } from '@emotion/react'; import { isDefined } from 'twenty-shared/utils'; import { useIcons } from 'twenty-ui/display'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx index 69b3218e88..ae0e47cda6 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx @@ -14,7 +14,7 @@ import { WEBHOOK_TRIGGER_AUTHENTICATION_OPTIONS } from '@/workflow/workflow-trig import { WEBHOOK_TRIGGER_HTTP_METHOD_OPTIONS } from '@/workflow/workflow-trigger/constants/WebhookTriggerHttpMethodOptions'; import { getTriggerHeaderType } from '@/workflow/workflow-trigger/utils/getTriggerHeaderType'; import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon'; -import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerLabel'; +import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel'; import { getWebhookTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getWebhookTriggerDefaultSettings'; import { useTheme } from '@emotion/react'; import { isNonEmptyString } from '@sniptt/guards'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/hooks/__tests__/useUpdateWorkflowVersionTrigger.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/hooks/__tests__/useUpdateWorkflowVersionTrigger.test.ts new file mode 100644 index 0000000000..2a8743d0be --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/hooks/__tests__/useUpdateWorkflowVersionTrigger.test.ts @@ -0,0 +1,98 @@ +import { act, renderHook } from '@testing-library/react'; +import { useUpdateWorkflowVersionTrigger } from '@/workflow/workflow-trigger/hooks/useUpdateWorkflowVersionTrigger'; +import { + WorkflowTrigger, + WorkflowWithCurrentVersion, +} from '@/workflow/types/Workflow'; + +const mockUpdateOneRecord = jest.fn(); +const mockGetUpdatableWorkflowVersion = jest.fn(); +const mockComputeStepOutputSchema = jest.fn(); + +jest.mock('@/object-record/hooks/useUpdateOneRecord', () => ({ + useUpdateOneRecord: jest.fn(() => ({ + updateOneRecord: mockUpdateOneRecord, + })), +})); + +jest.mock('@/workflow/hooks/useGetUpdatableWorkflowVersion', () => ({ + useGetUpdatableWorkflowVersion: jest.fn(() => ({ + getUpdatableWorkflowVersion: mockGetUpdatableWorkflowVersion, + })), +})); + +jest.mock('@/workflow/hooks/useComputeStepOutputSchema', () => ({ + useComputeStepOutputSchema: jest.fn(() => ({ + computeStepOutputSchema: mockComputeStepOutputSchema, + })), +})); + +describe('useUpdateWorkflowVersionTrigger', () => { + const mockWorkflow = { + id: 'workflow-id', + currentVersion: { id: 'version-id' }, + } as WorkflowWithCurrentVersion; + + const trigger: WorkflowTrigger = { + name: 'Company created', + type: 'DATABASE_EVENT', + settings: { + eventName: 'company.created', + outputSchema: {}, + }, + nextStepIds: ['step1'], + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('updates the trigger with computed output schema', async () => { + mockGetUpdatableWorkflowVersion.mockResolvedValue('version-id'); + mockComputeStepOutputSchema.mockResolvedValue({ + data: { computeStepOutputSchema: { field1: 'string' } }, + }); + + const { result } = renderHook(() => + useUpdateWorkflowVersionTrigger({ workflow: mockWorkflow }), + ); + + await act(async () => { + await result.current.updateTrigger(trigger); + }); + + expect(mockGetUpdatableWorkflowVersion).toHaveBeenCalledWith(mockWorkflow); + expect(mockComputeStepOutputSchema).toHaveBeenCalledWith({ step: trigger }); + expect(mockUpdateOneRecord).toHaveBeenCalledWith({ + idToUpdate: 'version-id', + updateOneRecordInput: { + trigger: { + ...trigger, + settings: { ...trigger.settings, outputSchema: { field1: 'string' } }, + }, + }, + }); + }); + + it('skips output schema computation when disabled', async () => { + mockGetUpdatableWorkflowVersion.mockResolvedValue('version-id'); + + const { result } = renderHook(() => + useUpdateWorkflowVersionTrigger({ workflow: mockWorkflow }), + ); + + await act(async () => { + await result.current.updateTrigger(trigger, { + computeOutputSchema: false, + }); + }); + + expect(mockComputeStepOutputSchema).not.toHaveBeenCalled(); + expect(mockUpdateOneRecord).toHaveBeenCalledWith({ + idToUpdate: 'version-id', + updateOneRecordInput: { + trigger, + }, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/__tests__/getTriggerDefaultDefinition.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/__tests__/getTriggerDefaultDefinition.test.ts index 28c700e080..96701ef3c2 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/__tests__/getTriggerDefaultDefinition.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/__tests__/getTriggerDefaultDefinition.test.ts @@ -28,6 +28,11 @@ describe('getTriggerDefaultDefinition', () => { eventName: `${generatedMockObjectMetadataItems[0].nameSingular}.created`, outputSchema: {}, }, + nextStepIds: [], + position: { + x: 0, + y: 0, + }, }); }); @@ -45,6 +50,11 @@ describe('getTriggerDefaultDefinition', () => { eventName: `${generatedMockObjectMetadataItems[0].nameSingular}.updated`, outputSchema: {}, }, + nextStepIds: [], + position: { + x: 0, + y: 0, + }, }); }); @@ -62,6 +72,11 @@ describe('getTriggerDefaultDefinition', () => { eventName: `${generatedMockObjectMetadataItems[0].nameSingular}.deleted`, outputSchema: {}, }, + nextStepIds: [], + position: { + x: 0, + y: 0, + }, }); }); @@ -79,6 +94,11 @@ describe('getTriggerDefaultDefinition', () => { eventName: `${generatedMockObjectMetadataItems[0].nameSingular}.created`, outputSchema: {}, }, + nextStepIds: [], + position: { + x: 0, + y: 0, + }, }); }); @@ -97,6 +117,11 @@ describe('getTriggerDefaultDefinition', () => { outputSchema: {}, icon: COMMAND_MENU_DEFAULT_ICON, }, + nextStepIds: [], + position: { + x: 0, + y: 0, + }, }); }); @@ -115,6 +140,11 @@ describe('getTriggerDefaultDefinition', () => { schedule: { day: 1, hour: 0, minute: 0 }, outputSchema: {}, }, + nextStepIds: [], + position: { + x: 0, + y: 0, + }, }); }); @@ -133,6 +163,11 @@ describe('getTriggerDefaultDefinition', () => { httpMethod: 'GET', authentication: null, }, + nextStepIds: [], + position: { + x: 0, + y: 0, + }, }); }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/__tests__/getTriggerDefaultLabel.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/__tests__/getTriggerDefaultLabel.test.ts index 0a80f46f82..516665e711 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/__tests__/getTriggerDefaultLabel.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/__tests__/getTriggerDefaultLabel.test.ts @@ -1,6 +1,6 @@ import { WorkflowTrigger } from '@/workflow/types/Workflow'; import { DatabaseTriggerDefaultLabel } from '@/workflow/workflow-trigger/constants/DatabaseTriggerDefaultLabel'; -import { getTriggerDefaultLabel } from '../getTriggerLabel'; +import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel'; describe('getTriggerDefaultLabel', () => { describe('DATABASE_EVENT triggers', () => { diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/__tests__/getTriggerIcon.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/__tests__/getTriggerIcon.test.ts new file mode 100644 index 0000000000..d0d96171da --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/__tests__/getTriggerIcon.test.ts @@ -0,0 +1,39 @@ +import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon'; + +describe('getTriggerIcon', () => { + it('should return proper Icon for DATABASE_EVENT', () => { + expect( + getTriggerIcon({ + type: 'DATABASE_EVENT', + settings: { eventName: 'company.created', outputSchema: {} }, + }), + ).toBe('IconPlaylistAdd'); + }); + + it('should return proper Icon for MANUAL', () => { + expect( + getTriggerIcon({ + type: 'MANUAL', + settings: { outputSchema: {} }, + }), + ).toBe('IconHandMove'); + }); + + it('should return proper Icon for CRON', () => { + expect( + getTriggerIcon({ + type: 'CRON', + settings: { outputSchema: {}, type: 'CUSTOM', pattern: '' }, + }), + ).toBe('IconClock'); + }); + + it('should return proper Icon for CRON', () => { + expect( + getTriggerIcon({ + type: 'WEBHOOK', + settings: { outputSchema: {}, httpMethod: 'GET', authentication: null }, + }), + ).toBe('IconWebhook'); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getRootStepIds.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getRootStepIds.ts new file mode 100644 index 0000000000..ec59ae2890 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getRootStepIds.ts @@ -0,0 +1,11 @@ +import { WorkflowAction } from '@/workflow/types/Workflow'; + +export const getRootStepIds = (steps: WorkflowAction[]): string[] => { + const childIds = new Set(); + + for (const step of steps) { + step.nextStepIds?.forEach((id) => childIds.add(id)); + } + + return steps.filter((step) => !childIds.has(step.id)).map((step) => step.id); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getTriggerDefaultDefinition.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getTriggerDefaultDefinition.ts index eb31ef867c..594d210cdf 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getTriggerDefaultDefinition.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getTriggerDefaultDefinition.ts @@ -1,20 +1,26 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { + WorkflowAction, WorkflowTrigger, WorkflowTriggerType, } from '@/workflow/types/Workflow'; import { assertUnreachable } from '@/workflow/utils/assertUnreachable'; import { DATABASE_TRIGGER_TYPES } from '@/workflow/workflow-trigger/constants/DatabaseTriggerTypes'; import { getManualTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getManualTriggerDefaultSettings'; +import { isDefined } from 'twenty-shared/utils'; +import { getRootStepIds } from '@/workflow/workflow-trigger/utils/getRootStepIds'; +// TODO: This needs to be migrated to the server export const getTriggerDefaultDefinition = ({ defaultLabel, type, activeNonSystemObjectMetadataItems, + steps, }: { defaultLabel: string; type: WorkflowTriggerType; activeNonSystemObjectMetadataItems: ObjectMetadataItem[]; + steps?: WorkflowAction[] | null; }): WorkflowTrigger => { if (activeNonSystemObjectMetadataItems.length === 0) { throw new Error( @@ -22,11 +28,19 @@ export const getTriggerDefaultDefinition = ({ ); } + const nextStepIds = isDefined(steps) ? getRootStepIds(steps) : []; + + const baseTriggerDefinition = { + name: defaultLabel, + position: { x: 0, y: 0 }, + nextStepIds, + }; + switch (type) { case 'DATABASE_EVENT': { return { + ...baseTriggerDefinition, type, - name: defaultLabel, settings: { eventName: `${activeNonSystemObjectMetadataItems[0].nameSingular}.${ DATABASE_TRIGGER_TYPES.find( @@ -39,8 +53,8 @@ export const getTriggerDefaultDefinition = ({ } case 'MANUAL': { return { + ...baseTriggerDefinition, type, - name: defaultLabel, settings: getManualTriggerDefaultSettings({ availability: 'WHEN_RECORD_SELECTED', activeNonSystemObjectMetadataItems, @@ -49,8 +63,8 @@ export const getTriggerDefaultDefinition = ({ } case 'CRON': { return { + ...baseTriggerDefinition, type, - name: defaultLabel, settings: { type: 'DAYS', schedule: { day: 1, hour: 0, minute: 0 }, @@ -60,8 +74,8 @@ export const getTriggerDefaultDefinition = ({ } case 'WEBHOOK': { return { + ...baseTriggerDefinition, type, - name: defaultLabel, settings: { outputSchema: {}, httpMethod: 'GET', diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getTriggerLabel.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getTriggerDefaultLabel.ts similarity index 100% rename from packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getTriggerLabel.ts rename to packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getTriggerDefaultLabel.ts diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getTriggerHeaderType.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getTriggerHeaderType.ts index 8e32e008fb..4c14db09eb 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getTriggerHeaderType.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getTriggerHeaderType.ts @@ -1,5 +1,5 @@ import { WorkflowTrigger } from '@/workflow/types/Workflow'; -import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerLabel'; +import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel'; import { assertUnreachable } from 'twenty-shared/utils'; export const getTriggerHeaderType = (trigger: WorkflowTrigger) => { diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/isBaseOutputSchema.test.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/isBaseOutputSchema.test.ts new file mode 100644 index 0000000000..e3d5672f6b --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/isBaseOutputSchema.test.ts @@ -0,0 +1,14 @@ +import { isBaseOutputSchema } from '@/workflow/workflow-variables/utils/isBaseOutputSchema'; + +describe('isBaseOutputSchema', () => { + // This looks weird, but that is the way this method was built + it('should return false for base output schema', () => { + expect( + isBaseOutputSchema({ _outputSchemaType: 'LINK', link: { isLeaf: true } }), + ).toBe(false); + }); + + it('should return true in other cases', () => { + expect(isBaseOutputSchema({})).toBe(true); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/isFieldTypeCompatibleWithRecordId.test.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/isFieldTypeCompatibleWithRecordId.test.ts new file mode 100644 index 0000000000..17f02396ae --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/isFieldTypeCompatibleWithRecordId.test.ts @@ -0,0 +1,16 @@ +import { isFieldTypeCompatibleWithRecordId } from '@/workflow/workflow-variables/utils/isFieldTypeCompatibleWithRecordId'; + +describe('isFieldTypeCompatibleWithRecordId', () => { + it('should return true', () => { + expect(isFieldTypeCompatibleWithRecordId('string')).toBe(true); + expect(isFieldTypeCompatibleWithRecordId('unknown')).toBe(true); + expect(isFieldTypeCompatibleWithRecordId()).toBe(true); + expect(isFieldTypeCompatibleWithRecordId(undefined)).toBe(true); + }); + it('should return false', () => { + expect(isFieldTypeCompatibleWithRecordId('number')).toBe(false); + expect(isFieldTypeCompatibleWithRecordId('boolean')).toBe(false); + expect(isFieldTypeCompatibleWithRecordId('object')).toBe(false); + expect(isFieldTypeCompatibleWithRecordId('array')).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-version/graphql/mutations/updateWorkflowVersionPositions.ts b/packages/twenty-front/src/modules/workflow/workflow-version/graphql/mutations/updateWorkflowVersionPositions.ts new file mode 100644 index 0000000000..55d9dfbe36 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-version/graphql/mutations/updateWorkflowVersionPositions.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const UPDATE_WORKFLOW_VERSION_POSITIONS = gql` + mutation UpdateWorkflowVersionPositions( + $input: UpdateWorkflowVersionPositionsInput! + ) { + updateWorkflowVersionPositions(input: $input) + } +`; diff --git a/packages/twenty-front/src/modules/workflow/workflow-version/hooks/useTidyUpWorkflowVersion.ts b/packages/twenty-front/src/modules/workflow/workflow-version/hooks/useTidyUpWorkflowVersion.ts new file mode 100644 index 0000000000..138d66ea18 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-version/hooks/useTidyUpWorkflowVersion.ts @@ -0,0 +1,131 @@ +import { + WorkflowVersion, + WorkflowWithCurrentVersion, +} from '@/workflow/types/Workflow'; +import { useGetUpdatableWorkflowVersion } from '@/workflow/hooks/useGetUpdatableWorkflowVersion'; +import { isDefined } from 'twenty-shared/utils'; +import { useMutation } from '@apollo/client'; +import { + UpdateWorkflowVersionPositionsMutation, + UpdateWorkflowVersionPositionsMutationVariables, + WorkflowAction, +} from '~/generated-metadata/graphql'; + +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache'; +import { UPDATE_WORKFLOW_VERSION_POSITIONS } from '@/workflow/workflow-version/graphql/mutations/updateWorkflowVersionPositions'; +import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; +import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState'; +import { getOrganizedDiagram } from '@/workflow/workflow-diagram/utils/getOrganizedDiagram'; + +export const useTidyUpWorkflowVersion = ({ + workflow, +}: { + workflow?: WorkflowWithCurrentVersion; +}) => { + const [workflowDiagram, setWorkflowDiagram] = useRecoilComponentStateV2( + workflowDiagramComponentState, + ); + + const apolloCoreClient = useApolloCoreClient(); + + const { objectMetadataItems } = useObjectMetadataItems(); + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular: CoreObjectNameSingular.WorkflowVersion, + }); + const getRecordFromCache = useGetRecordFromCache({ + objectNameSingular: CoreObjectNameSingular.WorkflowVersion, + }); + + const [mutate] = useMutation< + UpdateWorkflowVersionPositionsMutation, + UpdateWorkflowVersionPositionsMutationVariables + >(UPDATE_WORKFLOW_VERSION_POSITIONS, { client: apolloCoreClient }); + + const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion(); + + const updateWorkflowVersionPosition = async ( + positions: { id: string; position: { x: number; y: number } }[], + ) => { + if (!isDefined(workflow?.currentVersion)) { + throw new Error('Can not update an undefined workflow version.'); + } + + const workflowVersionId = await getUpdatableWorkflowVersion(workflow); + + await mutate({ variables: { input: { workflowVersionId, positions } } }); + + const cachedRecord = getRecordFromCache(workflowVersionId); + + if (!isDefined(cachedRecord)) { + return; + } + + const triggerPosition = positions.find( + (position) => position.id === 'trigger', + ); + + const updatedTrigger = + isDefined(triggerPosition) && isDefined(cachedRecord.trigger) + ? { ...cachedRecord.trigger, position: triggerPosition.position } + : cachedRecord.trigger; + + const updatedSteps = cachedRecord.steps?.map((step: WorkflowAction) => { + const stepPosition = positions.find( + (position) => position.id === step.id, + ); + + if (!isDefined(stepPosition)) { + return step; + } + + return { ...step, position: stepPosition.position }; + }); + + const newCachedRecord = { + ...cachedRecord, + trigger: updatedTrigger, + steps: updatedSteps, + }; + + const recordGqlFields = { + trigger: true, + steps: true, + }; + + updateRecordFromCache({ + objectMetadataItems, + objectMetadataItem, + cache: apolloCoreClient.cache, + record: newCachedRecord, + recordGqlFields, + objectPermissionsByObjectMetadataId, + }); + }; + + const tidyUpWorkflowVersion = async () => { + if (!isDefined(workflowDiagram) || !isDefined(workflow?.currentVersion)) { + return; + } + + const tidiedUpDiagram = getOrganizedDiagram(workflowDiagram); + + const positions = tidiedUpDiagram.nodes.map((node) => ({ + id: node.id, + position: node.position, + })); + + await updateWorkflowVersionPosition(positions); + + setWorkflowDiagram(tidiedUpDiagram); + }; + + return { tidyUpWorkflowVersion }; +}; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-3/1-3-add-next-step-ids-to-workflow-runs-trigger.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-3/1-3-add-next-step-ids-to-workflow-runs-trigger.command.ts new file mode 100644 index 0000000000..3a415fe2c2 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-3/1-3-add-next-step-ids-to-workflow-runs-trigger.command.ts @@ -0,0 +1,92 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { Repository } from 'typeorm'; +import { isDefined } from 'twenty-shared/utils'; + +import { + ActiveOrSuspendedWorkspacesMigrationCommandRunner, + RunOnWorkspaceArgs, +} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; +import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util'; + +@Command({ + name: 'upgrade:1-3:add-next-step-ids-to-workflow-runs-trigger', + description: 'Add next step ids to workflow runs trigger', +}) +export class AddNextStepIdsToWorkflowRunsTrigger extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { + constructor( + @InjectRepository(Workspace, 'core') + protected readonly workspaceRepository: Repository, + private readonly workspaceDataSourceService: WorkspaceDataSourceService, + protected readonly twentyORMGlobalManager: TwentyORMGlobalManager, + ) { + super(workspaceRepository, twentyORMGlobalManager); + } + + override async runOnWorkspace({ + workspaceId, + }: RunOnWorkspaceArgs): Promise { + const mainDataSource = + await this.workspaceDataSourceService.connectToMainDataSource(); + + const schemaName = getWorkspaceSchemaName(workspaceId); + + const workflowRuns = await mainDataSource.query( + `SELECT id, state FROM ${schemaName}."workflowRun"`, + ); + + let updatedWorkflowRunCount = 0; + + for (const workflowRun of workflowRuns) { + try { + if (isDefined(workflowRun.state.flow.trigger.nextStepIds)) { + continue; + } + + const rootSteps = this.getRootSteps(workflowRun.state.flow.steps || []); + + const nextStepIds = rootSteps.map((step) => step.id); + + const updatedState = { + ...workflowRun.state, + flow: { + ...workflowRun.state.flow, + trigger: { + ...workflowRun.state.flow.trigger, + nextStepIds, + }, + }, + }; + + await mainDataSource.query( + `UPDATE ${schemaName}."workflowRun" SET state = '${JSON.stringify(updatedState)}'::jsonb`, + ); + + updatedWorkflowRunCount += 1; + } catch (error) { + this.logger.error( + `Error while adding nextStepIds to workflowRun state '${workflowRun.id}'`, + error, + ); + } + } + this.logger.log( + `${updatedWorkflowRunCount}/${workflowRuns.length} triggers updated`, + ); + } + + private getRootSteps(steps: WorkflowAction[]): WorkflowAction[] { + const childIds = new Set(); + + for (const step of steps) { + step.nextStepIds?.forEach((id) => childIds.add(id)); + } + + return steps.filter((step) => !childIds.has(step.id)); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-3/1-3-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-3/1-3-upgrade-version-command.module.ts index 728e80ee22..0d7e409b1a 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-3/1-3-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-3/1-3-upgrade-version-command.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { AddNextStepIdsToWorkflowRunsTrigger } from 'src/database/commands/upgrade-version-command/1-3/1-3-add-next-step-ids-to-workflow-runs-trigger.command'; import { AssignRolesToExistingApiKeysCommand } from 'src/database/commands/upgrade-version-command/1-3/1-3-assign-roles-to-existing-api-keys.command'; import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity'; import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; @@ -14,6 +15,7 @@ import { RoleModule } from 'src/engine/metadata-modules/role/role.module'; import { WorkspaceFeatureFlagsMapCacheModule } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.module'; import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module'; import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; +import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; @Module({ imports: [ @@ -28,6 +30,7 @@ import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; ], 'core', ), + WorkspaceDataSourceModule, ApiKeyModule, FeatureFlagModule, TwentyORMModule, @@ -35,7 +38,13 @@ import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; WorkspacePermissionsCacheModule, WorkspaceFeatureFlagsMapCacheModule, ], - providers: [AssignRolesToExistingApiKeysCommand], - exports: [AssignRolesToExistingApiKeysCommand], + providers: [ + AssignRolesToExistingApiKeysCommand, + AddNextStepIdsToWorkflowRunsTrigger, + ], + exports: [ + AssignRolesToExistingApiKeysCommand, + AddNextStepIdsToWorkflowRunsTrigger, + ], }) export class V1_3_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts index 0b54a23d67..34cba19237 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts @@ -34,6 +34,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { SyncWorkspaceMetadataCommand } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command'; import { compareVersionMajorAndMinor } from 'src/utils/version/compare-version-minor-and-major'; +import { AddNextStepIdsToWorkflowRunsTrigger } from 'src/database/commands/upgrade-version-command/1-3/1-3-add-next-step-ids-to-workflow-runs-trigger.command'; const execPromise = promisify(exec); @@ -157,6 +158,8 @@ export class UpgradeCommand extends UpgradeCommandRunner { // 1.3 Commands protected readonly assignRolesToExistingApiKeysCommand: AssignRolesToExistingApiKeysCommand, + // protected readonly addNextStepIdsToWorkflowVersionTriggers: AddNextStepIdsToWorkflowVersionTriggers, + protected readonly addNextStepIdsToWorkflowRunsTrigger: AddNextStepIdsToWorkflowRunsTrigger, ) { super( workspaceRepository, @@ -216,7 +219,10 @@ export class UpgradeCommand extends UpgradeCommandRunner { }; const commands_130: VersionCommands = { - beforeSyncMetadata: [], + beforeSyncMetadata: [ + this.addNextStepIdsToWorkflowVersionTriggers, // We add that command again because nextStepIds where not added on freshly created triggers. It will be done in 1.3 + this.addNextStepIdsToWorkflowRunsTrigger, + ], afterSyncMetadata: [], }; diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index b87c4ef844..c64b690fe2 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -8,6 +8,7 @@ export enum FeatureFlagKey { IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED', IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED', IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED', + IS_WORKFLOW_BRANCH_ENABLED = 'IS_WORKFLOW_BRANCH_ENABLED', IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED', IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED = 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED', IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED', diff --git a/packages/twenty-server/src/engine/core-modules/workflow/dtos/create-draft-from-workflow-version-input.ts b/packages/twenty-server/src/engine/core-modules/workflow/dtos/create-draft-from-workflow-version-input.dto.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/workflow/dtos/create-draft-from-workflow-version-input.ts rename to packages/twenty-server/src/engine/core-modules/workflow/dtos/create-draft-from-workflow-version-input.dto.ts diff --git a/packages/twenty-server/src/engine/core-modules/workflow/dtos/create-workflow-version-edge-input.dto.ts b/packages/twenty-server/src/engine/core-modules/workflow/dtos/create-workflow-version-edge-input.dto.ts new file mode 100644 index 0000000000..e7a21a3a1d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workflow/dtos/create-workflow-version-edge-input.dto.ts @@ -0,0 +1,22 @@ +import { Field, InputType } from '@nestjs/graphql'; + +@InputType() +export class CreateWorkflowVersionEdgeInput { + @Field(() => String, { + description: 'Workflow version ID', + nullable: false, + }) + workflowVersionId: string; + + @Field(() => String, { + description: 'Workflow version source step ID', + nullable: false, + }) + source: string; + + @Field(() => String, { + description: 'Workflow version target step ID', + nullable: false, + }) + target: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto.ts b/packages/twenty-server/src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto.ts index 9b9edc9d1a..6489782bbf 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto.ts @@ -2,6 +2,7 @@ import { Field, InputType } from '@nestjs/graphql'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { WorkflowStepPositionInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-step-position-input.dto'; @InputType() export class CreateWorkflowVersionStepInput { @@ -17,7 +18,8 @@ export class CreateWorkflowVersionStepInput { }) stepType: WorkflowActionType; - @Field(() => UUIDScalarType, { + // Typed String as it can be 'trigger' + @Field(() => String, { description: 'Parent step ID', nullable: true, }) @@ -28,4 +30,10 @@ export class CreateWorkflowVersionStepInput { nullable: true, }) nextStepId?: string; + + @Field(() => WorkflowStepPositionInput, { + description: 'Step position', + nullable: true, + }) + position?: WorkflowStepPositionInput; } diff --git a/packages/twenty-server/src/engine/core-modules/workflow/dtos/update-workflow-step-position-input.dto.ts b/packages/twenty-server/src/engine/core-modules/workflow/dtos/update-workflow-step-position-input.dto.ts new file mode 100644 index 0000000000..de07e4cb39 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workflow/dtos/update-workflow-step-position-input.dto.ts @@ -0,0 +1,10 @@ +import { Field, InputType } from '@nestjs/graphql'; + +@InputType() +export class WorkflowStepPositionInput { + @Field(() => Number) + x: number; + + @Field(() => Number) + y: number; +} diff --git a/packages/twenty-server/src/engine/core-modules/workflow/dtos/update-workflow-step-position-update-input.dto.ts b/packages/twenty-server/src/engine/core-modules/workflow/dtos/update-workflow-step-position-update-input.dto.ts new file mode 100644 index 0000000000..b6a4511adb --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workflow/dtos/update-workflow-step-position-update-input.dto.ts @@ -0,0 +1,18 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { WorkflowStepPositionInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-step-position-input.dto'; + +@InputType() +export class WorkflowStepPositionUpdateInput { + @Field(() => String, { + description: 'Step or trigger ID', + nullable: false, + }) + id: string; + + @Field(() => WorkflowStepPositionInput, { + description: 'Position of the step or trigger', + nullable: false, + }) + position: WorkflowStepPositionInput; +} diff --git a/packages/twenty-server/src/engine/core-modules/workflow/dtos/update-workflow-version-positions-input.dto.ts b/packages/twenty-server/src/engine/core-modules/workflow/dtos/update-workflow-version-positions-input.dto.ts new file mode 100644 index 0000000000..5b11066bb4 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workflow/dtos/update-workflow-version-positions-input.dto.ts @@ -0,0 +1,19 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { WorkflowStepPositionUpdateInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-step-position-update-input.dto'; +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@InputType() +export class UpdateWorkflowVersionPositionsInput { + @Field(() => UUIDScalarType, { + description: 'Workflow version ID', + nullable: false, + }) + workflowVersionId: string; + + @Field(() => [WorkflowStepPositionUpdateInput], { + description: 'Workflow version updated positions', + nullable: false, + }) + positions: WorkflowStepPositionUpdateInput[]; +} diff --git a/packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-step-position.dto.ts b/packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-step-position.dto.ts new file mode 100644 index 0000000000..97e9eebed6 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-step-position.dto.ts @@ -0,0 +1,10 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType('WorkflowStepPosition') +export class WorkflowStepPosition { + @Field(() => Number) + x: number; + + @Field(() => Number) + y: number; +} diff --git a/packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-step.dto.ts b/packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-step.dto.ts index 85717b99d4..1f88d200dc 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-step.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-step.dto.ts @@ -4,6 +4,7 @@ import graphqlTypeJson from 'graphql-type-json'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { WorkflowStepPosition } from 'src/engine/core-modules/workflow/dtos/workflow-step-position.dto'; @ObjectType('WorkflowAction') export class WorkflowActionDTO { @@ -24,4 +25,7 @@ export class WorkflowActionDTO { @Field(() => [UUIDScalarType], { nullable: true }) nextStepIds?: string[]; + + @Field(() => WorkflowStepPosition, { nullable: true }) + position?: WorkflowStepPosition; } diff --git a/packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto.ts b/packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto.ts new file mode 100644 index 0000000000..893cb4d81d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto.ts @@ -0,0 +1,20 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import graphqlTypeJson from 'graphql-type-json'; + +import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-step.dto'; + +@ObjectType('WorkflowVersionStepChanges') +export class WorkflowVersionStepChangesDTO { + @Field(() => [String], { nullable: true }) + triggerNextStepIds?: string[]; + + @Field(() => graphqlTypeJson, { nullable: true }) + stepsNextStepIds?: Record; + + @Field(() => WorkflowActionDTO, { nullable: true }) + createdStep?: WorkflowActionDTO; + + @Field(() => String, { nullable: true }) + deletedStepId?: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-step.resolver.ts b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-step.resolver.ts index c1bd40d8aa..0935d98e0c 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-step.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-step.resolver.ts @@ -21,6 +21,8 @@ import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-module import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service'; import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service'; +import { CreateWorkflowVersionEdgeInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-edge-input.dto'; +import { WorkflowVersionStepChangesDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto'; @Resolver() @UsePipes(ResolverValidationPipe) @@ -40,12 +42,12 @@ export class WorkflowStepResolver { private readonly featureFlagService: FeatureFlagService, ) {} - @Mutation(() => WorkflowActionDTO) + @Mutation(() => WorkflowVersionStepChangesDTO) async createWorkflowVersionStep( @AuthWorkspace() { id: workspaceId }: Workspace, @Args('input') input: CreateWorkflowVersionStepInput, - ): Promise { + ): Promise { if (input.stepType === WorkflowActionType.AI_AGENT) { const isAiEnabled = await this.featureFlagService.isFeatureEnabled( FeatureFlagKey.IS_AI_ENABLED, @@ -78,12 +80,12 @@ export class WorkflowStepResolver { }); } - @Mutation(() => WorkflowActionDTO) + @Mutation(() => WorkflowVersionStepChangesDTO) async deleteWorkflowVersionStep( @AuthWorkspace() { id: workspaceId }: Workspace, @Args('input') { stepId, workflowVersionId }: DeleteWorkflowVersionStepInput, - ): Promise { + ): Promise { return this.workflowVersionStepWorkspaceService.deleteWorkflowVersionStep({ workspaceId, workflowVersionId, @@ -121,4 +123,32 @@ export class WorkflowStepResolver { return step; } + + @Mutation(() => WorkflowVersionStepChangesDTO) + async createWorkflowVersionEdge( + @AuthWorkspace() { id: workspaceId }: Workspace, + @Args('input') + { source, target, workflowVersionId }: CreateWorkflowVersionEdgeInput, + ): Promise { + return this.workflowVersionStepWorkspaceService.createWorkflowVersionEdge({ + source, + target, + workflowVersionId, + workspaceId, + }); + } + + @Mutation(() => WorkflowVersionStepChangesDTO) + async deleteWorkflowVersionEdge( + @AuthWorkspace() { id: workspaceId }: Workspace, + @Args('input') + { source, target, workflowVersionId }: CreateWorkflowVersionEdgeInput, + ): Promise { + return this.workflowVersionStepWorkspaceService.deleteWorkflowVersionEdge({ + source, + target, + workflowVersionId, + workspaceId, + }); + } } diff --git a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version.resolver.ts b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version.resolver.ts index 50b2e48ffd..4240d732b9 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version.resolver.ts @@ -3,7 +3,7 @@ import { Args, Mutation, Resolver } from '@nestjs/graphql'; import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; -import { CreateDraftFromWorkflowVersionInput } from 'src/engine/core-modules/workflow/dtos/create-draft-from-workflow-version-input'; +import { CreateDraftFromWorkflowVersionInput } from 'src/engine/core-modules/workflow/dtos/create-draft-from-workflow-version-input.dto'; import { WorkflowVersionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version.dto'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; @@ -13,6 +13,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 { UpdateWorkflowVersionPositionsInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-version-positions-input.dto'; @Resolver() @UsePipes(ResolverValidationPipe) @@ -49,4 +50,19 @@ export class WorkflowVersionResolver { ), }; } + + @Mutation(() => Boolean) + async updateWorkflowVersionPositions( + @AuthWorkspace() { id: workspaceId }: Workspace, + @Args('input') + { workflowVersionId, positions }: UpdateWorkflowVersionPositionsInput, + ) { + await this.workflowVersionWorkspaceService.updateWorkflowVersionPositions({ + workspaceId, + workflowVersionId, + positions, + }); + + return true; + } } diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index 7fe5e04a2b..8d90ee5fea 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -131,6 +131,7 @@ describe('WorkspaceEntityManager', () => { IS_IMAP_SMTP_CALDAV_ENABLED: false, IS_MORPH_RELATION_ENABLED: false, IS_WORKFLOW_FILTERING_ENABLED: false, + IS_WORKFLOW_BRANCH_ENABLED: false, IS_RELATION_CONNECT_ENABLED: false, IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED: false, IS_FIELDS_PERMISSIONS_ENABLED: false, diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts index 7e5c4c6a5d..68f7c1d610 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts @@ -45,6 +45,11 @@ export const seedFeatureFlags = async ( workspaceId: workspaceId, value: false, }, + { + key: FeatureFlagKey.IS_WORKFLOW_BRANCH_ENABLED, + workspaceId: workspaceId, + value: false, + }, { key: FeatureFlagKey.IS_IMAP_SMTP_CALDAV_ENABLED, workspaceId: workspaceId, diff --git a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/prefill-workflows.ts b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/prefill-workflows.ts index 58cc95af38..194c156b71 100644 --- a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/prefill-workflows.ts +++ b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/prefill-workflows.ts @@ -64,6 +64,7 @@ export const prefillWorkflows = async ( name: 'Launch manually', type: 'MANUAL', settings: { outputSchema: {}, icon: 'IconUserPlus' }, + nextStepIds: ['6e089bc9-aabd-435f-865f-f31c01c8f4a7'], }), steps: JSON.stringify([ { diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/__tests__/workflow-version-step.workspace-service.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/__tests__/workflow-version-step.workspace-service.spec.ts new file mode 100644 index 0000000000..c50544e5ec --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/__tests__/workflow-version-step.workspace-service.spec.ts @@ -0,0 +1,559 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service'; +import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; +import { AgentService } from 'src/engine/metadata-modules/agent/agent.service'; +import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service'; +import { WorkflowRunnerWorkspaceService } from 'src/modules/workflow/workflow-runner/workspace-services/workflow-runner.workspace-service'; +import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; +import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity'; +import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; +import { + WorkflowAction, + WorkflowActionType, +} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { WorkflowTriggerType } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type'; + +type MockWorkspaceRepository = Partial< + WorkspaceRepository +> & { + findOne: jest.Mock; + update: jest.Mock; +}; + +const mockWorkflowVersionId = 'workflow-version-id'; +const mockWorkspaceId = 'workspace-id'; + +const mockSteps = [ + { + id: 'step-1', + type: WorkflowActionType.FORM, + settings: { + errorHandlingOptions: { + continueOnFailure: { value: false }, + retryOnFailure: { value: false }, + }, + }, + nextStepIds: ['step-2'], + }, + { + id: 'step-2', + type: WorkflowActionType.SEND_EMAIL, + settings: { + errorHandlingOptions: { + continueOnFailure: { value: false }, + retryOnFailure: { value: false }, + }, + }, + nextStepIds: [], + }, + { + id: 'step-3', + type: WorkflowActionType.SEND_EMAIL, + settings: { + errorHandlingOptions: { + continueOnFailure: { value: false }, + retryOnFailure: { value: false }, + }, + }, + nextStepIds: [], + }, +] as WorkflowAction[]; + +const mockTrigger = { + type: WorkflowTriggerType.MANUAL, + settings: {}, + nextStepIds: ['step-1'], +}; + +const mockWorkflowVersion = { + id: mockWorkflowVersionId, + trigger: mockTrigger, + steps: mockSteps, + status: 'DRAFT', +} as WorkflowVersionWorkspaceEntity; + +describe('WorkflowVersionStepWorkspaceService', () => { + let twentyORMGlobalManager: jest.Mocked; + let service: WorkflowVersionStepWorkspaceService; + let mockWorkflowVersionWorkspaceRepository: MockWorkspaceRepository; + + beforeEach(async () => { + mockWorkflowVersionWorkspaceRepository = { + findOne: jest.fn(), + update: jest.fn(), + }; + + mockWorkflowVersionWorkspaceRepository.findOne.mockResolvedValue( + mockWorkflowVersion, + ); + + twentyORMGlobalManager = { + getRepositoryForWorkspace: jest + .fn() + .mockResolvedValue(mockWorkflowVersionWorkspaceRepository), + } as unknown as jest.Mocked; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WorkflowVersionStepWorkspaceService, + { + provide: TwentyORMGlobalManager, + useValue: twentyORMGlobalManager, + }, + { + provide: WorkflowSchemaWorkspaceService, + useValue: { + computeStepOutputSchema: jest.fn(), + }, + }, + { provide: ServerlessFunctionService, useValue: {} }, + { provide: AgentService, useValue: {} }, + { + provide: getRepositoryToken(ObjectMetadataEntity, 'core'), + useValue: { + findOne: jest.fn(), + }, + }, + { provide: WorkflowRunWorkspaceService, useValue: {} }, + { provide: WorkflowRunnerWorkspaceService, useValue: {} }, + { provide: WorkflowCommonWorkspaceService, useValue: {} }, + { provide: ScopedWorkspaceContextFactory, useValue: {} }, + ], + }).compile(); + + service = module.get(WorkflowVersionStepWorkspaceService); + }); + + describe('createWorkflowVersionStep', () => { + it('should create a step linked to trigger', async () => { + const result = await service.createWorkflowVersionStep({ + input: { + stepType: WorkflowActionType.FORM, + parentStepId: 'trigger', + nextStepId: undefined, + workflowVersionId: mockWorkflowVersionId, + }, + workspaceId: mockWorkspaceId, + }); + + expect(mockWorkflowVersionWorkspaceRepository.update).toHaveBeenCalled(); + + expect(result.createdStep).toBeDefined(); + + const createdStepId = result.createdStep?.id; + + expect(result.triggerNextStepIds).toEqual(['step-1', createdStepId]); + expect(result.stepsNextStepIds).toEqual({ + 'step-1': ['step-2'], + 'step-2': [], + 'step-3': [], + }); + }); + + it('should create a step between a trigger and a step', async () => { + const result = await service.createWorkflowVersionStep({ + input: { + stepType: WorkflowActionType.FORM, + parentStepId: 'trigger', + nextStepId: 'step-1', + workflowVersionId: mockWorkflowVersionId, + }, + workspaceId: mockWorkspaceId, + }); + + expect(mockWorkflowVersionWorkspaceRepository.update).toHaveBeenCalled(); + + expect(result.createdStep).toBeDefined(); + + const createdStepId = result.createdStep?.id as string; + + expect(result.triggerNextStepIds).toEqual([createdStepId]); + expect(result.stepsNextStepIds).toEqual({ + [createdStepId]: ['step-1'], + 'step-1': ['step-2'], + 'step-2': [], + 'step-3': [], + }); + }); + + it('should create a step between two steps', async () => { + const result = await service.createWorkflowVersionStep({ + input: { + stepType: WorkflowActionType.FORM, + parentStepId: 'step-1', + nextStepId: 'step-2', + workflowVersionId: mockWorkflowVersionId, + }, + workspaceId: mockWorkspaceId, + }); + + expect(mockWorkflowVersionWorkspaceRepository.update).toHaveBeenCalled(); + + expect(result.createdStep).toBeDefined(); + + const createdStepId = result.createdStep?.id as string; + + expect(result.triggerNextStepIds).toEqual(['step-1']); + expect(result.stepsNextStepIds).toEqual({ + 'step-1': [createdStepId], + [createdStepId]: ['step-2'], + 'step-2': [], + 'step-3': [], + }); + }); + + it('should create a step without parent or children', async () => { + const result = await service.createWorkflowVersionStep({ + input: { + stepType: WorkflowActionType.FORM, + parentStepId: undefined, + nextStepId: undefined, + workflowVersionId: mockWorkflowVersionId, + }, + workspaceId: mockWorkspaceId, + }); + + expect(mockWorkflowVersionWorkspaceRepository.update).toHaveBeenCalled(); + + expect(result.createdStep).toBeDefined(); + + expect(result.triggerNextStepIds).toEqual(['step-1']); + expect(result.stepsNextStepIds).toEqual({ + 'step-1': ['step-2'], + 'step-2': [], + 'step-3': [], + }); + }); + }); + + describe('createWorkflowVersionEdge', () => { + it('should throw if target does not exists', async () => { + const call = async () => + await service.createWorkflowVersionEdge({ + source: 'trigger', + target: 'not-existing-step', + workflowVersionId: mockWorkflowVersionId, + workspaceId: mockWorkspaceId, + }); + + await expect(call).rejects.toThrow( + `Target step 'not-existing-step' not found in workflowVersion '${mockWorkflowVersionId}'`, + ); + }); + + describe('with source is the trigger', () => { + it('should create an edge between trigger and step-1', async () => { + const result = await service.createWorkflowVersionEdge({ + source: 'trigger', + target: 'step-3', + workflowVersionId: mockWorkflowVersionId, + workspaceId: mockWorkspaceId, + }); + + expect( + mockWorkflowVersionWorkspaceRepository.update, + ).toHaveBeenCalledWith(mockWorkflowVersionId, { + trigger: { + ...mockTrigger, + nextStepIds: ['step-1', 'step-3'], + }, + }); + + expect(result).toEqual({ + triggerNextStepIds: ['step-1', 'step-3'], + stepsNextStepIds: { + 'step-1': ['step-2'], + 'step-2': [], + 'step-3': [], + }, + }); + }); + + it('should not duplicate stepIds if edge already exists', async () => { + const result = await service.createWorkflowVersionEdge({ + source: 'trigger', + target: 'step-1', + workflowVersionId: mockWorkflowVersionId, + workspaceId: mockWorkspaceId, + }); + + expect( + mockWorkflowVersionWorkspaceRepository.update, + ).not.toHaveBeenCalled(); + + expect(result).toEqual({ + triggerNextStepIds: ['step-1'], + stepsNextStepIds: { + 'step-1': ['step-2'], + 'step-2': [], + 'step-3': [], + }, + }); + }); + }); + + describe('with source is a step', () => { + it('should create an edge between step-2 and step-3', async () => { + const result = await service.createWorkflowVersionEdge({ + source: 'step-2', + target: 'step-3', + workflowVersionId: mockWorkflowVersionId, + workspaceId: mockWorkspaceId, + }); + + expect( + mockWorkflowVersionWorkspaceRepository.update, + ).toHaveBeenCalledWith(mockWorkflowVersionId, { + steps: mockSteps.map((step) => { + if (step.id === 'step-2') { + return { + ...step, + nextStepIds: ['step-3'], + }; + } + + return step; + }), + }); + + expect(result).toEqual({ + triggerNextStepIds: ['step-1'], + stepsNextStepIds: { + 'step-1': ['step-2'], + 'step-2': ['step-3'], + 'step-3': [], + }, + }); + }); + + it('should not duplicate if edge already exist between 2 steps', async () => { + const result = await service.createWorkflowVersionEdge({ + source: 'step-1', + target: 'step-2', + workflowVersionId: mockWorkflowVersionId, + workspaceId: mockWorkspaceId, + }); + + expect( + mockWorkflowVersionWorkspaceRepository.update, + ).not.toHaveBeenCalled(); + + expect(result).toEqual({ + triggerNextStepIds: ['step-1'], + stepsNextStepIds: { + 'step-1': ['step-2'], + 'step-2': [], + 'step-3': [], + }, + }); + }); + + it('should throw if source step does not exists', async () => { + const call = async () => + await service.createWorkflowVersionEdge({ + source: 'not-existing-step', + target: 'step-2', + workflowVersionId: mockWorkflowVersionId, + workspaceId: mockWorkspaceId, + }); + + await expect(call).rejects.toThrow( + `Source step 'not-existing-step' not found in workflowVersion '${mockWorkflowVersionId}'`, + ); + }); + }); + }); + + describe('deleteWorkflowVersionEdge', () => { + it('should throw if target does not exists', async () => { + const call = async () => + await service.deleteWorkflowVersionEdge({ + source: 'trigger', + target: 'not-existing-step', + workflowVersionId: mockWorkflowVersionId, + workspaceId: mockWorkspaceId, + }); + + await expect(call).rejects.toThrow( + `Target step 'not-existing-step' not found in workflowVersion '${mockWorkflowVersionId}'`, + ); + }); + + describe('with source is the trigger', () => { + it('should delete an edge between trigger and step-1', async () => { + const result = await service.deleteWorkflowVersionEdge({ + source: 'trigger', + target: 'step-1', + workflowVersionId: mockWorkflowVersionId, + workspaceId: mockWorkspaceId, + }); + + expect( + mockWorkflowVersionWorkspaceRepository.update, + ).toHaveBeenCalledWith(mockWorkflowVersionId, { + trigger: { + ...mockTrigger, + nextStepIds: [], + }, + }); + + expect(result).toEqual({ + triggerNextStepIds: [], + stepsNextStepIds: { + 'step-1': ['step-2'], + 'step-2': [], + 'step-3': [], + }, + }); + }); + + it('should not delete if edge does not exists', async () => { + const result = await service.deleteWorkflowVersionEdge({ + source: 'trigger', + target: 'step-2', + workflowVersionId: mockWorkflowVersionId, + workspaceId: mockWorkspaceId, + }); + + expect( + mockWorkflowVersionWorkspaceRepository.update, + ).not.toHaveBeenCalled(); + + expect(result).toEqual({ + triggerNextStepIds: ['step-1'], + stepsNextStepIds: { + 'step-1': ['step-2'], + 'step-2': [], + 'step-3': [], + }, + }); + }); + }); + + describe('with source is a step', () => { + it('should delete an existing edge between two steps', async () => { + const result = await service.deleteWorkflowVersionEdge({ + source: 'step-1', + target: 'step-2', + workflowVersionId: mockWorkflowVersionId, + workspaceId: mockWorkspaceId, + }); + + expect( + mockWorkflowVersionWorkspaceRepository.update, + ).toHaveBeenCalledWith(mockWorkflowVersionId, { + steps: mockSteps.map((step) => { + if (step.id === 'step-1') { + return { + ...step, + nextStepIds: [], + }; + } + + return step; + }), + }); + + expect(result).toEqual({ + triggerNextStepIds: ['step-1'], + stepsNextStepIds: { + 'step-1': [], + 'step-2': [], + 'step-3': [], + }, + }); + }); + + it('should not delete if edge does not exist', async () => { + const result = await service.deleteWorkflowVersionEdge({ + source: 'step-1', + target: 'step-3', + workflowVersionId: mockWorkflowVersionId, + workspaceId: mockWorkspaceId, + }); + + expect( + mockWorkflowVersionWorkspaceRepository.update, + ).not.toHaveBeenCalledWith(); + + expect(result).toEqual({ + triggerNextStepIds: ['step-1'], + stepsNextStepIds: { + 'step-1': ['step-2'], + 'step-2': [], + 'step-3': [], + }, + }); + }); + + it('should throw if source step does not exists', async () => { + const call = async () => + await service.deleteWorkflowVersionEdge({ + source: 'not-existing-step', + target: 'step-2', + workflowVersionId: mockWorkflowVersionId, + workspaceId: mockWorkspaceId, + }); + + await expect(call).rejects.toThrow( + `Source step 'not-existing-step' not found in workflowVersion '${mockWorkflowVersionId}'`, + ); + }); + }); + }); + + describe('deleteWorkflowVersionStep', () => { + it('should delete step linked to trigger', async () => { + const result = await service.deleteWorkflowVersionStep({ + stepIdToDelete: 'step-1', + workflowVersionId: mockWorkflowVersionId, + workspaceId: mockWorkspaceId, + }); + + expect( + mockWorkflowVersionWorkspaceRepository.update, + ).toHaveBeenCalledWith(mockWorkflowVersionId, { + trigger: { ...mockTrigger, nextStepIds: ['step-2'] }, + steps: mockSteps.filter((step) => step.id !== 'step-1'), + }); + + expect(result).toEqual({ + triggerNextStepIds: ['step-2'], + stepsNextStepIds: { + 'step-2': [], + 'step-3': [], + }, + deletedStepId: 'step-1', + }); + }); + + it('should delete trigger', async () => { + const result = await service.deleteWorkflowVersionStep({ + stepIdToDelete: 'trigger', + workflowVersionId: mockWorkflowVersionId, + workspaceId: mockWorkspaceId, + }); + + expect( + mockWorkflowVersionWorkspaceRepository.update, + ).toHaveBeenCalledWith(mockWorkflowVersionId, { + trigger: null, + }); + + expect(result).toEqual({ + stepsNextStepIds: { + 'step-1': ['step-2'], + 'step-2': [], + 'step-3': [], + }, + deletedStepId: 'trigger', + }); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/__tests__/compute-workflow-version-step-changes.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/__tests__/compute-workflow-version-step-changes.util.spec.ts new file mode 100644 index 0000000000..0c99c321ad --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/__tests__/compute-workflow-version-step-changes.util.spec.ts @@ -0,0 +1,24 @@ +import { computeWorkflowVersionStepChanges } from 'src/modules/workflow/workflow-builder/workflow-step/utils/compute-workflow-version-step-updates.util'; +import { WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type'; +import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; + +describe('computeWorkflowVersionStepChanges', () => { + it('should compute next step ids', () => { + const input = { + trigger: { nextStepIds: ['1', '2'] } as WorkflowTrigger, + steps: [ + { id: '1', nextStepIds: ['3'] }, + { id: '2', nextStepIds: ['3'] }, + ] as WorkflowAction[], + deletedStepId: '5', + }; + + const expectedResult = { + triggerNextStepIds: ['1', '2'], + stepsNextStepIds: { '1': ['3'], '2': ['3'] }, + deletedStepId: '5', + }; + + expect(computeWorkflowVersionStepChanges(input)).toEqual(expectedResult); + }); +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/__tests__/insert-step.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/__tests__/insert-step.spec.ts index cacd6d71b2..ad8f4fa593 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/__tests__/insert-step.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/__tests__/insert-step.spec.ts @@ -3,6 +3,10 @@ import { WorkflowAction, WorkflowActionType, } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { + WorkflowTrigger, + WorkflowTriggerType, +} from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type'; describe('insertStep', () => { const createMockAction = ( @@ -28,7 +32,15 @@ describe('insertStep', () => { nextStepIds, }); + const createMockTrigger = (nextStepIds: string[]): WorkflowTrigger => ({ + name: 'Trigger', + type: WorkflowTriggerType.MANUAL, + settings: { outputSchema: {} }, + nextStepIds, + }); + it('should insert a step at the end of the array when no parent or next step is specified', () => { + const existingTrigger = createMockTrigger(['1']); const step1 = createMockAction('1'); const step2 = createMockAction('2'); const newStep = createMockAction('new'); @@ -36,6 +48,7 @@ describe('insertStep', () => { const result = insertStep({ existingSteps: [step1, step2], insertedStep: newStep, + existingTrigger, }); expect(result.updatedSteps).toEqual([step1, step2, newStep]); @@ -43,6 +56,7 @@ describe('insertStep', () => { }); it('should update parent step nextStepIds when inserting a step between two steps', () => { + const existingTrigger = createMockTrigger(['1']); const step1 = createMockAction('1', ['2']); const step2 = createMockAction('2'); const newStep = createMockAction('new'); @@ -50,6 +64,7 @@ describe('insertStep', () => { const result = insertStep({ existingSteps: [step1, step2], insertedStep: newStep, + existingTrigger, parentStepId: '1', nextStepId: '2', }); @@ -62,10 +77,12 @@ describe('insertStep', () => { }); it('should handle inserting a step at the beginning of the workflow', () => { + const existingTrigger = createMockTrigger(['1']); const step1 = createMockAction('1'); const newStep = createMockAction('new'); const result = insertStep({ + existingTrigger, existingSteps: [step1], insertedStep: newStep, parentStepId: undefined, @@ -79,10 +96,12 @@ describe('insertStep', () => { }); it('should handle inserting a step at the end of the workflow', () => { + const existingTrigger = createMockTrigger(['1']); const step1 = createMockAction('1'); const newStep = createMockAction('new'); const result = insertStep({ + existingTrigger, existingSteps: [step1], insertedStep: newStep, parentStepId: '1', @@ -96,12 +115,14 @@ describe('insertStep', () => { }); it('should handle inserting a step between two steps with multiple nextStepIds', () => { + const existingTrigger = createMockTrigger(['1']); const step1 = createMockAction('1', ['2', '3']); const step2 = createMockAction('2'); const step3 = createMockAction('3'); const newStep = createMockAction('new'); const result = insertStep({ + existingTrigger, existingSteps: [step1, step2, step3], insertedStep: newStep, parentStepId: '1', @@ -115,4 +136,24 @@ describe('insertStep', () => { { ...newStep, nextStepIds: ['2'] }, ]); }); + + it('should handle inserting after trigger', () => { + const existingTrigger = createMockTrigger(['1']); + const step1 = createMockAction('1'); + const newStep = createMockAction('new'); + + const result = insertStep({ + existingTrigger, + existingSteps: [step1], + insertedStep: newStep, + parentStepId: 'trigger', + nextStepId: undefined, + }); + + expect(result.updatedSteps).toEqual([step1, newStep]); + expect(result.updatedTrigger).toEqual({ + ...existingTrigger, + nextStepIds: ['1', 'new'], + }); + }); }); diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/__tests__/remove-step.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/__tests__/remove-step.spec.ts index eb67648446..64954a1e2e 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/__tests__/remove-step.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/__tests__/remove-step.spec.ts @@ -3,6 +3,17 @@ import { WorkflowAction, WorkflowActionType, } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { + WorkflowTrigger, + WorkflowTriggerType, +} from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type'; + +const mockTrigger = { + name: 'Trigger', + type: WorkflowTriggerType.MANUAL, + settings: { outputSchema: {} }, + nextStepIds: ['1'], +} as WorkflowTrigger; describe('removeStep', () => { const createMockAction = ( @@ -34,11 +45,13 @@ describe('removeStep', () => { const step3 = createMockAction('3'); const result = removeStep({ + existingTrigger: mockTrigger, existingSteps: [step1, step2, step3], stepIdToDelete: '2', }); - expect(result).toEqual([step1, step3]); + expect(result.steps).toEqual([step1, step3]); + expect(result.trigger).toEqual(mockTrigger); }); it('should handle removing a step that has no next steps', () => { @@ -47,11 +60,13 @@ describe('removeStep', () => { const step3 = createMockAction('3'); const result = removeStep({ + existingTrigger: mockTrigger, existingSteps: [step1, step2, step3], stepIdToDelete: '2', }); - expect(result).toEqual([{ ...step1, nextStepIds: [] }, step3]); + expect(result.steps).toEqual([{ ...step1, nextStepIds: [] }, step3]); + expect(result.trigger).toEqual(mockTrigger); }); it('should update nextStepIds of parent steps to include children of removed step', () => { @@ -60,12 +75,14 @@ describe('removeStep', () => { const step3 = createMockAction('3'); const result = removeStep({ + existingTrigger: mockTrigger, existingSteps: [step1, step2, step3], stepIdToDelete: '2', stepToDeleteChildrenIds: ['3'], }); - expect(result).toEqual([{ ...step1, nextStepIds: ['3'] }, step3]); + expect(result.steps).toEqual([{ ...step1, nextStepIds: ['3'] }, step3]); + expect(result.trigger).toEqual(mockTrigger); }); it('should handle multiple parent steps pointing to the same step', () => { @@ -75,16 +92,18 @@ describe('removeStep', () => { const step4 = createMockAction('4'); const result = removeStep({ + existingTrigger: mockTrigger, existingSteps: [step1, step2, step3, step4], stepIdToDelete: '3', stepToDeleteChildrenIds: ['4'], }); - expect(result).toEqual([ + expect(result.steps).toEqual([ { ...step1, nextStepIds: ['4'] }, { ...step2, nextStepIds: ['4'] }, step4, ]); + expect(result.trigger).toEqual(mockTrigger); }); it('should handle removing a step with multiple children', () => { @@ -94,15 +113,33 @@ describe('removeStep', () => { const step4 = createMockAction('4'); const result = removeStep({ + existingTrigger: mockTrigger, existingSteps: [step1, step2, step3, step4], stepIdToDelete: '2', stepToDeleteChildrenIds: ['3', '4'], }); - expect(result).toEqual([ + expect(result.steps).toEqual([ { ...step1, nextStepIds: ['3', '4'] }, step3, step4, ]); + expect(result.trigger).toEqual(mockTrigger); + }); + + it('should handle removing a step linked to trigger', () => { + const step1 = createMockAction('1', ['2']); + const step2 = createMockAction('2', ['3']); + const step3 = createMockAction('3'); + + const result = removeStep({ + existingTrigger: mockTrigger, + existingSteps: [step1, step2, step3], + stepIdToDelete: '1', + stepToDeleteChildrenIds: ['2'], + }); + + expect(result.steps).toEqual([step2, step3]); + expect(result.trigger).toEqual({ ...mockTrigger, nextStepIds: ['2'] }); }); }); diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/compute-workflow-version-step-updates.util.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/compute-workflow-version-step-updates.util.ts new file mode 100644 index 0000000000..50f964c1fd --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/compute-workflow-version-step-updates.util.ts @@ -0,0 +1,24 @@ +import { WorkflowVersionStepChangesDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto'; +import { WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type'; +import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; + +export const computeWorkflowVersionStepChanges = ({ + trigger, + steps, + createdStep, + deletedStepId, +}: { + trigger: WorkflowTrigger | null; + steps: WorkflowAction[] | null; + createdStep?: WorkflowAction; + deletedStepId?: string; +}): WorkflowVersionStepChangesDTO => { + return { + triggerNextStepIds: trigger?.nextStepIds, + stepsNextStepIds: Object.fromEntries( + (steps || []).map((step) => [step.id, step.nextStepIds]), + ), + createdStep, + deletedStepId, + }; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/insert-step.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/insert-step.ts index 20fee33e44..ae7e2aa49a 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/insert-step.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/insert-step.ts @@ -1,32 +1,60 @@ import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type'; export const insertStep = ({ existingSteps, + existingTrigger, insertedStep, parentStepId, nextStepId, }: { existingSteps: WorkflowAction[]; + existingTrigger: WorkflowTrigger | null; insertedStep: WorkflowAction; parentStepId?: string; nextStepId?: string; -}): { updatedSteps: WorkflowAction[]; updatedInsertedStep: WorkflowAction } => { - const updatedExistingSteps = existingSteps.map((existingStep) => { - if (existingStep.id === parentStepId) { - return { - ...existingStep, - nextStepIds: [ - ...new Set([ - ...(existingStep.nextStepIds?.filter((id) => id !== nextStepId) || - []), - insertedStep.id, - ]), - ], - }; +}): { + updatedSteps: WorkflowAction[]; + updatedInsertedStep: WorkflowAction; + updatedTrigger: WorkflowTrigger | null; +} => { + let updatedTrigger = existingTrigger; + + let updatedExistingSteps = existingSteps; + + if (parentStepId === 'trigger') { + if (!existingTrigger) { + throw new Error('Cannot insert step from undefined trigger'); } - return existingStep; - }); + updatedTrigger = { + ...existingTrigger, + nextStepIds: [ + ...new Set([ + ...(existingTrigger.nextStepIds?.filter((id) => id !== nextStepId) || + []), + insertedStep.id, + ]), + ], + }; + } else { + updatedExistingSteps = existingSteps.map((existingStep) => { + if (existingStep.id === parentStepId) { + return { + ...existingStep, + nextStepIds: [ + ...new Set([ + ...(existingStep.nextStepIds?.filter((id) => id !== nextStepId) || + []), + insertedStep.id, + ]), + ], + }; + } + + return existingStep; + }); + } const updatedInsertedStep = { ...insertedStep, @@ -35,6 +63,7 @@ export const insertStep = ({ return { updatedSteps: [...updatedExistingSteps, updatedInsertedStep], + updatedTrigger, updatedInsertedStep, }; }; diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/remove-step.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/remove-step.ts index a86d4e4c69..bbbddb2e37 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/remove-step.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/remove-step.ts @@ -1,30 +1,72 @@ +import { isDefined } from 'twenty-shared/utils'; + import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type'; + +const computeUpdatedNextStepIds = ({ + existingNextStepIds, + stepIdToDelete, + stepToDeleteChildrenIds, +}: { + existingNextStepIds: string[]; + stepIdToDelete: string; + stepToDeleteChildrenIds?: string[]; +}): string[] => { + const filteredNextStepIds = isDefined(existingNextStepIds) + ? existingNextStepIds.filter((id) => id !== stepIdToDelete) + : []; + + return [ + ...new Set([ + ...filteredNextStepIds, + // We automatically link parent and child steps together + ...(stepToDeleteChildrenIds || []), + ]), + ]; +}; export const removeStep = ({ + existingTrigger, existingSteps, stepIdToDelete, stepToDeleteChildrenIds, }: { + existingTrigger: WorkflowTrigger | null; existingSteps: WorkflowAction[]; stepIdToDelete: string; stepToDeleteChildrenIds?: string[]; -}): WorkflowAction[] => { - return existingSteps +}): { steps: WorkflowAction[]; trigger: WorkflowTrigger | null } => { + const updatedSteps = existingSteps .filter((step) => step.id !== stepIdToDelete) .map((step) => { if (step.nextStepIds?.includes(stepIdToDelete)) { return { ...step, - nextStepIds: [ - ...new Set([ - ...step.nextStepIds.filter((id) => id !== stepIdToDelete), - // We automatically link parent and child steps together - ...(stepToDeleteChildrenIds || []), - ]), - ], + nextStepIds: computeUpdatedNextStepIds({ + existingNextStepIds: step.nextStepIds, + stepIdToDelete, + stepToDeleteChildrenIds, + }), }; } return step; }); + + let updatedTrigger = existingTrigger; + + if (isDefined(existingTrigger)) { + if (existingTrigger.nextStepIds?.includes(stepIdToDelete)) { + updatedTrigger = { + ...existingTrigger, + nextStepIds: computeUpdatedNextStepIds({ + existingNextStepIds: existingTrigger.nextStepIds, + stepIdToDelete, + stepToDeleteChildrenIds, + }), + }; + } + } + + return { trigger: updatedTrigger, steps: updatedSteps }; }; diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts index 3def164c11..cc15c7be2f 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts @@ -10,8 +10,6 @@ import { v4 } from 'uuid'; import { BASE_TYPESCRIPT_PROJECT_INPUT_SCHEMA } from 'src/engine/core-modules/serverless/drivers/constants/base-typescript-project-input-schema'; import { CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto'; -import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-step.dto'; -import { AgentChatService } from 'src/engine/metadata-modules/agent/agent-chat.service'; import { AgentService } from 'src/engine/metadata-modules/agent/agent.service'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; @@ -35,8 +33,9 @@ import { } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service'; import { WorkflowRunnerWorkspaceService } from 'src/modules/workflow/workflow-runner/workspace-services/workflow-runner.workspace-service'; - -const TRIGGER_STEP_ID = 'trigger'; +import { WorkflowStepPositionInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-step-position-input.dto'; +import { WorkflowVersionStepChangesDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto'; +import { computeWorkflowVersionStepChanges } from 'src/modules/workflow/workflow-builder/workflow-step/utils/compute-workflow-version-step-updates.util'; const BASE_STEP_DEFINITION: BaseWorkflowActionSettings = { outputSchema: {}, @@ -61,7 +60,6 @@ export class WorkflowVersionStepWorkspaceService { private readonly objectMetadataRepository: Repository, private readonly workflowRunWorkspaceService: WorkflowRunWorkspaceService, private readonly workflowRunnerWorkspaceService: WorkflowRunnerWorkspaceService, - private readonly agentChatService: AgentChatService, private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory, ) {} @@ -72,17 +70,21 @@ export class WorkflowVersionStepWorkspaceService { }: { workspaceId: string; input: CreateWorkflowVersionStepInput; - }): Promise { - const { workflowVersionId, stepType, parentStepId, nextStepId } = input; + }): Promise { + const { workflowVersionId, stepType, parentStepId, nextStepId, position } = + input; const newStep = await this.getStepDefaultDefinition({ type: stepType, workspaceId, + position, }); + const enrichedNewStep = await this.enrichOutputSchema({ step: newStep, workspaceId, }); + const workflowVersionRepository = await this.twentyORMGlobalManager.getRepositoryForWorkspace( workspaceId, @@ -107,18 +109,26 @@ export class WorkflowVersionStepWorkspaceService { const existingSteps = workflowVersion.steps || []; - const { updatedSteps, updatedInsertedStep } = insertStep({ + const existingTrigger = workflowVersion.trigger; + + const { updatedSteps, updatedInsertedStep, updatedTrigger } = insertStep({ existingSteps, + existingTrigger, insertedStep: enrichedNewStep, parentStepId, nextStepId, }); await workflowVersionRepository.update(workflowVersion.id, { + trigger: updatedTrigger, steps: updatedSteps, }); - return updatedInsertedStep; + return computeWorkflowVersionStepChanges({ + createdStep: updatedInsertedStep, + trigger: updatedTrigger, + steps: updatedSteps, + }); } async updateWorkflowVersionStep({ @@ -187,7 +197,7 @@ export class WorkflowVersionStepWorkspaceService { workspaceId: string; workflowVersionId: string; stepIdToDelete: string; - }): Promise { + }): Promise { const workflowVersionRepository = await this.twentyORMGlobalManager.getRepositoryForWorkspace( workspaceId, @@ -217,6 +227,20 @@ export class WorkflowVersionStepWorkspaceService { ); } + if (stepIdToDelete === 'trigger') { + await workflowVersionRepository.update(workflowVersion.id, { + trigger: null, + }); + + return computeWorkflowVersionStepChanges({ + trigger: null, + steps: workflowVersion?.steps, + deletedStepId: stepIdToDelete, + }); + } + + const existingTrigger = workflowVersion.trigger; + const stepToDelete = workflowVersion.steps.find( (step) => step.id === stepIdToDelete, ); @@ -228,16 +252,12 @@ export class WorkflowVersionStepWorkspaceService { ); } - const workflowVersionUpdates = - stepIdToDelete === TRIGGER_STEP_ID - ? { trigger: null } - : { - steps: removeStep({ - existingSteps: workflowVersion.steps, - stepIdToDelete, - stepToDeleteChildrenIds: stepToDelete.nextStepIds, - }), - }; + const workflowVersionUpdates = removeStep({ + existingTrigger, + existingSteps: workflowVersion.steps, + stepIdToDelete, + stepToDeleteChildrenIds: stepToDelete.nextStepIds, + }); await workflowVersionRepository.update( workflowVersion.id, @@ -249,7 +269,10 @@ export class WorkflowVersionStepWorkspaceService { workspaceId, }); - return stepToDelete; + return computeWorkflowVersionStepChanges({ + ...workflowVersionUpdates, + deletedStepId: stepIdToDelete, + }); } async duplicateStep({ @@ -345,6 +368,244 @@ export class WorkflowVersionStepWorkspaceService { }); } + async createWorkflowVersionEdge({ + source, + target, + workflowVersionId, + workspaceId, + }: { + source: string; + target: string; + workflowVersionId: string; + workspaceId: string; + }): Promise { + const workflowVersionRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + 'workflowVersion', + { shouldBypassPermissionChecks: true }, + ); + + const workflowVersion = await workflowVersionRepository.findOne({ + where: { + id: workflowVersionId, + }, + }); + + if (!isDefined(workflowVersion)) { + throw new WorkflowVersionStepException( + 'WorkflowVersion not found', + WorkflowVersionStepExceptionCode.NOT_FOUND, + ); + } + + assertWorkflowVersionIsDraft(workflowVersion); + + const steps = workflowVersion.steps || []; + + const trigger = workflowVersion.trigger; + + const isSourceTrigger = source === 'trigger'; + + const targetStep = steps.find((step) => step.id === target); + + if (!isDefined(targetStep)) { + throw new WorkflowVersionStepException( + `Target step '${target}' not found in workflowVersion '${workflowVersionId}'`, + WorkflowVersionStepExceptionCode.NOT_FOUND, + ); + } + + if (isSourceTrigger) { + if (!isDefined(trigger)) { + throw new WorkflowVersionStepException( + `Trigger not found in workflowVersion '${workflowVersionId}'`, + WorkflowVersionStepExceptionCode.NOT_FOUND, + ); + } + + if (trigger.nextStepIds?.includes(target)) { + return computeWorkflowVersionStepChanges({ + trigger, + steps, + }); + } + + const updatedTrigger = { + ...trigger, + nextStepIds: [...(trigger.nextStepIds ?? []), target], + }; + + await workflowVersionRepository.update(workflowVersion.id, { + trigger: updatedTrigger, + }); + + return computeWorkflowVersionStepChanges({ + trigger: updatedTrigger, + steps, + }); + } + + const sourceStep = steps.find((step) => step.id === source); + + if (!isDefined(sourceStep)) { + throw new WorkflowVersionStepException( + `Source step '${source}' not found in workflowVersion '${workflowVersionId}'`, + WorkflowVersionStepExceptionCode.NOT_FOUND, + ); + } + + if (sourceStep.nextStepIds?.includes(target)) { + return computeWorkflowVersionStepChanges({ + trigger, + steps, + }); + } + + const updatedSourceStep = { + ...sourceStep, + nextStepIds: [...(sourceStep.nextStepIds ?? []), target], + }; + + const updatedSteps = steps.map((step) => { + if (step.id === source) { + return updatedSourceStep; + } + + return step; + }); + + await workflowVersionRepository.update(workflowVersion.id, { + steps: updatedSteps, + }); + + return computeWorkflowVersionStepChanges({ + trigger, + steps: updatedSteps, + }); + } + + async deleteWorkflowVersionEdge({ + source, + target, + workflowVersionId, + workspaceId, + }: { + source: string; + target: string; + workflowVersionId: string; + workspaceId: string; + }): Promise { + const workflowVersionRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + 'workflowVersion', + { shouldBypassPermissionChecks: true }, + ); + + const workflowVersion = await workflowVersionRepository.findOne({ + where: { + id: workflowVersionId, + }, + }); + + if (!isDefined(workflowVersion)) { + throw new WorkflowVersionStepException( + 'WorkflowVersion not found', + WorkflowVersionStepExceptionCode.NOT_FOUND, + ); + } + + assertWorkflowVersionIsDraft(workflowVersion); + + const steps = workflowVersion.steps || []; + + const trigger = workflowVersion.trigger; + + const isSourceTrigger = source === 'trigger'; + + const targetStep = steps.find((step) => step.id === target); + + if (!isDefined(targetStep)) { + throw new WorkflowVersionStepException( + `Target step '${target}' not found in workflowVersion '${workflowVersionId}'`, + WorkflowVersionStepExceptionCode.NOT_FOUND, + ); + } + + if (isSourceTrigger) { + if (!isDefined(trigger)) { + throw new WorkflowVersionStepException( + `Trigger not found in workflowVersion '${workflowVersionId}'`, + WorkflowVersionStepExceptionCode.NOT_FOUND, + ); + } + + if (!trigger.nextStepIds?.includes(target)) { + return computeWorkflowVersionStepChanges({ + trigger, + steps, + }); + } + + const updatedTrigger = { + ...trigger, + nextStepIds: trigger.nextStepIds?.filter( + (nextStepId) => nextStepId !== target, + ), + }; + + await workflowVersionRepository.update(workflowVersion.id, { + trigger: updatedTrigger, + }); + + return computeWorkflowVersionStepChanges({ + trigger: updatedTrigger, + steps, + }); + } + + const sourceStep = steps.find((step) => step.id === source); + + if (!isDefined(sourceStep)) { + throw new WorkflowVersionStepException( + `Source step '${source}' not found in workflowVersion '${workflowVersionId}'`, + WorkflowVersionStepExceptionCode.NOT_FOUND, + ); + } + + if (!sourceStep.nextStepIds?.includes(target)) { + return computeWorkflowVersionStepChanges({ + trigger, + steps, + }); + } + + const updatedSourceStep = { + ...sourceStep, + nextStepIds: sourceStep.nextStepIds?.filter( + (nextStepId) => nextStepId !== target, + ), + }; + + const updatedSteps = steps.map((step) => { + if (step.id === source) { + return updatedSourceStep; + } + + return step; + }); + + await workflowVersionRepository.update(workflowVersion.id, { + steps: updatedSteps, + }); + + return computeWorkflowVersionStepChanges({ + trigger, + steps: updatedSteps, + }); + } + private async enrichOutputSchema({ step, workspaceId, @@ -423,12 +684,21 @@ export class WorkflowVersionStepWorkspaceService { private async getStepDefaultDefinition({ type, workspaceId, + position, }: { type: WorkflowActionType; workspaceId: string; + position?: WorkflowStepPositionInput; }): Promise { const newStepId = v4(); + const baseStep = { + id: newStepId, + position, + valid: false, + nextStepIds: [], + }; + switch (type) { case WorkflowActionType.CODE: { const newServerlessFunction = @@ -448,10 +718,9 @@ export class WorkflowVersionStepWorkspaceService { } return { - id: newStepId, + ...baseStep, name: 'Code - Serverless Function', type: WorkflowActionType.CODE, - valid: false, settings: { ...BASE_STEP_DEFINITION, outputSchema: { @@ -473,10 +742,9 @@ export class WorkflowVersionStepWorkspaceService { } case WorkflowActionType.SEND_EMAIL: { return { - id: newStepId, + ...baseStep, name: 'Send Email', type: WorkflowActionType.SEND_EMAIL, - valid: false, settings: { ...BASE_STEP_DEFINITION, input: { @@ -495,10 +763,9 @@ export class WorkflowVersionStepWorkspaceService { }); return { - id: newStepId, + ...baseStep, name: 'Create Record', type: WorkflowActionType.CREATE_RECORD, - valid: false, settings: { ...BASE_STEP_DEFINITION, input: { @@ -515,10 +782,9 @@ export class WorkflowVersionStepWorkspaceService { }); return { - id: newStepId, + ...baseStep, name: 'Update Record', type: WorkflowActionType.UPDATE_RECORD, - valid: false, settings: { ...BASE_STEP_DEFINITION, input: { @@ -537,10 +803,9 @@ export class WorkflowVersionStepWorkspaceService { }); return { - id: newStepId, + ...baseStep, name: 'Delete Record', type: WorkflowActionType.DELETE_RECORD, - valid: false, settings: { ...BASE_STEP_DEFINITION, input: { @@ -557,10 +822,9 @@ export class WorkflowVersionStepWorkspaceService { }); return { - id: newStepId, + ...baseStep, name: 'Search Records', type: WorkflowActionType.FIND_RECORDS, - valid: false, settings: { ...BASE_STEP_DEFINITION, input: { @@ -572,10 +836,9 @@ export class WorkflowVersionStepWorkspaceService { } case WorkflowActionType.FORM: { return { - id: newStepId, + ...baseStep, name: 'Form', type: WorkflowActionType.FORM, - valid: false, settings: { ...BASE_STEP_DEFINITION, input: [], @@ -584,10 +847,9 @@ export class WorkflowVersionStepWorkspaceService { } case WorkflowActionType.FILTER: { return { - id: newStepId, + ...baseStep, name: 'Filter', type: WorkflowActionType.FILTER, - valid: false, settings: { ...BASE_STEP_DEFINITION, input: { @@ -599,10 +861,9 @@ export class WorkflowVersionStepWorkspaceService { } case WorkflowActionType.HTTP_REQUEST: { return { - id: newStepId, + ...baseStep, name: 'HTTP Request', type: WorkflowActionType.HTTP_REQUEST, - valid: false, settings: { ...BASE_STEP_DEFINITION, input: { @@ -616,10 +877,9 @@ export class WorkflowVersionStepWorkspaceService { } case WorkflowActionType.AI_AGENT: { return { - id: newStepId, + ...baseStep, name: 'AI Agent', type: WorkflowActionType.AI_AGENT, - valid: false, settings: { ...BASE_STEP_DEFINITION, input: { 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 da6e7ba583..e05b01dbc2 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 @@ -17,6 +17,7 @@ import { assertWorkflowVersionIsDraft } from 'src/modules/workflow/common/utils/ import { assertWorkflowVersionTriggerIsDefined } from 'src/modules/workflow/common/utils/assert-workflow-version-trigger-is-defined.util'; import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service'; import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { WorkflowStepPositionUpdateInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-step-position-update-input.dto'; @Injectable() export class WorkflowVersionWorkspaceService { @@ -112,4 +113,61 @@ export class WorkflowVersionWorkspaceService { return draftWorkflowVersion.id; } + + async updateWorkflowVersionPositions({ + workflowVersionId, + positions, + workspaceId, + }: { + workflowVersionId: string; + positions: WorkflowStepPositionUpdateInput[]; + workspaceId: string; + }) { + const workflowVersionRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + 'workflowVersion', + { shouldBypassPermissionChecks: true }, + ); + + const workflowVersion = await workflowVersionRepository.findOneOrFail({ + where: { + id: workflowVersionId, + }, + }); + + assertWorkflowVersionIsDraft(workflowVersion); + + const triggerPosition = positions.find( + (position) => position.id === 'trigger', + ); + + const updatedTrigger = + isDefined(triggerPosition) && isDefined(workflowVersion.trigger) + ? { + ...workflowVersion.trigger, + position: triggerPosition.position, + } + : undefined; + + const updatedSteps = workflowVersion.steps?.map((step) => { + const updatedStep = positions.find((position) => position.id === step.id); + + if (updatedStep) { + return { + ...step, + position: updatedStep.position, + }; + } + + return step; + }); + + const updatePayload = { + ...(!isDefined(updatedTrigger) ? {} : { trigger: updatedTrigger }), + ...(!isDefined(updatedSteps) ? {} : { steps: updatedSteps }), + }; + + await workflowVersionRepository.update(workflowVersionId, updatePayload); + } } diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts index 6f7bbb1c56..f62ff457a2 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts @@ -30,6 +30,10 @@ type BaseWorkflowAction = { name: string; type: WorkflowActionType; settings: WorkflowActionSettings; + position?: { + x: number; + y: number; + }; valid: boolean; nextStepIds?: string[]; }; diff --git a/packages/twenty-server/src/modules/workflow/workflow-runner/jobs/run-workflow.job.ts b/packages/twenty-server/src/modules/workflow/workflow-runner/jobs/run-workflow.job.ts index 37c912779b..2611c0f7f9 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-runner/jobs/run-workflow.job.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-runner/jobs/run-workflow.job.ts @@ -113,7 +113,8 @@ export class RunWorkflowJob { const rootSteps = getRootSteps(workflowVersion.steps); await this.workflowExecutorWorkspaceService.executeFromSteps({ - stepIds: rootSteps.map((step) => step.id), + stepIds: + workflowVersion.trigger.nextStepIds ?? rootSteps.map((step) => step.id), workflowRunId, workspaceId, }); diff --git a/packages/twenty-server/src/modules/workflow/workflow-trigger/types/workflow-trigger.type.ts b/packages/twenty-server/src/modules/workflow/workflow-trigger/types/workflow-trigger.type.ts index 6b18be8204..7c6253d537 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-trigger/types/workflow-trigger.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-trigger/types/workflow-trigger.type.ts @@ -17,6 +17,10 @@ type BaseTrigger = { type: WorkflowTriggerType; settings: BaseWorkflowTriggerSettings; nextStepIds?: string[]; + position?: { + x: number; + y: number; + }; }; export type WorkflowDatabaseEventTrigger = BaseTrigger & { diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index 8bd2fec90e..581b4869f9 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -254,6 +254,7 @@ export { IconRelationOneToMany, IconRelationOneToOne, IconReload, + IconReorder, IconRepeat, IconRestore, IconRobot, diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts index ee2aa277b4..e95bb6f6ff 100644 --- a/packages/twenty-ui/src/display/index.ts +++ b/packages/twenty-ui/src/display/index.ts @@ -316,6 +316,7 @@ export { IconRelationOneToMany, IconRelationOneToOne, IconReload, + IconReorder, IconRepeat, IconRestore, IconRobot,