diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepInputDetail.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepInputDetail.tsx index 398edf8df8d..008693edc7a 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepInputDetail.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepInputDetail.tsx @@ -1,8 +1,11 @@ +import { workflowRunIteratorSubStepIterationIndexComponentState } from '@/command-menu/pages/workflow/step/view-run/states/workflowRunIteratorSubStepIterationIndexComponentState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun'; import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow'; import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow'; import { WorkflowRunStepJsonContainer } from '@/workflow/workflow-steps/components/WorkflowRunStepJsonContainer'; import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader'; +import { getIsDescendantOfIterator } from '@/workflow/workflow-steps/utils/getIsDescendantOfIterator'; import { getWorkflowRunStepContext } from '@/workflow/workflow-steps/utils/getWorkflowRunStepContext'; import { getWorkflowVariablesUsedInStep } from '@/workflow/workflow-steps/utils/getWorkflowVariablesUsedInStep'; import { getActionHeaderTypeOrThrow } from '@/workflow/workflow-steps/workflow-actions/utils/getActionHeaderTypeOrThrow'; @@ -33,6 +36,10 @@ export const WorkflowRunStepInputDetail = ({ stepId }: { stepId: string }) => { (step) => step.id === stepId, ); + const workflowRunIteratorSubStepIterationIndex = useRecoilComponentValue( + workflowRunIteratorSubStepIterationIndexComponentState, + ); + if ( !( isDefined(workflowRun) && @@ -71,6 +78,12 @@ export const WorkflowRunStepInputDetail = ({ stepId }: { stepId: string }) => { stepInfos: workflowRun.state.stepInfos, flow: workflowRun.state.flow, stepId, + currentLoopIterationIndex: getIsDescendantOfIterator({ + stepId, + steps: workflowRun.state.flow.steps, + }) + ? workflowRunIteratorSubStepIterationIndex + : undefined, }); if (stepContext.length === 0) { diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/types/WorkflowRunStepContext.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/types/WorkflowRunStepContext.ts new file mode 100644 index 00000000000..22d29ddc7d4 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/types/WorkflowRunStepContext.ts @@ -0,0 +1,5 @@ +export type WorkflowRunStepContext = { + id: string; + name: string; + context: unknown; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/utils/__tests__/getWorkflowRunStepContext.test.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/utils/__tests__/getWorkflowRunStepContext.test.ts index 37f2f55ce8c..15a404f19ef 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/utils/__tests__/getWorkflowRunStepContext.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/utils/__tests__/getWorkflowRunStepContext.test.ts @@ -2,284 +2,298 @@ import { type WorkflowRunFlow } from '@/workflow/types/Workflow'; import { StepStatus, TRIGGER_STEP_ID } from 'twenty-shared/workflow'; import { getWorkflowRunStepContext } from '../getWorkflowRunStepContext'; -describe('getWorkflowRunStepContext', () => { - it('should return an empty array for trigger step', () => { - const flow = { - trigger: { - name: 'Company Created', - type: 'DATABASE_EVENT', - settings: { - eventName: 'company.created', - outputSchema: {}, +const mockFlow = { + trigger: { + type: 'MANUAL', + name: 'Trigger', + settings: { outputSchema: {} }, + }, + steps: [ + { + id: 'step1', + name: 'Step 1', + type: 'CODE', + nextStepIds: ['step2'], + valid: true, + settings: { + input: { + serverlessFunctionId: '', + serverlessFunctionInput: {}, + serverlessFunctionVersion: '', + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { value: false }, + continueOnFailure: { value: false }, }, }, - steps: [], - } satisfies WorkflowRunFlow; - const stepInfos = { - [TRIGGER_STEP_ID]: { - result: { company: { id: '123' } }, - status: StepStatus.SUCCESS, + }, + { + id: 'step2', + name: 'Step 2', + type: 'CODE', + nextStepIds: [], + valid: true, + settings: { + input: { + serverlessFunctionId: '', + serverlessFunctionInput: {}, + serverlessFunctionVersion: '', + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { value: false }, + continueOnFailure: { value: false }, + }, }, - }; + }, + ], +} satisfies WorkflowRunFlow; +const mockStepInfos = { + [TRIGGER_STEP_ID]: { result: 'trigger-result', status: StepStatus.SUCCESS }, + step1: { result: 'step1-result', status: StepStatus.SUCCESS }, + step2: { result: 'step2-result', status: StepStatus.SUCCESS }, +}; + +const mockStepInfosWithHistory = { + [TRIGGER_STEP_ID]: { result: 'trigger-result', status: StepStatus.SUCCESS }, + step1: { + result: 'step1-result', + status: StepStatus.SUCCESS, + history: [ + { result: 'step1-result', status: StepStatus.SUCCESS }, + { result: 'step1-result-2', status: StepStatus.SUCCESS }, + ], + }, + step2: { + result: 'step2-result', + status: StepStatus.SUCCESS, + history: [ + { result: 'step2-result', status: StepStatus.SUCCESS }, + { result: 'step2-result-2', status: StepStatus.SUCCESS }, + ], + }, +}; + +describe('getWorkflowRunStepContext', () => { + it('returns empty array for trigger step', () => { const result = getWorkflowRunStepContext({ stepId: TRIGGER_STEP_ID, - flow, - stepInfos, + flow: mockFlow, + stepInfos: mockStepInfos, + currentLoopIterationIndex: undefined, }); - expect(result).toEqual([]); }); - it('should include previous steps context', () => { - const flow = { - trigger: { - name: 'Company Created', - type: 'DATABASE_EVENT', - settings: { - eventName: 'company.created', - outputSchema: {}, - }, - }, - steps: [ - { - id: 'step1', - name: 'Create company', - type: 'CREATE_RECORD', - settings: { - errorHandlingOptions: { - continueOnFailure: { value: false }, - retryOnFailure: { value: false }, - }, - input: { - objectName: 'Company', - objectRecord: {}, - }, - outputSchema: {}, - }, - valid: true, - nextStepIds: ['step2'], - }, - { - id: 'step2', - name: 'Send Email', - type: 'SEND_EMAIL', - settings: { - errorHandlingOptions: { - continueOnFailure: { value: false }, - retryOnFailure: { value: false }, - }, - input: { - connectedAccountId: '123', - email: '', - }, - outputSchema: {}, - }, - valid: true, - nextStepIds: [], - }, - ], - } satisfies WorkflowRunFlow; - - const stepInfos = { - [TRIGGER_STEP_ID]: { - result: { company: { id: '123' } }, - status: StepStatus.SUCCESS, - }, - step1: { result: { taskId: '456' }, status: StepStatus.SUCCESS }, - step2: { result: { taskId: '456' }, status: StepStatus.SUCCESS }, - }; - + it('returns context for previous steps and trigger', () => { const result = getWorkflowRunStepContext({ stepId: 'step2', - flow, - stepInfos, + flow: mockFlow, + stepInfos: mockStepInfos, + currentLoopIterationIndex: undefined, }); - expect(result).toEqual([ - { - id: TRIGGER_STEP_ID, - name: 'Company Created', - context: { company: { id: '123' } }, - }, - { - id: 'step1', - name: 'Create company', - context: { taskId: '456' }, - }, + { id: TRIGGER_STEP_ID, name: 'Trigger', context: 'trigger-result' }, + { id: 'step1', name: 'Step 1', context: 'step1-result' }, ]); }); - it('should not include subsequent steps context', () => { - const flow = { - trigger: { - name: 'Company Created', - type: 'DATABASE_EVENT', - settings: { - eventName: 'company.created', - outputSchema: {}, - }, - }, - steps: [ - { - id: 'step1', - name: 'Create company', - type: 'CREATE_RECORD', - settings: { - errorHandlingOptions: { - continueOnFailure: { value: false }, - retryOnFailure: { value: false }, - }, - input: { - objectName: 'Company', - objectRecord: {}, - }, - outputSchema: {}, - }, - valid: true, - }, - { - id: 'step2', - name: 'Send Email', - type: 'SEND_EMAIL', - settings: { - errorHandlingOptions: { - continueOnFailure: { value: false }, - retryOnFailure: { value: false }, - }, - input: { - connectedAccountId: '123', - email: '', - }, - outputSchema: {}, - }, - valid: true, - }, - ], - } satisfies WorkflowRunFlow; - const stepInfos = { - [TRIGGER_STEP_ID]: { - result: { company: { id: '123' } }, - status: StepStatus.SUCCESS, - }, - step1: { result: { taskId: '456' }, status: StepStatus.SUCCESS }, - step2: { result: { emailId: '789' }, status: StepStatus.SUCCESS }, - }; - + it('returns context for previous steps with loop index', () => { const result = getWorkflowRunStepContext({ - stepId: 'step1', - flow, - stepInfos, + stepId: 'step2', + flow: mockFlow, + stepInfos: mockStepInfosWithHistory, + currentLoopIterationIndex: 1, }); - expect(result).toEqual([ - { - id: TRIGGER_STEP_ID, - name: 'Company Created', - context: { company: { id: '123' } }, - }, + { id: TRIGGER_STEP_ID, name: 'Trigger', context: 'trigger-result' }, + { id: 'step1', name: 'Step 1', context: 'step1-result-2' }, ]); }); - it('should handle multiple steps with the same name', () => { - const flow = { + it('returns empty array if step not found', () => { + const result = getWorkflowRunStepContext({ + stepId: 'unknown', + flow: mockFlow, + stepInfos: mockStepInfos, + currentLoopIterationIndex: undefined, + }); + expect(result).toEqual([]); + }); + + it('handles iterator step type and loop context', () => { + const flowWithIterator = { trigger: { - name: 'Company Created', - type: 'DATABASE_EVENT', - settings: { - eventName: 'company.created', - outputSchema: {}, - }, + type: 'MANUAL', + name: 'Trigger', + settings: { outputSchema: {} }, }, steps: [ { id: 'step1', - name: 'Create Note', - type: 'CREATE_RECORD', + name: 'Step 1', + type: 'CODE', + nextStepIds: ['iterator1'], + valid: true, settings: { - errorHandlingOptions: { - continueOnFailure: { value: false }, - retryOnFailure: { value: false }, - }, input: { - objectName: 'Note', - objectRecord: {}, + serverlessFunctionId: '', + serverlessFunctionInput: {}, + serverlessFunctionVersion: '', }, outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { value: false }, + continueOnFailure: { value: false }, + }, }, - valid: true, - nextStepIds: ['step2'], }, { - id: 'step2', - name: 'Create Note', - type: 'CREATE_RECORD', - settings: { - errorHandlingOptions: { - continueOnFailure: { value: false }, - retryOnFailure: { value: false }, - }, - input: { - objectName: 'Note', - objectRecord: {}, - }, - outputSchema: {}, - }, - valid: true, - nextStepIds: ['step3'], - }, - { - id: 'step3', - name: 'Create Note', - type: 'CREATE_RECORD', - settings: { - errorHandlingOptions: { - continueOnFailure: { value: false }, - retryOnFailure: { value: false }, - }, - input: { - objectName: 'Note', - objectRecord: {}, - }, - outputSchema: {}, - }, - valid: true, + id: 'iterator1', + name: 'Iterator', + type: 'ITERATOR', nextStepIds: [], + valid: true, + settings: { + input: { + initialLoopStepIds: ['step2'], + items: [1, 2, 3], + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { value: false }, + continueOnFailure: { value: false }, + }, + }, + }, + { + id: 'step2', + name: 'Step 2', + type: 'CODE', + nextStepIds: ['iterator1'], + valid: true, + settings: { + input: { + serverlessFunctionId: '', + serverlessFunctionInput: {}, + serverlessFunctionVersion: '', + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { value: false }, + continueOnFailure: { value: false }, + }, + }, }, ], } satisfies WorkflowRunFlow; + const stepInfos = { [TRIGGER_STEP_ID]: { - result: { company: { id: '123' } }, + result: 'trigger-result', status: StepStatus.SUCCESS, }, - step1: { result: { noteId: '456' }, status: StepStatus.SUCCESS }, - step2: { result: { noteId: '789' }, status: StepStatus.SUCCESS }, - step3: { result: { noteId: '101' }, status: StepStatus.SUCCESS }, + step1: { + result: 'step1-result', + status: StepStatus.SUCCESS, + }, + iterator1: { + result: { + currentItemIndex: 2, + hasProcessedAllItems: true, + }, + status: StepStatus.SUCCESS, + history: [ + { + result: { + currentItem: 1, + currentItemIndex: 0, + hasProcessedAllItems: false, + }, + status: StepStatus.RUNNING, + }, + { + result: { + currentItem: 2, + currentItemIndex: 1, + hasProcessedAllItems: false, + }, + status: StepStatus.RUNNING, + }, + ], + }, + step2: { + result: 'step2-result-3', + status: StepStatus.SUCCESS, + history: [ + { result: 'step2-result-1', status: StepStatus.SUCCESS }, + { result: 'step2-result-2', status: StepStatus.SUCCESS }, + ], + }, }; - const result = getWorkflowRunStepContext({ - stepId: 'step3', - flow, + const step2Iteration0Result = getWorkflowRunStepContext({ + stepId: 'step2', + flow: flowWithIterator, stepInfos, + currentLoopIterationIndex: 0, }); - expect(result).toEqual([ - { - id: TRIGGER_STEP_ID, - name: 'Company Created', - context: { company: { id: '123' } }, - }, - { - id: 'step1', - name: 'Create Note', - context: { noteId: '456' }, - }, - { - id: 'step2', - name: 'Create Note', - context: { noteId: '789' }, - }, - ]); + expect(step2Iteration0Result).toMatchInlineSnapshot(` +[ + { + "context": "trigger-result", + "id": "trigger", + "name": "Trigger", + }, + { + "context": "step1-result", + "id": "step1", + "name": "Step 1", + }, + { + "context": { + "currentItem": 1, + "currentItemIndex": 0, + "hasProcessedAllItems": false, + }, + "id": "iterator1", + "name": "Iterator", + }, +] +`); + + const step2Iteration1Result = getWorkflowRunStepContext({ + stepId: 'step2', + flow: flowWithIterator, + stepInfos, + currentLoopIterationIndex: 1, + }); + + expect(step2Iteration1Result).toMatchInlineSnapshot(` +[ + { + "context": "trigger-result", + "id": "trigger", + "name": "Trigger", + }, + { + "context": "step1-result", + "id": "step1", + "name": "Step 1", + }, + { + "context": { + "currentItem": 2, + "currentItemIndex": 1, + "hasProcessedAllItems": false, + }, + "id": "iterator1", + "name": "Iterator", + }, +] +`); }); }); 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 7b319cd00de..7db5caf09fc 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,8 +1,9 @@ import { type WorkflowRunFlow } from '@/workflow/types/Workflow'; +import { type WorkflowRunStepContext } from '@/workflow/workflow-steps/types/WorkflowRunStepContext'; import { getPreviousSteps } from '@/workflow/workflow-steps/utils/getWorkflowPreviousSteps'; +import { getWorkflowRunAllStepInfoHistory } from '@/workflow/workflow-steps/utils/getWorkflowRunAllStepInfoHistory'; import { isDefined } from 'twenty-shared/utils'; import { - getWorkflowRunContext, TRIGGER_STEP_ID, type WorkflowRunStepInfos, } from 'twenty-shared/workflow'; @@ -11,10 +12,12 @@ export const getWorkflowRunStepContext = ({ stepId, flow, stepInfos, + currentLoopIterationIndex, }: { stepId: string; stepInfos: WorkflowRunStepInfos; flow: WorkflowRunFlow; + currentLoopIterationIndex: number | undefined; }) => { if (stepId === TRIGGER_STEP_ID) { return []; @@ -31,22 +34,39 @@ export const getWorkflowRunStepContext = ({ currentStep, }); - const context = getWorkflowRunContext(stepInfos); + const reversedPreviousSteps = previousSteps.toReversed(); - const previousStepsContext = previousSteps.map((step) => { - return { + const reversedPreviousStepsContext: WorkflowRunStepContext[] = []; + + let isInLoop = isDefined(currentLoopIterationIndex); + + for (const step of reversedPreviousSteps) { + const stepInfoHistory = getWorkflowRunAllStepInfoHistory({ + stepInfo: stepInfos[step.id], + }); + + const historyItemIndex = + isDefined(currentLoopIterationIndex) && isInLoop + ? currentLoopIterationIndex + : 0; + + reversedPreviousStepsContext.push({ id: step.id, name: step.name, - context: context[step.id], - }; - }); + context: stepInfoHistory[historyItemIndex].result, + }); + + if (step.type === 'ITERATOR') { + isInLoop = false; + } + } return [ { id: TRIGGER_STEP_ID, name: flow.trigger.name ?? 'Trigger', - context: context[TRIGGER_STEP_ID], + context: stepInfos[TRIGGER_STEP_ID].result, }, - ...previousStepsContext, + ...reversedPreviousStepsContext.toReversed(), ]; };