Infer array current item schema (#15115)

This PR allows to infer the schema of the current item of an iterator
step:
- iterator step receive a variable
- added an util that navigate to the array in schema -
navigateOutputSchemaProperty
- use the array value in schema to generate a new schema - used the
existing getFunctionOutputSchema that I renamed and moved to
twenty-shared

Also cleaned a bit the existing schema for AI.

Before


https://github.com/user-attachments/assets/9767fc89-3524-4bfb-b1ab-8abe92084767

After


https://github.com/user-attachments/assets/3650c1d2-14f2-44f9-b10c-e649fe04128d
This commit is contained in:
Thomas Trompette
2025-10-15 18:15:08 +02:00
committed by GitHub
parent b16ab1b7c9
commit d3f3f991a5
42 changed files with 774 additions and 170 deletions
@@ -6,7 +6,6 @@ import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { type BaseOutputSchemaV2 } from '@/workflow/workflow-variables/types/BaseOutputSchemaV2';
import { type LinkOutputSchema } from '@/workflow/workflow-variables/types/LinkOutputSchema';
import { type FieldOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2';
import { type StepOutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2';
@@ -14,6 +13,7 @@ import { getVariableTemplateFromPath } from '@/workflow/workflow-variables/utils
import { useState } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { type BaseOutputSchemaV2 } from 'twenty-shared/workflow';
import { useIcons } from 'twenty-ui/display';
import { isBaseOutputSchemaV2 } from '../types/guards/isBaseOutputSchemaV2';
import { isLinkOutputSchema } from '../types/guards/isLinkOutputSchema';
@@ -0,0 +1,17 @@
import { type InputSchemaPropertyType } from '@/workflow/types/InputSchema';
export type AiAgentLeaf = {
isLeaf: true;
type: InputSchemaPropertyType | undefined;
label: string;
value: any;
};
export type AiAgentNode = {
isLeaf: false;
type: 'object' | 'unknown';
label: string;
value: AiAgentOutputSchema;
};
export type AiAgentOutputSchema = Record<string, AiAgentLeaf | AiAgentNode>;
@@ -1,28 +0,0 @@
import { type InputSchemaPropertyType } from '@/workflow/types/InputSchema';
export type LeafType = 'string' | 'number' | 'boolean' | 'array' | 'unknown';
export type Leaf = {
isLeaf: true;
type: LeafType;
label: string;
value: any;
};
export type Node = {
isLeaf: false;
type: 'object' | 'unknown';
label: string;
value: BaseOutputSchemaV2;
};
export type BaseOutputSchemaV2 = Record<string, Leaf | Node>;
export type LeafDeprecated = {
isLeaf: true;
type: InputSchemaPropertyType | undefined;
label: string;
value: any;
};
export type BaseOutputSchemaDeprecated = Record<string, LeafDeprecated | Node>;
@@ -1,4 +1,4 @@
import { type BaseOutputSchemaV2 } from '@/workflow/workflow-variables/types/BaseOutputSchemaV2';
import { type LinkOutputSchema } from '@/workflow/workflow-variables/types/LinkOutputSchema';
import { type BaseOutputSchemaV2 } from 'twenty-shared/workflow';
export type CodeOutputSchema = LinkOutputSchema | BaseOutputSchemaV2;
@@ -1,5 +1,5 @@
import type { Leaf } from '@/workflow/workflow-variables/types/BaseOutputSchemaV2';
import { type RecordNode } from '@/workflow/workflow-variables/types/RecordNode';
import type { Leaf } from 'twenty-shared/workflow';
export type FindRecordsOutputSchema = {
first: RecordNode;
@@ -1,5 +1,5 @@
import { type Leaf } from '@/workflow/workflow-variables/types/BaseOutputSchemaV2';
import { type RecordNode } from '@/workflow/workflow-variables/types/RecordNode';
import { type Leaf } from 'twenty-shared/workflow';
export type IteratorOutputSchema = {
// TODO(t.trompette): add support for node items that are not records
@@ -1,5 +1,5 @@
import { type BaseOutputSchemaV2 } from '@/workflow/workflow-variables/types/BaseOutputSchemaV2';
import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2';
import { type BaseOutputSchemaV2 } from 'twenty-shared/workflow';
export type ManualTriggerOutputSchema =
| BaseOutputSchemaV2
@@ -1,3 +1,3 @@
import { type BaseOutputSchemaV2 } from '@/workflow/workflow-variables/types/BaseOutputSchemaV2';
import { type BaseOutputSchemaV2 } from 'twenty-shared/workflow';
export type OpenStepOutputSchema = BaseOutputSchemaV2;
@@ -2,13 +2,13 @@ import {
type WorkflowActionType,
type WorkflowTriggerType,
} from '@/workflow/types/Workflow';
import { type BaseOutputSchemaV2 } from '@/workflow/workflow-variables/types/BaseOutputSchemaV2';
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';
import { type BaseOutputSchemaV2 } from 'twenty-shared/workflow';
export type OutputSchemaV2 =
| BaseOutputSchemaV2
@@ -1,6 +1,6 @@
import { type BaseOutputSchemaV2 } from '@/workflow/workflow-variables/types/BaseOutputSchemaV2';
import { type OutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2';
import { isObject } from '@sniptt/guards';
import { type BaseOutputSchemaV2 } from 'twenty-shared/workflow';
export const isBaseOutputSchemaV2 = (
outputSchema: OutputSchemaV2,
@@ -1,6 +1,6 @@
import { type BaseOutputSchemaV2 } from '@/workflow/workflow-variables/types/BaseOutputSchemaV2';
import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2';
import { FieldMetadataType } from 'twenty-shared/types';
import { type BaseOutputSchemaV2 } from 'twenty-shared/workflow';
import { filterOutputSchema } from '../filterOutputSchema';
describe('filterOutputSchema', () => {
@@ -1,5 +1,5 @@
import type { BaseOutputSchemaV2 } from '@/workflow/workflow-variables/types/BaseOutputSchemaV2';
import { searchVariableThroughBaseOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema';
import type { BaseOutputSchemaV2 } from 'twenty-shared/workflow';
describe('searchVariableThroughBaseOutputSchema', () => {
const mockBaseSchema: BaseOutputSchemaV2 = {
@@ -91,7 +91,6 @@ describe('searchVariableThroughBaseOutputSchema', () => {
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.message}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -106,7 +105,6 @@ describe('searchVariableThroughBaseOutputSchema', () => {
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.count}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -121,7 +119,6 @@ describe('searchVariableThroughBaseOutputSchema', () => {
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.isSuccess}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -136,7 +133,6 @@ describe('searchVariableThroughBaseOutputSchema', () => {
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.items}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -151,7 +147,6 @@ describe('searchVariableThroughBaseOutputSchema', () => {
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.user.name}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -166,7 +161,6 @@ describe('searchVariableThroughBaseOutputSchema', () => {
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.user.profile.email}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -182,7 +176,6 @@ describe('searchVariableThroughBaseOutputSchema', () => {
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.user.profile.isActive}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -198,7 +191,6 @@ describe('searchVariableThroughBaseOutputSchema', () => {
stepName: 'Code Action',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.config.timeout}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -213,7 +205,6 @@ describe('searchVariableThroughBaseOutputSchema', () => {
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.invalidField}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -227,7 +218,6 @@ describe('searchVariableThroughBaseOutputSchema', () => {
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.user.invalidField}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -241,7 +231,6 @@ describe('searchVariableThroughBaseOutputSchema', () => {
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.user.profile.invalidField}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -255,7 +244,6 @@ describe('searchVariableThroughBaseOutputSchema', () => {
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.message.nestedField}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -269,7 +257,6 @@ describe('searchVariableThroughBaseOutputSchema', () => {
stepName: 'HTTP Request',
baseOutputSchema: undefined as any,
rawVariableName: '{{step1.message}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -283,7 +270,6 @@ describe('searchVariableThroughBaseOutputSchema', () => {
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -297,7 +283,6 @@ describe('searchVariableThroughBaseOutputSchema', () => {
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: 'step1.message',
isFullRecord: false,
});
expect(result).toEqual({
@@ -312,7 +297,6 @@ describe('searchVariableThroughBaseOutputSchema', () => {
stepName: 'Code Action',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.user}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -336,7 +320,6 @@ describe('searchVariableThroughBaseOutputSchema', () => {
stepName: 'AI Agent',
baseOutputSchema: schemaWithUnknown,
rawVariableName: '{{step1.unknownField}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -1,6 +1,6 @@
import type { BaseOutputSchemaV2 } from '@/workflow/workflow-variables/types/BaseOutputSchemaV2';
import type { CodeOutputSchema } from '@/workflow/workflow-variables/types/CodeOutputSchema';
import { searchVariableThroughCodeOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughCodeOutputSchema';
import type { BaseOutputSchemaV2 } from 'twenty-shared/workflow';
describe('searchVariableThroughCodeOutputSchema', () => {
describe('LinkOutputSchema tests', () => {
@@ -18,7 +18,6 @@ describe('searchVariableThroughCodeOutputSchema', () => {
stepName: 'Code Step',
codeOutputSchema: mockLinkSchema,
rawVariableName: '{{step1.link}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -87,7 +86,6 @@ describe('searchVariableThroughCodeOutputSchema', () => {
stepName: 'Code Action',
codeOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.message}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -102,7 +100,6 @@ describe('searchVariableThroughCodeOutputSchema', () => {
stepName: 'Code Action',
codeOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.count}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -117,7 +114,6 @@ describe('searchVariableThroughCodeOutputSchema', () => {
stepName: 'Code Action',
codeOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.isEnabled}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -132,7 +128,6 @@ describe('searchVariableThroughCodeOutputSchema', () => {
stepName: 'Code Action',
codeOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.data.userId}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -147,7 +142,6 @@ describe('searchVariableThroughCodeOutputSchema', () => {
stepName: 'Code Action',
codeOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.data.profile.name}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -163,7 +157,6 @@ describe('searchVariableThroughCodeOutputSchema', () => {
stepName: 'Code Action',
codeOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.data.profile.email}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -179,7 +172,6 @@ describe('searchVariableThroughCodeOutputSchema', () => {
stepName: 'Code Action',
codeOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.data}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -194,7 +186,6 @@ describe('searchVariableThroughCodeOutputSchema', () => {
stepName: 'Code Action',
codeOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.invalidField}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -208,7 +199,6 @@ describe('searchVariableThroughCodeOutputSchema', () => {
stepName: 'Code Action',
codeOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.data.invalidField}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -233,7 +223,6 @@ describe('searchVariableThroughCodeOutputSchema', () => {
stepName: 'Code Action',
codeOutputSchema: undefined as any,
rawVariableName: '{{step1.message}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -247,7 +236,6 @@ describe('searchVariableThroughCodeOutputSchema', () => {
stepName: 'Code Action',
codeOutputSchema: mockBaseSchema,
rawVariableName: '{{}}',
isFullRecord: false,
});
expect(result).toEqual({
@@ -261,7 +249,6 @@ describe('searchVariableThroughCodeOutputSchema', () => {
stepName: 'Code Action',
codeOutputSchema: mockBaseSchema,
rawVariableName: 'step1.simpleField',
isFullRecord: false,
});
expect(result).toEqual({
@@ -1,8 +1,4 @@
import { type InputSchemaPropertyType } from '@/workflow/types/InputSchema';
import {
type BaseOutputSchemaV2,
type Node,
} from '@/workflow/workflow-variables/types/BaseOutputSchemaV2';
import {
type FieldOutputSchemaV2,
type RecordOutputSchemaV2,
@@ -13,6 +9,7 @@ import { isLinkOutputSchema } from '@/workflow/workflow-variables/types/guards/i
import { isRecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/guards/isRecordOutputSchemaV2';
import { isFieldTypeCompatibleWithRecordId } from '@/workflow/workflow-variables/utils/isFieldTypeCompatibleWithRecordId';
import { isDefined } from 'twenty-shared/utils';
import { type BaseOutputSchemaV2, type Node } from 'twenty-shared/workflow';
const isValidRecordOutputSchema = ({
shouldDisplayRecordFields,
@@ -108,7 +105,7 @@ const filterBaseOutputSchema = ({
continue;
}
if (field.isLeaf) {
if (field.isLeaf === true) {
if (isFieldTypeCompatibleWithRecordId(field.type)) {
filteredSchema[key] = field;
hasValidFields = true;
@@ -1,7 +1,9 @@
import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable';
import type { BaseOutputSchemaV2 } from '@/workflow/workflow-variables/types/BaseOutputSchemaV2';
import { isDefined } from 'twenty-shared/utils';
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from 'twenty-shared/workflow';
import {
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
type BaseOutputSchemaV2,
} from 'twenty-shared/workflow';
const parseVariableName = (rawVariableName: string) => {
const variableWithoutBrackets = rawVariableName.replace(
@@ -29,7 +31,7 @@ const navigateToTargetField = (
for (const pathSegment of pathSegments) {
const field = currentSchema[pathSegment];
if (!isDefined(field) || field.isLeaf) {
if (!isDefined(field) || field.isLeaf === true) {
return null;
}
@@ -66,6 +68,35 @@ const buildVariableResult = (
};
};
export const searchBaseOutputSchema = ({
stepName,
baseOutputSchema,
path,
selectedField,
}: {
stepName: string;
baseOutputSchema: BaseOutputSchemaV2;
path: string[];
selectedField: string;
}): VariableSearchResult => {
const navigationResult = navigateToTargetField(baseOutputSchema, path);
if (!navigationResult) {
return {
variableLabel: undefined,
variablePathLabel: undefined,
variableType: undefined,
};
}
return buildVariableResult(
stepName,
navigationResult.pathLabels,
navigationResult.schema,
selectedField,
);
};
/**
* Searches for a variable within a base output schema and returns its metadata
*
@@ -83,7 +114,6 @@ export const searchVariableThroughBaseOutputSchema = ({
stepName: string;
baseOutputSchema: BaseOutputSchemaV2;
rawVariableName: string;
isFullRecord?: boolean;
}): VariableSearchResult => {
if (!isDefined(baseOutputSchema)) {
return {
@@ -102,22 +132,10 @@ export const searchVariableThroughBaseOutputSchema = ({
};
}
const navigationResult = navigateToTargetField(
baseOutputSchema,
pathSegments,
);
if (!navigationResult) {
return {
variableLabel: undefined,
variablePathLabel: undefined,
};
}
return buildVariableResult(
return searchBaseOutputSchema({
stepName,
navigationResult.pathLabels,
navigationResult.schema,
targetFieldName,
);
baseOutputSchema,
path: pathSegments,
selectedField: targetFieldName,
});
};
@@ -24,12 +24,10 @@ export const searchVariableThroughCodeOutputSchema = ({
stepName,
codeOutputSchema,
rawVariableName,
isFullRecord = false,
}: {
stepName: string;
codeOutputSchema: CodeOutputSchema;
rawVariableName: string;
isFullRecord?: boolean;
}): VariableSearchResult => {
if (!isDefined(codeOutputSchema) || isLinkOutputSchema(codeOutputSchema)) {
return {
@@ -42,6 +40,5 @@ export const searchVariableThroughCodeOutputSchema = ({
stepName,
baseOutputSchema: codeOutputSchema,
rawVariableName,
isFullRecord,
});
};
@@ -1,6 +1,8 @@
import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable';
import { isBaseOutputSchemaV2 } from '@/workflow/workflow-variables/types/guards/isBaseOutputSchemaV2';
import { isRecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/guards/isRecordOutputSchemaV2';
import { type IteratorOutputSchema } from '@/workflow/workflow-variables/types/IteratorOutputSchema';
import { searchBaseOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema';
import { searchRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
@@ -94,10 +96,21 @@ export const searchVariableThroughIteratorOutputSchema = ({
});
}
if (isBaseOutputSchemaV2(schema) && isDefined(fieldName)) {
return searchBaseOutputSchema({
stepName,
baseOutputSchema: schema,
path: pathSegments,
selectedField: fieldName,
});
}
return {
variableLabel: 'Current Item',
variablePathLabel: `${stepName} > Current Item`,
variableType: schema.type,
variableLabel: iteratorOutputSchema.currentItem.label,
variablePathLabel: `${stepName} > ${iteratorOutputSchema.currentItem.label}`,
variableType: iteratorOutputSchema.currentItem.isLeaf
? iteratorOutputSchema.currentItem.type
: 'unknown',
};
}
@@ -27,6 +27,5 @@ export const searchVariableThroughManualTriggerOutputSchema = ({
stepName,
baseOutputSchema: manualTriggerOutputSchema,
rawVariableName,
isFullRecord,
});
};
@@ -83,7 +83,6 @@ export const searchVariableThroughOutputSchemaV2 = ({
stepName: stepOutputSchema.name,
codeOutputSchema: stepOutputSchema.outputSchema,
rawVariableName,
isFullRecord,
});
}
@@ -100,6 +99,5 @@ export const searchVariableThroughOutputSchemaV2 = ({
stepName: stepOutputSchema.name,
baseOutputSchema: stepOutputSchema.outputSchema,
rawVariableName,
isFullRecord,
});
};