From bcd5cf7f25be5a403e56d4e2f83516879da14db4 Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Mon, 1 Sep 2025 18:46:33 +0200 Subject: [PATCH] Add first frontend version for iterators (#14213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a base for testing iterator step in frontend. Loop next step ids need to be added to an input since the loop handle does not exist on the step yet. Here is an example of run: Capture d’écran 2025-09-01 à 16 21
39 First create record in the loop name is `{{currentItem}} - first`, the second is `{{currentItem}} - second` Capture d’écran 2025-09-01 à 16 22
25 --- .../src/generated-metadata/graphql.ts | 1 + .../twenty-front/src/generated/graphql.ts | 1 + ...CommandMenuWorkflowSelectActionContent.tsx | 12 ++ .../src/modules/workflow/types/Workflow.ts | 8 +- .../components/WorkflowRunStepNodeDetail.tsx | 12 ++ .../components/WorkflowStepDetail.tsx | 11 +- .../constants/OtherActions.ts | 31 +---- .../WorkflowEditActionIterator.tsx | 121 ++++++++++++++++++ .../utils/getActionHeaderTypeOrThrow.ts | 4 +- .../workflow-actions/utils/getActionIcon.ts | 2 + .../utils/getActionIconColorOrThrow.ts | 1 + .../enums/feature-flag-key.enum.ts | 1 + .../workspace-entity-manager.spec.ts | 2 + .../core/utils/seed-feature-flags.util.ts | 5 + packages/twenty-shared/src/workflow/index.ts | 2 + .../src/workflow/schemas/workflow.schema.ts | 15 +++ 16 files changed, 198 insertions(+), 31 deletions(-) create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/iterator-action/WorkflowEditActionIterator.tsx diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 3cf53dc876..aad4f42d90 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -980,6 +980,7 @@ export enum FeatureFlagKey { IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED', IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED', IS_WORKFLOW_BRANCH_ENABLED = 'IS_WORKFLOW_BRANCH_ENABLED', + IS_WORKFLOW_ITERATOR_ENABLED = 'IS_WORKFLOW_ITERATOR_ENABLED', IS_WORKSPACE_MIGRATION_V2_ENABLED = 'IS_WORKSPACE_MIGRATION_V2_ENABLED' } diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 39d7feae44..ab86491a2b 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -944,6 +944,7 @@ export enum FeatureFlagKey { IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED', IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED', IS_WORKFLOW_BRANCH_ENABLED = 'IS_WORKFLOW_BRANCH_ENABLED', + IS_WORKFLOW_ITERATOR_ENABLED = 'IS_WORKFLOW_ITERATOR_ENABLED', IS_WORKSPACE_MIGRATION_V2_ENABLED = 'IS_WORKSPACE_MIGRATION_V2_ENABLED' } diff --git a/packages/twenty-front/src/modules/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectActionContent.tsx b/packages/twenty-front/src/modules/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectActionContent.tsx index b355721b73..3cc6cf0eb9 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectActionContent.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectActionContent.tsx @@ -16,6 +16,7 @@ import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/s import { AI_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/AiActions'; import { CORE_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/CoreActions'; import { HUMAN_INPUT_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/HumanInputActions'; +import { OTHER_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/OtherActions'; import { RECORD_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/RecordActions'; import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; @@ -91,6 +92,9 @@ export const CommandMenuWorkflowSelectActionContent = ({ const theme = useTheme(); const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED); + const isIteratorEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_WORKFLOW_ITERATOR_ENABLED, + ); const { t } = useLingui(); @@ -116,6 +120,14 @@ export const CommandMenuWorkflowSelectActionContent = ({ {t`Human Input`} {WorkflowActionMenuItems(HUMAN_INPUT_ACTIONS, theme, handleCreateStep)} + {isIteratorEnabled && ( + <> + + {t`Others`} + + {WorkflowActionMenuItems(OTHER_ACTIONS, theme, handleCreateStep)} + + )} ); }; diff --git a/packages/twenty-front/src/modules/workflow/types/Workflow.ts b/packages/twenty-front/src/modules/workflow/types/Workflow.ts index 53973041df..cdd250367e 100644 --- a/packages/twenty-front/src/modules/workflow/types/Workflow.ts +++ b/packages/twenty-front/src/modules/workflow/types/Workflow.ts @@ -9,6 +9,7 @@ import { type workflowFindRecordsActionSchema, type workflowFormActionSchema, type workflowHttpRequestActionSchema, + type workflowIteratorActionSchema, type workflowManualTriggerSchema, type workflowRunSchema, type workflowRunStateSchema, @@ -42,7 +43,9 @@ export type WorkflowFormAction = z.infer; export type WorkflowHttpRequestAction = z.infer< typeof workflowHttpRequestActionSchema >; - +export type WorkflowIteratorAction = z.infer< + typeof workflowIteratorActionSchema +>; export type WorkflowAiAgentAction = z.infer; export type WorkflowAction = @@ -55,7 +58,8 @@ export type WorkflowAction = | WorkflowFilterAction | WorkflowFormAction | WorkflowHttpRequestAction - | WorkflowAiAgentAction; + | WorkflowAiAgentAction + | WorkflowIteratorAction; 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 1e564ded2b..c6396fb686 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 @@ -14,6 +14,7 @@ import { WorkflowEditActionFilter } from '@/workflow/workflow-steps/workflow-act import { WorkflowEditActionFindRecords } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords'; import { WorkflowEditActionFormFiller } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormFiller'; import { WorkflowEditActionHttpRequest } from '@/workflow/workflow-steps/workflow-actions/http-request-action/components/WorkflowEditActionHttpRequest'; +import { WorkflowEditActionIterator } from '@/workflow/workflow-steps/workflow-actions/iterator-action/WorkflowEditActionIterator'; import { WorkflowEditTriggerCronForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm'; import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm'; import { WorkflowEditTriggerManualForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerManualForm'; @@ -214,6 +215,17 @@ export const WorkflowRunStepNodeDetail = ({ /> ); } + case 'ITERATOR': { + 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 732ae3cb27..146c5a4949 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 @@ -13,6 +13,7 @@ import { WorkflowEditActionFilter } from '@/workflow/workflow-steps/workflow-act import { WorkflowEditActionFindRecords } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords'; import { WorkflowEditActionFormBuilder } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder'; import { WorkflowEditActionHttpRequest } from '@/workflow/workflow-steps/workflow-actions/http-request-action/components/WorkflowEditActionHttpRequest'; +import { WorkflowEditActionIterator } from '@/workflow/workflow-steps/workflow-actions/iterator-action/WorkflowEditActionIterator'; import { WorkflowEditTriggerCronForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm'; import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm'; import { WorkflowEditTriggerManualForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerManualForm'; @@ -196,7 +197,15 @@ export const WorkflowStepDetail = ({ /> ); } - + case 'ITERATOR': { + return ( + + ); + } default: return assertUnreachable( stepDefinition.definition, diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/OtherActions.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/OtherActions.ts index 141c77440a..6791bd27dd 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/OtherActions.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/constants/OtherActions.ts @@ -2,35 +2,12 @@ import { type WorkflowActionType } from '@/workflow/types/Workflow'; export const OTHER_ACTIONS: Array<{ label: string; - type: Exclude< - WorkflowActionType, - 'CREATE_RECORD' | 'UPDATE_RECORD' | 'DELETE_RECORD' | 'FIND_RECORDS' - >; + type: Extract; icon: string; }> = [ { - label: 'Send Email', - type: 'SEND_EMAIL', - icon: 'IconSend', - }, - { - label: 'Code', - type: 'CODE', - icon: 'IconCode', - }, - { - label: 'Form', - type: 'FORM', - icon: 'IconForms', - }, - { - label: 'HTTP Request', - type: 'HTTP_REQUEST', - icon: 'IconWorld', - }, - { - label: 'AI Agent', - type: 'AI_AGENT', - icon: 'IconBrain', + label: 'Iterator', + type: 'ITERATOR', + icon: 'IconRepeat', }, ]; 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 new file mode 100644 index 0000000000..e03a7e81ad --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/iterator-action/WorkflowEditActionIterator.tsx @@ -0,0 +1,121 @@ +import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput'; +import { type WorkflowIteratorAction } from '@/workflow/types/Workflow'; +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 { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker'; +import { useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; +import { useDebouncedCallback } from 'use-debounce'; + +type WorkflowEditActionIteratorProps = { + action: WorkflowIteratorAction; + actionOptions: + | { + readonly: true; + } + | { + readonly?: false; + onActionUpdate: (action: WorkflowIteratorAction) => void; + }; +}; + +export const WorkflowEditActionIterator = ({ + action, + actionOptions, +}: WorkflowEditActionIteratorProps) => { + const { headerTitle, headerIcon, headerIconColor, headerType, getIcon } = + useWorkflowActionHeader({ + action, + defaultTitle: 'Iterator', + }); + + const { t } = useLingui(); + + const [formData, setFormData] = useState({ + items: action.settings.input.items || [], + initialLoopStepIds: action.settings.input.initialLoopStepIds || [], + }); + + const saveAction = useDebouncedCallback( + (updatedFormData: typeof formData) => { + if (actionOptions.readonly === true) { + return; + } + + actionOptions.onActionUpdate?.({ + ...action, + settings: { + ...action.settings, + input: { + items: updatedFormData.items, + initialLoopStepIds: updatedFormData.initialLoopStepIds, + }, + }, + }); + }, + 1000, + ); + + const handleFieldChange = (field: string, value: any) => { + if (actionOptions.readonly === true) { + return; + } + + const updatedFormData = { ...formData, [field]: value }; + setFormData(updatedFormData); + saveAction(updatedFormData); + }; + + const handleTitleChange = (newName: string) => { + if (actionOptions.readonly === true) { + return; + } + + actionOptions.onActionUpdate?.({ + ...action, + name: newName, + }); + }; + + return ( + <> + + + handleFieldChange('items', value)} + 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 306d09aabf..e065250579 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 @@ -20,7 +20,9 @@ export const getActionHeaderTypeOrThrow = (actionType: WorkflowActionType) => { case 'FILTER': { return msg`Filter`; } - + case 'ITERATOR': { + return msg`Iterator`; + } default: assertUnreachable(actionType, `Unsupported action type: ${actionType}`); } diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIcon.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIcon.ts index 1da2091338..040fb29b15 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIcon.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIcon.ts @@ -21,6 +21,8 @@ export const getActionIcon = (actionType: WorkflowActionType) => { return CORE_ACTIONS.find((item) => item.type === actionType)?.icon; case 'FORM': return HUMAN_INPUT_ACTIONS.find((item) => item.type === actionType)?.icon; + case 'ITERATOR': + return 'IconRepeat'; default: return 'IconDefault'; } 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 9adfb9eff8..52e87d047f 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 @@ -21,6 +21,7 @@ export const getActionIconColorOrThrow = ({ return theme.font.color.tertiary; case 'FORM': return theme.color.orange; + case 'ITERATOR': case 'FILTER': return theme.font.color.tertiary; case 'AI_AGENT': diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index 6ee344972c..932eed7933 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -15,4 +15,5 @@ export enum FeatureFlagKey { IS_API_KEY_ROLES_ENABLED = 'IS_API_KEY_ROLES_ENABLED', IS_PAGE_LAYOUT_ENABLED = 'IS_PAGE_LAYOUT_ENABLED', IS_MESSAGE_FOLDER_CONTROL_ENABLED = 'IS_MESSAGE_FOLDER_CONTROL_ENABLED', + IS_WORKFLOW_ITERATOR_ENABLED = 'IS_WORKFLOW_ITERATOR_ENABLED', } diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index 6c6ebfa3e1..047c6e4ebf 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -138,6 +138,7 @@ describe('WorkspaceEntityManager', () => { IS_API_KEY_ROLES_ENABLED: false, IS_PAGE_LAYOUT_ENABLED: false, IS_MESSAGE_FOLDER_CONTROL_ENABLED: false, + IS_WORKFLOW_ITERATOR_ENABLED: false, }, eventEmitterService: { emitMutationEvent: jest.fn(), @@ -163,6 +164,7 @@ describe('WorkspaceEntityManager', () => { IS_WORKSPACE_MIGRATION_V2_ENABLED: false, IS_API_KEY_ROLES_ENABLED: false, IS_PAGE_LAYOUT_ENABLED: false, + IS_WORKFLOW_ITERATOR_ENABLED: false, }, permissionsPerRoleId: {}, } as WorkspaceDataSource; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts index 4d816645d5..caa96f75a7 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts @@ -80,6 +80,11 @@ export const seedFeatureFlags = async ( workspaceId: workspaceId, value: true, }, + { + key: FeatureFlagKey.IS_WORKFLOW_ITERATOR_ENABLED, + workspaceId: workspaceId, + value: false, + }, ]) .execute(); }; diff --git a/packages/twenty-shared/src/workflow/index.ts b/packages/twenty-shared/src/workflow/index.ts index bb9e08d34f..7befcc6f52 100644 --- a/packages/twenty-shared/src/workflow/index.ts +++ b/packages/twenty-shared/src/workflow/index.ts @@ -23,6 +23,7 @@ export { workflowHttpRequestActionSettingsSchema, workflowAiAgentActionSettingsSchema, workflowFilterActionSettingsSchema, + workflowIteratorActionSettingsSchema, workflowCodeActionSchema, workflowSendEmailActionSchema, workflowCreateRecordActionSchema, @@ -33,6 +34,7 @@ export { workflowHttpRequestActionSchema, workflowAiAgentActionSchema, workflowFilterActionSchema, + workflowIteratorActionSchema, 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 c56dffffb8..a78ddbde99 100644 --- a/packages/twenty-shared/src/workflow/schemas/workflow.schema.ts +++ b/packages/twenty-shared/src/workflow/schemas/workflow.schema.ts @@ -219,6 +219,15 @@ export const workflowFilterActionSettingsSchema = }), }); +export const workflowIteratorActionSettingsSchema = + baseWorkflowActionSettingsSchema.extend({ + input: z.object({ + items: z.union([z.array(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(), + }), + }); + // Action schemas export const workflowCodeActionSchema = baseWorkflowActionSchema.extend({ type: z.literal('CODE'), @@ -270,6 +279,11 @@ export const workflowFilterActionSchema = baseWorkflowActionSchema.extend({ settings: workflowFilterActionSettingsSchema, }); +export const workflowIteratorActionSchema = baseWorkflowActionSchema.extend({ + type: z.literal('ITERATOR'), + settings: workflowIteratorActionSettingsSchema, +}); + // Combined action schema export const workflowActionSchema = z.discriminatedUnion('type', [ workflowCodeActionSchema, @@ -282,6 +296,7 @@ export const workflowActionSchema = z.discriminatedUnion('type', [ workflowHttpRequestActionSchema, workflowAiAgentActionSchema, workflowFilterActionSchema, + workflowIteratorActionSchema, ]); // Trigger schemas