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
This commit is contained in:
Thomas Trompette
2025-09-26 15:42:40 +02:00
committed by GitHub
parent 9cd0025aed
commit ead351cb9e
12 changed files with 304 additions and 19 deletions
@@ -445,7 +445,7 @@ export type ComputeStepOutputSchemaInput = {
/** Step JSON format */
step: Scalars['JSON'];
/** Workflow version ID */
workflowVersionId: Scalars['UUID'];
workflowVersionId?: InputMaybe<Scalars['UUID']>;
};
export enum ConfigSource {
@@ -445,7 +445,7 @@ export type ComputeStepOutputSchemaInput = {
/** Step JSON format */
step: Scalars['JSON'];
/** Workflow version ID */
workflowVersionId: Scalars['UUID'];
workflowVersionId?: InputMaybe<Scalars['UUID']>;
};
export enum ConfigSource {
@@ -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;
@@ -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;
};
@@ -0,0 +1,7 @@
import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2';
export type RecordNode = {
isLeaf: false;
label: string;
value: RecordOutputSchemaV2;
};
@@ -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;
@@ -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';
};
@@ -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,
});
});
});
@@ -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,
};
};
@@ -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,
@@ -16,7 +16,7 @@ export class ComputeStepOutputSchemaInput {
@Field(() => UUIDScalarType, {
description: 'Workflow version ID',
nullable: false,
nullable: true,
})
workflowVersionId: string;
workflowVersionId?: string;
}
@@ -47,7 +47,7 @@ export class WorkflowSchemaWorkspaceService {
}: {
step: WorkflowTrigger | WorkflowAction;
workspaceId: string;
workflowVersionId: string;
workflowVersionId?: string;
}): Promise<OutputSchema> {
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<Leaf | Node> {
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<Leaf | Node> {
if (!isDefined(workflowVersionId)) {
return DEFAULT_ITERATOR_CURRENT_ITEM;
}
const workflowVersion =
await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({
workflowVersionId,