Add first frontend version for iterators (#14213)

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:
<img width="1271" height="653" alt="Capture d’écran 2025-09-01 à 16 21
39"
src="https://github.com/user-attachments/assets/06ecf981-be17-4d31-84f6-11c2cc98cb66"
/>

First create record in the loop name is `{{currentItem}} - first`, the
second is `{{currentItem}} - second`
<img width="276" height="326" alt="Capture d’écran 2025-09-01 à 16 22
25"
src="https://github.com/user-attachments/assets/0ae03b65-aeda-41a6-8693-c17711ec2102"
/>
This commit is contained in:
Thomas Trompette
2025-09-01 18:46:33 +02:00
committed by GitHub
parent 0d5fedfb0a
commit bcd5cf7f25
16 changed files with 198 additions and 31 deletions
@@ -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'
}
@@ -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'
}
@@ -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`}
</RightDrawerWorkflowSelectStepTitle>
{WorkflowActionMenuItems(HUMAN_INPUT_ACTIONS, theme, handleCreateStep)}
{isIteratorEnabled && (
<>
<RightDrawerWorkflowSelectStepTitle>
{t`Others`}
</RightDrawerWorkflowSelectStepTitle>
{WorkflowActionMenuItems(OTHER_ACTIONS, theme, handleCreateStep)}
</>
)}
</RightDrawerStepListContainer>
);
};
@@ -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<typeof workflowFormActionSchema>;
export type WorkflowHttpRequestAction = z.infer<
typeof workflowHttpRequestActionSchema
>;
export type WorkflowIteratorAction = z.infer<
typeof workflowIteratorActionSchema
>;
export type WorkflowAiAgentAction = z.infer<typeof workflowAiAgentActionSchema>;
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;
@@ -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 (
<WorkflowEditActionIterator
key={stepId}
action={stepDefinition.definition}
actionOptions={{
readonly: true,
}}
/>
);
}
}
}
}
@@ -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 (
<WorkflowEditActionIterator
key={stepId}
action={stepDefinition.definition}
actionOptions={props}
/>
);
}
default:
return assertUnreachable(
stepDefinition.definition,
@@ -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<WorkflowActionType, 'ITERATOR'>;
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',
},
];
@@ -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 (
<>
<WorkflowStepHeader
onTitleChange={handleTitleChange}
Icon={getIcon(headerIcon || 'IconRepeat')}
iconColor={headerIconColor}
initialTitle={headerTitle}
headerType={headerType}
disabled={actionOptions.readonly}
/>
<WorkflowStepBody>
<FormTextFieldInput
label={t`Items to iterate over`}
placeholder={t`Enter array of items or variable expression`}
defaultValue={
Array.isArray(formData.items)
? JSON.stringify(formData.items)
: (formData.items as string) || ''
}
onChange={(value: string) => handleFieldChange('items', value)}
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>
</>
);
};
@@ -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}`);
}
@@ -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';
}
@@ -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':
@@ -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',
}
@@ -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;
@@ -80,6 +80,11 @@ export const seedFeatureFlags = async (
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_WORKFLOW_ITERATOR_ENABLED,
workspaceId: workspaceId,
value: false,
},
])
.execute();
};
@@ -23,6 +23,7 @@ export {
workflowHttpRequestActionSettingsSchema,
workflowAiAgentActionSettingsSchema,
workflowFilterActionSettingsSchema,
workflowIteratorActionSettingsSchema,
workflowCodeActionSchema,
workflowSendEmailActionSchema,
workflowCreateRecordActionSchema,
@@ -33,6 +34,7 @@ export {
workflowHttpRequestActionSchema,
workflowAiAgentActionSchema,
workflowFilterActionSchema,
workflowIteratorActionSchema,
workflowActionSchema,
workflowDatabaseEventTriggerSchema,
workflowManualTriggerSchema,
@@ -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