diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/utils/__tests__/getWorkflowPreviousSteps.test.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/utils/__tests__/getWorkflowPreviousSteps.test.ts index 74b0114c8c5..81d9ee73c6d 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/utils/__tests__/getWorkflowPreviousSteps.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/utils/__tests__/getWorkflowPreviousSteps.test.ts @@ -1,111 +1,188 @@ import { type WorkflowStep } from '@/workflow/types/Workflow'; import { getPreviousSteps } from '../getWorkflowPreviousSteps'; -const mockWorkflow: WorkflowStep[] = [ - { - id: 'step1', - name: 'First Step', - type: 'CODE', - valid: true, - nextStepIds: ['step2', 'step3'], - settings: { - input: { - serverlessFunctionId: 'func1', - serverlessFunctionVersion: '1.0.0', - serverlessFunctionInput: {}, - }, - outputSchema: {}, - errorHandlingOptions: { - retryOnFailure: { value: true }, - continueOnFailure: { value: true }, - }, - }, - }, - { - id: 'step2', - name: 'Second Step', - type: 'CODE', - valid: true, - nextStepIds: ['step4'], - settings: { - input: { - serverlessFunctionId: 'func2', - serverlessFunctionVersion: '1.0.0', - serverlessFunctionInput: {}, - }, - outputSchema: {}, - errorHandlingOptions: { - retryOnFailure: { value: true }, - continueOnFailure: { value: true }, - }, - }, - }, - { - id: 'step3', - name: 'Third Step', - type: 'CODE', - valid: true, - nextStepIds: ['step4'], - settings: { - input: { - serverlessFunctionId: 'func3', - serverlessFunctionVersion: '1.0.0', - serverlessFunctionInput: {}, - }, - outputSchema: {}, - errorHandlingOptions: { - retryOnFailure: { value: true }, - continueOnFailure: { value: true }, - }, - }, - }, - { - id: 'step4', - name: 'Fourth Step', - type: 'CODE', - valid: true, - nextStepIds: [], - settings: { - input: { - serverlessFunctionId: 'func4', - serverlessFunctionVersion: '1.0.0', - serverlessFunctionInput: {}, - }, - outputSchema: {}, - errorHandlingOptions: { - retryOnFailure: { value: true }, - continueOnFailure: { value: true }, - }, - }, - }, -]; - describe('getWorkflowPreviousSteps', () => { - it('should return empty array when there are no previous steps', () => { - const result = getPreviousSteps(mockWorkflow, 'step1'); - expect(result).toEqual([]); + describe('using a simple workflow', () => { + const mockWorkflow: WorkflowStep[] = [ + { + id: 'step1', + name: 'First Step', + type: 'CODE', + valid: true, + nextStepIds: ['step2', 'step3'], + settings: { + input: { + serverlessFunctionId: 'func1', + serverlessFunctionVersion: '1.0.0', + serverlessFunctionInput: {}, + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: true }, + }, + }, + }, + { + id: 'step2', + name: 'Second Step', + type: 'CODE', + valid: true, + nextStepIds: ['step4'], + settings: { + input: { + serverlessFunctionId: 'func2', + serverlessFunctionVersion: '1.0.0', + serverlessFunctionInput: {}, + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: true }, + }, + }, + }, + { + id: 'step3', + name: 'Third Step', + type: 'CODE', + valid: true, + nextStepIds: ['step4'], + settings: { + input: { + serverlessFunctionId: 'func3', + serverlessFunctionVersion: '1.0.0', + serverlessFunctionInput: {}, + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: true }, + }, + }, + }, + { + id: 'step4', + name: 'Fourth Step', + type: 'CODE', + valid: true, + nextStepIds: [], + settings: { + input: { + serverlessFunctionId: 'func4', + serverlessFunctionVersion: '1.0.0', + serverlessFunctionInput: {}, + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { value: true }, + continueOnFailure: { value: true }, + }, + }, + }, + ]; + + it('should return empty array when there are no previous steps', () => { + const result = getPreviousSteps({ + steps: mockWorkflow, + currentStep: mockWorkflow[0], + }); + expect(result).toEqual([]); + }); + + it('should return direct previous steps', () => { + const result = getPreviousSteps({ + steps: mockWorkflow, + currentStep: mockWorkflow[1], + }); + expect(result).toEqual([mockWorkflow[0]]); + }); + + it('should return all previous steps including indirect ones', () => { + const result = getPreviousSteps({ + steps: mockWorkflow, + currentStep: mockWorkflow[3], + }); + expect(result).toEqual([ + mockWorkflow[0], + mockWorkflow[1], + mockWorkflow[2], + ]); + }); + + it('should handle circular dependencies', () => { + const circularWorkflow = [...mockWorkflow]; + circularWorkflow[3].nextStepIds = ['step1']; // Make step4 point back to step1 + + const result = getPreviousSteps({ + steps: circularWorkflow, + currentStep: circularWorkflow[3], + }); + expect(result).toEqual([ + mockWorkflow[0], + mockWorkflow[1], + mockWorkflow[2], + ]); + }); }); - it('should return direct previous steps', () => { - const result = getPreviousSteps(mockWorkflow, 'step2'); - expect(result).toEqual([mockWorkflow[0]]); - }); + describe('using a workflow with an iterator', () => { + const mockWorkflowWithIterator = [ + { + id: 'iterator1', + name: 'Iterator Step', + type: 'ITERATOR', + valid: true, + nextStepIds: ['step3'], + settings: { + input: { + initialLoopStepIds: ['step2'], + }, + }, + }, + { + id: 'step2', + name: 'Second Step', + type: 'CODE', + valid: true, + nextStepIds: ['iterator1'], + settings: {}, + }, + { + id: 'step3', + name: 'Third Step', + type: 'CODE', + valid: true, + nextStepIds: [], + settings: {}, + }, + ] as WorkflowStep[]; - it('should return all previous steps including indirect ones', () => { - const result = getPreviousSteps(mockWorkflow, 'step4'); - expect(result).toEqual([mockWorkflow[0], mockWorkflow[1], mockWorkflow[2]]); - }); + it('should consider iterator step as parent of loop steps', () => { + const result = getPreviousSteps({ + steps: mockWorkflowWithIterator, + currentStep: mockWorkflowWithIterator[1], + }); - it('should handle circular dependencies', () => { - const circularWorkflow = [...mockWorkflow]; - circularWorkflow[3].nextStepIds = ['step1']; // Make step4 point back to step1 + expect(result).toEqual([mockWorkflowWithIterator[0]]); + }); - const result = getPreviousSteps(circularWorkflow, 'step4'); - expect(result).toEqual([mockWorkflow[0], mockWorkflow[1], mockWorkflow[2]]); - }); + it('should not consider loop step as parent of iterator', () => { + const result = getPreviousSteps({ + steps: mockWorkflowWithIterator, + currentStep: mockWorkflowWithIterator[0], + }); - it('should handle non-existent step ID', () => { - const result = getPreviousSteps(mockWorkflow, 'non-existent-step'); - expect(result).toEqual([]); + expect(result).toEqual([]); + }); + + it('should consider iterator step as parent of non-loop steps', () => { + const result = getPreviousSteps({ + steps: mockWorkflowWithIterator, + currentStep: mockWorkflowWithIterator[2], + }); + + expect(result).toEqual([mockWorkflowWithIterator[0]]); + }); }); }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/utils/getWorkflowPreviousSteps.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/utils/getWorkflowPreviousSteps.ts index fb973b7175b..af1f33f6a3d 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/utils/getWorkflowPreviousSteps.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/utils/getWorkflowPreviousSteps.ts @@ -1,12 +1,54 @@ import { type WorkflowStep } from '@/workflow/types/Workflow'; +import { isLastStepOfLoop } from '@/workflow/workflow-diagram/utils/isLastStepOfLoop'; -export const getPreviousSteps = ( - steps: WorkflowStep[], - currentStepId: string, - visitedSteps: Set = new Set([currentStepId]), -): WorkflowStep[] => { +const isParentStep = ({ + currentStep, + potentialParentStep, + steps, +}: { + currentStep: WorkflowStep; + potentialParentStep: WorkflowStep; + steps: WorkflowStep[]; +}) => { + if (potentialParentStep.type === 'ITERATOR') { + return ( + potentialParentStep.settings.input.initialLoopStepIds?.includes( + currentStep.id, + ) || potentialParentStep.nextStepIds?.includes(currentStep.id) + ); + } + + if (currentStep.type === 'ITERATOR') { + return ( + potentialParentStep.nextStepIds?.includes(currentStep.id) && + !isLastStepOfLoop({ + iterator: currentStep, + stepId: potentialParentStep.id, + steps, + }) + ); + } + + return potentialParentStep.nextStepIds?.includes(currentStep.id); +}; + +export const getPreviousSteps = ({ + steps, + currentStep, + visitedSteps = new Set([currentStep.id]), +}: { + steps: WorkflowStep[]; + currentStep: WorkflowStep; + visitedSteps?: Set; +}): WorkflowStep[] => { const parentSteps = steps - .filter((step) => step.nextStepIds?.includes(currentStepId)) + .filter((step) => + isParentStep({ + currentStep, + potentialParentStep: step, + steps, + }), + ) .filter((step) => !visitedSteps.has(step.id)); const grandParentSteps = parentSteps @@ -15,7 +57,7 @@ export const getPreviousSteps = ( return []; } visitedSteps.add(step.id); - return getPreviousSteps(steps, step.id, visitedSteps); + return getPreviousSteps({ steps, currentStep: step, visitedSteps }); }) .flat(); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/utils/getWorkflowRunStepContext.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/utils/getWorkflowRunStepContext.ts index eab8094e6af..7b319cd00de 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/utils/getWorkflowRunStepContext.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/utils/getWorkflowRunStepContext.ts @@ -1,5 +1,6 @@ import { type WorkflowRunFlow } from '@/workflow/types/Workflow'; import { getPreviousSteps } from '@/workflow/workflow-steps/utils/getWorkflowPreviousSteps'; +import { isDefined } from 'twenty-shared/utils'; import { getWorkflowRunContext, TRIGGER_STEP_ID, @@ -19,7 +20,16 @@ export const getWorkflowRunStepContext = ({ return []; } - const previousSteps = getPreviousSteps(flow.steps, stepId); + const currentStep = flow.steps.find((step) => step.id === stepId); + + if (!isDefined(currentStep)) { + return []; + } + + const previousSteps = getPreviousSteps({ + steps: flow.steps, + currentStep, + }); const context = getWorkflowRunContext(stepInfos); diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/hooks/useAvailableVariablesInWorkflowStep.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/hooks/useAvailableVariablesInWorkflowStep.ts index 4ff1a5c21ba..9692ddc9c79 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/hooks/useAvailableVariablesInWorkflowStep.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/hooks/useAvailableVariablesInWorkflowStep.ts @@ -25,9 +25,10 @@ export const useAvailableVariablesInWorkflowStep = ({ ); const flow = useFlowOrThrow(); const steps = flow.steps ?? []; + const currentStep = steps.find((step) => step.id === workflowSelectedNode); - const previousStepIds: string[] = isDefined(workflowSelectedNode) - ? getPreviousSteps(steps, workflowSelectedNode).map((step) => step.id) + const previousStepIds: string[] = isDefined(currentStep) + ? getPreviousSteps({ steps, currentStep }).map((step) => step.id) : []; const availableStepsOutputSchema: StepOutputSchemaV2[] = useRecoilValue( diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service.ts index 15dfee1e174..886648dd917 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service.ts @@ -78,11 +78,24 @@ export class WorkflowSchemaWorkspaceService { }); case WorkflowActionType.ITERATOR: { return { - nextItemToProcess: { + currentItem: { + label: 'Current Item', isLeaf: true, type: 'unknown', value: generateFakeValue('unknown'), }, + currentItemIndex: { + label: 'Current Item Index', + isLeaf: true, + type: 'number', + value: generateFakeValue('number'), + }, + hasProcessedAllItems: { + label: 'Has Processed All Items', + isLeaf: true, + type: 'boolean', + value: false, + }, }; } case WorkflowActionType.CODE: // StepOutput schema is computed on serverlessFunction draft execution