diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index dee6ba07b..9a01771ac 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -411,6 +411,10 @@ export type CreateServerlessFunctionInput = { }; export type CreateWorkflowVersionStepInput = { + /** Next step ID */ + nextStepId?: InputMaybe; + /** Parent step ID */ + parentStepId?: InputMaybe; /** New step type */ stepType: Scalars['String']; /** Workflow version ID */ 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 f90b0c6b1..2154850c5 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 @@ -12,8 +12,8 @@ import { import { getWorkflowDiagramTriggerNode } from '@/workflow/workflow-diagram/utils/getWorkflowDiagramTriggerNode'; import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId'; -import { v4 } from 'uuid'; import { isDefined } from 'twenty-shared/utils'; +import { v4 } from 'uuid'; export const generateWorkflowDiagram = ({ trigger, 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 a5bb34611..674ca3a0f 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 @@ -38,6 +38,8 @@ export const useCreateStep = ({ await createWorkflowVersionStep({ workflowVersionId, stepType: newStepType, + parentStepId: workflowCreateStepFromParentStepId, + nextStepId: undefined, }) )?.data?.createWorkflowVersionStep; 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 f8df518d9..5105e057c 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 @@ -15,4 +15,16 @@ export class CreateWorkflowVersionStepInput { nullable: false, }) stepType: WorkflowActionType; + + @Field(() => String, { + description: 'Parent step ID', + nullable: true, + }) + parentStepId?: string; + + @Field(() => String, { + description: 'Next step ID', + nullable: true, + }) + nextStepId?: 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 52867a53a..33b1f1f37 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 @@ -26,12 +26,11 @@ export class WorkflowStepResolver { async createWorkflowVersionStep( @AuthWorkspace() { id: workspaceId }: Workspace, @Args('input') - { stepType, workflowVersionId }: CreateWorkflowVersionStepInput, + input: CreateWorkflowVersionStepInput, ): Promise { return this.workflowVersionStepWorkspaceService.createWorkflowVersionStep({ workspaceId, - workflowVersionId, - stepType, + input, }); } @@ -57,7 +56,7 @@ export class WorkflowStepResolver { return this.workflowVersionStepWorkspaceService.deleteWorkflowVersionStep({ workspaceId, workflowVersionId, - stepId, + stepIdToDelete: stepId, }); } 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 new file mode 100644 index 000000000..60fd0d0db --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/__tests__/insert-step.spec.ts @@ -0,0 +1,111 @@ +import { insertStep } from 'src/modules/workflow/workflow-builder/workflow-step/utils/insert-step'; +import { + WorkflowAction, + WorkflowActionType, +} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; + +describe('insertStep', () => { + const createMockAction = ( + id: string, + nextStepIds?: string[], + ): WorkflowAction => ({ + id, + name: `Action ${id}`, + type: WorkflowActionType.CODE, + settings: { + input: { + serverlessFunctionId: 'test', + serverlessFunctionVersion: '1.0.0', + serverlessFunctionInput: {}, + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { value: false }, + continueOnFailure: { value: false }, + }, + }, + valid: true, + nextStepIds, + }); + + it('should insert a step at the end of the array when no parent or next step is specified', () => { + const step1 = createMockAction('1'); + const step2 = createMockAction('2'); + const newStep = createMockAction('new'); + + const result = insertStep({ + existingSteps: [step1, step2], + insertedStep: newStep, + }); + + expect(result).toEqual([step1, step2, newStep]); + }); + + it('should update parent step nextStepIds when inserting a step between two steps', () => { + const step1 = createMockAction('1', ['2']); + const step2 = createMockAction('2'); + const newStep = createMockAction('new'); + + const result = insertStep({ + existingSteps: [step1, step2], + insertedStep: newStep, + parentStepId: '1', + nextStepId: '2', + }); + + expect(result).toEqual([ + { ...step1, nextStepIds: ['new'] }, + step2, + { ...newStep, nextStepIds: ['2'] }, + ]); + }); + + it('should handle inserting a step at the beginning of the workflow', () => { + const step1 = createMockAction('1'); + const newStep = createMockAction('new'); + + const result = insertStep({ + existingSteps: [step1], + insertedStep: newStep, + parentStepId: undefined, + nextStepId: '1', + }); + + expect(result).toEqual([step1, { ...newStep, nextStepIds: ['1'] }]); + }); + + it('should handle inserting a step at the end of the workflow', () => { + const step1 = createMockAction('1'); + const newStep = createMockAction('new'); + + const result = insertStep({ + existingSteps: [step1], + insertedStep: newStep, + parentStepId: '1', + nextStepId: undefined, + }); + + expect(result).toEqual([{ ...step1, nextStepIds: ['new'] }, newStep]); + }); + + it('should handle inserting a step between two steps with multiple nextStepIds', () => { + const step1 = createMockAction('1', ['2', '3']); + const step2 = createMockAction('2'); + const step3 = createMockAction('3'); + const newStep = createMockAction('new'); + + const result = insertStep({ + existingSteps: [step1, step2, step3], + insertedStep: newStep, + parentStepId: '1', + nextStepId: '2', + }); + + expect(result).toEqual([ + { ...step1, nextStepIds: ['3', 'new'] }, + step2, + step3, + { ...newStep, nextStepIds: ['2'] }, + ]); + }); +}); 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 new file mode 100644 index 000000000..eb6764844 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/__tests__/remove-step.spec.ts @@ -0,0 +1,108 @@ +import { removeStep } from 'src/modules/workflow/workflow-builder/workflow-step/utils/remove-step'; +import { + WorkflowAction, + WorkflowActionType, +} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; + +describe('removeStep', () => { + const createMockAction = ( + id: string, + nextStepIds?: string[], + ): WorkflowAction => ({ + id, + name: `Action ${id}`, + type: WorkflowActionType.CODE, + settings: { + input: { + serverlessFunctionId: 'test', + serverlessFunctionVersion: '1.0.0', + serverlessFunctionInput: {}, + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { value: false }, + continueOnFailure: { value: false }, + }, + }, + valid: true, + nextStepIds, + }); + + it('should remove the specified step from the array', () => { + const step1 = createMockAction('1'); + const step2 = createMockAction('2'); + const step3 = createMockAction('3'); + + const result = removeStep({ + existingSteps: [step1, step2, step3], + stepIdToDelete: '2', + }); + + expect(result).toEqual([step1, step3]); + }); + + it('should handle removing a step that has no next steps', () => { + const step1 = createMockAction('1', ['2']); + const step2 = createMockAction('2'); + const step3 = createMockAction('3'); + + const result = removeStep({ + existingSteps: [step1, step2, step3], + stepIdToDelete: '2', + }); + + expect(result).toEqual([{ ...step1, nextStepIds: [] }, step3]); + }); + + it('should update nextStepIds of parent steps to include children of removed step', () => { + const step1 = createMockAction('1', ['2']); + const step2 = createMockAction('2', ['3']); + const step3 = createMockAction('3'); + + const result = removeStep({ + existingSteps: [step1, step2, step3], + stepIdToDelete: '2', + stepToDeleteChildrenIds: ['3'], + }); + + expect(result).toEqual([{ ...step1, nextStepIds: ['3'] }, step3]); + }); + + it('should handle multiple parent steps pointing to the same step', () => { + const step1 = createMockAction('1', ['3']); + const step2 = createMockAction('2', ['3']); + const step3 = createMockAction('3', ['4']); + const step4 = createMockAction('4'); + + const result = removeStep({ + existingSteps: [step1, step2, step3, step4], + stepIdToDelete: '3', + stepToDeleteChildrenIds: ['4'], + }); + + expect(result).toEqual([ + { ...step1, nextStepIds: ['4'] }, + { ...step2, nextStepIds: ['4'] }, + step4, + ]); + }); + + it('should handle removing a step with multiple children', () => { + const step1 = createMockAction('1', ['2']); + const step2 = createMockAction('2', ['3', '4']); + const step3 = createMockAction('3'); + const step4 = createMockAction('4'); + + const result = removeStep({ + existingSteps: [step1, step2, step3, step4], + stepIdToDelete: '2', + stepToDeleteChildrenIds: ['3', '4'], + }); + + expect(result).toEqual([ + { ...step1, nextStepIds: ['3', '4'] }, + step3, + step4, + ]); + }); +}); 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 new file mode 100644 index 000000000..38ab226b7 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/insert-step.ts @@ -0,0 +1,38 @@ +import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; + +export const insertStep = ({ + existingSteps, + insertedStep, + parentStepId, + nextStepId, +}: { + existingSteps: WorkflowAction[]; + insertedStep: WorkflowAction; + parentStepId?: string; + nextStepId?: string; +}): WorkflowAction[] => { + const updatedExistingSteps = existingSteps.map((existingStep) => { + if (existingStep.id === parentStepId) { + return { + ...existingStep, + nextStepIds: [ + ...new Set([ + ...(existingStep.nextStepIds?.filter((id) => id !== nextStepId) || + []), + insertedStep.id, + ]), + ], + }; + } + + return existingStep; + }); + + return [ + ...updatedExistingSteps, + { + ...insertedStep, + nextStepIds: nextStepId ? [nextStepId] : undefined, + }, + ]; +}; 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 new file mode 100644 index 000000000..a86d4e4c6 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/utils/remove-step.ts @@ -0,0 +1,30 @@ +import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; + +export const removeStep = ({ + existingSteps, + stepIdToDelete, + stepToDeleteChildrenIds, +}: { + existingSteps: WorkflowAction[]; + stepIdToDelete: string; + stepToDeleteChildrenIds?: string[]; +}): WorkflowAction[] => { + return 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 || []), + ]), + ], + }; + } + + return step; + }); +}; 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 3445dacf4..666b89c05 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 @@ -7,6 +7,7 @@ import { Repository } from 'typeorm'; 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 { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; @@ -18,6 +19,8 @@ import { import { StepOutput } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity'; import { WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity'; import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service'; +import { insertStep } from 'src/modules/workflow/workflow-builder/workflow-step/utils/insert-step'; +import { removeStep } from 'src/modules/workflow/workflow-builder/workflow-step/utils/remove-step'; import { BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type'; import { WorkflowAction, @@ -54,13 +57,13 @@ export class WorkflowVersionStepWorkspaceService { async createWorkflowVersionStep({ workspaceId, - workflowVersionId, - stepType, + input, }: { workspaceId: string; - workflowVersionId: string; - stepType: WorkflowActionType; + input: CreateWorkflowVersionStepInput; }): Promise { + const { workflowVersionId, stepType, parentStepId, nextStepId } = input; + const newStep = await this.getStepDefaultDefinition({ type: stepType, workspaceId, @@ -87,8 +90,16 @@ export class WorkflowVersionStepWorkspaceService { ); } + const existingSteps = workflowVersion.steps || []; + const updatedSteps = insertStep({ + existingSteps, + insertedStep: enrichedNewStep, + parentStepId, + nextStepId, + }); + await workflowVersionRepository.update(workflowVersion.id, { - steps: [...(workflowVersion.steps || []), enrichedNewStep], + steps: updatedSteps, }); return enrichedNewStep; @@ -151,11 +162,11 @@ export class WorkflowVersionStepWorkspaceService { async deleteWorkflowVersionStep({ workspaceId, workflowVersionId, - stepId, + stepIdToDelete, }: { workspaceId: string; workflowVersionId: string; - stepId: string; + stepIdToDelete: string; }): Promise { const workflowVersionRepository = await this.twentyORMManager.getRepository( @@ -182,9 +193,9 @@ export class WorkflowVersionStepWorkspaceService { ); } - const stepToDelete = workflowVersion.steps.filter( - (step) => step.id === stepId, - )?.[0]; + const stepToDelete = workflowVersion.steps.find( + (step) => step.id === stepIdToDelete, + ); if (!isDefined(stepToDelete)) { throw new WorkflowVersionStepException( @@ -194,9 +205,15 @@ export class WorkflowVersionStepWorkspaceService { } const workflowVersionUpdates = - stepId === TRIGGER_STEP_ID + stepIdToDelete === TRIGGER_STEP_ID ? { trigger: null } - : { steps: workflowVersion.steps.filter((step) => step.id !== stepId) }; + : { + steps: removeStep({ + existingSteps: workflowVersion.steps, + stepIdToDelete, + stepToDeleteChildrenIds: stepToDelete.nextStepIds, + }), + }; await workflowVersionRepository.update( workflowVersion.id,