From ead351cb9e1bed26e369123dcac37c1d9c273f16 Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Fri, 26 Sep 2025 15:42:40 +0200 Subject: [PATCH] Fix variable search for iterators (#14742) Add specific search util for iterators. Also making workflow version id optional in compute step output schema endpoint to avoid breaking changes on next release. Before https://github.com/user-attachments/assets/2ceb5b38-4c0a-4d38-8f57-0e4467dcfd93 After https://github.com/user-attachments/assets/3ce503ac-5299-4bca-906f-e0fef379df7c --- .../src/generated-metadata/graphql.ts | 2 +- .../twenty-front/src/generated/graphql.ts | 2 +- .../types/FindRecordsOutputSchema.ts | 8 +- .../types/IteratorOutputSchema.ts | 9 ++ .../workflow-variables/types/RecordNode.ts | 7 + .../types/StepOutputSchemaV2.ts | 4 +- .../types/guards/isIteratorOutputSchema.ts | 13 ++ ...ariableThroughIteratorOutputSchema.test.ts | 145 ++++++++++++++++++ ...archVariableThroughIteratorOutputSchema.ts | 100 ++++++++++++ .../searchVariableThroughOutputSchemaV2.ts | 11 ++ .../compute-step-output-schema-input.dto.ts | 4 +- .../workflow-schema.workspace-service.ts | 18 ++- 12 files changed, 304 insertions(+), 19 deletions(-) create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/types/IteratorOutputSchema.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/types/RecordNode.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/types/guards/isIteratorOutputSchema.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughIteratorOutputSchema.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughIteratorOutputSchema.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 9d59b317a6..ad3b4e8d39 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -445,7 +445,7 @@ export type ComputeStepOutputSchemaInput = { /** Step JSON format */ step: Scalars['JSON']; /** Workflow version ID */ - workflowVersionId: Scalars['UUID']; + workflowVersionId?: InputMaybe; }; export enum ConfigSource { diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 6265c1a100..88df56b83e 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -445,7 +445,7 @@ export type ComputeStepOutputSchemaInput = { /** Step JSON format */ step: Scalars['JSON']; /** Workflow version ID */ - workflowVersionId: Scalars['UUID']; + workflowVersionId?: InputMaybe; }; export enum ConfigSource { diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/types/FindRecordsOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/types/FindRecordsOutputSchema.ts index bcdb6808ea..3a41b9956b 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/types/FindRecordsOutputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/types/FindRecordsOutputSchema.ts @@ -1,10 +1,4 @@ -import { type RecordOutputSchemaV2 } from './RecordOutputSchemaV2'; - -type RecordNode = { - isLeaf: false; - label: string; - value: RecordOutputSchemaV2; -}; +import { type RecordNode } from '@/workflow/workflow-variables/types/RecordNode'; export type FindRecordsOutputSchema = { first: RecordNode; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/types/IteratorOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/types/IteratorOutputSchema.ts new file mode 100644 index 0000000000..8263744da4 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/types/IteratorOutputSchema.ts @@ -0,0 +1,9 @@ +import { type Leaf } from '@/workflow/workflow-variables/types/BaseOutputSchemaV2'; +import { type RecordNode } from '@/workflow/workflow-variables/types/RecordNode'; + +export type IteratorOutputSchema = { + // TODO(t.trompette): add support for node items that are not records + currentItem: RecordNode | Leaf; + currentItemIndex: number; + hasProcessedAllItems: boolean; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/types/RecordNode.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/types/RecordNode.ts new file mode 100644 index 0000000000..40711b73b1 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/types/RecordNode.ts @@ -0,0 +1,7 @@ +import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2'; + +export type RecordNode = { + isLeaf: false; + label: string; + value: RecordOutputSchemaV2; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/types/StepOutputSchemaV2.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/types/StepOutputSchemaV2.ts index abec5a950c..06c83b0c85 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/types/StepOutputSchemaV2.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/types/StepOutputSchemaV2.ts @@ -6,6 +6,7 @@ import { type BaseOutputSchemaV2 } from '@/workflow/workflow-variables/types/Bas import { type CodeOutputSchema } from '@/workflow/workflow-variables/types/CodeOutputSchema'; import { type FindRecordsOutputSchema } from '@/workflow/workflow-variables/types/FindRecordsOutputSchema'; import { type FormOutputSchema } from '@/workflow/workflow-variables/types/FormOutputSchema'; +import { type IteratorOutputSchema } from '@/workflow/workflow-variables/types/IteratorOutputSchema'; import { type ManualTriggerOutputSchema } from '@/workflow/workflow-variables/types/ManualTriggerOutputSchema'; import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2'; @@ -15,7 +16,8 @@ export type OutputSchemaV2 = | FindRecordsOutputSchema | FormOutputSchema | RecordOutputSchemaV2 - | ManualTriggerOutputSchema; + | ManualTriggerOutputSchema + | IteratorOutputSchema; export type StepOutputSchemaV2 = { id: string; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/types/guards/isIteratorOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/types/guards/isIteratorOutputSchema.ts new file mode 100644 index 0000000000..6a1e6b6c0e --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/types/guards/isIteratorOutputSchema.ts @@ -0,0 +1,13 @@ +import { + type WorkflowActionType, + type WorkflowTriggerType, +} from '@/workflow/types/Workflow'; +import { type OutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2'; +import { type IteratorOutputSchema } from '../IteratorOutputSchema'; + +export const isIteratorOutputSchema = ( + stepType: WorkflowActionType | WorkflowTriggerType, + schema: OutputSchemaV2, +): schema is IteratorOutputSchema => { + return stepType === 'ITERATOR'; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughIteratorOutputSchema.test.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughIteratorOutputSchema.test.ts new file mode 100644 index 0000000000..29588cba8e --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughIteratorOutputSchema.test.ts @@ -0,0 +1,145 @@ +import { type IteratorOutputSchema } from '@/workflow/workflow-variables/types/IteratorOutputSchema'; +import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2'; +import { searchVariableThroughIteratorOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughIteratorOutputSchema'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +describe('searchVariableThroughIteratorOutputSchema', () => { + const mockRecordSchema: RecordOutputSchemaV2 = { + object: { + objectMetadataId: 'company-metadata-id', + label: 'Company', + }, + fields: { + name: { + isLeaf: true, + type: FieldMetadataType.TEXT, + label: 'Company Name', + value: 'Acme Corp', + fieldMetadataId: 'company-name-metadata-id', + isCompositeSubField: false, + }, + revenue: { + isLeaf: true, + type: FieldMetadataType.NUMBER, + label: 'Revenue', + value: 1000000, + fieldMetadataId: 'company-revenue-metadata-id', + isCompositeSubField: false, + }, + }, + _outputSchemaType: 'RECORD', + }; + + const mockIteratorSchema: IteratorOutputSchema = { + currentItem: { + isLeaf: false, + label: 'Current Item', + value: mockRecordSchema, + }, + currentItemIndex: 0, + hasProcessedAllItems: false, + }; + + it('should handle currentItemIndex variable correctly', () => { + const result = searchVariableThroughIteratorOutputSchema({ + stepName: 'Iterate Companies', + iteratorOutputSchema: mockIteratorSchema, + rawVariableName: '{{step1.currentItemIndex}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: 'Current Item Index', + variablePathLabel: 'Iterate Companies > Current Item Index', + variableType: FieldMetadataType.NUMBER, + }); + }); + + it('should handle hasProcessedAllItems variable correctly', () => { + const result = searchVariableThroughIteratorOutputSchema({ + stepName: 'Iterate Companies', + iteratorOutputSchema: mockIteratorSchema, + rawVariableName: '{{step1.hasProcessedAllItems}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: 'Has Processed All Items', + variablePathLabel: 'Iterate Companies > Has Processed All Items', + variableType: FieldMetadataType.BOOLEAN, + }); + }); + + it('should handle currentItem field access correctly', () => { + const result = searchVariableThroughIteratorOutputSchema({ + stepName: 'Iterate Companies', + iteratorOutputSchema: mockIteratorSchema, + rawVariableName: '{{step1.currentItem.name}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: 'Company Name', + variablePathLabel: 'Iterate Companies > Current Item > Company Name', + variableType: FieldMetadataType.TEXT, + fieldMetadataId: 'company-name-metadata-id', + compositeFieldSubFieldName: undefined, + }); + }); + + it('should return undefined for invalid field name', () => { + const result = searchVariableThroughIteratorOutputSchema({ + stepName: 'Iterate Companies', + iteratorOutputSchema: mockIteratorSchema, + rawVariableName: '{{step1.currentItem.invalidField}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: undefined, + variablePathLabel: undefined, + }); + }); + + it('should return undefined for invalid iterator result key', () => { + const result = searchVariableThroughIteratorOutputSchema({ + stepName: 'Iterate Companies', + iteratorOutputSchema: mockIteratorSchema, + rawVariableName: '{{step1.invalid.name}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: undefined, + variablePathLabel: undefined, + }); + }); + + it('should return undefined when iteratorOutputSchema is undefined', () => { + const result = searchVariableThroughIteratorOutputSchema({ + stepName: 'Iterate Companies', + iteratorOutputSchema: undefined as any, + rawVariableName: '{{step1.currentItem.name}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: undefined, + variablePathLabel: undefined, + }); + }); + + it('should return undefined when stepId or iteratorResultKey is undefined', () => { + const result = searchVariableThroughIteratorOutputSchema({ + stepName: 'Iterate Companies', + iteratorOutputSchema: mockIteratorSchema, + rawVariableName: '{{}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: undefined, + variablePathLabel: undefined, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughIteratorOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughIteratorOutputSchema.ts new file mode 100644 index 0000000000..8fa693428d --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughIteratorOutputSchema.ts @@ -0,0 +1,100 @@ +import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable'; +import { isRecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/guards/isRecordOutputSchemaV2'; +import { type IteratorOutputSchema } from '@/workflow/workflow-variables/types/IteratorOutputSchema'; +import { searchRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from 'twenty-shared/workflow'; + +type IteratorResultKey = + | 'currentItem' + | 'currentItemIndex' + | 'hasProcessedAllItems'; + +const parseVariableName = (rawVariableName: string) => { + const variableWithoutBrackets = rawVariableName.replace( + CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, + (_, variableName) => variableName, + ); + + const parts = variableWithoutBrackets.split('.'); + const stepId = parts.at(0); + const iteratorResultKey = parts.at(1) as IteratorResultKey; + const remainingParts = parts.slice(2); + + return { + stepId, + iteratorResultKey, + fieldName: remainingParts.at(-1), + pathSegments: remainingParts.slice(0, -1), + }; +}; + +export const searchVariableThroughIteratorOutputSchema = ({ + stepName, + iteratorOutputSchema, + rawVariableName, + isFullRecord = false, +}: { + stepName: string; + iteratorOutputSchema: IteratorOutputSchema; + rawVariableName: string; + isFullRecord?: boolean; +}): VariableSearchResult => { + if (!isDefined(iteratorOutputSchema)) { + return { + variableLabel: undefined, + variablePathLabel: undefined, + }; + } + + const { stepId, iteratorResultKey, fieldName, pathSegments } = + parseVariableName(rawVariableName); + + if (!isDefined(stepId) || !isDefined(iteratorResultKey)) { + return { + variableLabel: undefined, + variablePathLabel: undefined, + }; + } + + if (iteratorResultKey === 'currentItemIndex') { + return { + variableLabel: 'Current Item Index', + variablePathLabel: `${stepName} > Current Item Index`, + variableType: FieldMetadataType.NUMBER, + }; + } + + if (iteratorResultKey === 'hasProcessedAllItems') { + return { + variableLabel: 'Has Processed All Items', + variablePathLabel: `${stepName} > Has Processed All Items`, + variableType: FieldMetadataType.BOOLEAN, + }; + } + + if (iteratorResultKey === 'currentItem') { + const schema = iteratorOutputSchema.currentItem.value; + if (isRecordOutputSchemaV2(schema) && isDefined(fieldName)) { + return searchRecordOutputSchema({ + stepName: `${stepName} > Current Item`, + recordOutputSchema: schema, + path: pathSegments, + selectedField: fieldName, + isFullRecord, + }); + } + + return { + variableLabel: 'Current Item', + variablePathLabel: `${stepName} > Current Item`, + variableType: schema.type, + }; + } + + return { + variableLabel: undefined, + variablePathLabel: undefined, + }; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughOutputSchemaV2.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughOutputSchemaV2.ts index 24cfc03a21..1d282812de 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughOutputSchemaV2.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughOutputSchemaV2.ts @@ -6,6 +6,7 @@ import { isCodeOutputSchema } from '@/workflow/workflow-variables/types/guards/i import { isDatabaseEventTriggerOutputSchema } from '@/workflow/workflow-variables/types/guards/isDatabaseEventTriggerOutputSchema'; import { isFindRecordsOutputSchema } from '@/workflow/workflow-variables/types/guards/isFindRecordsOutputSchema'; import { isFormOutputSchema } from '@/workflow/workflow-variables/types/guards/isFormOutputSchema'; +import { isIteratorOutputSchema } from '@/workflow/workflow-variables/types/guards/isIteratorOutputSchema'; import { isManualTriggerOutputSchema } from '@/workflow/workflow-variables/types/guards/isManualTriggerOutputSchema'; import { isRecordStepOutputSchema } from '@/workflow/workflow-variables/types/guards/isRecordStepOutputSchema'; import { type StepOutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2'; @@ -14,6 +15,7 @@ import { searchVariableThroughBaseOutputSchema } from '@/workflow/workflow-varia import { searchVariableThroughCodeOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughCodeOutputSchema'; import { searchVariableThroughFindRecordsOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughFindRecordsOutputSchema'; import { searchVariableThroughFormOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughFormOutputSchema'; +import { searchVariableThroughIteratorOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughIteratorOutputSchema'; import { searchVariableThroughManualTriggerOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughManualTriggerOutputSchema'; import { searchVariableThroughRecordEventOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordEventOutputSchema'; import { searchVariableThroughRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema'; @@ -85,6 +87,15 @@ export const searchVariableThroughOutputSchemaV2 = ({ }); } + if (isIteratorOutputSchema(stepType, stepOutputSchema.outputSchema)) { + return searchVariableThroughIteratorOutputSchema({ + stepName: stepOutputSchema.name, + iteratorOutputSchema: stepOutputSchema.outputSchema, + rawVariableName, + isFullRecord, + }); + } + return searchVariableThroughBaseOutputSchema({ stepName: stepOutputSchema.name, baseOutputSchema: stepOutputSchema.outputSchema, diff --git a/packages/twenty-server/src/engine/core-modules/workflow/dtos/compute-step-output-schema-input.dto.ts b/packages/twenty-server/src/engine/core-modules/workflow/dtos/compute-step-output-schema-input.dto.ts index 46afb8b9cd..6cc7d77c03 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/dtos/compute-step-output-schema-input.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/dtos/compute-step-output-schema-input.dto.ts @@ -16,7 +16,7 @@ export class ComputeStepOutputSchemaInput { @Field(() => UUIDScalarType, { description: 'Workflow version ID', - nullable: false, + nullable: true, }) - workflowVersionId: string; + workflowVersionId?: string; } 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 465b0fa94d..1197c9bd9f 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 @@ -47,7 +47,7 @@ export class WorkflowSchemaWorkspaceService { }: { step: WorkflowTrigger | WorkflowAction; workspaceId: string; - workflowVersionId: string; + workflowVersionId?: string; }): Promise { const stepType = step.type; @@ -108,8 +108,8 @@ export class WorkflowSchemaWorkspaceService { return { currentItem: await this.computeLoopCurrentItemOutputSchema({ items, - workflowVersionId, workspaceId, + workflowVersionId, }), currentItemIndex: { label: 'Current Item Index', @@ -308,12 +308,12 @@ export class WorkflowSchemaWorkspaceService { private async computeLoopCurrentItemOutputSchema({ items, - workflowVersionId, workspaceId, + workflowVersionId, }: { items: string | undefined | unknown[]; - workflowVersionId: string; workspaceId: string; + workflowVersionId?: string; }): Promise { if (!isDefined(items)) { return DEFAULT_ITERATOR_CURRENT_ITEM; @@ -322,8 +322,8 @@ export class WorkflowSchemaWorkspaceService { if (isString(items) && isValidVariable(items)) { return this.computeIteratorCurrentItemFromVariable({ items, - workflowVersionId, workspaceId, + workflowVersionId, }); } @@ -332,13 +332,17 @@ export class WorkflowSchemaWorkspaceService { private async computeIteratorCurrentItemFromVariable({ items, - workflowVersionId, workspaceId, + workflowVersionId, }: { items: string; - workflowVersionId: string; workspaceId: string; + workflowVersionId?: string; }): Promise { + if (!isDefined(workflowVersionId)) { + return DEFAULT_ITERATOR_CURRENT_ITEM; + } + const workflowVersion = await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({ workflowVersionId,