Search variables - finish the search by step type (#14046)

- Add a specific search and type for find records step. Will be mainly
useful when we start fetching list of records
- Same for forms
This commit is contained in:
Thomas Trompette
2025-08-25 13:48:12 +00:00
committed by GitHub
parent 97e5f06ff9
commit 293cf0be39
14 changed files with 1528 additions and 5 deletions
@@ -1,9 +1,16 @@
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
import { useWorkflowVersionIdOrThrow } from '@/workflow/hooks/useWorkflowVersionIdOrThrow';
import { stepsOutputSchemaFamilySelector } from '@/workflow/states/selectors/stepsOutputSchemaFamilySelector';
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 RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2';
import { getOutputSchemaType } from '@/workflow/workflow-variables/utils/getOutputSchemaType';
import { searchVariableThroughOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughOutputSchema';
import { searchVariableThroughBaseOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema';
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 { searchVariableThroughRecordEventOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordEventOutputSchema';
import { searchVariableThroughRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema';
import { useRecoilValue } from 'recoil';
@@ -67,9 +74,39 @@ export const useSearchVariable = ({
});
}
// TODO: remove old search once all schema types are handled
return searchVariableThroughOutputSchema({
stepOutputSchema,
if (outputSchemaType === 'FIND_RECORDS') {
return searchVariableThroughFindRecordsOutputSchema({
stepName: stepOutputSchema.name,
searchRecordOutputSchema:
stepOutputSchema.outputSchema as unknown as FindRecordsOutputSchema,
rawVariableName,
isFullRecord,
});
}
if (outputSchemaType === 'FORM') {
return searchVariableThroughFormOutputSchema({
stepName: stepOutputSchema.name,
formOutputSchema:
stepOutputSchema.outputSchema as unknown as FormOutputSchema,
rawVariableName,
isFullRecord,
});
}
if (outputSchemaType === 'CODE') {
return searchVariableThroughCodeOutputSchema({
stepName: stepOutputSchema.name,
codeOutputSchema:
stepOutputSchema.outputSchema as unknown as CodeOutputSchema,
rawVariableName,
isFullRecord,
});
}
return searchVariableThroughBaseOutputSchema({
stepName: stepOutputSchema.name,
baseOutputSchema: stepOutputSchema.outputSchema as BaseOutputSchemaV2,
rawVariableName,
isFullRecord,
});
@@ -0,0 +1,40 @@
type BaseLeaf = {
isLeaf: true;
label: string;
};
type LeafString = BaseLeaf & {
type: 'string';
value: string;
};
type LeafNumber = BaseLeaf & {
type: 'number';
value: number;
};
type LeafBoolean = BaseLeaf & {
type: 'boolean';
value: boolean;
};
type LeafArray = BaseLeaf & {
type: 'array';
value: unknown[];
};
type LeafUnknown = BaseLeaf & {
type: 'unknown';
value: unknown;
};
type Leaf = LeafString | LeafNumber | LeafBoolean | LeafArray | LeafUnknown;
type Node = {
isLeaf: false;
type: 'object' | 'unknown';
label: string;
value: BaseOutputSchemaV2;
};
export type BaseOutputSchemaV2 = Record<string, Leaf | Node>;
@@ -0,0 +1,14 @@
import { type BaseOutputSchemaV2 } from '@/workflow/workflow-variables/types/BaseOutputSchemaV2';
type Link = {
isLeaf: true;
tab?: string;
label?: string;
};
export type LinkOutputSchema = {
link: Link;
_outputSchemaType: 'LINK';
};
export type CodeOutputSchema = LinkOutputSchema | BaseOutputSchemaV2;
@@ -0,0 +1,13 @@
import { type RecordOutputSchemaV2 } from './RecordOutputSchemaV2';
type RecordNode = {
isLeaf: false;
label: string;
value: RecordOutputSchemaV2;
};
export type FindRecordsOutputSchema = {
first: RecordNode;
last: RecordNode;
totalCount: number;
};
@@ -0,0 +1,17 @@
import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2';
import { type FieldMetadataType } from 'twenty-shared/types';
type FormFieldLeaf = {
isLeaf: true;
type: FieldMetadataType;
label: string;
value: any;
};
type FormFieldNode = {
isLeaf: false;
label: string;
value: RecordOutputSchemaV2;
};
export type FormOutputSchema = Record<string, FormFieldLeaf | FormFieldNode>;
@@ -0,0 +1,348 @@
import type { BaseOutputSchemaV2 } from '@/workflow/workflow-variables/types/BaseOutputSchemaV2';
import { searchVariableThroughBaseOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema';
describe('searchVariableThroughBaseOutputSchema', () => {
const mockBaseSchema: BaseOutputSchemaV2 = {
message: {
isLeaf: true,
type: 'string',
label: 'Message',
value: 'Hello World',
},
count: {
isLeaf: true,
type: 'number',
label: 'Count',
value: 42,
},
isSuccess: {
isLeaf: true,
type: 'boolean',
label: 'Success Status',
value: true,
},
items: {
isLeaf: true,
type: 'array',
label: 'Items List',
value: ['item1', 'item2', 'item3'],
},
user: {
isLeaf: false,
type: 'object',
label: 'User Information',
value: {
name: {
isLeaf: true,
type: 'string',
label: 'Full Name',
value: 'John Doe',
},
age: {
isLeaf: true,
type: 'number',
label: 'Age',
value: 30,
},
profile: {
isLeaf: false,
type: 'object',
label: 'Profile',
value: {
email: {
isLeaf: true,
type: 'string',
label: 'Email Address',
value: 'john@example.com',
},
isActive: {
isLeaf: true,
type: 'boolean',
label: 'Is Active',
value: true,
},
},
},
},
},
config: {
isLeaf: false,
type: 'object',
label: 'Configuration',
value: {
timeout: {
isLeaf: true,
type: 'number',
label: 'Timeout (ms)',
value: 5000,
},
retries: {
isLeaf: true,
type: 'number',
label: 'Retry Count',
value: 3,
},
},
},
};
it('should handle simple string field access correctly', () => {
const result = searchVariableThroughBaseOutputSchema({
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.message}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Message',
variablePathLabel: 'HTTP Request > Message',
variableType: 'string',
});
});
it('should handle simple number field access correctly', () => {
const result = searchVariableThroughBaseOutputSchema({
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.count}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Count',
variablePathLabel: 'HTTP Request > Count',
variableType: 'number',
});
});
it('should handle simple boolean field access correctly', () => {
const result = searchVariableThroughBaseOutputSchema({
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.isSuccess}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Success Status',
variablePathLabel: 'HTTP Request > Success Status',
variableType: 'boolean',
});
});
it('should handle array field access correctly', () => {
const result = searchVariableThroughBaseOutputSchema({
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.items}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Items List',
variablePathLabel: 'HTTP Request > Items List',
variableType: 'array',
});
});
it('should handle nested object field access correctly', () => {
const result = searchVariableThroughBaseOutputSchema({
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.user.name}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Full Name',
variablePathLabel: 'HTTP Request > User Information > Full Name',
variableType: 'string',
});
});
it('should handle deeply nested object field access correctly', () => {
const result = searchVariableThroughBaseOutputSchema({
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.user.profile.email}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Email Address',
variablePathLabel:
'HTTP Request > User Information > Profile > Email Address',
variableType: 'string',
});
});
it('should handle deeply nested boolean field access correctly', () => {
const result = searchVariableThroughBaseOutputSchema({
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.user.profile.isActive}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Is Active',
variablePathLabel:
'HTTP Request > User Information > Profile > Is Active',
variableType: 'boolean',
});
});
it('should handle config object field access correctly', () => {
const result = searchVariableThroughBaseOutputSchema({
stepName: 'Code Action',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.config.timeout}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Timeout (ms)',
variablePathLabel: 'Code Action > Configuration > Timeout (ms)',
variableType: 'number',
});
});
it('should return undefined for invalid field name', () => {
const result = searchVariableThroughBaseOutputSchema({
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.invalidField}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: undefined,
});
});
it('should return undefined for invalid nested field name', () => {
const result = searchVariableThroughBaseOutputSchema({
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.user.invalidField}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: undefined,
});
});
it('should return undefined for deeply invalid nested field name', () => {
const result = searchVariableThroughBaseOutputSchema({
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.user.profile.invalidField}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: undefined,
});
});
it('should return undefined when trying to access nested field on a leaf field', () => {
const result = searchVariableThroughBaseOutputSchema({
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.message.nestedField}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: undefined,
});
});
it('should return undefined when baseOutputSchema is undefined', () => {
const result = searchVariableThroughBaseOutputSchema({
stepName: 'HTTP Request',
baseOutputSchema: undefined as any,
rawVariableName: '{{step1.message}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: undefined,
});
});
it('should return undefined when stepId or fieldName is undefined', () => {
const result = searchVariableThroughBaseOutputSchema({
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: undefined,
});
});
it('should handle variables without curly braces', () => {
const result = searchVariableThroughBaseOutputSchema({
stepName: 'HTTP Request',
baseOutputSchema: mockBaseSchema,
rawVariableName: 'step1.message',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Message',
variablePathLabel: 'HTTP Request > Message',
variableType: 'string',
});
});
it('should handle object field access without target field (should return object info)', () => {
const result = searchVariableThroughBaseOutputSchema({
stepName: 'Code Action',
baseOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.user}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'User Information',
variablePathLabel: 'Code Action > User Information',
variableType: 'object',
});
});
it('should handle unknown type field correctly', () => {
const schemaWithUnknown: BaseOutputSchemaV2 = {
unknownField: {
isLeaf: true,
type: 'unknown',
label: 'Unknown Data',
value: null,
},
};
const result = searchVariableThroughBaseOutputSchema({
stepName: 'AI Agent',
baseOutputSchema: schemaWithUnknown,
rawVariableName: '{{step1.unknownField}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Unknown Data',
variablePathLabel: 'AI Agent > Unknown Data',
variableType: 'unknown',
});
});
});
@@ -0,0 +1,274 @@
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';
describe('searchVariableThroughCodeOutputSchema', () => {
describe('LinkOutputSchema tests', () => {
const mockLinkSchema: CodeOutputSchema = {
link: {
isLeaf: true,
tab: 'main',
label: 'External Link',
},
_outputSchemaType: 'LINK',
};
it('should return undefined', () => {
const result = searchVariableThroughCodeOutputSchema({
stepName: 'Code Step',
codeOutputSchema: mockLinkSchema,
rawVariableName: '{{step1.link}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: undefined,
});
});
});
describe('BaseOutputSchemaV2 tests', () => {
const mockBaseSchema: BaseOutputSchemaV2 = {
message: {
isLeaf: true,
type: 'string',
label: 'Response Message',
value: 'Success',
},
data: {
isLeaf: false,
type: 'object',
label: 'Response Data',
value: {
userId: {
isLeaf: true,
type: 'number',
label: 'User ID',
value: 123,
},
profile: {
isLeaf: false,
type: 'object',
label: 'User Profile',
value: {
name: {
isLeaf: true,
type: 'string',
label: 'Full Name',
value: 'John Doe',
},
email: {
isLeaf: true,
type: 'string',
label: 'Email Address',
value: 'john@example.com',
},
},
},
},
},
count: {
isLeaf: true,
type: 'number',
label: 'Item Count',
value: 42,
},
isEnabled: {
isLeaf: true,
type: 'boolean',
label: 'Is Enabled',
value: true,
},
};
it('should handle simple string field access correctly', () => {
const result = searchVariableThroughCodeOutputSchema({
stepName: 'Code Action',
codeOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.message}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Response Message',
variablePathLabel: 'Code Action > Response Message',
variableType: 'string',
});
});
it('should handle simple number field access correctly', () => {
const result = searchVariableThroughCodeOutputSchema({
stepName: 'Code Action',
codeOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.count}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Item Count',
variablePathLabel: 'Code Action > Item Count',
variableType: 'number',
});
});
it('should handle simple boolean field access correctly', () => {
const result = searchVariableThroughCodeOutputSchema({
stepName: 'Code Action',
codeOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.isEnabled}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Is Enabled',
variablePathLabel: 'Code Action > Is Enabled',
variableType: 'boolean',
});
});
it('should handle nested object field access correctly', () => {
const result = searchVariableThroughCodeOutputSchema({
stepName: 'Code Action',
codeOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.data.userId}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'User ID',
variablePathLabel: 'Code Action > Response Data > User ID',
variableType: 'number',
});
});
it('should handle deeply nested object field access correctly', () => {
const result = searchVariableThroughCodeOutputSchema({
stepName: 'Code Action',
codeOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.data.profile.name}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Full Name',
variablePathLabel:
'Code Action > Response Data > User Profile > Full Name',
variableType: 'string',
});
});
it('should handle deeply nested email field access correctly', () => {
const result = searchVariableThroughCodeOutputSchema({
stepName: 'Code Action',
codeOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.data.profile.email}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Email Address',
variablePathLabel:
'Code Action > Response Data > User Profile > Email Address',
variableType: 'string',
});
});
it('should handle object field access (returns object info)', () => {
const result = searchVariableThroughCodeOutputSchema({
stepName: 'Code Action',
codeOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.data}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Response Data',
variablePathLabel: 'Code Action > Response Data',
variableType: 'object',
});
});
it('should return undefined for invalid field name', () => {
const result = searchVariableThroughCodeOutputSchema({
stepName: 'Code Action',
codeOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.invalidField}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: undefined,
});
});
it('should return undefined for invalid nested field name', () => {
const result = searchVariableThroughCodeOutputSchema({
stepName: 'Code Action',
codeOutputSchema: mockBaseSchema,
rawVariableName: '{{step1.data.invalidField}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: undefined,
});
});
});
describe('Edge cases', () => {
const mockBaseSchema: BaseOutputSchemaV2 = {
simpleField: {
isLeaf: true,
type: 'string',
label: 'Simple Field',
value: 'test',
},
};
it('should return undefined when codeOutputSchema is undefined', () => {
const result = searchVariableThroughCodeOutputSchema({
stepName: 'Code Action',
codeOutputSchema: undefined as any,
rawVariableName: '{{step1.message}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: undefined,
});
});
it('should return undefined when stepId or fieldName is undefined', () => {
const result = searchVariableThroughCodeOutputSchema({
stepName: 'Code Action',
codeOutputSchema: mockBaseSchema,
rawVariableName: '{{}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: undefined,
});
});
it('should handle variables without curly braces', () => {
const result = searchVariableThroughCodeOutputSchema({
stepName: 'Code Action',
codeOutputSchema: mockBaseSchema,
rawVariableName: 'step1.simpleField',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Simple Field',
variablePathLabel: 'Code Action > Simple Field',
variableType: 'string',
});
});
});
});
@@ -0,0 +1,151 @@
import { type FindRecordsOutputSchema } from '@/workflow/workflow-variables/types/FindRecordsOutputSchema';
import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2';
import { searchVariableThroughFindRecordsOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughFindRecordsOutputSchema';
import { FieldMetadataType } from '~/generated-metadata/graphql';
describe('searchVariableThroughFindRecordsOutputSchema', () => {
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 mockSearchRecordSchema: FindRecordsOutputSchema = {
first: {
isLeaf: false,
label: 'First',
value: mockRecordSchema,
},
last: {
isLeaf: false,
label: 'Last',
value: mockRecordSchema,
},
totalCount: 42,
};
it('should handle totalCount variable correctly', () => {
const result = searchVariableThroughFindRecordsOutputSchema({
stepName: 'Find Companies',
searchRecordOutputSchema: mockSearchRecordSchema,
rawVariableName: '{{step1.totalCount}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Total Count',
variablePathLabel: 'Find Companies > Total Count',
variableType: FieldMetadataType.NUMBER,
});
});
it('should handle first record field access correctly', () => {
const result = searchVariableThroughFindRecordsOutputSchema({
stepName: 'Find Companies',
searchRecordOutputSchema: mockSearchRecordSchema,
rawVariableName: '{{step1.first.name}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Company Name',
variablePathLabel: 'Find Companies > First > Company Name',
variableType: FieldMetadataType.TEXT,
fieldMetadataId: 'company-name-metadata-id',
compositeFieldSubFieldName: undefined,
});
});
it('should handle last record field access correctly', () => {
const result = searchVariableThroughFindRecordsOutputSchema({
stepName: 'Find Companies',
searchRecordOutputSchema: mockSearchRecordSchema,
rawVariableName: '{{step1.last.revenue}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Revenue',
variablePathLabel: 'Find Companies > Last > Revenue',
variableType: FieldMetadataType.NUMBER,
fieldMetadataId: 'company-revenue-metadata-id',
compositeFieldSubFieldName: undefined,
});
});
it('should return undefined for invalid field name', () => {
const result = searchVariableThroughFindRecordsOutputSchema({
stepName: 'Find Companies',
searchRecordOutputSchema: mockSearchRecordSchema,
rawVariableName: '{{step1.first.invalidField}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: undefined,
});
});
it('should return undefined for invalid search result key', () => {
const result = searchVariableThroughFindRecordsOutputSchema({
stepName: 'Find Companies',
searchRecordOutputSchema: mockSearchRecordSchema,
rawVariableName: '{{step1.invalid.name}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: undefined,
});
});
it('should return undefined when searchRecordOutputSchema is undefined', () => {
const result = searchVariableThroughFindRecordsOutputSchema({
stepName: 'Find Companies',
searchRecordOutputSchema: undefined as any,
rawVariableName: '{{step1.first.name}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: undefined,
});
});
it('should return undefined when stepId or searchResultKey is undefined', () => {
const result = searchVariableThroughFindRecordsOutputSchema({
stepName: 'Find Companies',
searchRecordOutputSchema: mockSearchRecordSchema,
rawVariableName: '{{}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: undefined,
});
});
});
@@ -0,0 +1,252 @@
import { type FormOutputSchema } from '@/workflow/workflow-variables/types/FormOutputSchema';
import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2';
import { searchVariableThroughFormOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughFormOutputSchema';
import { FieldMetadataType } from '~/generated-metadata/graphql';
describe('searchVariableThroughFormOutputSchema', () => {
const mockRecordSchema: RecordOutputSchemaV2 = {
object: {
objectMetadataId: 'person-metadata-id',
label: 'Person',
},
fields: {
firstName: {
isLeaf: true,
type: FieldMetadataType.TEXT,
label: 'First Name',
value: 'John',
fieldMetadataId: 'person-firstName-metadata-id',
isCompositeSubField: false,
},
lastName: {
isLeaf: true,
type: FieldMetadataType.TEXT,
label: 'Last Name',
value: 'Doe',
fieldMetadataId: 'person-lastName-metadata-id',
isCompositeSubField: false,
},
email: {
isLeaf: true,
type: FieldMetadataType.EMAILS,
label: 'Email',
value: 'john.doe@example.com',
fieldMetadataId: 'person-email-metadata-id',
isCompositeSubField: false,
},
},
_outputSchemaType: 'RECORD',
};
const mockFormSchema: FormOutputSchema = {
// Simple text field
companyName: {
isLeaf: true,
type: FieldMetadataType.TEXT,
label: 'Company Name',
value: 'Acme Corp',
},
// Number field
revenue: {
isLeaf: true,
type: FieldMetadataType.NUMBER,
label: 'Annual Revenue',
value: 1000000,
},
// Email field
contactEmail: {
isLeaf: true,
type: FieldMetadataType.EMAILS,
label: 'Contact Email',
value: 'contact@acme.com',
},
// Record field (nested record)
contactPerson: {
isLeaf: false,
label: 'Contact Person',
value: mockRecordSchema,
},
};
it('should handle simple text field access correctly', () => {
const result = searchVariableThroughFormOutputSchema({
stepName: 'Company Form',
formOutputSchema: mockFormSchema,
rawVariableName: '{{step1.companyName}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Company Name',
variablePathLabel: 'Company Form > Company Name',
variableType: FieldMetadataType.TEXT,
});
});
it('should handle number field access correctly', () => {
const result = searchVariableThroughFormOutputSchema({
stepName: 'Company Form',
formOutputSchema: mockFormSchema,
rawVariableName: '{{step1.revenue}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Annual Revenue',
variablePathLabel: 'Company Form > Annual Revenue',
variableType: FieldMetadataType.NUMBER,
});
});
it('should handle email field access correctly', () => {
const result = searchVariableThroughFormOutputSchema({
stepName: 'Company Form',
formOutputSchema: mockFormSchema,
rawVariableName: '{{step1.contactEmail}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Contact Email',
variablePathLabel: 'Company Form > Contact Email',
variableType: FieldMetadataType.EMAILS,
});
});
it('should handle nested record field access correctly', () => {
const result = searchVariableThroughFormOutputSchema({
stepName: 'Company Form',
formOutputSchema: mockFormSchema,
rawVariableName: '{{step1.contactPerson.firstName}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'First Name',
variablePathLabel: 'Company Form > Contact Person > First Name',
variableType: FieldMetadataType.TEXT,
fieldMetadataId: 'person-firstName-metadata-id',
compositeFieldSubFieldName: undefined,
});
});
it('should handle nested record email field access correctly', () => {
const result = searchVariableThroughFormOutputSchema({
stepName: 'Company Form',
formOutputSchema: mockFormSchema,
rawVariableName: '{{step1.contactPerson.email}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Email',
variablePathLabel: 'Company Form > Contact Person > Email',
variableType: FieldMetadataType.EMAILS,
fieldMetadataId: 'person-email-metadata-id',
compositeFieldSubFieldName: undefined,
});
});
it('should return undefined for invalid field name', () => {
const result = searchVariableThroughFormOutputSchema({
stepName: 'Company Form',
formOutputSchema: mockFormSchema,
rawVariableName: '{{step1.invalidField}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: undefined,
});
});
it('should return undefined for invalid nested field name', () => {
const result = searchVariableThroughFormOutputSchema({
stepName: 'Company Form',
formOutputSchema: mockFormSchema,
rawVariableName: '{{step1.contactPerson.invalidField}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: undefined,
});
});
it('should return undefined when trying to access record field without specifying property', () => {
const result = searchVariableThroughFormOutputSchema({
stepName: 'Company Form',
formOutputSchema: mockFormSchema,
rawVariableName: '{{step1.contactPerson}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: undefined,
});
});
it('should return undefined when formOutputSchema is undefined', () => {
const result = searchVariableThroughFormOutputSchema({
stepName: 'Company Form',
formOutputSchema: undefined as any,
rawVariableName: '{{step1.companyName}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: undefined,
});
});
it('should return undefined when stepId or fieldName is undefined', () => {
const result = searchVariableThroughFormOutputSchema({
stepName: 'Company Form',
formOutputSchema: mockFormSchema,
rawVariableName: '{{}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: undefined,
});
});
it('should handle variables without curly braces', () => {
const result = searchVariableThroughFormOutputSchema({
stepName: 'Company Form',
formOutputSchema: mockFormSchema,
rawVariableName: 'step1.companyName',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Company Name',
variablePathLabel: 'Company Form > Company Name',
variableType: FieldMetadataType.TEXT,
});
});
it('should handle complex nested path correctly', () => {
// Test with a deeper path in case we have complex nested records
const result = searchVariableThroughFormOutputSchema({
stepName: 'Company Form',
formOutputSchema: mockFormSchema,
rawVariableName: '{{step1.contactPerson.lastName}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Last Name',
variablePathLabel: 'Company Form > Contact Person > Last Name',
variableType: FieldMetadataType.TEXT,
fieldMetadataId: 'person-lastName-metadata-id',
compositeFieldSubFieldName: undefined,
});
});
});
@@ -5,7 +5,7 @@ import {
export const getOutputSchemaType = (
stepType: WorkflowActionType | WorkflowTriggerType,
): 'RECORD' | 'DATABASE_EVENT' | 'BASE' => {
): 'RECORD' | 'DATABASE_EVENT' | 'FIND_RECORDS' | 'FORM' | 'CODE' | 'BASE' => {
switch (stepType) {
case 'CREATE_RECORD':
case 'UPDATE_RECORD':
@@ -14,6 +14,12 @@ export const getOutputSchemaType = (
return 'RECORD';
case 'DATABASE_EVENT':
return 'DATABASE_EVENT';
case 'FIND_RECORDS':
return 'FIND_RECORDS';
case 'FORM':
return 'FORM';
case 'CODE':
return 'CODE';
default:
return 'BASE';
}
@@ -0,0 +1,123 @@
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from '@/workflow/workflow-variables/constants/CaptureAllVariableTagInnerRegex';
import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable';
import type { BaseOutputSchemaV2 } from '@/workflow/workflow-variables/types/BaseOutputSchemaV2';
import { isDefined } from 'twenty-shared/utils';
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);
return {
stepId,
targetFieldName: parts.at(-1),
pathSegments: parts.slice(1, -1),
};
};
const navigateToTargetField = (
startingSchema: BaseOutputSchemaV2,
pathSegments: string[],
): { schema: BaseOutputSchemaV2; pathLabels: string[] } | null => {
let currentSchema: BaseOutputSchemaV2 = startingSchema;
const pathLabels: string[] = [];
for (const pathSegment of pathSegments) {
const field = currentSchema[pathSegment];
if (!isDefined(field) || field.isLeaf) {
return null;
}
pathLabels.push(field.label);
currentSchema = field.value;
}
return { schema: currentSchema, pathLabels };
};
const buildVariableResult = (
stepName: string,
pathLabels: string[],
targetSchema: BaseOutputSchemaV2,
targetFieldName: string,
): VariableSearchResult => {
const targetField = targetSchema[targetFieldName];
if (!isDefined(targetField)) {
return {
variableLabel: undefined,
variablePathLabel: undefined,
};
}
// Build the full path: stepName > field1 > field2 > targetField
const fullPathSegments = [stepName, ...pathLabels, targetField.label];
const variablePathLabel = fullPathSegments.join(' > ');
return {
variableLabel: targetField.label,
variablePathLabel,
variableType: targetField.type,
};
};
/**
* Searches for a variable within a base output schema and returns its metadata
*
* @param stepName - Display name of the workflow step
* @param baseOutputSchema - The base schema to search within
* @param rawVariableName - Variable name like "{{step1.fieldName}}" or "step1.object.nested.value"
* @param isFullRecord - Whether to return info for the entire record vs specific field (not used for base schema)
* @returns Variable metadata including labels, types, and field information
*/
export const searchVariableThroughBaseOutputSchema = ({
stepName,
baseOutputSchema,
rawVariableName,
}: {
stepName: string;
baseOutputSchema: BaseOutputSchemaV2;
rawVariableName: string;
isFullRecord?: boolean;
}): VariableSearchResult => {
if (!isDefined(baseOutputSchema)) {
return {
variableLabel: undefined,
variablePathLabel: undefined,
};
}
const { stepId, pathSegments, targetFieldName } =
parseVariableName(rawVariableName);
if (!isDefined(stepId) || !isDefined(targetFieldName)) {
return {
variableLabel: undefined,
variablePathLabel: undefined,
};
}
const navigationResult = navigateToTargetField(
baseOutputSchema,
pathSegments,
);
if (!navigationResult) {
return {
variableLabel: undefined,
variablePathLabel: undefined,
};
}
return buildVariableResult(
stepName,
navigationResult.pathLabels,
navigationResult.schema,
targetFieldName,
);
};
@@ -0,0 +1,47 @@
import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable';
import type { CodeOutputSchema } from '@/workflow/workflow-variables/types/CodeOutputSchema';
import { searchVariableThroughBaseOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema';
import { isDefined } from 'twenty-shared/utils';
const isLinkOutputSchema = (
codeOutputSchema: CodeOutputSchema,
): codeOutputSchema is { link: any; _outputSchemaType: 'LINK' } => {
return (
isDefined(codeOutputSchema) && codeOutputSchema._outputSchemaType === 'LINK'
);
};
/**
* Searches for a variable within a code output schema and returns its metadata
*
* @param stepName - Display name of the workflow step
* @param codeOutputSchema - The code schema to search within
* @param rawVariableName - Variable name like "{{step1.fieldName}}" or "step1.link"
* @param isFullRecord - Whether to return info for the entire record vs specific field
* @returns Variable metadata including labels, types, and field information
*/
export const searchVariableThroughCodeOutputSchema = ({
stepName,
codeOutputSchema,
rawVariableName,
isFullRecord = false,
}: {
stepName: string;
codeOutputSchema: CodeOutputSchema;
rawVariableName: string;
isFullRecord?: boolean;
}): VariableSearchResult => {
if (!isDefined(codeOutputSchema) || isLinkOutputSchema(codeOutputSchema)) {
return {
variableLabel: undefined,
variablePathLabel: undefined,
};
}
return searchVariableThroughBaseOutputSchema({
stepName,
baseOutputSchema: codeOutputSchema,
rawVariableName,
isFullRecord,
});
};
@@ -0,0 +1,102 @@
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from '@/workflow/workflow-variables/constants/CaptureAllVariableTagInnerRegex';
import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable';
import type { FindRecordsOutputSchema } from '@/workflow/workflow-variables/types/FindRecordsOutputSchema';
import { searchRecordOutputSchema as searchRecordOutputSchemaUtil } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
type SearchResultKey = 'first' | 'last' | 'totalCount';
/**
* Parses a variable name to extract its components for SearchRecord outputs
* Example: "{{step1.first.user.name}}" -> { stepId: "step1", searchResultKey: "first", pathSegments: ["user"], fieldName: "name" }
* Example: "{{step1.totalCount}}" -> { stepId: "step1", searchResultKey: "totalCount", pathSegments: [], fieldName: undefined }
*/
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 searchResultKey = parts.at(1) as SearchResultKey;
const remainingParts = parts.slice(2);
return {
stepId,
searchResultKey,
fieldName: remainingParts.at(-1),
pathSegments: remainingParts.slice(0, -1),
};
};
/**
* Searches for a variable within a search record output schema and returns its metadata
*
* @param stepName - Display name of the workflow step
* @param searchRecordOutputSchema - The search record schema to search within
* @param rawVariableName - Variable name like "{{step1.first.user.name}}" or "step1.totalCount"
* @param isFullRecord - Whether to return info for the entire record vs specific field
* @returns Variable metadata including labels, types, and field information
*/
export const searchVariableThroughFindRecordsOutputSchema = ({
stepName,
searchRecordOutputSchema,
rawVariableName,
isFullRecord = false,
}: {
stepName: string;
searchRecordOutputSchema: FindRecordsOutputSchema;
rawVariableName: string;
isFullRecord?: boolean;
}): VariableSearchResult => {
if (!isDefined(searchRecordOutputSchema)) {
return {
variableLabel: undefined,
variablePathLabel: undefined,
};
}
const { stepId, searchResultKey, fieldName, pathSegments } =
parseVariableName(rawVariableName);
if (!isDefined(stepId) || !isDefined(searchResultKey)) {
return {
variableLabel: undefined,
variablePathLabel: undefined,
};
}
if (searchResultKey === 'totalCount') {
return {
variableLabel: 'Total Count',
variablePathLabel: `${stepName} > Total Count`,
variableType: FieldMetadataType.NUMBER,
};
}
if (searchResultKey === 'first' || searchResultKey === 'last') {
const recordSchema = searchRecordOutputSchema[searchResultKey]?.value;
if (!isDefined(recordSchema) || !isDefined(fieldName)) {
return {
variableLabel: undefined,
variablePathLabel: undefined,
};
}
return searchRecordOutputSchemaUtil({
stepName: `${stepName} > ${searchResultKey === 'first' ? 'First' : 'Last'}`,
recordOutputSchema: recordSchema,
selectedField: fieldName,
path: pathSegments,
isFullRecord,
});
}
return {
variableLabel: undefined,
variablePathLabel: undefined,
};
};
@@ -0,0 +1,99 @@
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from '@/workflow/workflow-variables/constants/CaptureAllVariableTagInnerRegex';
import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable';
import type { FormOutputSchema } from '@/workflow/workflow-variables/types/FormOutputSchema';
import { searchRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema';
import { isDefined } from 'twenty-shared/utils';
/**
* Parses a variable name to extract its components for Form outputs
* Example: "{{step1.fieldName}}" -> { stepId: "step1", fieldName: "fieldName", pathSegments: [] }
* Example: "{{step1.recordField.user.name}}" -> { stepId: "step1", fieldName: "recordField", pathSegments: ["user"], recordFieldName: "name" }
*/
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 fieldName = parts.at(1);
const remainingParts = parts.slice(2);
return {
stepId,
fieldName,
pathSegments: remainingParts.slice(0, -1),
recordFieldName: remainingParts.at(-1),
};
};
/**
* Searches for a variable within a form output schema and returns its metadata
*
* @param stepName - Display name of the workflow step
* @param formOutputSchema - The form schema to search within
* @param rawVariableName - Variable name like "{{step1.fieldName}}" or "step1.recordField.user.name"
* @param isFullRecord - Whether to return info for the entire record vs specific field
* @returns Variable metadata including labels, types, and field information
*/
export const searchVariableThroughFormOutputSchema = ({
stepName,
formOutputSchema,
rawVariableName,
isFullRecord = false,
}: {
stepName: string;
formOutputSchema: FormOutputSchema;
rawVariableName: string;
isFullRecord?: boolean;
}): VariableSearchResult => {
if (!isDefined(formOutputSchema)) {
return {
variableLabel: undefined,
variablePathLabel: undefined,
};
}
const { stepId, fieldName, pathSegments, recordFieldName } =
parseVariableName(rawVariableName);
if (!isDefined(stepId) || !isDefined(fieldName)) {
return {
variableLabel: undefined,
variablePathLabel: undefined,
};
}
const formField = formOutputSchema[fieldName];
if (!isDefined(formField)) {
return {
variableLabel: undefined,
variablePathLabel: undefined,
};
}
if (formField.isLeaf) {
return {
variableLabel: formField.label,
variablePathLabel: `${stepName} > ${formField.label}`,
variableType: formField.type,
};
}
if (!formField.isLeaf && isDefined(recordFieldName)) {
return searchRecordOutputSchema({
stepName: `${stepName} > ${formField.label}`,
recordOutputSchema: formField.value,
selectedField: recordFieldName,
path: pathSegments,
isFullRecord,
});
}
return {
variableLabel: undefined,
variablePathLabel: undefined,
};
};