[Workflows] Allow iterator to take whole item as variable (#22031)
**Select the whole item in iterator loops, and iterate over a step's array output** ## Summary Two related improvements to working with lists in workflows: - Pick the current item as a whole inside an iterator loop. Previously, in a node inside the loop, you could only reference individual fields of the Iterator's current item. Now you can select the whole item (e.g. a full record) — useful for passing it straight into a downstream step. <img width="1270" height="744" alt="Screenshot 2026-06-23 at 17 02 47" src="https://github.com/user-attachments/assets/6b92e72e-ec25-4c1a-9841-3a438210e753" /> - Iterate over a step's array output. A Code / Logic Function step that returns a top-level array couldn't be fed to the Iterator: its output was flattened into indexed entries (0, 1, …) with no way to select the array as a whole. A new "Whole list" option selects the step's entire output, and the Iterator infers the per-iteration item shape from it. <img width="1026" height="728" alt="Screenshot 2026-06-23 at 17 17 53" src="https://github.com/user-attachments/assets/db07dcd8-4fb8-4db9-8b45-aa56051d9f3b" /> Together these complete the loop ergonomics: select a list → iterate → reference the current item (whole or by field) downstream — matching the model used by tools like Windmill. ## What changed - The variable picker offers a "Use the whole item" option when viewing an iterator's current item, and a "Whole list" option when a step returns a top-level array. - The Iterator's current-item schema can now be inferred from a variable pointing at a step's whole output. ## Risks for existing workflows None expected. The change is purely additive: - No DB migration and no change to how output schemas are stored or read — existing schemas, variables, and iterators behave identically. - No change to runtime variable resolution; existing {{step.field}} and current-item references are untouched. - The new options only apply to new selections (whole item / whole list); all existing paths take the unchanged code path. - The only edge case: array detection is heuristic (an output whose keys are exactly 0…n-1), so an object that happens to have those keys would also show "Whole list". This is rare for real outputs, affects nothing unless a user selects it, and fails safe — the Iterator validates its input and throws a clear "items must be an array" error if a non-array is passed. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22031?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
+18
@@ -1,4 +1,5 @@
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { FormArrayFieldInput } from '@/object-record/record-field/ui/form-types/components/FormArrayFieldInput';
|
||||
import { FormBooleanFieldInput } from '@/object-record/record-field/ui/form-types/components/FormBooleanFieldInput';
|
||||
import { FormMultiRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormMultiRecordPicker';
|
||||
import { FormNumberFieldInput } from '@/object-record/record-field/ui/form-types/components/FormNumberFieldInput';
|
||||
@@ -6,6 +7,7 @@ import { FormSelectFieldInput } from '@/object-record/record-field/ui/form-types
|
||||
import { FormSingleRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormSingleRecordPicker';
|
||||
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
|
||||
import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
|
||||
import { type FieldArrayValue } from '@/object-record/record-field/ui/types/FieldMetadata';
|
||||
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
|
||||
import { getWorkflowCodeFieldsEnumSelectOptions } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsEnumSelectOptions';
|
||||
import { getWorkflowCodeFieldsLeafKind } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsLeafKind';
|
||||
@@ -89,6 +91,22 @@ export const WorkflowEditActionCodeFieldLeaf = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (leafKind === 'array') {
|
||||
return (
|
||||
<FormArrayFieldInput
|
||||
label={label}
|
||||
defaultValue={
|
||||
Array.isArray(inputValue) || isStandaloneVariableString(inputValue)
|
||||
? (inputValue as FieldArrayValue | string)
|
||||
: undefined
|
||||
}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
VariablePicker={VariablePicker}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (leafKind === 'boolean') {
|
||||
return (
|
||||
<FormBooleanFieldInput
|
||||
|
||||
+24
-6
@@ -49,6 +49,30 @@ describe('getWorkflowCodeFieldsLeafKind', () => {
|
||||
).toBe('record-array');
|
||||
});
|
||||
|
||||
it('should map arrays of primitives to the array kind', () => {
|
||||
expect(
|
||||
getWorkflowCodeFieldsLeafKind({
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
}),
|
||||
).toBe('array');
|
||||
expect(
|
||||
getWorkflowCodeFieldsLeafKind({
|
||||
type: 'array',
|
||||
items: { type: 'number' },
|
||||
}),
|
||||
).toBe('array');
|
||||
expect(
|
||||
getWorkflowCodeFieldsLeafKind({
|
||||
type: 'array',
|
||||
items: { type: 'boolean' },
|
||||
}),
|
||||
).toBe('array');
|
||||
expect(
|
||||
getWorkflowCodeFieldsLeafKind({ type: FieldMetadataType.ARRAY }),
|
||||
).toBe('array');
|
||||
});
|
||||
|
||||
it('should map the legacy object/array+marker form to record kinds', () => {
|
||||
expect(
|
||||
getWorkflowCodeFieldsLeafKind({
|
||||
@@ -66,11 +90,5 @@ describe('getWorkflowCodeFieldsLeafKind', () => {
|
||||
}),
|
||||
).toBe('record-array');
|
||||
expect(getWorkflowCodeFieldsLeafKind({ type: 'object' })).toBe('text');
|
||||
expect(
|
||||
getWorkflowCodeFieldsLeafKind({
|
||||
type: 'array',
|
||||
items: { type: 'object' },
|
||||
}),
|
||||
).toBe('text');
|
||||
});
|
||||
});
|
||||
|
||||
+5
@@ -8,6 +8,7 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
import { type InputSchemaProperty } from 'twenty-shared/workflow';
|
||||
|
||||
type WorkflowCodeFieldsLeafKind =
|
||||
| 'array'
|
||||
| 'boolean'
|
||||
| 'enum'
|
||||
| 'number'
|
||||
@@ -37,6 +38,10 @@ export const getWorkflowCodeFieldsLeafKind = (
|
||||
return 'enum';
|
||||
}
|
||||
|
||||
if (property.type === 'array' || property.type === FieldMetadataType.ARRAY) {
|
||||
return 'array';
|
||||
}
|
||||
|
||||
switch (property.type) {
|
||||
case 'boolean':
|
||||
case FieldMetadataType.BOOLEAN:
|
||||
|
||||
+38
-4
@@ -15,11 +15,15 @@ import { getCurrentSubStepFromPath } from '@/workflow/workflow-variables/utils/g
|
||||
import { getStepHeaderLabel } from '@/workflow/workflow-variables/utils/getStepHeaderLabel';
|
||||
import { getStepItemIcon } from '@/workflow/workflow-variables/utils/getStepItemIcon';
|
||||
import { getVariableTemplateFromPath } from '@/workflow/workflow-variables/utils/getVariableTemplateFromPath';
|
||||
import {
|
||||
getWorkflowVariableSpecialItems,
|
||||
type WorkflowVariableSpecialItem,
|
||||
} from '@/workflow/workflow-variables/utils/getWorkflowVariableSpecialItems';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconChevronLeft, useIcons } from 'twenty-ui/icon';
|
||||
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
|
||||
import { MenuItemSelect } from 'twenty-ui/navigation';
|
||||
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
|
||||
|
||||
type WorkflowVariablesDropdownStepItemsProps = {
|
||||
step: StepOutputSchemaV2;
|
||||
@@ -80,6 +84,23 @@ export const WorkflowVariablesDropdownStepItems = ({
|
||||
);
|
||||
};
|
||||
|
||||
const specialItems = getWorkflowVariableSpecialItems({
|
||||
step,
|
||||
currentPath,
|
||||
searchInputValue,
|
||||
});
|
||||
|
||||
const handleSelectSpecialItem = (
|
||||
specialItem: WorkflowVariableSpecialItem,
|
||||
) => {
|
||||
onSelect(
|
||||
getVariableTemplateFromPath({
|
||||
stepId: step.id,
|
||||
path: specialItem.path,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const displayedSubStepObject = getDisplayedSubStepObject();
|
||||
|
||||
const displayedSubStepObjectMetadata = isDefined(displayedSubStepObject)
|
||||
@@ -136,6 +157,18 @@ export const WorkflowVariablesDropdownStepItems = ({
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemsContainer hasMaxHeight>
|
||||
{specialItems.map((specialItem) => (
|
||||
<MenuItemSelect
|
||||
key={specialItem.id}
|
||||
selected={false}
|
||||
focused={false}
|
||||
onClick={() => handleSelectSpecialItem(specialItem)}
|
||||
text={specialItem.label}
|
||||
hasSubMenu={false}
|
||||
LeftIcon={getIcon(specialItem.iconName)}
|
||||
contextualText={specialItem.contextualText}
|
||||
/>
|
||||
))}
|
||||
{shouldDisplaySubStepObject && (
|
||||
<MenuItemSelect
|
||||
selected={false}
|
||||
@@ -148,9 +181,10 @@ export const WorkflowVariablesDropdownStepItems = ({
|
||||
contextualText={t`Pick a ${objectLabel} record`}
|
||||
/>
|
||||
)}
|
||||
{filteredOptions.length > 0 && shouldDisplaySubStepObject && (
|
||||
<DropdownMenuSeparator />
|
||||
)}
|
||||
{filteredOptions.length > 0 &&
|
||||
(shouldDisplaySubStepObject || specialItems.length > 0) && (
|
||||
<DropdownMenuSeparator />
|
||||
)}
|
||||
{filteredOptions.map(([key, subStep]) => {
|
||||
if (!isDefined(subStep)) {
|
||||
return null;
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import { type StepOutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2';
|
||||
import { getWorkflowVariableSpecialItems } from '@/workflow/workflow-variables/utils/getWorkflowVariableSpecialItems';
|
||||
|
||||
const ITERATOR_STEP_WITH_OBJECT_CURRENT_ITEM: StepOutputSchemaV2 = {
|
||||
id: 'step-1',
|
||||
name: 'Loop',
|
||||
type: 'ITERATOR',
|
||||
outputSchema: {
|
||||
currentItem: {
|
||||
isLeaf: false,
|
||||
icon: 'IconUser',
|
||||
label: 'Current Item',
|
||||
value: {} as never,
|
||||
},
|
||||
currentItemIndex: 0,
|
||||
hasProcessedAllItems: false,
|
||||
},
|
||||
};
|
||||
|
||||
const CODE_STEP_WITH_FLATTENED_ARRAY: StepOutputSchemaV2 = {
|
||||
id: 'step-2',
|
||||
name: 'Run code',
|
||||
type: 'CODE',
|
||||
outputSchema: {
|
||||
'0': { isLeaf: true, type: 'string', label: '0', value: 'a' },
|
||||
'1': { isLeaf: true, type: 'string', label: '1', value: 'b' },
|
||||
},
|
||||
};
|
||||
|
||||
const CODE_STEP_WITH_OBJECT_OUTPUT: StepOutputSchemaV2 = {
|
||||
id: 'step-3',
|
||||
name: 'Run code',
|
||||
type: 'CODE',
|
||||
outputSchema: {
|
||||
message: { isLeaf: true, type: 'string', label: 'message', value: 'hi' },
|
||||
},
|
||||
};
|
||||
|
||||
describe('getWorkflowVariableSpecialItems', () => {
|
||||
it('should offer the whole iterator item when viewing a non-leaf currentItem', () => {
|
||||
const specialItems = getWorkflowVariableSpecialItems({
|
||||
step: ITERATOR_STEP_WITH_OBJECT_CURRENT_ITEM,
|
||||
currentPath: ['currentItem'],
|
||||
});
|
||||
|
||||
expect(specialItems).toEqual([
|
||||
{
|
||||
id: 'wholeIteratorItem',
|
||||
label: 'Current Item',
|
||||
contextualText: 'Use the whole item',
|
||||
iconName: 'IconUser',
|
||||
path: ['currentItem'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not offer the whole iterator item outside of the currentItem path', () => {
|
||||
const specialItems = getWorkflowVariableSpecialItems({
|
||||
step: ITERATOR_STEP_WITH_OBJECT_CURRENT_ITEM,
|
||||
currentPath: [],
|
||||
});
|
||||
|
||||
expect(specialItems).toEqual([]);
|
||||
});
|
||||
|
||||
it('should offer the whole list when a step output is a flattened array', () => {
|
||||
const specialItems = getWorkflowVariableSpecialItems({
|
||||
step: CODE_STEP_WITH_FLATTENED_ARRAY,
|
||||
currentPath: [],
|
||||
});
|
||||
|
||||
expect(specialItems).toEqual([
|
||||
{
|
||||
id: 'wholeList',
|
||||
label: 'Whole list',
|
||||
contextualText: 'Use the whole list',
|
||||
iconName: 'IconListDetails',
|
||||
path: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not offer the whole list when navigating inside the array', () => {
|
||||
const specialItems = getWorkflowVariableSpecialItems({
|
||||
step: CODE_STEP_WITH_FLATTENED_ARRAY,
|
||||
currentPath: ['0'],
|
||||
});
|
||||
|
||||
expect(specialItems).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not offer any special item for a regular object output', () => {
|
||||
const specialItems = getWorkflowVariableSpecialItems({
|
||||
step: CODE_STEP_WITH_OBJECT_OUTPUT,
|
||||
currentPath: [],
|
||||
});
|
||||
|
||||
expect(specialItems).toEqual([]);
|
||||
});
|
||||
|
||||
it('should hide the whole list when it does not match the search', () => {
|
||||
const specialItems = getWorkflowVariableSpecialItems({
|
||||
step: CODE_STEP_WITH_FLATTENED_ARRAY,
|
||||
currentPath: [],
|
||||
searchInputValue: 'zzz',
|
||||
});
|
||||
|
||||
expect(specialItems).toEqual([]);
|
||||
});
|
||||
|
||||
it('should keep the whole list when it matches the search', () => {
|
||||
const specialItems = getWorkflowVariableSpecialItems({
|
||||
step: CODE_STEP_WITH_FLATTENED_ARRAY,
|
||||
currentPath: [],
|
||||
searchInputValue: 'whole',
|
||||
});
|
||||
|
||||
expect(specialItems).toHaveLength(1);
|
||||
expect(specialItems[0].id).toBe('wholeList');
|
||||
});
|
||||
});
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { isBaseOutputSchemaV2 } from '@/workflow/workflow-variables/types/guards/isBaseOutputSchemaV2';
|
||||
import { isIteratorOutputSchema } from '@/workflow/workflow-variables/types/guards/isIteratorOutputSchema';
|
||||
import { type StepOutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isFlattenedArrayOutputSchema } from 'twenty-shared/workflow';
|
||||
|
||||
export type WorkflowVariableSpecialItemId = 'wholeIteratorItem' | 'wholeList';
|
||||
|
||||
export type WorkflowVariableSpecialItem = {
|
||||
id: WorkflowVariableSpecialItemId;
|
||||
label: string;
|
||||
contextualText: string;
|
||||
iconName: string;
|
||||
path: string[];
|
||||
};
|
||||
|
||||
const matchesSearch = (label: string, searchInputValue?: string): boolean =>
|
||||
!isDefined(searchInputValue) ||
|
||||
label.toLowerCase().includes(searchInputValue.toLowerCase());
|
||||
|
||||
export const getWorkflowVariableSpecialItems = ({
|
||||
step,
|
||||
currentPath,
|
||||
searchInputValue,
|
||||
}: {
|
||||
step: StepOutputSchemaV2;
|
||||
currentPath: string[];
|
||||
searchInputValue?: string;
|
||||
}): WorkflowVariableSpecialItem[] => {
|
||||
const specialItems: WorkflowVariableSpecialItem[] = [];
|
||||
|
||||
const iteratorCurrentItemNode = isIteratorOutputSchema(
|
||||
step.type,
|
||||
step.outputSchema,
|
||||
)
|
||||
? step.outputSchema.currentItem
|
||||
: undefined;
|
||||
|
||||
const isViewingIteratorCurrentItem =
|
||||
isDefined(iteratorCurrentItemNode) &&
|
||||
!iteratorCurrentItemNode.isLeaf &&
|
||||
currentPath.length === 1 &&
|
||||
currentPath[0] === 'currentItem';
|
||||
|
||||
if (
|
||||
isViewingIteratorCurrentItem &&
|
||||
matchesSearch(iteratorCurrentItemNode.label, searchInputValue)
|
||||
) {
|
||||
specialItems.push({
|
||||
id: 'wholeIteratorItem',
|
||||
label: iteratorCurrentItemNode.label,
|
||||
contextualText: t`Use the whole item`,
|
||||
iconName: iteratorCurrentItemNode.icon ?? 'IconBraces',
|
||||
path: currentPath,
|
||||
});
|
||||
}
|
||||
|
||||
const isStepOutputFlattenedArray =
|
||||
isBaseOutputSchemaV2(step.outputSchema) &&
|
||||
isFlattenedArrayOutputSchema(step.outputSchema);
|
||||
|
||||
const wholeListLabel = t`Whole list`;
|
||||
|
||||
if (
|
||||
isStepOutputFlattenedArray &&
|
||||
currentPath.length === 0 &&
|
||||
matchesSearch(wholeListLabel, searchInputValue)
|
||||
) {
|
||||
specialItems.push({
|
||||
id: 'wholeList',
|
||||
label: wholeListLabel,
|
||||
contextualText: t`Use the whole list`,
|
||||
iconName: 'IconListDetails',
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
|
||||
return specialItems;
|
||||
};
|
||||
Reference in New Issue
Block a user