diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/VariableChip.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/VariableChip.tsx index bb9ea29d9f..b4ecb8a0ec 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/VariableChip.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/VariableChip.tsx @@ -1,11 +1,8 @@ -import { useWorkflowVersionIdOrThrow } from '@/workflow/hooks/useWorkflowVersionIdOrThrow'; -import { stepsOutputSchemaFamilySelector } from '@/workflow/states/selectors/stepsOutputSchemaFamilySelector'; +import { useSearchVariable } from '@/workflow/workflow-variables/hooks/useSearchVariable'; import { extractRawVariableNamePart } from '@/workflow/workflow-variables/utils/extractRawVariableNamePart'; -import { searchVariableThroughOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughOutputSchema'; import { css, useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { useLingui } from '@lingui/react/macro'; -import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; import { IconAlertTriangle, IconX } from 'twenty-ui/display'; @@ -82,29 +79,15 @@ export const VariableChip = ({ }: VariableChipProps) => { const theme = useTheme(); const { t } = useLingui(); - const workflowVersionId = useWorkflowVersionIdOrThrow(); - const stepId = extractRawVariableNamePart({ - rawVariableName, - part: 'stepId', - }); - const stepsOutputSchema = useRecoilValue( - stepsOutputSchemaFamilySelector({ - workflowVersionId, - stepIds: [stepId], - }), - ); - - if (!isDefined(stepId)) { - return null; - } - - const { variableLabel, variablePathLabel } = - searchVariableThroughOutputSchema({ - stepOutputSchema: stepsOutputSchema?.[0], + const { variableLabel, variablePathLabel } = useSearchVariable({ + stepId: extractRawVariableNamePart({ rawVariableName, - isFullRecord, - }); + part: 'stepId', + }), + rawVariableName, + isFullRecord, + }); const isVariableNotFound = !isDefined(variableLabel); const label = isVariableNotFound ? t`Not Found` : variableLabel; 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 new file mode 100644 index 0000000000..068d460d5d --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/hooks/useSearchVariable.ts @@ -0,0 +1,76 @@ +import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow'; +import { useWorkflowVersionIdOrThrow } from '@/workflow/hooks/useWorkflowVersionIdOrThrow'; +import { stepsOutputSchemaFamilySelector } from '@/workflow/states/selectors/stepsOutputSchemaFamilySelector'; +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 { searchVariableThroughRecordEventOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordEventOutputSchema'; +import { searchVariableThroughRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { TRIGGER_STEP_ID } from 'twenty-shared/workflow'; + +export type VariableSearchResult = { + variableLabel: string | undefined; + variablePathLabel: string | undefined; + variableType?: string; + fieldMetadataId?: string; + compositeFieldSubFieldName?: string; +}; + +export const useSearchVariable = ({ + stepId, + rawVariableName, + isFullRecord, +}: { + stepId: string; + rawVariableName: string; + isFullRecord: boolean; +}): VariableSearchResult => { + const workflowVersionId = useWorkflowVersionIdOrThrow(); + const flow = useFlowOrThrow(); + const [stepOutputSchema] = useRecoilValue( + stepsOutputSchemaFamilySelector({ + workflowVersionId, + stepIds: [stepId], + }), + ); + const stepType = + stepId === TRIGGER_STEP_ID + ? flow.trigger?.type + : flow.steps?.find((step) => step.id === stepId)?.type; + + if (!isDefined(stepType)) { + return { + variableLabel: undefined, + variablePathLabel: undefined, + }; + } + + const outputSchemaType = getOutputSchemaType(stepType); + + if (outputSchemaType === 'RECORD') { + return searchVariableThroughRecordOutputSchema({ + stepName: stepOutputSchema.name, + recordOutputSchema: stepOutputSchema.outputSchema as RecordOutputSchemaV2, + rawVariableName, + isFullRecord, + }); + } + + if (outputSchemaType === 'DATABASE_EVENT') { + return searchVariableThroughRecordEventOutputSchema({ + stepName: stepOutputSchema.name, + recordOutputSchema: stepOutputSchema.outputSchema as RecordOutputSchemaV2, + rawVariableName, + isFullRecord, + }); + } + + // TODO: remove old search once all schema types are handled + return searchVariableThroughOutputSchema({ + stepOutputSchema, + rawVariableName, + isFullRecord, + }); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/types/RecordOutputSchemaV2.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/types/RecordOutputSchemaV2.ts new file mode 100644 index 0000000000..236c232812 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/types/RecordOutputSchemaV2.ts @@ -0,0 +1,34 @@ +import { type FieldMetadataType } from 'twenty-shared/types'; + +export type RecordFieldLeaf = { + isLeaf: true; + type: FieldMetadataType; + label: string; + value: any; + fieldMetadataId: string; + isCompositeSubField: boolean; +}; + +export type RecordFieldNode = { + isLeaf: false; + type: FieldMetadataType; + label: string; + value: RecordFieldNodeValue; + fieldMetadataId: string; +}; + +export type RecordFieldNodeValue = + | RecordOutputSchemaV2 + | Record; + +export type FieldOutputSchemaV2 = RecordFieldLeaf | RecordFieldNode; + +export type RecordOutputSchemaV2 = { + object: { + label: string; + objectMetadataId: string; + isRelationField?: boolean; + }; + fields: Record; + _outputSchemaType: 'RECORD'; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughRecordEventOutputSchema.test.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughRecordEventOutputSchema.test.ts new file mode 100644 index 0000000000..e6d637c6dd --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughRecordEventOutputSchema.test.ts @@ -0,0 +1,492 @@ +import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2'; +import { searchVariableThroughRecordEventOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordEventOutputSchema'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +describe('searchVariableThroughRecordEventOutputSchema', () => { + const mockRecordSchema: RecordOutputSchemaV2 = { + object: { + objectMetadataId: 'company-metadata-id', + label: 'Company', + }, + fields: { + // Event-based fields with properties.after prefix + 'properties.after.name': { + isLeaf: true, + type: FieldMetadataType.TEXT, + label: 'Company Name', + value: 'Acme Corp', + fieldMetadataId: 'company-name-metadata-id', + isCompositeSubField: false, + }, + 'properties.after.address': { + isLeaf: false, + label: 'Address', + fieldMetadataId: 'address-metadata-id', + type: FieldMetadataType.ADDRESS, + value: { + street: { + isLeaf: true, + type: FieldMetadataType.TEXT, + label: 'Street', + value: '123 Main St', + fieldMetadataId: 'street-metadata-id', + isCompositeSubField: true, + }, + city: { + isLeaf: true, + type: FieldMetadataType.TEXT, + label: 'City', + value: 'New York', + fieldMetadataId: 'city-metadata-id', + isCompositeSubField: true, + }, + }, + }, + 'properties.after.owner': { + isLeaf: false, + label: 'Owner', + fieldMetadataId: 'owner-metadata-id', + type: FieldMetadataType.RELATION, + value: { + object: { + objectMetadataId: 'person-metadata-id', + label: 'Owner Person', + isRelationField: true, + }, + fields: { + firstName: { + isLeaf: true, + type: FieldMetadataType.TEXT, + label: 'Owner First Name', + value: 'Jane', + fieldMetadataId: 'owner-firstName-metadata-id', + isCompositeSubField: false, + }, + email: { + isLeaf: true, + type: FieldMetadataType.EMAILS, + label: 'Owner Email', + value: 'jane@example.com', + fieldMetadataId: 'owner-email-metadata-id', + isCompositeSubField: false, + }, + }, + _outputSchemaType: 'RECORD', + }, + }, + // Event-based fields with properties.before prefix + 'properties.before.name': { + isLeaf: true, + type: FieldMetadataType.TEXT, + label: 'Company Name', + value: 'Old Acme Corp', + fieldMetadataId: 'company-name-metadata-id', + isCompositeSubField: false, + }, + 'properties.before.address': { + isLeaf: false, + label: 'Address', + fieldMetadataId: 'address-metadata-id', + type: FieldMetadataType.ADDRESS, + value: { + street: { + isLeaf: true, + type: FieldMetadataType.TEXT, + label: 'Street', + value: '456 Old St', + fieldMetadataId: 'street-metadata-id', + isCompositeSubField: true, + }, + city: { + isLeaf: true, + type: FieldMetadataType.TEXT, + label: 'City', + value: 'Old York', + fieldMetadataId: 'city-metadata-id', + isCompositeSubField: true, + }, + }, + }, + 'properties.before.owner': { + isLeaf: false, + label: 'Owner', + fieldMetadataId: 'owner-metadata-id', + type: FieldMetadataType.RELATION, + value: { + object: { + objectMetadataId: 'person-metadata-id', + label: 'Owner Person', + isRelationField: true, + }, + fields: { + firstName: { + isLeaf: true, + type: FieldMetadataType.TEXT, + label: 'Owner First Name', + value: 'John', + fieldMetadataId: 'owner-firstName-metadata-id', + isCompositeSubField: false, + }, + email: { + isLeaf: true, + type: FieldMetadataType.EMAILS, + label: 'Owner Email', + value: 'john@example.com', + fieldMetadataId: 'owner-email-metadata-id', + isCompositeSubField: false, + }, + }, + _outputSchemaType: 'RECORD', + }, + }, + }, + _outputSchemaType: 'RECORD', + }; + + describe('event variable parsing with properties.after prefix', () => { + it('should find a basic field with properties.after prefix', () => { + const result = searchVariableThroughRecordEventOutputSchema({ + stepName: 'Record Updated', + recordOutputSchema: mockRecordSchema, + rawVariableName: '{{step1.properties.after.name}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: 'Company Name', + variablePathLabel: 'Record Updated > Company Name', + variableType: FieldMetadataType.TEXT, + fieldMetadataId: 'company-name-metadata-id', + compositeFieldSubFieldName: undefined, + }); + }); + + it('should find a composite field with properties.after prefix', () => { + const result = searchVariableThroughRecordEventOutputSchema({ + stepName: 'Record Updated', + recordOutputSchema: mockRecordSchema, + rawVariableName: '{{step1.properties.after.address.street}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: 'Street', + variablePathLabel: 'Record Updated > Address > Street', + variableType: FieldMetadataType.TEXT, + fieldMetadataId: 'street-metadata-id', + compositeFieldSubFieldName: 'street', + }); + }); + + it('should find a nested record field with properties.after prefix', () => { + const result = searchVariableThroughRecordEventOutputSchema({ + stepName: 'Record Updated', + recordOutputSchema: mockRecordSchema, + rawVariableName: '{{step1.properties.after.owner.firstName}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: 'Owner First Name', + variablePathLabel: 'Record Updated > Owner > Owner First Name', + variableType: FieldMetadataType.TEXT, + fieldMetadataId: 'owner-firstName-metadata-id', + compositeFieldSubFieldName: undefined, + }); + }); + }); + + describe('event variable parsing with properties.before prefix', () => { + it('should find a basic field with properties.before prefix', () => { + const result = searchVariableThroughRecordEventOutputSchema({ + stepName: 'Record Updated', + recordOutputSchema: mockRecordSchema, + rawVariableName: '{{step1.properties.before.name}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: 'Company Name', + variablePathLabel: 'Record Updated > Company Name', + variableType: FieldMetadataType.TEXT, + fieldMetadataId: 'company-name-metadata-id', + compositeFieldSubFieldName: undefined, + }); + }); + + it('should find a composite field with properties.before prefix', () => { + const result = searchVariableThroughRecordEventOutputSchema({ + stepName: 'Record Updated', + recordOutputSchema: mockRecordSchema, + rawVariableName: '{{step1.properties.before.address.city}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: 'City', + variablePathLabel: 'Record Updated > Address > City', + variableType: FieldMetadataType.TEXT, + fieldMetadataId: 'city-metadata-id', + compositeFieldSubFieldName: 'city', + }); + }); + + it('should find a nested record field with properties.before prefix', () => { + const result = searchVariableThroughRecordEventOutputSchema({ + stepName: 'Record Updated', + recordOutputSchema: mockRecordSchema, + rawVariableName: '{{step1.properties.before.owner.email}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: 'Owner Email', + variablePathLabel: 'Record Updated > Owner > Owner Email', + variableType: FieldMetadataType.EMAILS, + fieldMetadataId: 'owner-email-metadata-id', + compositeFieldSubFieldName: undefined, + }); + }); + }); + + describe('variable name without brackets', () => { + it('should handle variable names without double brackets', () => { + const result = searchVariableThroughRecordEventOutputSchema({ + stepName: 'Record Updated', + recordOutputSchema: mockRecordSchema, + rawVariableName: 'step1.properties.after.name', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: 'Company Name', + variablePathLabel: 'Record Updated > Company Name', + variableType: FieldMetadataType.TEXT, + fieldMetadataId: 'company-name-metadata-id', + compositeFieldSubFieldName: undefined, + }); + }); + }); + + describe('complex nested paths', () => { + it('should handle deeply nested paths with event prefix', () => { + const result = searchVariableThroughRecordEventOutputSchema({ + stepName: 'Record Created', + recordOutputSchema: mockRecordSchema, + rawVariableName: '{{step1.properties.after.address.street}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: 'Street', + variablePathLabel: 'Record Created > Address > Street', + variableType: FieldMetadataType.TEXT, + fieldMetadataId: 'street-metadata-id', + compositeFieldSubFieldName: 'street', + }); + }); + + it('should handle multiple path segments with event prefix', () => { + const result = searchVariableThroughRecordEventOutputSchema({ + stepName: 'Record Updated', + recordOutputSchema: mockRecordSchema, + rawVariableName: '{{step1.properties.after.owner.firstName}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: 'Owner First Name', + variablePathLabel: 'Record Updated > Owner > Owner First Name', + variableType: FieldMetadataType.TEXT, + fieldMetadataId: 'owner-firstName-metadata-id', + compositeFieldSubFieldName: undefined, + }); + }); + }); + + describe('full record mode', () => { + it('should return record object label when isFullRecord is true with event prefix', () => { + const result = searchVariableThroughRecordEventOutputSchema({ + stepName: 'Record Created', + recordOutputSchema: mockRecordSchema, + rawVariableName: '{{step1.properties.after.id}}', + isFullRecord: true, + }); + + expect(result).toEqual({ + variableLabel: 'Company', + variablePathLabel: 'Record Created > Company', + variableType: undefined, + fieldMetadataId: undefined, + compositeFieldSubFieldName: undefined, + }); + }); + + it('should return nested record object label when isFullRecord is true with event prefix', () => { + const result = searchVariableThroughRecordEventOutputSchema({ + stepName: 'Record Updated', + recordOutputSchema: mockRecordSchema, + rawVariableName: '{{step1.properties.before.owner.id}}', + isFullRecord: true, + }); + + expect(result).toEqual({ + variableLabel: 'Owner Person', + variablePathLabel: 'Record Updated > Owner > Owner Person', + variableType: undefined, + fieldMetadataId: undefined, + compositeFieldSubFieldName: undefined, + }); + }); + }); + + describe('error handling', () => { + it('should handle undefined recordOutputSchema', () => { + const result = searchVariableThroughRecordEventOutputSchema({ + stepName: 'Test Step', + recordOutputSchema: undefined as any, + rawVariableName: '{{step1.properties.after.field}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: undefined, + variablePathLabel: undefined, + }); + }); + + it('should handle malformed variable name without stepId', () => { + const result = searchVariableThroughRecordEventOutputSchema({ + stepName: 'Record Updated', + recordOutputSchema: mockRecordSchema, + rawVariableName: '{{properties.after.name}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: undefined, + variablePathLabel: undefined, + }); + }); + + it('should handle malformed variable name without field name', () => { + const result = searchVariableThroughRecordEventOutputSchema({ + stepName: 'Record Updated', + recordOutputSchema: mockRecordSchema, + rawVariableName: '{{step1}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: undefined, + variablePathLabel: undefined, + }); + }); + + it('should handle non-existent field with event prefix', () => { + const result = searchVariableThroughRecordEventOutputSchema({ + stepName: 'Record Updated', + recordOutputSchema: mockRecordSchema, + rawVariableName: '{{step1.properties.after.nonExistentField}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: undefined, + variablePathLabel: undefined, + variableType: undefined, + }); + }); + + it('should handle broken nested path with event prefix', () => { + const result = searchVariableThroughRecordEventOutputSchema({ + stepName: 'Record Updated', + recordOutputSchema: mockRecordSchema, + rawVariableName: '{{step1.properties.after.address.nonExistent}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: undefined, + variablePathLabel: undefined, + variableType: undefined, + }); + }); + + it('should handle broken nested record path with event prefix', () => { + const result = searchVariableThroughRecordEventOutputSchema({ + stepName: 'Record Updated', + recordOutputSchema: mockRecordSchema, + rawVariableName: '{{step1.properties.after.owner.nonExistent}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: undefined, + variablePathLabel: undefined, + variableType: undefined, + }); + }); + + it('should handle empty variable name', () => { + const result = searchVariableThroughRecordEventOutputSchema({ + stepName: 'Record Updated', + recordOutputSchema: mockRecordSchema, + rawVariableName: '', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: undefined, + variablePathLabel: undefined, + }); + }); + + it('should handle variable name with only brackets', () => { + const result = searchVariableThroughRecordEventOutputSchema({ + stepName: 'Record Updated', + recordOutputSchema: mockRecordSchema, + rawVariableName: '{{}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: undefined, + variablePathLabel: undefined, + }); + }); + }); + + describe('edge cases with variable parsing', () => { + it('should handle variable with incomplete event prefix', () => { + const result = searchVariableThroughRecordEventOutputSchema({ + stepName: 'Record Updated', + recordOutputSchema: mockRecordSchema, + rawVariableName: '{{step1.properties.name}}', + isFullRecord: false, + }); + + // This should still work as the parser extracts what it can + expect(result).toEqual({ + variableLabel: undefined, + variablePathLabel: undefined, + variableType: undefined, + }); + }); + + it('should handle variable with extra dots', () => { + const result = searchVariableThroughRecordEventOutputSchema({ + stepName: 'Record Updated', + recordOutputSchema: mockRecordSchema, + rawVariableName: '{{step1.properties.after.name.extra.segments}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: undefined, + variablePathLabel: undefined, + variableType: undefined, + }); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughRecordOutputSchema.test.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughRecordOutputSchema.test.ts new file mode 100644 index 0000000000..3769f05d52 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughRecordOutputSchema.test.ts @@ -0,0 +1,203 @@ +import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2'; +import { searchVariableThroughRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +describe('searchVariableThroughRecordOutputSchema', () => { + 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, + }, + address: { + isLeaf: false, + label: 'Address', + fieldMetadataId: 'address-metadata-id', + type: FieldMetadataType.ADDRESS, + value: { + street: { + isLeaf: true, + type: FieldMetadataType.TEXT, + label: 'Street', + value: '123 Main St', + fieldMetadataId: 'street-metadata-id', + isCompositeSubField: true, + }, + }, + }, + // Record field (nested record) + owner: { + isLeaf: false, + label: 'Owner', + fieldMetadataId: 'owner-metadata-id', + type: FieldMetadataType.RELATION, + value: { + object: { + objectMetadataId: 'person-metadata-id', + label: 'Owner Person', + isRelationField: true, + }, + fields: { + firstName: { + isLeaf: true, + type: FieldMetadataType.TEXT, + label: 'Owner First Name', + value: 'Jane', + fieldMetadataId: 'owner-firstName-metadata-id', + isCompositeSubField: false, + }, + }, + _outputSchemaType: 'RECORD', + }, + }, + }, + _outputSchemaType: 'RECORD', + }; + + describe('basic field access', () => { + it('should find a basic field (leaf)', () => { + const result = searchVariableThroughRecordOutputSchema({ + stepName: 'Create Company', + recordOutputSchema: mockRecordSchema, + rawVariableName: '{{step1.name}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: 'Company Name', + variablePathLabel: 'Create Company > Company Name', + variableType: FieldMetadataType.TEXT, + fieldMetadataId: 'company-name-metadata-id', + compositeFieldSubFieldName: undefined, + }); + }); + }); + + describe('node field access', () => { + it('should find a node field (composite field)', () => { + const result = searchVariableThroughRecordOutputSchema({ + stepName: 'Create Company', + recordOutputSchema: mockRecordSchema, + rawVariableName: '{{step1.address.street}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: 'Street', + variablePathLabel: 'Create Company > Address > Street', + variableType: FieldMetadataType.TEXT, + fieldMetadataId: 'street-metadata-id', + compositeFieldSubFieldName: 'street', + }); + }); + }); + + describe('record field access', () => { + it('should find a record field (nested record)', () => { + const result = searchVariableThroughRecordOutputSchema({ + stepName: 'Create Company', + recordOutputSchema: mockRecordSchema, + rawVariableName: '{{step1.owner.firstName}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: 'Owner First Name', + variablePathLabel: 'Create Company > Owner > Owner First Name', + variableType: FieldMetadataType.TEXT, + fieldMetadataId: 'owner-firstName-metadata-id', + compositeFieldSubFieldName: undefined, + }); + }); + }); + + describe('full record mode', () => { + it('should return record object label when isFullRecord is true', () => { + const result = searchVariableThroughRecordOutputSchema({ + stepName: 'Create Company', + recordOutputSchema: mockRecordSchema, + rawVariableName: '{{step1.id}}', + isFullRecord: true, + }); + + expect(result).toEqual({ + variableLabel: 'Company', + variablePathLabel: 'Create Company > Company', + variableType: undefined, + fieldMetadataId: undefined, + compositeFieldSubFieldName: undefined, + }); + }); + + it('should return nested record object label when isFullRecord is true', () => { + const result = searchVariableThroughRecordOutputSchema({ + stepName: 'Create Company', + recordOutputSchema: mockRecordSchema, + rawVariableName: '{{step1.owner.id}}', + isFullRecord: true, + }); + + expect(result).toEqual({ + variableLabel: 'Owner Person', + variablePathLabel: 'Create Company > Owner > Owner Person', + variableType: undefined, + fieldMetadataId: undefined, + compositeFieldSubFieldName: undefined, + }); + }); + }); + + describe('error handling', () => { + it('should handle undefined recordOutputSchema', () => { + const result = searchVariableThroughRecordOutputSchema({ + stepName: 'Test Step', + recordOutputSchema: undefined as any, + rawVariableName: '{{step1.field}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: undefined, + variablePathLabel: undefined, + }); + }); + + it('should handle non-existent field', () => { + const result = searchVariableThroughRecordOutputSchema({ + stepName: 'Create Company', + recordOutputSchema: mockRecordSchema, + rawVariableName: '{{step1.nonExistentField}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: undefined, + variablePathLabel: undefined, + variableType: undefined, + }); + }); + + it('should handle broken nested path', () => { + const result = searchVariableThroughRecordOutputSchema({ + stepName: 'Create Company', + recordOutputSchema: mockRecordSchema, + rawVariableName: '{{step1.address.nonExistent}}', + isFullRecord: false, + }); + + expect(result).toEqual({ + variableLabel: undefined, + variablePathLabel: undefined, + variableType: 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 new file mode 100644 index 0000000000..6007d64aeb --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/getOutputSchemaType.ts @@ -0,0 +1,20 @@ +import { + type WorkflowActionType, + type WorkflowTriggerType, +} from '@/workflow/types/Workflow'; + +export const getOutputSchemaType = ( + stepType: WorkflowActionType | WorkflowTriggerType, +): 'RECORD' | 'DATABASE_EVENT' | 'BASE' => { + switch (stepType) { + case 'CREATE_RECORD': + case 'UPDATE_RECORD': + case 'DELETE_RECORD': + case 'MANUAL': + return 'RECORD'; + case 'DATABASE_EVENT': + return 'DATABASE_EVENT'; + default: + return 'BASE'; + } +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/isRecordOutputSchemaV2.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/isRecordOutputSchemaV2.ts new file mode 100644 index 0000000000..54b3ab9533 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/isRecordOutputSchemaV2.ts @@ -0,0 +1,10 @@ +import { + type RecordFieldLeaf, + type RecordOutputSchemaV2, +} from '@/workflow/workflow-variables/types/RecordOutputSchemaV2'; + +export const isRecordOutputSchemaV2 = ( + outputSchema: RecordOutputSchemaV2 | Record, +): outputSchema is RecordOutputSchemaV2 => { + return outputSchema._outputSchemaType === 'RECORD'; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughOutputSchema.ts index 7030b529e0..5df8d9c021 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughOutputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughOutputSchema.ts @@ -1,4 +1,5 @@ import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from '@/workflow/workflow-variables/constants/CaptureAllVariableTagInnerRegex'; +import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable'; import { type OutputSchema, type StepOutputSchema, @@ -8,14 +9,6 @@ import { isLinkOutputSchema } from '@/workflow/workflow-variables/utils/isLinkOu import { isRecordOutputSchema } from '@/workflow/workflow-variables/utils/isRecordOutputSchema'; import { isDefined } from 'twenty-shared/utils'; -type VariableInfo = { - variableLabel: string | undefined; - variablePathLabel: string | undefined; - variableType?: string | undefined; - fieldMetadataId?: string | undefined; - compositeFieldSubFieldName?: string | undefined; -}; - const getDisplayedSubStepObjectLabel = (outputSchema: OutputSchema) => { if (!isRecordOutputSchema(outputSchema)) { return; @@ -83,7 +76,7 @@ const searchCurrentStepOutputSchema = ({ path: string[]; isFullRecord: boolean; selectedField: string; -}) => { +}): VariableSearchResult => { let currentSubStep = stepOutputSchema.outputSchema; let nextKeyIndex = 0; let nextKey = path[nextKeyIndex]; @@ -169,7 +162,7 @@ export const searchVariableThroughOutputSchema = ({ stepOutputSchema: StepOutputSchema; rawVariableName: string; isFullRecord?: boolean; -}): VariableInfo => { +}): VariableSearchResult => { if (!isDefined(stepOutputSchema)) { return { variableLabel: undefined, diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordEventOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordEventOutputSchema.ts new file mode 100644 index 0000000000..bb5e15bfc3 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordEventOutputSchema.ts @@ -0,0 +1,76 @@ +import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from '@/workflow/workflow-variables/constants/CaptureAllVariableTagInnerRegex'; +import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable'; +import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2'; +import { searchRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema'; +import { isDefined } from 'twenty-shared/utils'; + +/** + * Parses a variable name to extract its components + * Example: "{{step1.properties.after.user.name}}" -> { stepId: "step1", eventPrefix: "properties.after", pathSegments: ["user"], fieldName: "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); + // after stepId, we have a prefix (properties.after or properties.before). Path segments are the rest of the string + // join the first 3 parts to get the event prefix + const firstFieldWithEventPrefix = parts.slice(1, 4).join('.'); + const remainingParts = parts.slice(4); + const partsWithoutStepId = [firstFieldWithEventPrefix, ...remainingParts]; + + return { + stepId, + fieldName: partsWithoutStepId.at(-1), + pathSegments: partsWithoutStepId.slice(0, -1), + }; +}; + +/** + * Searches for a variable within a record output schema and returns its metadata + * + * @param stepName - Display name of the workflow step + * @param recordOutputSchema - The schema to search within + * @param rawVariableName - Variable name like "{{step1.user.name}}" or "step1.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 searchVariableThroughRecordEventOutputSchema = ({ + stepName, + recordOutputSchema, + rawVariableName, + isFullRecord = false, +}: { + stepName: string; + recordOutputSchema: RecordOutputSchemaV2; + rawVariableName: string; + isFullRecord?: boolean; +}): VariableSearchResult => { + if (!isDefined(recordOutputSchema)) { + return { + variableLabel: undefined, + variablePathLabel: undefined, + }; + } + + const { stepId, fieldName, pathSegments } = + parseVariableName(rawVariableName); + + if (!isDefined(stepId) || !isDefined(fieldName)) { + return { + variableLabel: undefined, + variablePathLabel: undefined, + }; + } + + return searchRecordOutputSchema({ + stepName, + recordOutputSchema, + selectedField: fieldName, + path: pathSegments, + isFullRecord, + }); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema.ts new file mode 100644 index 0000000000..0d8bf04cf9 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema.ts @@ -0,0 +1,199 @@ +import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from '@/workflow/workflow-variables/constants/CaptureAllVariableTagInnerRegex'; +import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable'; +import { + type FieldOutputSchemaV2, + type RecordFieldNodeValue, + type RecordOutputSchemaV2, +} from '@/workflow/workflow-variables/types/RecordOutputSchemaV2'; +import { isRecordOutputSchemaV2 } from '@/workflow/workflow-variables/utils/isRecordOutputSchemaV2'; +import { isDefined } from 'twenty-shared/utils'; + +const getRecordObjectLabel = ( + recordSchema: RecordOutputSchemaV2, +): string | undefined => { + return recordSchema.object.label; +}; + +const getFieldFromSchema = ( + fieldKey: string, + recordSchema: RecordFieldNodeValue, +): FieldOutputSchemaV2 | undefined => { + return isRecordOutputSchemaV2(recordSchema) + ? recordSchema.fields[fieldKey] + : recordSchema[fieldKey]; +}; + +const getCompositeSubFieldName = ( + recordSchema: RecordFieldNodeValue, + fieldKey: string, +): string | undefined => { + return isRecordOutputSchemaV2(recordSchema) + ? undefined + : recordSchema[fieldKey]?.isCompositeSubField + ? fieldKey + : undefined; +}; + +const navigateToTargetField = ( + startingSchema: RecordOutputSchemaV2, + pathSegments: string[], +): { schema: RecordFieldNodeValue; pathLabels: string[] } | null => { + let currentSchema: RecordFieldNodeValue = startingSchema; + const pathLabels: string[] = []; + + for (const pathSegment of pathSegments) { + const field = getFieldFromSchema(pathSegment, currentSchema); + + if (!isDefined(field)) { + return null; // Path not found + } + + if (isDefined(field.label)) { + pathLabels.push(field.label); + } + + const nextSchema = field.value; + if (!isDefined(nextSchema)) { + return null; // Dead end in path + } + + currentSchema = nextSchema; + } + + return { schema: currentSchema, pathLabels }; +}; + +const buildVariableResult = ( + stepName: string, + pathLabels: string[], + targetSchema: RecordFieldNodeValue, + targetFieldName: string, + isFullRecord: boolean, +): VariableSearchResult => { + const targetField = getFieldFromSchema(targetFieldName, targetSchema); + // Determine the variable label based on whether we want the full record or a specific field + const variableLabel = + isFullRecord && isRecordOutputSchemaV2(targetSchema) + ? getRecordObjectLabel(targetSchema) + : targetField?.label; + + if (!variableLabel) { + return { + variableLabel: undefined, + variablePathLabel: undefined, + variableType: undefined, + }; + } + + // Build the full path: stepName > field1 > field2 > targetField + const fullPathSegments = [stepName, ...pathLabels, variableLabel]; + const variablePathLabel = fullPathSegments.join(' > '); + + return { + variableLabel, + variablePathLabel, + variableType: targetField?.type, + fieldMetadataId: targetField?.fieldMetadataId, + compositeFieldSubFieldName: getCompositeSubFieldName( + targetSchema, + targetFieldName, + ), + }; +}; + +export const searchRecordOutputSchema = ({ + stepName, + recordOutputSchema, + path, + selectedField, + isFullRecord, +}: { + stepName: string; + recordOutputSchema: RecordOutputSchemaV2; + path: string[]; + selectedField: string; + isFullRecord: boolean; +}): VariableSearchResult => { + const navigationResult = navigateToTargetField(recordOutputSchema, path); + + if (!navigationResult) { + return { + variableLabel: undefined, + variablePathLabel: undefined, + variableType: undefined, + }; + } + + return buildVariableResult( + stepName, + navigationResult.pathLabels, + navigationResult.schema, + selectedField, + isFullRecord, + ); +}; + +/** + * Parses a variable name to extract its components + * Example: "{{step1.user.name}}" -> { stepId: "step1", pathSegments: ["user"], fieldName: "name" } + */ +const parseVariableName = (rawVariableName: string) => { + const variableWithoutBrackets = rawVariableName.replace( + CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, + (_, variableName) => variableName, + ); + + const parts = variableWithoutBrackets.split('.'); + + return { + stepId: parts.at(0), + fieldName: parts.at(-1), + pathSegments: parts.slice(1, -1), // Everything between stepId and fieldName + }; +}; + +/** + * Searches for a variable within a record output schema and returns its metadata + * + * @param stepName - Display name of the workflow step + * @param recordOutputSchema - The schema to search within + * @param rawVariableName - Variable name like "{{step1.user.name}}" or "step1.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 searchVariableThroughRecordOutputSchema = ({ + stepName, + recordOutputSchema, + rawVariableName, + isFullRecord = false, +}: { + stepName: string; + recordOutputSchema: RecordOutputSchemaV2; + rawVariableName: string; + isFullRecord?: boolean; +}): VariableSearchResult => { + if (!isDefined(recordOutputSchema)) { + return { + variableLabel: undefined, + variablePathLabel: undefined, + }; + } + + const { stepId, fieldName, pathSegments } = + parseVariableName(rawVariableName); + + if (!isDefined(stepId) || !isDefined(fieldName)) { + return { + variableLabel: undefined, + variablePathLabel: undefined, + }; + } + + return searchRecordOutputSchema({ + stepName, + recordOutputSchema, + selectedField: fieldName, + path: pathSegments, + isFullRecord, + }); +};