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.
This commit is contained in:
Thomas Trompette
2025-08-22 12:20:07 +02:00
committed by GitHub
parent 91ce3d4e0c
commit 4ecd275f5e
10 changed files with 1121 additions and 35 deletions
@@ -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;
@@ -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,
});
};
@@ -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<string, RecordFieldLeaf>;
export type FieldOutputSchemaV2 = RecordFieldLeaf | RecordFieldNode;
export type RecordOutputSchemaV2 = {
object: {
label: string;
objectMetadataId: string;
isRelationField?: boolean;
};
fields: Record<string, FieldOutputSchemaV2>;
_outputSchemaType: 'RECORD';
};
@@ -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,
});
});
});
});
@@ -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,
});
});
});
});
@@ -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';
}
};
@@ -0,0 +1,10 @@
import {
type RecordFieldLeaf,
type RecordOutputSchemaV2,
} from '@/workflow/workflow-variables/types/RecordOutputSchemaV2';
export const isRecordOutputSchemaV2 = (
outputSchema: RecordOutputSchemaV2 | Record<string, RecordFieldLeaf>,
): outputSchema is RecordOutputSchemaV2 => {
return outputSchema._outputSchemaType === 'RECORD';
};
@@ -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,
@@ -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,
});
};
@@ -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,
});
};