From 6e2df0654bc56e945853637d9225ab114d6f1ce3 Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:26:11 +0200 Subject: [PATCH] [Workflows] Allow iterator to take whole item as variable (#22031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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. Screenshot 2026-06-23 at 17 02 47 - 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. Screenshot 2026-06-23 at 17 17 53 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. Review in cubic --------- Co-authored-by: Charles Bochet --- .../WorkflowEditActionCodeFieldLeaf.tsx | 18 +++ .../getWorkflowCodeFieldsLeafKind.test.ts | 30 ++++- .../utils/getWorkflowCodeFieldsLeafKind.ts | 5 + .../WorkflowVariablesDropdownStepItems.tsx | 42 +++++- .../getWorkflowVariableSpecialItems.test.ts | 121 ++++++++++++++++++ .../utils/getWorkflowVariableSpecialItems.ts | 80 ++++++++++++ .../workflow-schema.workspace-service.ts | 22 +++- packages/twenty-shared/src/workflow/index.ts | 4 + .../src/workflow/workflow-schema/index.ts | 4 + .../flattened-array-output-schema.test.ts | 119 +++++++++++++++++ ...rch-variable-in-output-schema.code.test.ts | 49 +++++++ ...variable-in-output-schema.iterator.test.ts | 15 +++ .../utils/flattened-array-output-schema.ts | 42 ++++++ .../utils/search-variable-in-output-schema.ts | 16 ++- 14 files changed, 555 insertions(+), 12 deletions(-) create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/getWorkflowVariableSpecialItems.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/utils/getWorkflowVariableSpecialItems.ts create mode 100644 packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/flattened-array-output-schema.test.ts create mode 100644 packages/twenty-shared/src/workflow/workflow-schema/utils/flattened-array-output-schema.ts diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFieldLeaf.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFieldLeaf.tsx index 7ba327756a..48c0f716a9 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFieldLeaf.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFieldLeaf.tsx @@ -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 ( + + ); + } + if (leafKind === 'boolean') { return ( { ).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'); }); }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsLeafKind.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsLeafKind.ts index 873d8aad8b..90d475276e 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsLeafKind.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsLeafKind.ts @@ -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: diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/components/WorkflowVariablesDropdownStepItems.tsx b/packages/twenty-front/src/modules/workflow/workflow-variables/components/WorkflowVariablesDropdownStepItems.tsx index e42521750a..e4e6e1552a 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/components/WorkflowVariablesDropdownStepItems.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/components/WorkflowVariablesDropdownStepItems.tsx @@ -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 = ({ /> + {specialItems.map((specialItem) => ( + handleSelectSpecialItem(specialItem)} + text={specialItem.label} + hasSubMenu={false} + LeftIcon={getIcon(specialItem.iconName)} + contextualText={specialItem.contextualText} + /> + ))} {shouldDisplaySubStepObject && ( )} - {filteredOptions.length > 0 && shouldDisplaySubStepObject && ( - - )} + {filteredOptions.length > 0 && + (shouldDisplaySubStepObject || specialItems.length > 0) && ( + + )} {filteredOptions.map(([key, subStep]) => { if (!isDefined(subStep)) { return null; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/getWorkflowVariableSpecialItems.test.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/getWorkflowVariableSpecialItems.test.ts new file mode 100644 index 0000000000..b49f937b40 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/getWorkflowVariableSpecialItems.test.ts @@ -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'); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/getWorkflowVariableSpecialItems.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/getWorkflowVariableSpecialItems.ts new file mode 100644 index 0000000000..d97f29af84 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/getWorkflowVariableSpecialItems.ts @@ -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; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service.ts index 46da8a1890..8748fe2004 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service.ts @@ -11,8 +11,10 @@ import { buildManualTriggerMetadataNode, BulkRecordsAvailability, extractRawVariableNamePart, + getCurrentItemSchemaFromFlattenedArrayOutputSchema, GlobalAvailability, isBaseOutputSchemaV2, + isFlattenedArrayOutputSchema, navigateOutputSchemaProperty, SingleRecordAvailability, TRIGGER_STEP_ID, @@ -653,8 +655,26 @@ export class WorkflowSchemaWorkspaceService { case WorkflowActionType.HTTP_REQUEST: case WorkflowActionType.LOGIC_FUNCTION: { const propertyPath = extractPropertyPathFromVariable(items); + const outputSchema = this.getOutputSchemaWithExpectedFallback( + step.settings, + ); + + const variableTargetsWholeStepOutput = propertyPath.length === 0; + + if (variableTargetsWholeStepOutput) { + if (!isFlattenedArrayOutputSchema(outputSchema)) { + return DEFAULT_ITERATOR_CURRENT_ITEM; + } + + return ( + getCurrentItemSchemaFromFlattenedArrayOutputSchema({ + schema: outputSchema, + }) ?? DEFAULT_ITERATOR_CURRENT_ITEM + ); + } + const schemaNode = navigateOutputSchemaProperty({ - schema: this.getOutputSchemaWithExpectedFallback(step.settings), + schema: outputSchema, propertyPath, }); diff --git a/packages/twenty-shared/src/workflow/index.ts b/packages/twenty-shared/src/workflow/index.ts index ff515f1a80..ae24b301a4 100644 --- a/packages/twenty-shared/src/workflow/index.ts +++ b/packages/twenty-shared/src/workflow/index.ts @@ -188,6 +188,10 @@ export { buildManualTriggerMetadataNode } from './workflow-schema/utils/build-ma export { collectOutputSchemaPaths } from './workflow-schema/utils/collect-output-schema-paths'; export type { OutputSchemaPathFailure } from './workflow-schema/utils/find-output-schema-path-failure'; export { findOutputSchemaPathFailure } from './workflow-schema/utils/find-output-schema-path-failure'; +export { + isFlattenedArrayOutputSchema, + getCurrentItemSchemaFromFlattenedArrayOutputSchema, +} from './workflow-schema/utils/flattened-array-output-schema'; export { navigateOutputSchemaProperty } from './workflow-schema/utils/navigate-output-schema-property'; export type { ResolvedVariable } from './workflow-schema/utils/resolve-variable-path-in-output-schema'; export { diff --git a/packages/twenty-shared/src/workflow/workflow-schema/index.ts b/packages/twenty-shared/src/workflow/workflow-schema/index.ts index d1f04dee48..5673a3bf9b 100644 --- a/packages/twenty-shared/src/workflow/workflow-schema/index.ts +++ b/packages/twenty-shared/src/workflow/workflow-schema/index.ts @@ -7,6 +7,10 @@ export type { NodeType, } from './types/base-output-schema.type'; export { collectOutputSchemaPaths } from './utils/collect-output-schema-paths'; +export { + getCurrentItemSchemaFromFlattenedArrayOutputSchema, + isFlattenedArrayOutputSchema, +} from './utils/flattened-array-output-schema'; export { findOutputSchemaPathFailure, type OutputSchemaPathFailure, diff --git a/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/flattened-array-output-schema.test.ts b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/flattened-array-output-schema.test.ts new file mode 100644 index 0000000000..cae7748534 --- /dev/null +++ b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/flattened-array-output-schema.test.ts @@ -0,0 +1,119 @@ +import { type BaseOutputSchemaV2 } from '../../types/base-output-schema.type'; +import { + getCurrentItemSchemaFromFlattenedArrayOutputSchema, + isFlattenedArrayOutputSchema, +} from '../flattened-array-output-schema'; + +describe('isFlattenedArrayOutputSchema', () => { + it('should return true when keys are the sequential array indexes', () => { + const schema: BaseOutputSchemaV2 = { + '0': { isLeaf: false, type: 'object', label: '0', value: {} }, + '1': { isLeaf: false, type: 'object', label: '1', value: {} }, + }; + + expect(isFlattenedArrayOutputSchema(schema)).toBe(true); + }); + + it('should return false for a regular object schema', () => { + const schema: BaseOutputSchemaV2 = { + message: { isLeaf: true, type: 'string', label: 'message', value: 'hi' }, + }; + + expect(isFlattenedArrayOutputSchema(schema)).toBe(false); + }); + + it('should return false for non-sequential numeric keys', () => { + const schema: BaseOutputSchemaV2 = { + '0': { isLeaf: true, type: 'string', label: '0', value: 'a' }, + '2': { isLeaf: true, type: 'string', label: '2', value: 'b' }, + }; + + expect(isFlattenedArrayOutputSchema(schema)).toBe(false); + }); + + it('should return false for an empty schema', () => { + expect(isFlattenedArrayOutputSchema({})).toBe(false); + }); + + it('should return false for undefined', () => { + expect(isFlattenedArrayOutputSchema(undefined)).toBe(false); + }); +}); + +describe('getCurrentItemSchemaFromFlattenedArrayOutputSchema', () => { + it('should derive the current item from the first object element', () => { + const schema: BaseOutputSchemaV2 = { + '0': { + isLeaf: false, + type: 'object', + label: '0', + value: { + hello: { isLeaf: true, type: 'string', label: 'hello', value: '1' }, + }, + }, + '1': { + isLeaf: false, + type: 'object', + label: '1', + value: { + hello: { isLeaf: true, type: 'string', label: 'hello', value: '2' }, + }, + }, + }; + + expect( + getCurrentItemSchemaFromFlattenedArrayOutputSchema({ schema }), + ).toEqual({ + isLeaf: false, + type: 'object', + label: 'Current Item', + value: { + hello: { isLeaf: true, type: 'string', label: 'hello', value: '1' }, + }, + }); + }); + + it('should derive the current item from the first primitive element', () => { + const schema: BaseOutputSchemaV2 = { + '0': { isLeaf: true, type: 'number', label: '0', value: 1 }, + '1': { isLeaf: true, type: 'number', label: '1', value: 2 }, + }; + + expect( + getCurrentItemSchemaFromFlattenedArrayOutputSchema({ schema }), + ).toEqual({ + isLeaf: true, + type: 'number', + label: 'Current Item', + value: 1, + }); + }); + + it('should support a custom label', () => { + const schema: BaseOutputSchemaV2 = { + '0': { isLeaf: true, type: 'string', label: '0', value: 'a' }, + }; + + expect( + getCurrentItemSchemaFromFlattenedArrayOutputSchema({ + schema, + label: 'Item', + }), + ).toEqual({ + isLeaf: true, + type: 'string', + label: 'Item', + value: 'a', + }); + }); + + it('should return undefined when there is no first element', () => { + const schema: BaseOutputSchemaV2 = { + message: { isLeaf: true, type: 'string', label: 'message', value: 'hi' }, + }; + + expect( + getCurrentItemSchemaFromFlattenedArrayOutputSchema({ schema }), + ).toBeUndefined(); + }); +}); diff --git a/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.code.test.ts b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.code.test.ts index 2a374efe5c..be872547bb 100644 --- a/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.code.test.ts +++ b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.code.test.ts @@ -225,6 +225,55 @@ describe('searchVariableInOutputSchema - code output schema', () => { }); }); + describe('Flattened top-level array tests', () => { + const mockFlattenedArraySchema: BaseOutputSchemaV2 = { + '0': { + isLeaf: false, + type: 'object', + label: '0', + value: { + hello: { isLeaf: true, type: 'string', label: 'hello', value: '1' }, + }, + }, + '1': { + isLeaf: false, + type: 'object', + label: '1', + value: { + hello: { isLeaf: true, type: 'string', label: 'hello', value: '2' }, + }, + }, + }; + + it('should label the whole output ({{stepId}}) as an array', () => { + const result = searchVariableThroughCodeOutputSchema({ + stepName: 'Code Action', + codeOutputSchema: mockFlattenedArraySchema, + rawVariableName: '{{step1}}', + }); + + expect(result).toEqual({ + variableLabel: 'Code Action', + variablePathLabel: 'Code Action', + variableType: 'ARRAY', + }); + }); + + it('should still resolve an indexed element of the array', () => { + const result = searchVariableThroughCodeOutputSchema({ + stepName: 'Code Action', + codeOutputSchema: mockFlattenedArraySchema, + rawVariableName: '{{step1.0.hello}}', + }); + + expect(result).toEqual({ + variableLabel: 'hello', + variablePathLabel: 'Code Action > 0 > hello', + variableType: 'string', + }); + }); + }); + describe('Edge cases', () => { const mockBaseSchema: BaseOutputSchemaV2 = { simpleField: { diff --git a/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.iterator.test.ts b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.iterator.test.ts index a752219875..5c3b600d16 100644 --- a/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.iterator.test.ts +++ b/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/search-variable-in-output-schema.iterator.test.ts @@ -108,6 +108,21 @@ describe('searchVariableInOutputSchema - iterator output schema', () => { }); }); + it('should handle selecting the whole currentItem correctly', () => { + const result = searchVariableThroughIteratorOutputSchema({ + stepName: 'Iterate Companies', + iteratorOutputSchema: mockIteratorSchema, + rawVariableName: '{{step1.currentItem}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: 'Current Item', + variablePathLabel: 'Iterate Companies > Current Item', + variableType: 'unknown', + }); + }); + it('should return undefined for invalid field name', () => { const result = searchVariableThroughIteratorOutputSchema({ stepName: 'Iterate Companies', diff --git a/packages/twenty-shared/src/workflow/workflow-schema/utils/flattened-array-output-schema.ts b/packages/twenty-shared/src/workflow/workflow-schema/utils/flattened-array-output-schema.ts new file mode 100644 index 0000000000..f6724f09af --- /dev/null +++ b/packages/twenty-shared/src/workflow/workflow-schema/utils/flattened-array-output-schema.ts @@ -0,0 +1,42 @@ +import { isDefined } from '@/utils'; + +import { + type BaseOutputSchemaV2, + type Leaf, + type Node, +} from '../types/base-output-schema.type'; + +export const isFlattenedArrayOutputSchema = ( + schema: BaseOutputSchemaV2 | undefined, +): boolean => { + if (!isDefined(schema)) { + return false; + } + + const keys = Object.keys(schema); + + if (keys.length === 0) { + return false; + } + + return keys.every((key, index) => key === String(index)); +}; + +export const getCurrentItemSchemaFromFlattenedArrayOutputSchema = ({ + schema, + label = 'Current Item', +}: { + schema: BaseOutputSchemaV2; + label?: string; +}): Leaf | Node | undefined => { + const firstItemNode = schema['0']; + + if (!isDefined(firstItemNode)) { + return undefined; + } + + return { + ...firstItemNode, + label, + }; +}; diff --git a/packages/twenty-shared/src/workflow/workflow-schema/utils/search-variable-in-output-schema.ts b/packages/twenty-shared/src/workflow/workflow-schema/utils/search-variable-in-output-schema.ts index fab2835d78..112c2cbf4e 100644 --- a/packages/twenty-shared/src/workflow/workflow-schema/utils/search-variable-in-output-schema.ts +++ b/packages/twenty-shared/src/workflow/workflow-schema/utils/search-variable-in-output-schema.ts @@ -1,6 +1,6 @@ +import { FieldMetadataType } from '@/types/FieldMetadataType'; import { isDefined } from '@/utils'; import { isObject } from 'class-validator'; -import { FieldMetadataType } from '@/types/FieldMetadataType'; import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from '../../constants/CaptureAllVariableTagInnerRegex'; import { parseVariablePath } from '../../utils/variable-path.util'; @@ -16,6 +16,7 @@ import { type RecordOutputSchemaV2, type VariableSearchResult, } from '../types/output-schema.type'; +import { isFlattenedArrayOutputSchema } from './flattened-array-output-schema'; const EMPTY_RESULT: VariableSearchResult = { variableLabel: undefined, @@ -490,6 +491,19 @@ const searchThroughCodeOutputSchema = ({ return EMPTY_RESULT; } + const parts = parseVariablePath(stripBrackets(rawVariableName)); + + if ( + parts.length === 1 && + isFlattenedArrayOutputSchema(codeOutputSchema as BaseOutputSchemaV2) + ) { + return { + variableLabel: stepName, + variablePathLabel: stepName, + variableType: FieldMetadataType.ARRAY, + }; + } + return searchThroughBaseOutputSchema({ stepName, baseOutputSchema: codeOutputSchema as BaseOutputSchemaV2,