From 4ecd275f5e6eb42d27a66b1b084c964760efb5c0 Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Fri, 22 Aug 2025 12:20:07 +0200 Subject: [PATCH] Add separated types + build a search for records (#14014) The code to search variables became too complex. This is mainly because it tries to handle all use cases at once: - steps returning records (MANUAL TRIGGER, CREATE RECORD...) - steps returning record events - primitive types (code, webhook...) - forms To make the code maintainable, let's make a few updates: - clear schema output types for each steps - group the output types that are similar and implement a search schema function for each - once this is implemented on frontend, let's remove the logic on backend side. Schema should be recompute and not stored This PR implement the refacto for steps returning records and events. --- .../ui/form-types/components/VariableChip.tsx | 33 +- .../hooks/useSearchVariable.ts | 76 +++ .../types/RecordOutputSchemaV2.ts | 34 ++ ...ableThroughRecordEventOutputSchema.test.ts | 492 ++++++++++++++++++ ...hVariableThroughRecordOutputSchema.test.ts | 203 ++++++++ .../utils/getOutputSchemaType.ts | 20 + .../utils/isRecordOutputSchemaV2.ts | 10 + .../searchVariableThroughOutputSchema.ts | 13 +- ...hVariableThroughRecordEventOutputSchema.ts | 76 +++ ...searchVariableThroughRecordOutputSchema.ts | 199 +++++++ 10 files changed, 1121 insertions(+), 35 deletions(-) create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/hooks/useSearchVariable.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/types/RecordOutputSchemaV2.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughRecordEventOutputSchema.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/searchVariableThroughRecordOutputSchema.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/utils/getOutputSchemaType.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/utils/isRecordOutputSchemaV2.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordEventOutputSchema.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema.ts 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, + }); +};