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:
+1
-2
@@ -1,5 +1,4 @@
|
||||
import { type Leaf } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
|
||||
|
||||
import { type Leaf } from 'twenty-shared/workflow';
|
||||
export const DEFAULT_ITERATOR_CURRENT_ITEM: Leaf = {
|
||||
label: 'Current Item',
|
||||
isLeaf: true,
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { extractPropertyPathFromVariable } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/extract-property-path-from-variable';
|
||||
|
||||
describe('extractPropertyPathFromVariable', () => {
|
||||
it('should extract single property path', () => {
|
||||
const result = extractPropertyPathFromVariable('{{step1.result}}');
|
||||
|
||||
expect(result).toEqual(['result']);
|
||||
});
|
||||
|
||||
it('should extract nested property path', () => {
|
||||
const result = extractPropertyPathFromVariable('{{step1.result.items}}');
|
||||
|
||||
expect(result).toEqual(['result', 'items']);
|
||||
});
|
||||
|
||||
it('should extract deeply nested property path', () => {
|
||||
const result = extractPropertyPathFromVariable(
|
||||
'{{step1.data.user.address.street}}',
|
||||
);
|
||||
|
||||
expect(result).toEqual(['data', 'user', 'address', 'street']);
|
||||
});
|
||||
|
||||
it('should handle variable without brackets', () => {
|
||||
const result = extractPropertyPathFromVariable('step1.result.items');
|
||||
|
||||
expect(result).toEqual(['result', 'items']);
|
||||
});
|
||||
|
||||
it('should return empty array for variable with only step id', () => {
|
||||
const result = extractPropertyPathFromVariable('{{step1}}');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array for variable with only step id without brackets', () => {
|
||||
const result = extractPropertyPathFromVariable('step1');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle complex step ids', () => {
|
||||
const result = extractPropertyPathFromVariable(
|
||||
'{{step_with_underscore.result.data}}',
|
||||
);
|
||||
|
||||
expect(result).toEqual(['result', 'data']);
|
||||
});
|
||||
|
||||
it('should handle numeric step ids', () => {
|
||||
const result = extractPropertyPathFromVariable('{{step123.output.value}}');
|
||||
|
||||
expect(result).toEqual(['output', 'value']);
|
||||
});
|
||||
});
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
import { type Leaf, type Node } from 'twenty-shared/workflow';
|
||||
|
||||
import { DEFAULT_ITERATOR_CURRENT_ITEM } from 'src/modules/workflow/workflow-builder/workflow-schema/constants/default-iterator-current-item.const';
|
||||
import { inferArrayItemSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/infer-array-item-schema';
|
||||
|
||||
describe('inferArrayItemSchema', () => {
|
||||
it('should return DEFAULT_ITERATOR_CURRENT_ITEM for non-leaf node', () => {
|
||||
const schemaNode: Node = {
|
||||
isLeaf: false,
|
||||
type: 'object',
|
||||
label: 'test',
|
||||
value: {},
|
||||
};
|
||||
|
||||
const result = inferArrayItemSchema({ schemaNode });
|
||||
|
||||
expect(result).toEqual(DEFAULT_ITERATOR_CURRENT_ITEM);
|
||||
});
|
||||
|
||||
it('should return DEFAULT_ITERATOR_CURRENT_ITEM for non-array leaf', () => {
|
||||
const schemaNode: Leaf = {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'test',
|
||||
value: 'not an array',
|
||||
};
|
||||
|
||||
const result = inferArrayItemSchema({ schemaNode });
|
||||
|
||||
expect(result).toEqual(DEFAULT_ITERATOR_CURRENT_ITEM);
|
||||
});
|
||||
|
||||
it('should return DEFAULT_ITERATOR_CURRENT_ITEM for empty array', () => {
|
||||
const schemaNode: Leaf = {
|
||||
isLeaf: true,
|
||||
type: 'array',
|
||||
label: 'items',
|
||||
value: [],
|
||||
};
|
||||
|
||||
const result = inferArrayItemSchema({ schemaNode });
|
||||
|
||||
expect(result).toEqual(DEFAULT_ITERATOR_CURRENT_ITEM);
|
||||
});
|
||||
|
||||
it('should infer schema for array of strings', () => {
|
||||
const schemaNode: Leaf = {
|
||||
isLeaf: true,
|
||||
type: 'array',
|
||||
label: 'items',
|
||||
value: ['item1', 'item2', 'item3'],
|
||||
};
|
||||
|
||||
const result = inferArrayItemSchema({ schemaNode });
|
||||
|
||||
expect(result).toEqual({
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'Current Item',
|
||||
value: 'item1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should infer schema for array of numbers', () => {
|
||||
const schemaNode: Leaf = {
|
||||
isLeaf: true,
|
||||
type: 'array',
|
||||
label: 'items',
|
||||
value: [1, 2, 3],
|
||||
};
|
||||
|
||||
const result = inferArrayItemSchema({ schemaNode });
|
||||
|
||||
expect(result).toEqual({
|
||||
isLeaf: true,
|
||||
type: 'number',
|
||||
label: 'Current Item',
|
||||
value: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should infer schema for array of booleans', () => {
|
||||
const schemaNode: Leaf = {
|
||||
isLeaf: true,
|
||||
type: 'array',
|
||||
label: 'items',
|
||||
value: [true, false],
|
||||
};
|
||||
|
||||
const result = inferArrayItemSchema({ schemaNode });
|
||||
|
||||
expect(result).toEqual({
|
||||
isLeaf: true,
|
||||
type: 'boolean',
|
||||
label: 'Current Item',
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should infer full schema for array of simple objects', () => {
|
||||
const schemaNode: Leaf = {
|
||||
isLeaf: true,
|
||||
type: 'array',
|
||||
label: 'items',
|
||||
value: [
|
||||
{ id: 1, name: 'Item 1' },
|
||||
{ id: 2, name: 'Item 2' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = inferArrayItemSchema({ schemaNode });
|
||||
|
||||
expect(result).toEqual({
|
||||
isLeaf: false,
|
||||
type: 'object',
|
||||
label: 'Current Item',
|
||||
value: {
|
||||
id: {
|
||||
isLeaf: true,
|
||||
type: 'number',
|
||||
label: 'id',
|
||||
value: 1,
|
||||
},
|
||||
name: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'name',
|
||||
value: 'Item 1',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should infer full schema for array of nested objects', () => {
|
||||
const schemaNode: Leaf = {
|
||||
isLeaf: true,
|
||||
type: 'array',
|
||||
label: 'items',
|
||||
value: [
|
||||
{
|
||||
toto: {
|
||||
titi: 1,
|
||||
tata: 'hello',
|
||||
},
|
||||
},
|
||||
{
|
||||
toto: {
|
||||
titi: 2,
|
||||
tata: 'world',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = inferArrayItemSchema({ schemaNode });
|
||||
|
||||
expect(result).toEqual({
|
||||
isLeaf: false,
|
||||
type: 'object',
|
||||
label: 'Current Item',
|
||||
value: {
|
||||
toto: {
|
||||
isLeaf: false,
|
||||
type: 'object',
|
||||
label: 'toto',
|
||||
value: {
|
||||
titi: {
|
||||
isLeaf: true,
|
||||
type: 'number',
|
||||
label: 'titi',
|
||||
value: 1,
|
||||
},
|
||||
tata: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'tata',
|
||||
value: 'hello',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle array with null first item', () => {
|
||||
const schemaNode: Leaf = {
|
||||
isLeaf: true,
|
||||
type: 'array',
|
||||
label: 'items',
|
||||
value: [null, 'item2'],
|
||||
};
|
||||
|
||||
const result = inferArrayItemSchema({ schemaNode });
|
||||
|
||||
expect(result).toEqual(DEFAULT_ITERATOR_CURRENT_ITEM);
|
||||
});
|
||||
|
||||
it('should handle array with undefined first item', () => {
|
||||
const schemaNode: Leaf = {
|
||||
isLeaf: true,
|
||||
type: 'array',
|
||||
label: 'items',
|
||||
value: [undefined, 'item2'],
|
||||
};
|
||||
|
||||
const result = inferArrayItemSchema({ schemaNode });
|
||||
|
||||
expect(result).toEqual(DEFAULT_ITERATOR_CURRENT_ITEM);
|
||||
});
|
||||
});
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from 'twenty-shared/workflow';
|
||||
|
||||
export const extractPropertyPathFromVariable = (
|
||||
rawVariableName: string,
|
||||
): string[] => {
|
||||
const variableWithoutBrackets = rawVariableName.replace(
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
(_, variableName) => variableName,
|
||||
);
|
||||
|
||||
const parts = variableWithoutBrackets.split('.');
|
||||
|
||||
return parts.slice(1);
|
||||
};
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
isArray,
|
||||
isBoolean,
|
||||
isNumber,
|
||||
isObject,
|
||||
isString,
|
||||
} from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
buildOutputSchemaFromValue,
|
||||
type Leaf,
|
||||
type LeafType,
|
||||
type Node,
|
||||
} from 'twenty-shared/workflow';
|
||||
|
||||
import { DEFAULT_ITERATOR_CURRENT_ITEM } from 'src/modules/workflow/workflow-builder/workflow-schema/constants/default-iterator-current-item.const';
|
||||
|
||||
export const inferArrayItemSchema = ({
|
||||
schemaNode,
|
||||
}: {
|
||||
schemaNode: Leaf | Node;
|
||||
}): Leaf | Node => {
|
||||
if (!schemaNode.isLeaf || schemaNode.type !== 'array') {
|
||||
return DEFAULT_ITERATOR_CURRENT_ITEM;
|
||||
}
|
||||
|
||||
const arrayValue = schemaNode.value;
|
||||
|
||||
if (!Array.isArray(arrayValue) || arrayValue.length === 0) {
|
||||
return DEFAULT_ITERATOR_CURRENT_ITEM;
|
||||
}
|
||||
|
||||
const firstItem = arrayValue[0];
|
||||
|
||||
if (!isDefined(firstItem)) {
|
||||
return DEFAULT_ITERATOR_CURRENT_ITEM;
|
||||
}
|
||||
|
||||
if (isObject(firstItem)) {
|
||||
const itemSchema = buildOutputSchemaFromValue(firstItem);
|
||||
|
||||
return {
|
||||
isLeaf: false,
|
||||
type: 'object',
|
||||
label: 'Current Item',
|
||||
value: itemSchema,
|
||||
};
|
||||
}
|
||||
|
||||
const getValueType = (value: unknown): LeafType => {
|
||||
if (isString(value)) return 'string';
|
||||
if (isNumber(value)) return 'number';
|
||||
if (isBoolean(value)) return 'boolean';
|
||||
if (isArray(value)) return 'array';
|
||||
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
return {
|
||||
isLeaf: true,
|
||||
type: getValueType(firstItem),
|
||||
label: 'Current Item',
|
||||
value: firstItem,
|
||||
};
|
||||
};
|
||||
+31
-1
@@ -3,9 +3,11 @@ import { Injectable } from '@nestjs/common';
|
||||
import { isString } from '@sniptt/guards';
|
||||
import { isDefined, isValidVariable } from 'twenty-shared/utils';
|
||||
import {
|
||||
BaseOutputSchemaV2,
|
||||
BulkRecordsAvailability,
|
||||
extractRawVariableNamePart,
|
||||
GlobalAvailability,
|
||||
navigateOutputSchemaProperty,
|
||||
SingleRecordAvailability,
|
||||
TRIGGER_STEP_ID,
|
||||
} from 'twenty-shared/workflow';
|
||||
@@ -22,10 +24,12 @@ import {
|
||||
Node,
|
||||
type OutputSchema,
|
||||
} from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
|
||||
import { extractPropertyPathFromVariable } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/extract-property-path-from-variable';
|
||||
import { generateFakeArrayItem } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-array-item';
|
||||
import { generateFakeFormResponse } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-form-response';
|
||||
import { generateFakeObjectRecord } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-object-record';
|
||||
import { generateFakeObjectRecordEvent } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-object-record-event';
|
||||
import { inferArrayItemSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/infer-array-item-schema';
|
||||
import { type FormFieldMetadata } from 'src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
@@ -413,7 +417,19 @@ export class WorkflowSchemaWorkspaceService {
|
||||
|
||||
return DEFAULT_ITERATOR_CURRENT_ITEM;
|
||||
}
|
||||
// TODO(t.trompette): handle other trigger types
|
||||
case WorkflowTriggerType.WEBHOOK: {
|
||||
const propertyPath = extractPropertyPathFromVariable(items);
|
||||
const schemaNode = navigateOutputSchemaProperty({
|
||||
schema: trigger.settings.outputSchema as BaseOutputSchemaV2,
|
||||
propertyPath,
|
||||
});
|
||||
|
||||
if (!isDefined(schemaNode)) {
|
||||
return DEFAULT_ITERATOR_CURRENT_ITEM;
|
||||
}
|
||||
|
||||
return inferArrayItemSchema({ schemaNode });
|
||||
}
|
||||
default: {
|
||||
return DEFAULT_ITERATOR_CURRENT_ITEM;
|
||||
}
|
||||
@@ -447,6 +463,20 @@ export class WorkflowSchemaWorkspaceService {
|
||||
}),
|
||||
};
|
||||
}
|
||||
case WorkflowActionType.CODE:
|
||||
case WorkflowActionType.HTTP_REQUEST: {
|
||||
const propertyPath = extractPropertyPathFromVariable(items);
|
||||
const schemaNode = navigateOutputSchemaProperty({
|
||||
schema: step.settings.outputSchema as BaseOutputSchemaV2,
|
||||
propertyPath,
|
||||
});
|
||||
|
||||
if (!isDefined(schemaNode)) {
|
||||
return DEFAULT_ITERATOR_CURRENT_ITEM;
|
||||
}
|
||||
|
||||
return inferArrayItemSchema({ schemaNode });
|
||||
}
|
||||
default: {
|
||||
return DEFAULT_ITERATOR_CURRENT_ITEM;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user