diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index c54bc9b292..614af25a27 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -849,6 +849,8 @@ export type CreateWorkflowVersionStepInput = { nextStepId?: InputMaybe; /** Parent step ID */ parentStepId?: InputMaybe; + /** Step creation options */ + parentStepOptions?: InputMaybe; /** Step position */ position?: InputMaybe; /** New step type */ diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 1255bf309b..2f1db2e7a3 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -813,6 +813,8 @@ export type CreateWorkflowVersionStepInput = { nextStepId?: InputMaybe; /** Parent step ID */ parentStepId?: InputMaybe; + /** Step creation options */ + parentStepOptions?: InputMaybe; /** Step position */ position?: InputMaybe; /** New step type */ diff --git a/packages/twenty-front/src/modules/workflow/types/Workflow.ts b/packages/twenty-front/src/modules/workflow/types/Workflow.ts index cdd250367e..bc94c37b1f 100644 --- a/packages/twenty-front/src/modules/workflow/types/Workflow.ts +++ b/packages/twenty-front/src/modules/workflow/types/Workflow.ts @@ -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; +export type WorkflowEmptyAction = z.infer; 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; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx index c6396fb686..7525eba490 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx @@ -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 ( + + ); + } } } } diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowStepDetail.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowStepDetail.tsx index 146c5a4949..1febc0da58 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowStepDetail.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowStepDetail.tsx @@ -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 ( + + ); + } default: return assertUnreachable( stepDefinition.definition, diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionEmpty.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionEmpty.tsx new file mode 100644 index 0000000000..865667882c --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionEmpty.tsx @@ -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 ( + <> + { + if (actionOptions.readonly === true) { + return; + } + + actionOptions.onActionUpdate({ + ...action, + name: newName, + }); + }} + Icon={getIcon(headerIcon)} + iconColor={headerIconColor} + initialTitle={headerTitle} + headerType={headerType} + disabled={actionOptions.readonly} + /> + {'Empty Node'} + {!actionOptions.readonly && } + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/iterator-action/WorkflowEditActionIterator.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/iterator-action/WorkflowEditActionIterator.tsx index e03a7e81ad..71bb02d0ca 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/iterator-action/WorkflowEditActionIterator.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/iterator-action/WorkflowEditActionIterator.tsx @@ -101,20 +101,6 @@ export const WorkflowEditActionIterator = ({ readonly={actionOptions.readonly} VariablePicker={WorkflowVariablePicker} /> - {/* TODO: remove once the UI is implemented */} - - handleFieldChange('initialLoopStepIds', value) - } - readonly={actionOptions.readonly} - /> ); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionHeaderTypeOrThrow.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionHeaderTypeOrThrow.ts index e065250579..10e8066656 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionHeaderTypeOrThrow.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionHeaderTypeOrThrow.ts @@ -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}`); } diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow.ts index 52e87d047f..057adcc36a 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow.ts @@ -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; 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 6489782bbf..036289ac01 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 @@ -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, diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/types/WorkflowStepCreationOptions.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/types/WorkflowStepCreationOptions.ts new file mode 100644 index 0000000000..4d4cd57e55 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/types/WorkflowStepCreationOptions.ts @@ -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; diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/__tests__/insert-step.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/__tests__/insert-step.spec.ts index 2d03af8b59..1c2bbce629 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/__tests__/insert-step.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/__tests__/insert-step.spec.ts @@ -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', + ]); + }); }); diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/__tests__/remove-step.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/__tests__/remove-step.spec.ts index 6e0083cb13..09f0f3b8a1 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/__tests__/remove-step.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/__tests__/remove-step.spec.ts @@ -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); + }); }); diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/insert-step.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/insert-step.ts index 4ef6a9eb7e..3d3cdee62d 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/insert-step.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/insert-step.ts @@ -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, + }; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/remove-step.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/remove-step.ts index 5f0c0e33fd..c2ad1109b7 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/remove-step.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/utils/remove-step.ts @@ -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; }) ?? []; diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service.ts index 384405b612..4957aec0ab 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service.ts @@ -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 { - 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 { 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 { + const workflowVersionRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + 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; + } } diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/iterator.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/iterator.workflow-action.ts index 3861ba81b6..4bf154bf9e 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/iterator.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/iterator.workflow-action.ts @@ -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, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/types/workflow-iterator-action-settings.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/types/workflow-iterator-action-settings.type.ts index b5ea25755e..07233cbf39 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/types/workflow-iterator-action-settings.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/types/workflow-iterator-action-settings.type.ts @@ -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 | string; - // testing purpose, should never be a string nor undefined - initialLoopStepIds?: string[] | string; + initialLoopStepIds?: string[]; }; export type WorkflowIteratorActionSettings = BaseWorkflowActionSettings & { diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/can-execute-iterator-step.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/can-execute-iterator-step.util.spec.ts index 0ebf2a6ea0..e9e8dfff15 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/can-execute-iterator-step.util.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/__tests__/can-execute-iterator-step.util.spec.ts @@ -22,7 +22,7 @@ const { getAllStepIdsInLoop } = jest.requireMock( describe('canExecuteIteratorStep', () => { const createMockIteratorStep = ( id: string, - initialLoopStepIds: string[] | string = [], + initialLoopStepIds: string[] = [], ): WorkflowIteratorAction => ({ id, name: 'Iterator Step', diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/can-execute-iterator-step.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/can-execute-iterator-step.util.ts index 2b2567c708..44065d13a3 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/can-execute-iterator-step.util.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/can-execute-iterator-step.util.ts @@ -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), diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/get-all-step-ids-in-loop.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/get-all-step-ids-in-loop.util.ts index f584ce07d7..54c0c796bd 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/get-all-step-ids-in-loop.util.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/get-all-step-ids-in-loop.util.ts @@ -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, diff --git a/packages/twenty-shared/src/workflow/index.ts b/packages/twenty-shared/src/workflow/index.ts index 6bbd659f89..e1d5a23b00 100644 --- a/packages/twenty-shared/src/workflow/index.ts +++ b/packages/twenty-shared/src/workflow/index.ts @@ -25,6 +25,7 @@ export { workflowAiAgentActionSettingsSchema, workflowFilterActionSettingsSchema, workflowIteratorActionSettingsSchema, + workflowEmptyActionSettingsSchema, workflowCodeActionSchema, workflowSendEmailActionSchema, workflowCreateRecordActionSchema, @@ -36,6 +37,7 @@ export { workflowAiAgentActionSchema, workflowFilterActionSchema, workflowIteratorActionSchema, + workflowEmptyActionSchema, workflowActionSchema, workflowDatabaseEventTriggerSchema, workflowManualTriggerSchema, diff --git a/packages/twenty-shared/src/workflow/schemas/workflow.schema.ts b/packages/twenty-shared/src/workflow/schemas/workflow.schema.ts index 4ea9e11388..6dc9adfd61 100644 --- a/packages/twenty-shared/src/workflow/schemas/workflow.schema.ts +++ b/packages/twenty-shared/src/workflow/schemas/workflow.schema.ts @@ -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