Use variables within Iterators (#14604)

Variables were not working properly. Output schema was outdated and
parent steps were not calculated properly.

Before


https://github.com/user-attachments/assets/b070c1ff-41dd-4ea0-a590-25bb39027456

After


https://github.com/user-attachments/assets/baa43e69-0e7c-4697-8812-3ce523efba13
This commit is contained in:
Thomas Trompette
2025-09-18 23:13:57 +02:00
committed by GitHub
parent c7005db860
commit 6d5635f4e2
5 changed files with 253 additions and 110 deletions
@@ -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]]);
});
});
});
@@ -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<string> = 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<string>;
}): 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();
@@ -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);
@@ -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(
@@ -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