diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/hooks/useSearchVariable.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/hooks/useSearchVariable.ts index 068d460d5d1..71d26888896 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/hooks/useSearchVariable.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/hooks/useSearchVariable.ts @@ -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, }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/types/BaseOutputSchemaV2.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/types/BaseOutputSchemaV2.ts new file mode 100644 index 00000000000..0d362efba30 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/types/BaseOutputSchemaV2.ts @@ -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; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/types/CodeOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/types/CodeOutputSchema.ts new file mode 100644 index 00000000000..0727ea7668c --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/types/CodeOutputSchema.ts @@ -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; 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 new file mode 100644 index 00000000000..bcdb6808ea0 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/types/FindRecordsOutputSchema.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/types/FormOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/types/FormOutputSchema.ts new file mode 100644 index 00000000000..0e2f127350e --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/types/FormOutputSchema.ts @@ -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; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughBaseOutputSchema.test.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughBaseOutputSchema.test.ts new file mode 100644 index 00000000000..5e64c0d2a77 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughBaseOutputSchema.test.ts @@ -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', + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughCodeOutputSchema.test.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughCodeOutputSchema.test.ts new file mode 100644 index 00000000000..d12085b71d7 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughCodeOutputSchema.test.ts @@ -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', + }); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughFindRecordsOutputSchema.test.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughFindRecordsOutputSchema.test.ts new file mode 100644 index 00000000000..6ad7dd985e8 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughFindRecordsOutputSchema.test.ts @@ -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, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughFormOutputSchema.test.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughFormOutputSchema.test.ts new file mode 100644 index 00000000000..45c1650c8fd --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughFormOutputSchema.test.ts @@ -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, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/getOutputSchemaType.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/getOutputSchemaType.ts index 6007d64aebf..c25b2268307 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/getOutputSchemaType.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/getOutputSchemaType.ts @@ -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'; } diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema.ts new file mode 100644 index 00000000000..116533754af --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema.ts @@ -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, + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughCodeOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughCodeOutputSchema.ts new file mode 100644 index 00000000000..98e22eb78dd --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughCodeOutputSchema.ts @@ -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, + }); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFindRecordsOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFindRecordsOutputSchema.ts new file mode 100644 index 00000000000..df6d56da82e --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFindRecordsOutputSchema.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFormOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFormOutputSchema.ts new file mode 100644 index 00000000000..5e2cf1827c2 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFormOutputSchema.ts @@ -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, + }; +};