Allow to insert into loop step ids (#14425)

- Create empty node on iterator creation
- Add options on step creation to insert in loop
- Handle properly when a step is removed from loop
- Remove loopNextStepIds from frontend action
- Add a frontend skeleton for empty action



https://github.com/user-attachments/assets/281a8c15-8062-4702-afb4-0d9a50902252
This commit is contained in:
Thomas Trompette
2025-09-12 09:30:28 +02:00
committed by GitHub
parent bdf60b1680
commit 231d1a98af
23 changed files with 538 additions and 92 deletions
@@ -849,6 +849,8 @@ export type CreateWorkflowVersionStepInput = {
nextStepId?: InputMaybe<Scalars['UUID']>;
/** Parent step ID */
parentStepId?: InputMaybe<Scalars['String']>;
/** Step creation options */
parentStepOptions?: InputMaybe<Scalars['JSON']>;
/** Step position */
position?: InputMaybe<WorkflowStepPositionInput>;
/** New step type */
@@ -813,6 +813,8 @@ export type CreateWorkflowVersionStepInput = {
nextStepId?: InputMaybe<Scalars['UUID']>;
/** Parent step ID */
parentStepId?: InputMaybe<Scalars['String']>;
/** Step creation options */
parentStepOptions?: InputMaybe<Scalars['JSON']>;
/** Step position */
position?: InputMaybe<WorkflowStepPositionInput>;
/** New step type */
@@ -5,6 +5,7 @@ import {
type workflowCronTriggerSchema,
type workflowDatabaseEventTriggerSchema,
type workflowDeleteRecordActionSchema,
type workflowEmptyActionSchema,
type workflowFilterActionSchema,
type workflowFindRecordsActionSchema,
type workflowFormActionSchema,
@@ -47,6 +48,7 @@ export type WorkflowIteratorAction = z.infer<
typeof workflowIteratorActionSchema
>;
export type WorkflowAiAgentAction = z.infer<typeof workflowAiAgentActionSchema>;
export type WorkflowEmptyAction = z.infer<typeof workflowEmptyActionSchema>;
export type WorkflowAction =
| WorkflowCodeAction
@@ -59,7 +61,8 @@ export type WorkflowAction =
| WorkflowFormAction
| WorkflowHttpRequestAction
| WorkflowAiAgentAction
| WorkflowIteratorAction;
| WorkflowIteratorAction
| WorkflowEmptyAction;
export type WorkflowActionType = WorkflowAction['type'];
export type WorkflowStep = WorkflowAction;
@@ -8,6 +8,7 @@ import { WorkflowEditActionAiAgent } from '@/workflow/workflow-steps/workflow-ac
import { WorkflowActionServerlessFunction } from '@/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowActionServerlessFunction';
import { WorkflowEditActionCreateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord';
import { WorkflowEditActionDeleteRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionDeleteRecord';
import { WorkflowEditActionEmpty } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionEmpty';
import { WorkflowEditActionSendEmail } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail';
import { WorkflowEditActionUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord';
import { WorkflowEditActionFilter } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilter';
@@ -226,6 +227,17 @@ export const WorkflowRunStepNodeDetail = ({
/>
);
}
case 'EMPTY': {
return (
<WorkflowEditActionEmpty
key={stepId}
action={stepDefinition.definition}
actionOptions={{
readonly: true,
}}
/>
);
}
}
}
}
@@ -7,6 +7,7 @@ import { WorkflowEditActionAiAgent } from '@/workflow/workflow-steps/workflow-ac
import { WorkflowActionServerlessFunction } from '@/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowActionServerlessFunction';
import { WorkflowEditActionCreateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord';
import { WorkflowEditActionDeleteRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionDeleteRecord';
import { WorkflowEditActionEmpty } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionEmpty';
import { WorkflowEditActionSendEmail } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail';
import { WorkflowEditActionUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord';
import { WorkflowEditActionFilter } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilter';
@@ -206,6 +207,15 @@ export const WorkflowStepDetail = ({
/>
);
}
case 'EMPTY': {
return (
<WorkflowEditActionEmpty
key={stepId}
action={stepDefinition.definition}
actionOptions={props}
/>
);
}
default:
return assertUnreachable(
stepDefinition.definition,
@@ -0,0 +1,54 @@
import { type WorkflowEmptyAction } from '@/workflow/types/Workflow';
import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { useIcons } from 'twenty-ui/display';
type WorkflowEditActionEmptyProps = {
action: WorkflowEmptyAction;
actionOptions:
| {
readonly: true;
}
| {
readonly?: false;
onActionUpdate: (action: WorkflowEmptyAction) => void;
};
};
export const WorkflowEditActionEmpty = ({
action,
actionOptions,
}: WorkflowEditActionEmptyProps) => {
const { getIcon } = useIcons();
const { headerTitle, headerIcon, headerIconColor, headerType } =
useWorkflowActionHeader({
action,
defaultTitle: 'Empty Node',
});
return (
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={getIcon(headerIcon)}
iconColor={headerIconColor}
initialTitle={headerTitle}
headerType={headerType}
disabled={actionOptions.readonly}
/>
<WorkflowStepBody>{'Empty Node'}</WorkflowStepBody>
{!actionOptions.readonly && <WorkflowActionFooter stepId={action.id} />}
</>
);
};
@@ -101,20 +101,6 @@ export const WorkflowEditActionIterator = ({
readonly={actionOptions.readonly}
VariablePicker={WorkflowVariablePicker}
/>
{/* TODO: remove once the UI is implemented */}
<FormTextFieldInput
label={t`Initial loop step IDs`}
placeholder={t`Enter array of step IDs`}
defaultValue={
Array.isArray(formData.initialLoopStepIds)
? JSON.stringify(formData.initialLoopStepIds)
: (formData.initialLoopStepIds as string) || ''
}
onChange={(value: string) =>
handleFieldChange('initialLoopStepIds', value)
}
readonly={actionOptions.readonly}
/>
</WorkflowStepBody>
</>
);
@@ -23,6 +23,9 @@ export const getActionHeaderTypeOrThrow = (actionType: WorkflowActionType) => {
case 'ITERATOR': {
return msg`Iterator`;
}
case 'EMPTY': {
return msg`Empty Node`;
}
default:
assertUnreachable(actionType, `Unsupported action type: ${actionType}`);
}
@@ -23,6 +23,7 @@ export const getActionIconColorOrThrow = ({
return theme.color.orange;
case 'ITERATOR':
case 'FILTER':
case 'EMPTY':
return theme.font.color.tertiary;
case 'AI_AGENT':
return theme.color.pink;
@@ -1,8 +1,11 @@
import { Field, InputType } from '@nestjs/graphql';
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 { WorkflowStepPositionInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-step-position-input.dto';
import { WorkflowStepCreationOptions } from 'src/modules/workflow/workflow-builder/workflow-version-step/types/WorkflowStepCreationOptions';
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
@InputType()
export class CreateWorkflowVersionStepInput {
@@ -25,6 +28,12 @@ export class CreateWorkflowVersionStepInput {
})
parentStepId?: string;
@Field(() => graphqlTypeJson, {
description: 'Step creation options',
nullable: true,
})
parentStepOptions?: WorkflowStepCreationOptions;
@Field(() => UUIDScalarType, {
description: 'Next step ID',
nullable: true,
@@ -0,0 +1,10 @@
import { type WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
type WorkflowIteratorStepCreationOptions = {
parentStepType: WorkflowActionType.ITERATOR;
settings: {
shouldInsertToLoop: boolean;
};
};
export type WorkflowStepCreationOptions = WorkflowIteratorStepCreationOptions;
@@ -3,6 +3,7 @@ import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { insertStep } from 'src/modules/workflow/workflow-builder/workflow-version-step/utils/insert-step';
import {
type WorkflowAction,
type WorkflowIteratorAction,
WorkflowActionType,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import {
@@ -10,6 +11,24 @@ import {
WorkflowTriggerType,
} from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
const mockIteratorStep: WorkflowIteratorAction = {
id: '1',
name: 'Iterator 1',
type: WorkflowActionType.ITERATOR,
settings: {
input: {
initialLoopStepIds: ['existing-loop-step'],
items: [],
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
valid: true,
};
describe('insertStep', () => {
const createMockAction = (
id: string,
@@ -158,4 +177,55 @@ describe('insertStep', () => {
nextStepIds: ['1', 'new'],
});
});
it('should add step to iterator initialLoopStepIds when shouldInsertToLoop is true', () => {
const existingTrigger = createMockTrigger(['1']);
const newStep = createMockAction('new');
const result = insertStep({
existingTrigger,
existingSteps: [mockIteratorStep],
insertedStep: newStep,
parentStepId: '1',
parentStepOptions: {
parentStepType: WorkflowActionType.ITERATOR,
settings: {
shouldInsertToLoop: true,
},
},
});
const updatedIteratorStep = result
.updatedSteps[0] as WorkflowIteratorAction;
expect(updatedIteratorStep.settings.input.initialLoopStepIds).toEqual([
'existing-loop-step',
'new',
]);
});
it('should not add step to iterator initialLoopStepIds when shouldInsertToLoop is false', () => {
const existingTrigger = createMockTrigger(['1']);
const newStep = createMockAction('new');
const result = insertStep({
existingTrigger,
existingSteps: [mockIteratorStep],
insertedStep: newStep,
parentStepId: '1',
parentStepOptions: {
parentStepType: WorkflowActionType.ITERATOR,
settings: {
shouldInsertToLoop: false,
},
},
});
const updatedIteratorStep = result
.updatedSteps[0] as WorkflowIteratorAction;
expect(updatedIteratorStep.settings.input.initialLoopStepIds).toEqual([
'existing-loop-step',
]);
});
});
@@ -255,4 +255,54 @@ describe('removeStep', () => {
expect(result.updatedTrigger).toEqual(null);
expect(result.updatedSteps).toEqual([]);
});
it('should handle removing a step that is part of iteratorLoopStepIds', () => {
const step1 = createMockAction('1', ['2']);
const iteratorStep = {
id: '2',
name: 'Iterator Step',
type: WorkflowActionType.ITERATOR,
settings: {
input: {
initialLoopStepIds: ['3'],
iterableValue: { value: [] },
iteratorKey: 'item',
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
valid: true,
nextStepIds: ['4'],
} as WorkflowAction;
const step3 = createMockAction('3', ['5']);
const step4 = createMockAction('4');
const step5 = createMockAction('5');
const result = removeStep({
existingTrigger: mockTrigger,
existingSteps: [step1, iteratorStep, step3, step4, step5],
stepIdToDelete: '3',
stepToDeleteChildrenIds: ['5'],
});
expect(result.updatedSteps).toEqual([
step1,
{
...iteratorStep,
settings: {
...iteratorStep.settings,
input: {
...iteratorStep.settings.input,
initialLoopStepIds: ['5'],
},
},
},
step4,
step5,
]);
expect(result.updatedTrigger).toEqual(mockTrigger);
});
});
@@ -1,62 +1,50 @@
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import {
WorkflowVersionStepException,
WorkflowVersionStepExceptionCode,
} from 'src/modules/workflow/common/exceptions/workflow-version-step.exception';
import { type WorkflowStepCreationOptions } from 'src/modules/workflow/workflow-builder/workflow-version-step/types/WorkflowStepCreationOptions';
import { type WorkflowIteratorActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/types/workflow-iterator-action-settings.type';
import {
WorkflowActionType,
type WorkflowAction,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { type WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
export const insertStep = ({
existingSteps,
existingTrigger,
insertedStep,
parentStepId,
nextStepId,
parentStepId,
parentStepOptions,
}: {
existingSteps: WorkflowAction[];
existingTrigger: WorkflowTrigger | null;
insertedStep: WorkflowAction;
parentStepId?: string;
nextStepId?: string;
parentStepId?: string;
parentStepOptions?: WorkflowStepCreationOptions;
}): {
updatedSteps: WorkflowAction[];
updatedInsertedStep: WorkflowAction;
updatedTrigger: WorkflowTrigger | null;
} => {
let updatedTrigger = existingTrigger;
let updatedExistingSteps = existingSteps;
if (parentStepId === TRIGGER_STEP_ID) {
if (!existingTrigger) {
throw new Error('Cannot insert step from undefined trigger');
}
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;
});
}
let { updatedSteps, updatedTrigger } = isDefined(parentStepId)
? updateParentStep({
trigger: existingTrigger,
steps: existingSteps,
parentStepId,
insertedStepId: insertedStep.id,
nextStepId,
parentStepOptions,
})
: {
updatedSteps: existingSteps,
updatedTrigger: existingTrigger,
};
const updatedInsertedStep = {
...insertedStep,
@@ -64,8 +52,165 @@ export const insertStep = ({
};
return {
updatedSteps: [...updatedExistingSteps, updatedInsertedStep],
updatedSteps: [...updatedSteps, updatedInsertedStep],
updatedTrigger,
updatedInsertedStep,
};
};
const updateParentStep = ({
steps,
trigger,
parentStepId,
insertedStepId,
nextStepId,
parentStepOptions,
}: {
steps: WorkflowAction[];
trigger: WorkflowTrigger | null;
parentStepId: string;
insertedStepId: string;
nextStepId?: string;
parentStepOptions?: WorkflowStepCreationOptions;
}): {
updatedSteps: WorkflowAction[];
updatedTrigger: WorkflowTrigger | null;
} => {
if (isDefined(parentStepOptions)) {
return updateStepsWithOptions({
steps,
parentStepId,
insertedStepId,
parentStepOptions,
trigger,
});
} else {
return updateParentStepNextStepIds({
steps,
trigger,
parentStepId,
insertedStepId,
nextStepId,
});
}
};
const updateParentStepNextStepIds = ({
steps,
trigger,
parentStepId,
insertedStepId,
nextStepId,
}: {
steps: WorkflowAction[];
trigger: WorkflowTrigger | null;
parentStepId: string;
insertedStepId: string;
nextStepId?: string;
}): {
updatedSteps: WorkflowAction[];
updatedTrigger: WorkflowTrigger | null;
} => {
let updatedTrigger = trigger;
let updatedSteps = steps;
if (parentStepId === TRIGGER_STEP_ID) {
if (!trigger) {
throw new WorkflowVersionStepException(
'Cannot insert step from undefined trigger',
WorkflowVersionStepExceptionCode.INVALID_REQUEST,
);
}
updatedTrigger = {
...trigger,
nextStepIds: [
...new Set([
...(trigger.nextStepIds?.filter((id) => id !== nextStepId) || []),
insertedStepId,
]),
],
};
} else {
updatedSteps = steps.map((step) => {
if (step.id === parentStepId) {
return {
...step,
nextStepIds: [
...new Set([
...(step.nextStepIds?.filter((id) => id !== nextStepId) || []),
insertedStepId,
]),
],
};
}
return step;
});
}
return {
updatedSteps,
updatedTrigger,
};
};
const updateStepsWithOptions = ({
parentStepId,
insertedStepId,
steps,
parentStepOptions,
trigger,
}: {
parentStepId: string;
insertedStepId: string;
steps: WorkflowAction[];
parentStepOptions: WorkflowStepCreationOptions;
trigger: WorkflowTrigger | null;
}) => {
let updatedSteps = steps;
switch (parentStepOptions.parentStepType) {
case WorkflowActionType.ITERATOR:
if (!parentStepOptions.settings.shouldInsertToLoop) {
break;
}
updatedSteps = steps.map((step) => {
if (step.id === parentStepId) {
if (step.type !== WorkflowActionType.ITERATOR) {
throw new WorkflowVersionStepException(
`Step ${step.id} is not an iterator`,
WorkflowVersionStepExceptionCode.INVALID_REQUEST,
);
}
return {
...step,
settings: {
...step.settings,
input: {
...step.settings.input,
initialLoopStepIds: [
...(step.settings.input.initialLoopStepIds || []),
insertedStepId,
],
},
} satisfies WorkflowIteratorActionSettings,
};
}
return step;
});
break;
default:
break;
}
return {
updatedSteps,
updatedTrigger: trigger,
};
};
@@ -59,6 +59,27 @@ const removeOneStep = ({
};
}
if (
step.type === WorkflowActionType.ITERATOR &&
isDefined(step.settings.input.initialLoopStepIds) &&
step.settings.input.initialLoopStepIds.includes(stepIdToDelete)
) {
return {
...step,
settings: {
...step.settings,
input: {
...step.settings.input,
initialLoopStepIds: computeUpdatedNextStepIds({
existingNextStepIds: step.settings.input.initialLoopStepIds,
stepIdToRemove: stepIdToDelete,
stepToDeleteChildrenIds: stepToDeleteChildrenIds,
}),
},
},
};
}
return step;
}) ?? [];
@@ -31,6 +31,7 @@ import { type BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-e
import {
type WorkflowAction,
WorkflowActionType,
WorkflowEmptyAction,
type WorkflowFormAction,
} 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';
@@ -72,13 +73,20 @@ export class WorkflowVersionStepWorkspaceService {
workspaceId: string;
input: CreateWorkflowVersionStepInput;
}): Promise<WorkflowVersionStepChangesDTO> {
const { workflowVersionId, stepType, parentStepId, nextStepId, position } =
input;
const {
workflowVersionId,
stepType,
parentStepId,
nextStepId,
position,
parentStepOptions,
} = input;
const newStep = await this.getStepDefaultDefinition({
const newStep = await this.runStepCreationSideEffectsAndBuildStep({
type: stepType,
workspaceId,
position,
workflowVersionId,
});
const enrichedNewStep = await this.enrichOutputSchema({
@@ -118,6 +126,7 @@ export class WorkflowVersionStepWorkspaceService {
insertedStep: enrichedNewStep,
parentStepId,
nextStepId,
parentStepOptions,
});
await workflowVersionRepository.update(workflowVersion.id, {
@@ -513,14 +522,16 @@ export class WorkflowVersionStepWorkspaceService {
}
}
private async getStepDefaultDefinition({
private async runStepCreationSideEffectsAndBuildStep({
type,
workspaceId,
position,
workflowVersionId,
}: {
type: WorkflowActionType;
workspaceId: string;
position?: WorkflowStepPositionInput;
workflowVersionId: string;
}): Promise<WorkflowAction> {
const newStepId = v4();
@@ -722,6 +733,12 @@ export class WorkflowVersionStepWorkspaceService {
};
}
case WorkflowActionType.ITERATOR: {
const emptyNodeStep = await this.createEmptyNodeForIteratorStep({
iteratorStepId: baseStep.id,
workflowVersionId,
workspaceId,
});
return {
...baseStep,
name: 'Iterator',
@@ -730,7 +747,7 @@ export class WorkflowVersionStepWorkspaceService {
...BASE_STEP_DEFINITION,
input: {
items: [],
initialLoopStepIds: [],
initialLoopStepIds: [emptyNodeStep.id],
},
},
};
@@ -861,4 +878,54 @@ export class WorkflowVersionStepWorkspaceService {
}
}
}
private async createEmptyNodeForIteratorStep({
iteratorStepId,
workflowVersionId,
workspaceId,
}: {
iteratorStepId: string;
workflowVersionId: string;
workspaceId: string;
}): Promise<WorkflowAction> {
const workflowVersionRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkflowVersionWorkspaceEntity>(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
const workflowVersion = await workflowVersionRepository.findOne({
where: {
id: workflowVersionId,
},
});
if (!isDefined(workflowVersion)) {
throw new WorkflowVersionStepException(
'WorkflowVersion not found',
WorkflowVersionStepExceptionCode.NOT_FOUND,
);
}
const existingSteps = workflowVersion.steps ?? [];
const emptyNodeStep: WorkflowEmptyAction = {
id: v4(),
name: 'Empty Node',
type: WorkflowActionType.EMPTY,
valid: true,
nextStepIds: [iteratorStepId],
settings: {
...BASE_STEP_DEFINITION,
input: {},
},
};
await workflowVersionRepository.update(workflowVersion.id, {
steps: [...existingSteps, emptyNodeStep],
});
return emptyNodeStep;
}
}
@@ -50,11 +50,6 @@ export class IteratorWorkflowAction implements WorkflowActionInterface {
const { items, initialLoopStepIds } = iteratorInput;
// TODO: remove once the UI is implemented
const parsedInitialLoopStepIds = isString(initialLoopStepIds)
? JSON.parse(initialLoopStepIds)
: initialLoopStepIds;
const parsedItems = isString(items) ? JSON.parse(items) : items;
if (!Array.isArray(parsedItems)) {
@@ -64,7 +59,11 @@ export class IteratorWorkflowAction implements WorkflowActionInterface {
);
}
if (parsedInitialLoopStepIds.length === 0 || parsedItems.length === 0) {
if (
!isDefined(initialLoopStepIds) ||
initialLoopStepIds.length === 0 ||
parsedItems.length === 0
) {
return {
result: {
currentItemIndex: 0,
@@ -109,7 +108,7 @@ export class IteratorWorkflowAction implements WorkflowActionInterface {
if (!hasProcessedAllItems) {
await this.resetStepsInLoop({
iteratorStepId,
initialLoopStepIds: parsedInitialLoopStepIds,
initialLoopStepIds,
workflowRunId: runInfo.workflowRunId,
workspaceId: runInfo.workspaceId,
steps,
@@ -3,8 +3,7 @@ import { type BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-e
export type WorkflowIteratorActionInput = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
items?: Array<any> | string;
// testing purpose, should never be a string nor undefined
initialLoopStepIds?: string[] | string;
initialLoopStepIds?: string[];
};
export type WorkflowIteratorActionSettings = BaseWorkflowActionSettings & {
@@ -22,7 +22,7 @@ const { getAllStepIdsInLoop } = jest.requireMock(
describe('canExecuteIteratorStep', () => {
const createMockIteratorStep = (
id: string,
initialLoopStepIds: string[] | string = [],
initialLoopStepIds: string[] = [],
): WorkflowIteratorAction => ({
id,
name: 'Iterator Step',
@@ -1,4 +1,3 @@
import { isString } from 'class-validator';
import { isDefined } from 'twenty-shared/utils';
import { StepStatus, type WorkflowRunStepInfos } from 'twenty-shared/workflow';
@@ -29,19 +28,15 @@ export const canExecuteIteratorStep = ({
(step) => stepInfos[step.id]?.status === StepStatus.SUCCESS,
);
} else {
// On the first iteration, the loop steps will not have been started yet.
// TODO: remove parsing once the UI is implemented
const parsedInitialLoopStepIds = isString(
step.settings.input.initialLoopStepIds,
)
? JSON.parse(step.settings.input.initialLoopStepIds)
: step.settings.input.initialLoopStepIds;
const initialLoopStepIds = step.settings.input.initialLoopStepIds;
const stepIdsInLoop = getAllStepIdsInLoop({
iteratorStepId: step.id,
initialLoopStepIds: parsedInitialLoopStepIds,
steps,
});
const stepIdsInLoop = isDefined(initialLoopStepIds)
? getAllStepIdsInLoop({
iteratorStepId: step.id,
initialLoopStepIds,
steps,
})
: [];
const parentSteps = stepsTargetingIterator.filter(
(step) => !stepIdsInLoop.includes(step.id),
@@ -33,11 +33,7 @@ const traverseSteps = ({
const nestedIteratorInput = step.settings
.input as WorkflowIteratorActionInput;
if (
nestedIteratorInput.initialLoopStepIds &&
// TODO: To remove once we remove the string input for initialLoopStepIds
Array.isArray(nestedIteratorInput.initialLoopStepIds)
) {
if (nestedIteratorInput.initialLoopStepIds) {
const nestedLoopStepIds = getAllStepIdsInLoop({
iteratorStepId: stepId,
initialLoopStepIds: nestedIteratorInput.initialLoopStepIds,
@@ -25,6 +25,7 @@ export {
workflowAiAgentActionSettingsSchema,
workflowFilterActionSettingsSchema,
workflowIteratorActionSettingsSchema,
workflowEmptyActionSettingsSchema,
workflowCodeActionSchema,
workflowSendEmailActionSchema,
workflowCreateRecordActionSchema,
@@ -36,6 +37,7 @@ export {
workflowAiAgentActionSchema,
workflowFilterActionSchema,
workflowIteratorActionSchema,
workflowEmptyActionSchema,
workflowActionSchema,
workflowDatabaseEventTriggerSchema,
workflowManualTriggerSchema,
@@ -223,11 +223,15 @@ export const workflowIteratorActionSettingsSchema =
baseWorkflowActionSettingsSchema.extend({
input: z.object({
items: z.union([z.array(z.union([z.string(), z.number(), z.boolean(), z.null(), z.record(z.any()), z.any()])), z.string()]).optional(),
// TODO: should never be a string once fix the UI
initialLoopStepIds: z.union([z.array(z.string()), z.string()]).optional(),
initialLoopStepIds: z.array(z.string()).optional(),
}),
});
export const workflowEmptyActionSettingsSchema =
baseWorkflowActionSettingsSchema.extend({
input: z.object({}),
});
// Action schemas
export const workflowCodeActionSchema = baseWorkflowActionSchema.extend({
type: z.literal('CODE'),
@@ -284,6 +288,11 @@ export const workflowIteratorActionSchema = baseWorkflowActionSchema.extend({
settings: workflowIteratorActionSettingsSchema,
});
export const workflowEmptyActionSchema = baseWorkflowActionSchema.extend({
type: z.literal('EMPTY'),
settings: workflowEmptyActionSettingsSchema,
});
// Combined action schema
export const workflowActionSchema = z.discriminatedUnion('type', [
workflowCodeActionSchema,
@@ -297,6 +306,7 @@ export const workflowActionSchema = z.discriminatedUnion('type', [
workflowAiAgentActionSchema,
workflowFilterActionSchema,
workflowIteratorActionSchema,
workflowEmptyActionSchema,
]);
// Trigger schemas