[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:
Marie
2026-06-26 09:26:11 +02:00
committed by GitHub
parent ea9e11581c
commit 6e2df0654b
14 changed files with 555 additions and 12 deletions
@@ -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
@@ -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');
});
});
@@ -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:
@@ -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;
@@ -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');
});
});
@@ -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;
};