feat(workflow) - Add validation layer (#21422)
Add workflow validation framework and consolidate output schema types/search logic into twenty-shared This PR introduces a comprehensive workflow validation system that catches configuration errors at build-time, and consolidates the fragmented output-schema type definitions and variable-search logic from the front-end into twenty-shared **Workflow validation** — A new system that checks workflows for errors before activation: graph connectivity (unreachable steps, dangling references), step parameter schemas (via Zod), variable references (typos, wrong step order), and workspace metadata (non-existent objects). Returns structured errors/warnings with "did you mean?" suggestions. Runs automatically after create_complete_workflow and update_workflow_version_step, and is also available as a standalone validate_workflow tool. **Output schema consolidation** — Moves all output schema types and the variable-search logic from scattered front-end files into twenty-shared, replacing ~800 lines of duplicated per-schema-type code with a single unified searchVariableInOutputSchema dispatcher. To do : - validation on CODE and AGENT step --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+180
@@ -0,0 +1,180 @@
|
||||
import { computeCursorArgFilter } from '@/object-record/graphql/utils/computeCursorArgFilter';
|
||||
import { type RecordGqlOperationOrderBy } from 'twenty-shared/types';
|
||||
|
||||
describe('computeCursorArgFilter', () => {
|
||||
it('should append an id tie-breaker when ordering does not include id', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [{ createdAt: 'AscNullsFirst' }];
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { createdAt: '2024-01-01', id: 'record-1' },
|
||||
isForwardPagination: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
or: [
|
||||
{ createdAt: { gt: '2024-01-01' } },
|
||||
{
|
||||
and: [
|
||||
{ createdAt: { eq: '2024-01-01' } },
|
||||
{ id: { gt: 'record-1' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not append an id field when it is already part of the ordering', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [{ id: 'AscNullsFirst' }];
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { id: 'record-1' },
|
||||
isForwardPagination: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ or: [{ id: { gt: 'record-1' } }] });
|
||||
});
|
||||
|
||||
it('should use lt operator for ascending order with backward pagination', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [{ id: 'AscNullsLast' }];
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { id: 'record-1' },
|
||||
isForwardPagination: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ or: [{ id: { lt: 'record-1' } }] });
|
||||
});
|
||||
|
||||
it('should use lt operator for descending order with forward pagination', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [{ id: 'DescNullsFirst' }];
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { id: 'record-1' },
|
||||
isForwardPagination: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ or: [{ id: { lt: 'record-1' } }] });
|
||||
});
|
||||
|
||||
it('should use gt operator for descending order with backward pagination', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [{ id: 'DescNullsLast' }];
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { id: 'record-1' },
|
||||
isForwardPagination: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ or: [{ id: { gt: 'record-1' } }] });
|
||||
});
|
||||
|
||||
it('should resolve nested composite sub-fields and read their cursor value', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [
|
||||
{ name: { firstName: 'AscNullsFirst' } },
|
||||
];
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { name: { firstName: 'John' }, id: 'record-1' },
|
||||
isForwardPagination: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
or: [
|
||||
{ name: { firstName: { gt: 'John' } } },
|
||||
{
|
||||
and: [
|
||||
{ name: { firstName: { eq: 'John' } } },
|
||||
{ id: { gt: 'record-1' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should fall back to undefined cursor value for missing composite parent', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [
|
||||
{ name: { firstName: 'AscNullsFirst' } },
|
||||
];
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { id: 'record-1' },
|
||||
isForwardPagination: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
or: [
|
||||
{ name: { firstName: { gt: undefined } } },
|
||||
{
|
||||
and: [
|
||||
{ name: { firstName: { eq: undefined } } },
|
||||
{ id: { gt: 'record-1' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore nested values that are not order-by directions', () => {
|
||||
const orderBy = [
|
||||
{ name: { firstName: 'AscNullsFirst', metadata: 'not-a-direction' } },
|
||||
] as unknown as RecordGqlOperationOrderBy;
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { name: { firstName: 'John' }, id: 'record-1' },
|
||||
isForwardPagination: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
or: [
|
||||
{ name: { firstName: { gt: 'John' } } },
|
||||
{
|
||||
and: [
|
||||
{ name: { firstName: { eq: 'John' } } },
|
||||
{ id: { gt: 'record-1' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should build cumulative equality prefixes across multiple fields', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [
|
||||
{ score: 'DescNullsLast' },
|
||||
{ id: 'AscNullsFirst' },
|
||||
];
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { score: 42, id: 'record-1' },
|
||||
isForwardPagination: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
or: [
|
||||
{ score: { lt: 42 } },
|
||||
{
|
||||
and: [{ score: { eq: 42 } }, { id: { gt: 'record-1' } }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should fall back to the id tie-breaker when there are no order-by fields', () => {
|
||||
const orderBy = [{}] as unknown as RecordGqlOperationOrderBy;
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { id: 'record-1' },
|
||||
isForwardPagination: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ or: [{ id: { gt: 'record-1' } }] });
|
||||
});
|
||||
});
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { extractOrderByFieldNames } from '@/object-record/graphql/utils/extractOrderByFieldNames';
|
||||
import { type RecordGqlOperationOrderBy } from 'twenty-shared/types';
|
||||
|
||||
describe('extractOrderByFieldNames', () => {
|
||||
it('should always include the id field', () => {
|
||||
expect(extractOrderByFieldNames([])).toEqual({ id: true });
|
||||
});
|
||||
|
||||
it('should extract top-level field names', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [
|
||||
{ createdAt: 'AscNullsFirst' },
|
||||
{ name: 'DescNullsLast' },
|
||||
];
|
||||
|
||||
expect(extractOrderByFieldNames(orderBy)).toEqual({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
name: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract nested composite sub-field names', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [
|
||||
{ name: { firstName: 'AscNullsFirst', lastName: 'DescNullsLast' } },
|
||||
];
|
||||
|
||||
expect(extractOrderByFieldNames(orderBy)).toEqual({
|
||||
id: true,
|
||||
name: { firstName: true, lastName: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore nested values that are not directions', () => {
|
||||
const orderBy = [
|
||||
{ name: { firstName: 'AscNullsFirst', meta: 'Unknown' } },
|
||||
] as unknown as RecordGqlOperationOrderBy;
|
||||
|
||||
expect(extractOrderByFieldNames(orderBy)).toEqual({
|
||||
id: true,
|
||||
name: { firstName: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { isOrderByDirection } from '@/object-record/graphql/utils/isOrderByDirection';
|
||||
|
||||
describe('isOrderByDirection', () => {
|
||||
it('should return true for valid order by directions', () => {
|
||||
expect(isOrderByDirection('AscNullsFirst')).toBe(true);
|
||||
expect(isOrderByDirection('AscNullsLast')).toBe(true);
|
||||
expect(isOrderByDirection('DescNullsFirst')).toBe(true);
|
||||
expect(isOrderByDirection('DescNullsLast')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for unknown strings', () => {
|
||||
expect(isOrderByDirection('Asc')).toBe(false);
|
||||
expect(isOrderByDirection('random')).toBe(false);
|
||||
expect(isOrderByDirection('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-string values', () => {
|
||||
expect(isOrderByDirection(undefined)).toBe(false);
|
||||
expect(isOrderByDirection(null)).toBe(false);
|
||||
expect(isOrderByDirection(42)).toBe(false);
|
||||
expect(isOrderByDirection({ foo: 'AscNullsFirst' })).toBe(false);
|
||||
});
|
||||
});
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { reverseOrderBy } from '@/object-record/graphql/utils/reverseOrderBy';
|
||||
import { type RecordGqlOperationOrderBy } from 'twenty-shared/types';
|
||||
|
||||
describe('reverseOrderBy', () => {
|
||||
it('should reverse top-level directions', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [
|
||||
{ createdAt: 'AscNullsFirst' },
|
||||
{ name: 'DescNullsLast' },
|
||||
];
|
||||
|
||||
expect(reverseOrderBy(orderBy)).toEqual([
|
||||
{ createdAt: 'DescNullsLast' },
|
||||
{ name: 'AscNullsFirst' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should reverse all direction variants', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [
|
||||
{ a: 'AscNullsFirst' },
|
||||
{ b: 'AscNullsLast' },
|
||||
{ c: 'DescNullsFirst' },
|
||||
{ d: 'DescNullsLast' },
|
||||
];
|
||||
|
||||
expect(reverseOrderBy(orderBy)).toEqual([
|
||||
{ a: 'DescNullsLast' },
|
||||
{ b: 'DescNullsFirst' },
|
||||
{ c: 'AscNullsLast' },
|
||||
{ d: 'AscNullsFirst' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should reverse nested composite field directions', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [
|
||||
{ name: { firstName: 'AscNullsFirst', lastName: 'DescNullsLast' } },
|
||||
];
|
||||
|
||||
expect(reverseOrderBy(orderBy)).toEqual([
|
||||
{ name: { firstName: 'DescNullsLast', lastName: 'AscNullsFirst' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should leave unknown values untouched', () => {
|
||||
const orderBy = [
|
||||
{ name: 'Unknown' },
|
||||
] as unknown as RecordGqlOperationOrderBy;
|
||||
|
||||
expect(reverseOrderBy(orderBy)).toEqual([{ name: 'Unknown' }]);
|
||||
});
|
||||
});
|
||||
+4
-9
@@ -4,15 +4,10 @@ import { useWorkflowVersionIdOrThrow } from '@/workflow/hooks/useWorkflowVersion
|
||||
import { stepsOutputSchemaFamilySelector } from '@/workflow/states/selectors/stepsOutputSchemaFamilySelector';
|
||||
import { searchVariableThroughOutputSchemaV2 } from '@/workflow/workflow-variables/utils/searchVariableThroughOutputSchemaV2';
|
||||
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;
|
||||
};
|
||||
import {
|
||||
TRIGGER_STEP_ID,
|
||||
type VariableSearchResult,
|
||||
} from 'twenty-shared/workflow';
|
||||
|
||||
export const useSearchVariable = ({
|
||||
stepId,
|
||||
|
||||
-331
@@ -1,331 +0,0 @@
|
||||
import { searchVariableThroughBaseOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema';
|
||||
import type { BaseOutputSchemaV2 } from 'twenty-shared/workflow';
|
||||
|
||||
describe('searchVariableThroughBaseOutputSchema', () => {
|
||||
const mockBaseSchema: BaseOutputSchemaV2 = {
|
||||
message: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'Message',
|
||||
value: 'Hello World',
|
||||
},
|
||||
count: {
|
||||
isLeaf: true,
|
||||
type: 'number',
|
||||
label: 'Count',
|
||||
value: 42,
|
||||
},
|
||||
isSuccess: {
|
||||
isLeaf: true,
|
||||
type: 'boolean',
|
||||
label: 'Success Status',
|
||||
value: true,
|
||||
},
|
||||
items: {
|
||||
isLeaf: true,
|
||||
type: 'array',
|
||||
label: 'Items List',
|
||||
value: ['item1', 'item2', 'item3'],
|
||||
},
|
||||
user: {
|
||||
isLeaf: false,
|
||||
type: 'object',
|
||||
label: 'User Information',
|
||||
value: {
|
||||
name: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'Full Name',
|
||||
value: 'John Doe',
|
||||
},
|
||||
age: {
|
||||
isLeaf: true,
|
||||
type: 'number',
|
||||
label: 'Age',
|
||||
value: 30,
|
||||
},
|
||||
profile: {
|
||||
isLeaf: false,
|
||||
type: 'object',
|
||||
label: 'Profile',
|
||||
value: {
|
||||
email: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'Email Address',
|
||||
value: 'john@example.com',
|
||||
},
|
||||
isActive: {
|
||||
isLeaf: true,
|
||||
type: 'boolean',
|
||||
label: 'Is Active',
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
config: {
|
||||
isLeaf: false,
|
||||
type: 'object',
|
||||
label: 'Configuration',
|
||||
value: {
|
||||
timeout: {
|
||||
isLeaf: true,
|
||||
type: 'number',
|
||||
label: 'Timeout (ms)',
|
||||
value: 5000,
|
||||
},
|
||||
retries: {
|
||||
isLeaf: true,
|
||||
type: 'number',
|
||||
label: 'Retry Count',
|
||||
value: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('should handle simple string field access correctly', () => {
|
||||
const result = searchVariableThroughBaseOutputSchema({
|
||||
stepName: 'HTTP Request',
|
||||
baseOutputSchema: mockBaseSchema,
|
||||
rawVariableName: '{{step1.message}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Message',
|
||||
variablePathLabel: 'HTTP Request > Message',
|
||||
variableType: 'string',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle simple number field access correctly', () => {
|
||||
const result = searchVariableThroughBaseOutputSchema({
|
||||
stepName: 'HTTP Request',
|
||||
baseOutputSchema: mockBaseSchema,
|
||||
rawVariableName: '{{step1.count}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Count',
|
||||
variablePathLabel: 'HTTP Request > Count',
|
||||
variableType: 'number',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle simple boolean field access correctly', () => {
|
||||
const result = searchVariableThroughBaseOutputSchema({
|
||||
stepName: 'HTTP Request',
|
||||
baseOutputSchema: mockBaseSchema,
|
||||
rawVariableName: '{{step1.isSuccess}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Success Status',
|
||||
variablePathLabel: 'HTTP Request > Success Status',
|
||||
variableType: 'boolean',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle array field access correctly', () => {
|
||||
const result = searchVariableThroughBaseOutputSchema({
|
||||
stepName: 'HTTP Request',
|
||||
baseOutputSchema: mockBaseSchema,
|
||||
rawVariableName: '{{step1.items}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Items List',
|
||||
variablePathLabel: 'HTTP Request > Items List',
|
||||
variableType: 'array',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle nested object field access correctly', () => {
|
||||
const result = searchVariableThroughBaseOutputSchema({
|
||||
stepName: 'HTTP Request',
|
||||
baseOutputSchema: mockBaseSchema,
|
||||
rawVariableName: '{{step1.user.name}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Full Name',
|
||||
variablePathLabel: 'HTTP Request > User Information > Full Name',
|
||||
variableType: 'string',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle deeply nested object field access correctly', () => {
|
||||
const result = searchVariableThroughBaseOutputSchema({
|
||||
stepName: 'HTTP Request',
|
||||
baseOutputSchema: mockBaseSchema,
|
||||
rawVariableName: '{{step1.user.profile.email}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Email Address',
|
||||
variablePathLabel:
|
||||
'HTTP Request > User Information > Profile > Email Address',
|
||||
variableType: 'string',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle deeply nested boolean field access correctly', () => {
|
||||
const result = searchVariableThroughBaseOutputSchema({
|
||||
stepName: 'HTTP Request',
|
||||
baseOutputSchema: mockBaseSchema,
|
||||
rawVariableName: '{{step1.user.profile.isActive}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Is Active',
|
||||
variablePathLabel:
|
||||
'HTTP Request > User Information > Profile > Is Active',
|
||||
variableType: 'boolean',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle config object field access correctly', () => {
|
||||
const result = searchVariableThroughBaseOutputSchema({
|
||||
stepName: 'Code Action',
|
||||
baseOutputSchema: mockBaseSchema,
|
||||
rawVariableName: '{{step1.config.timeout}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Timeout (ms)',
|
||||
variablePathLabel: 'Code Action > Configuration > Timeout (ms)',
|
||||
variableType: 'number',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined for invalid field name', () => {
|
||||
const result = searchVariableThroughBaseOutputSchema({
|
||||
stepName: 'HTTP Request',
|
||||
baseOutputSchema: mockBaseSchema,
|
||||
rawVariableName: '{{step1.invalidField}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined for invalid nested field name', () => {
|
||||
const result = searchVariableThroughBaseOutputSchema({
|
||||
stepName: 'HTTP Request',
|
||||
baseOutputSchema: mockBaseSchema,
|
||||
rawVariableName: '{{step1.user.invalidField}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined for deeply invalid nested field name', () => {
|
||||
const result = searchVariableThroughBaseOutputSchema({
|
||||
stepName: 'HTTP Request',
|
||||
baseOutputSchema: mockBaseSchema,
|
||||
rawVariableName: '{{step1.user.profile.invalidField}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined when trying to access nested field on a leaf field', () => {
|
||||
const result = searchVariableThroughBaseOutputSchema({
|
||||
stepName: 'HTTP Request',
|
||||
baseOutputSchema: mockBaseSchema,
|
||||
rawVariableName: '{{step1.message.nestedField}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined when baseOutputSchema is undefined', () => {
|
||||
const result = searchVariableThroughBaseOutputSchema({
|
||||
stepName: 'HTTP Request',
|
||||
baseOutputSchema: undefined as any,
|
||||
rawVariableName: '{{step1.message}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined when stepId or fieldName is undefined', () => {
|
||||
const result = searchVariableThroughBaseOutputSchema({
|
||||
stepName: 'HTTP Request',
|
||||
baseOutputSchema: mockBaseSchema,
|
||||
rawVariableName: '{{}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle variables without curly braces', () => {
|
||||
const result = searchVariableThroughBaseOutputSchema({
|
||||
stepName: 'HTTP Request',
|
||||
baseOutputSchema: mockBaseSchema,
|
||||
rawVariableName: 'step1.message',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Message',
|
||||
variablePathLabel: 'HTTP Request > Message',
|
||||
variableType: 'string',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle object field access without target field (should return object info)', () => {
|
||||
const result = searchVariableThroughBaseOutputSchema({
|
||||
stepName: 'Code Action',
|
||||
baseOutputSchema: mockBaseSchema,
|
||||
rawVariableName: '{{step1.user}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'User Information',
|
||||
variablePathLabel: 'Code Action > User Information',
|
||||
variableType: 'object',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle unknown type field correctly', () => {
|
||||
const schemaWithUnknown: BaseOutputSchemaV2 = {
|
||||
unknownField: {
|
||||
isLeaf: true,
|
||||
type: 'unknown',
|
||||
label: 'Unknown Data',
|
||||
value: null,
|
||||
},
|
||||
};
|
||||
|
||||
const result = searchVariableThroughBaseOutputSchema({
|
||||
stepName: 'AI Agent',
|
||||
baseOutputSchema: schemaWithUnknown,
|
||||
rawVariableName: '{{step1.unknownField}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Unknown Data',
|
||||
variablePathLabel: 'AI Agent > Unknown Data',
|
||||
variableType: 'unknown',
|
||||
});
|
||||
});
|
||||
});
|
||||
-261
@@ -1,261 +0,0 @@
|
||||
import type { CodeOutputSchema } from '@/workflow/workflow-variables/types/CodeOutputSchema';
|
||||
import { searchVariableThroughCodeOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughCodeOutputSchema';
|
||||
import type { BaseOutputSchemaV2 } from 'twenty-shared/workflow';
|
||||
|
||||
describe('searchVariableThroughCodeOutputSchema', () => {
|
||||
describe('LinkOutputSchema tests', () => {
|
||||
const mockLinkSchema: CodeOutputSchema = {
|
||||
link: {
|
||||
isLeaf: true,
|
||||
tab: 'main',
|
||||
label: 'External Link',
|
||||
},
|
||||
_outputSchemaType: 'LINK',
|
||||
};
|
||||
|
||||
it('should return undefined', () => {
|
||||
const result = searchVariableThroughCodeOutputSchema({
|
||||
stepName: 'Code Step',
|
||||
codeOutputSchema: mockLinkSchema,
|
||||
rawVariableName: '{{step1.link}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('BaseOutputSchemaV2 tests', () => {
|
||||
const mockBaseSchema: BaseOutputSchemaV2 = {
|
||||
message: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'Response Message',
|
||||
value: 'Success',
|
||||
},
|
||||
data: {
|
||||
isLeaf: false,
|
||||
type: 'object',
|
||||
label: 'Response Data',
|
||||
value: {
|
||||
userId: {
|
||||
isLeaf: true,
|
||||
type: 'number',
|
||||
label: 'User ID',
|
||||
value: 123,
|
||||
},
|
||||
profile: {
|
||||
isLeaf: false,
|
||||
type: 'object',
|
||||
label: 'User Profile',
|
||||
value: {
|
||||
name: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'Full Name',
|
||||
value: 'John Doe',
|
||||
},
|
||||
email: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'Email Address',
|
||||
value: 'john@example.com',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
count: {
|
||||
isLeaf: true,
|
||||
type: 'number',
|
||||
label: 'Item Count',
|
||||
value: 42,
|
||||
},
|
||||
isEnabled: {
|
||||
isLeaf: true,
|
||||
type: 'boolean',
|
||||
label: 'Is Enabled',
|
||||
value: true,
|
||||
},
|
||||
};
|
||||
|
||||
it('should handle simple string field access correctly', () => {
|
||||
const result = searchVariableThroughCodeOutputSchema({
|
||||
stepName: 'Code Action',
|
||||
codeOutputSchema: mockBaseSchema,
|
||||
rawVariableName: '{{step1.message}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Response Message',
|
||||
variablePathLabel: 'Code Action > Response Message',
|
||||
variableType: 'string',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle simple number field access correctly', () => {
|
||||
const result = searchVariableThroughCodeOutputSchema({
|
||||
stepName: 'Code Action',
|
||||
codeOutputSchema: mockBaseSchema,
|
||||
rawVariableName: '{{step1.count}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Item Count',
|
||||
variablePathLabel: 'Code Action > Item Count',
|
||||
variableType: 'number',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle simple boolean field access correctly', () => {
|
||||
const result = searchVariableThroughCodeOutputSchema({
|
||||
stepName: 'Code Action',
|
||||
codeOutputSchema: mockBaseSchema,
|
||||
rawVariableName: '{{step1.isEnabled}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Is Enabled',
|
||||
variablePathLabel: 'Code Action > Is Enabled',
|
||||
variableType: 'boolean',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle nested object field access correctly', () => {
|
||||
const result = searchVariableThroughCodeOutputSchema({
|
||||
stepName: 'Code Action',
|
||||
codeOutputSchema: mockBaseSchema,
|
||||
rawVariableName: '{{step1.data.userId}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'User ID',
|
||||
variablePathLabel: 'Code Action > Response Data > User ID',
|
||||
variableType: 'number',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle deeply nested object field access correctly', () => {
|
||||
const result = searchVariableThroughCodeOutputSchema({
|
||||
stepName: 'Code Action',
|
||||
codeOutputSchema: mockBaseSchema,
|
||||
rawVariableName: '{{step1.data.profile.name}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Full Name',
|
||||
variablePathLabel:
|
||||
'Code Action > Response Data > User Profile > Full Name',
|
||||
variableType: 'string',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle deeply nested email field access correctly', () => {
|
||||
const result = searchVariableThroughCodeOutputSchema({
|
||||
stepName: 'Code Action',
|
||||
codeOutputSchema: mockBaseSchema,
|
||||
rawVariableName: '{{step1.data.profile.email}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Email Address',
|
||||
variablePathLabel:
|
||||
'Code Action > Response Data > User Profile > Email Address',
|
||||
variableType: 'string',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle object field access (returns object info)', () => {
|
||||
const result = searchVariableThroughCodeOutputSchema({
|
||||
stepName: 'Code Action',
|
||||
codeOutputSchema: mockBaseSchema,
|
||||
rawVariableName: '{{step1.data}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Response Data',
|
||||
variablePathLabel: 'Code Action > Response Data',
|
||||
variableType: 'object',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined for invalid field name', () => {
|
||||
const result = searchVariableThroughCodeOutputSchema({
|
||||
stepName: 'Code Action',
|
||||
codeOutputSchema: mockBaseSchema,
|
||||
rawVariableName: '{{step1.invalidField}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined for invalid nested field name', () => {
|
||||
const result = searchVariableThroughCodeOutputSchema({
|
||||
stepName: 'Code Action',
|
||||
codeOutputSchema: mockBaseSchema,
|
||||
rawVariableName: '{{step1.data.invalidField}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
const mockBaseSchema: BaseOutputSchemaV2 = {
|
||||
simpleField: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'Simple Field',
|
||||
value: 'test',
|
||||
},
|
||||
};
|
||||
|
||||
it('should return undefined when codeOutputSchema is undefined', () => {
|
||||
const result = searchVariableThroughCodeOutputSchema({
|
||||
stepName: 'Code Action',
|
||||
codeOutputSchema: undefined as any,
|
||||
rawVariableName: '{{step1.message}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined when stepId or fieldName is undefined', () => {
|
||||
const result = searchVariableThroughCodeOutputSchema({
|
||||
stepName: 'Code Action',
|
||||
codeOutputSchema: mockBaseSchema,
|
||||
rawVariableName: '{{}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle variables without curly braces', () => {
|
||||
const result = searchVariableThroughCodeOutputSchema({
|
||||
stepName: 'Code Action',
|
||||
codeOutputSchema: mockBaseSchema,
|
||||
rawVariableName: 'step1.simpleField',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Simple Field',
|
||||
variablePathLabel: 'Code Action > Simple Field',
|
||||
variableType: 'string',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
-155
@@ -1,155 +0,0 @@
|
||||
import { type FindRecordsOutputSchema } from '@/workflow/workflow-variables/types/FindRecordsOutputSchema';
|
||||
import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2';
|
||||
import { searchVariableThroughFindRecordsOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughFindRecordsOutputSchema';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
describe('searchVariableThroughFindRecordsOutputSchema', () => {
|
||||
const mockRecordSchema: RecordOutputSchemaV2 = {
|
||||
object: {
|
||||
objectMetadataId: 'company-metadata-id',
|
||||
label: 'Company',
|
||||
},
|
||||
fields: {
|
||||
name: {
|
||||
isLeaf: true,
|
||||
type: FieldMetadataType.TEXT,
|
||||
label: 'Company Name',
|
||||
value: 'Acme Corp',
|
||||
fieldMetadataId: 'company-name-metadata-id',
|
||||
isCompositeSubField: false,
|
||||
},
|
||||
revenue: {
|
||||
isLeaf: true,
|
||||
type: FieldMetadataType.NUMBER,
|
||||
label: 'Revenue',
|
||||
value: 1000000,
|
||||
fieldMetadataId: 'company-revenue-metadata-id',
|
||||
isCompositeSubField: false,
|
||||
},
|
||||
},
|
||||
_outputSchemaType: 'RECORD',
|
||||
};
|
||||
|
||||
const mockSearchRecordSchema: FindRecordsOutputSchema = {
|
||||
first: {
|
||||
isLeaf: false,
|
||||
label: 'First',
|
||||
value: mockRecordSchema,
|
||||
},
|
||||
all: {
|
||||
isLeaf: true,
|
||||
label: 'All',
|
||||
value: 'Returns an array of records',
|
||||
type: 'array',
|
||||
},
|
||||
totalCount: {
|
||||
isLeaf: true,
|
||||
label: 'Total Count',
|
||||
value: 42,
|
||||
type: 'number',
|
||||
},
|
||||
};
|
||||
|
||||
it('should handle totalCount variable correctly', () => {
|
||||
const result = searchVariableThroughFindRecordsOutputSchema({
|
||||
stepName: 'Find Companies',
|
||||
searchRecordOutputSchema: mockSearchRecordSchema,
|
||||
rawVariableName: '{{step1.totalCount}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Total Count',
|
||||
variablePathLabel: 'Find Companies > Total Count',
|
||||
variableType: FieldMetadataType.NUMBER,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle first record field access correctly', () => {
|
||||
const result = searchVariableThroughFindRecordsOutputSchema({
|
||||
stepName: 'Find Companies',
|
||||
searchRecordOutputSchema: mockSearchRecordSchema,
|
||||
rawVariableName: '{{step1.first.name}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Company Name',
|
||||
variablePathLabel: 'Find Companies > First > Company Name',
|
||||
variableType: FieldMetadataType.TEXT,
|
||||
fieldMetadataId: 'company-name-metadata-id',
|
||||
compositeFieldSubFieldName: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle all records access correctly', () => {
|
||||
const result = searchVariableThroughFindRecordsOutputSchema({
|
||||
stepName: 'Find Companies',
|
||||
searchRecordOutputSchema: mockSearchRecordSchema,
|
||||
rawVariableName: '{{step1.all}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'All',
|
||||
variablePathLabel: 'Find Companies > All',
|
||||
variableType: FieldMetadataType.ARRAY,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined for invalid field name', () => {
|
||||
const result = searchVariableThroughFindRecordsOutputSchema({
|
||||
stepName: 'Find Companies',
|
||||
searchRecordOutputSchema: mockSearchRecordSchema,
|
||||
rawVariableName: '{{step1.first.invalidField}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined for invalid search result key', () => {
|
||||
const result = searchVariableThroughFindRecordsOutputSchema({
|
||||
stepName: 'Find Companies',
|
||||
searchRecordOutputSchema: mockSearchRecordSchema,
|
||||
rawVariableName: '{{step1.invalid.name}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined when searchRecordOutputSchema is undefined', () => {
|
||||
const result = searchVariableThroughFindRecordsOutputSchema({
|
||||
stepName: 'Find Companies',
|
||||
searchRecordOutputSchema: undefined as any,
|
||||
rawVariableName: '{{step1.first.name}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined when stepId or searchResultKey is undefined', () => {
|
||||
const result = searchVariableThroughFindRecordsOutputSchema({
|
||||
stepName: 'Find Companies',
|
||||
searchRecordOutputSchema: mockSearchRecordSchema,
|
||||
rawVariableName: '{{}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
-252
@@ -1,252 +0,0 @@
|
||||
import { type FormOutputSchema } from '@/workflow/workflow-variables/types/FormOutputSchema';
|
||||
import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2';
|
||||
import { searchVariableThroughFormOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughFormOutputSchema';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
describe('searchVariableThroughFormOutputSchema', () => {
|
||||
const mockRecordSchema: RecordOutputSchemaV2 = {
|
||||
object: {
|
||||
objectMetadataId: 'person-metadata-id',
|
||||
label: 'Person',
|
||||
},
|
||||
fields: {
|
||||
firstName: {
|
||||
isLeaf: true,
|
||||
type: FieldMetadataType.TEXT,
|
||||
label: 'First Name',
|
||||
value: 'John',
|
||||
fieldMetadataId: 'person-firstName-metadata-id',
|
||||
isCompositeSubField: false,
|
||||
},
|
||||
lastName: {
|
||||
isLeaf: true,
|
||||
type: FieldMetadataType.TEXT,
|
||||
label: 'Last Name',
|
||||
value: 'Doe',
|
||||
fieldMetadataId: 'person-lastName-metadata-id',
|
||||
isCompositeSubField: false,
|
||||
},
|
||||
email: {
|
||||
isLeaf: true,
|
||||
type: FieldMetadataType.EMAILS,
|
||||
label: 'Email',
|
||||
value: 'john.doe@example.com',
|
||||
fieldMetadataId: 'person-email-metadata-id',
|
||||
isCompositeSubField: false,
|
||||
},
|
||||
},
|
||||
_outputSchemaType: 'RECORD',
|
||||
};
|
||||
|
||||
const mockFormSchema: FormOutputSchema = {
|
||||
// Simple text field
|
||||
companyName: {
|
||||
isLeaf: true,
|
||||
type: FieldMetadataType.TEXT,
|
||||
label: 'Company Name',
|
||||
value: 'Acme Corp',
|
||||
},
|
||||
// Number field
|
||||
revenue: {
|
||||
isLeaf: true,
|
||||
type: FieldMetadataType.NUMBER,
|
||||
label: 'Annual Revenue',
|
||||
value: 1000000,
|
||||
},
|
||||
// Email field
|
||||
contactEmail: {
|
||||
isLeaf: true,
|
||||
type: FieldMetadataType.EMAILS,
|
||||
label: 'Contact Email',
|
||||
value: 'contact@acme.com',
|
||||
},
|
||||
// Record field (nested record)
|
||||
contactPerson: {
|
||||
isLeaf: false,
|
||||
label: 'Contact Person',
|
||||
value: mockRecordSchema,
|
||||
},
|
||||
};
|
||||
|
||||
it('should handle simple text field access correctly', () => {
|
||||
const result = searchVariableThroughFormOutputSchema({
|
||||
stepName: 'Company Form',
|
||||
formOutputSchema: mockFormSchema,
|
||||
rawVariableName: '{{step1.companyName}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Company Name',
|
||||
variablePathLabel: 'Company Form > Company Name',
|
||||
variableType: FieldMetadataType.TEXT,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle number field access correctly', () => {
|
||||
const result = searchVariableThroughFormOutputSchema({
|
||||
stepName: 'Company Form',
|
||||
formOutputSchema: mockFormSchema,
|
||||
rawVariableName: '{{step1.revenue}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Annual Revenue',
|
||||
variablePathLabel: 'Company Form > Annual Revenue',
|
||||
variableType: FieldMetadataType.NUMBER,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle email field access correctly', () => {
|
||||
const result = searchVariableThroughFormOutputSchema({
|
||||
stepName: 'Company Form',
|
||||
formOutputSchema: mockFormSchema,
|
||||
rawVariableName: '{{step1.contactEmail}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Contact Email',
|
||||
variablePathLabel: 'Company Form > Contact Email',
|
||||
variableType: FieldMetadataType.EMAILS,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle nested record field access correctly', () => {
|
||||
const result = searchVariableThroughFormOutputSchema({
|
||||
stepName: 'Company Form',
|
||||
formOutputSchema: mockFormSchema,
|
||||
rawVariableName: '{{step1.contactPerson.firstName}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'First Name',
|
||||
variablePathLabel: 'Company Form > Contact Person > First Name',
|
||||
variableType: FieldMetadataType.TEXT,
|
||||
fieldMetadataId: 'person-firstName-metadata-id',
|
||||
compositeFieldSubFieldName: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle nested record email field access correctly', () => {
|
||||
const result = searchVariableThroughFormOutputSchema({
|
||||
stepName: 'Company Form',
|
||||
formOutputSchema: mockFormSchema,
|
||||
rawVariableName: '{{step1.contactPerson.email}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Email',
|
||||
variablePathLabel: 'Company Form > Contact Person > Email',
|
||||
variableType: FieldMetadataType.EMAILS,
|
||||
fieldMetadataId: 'person-email-metadata-id',
|
||||
compositeFieldSubFieldName: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined for invalid field name', () => {
|
||||
const result = searchVariableThroughFormOutputSchema({
|
||||
stepName: 'Company Form',
|
||||
formOutputSchema: mockFormSchema,
|
||||
rawVariableName: '{{step1.invalidField}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined for invalid nested field name', () => {
|
||||
const result = searchVariableThroughFormOutputSchema({
|
||||
stepName: 'Company Form',
|
||||
formOutputSchema: mockFormSchema,
|
||||
rawVariableName: '{{step1.contactPerson.invalidField}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined when trying to access record field without specifying property', () => {
|
||||
const result = searchVariableThroughFormOutputSchema({
|
||||
stepName: 'Company Form',
|
||||
formOutputSchema: mockFormSchema,
|
||||
rawVariableName: '{{step1.contactPerson}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined when formOutputSchema is undefined', () => {
|
||||
const result = searchVariableThroughFormOutputSchema({
|
||||
stepName: 'Company Form',
|
||||
formOutputSchema: undefined as any,
|
||||
rawVariableName: '{{step1.companyName}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined when stepId or fieldName is undefined', () => {
|
||||
const result = searchVariableThroughFormOutputSchema({
|
||||
stepName: 'Company Form',
|
||||
formOutputSchema: mockFormSchema,
|
||||
rawVariableName: '{{}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle variables without curly braces', () => {
|
||||
const result = searchVariableThroughFormOutputSchema({
|
||||
stepName: 'Company Form',
|
||||
formOutputSchema: mockFormSchema,
|
||||
rawVariableName: 'step1.companyName',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Company Name',
|
||||
variablePathLabel: 'Company Form > Company Name',
|
||||
variableType: FieldMetadataType.TEXT,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex nested path correctly', () => {
|
||||
// Test with a deeper path in case we have complex nested records
|
||||
const result = searchVariableThroughFormOutputSchema({
|
||||
stepName: 'Company Form',
|
||||
formOutputSchema: mockFormSchema,
|
||||
rawVariableName: '{{step1.contactPerson.lastName}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Last Name',
|
||||
variablePathLabel: 'Company Form > Contact Person > Last Name',
|
||||
variableType: FieldMetadataType.TEXT,
|
||||
fieldMetadataId: 'person-lastName-metadata-id',
|
||||
compositeFieldSubFieldName: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
-145
@@ -1,145 +0,0 @@
|
||||
import { type IteratorOutputSchema } from '@/workflow/workflow-variables/types/IteratorOutputSchema';
|
||||
import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2';
|
||||
import { searchVariableThroughIteratorOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughIteratorOutputSchema';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
describe('searchVariableThroughIteratorOutputSchema', () => {
|
||||
const mockRecordSchema: RecordOutputSchemaV2 = {
|
||||
object: {
|
||||
objectMetadataId: 'company-metadata-id',
|
||||
label: 'Company',
|
||||
},
|
||||
fields: {
|
||||
name: {
|
||||
isLeaf: true,
|
||||
type: FieldMetadataType.TEXT,
|
||||
label: 'Company Name',
|
||||
value: 'Acme Corp',
|
||||
fieldMetadataId: 'company-name-metadata-id',
|
||||
isCompositeSubField: false,
|
||||
},
|
||||
revenue: {
|
||||
isLeaf: true,
|
||||
type: FieldMetadataType.NUMBER,
|
||||
label: 'Revenue',
|
||||
value: 1000000,
|
||||
fieldMetadataId: 'company-revenue-metadata-id',
|
||||
isCompositeSubField: false,
|
||||
},
|
||||
},
|
||||
_outputSchemaType: 'RECORD',
|
||||
};
|
||||
|
||||
const mockIteratorSchema: IteratorOutputSchema = {
|
||||
currentItem: {
|
||||
isLeaf: false,
|
||||
label: 'Current Item',
|
||||
value: mockRecordSchema,
|
||||
},
|
||||
currentItemIndex: 0,
|
||||
hasProcessedAllItems: false,
|
||||
};
|
||||
|
||||
it('should handle currentItemIndex variable correctly', () => {
|
||||
const result = searchVariableThroughIteratorOutputSchema({
|
||||
stepName: 'Iterate Companies',
|
||||
iteratorOutputSchema: mockIteratorSchema,
|
||||
rawVariableName: '{{step1.currentItemIndex}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Current Item Index',
|
||||
variablePathLabel: 'Iterate Companies > Current Item Index',
|
||||
variableType: FieldMetadataType.NUMBER,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle hasProcessedAllItems variable correctly', () => {
|
||||
const result = searchVariableThroughIteratorOutputSchema({
|
||||
stepName: 'Iterate Companies',
|
||||
iteratorOutputSchema: mockIteratorSchema,
|
||||
rawVariableName: '{{step1.hasProcessedAllItems}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Has Processed All Items',
|
||||
variablePathLabel: 'Iterate Companies > Has Processed All Items',
|
||||
variableType: FieldMetadataType.BOOLEAN,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle currentItem field access correctly', () => {
|
||||
const result = searchVariableThroughIteratorOutputSchema({
|
||||
stepName: 'Iterate Companies',
|
||||
iteratorOutputSchema: mockIteratorSchema,
|
||||
rawVariableName: '{{step1.currentItem.name}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Company Name',
|
||||
variablePathLabel: 'Iterate Companies > Current Item > Company Name',
|
||||
variableType: FieldMetadataType.TEXT,
|
||||
fieldMetadataId: 'company-name-metadata-id',
|
||||
compositeFieldSubFieldName: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined for invalid field name', () => {
|
||||
const result = searchVariableThroughIteratorOutputSchema({
|
||||
stepName: 'Iterate Companies',
|
||||
iteratorOutputSchema: mockIteratorSchema,
|
||||
rawVariableName: '{{step1.currentItem.invalidField}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined for invalid iterator result key', () => {
|
||||
const result = searchVariableThroughIteratorOutputSchema({
|
||||
stepName: 'Iterate Companies',
|
||||
iteratorOutputSchema: mockIteratorSchema,
|
||||
rawVariableName: '{{step1.invalid.name}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined when iteratorOutputSchema is undefined', () => {
|
||||
const result = searchVariableThroughIteratorOutputSchema({
|
||||
stepName: 'Iterate Companies',
|
||||
iteratorOutputSchema: undefined as any,
|
||||
rawVariableName: '{{step1.currentItem.name}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined when stepId or iteratorResultKey is undefined', () => {
|
||||
const result = searchVariableThroughIteratorOutputSchema({
|
||||
stepName: 'Iterate Companies',
|
||||
iteratorOutputSchema: mockIteratorSchema,
|
||||
rawVariableName: '{{}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { type StepOutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2';
|
||||
import { searchVariableThroughOutputSchemaV2 } from '@/workflow/workflow-variables/utils/searchVariableThroughOutputSchemaV2';
|
||||
|
||||
describe('searchVariableThroughOutputSchemaV2', () => {
|
||||
const stepOutputSchema: StepOutputSchemaV2 = {
|
||||
id: 'step-1',
|
||||
name: 'HTTP Request',
|
||||
type: 'CODE',
|
||||
outputSchema: {
|
||||
message: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'Message',
|
||||
value: 'Hello World',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('should resolve a variable through the shared dispatcher', () => {
|
||||
const result = searchVariableThroughOutputSchemaV2({
|
||||
stepOutputSchema,
|
||||
stepType: 'CODE',
|
||||
rawVariableName: '{{step-1.message}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Message',
|
||||
variablePathLabel: 'HTTP Request > Message',
|
||||
variableType: 'string',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an empty result for an unknown variable path', () => {
|
||||
const result = searchVariableThroughOutputSchemaV2({
|
||||
stepOutputSchema,
|
||||
stepType: 'CODE',
|
||||
rawVariableName: '{{step-1.unknownField}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result.variableLabel).toBeUndefined();
|
||||
expect(result.variablePathLabel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
-492
@@ -1,492 +0,0 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
-203
@@ -1,203 +0,0 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
-146
@@ -1,146 +0,0 @@
|
||||
import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
parseVariablePath,
|
||||
type BaseOutputSchemaV2,
|
||||
} from 'twenty-shared/workflow';
|
||||
|
||||
/**
|
||||
* Parses a variable name to extract its components
|
||||
* Example: "{{step1.field.value}}" -> { stepId: "step1", pathSegments: ["field"], targetFieldName: "value" }
|
||||
*/
|
||||
const parseVariableName = (rawVariableName: string) => {
|
||||
const variableWithoutBrackets = rawVariableName.replace(
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
(_, variableName) => variableName,
|
||||
);
|
||||
|
||||
const parts = parseVariablePath(variableWithoutBrackets);
|
||||
const stepId = parts.at(0);
|
||||
|
||||
return {
|
||||
stepId,
|
||||
targetFieldName: parts.at(-1),
|
||||
pathSegments: parts.slice(1, -1),
|
||||
};
|
||||
};
|
||||
|
||||
const navigateToTargetField = (
|
||||
startingSchema: BaseOutputSchemaV2,
|
||||
pathSegments: string[],
|
||||
): { schema: BaseOutputSchemaV2; pathLabels: string[] } | null => {
|
||||
let currentSchema: BaseOutputSchemaV2 = startingSchema;
|
||||
const pathLabels: string[] = [];
|
||||
|
||||
for (const pathSegment of pathSegments) {
|
||||
const field = currentSchema[pathSegment];
|
||||
|
||||
if (!isDefined(field) || field.isLeaf === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
pathLabels.push(field.label);
|
||||
currentSchema = field.value;
|
||||
}
|
||||
|
||||
return { schema: currentSchema, pathLabels };
|
||||
};
|
||||
|
||||
const buildVariableResult = (
|
||||
stepName: string,
|
||||
pathLabels: string[],
|
||||
targetSchema: BaseOutputSchemaV2,
|
||||
targetFieldName: string,
|
||||
): VariableSearchResult => {
|
||||
const targetField = targetSchema[targetFieldName];
|
||||
|
||||
if (!isDefined(targetField)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Build the full path: stepName > field1 > field2 > targetField
|
||||
const fullPathSegments = [stepName, ...pathLabels, targetField.label];
|
||||
const variablePathLabel = fullPathSegments.join(' > ');
|
||||
|
||||
return {
|
||||
variableLabel: targetField.label,
|
||||
variablePathLabel,
|
||||
variableType: targetField.type,
|
||||
};
|
||||
};
|
||||
|
||||
export const searchBaseOutputSchema = ({
|
||||
stepName,
|
||||
baseOutputSchema,
|
||||
path,
|
||||
selectedField,
|
||||
}: {
|
||||
stepName: string;
|
||||
baseOutputSchema: BaseOutputSchemaV2;
|
||||
path: string[];
|
||||
selectedField: string;
|
||||
}): VariableSearchResult => {
|
||||
const navigationResult = navigateToTargetField(baseOutputSchema, path);
|
||||
|
||||
if (!navigationResult) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
variableType: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return buildVariableResult(
|
||||
stepName,
|
||||
navigationResult.pathLabels,
|
||||
navigationResult.schema,
|
||||
selectedField,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Searches for a variable within a base output schema and returns its metadata
|
||||
*
|
||||
* @param stepName - Display name of the workflow step
|
||||
* @param baseOutputSchema - The base schema to search within
|
||||
* @param rawVariableName - Variable name like "{{step1.fieldName}}" or "step1.object.nested.value"
|
||||
* @param isFullRecord - Whether to return info for the entire record vs specific field (not used for base schema)
|
||||
* @returns Variable metadata including labels, types, and field information
|
||||
*/
|
||||
export const searchVariableThroughBaseOutputSchema = ({
|
||||
stepName,
|
||||
baseOutputSchema,
|
||||
rawVariableName,
|
||||
}: {
|
||||
stepName: string;
|
||||
baseOutputSchema: BaseOutputSchemaV2;
|
||||
rawVariableName: string;
|
||||
}): VariableSearchResult => {
|
||||
if (!isDefined(baseOutputSchema)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const { stepId, pathSegments, targetFieldName } =
|
||||
parseVariableName(rawVariableName);
|
||||
|
||||
if (!isDefined(stepId) || !isDefined(targetFieldName)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return searchBaseOutputSchema({
|
||||
stepName,
|
||||
baseOutputSchema,
|
||||
path: pathSegments,
|
||||
selectedField: targetFieldName,
|
||||
});
|
||||
};
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable';
|
||||
import type { CodeOutputSchema } from '@/workflow/workflow-variables/types/CodeOutputSchema';
|
||||
import { searchVariableThroughBaseOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const isLinkOutputSchema = (
|
||||
codeOutputSchema: CodeOutputSchema,
|
||||
): codeOutputSchema is { link: any; _outputSchemaType: 'LINK' } => {
|
||||
return (
|
||||
isDefined(codeOutputSchema) && codeOutputSchema._outputSchemaType === 'LINK'
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Searches for a variable within a code output schema and returns its metadata
|
||||
*
|
||||
* @param stepName - Display name of the workflow step
|
||||
* @param codeOutputSchema - The code schema to search within
|
||||
* @param rawVariableName - Variable name like "{{step1.fieldName}}" or "step1.link"
|
||||
* @param isFullRecord - Whether to return info for the entire record vs specific field
|
||||
* @returns Variable metadata including labels, types, and field information
|
||||
*/
|
||||
export const searchVariableThroughCodeOutputSchema = ({
|
||||
stepName,
|
||||
codeOutputSchema,
|
||||
rawVariableName,
|
||||
}: {
|
||||
stepName: string;
|
||||
codeOutputSchema: CodeOutputSchema;
|
||||
rawVariableName: string;
|
||||
}): VariableSearchResult => {
|
||||
if (!isDefined(codeOutputSchema) || isLinkOutputSchema(codeOutputSchema)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return searchVariableThroughBaseOutputSchema({
|
||||
stepName,
|
||||
baseOutputSchema: codeOutputSchema,
|
||||
rawVariableName,
|
||||
});
|
||||
};
|
||||
-127
@@ -1,127 +0,0 @@
|
||||
import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable';
|
||||
import type { FindRecordsOutputSchema } from '@/workflow/workflow-variables/types/FindRecordsOutputSchema';
|
||||
import { searchRecordOutputSchema as searchRecordOutputSchemaUtil } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
parseVariablePath,
|
||||
} from 'twenty-shared/workflow';
|
||||
|
||||
type SearchResultKey = 'first' | 'all' | 'totalCount';
|
||||
|
||||
/**
|
||||
* Parses a variable name to extract its components for SearchRecord outputs
|
||||
* Example: "{{step1.first.user.name}}" -> { stepId: "step1", searchResultKey: "first", pathSegments: ["user"], fieldName: "name" }
|
||||
* Example: "{{step1.totalCount}}" -> { stepId: "step1", searchResultKey: "totalCount", pathSegments: [], fieldName: undefined }
|
||||
*/
|
||||
const parseVariableName = (rawVariableName: string) => {
|
||||
const variableWithoutBrackets = rawVariableName.replace(
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
(_, variableName) => variableName,
|
||||
);
|
||||
|
||||
const parts = parseVariablePath(variableWithoutBrackets);
|
||||
const stepId = parts.at(0);
|
||||
const searchResultKey = parts.at(1) as SearchResultKey;
|
||||
const remainingParts = parts.slice(2);
|
||||
|
||||
return {
|
||||
stepId,
|
||||
searchResultKey,
|
||||
fieldName: remainingParts.at(-1),
|
||||
pathSegments: remainingParts.slice(0, -1),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Searches for a variable within a search record output schema and returns its metadata
|
||||
*
|
||||
* @param stepName - Display name of the workflow step
|
||||
* @param searchRecordOutputSchema - The search record schema to search within
|
||||
* @param rawVariableName - Variable name like "{{step1.first.user.name}}" or "step1.totalCount"
|
||||
* @param isFullRecord - Whether to return info for the entire record vs specific field
|
||||
* @returns Variable metadata including labels, types, and field information
|
||||
*/
|
||||
export const searchVariableThroughFindRecordsOutputSchema = ({
|
||||
stepName,
|
||||
searchRecordOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord = false,
|
||||
stepNameLabel,
|
||||
}: {
|
||||
stepName: string;
|
||||
searchRecordOutputSchema: FindRecordsOutputSchema;
|
||||
rawVariableName: string;
|
||||
isFullRecord?: boolean;
|
||||
stepNameLabel?: string;
|
||||
}): VariableSearchResult => {
|
||||
if (!isDefined(searchRecordOutputSchema)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const { stepId, searchResultKey, fieldName, pathSegments } =
|
||||
parseVariableName(rawVariableName);
|
||||
|
||||
if (!isDefined(stepId) || !isDefined(searchResultKey)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (searchResultKey === 'first') {
|
||||
const recordSchema = searchRecordOutputSchema[searchResultKey]?.value;
|
||||
|
||||
if (!isDefined(recordSchema) || !isDefined(fieldName)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return searchRecordOutputSchemaUtil({
|
||||
stepName: `${stepName} > ${searchRecordOutputSchema[searchResultKey]?.label ?? 'First'}`,
|
||||
recordOutputSchema: recordSchema,
|
||||
selectedField: fieldName,
|
||||
path: pathSegments,
|
||||
isFullRecord,
|
||||
stepNameLabel,
|
||||
});
|
||||
}
|
||||
|
||||
if (searchResultKey === 'totalCount') {
|
||||
const label =
|
||||
searchRecordOutputSchema[searchResultKey]?.label ?? 'Total Count';
|
||||
const basePath = `${stepName} > ${label}`;
|
||||
return {
|
||||
variableLabel: label,
|
||||
variablePathLabel: stepNameLabel
|
||||
? `${basePath} (${stepNameLabel})`
|
||||
: basePath,
|
||||
variableType: FieldMetadataType.NUMBER,
|
||||
};
|
||||
}
|
||||
|
||||
if (searchResultKey === 'all') {
|
||||
const label =
|
||||
searchRecordOutputSchema[searchResultKey]?.label ?? 'All Records';
|
||||
const basePath = `${stepName} > ${label}`;
|
||||
return {
|
||||
variableLabel:
|
||||
searchRecordOutputSchema[searchResultKey]?.label ?? 'All Records',
|
||||
variablePathLabel: stepNameLabel
|
||||
? `${basePath} (${stepNameLabel})`
|
||||
: basePath,
|
||||
variableType: FieldMetadataType.ARRAY,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
};
|
||||
-102
@@ -1,102 +0,0 @@
|
||||
import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable';
|
||||
import type { FormOutputSchema } from '@/workflow/workflow-variables/types/FormOutputSchema';
|
||||
import { searchRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
parseVariablePath,
|
||||
} from 'twenty-shared/workflow';
|
||||
|
||||
/**
|
||||
* Parses a variable name to extract its components for Form outputs
|
||||
* Example: "{{step1.fieldName}}" -> { stepId: "step1", fieldName: "fieldName", pathSegments: [] }
|
||||
* Example: "{{step1.recordField.user.name}}" -> { stepId: "step1", fieldName: "recordField", pathSegments: ["user"], recordFieldName: "name" }
|
||||
*/
|
||||
const parseVariableName = (rawVariableName: string) => {
|
||||
const variableWithoutBrackets = rawVariableName.replace(
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
(_, variableName) => variableName,
|
||||
);
|
||||
|
||||
const parts = parseVariablePath(variableWithoutBrackets);
|
||||
const stepId = parts.at(0);
|
||||
const fieldName = parts.at(1);
|
||||
const remainingParts = parts.slice(2);
|
||||
|
||||
return {
|
||||
stepId,
|
||||
fieldName,
|
||||
pathSegments: remainingParts.slice(0, -1),
|
||||
recordFieldName: remainingParts.at(-1),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Searches for a variable within a form output schema and returns its metadata
|
||||
*
|
||||
* @param stepName - Display name of the workflow step
|
||||
* @param formOutputSchema - The form schema to search within
|
||||
* @param rawVariableName - Variable name like "{{step1.fieldName}}" or "step1.recordField.user.name"
|
||||
* @param isFullRecord - Whether to return info for the entire record vs specific field
|
||||
* @returns Variable metadata including labels, types, and field information
|
||||
*/
|
||||
export const searchVariableThroughFormOutputSchema = ({
|
||||
stepName,
|
||||
formOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord = false,
|
||||
}: {
|
||||
stepName: string;
|
||||
formOutputSchema: FormOutputSchema;
|
||||
rawVariableName: string;
|
||||
isFullRecord?: boolean;
|
||||
}): VariableSearchResult => {
|
||||
if (!isDefined(formOutputSchema)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const { stepId, fieldName, pathSegments, recordFieldName } =
|
||||
parseVariableName(rawVariableName);
|
||||
|
||||
if (!isDefined(stepId) || !isDefined(fieldName)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const formField = formOutputSchema[fieldName];
|
||||
|
||||
if (!isDefined(formField)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (formField.isLeaf) {
|
||||
return {
|
||||
variableLabel: formField.label,
|
||||
variablePathLabel: `${stepName} > ${formField.label}`,
|
||||
variableType: formField.type,
|
||||
};
|
||||
}
|
||||
|
||||
if (!formField.isLeaf && isDefined(recordFieldName)) {
|
||||
return searchRecordOutputSchema({
|
||||
stepName: `${stepName} > ${formField.label}`,
|
||||
recordOutputSchema: formField.value,
|
||||
selectedField: recordFieldName,
|
||||
path: pathSegments,
|
||||
isFullRecord,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
};
|
||||
-128
@@ -1,128 +0,0 @@
|
||||
import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable';
|
||||
import { isBaseOutputSchemaV2 } from '@/workflow/workflow-variables/types/guards/isBaseOutputSchemaV2';
|
||||
import { isRecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/guards/isRecordOutputSchemaV2';
|
||||
import { type IteratorOutputSchema } from '@/workflow/workflow-variables/types/IteratorOutputSchema';
|
||||
import { searchBaseOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema';
|
||||
import { searchRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
parseVariablePath,
|
||||
} from 'twenty-shared/workflow';
|
||||
|
||||
type IteratorResultKey =
|
||||
| 'currentItem'
|
||||
| 'currentItemIndex'
|
||||
| 'hasProcessedAllItems';
|
||||
|
||||
/**
|
||||
* Parses a variable name to extract its components
|
||||
* Example: "{{step1.currentItem.field}}" -> { stepId: "step1", iteratorResultKey: "currentItem", pathSegments: [], fieldName: "field" }
|
||||
*/
|
||||
const parseVariableName = (rawVariableName: string) => {
|
||||
const variableWithoutBrackets = rawVariableName.replace(
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
(_, variableName) => variableName,
|
||||
);
|
||||
|
||||
const parts = parseVariablePath(variableWithoutBrackets);
|
||||
const stepId = parts.at(0);
|
||||
const iteratorResultKey = parts.at(1) as IteratorResultKey;
|
||||
const remainingParts = parts.slice(2);
|
||||
|
||||
return {
|
||||
stepId,
|
||||
iteratorResultKey,
|
||||
fieldName: remainingParts.at(-1),
|
||||
pathSegments: remainingParts.slice(0, -1),
|
||||
};
|
||||
};
|
||||
|
||||
export const searchVariableThroughIteratorOutputSchema = ({
|
||||
stepName,
|
||||
iteratorOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord = false,
|
||||
}: {
|
||||
stepName: string;
|
||||
iteratorOutputSchema: IteratorOutputSchema;
|
||||
rawVariableName: string;
|
||||
isFullRecord?: boolean;
|
||||
}): VariableSearchResult => {
|
||||
if (!isDefined(iteratorOutputSchema)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const { stepId, iteratorResultKey, fieldName, pathSegments } =
|
||||
parseVariableName(rawVariableName);
|
||||
|
||||
if (!isDefined(stepId) || !isDefined(iteratorResultKey)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (iteratorResultKey === 'currentItemIndex') {
|
||||
return {
|
||||
variableLabel: 'Current Item Index',
|
||||
variablePathLabel: `${stepName} > Current Item Index`,
|
||||
variableType: FieldMetadataType.NUMBER,
|
||||
};
|
||||
}
|
||||
|
||||
if (iteratorResultKey === 'hasProcessedAllItems') {
|
||||
return {
|
||||
variableLabel: 'Has Processed All Items',
|
||||
variablePathLabel: `${stepName} > Has Processed All Items`,
|
||||
variableType: FieldMetadataType.BOOLEAN,
|
||||
};
|
||||
}
|
||||
|
||||
if (iteratorResultKey === 'currentItem') {
|
||||
const schema = iteratorOutputSchema.currentItem.value;
|
||||
|
||||
if (!isDefined(schema)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (isRecordOutputSchemaV2(schema) && isDefined(fieldName)) {
|
||||
return searchRecordOutputSchema({
|
||||
stepName: `${stepName} > Current Item`,
|
||||
recordOutputSchema: schema,
|
||||
path: pathSegments,
|
||||
selectedField: fieldName,
|
||||
isFullRecord,
|
||||
});
|
||||
}
|
||||
|
||||
if (isBaseOutputSchemaV2(schema) && isDefined(fieldName)) {
|
||||
return searchBaseOutputSchema({
|
||||
stepName,
|
||||
baseOutputSchema: schema,
|
||||
path: pathSegments,
|
||||
selectedField: fieldName,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
variableLabel: iteratorOutputSchema.currentItem.label,
|
||||
variablePathLabel: `${stepName} > ${iteratorOutputSchema.currentItem.label}`,
|
||||
variableType: iteratorOutputSchema.currentItem.isLeaf
|
||||
? iteratorOutputSchema.currentItem.type
|
||||
: 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
};
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
import { isRecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/guards/isRecordOutputSchemaV2';
|
||||
import { type ManualTriggerOutputSchema } from '@/workflow/workflow-variables/types/ManualTriggerOutputSchema';
|
||||
import { searchVariableThroughBaseOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema';
|
||||
import { searchVariableThroughRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema';
|
||||
|
||||
export const searchVariableThroughManualTriggerOutputSchema = ({
|
||||
stepName,
|
||||
manualTriggerOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
}: {
|
||||
stepName: string;
|
||||
manualTriggerOutputSchema: ManualTriggerOutputSchema;
|
||||
rawVariableName: string;
|
||||
isFullRecord: boolean;
|
||||
}) => {
|
||||
if (isRecordOutputSchemaV2(manualTriggerOutputSchema)) {
|
||||
return searchVariableThroughRecordOutputSchema({
|
||||
stepName,
|
||||
recordOutputSchema: manualTriggerOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
});
|
||||
}
|
||||
|
||||
return searchVariableThroughBaseOutputSchema({
|
||||
stepName,
|
||||
baseOutputSchema: manualTriggerOutputSchema,
|
||||
rawVariableName,
|
||||
});
|
||||
};
|
||||
+10
-84
@@ -2,23 +2,11 @@ import {
|
||||
type WorkflowActionType,
|
||||
type WorkflowTriggerType,
|
||||
} from '@/workflow/types/Workflow';
|
||||
import { isCodeOutputSchema } from '@/workflow/workflow-variables/types/guards/isCodeOutputSchema';
|
||||
import { isDatabaseEventTriggerOutputSchema } from '@/workflow/workflow-variables/types/guards/isDatabaseEventTriggerOutputSchema';
|
||||
import { isFindRecordsOutputSchema } from '@/workflow/workflow-variables/types/guards/isFindRecordsOutputSchema';
|
||||
import { isFormOutputSchema } from '@/workflow/workflow-variables/types/guards/isFormOutputSchema';
|
||||
import { isIteratorOutputSchema } from '@/workflow/workflow-variables/types/guards/isIteratorOutputSchema';
|
||||
import { isManualTriggerOutputSchema } from '@/workflow/workflow-variables/types/guards/isManualTriggerOutputSchema';
|
||||
import { isRecordStepOutputSchema } from '@/workflow/workflow-variables/types/guards/isRecordStepOutputSchema';
|
||||
import { type StepOutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2';
|
||||
|
||||
import { searchVariableThroughBaseOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema';
|
||||
import { searchVariableThroughCodeOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughCodeOutputSchema';
|
||||
import { searchVariableThroughFindRecordsOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughFindRecordsOutputSchema';
|
||||
import { searchVariableThroughFormOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughFormOutputSchema';
|
||||
import { searchVariableThroughIteratorOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughIteratorOutputSchema';
|
||||
import { searchVariableThroughManualTriggerOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughManualTriggerOutputSchema';
|
||||
import { searchVariableThroughRecordEventOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordEventOutputSchema';
|
||||
import { searchVariableThroughRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema';
|
||||
import {
|
||||
searchVariableInOutputSchema,
|
||||
type VariableSearchResult,
|
||||
} from 'twenty-shared/workflow';
|
||||
|
||||
export const searchVariableThroughOutputSchemaV2 = ({
|
||||
stepOutputSchema,
|
||||
@@ -30,75 +18,13 @@ export const searchVariableThroughOutputSchemaV2 = ({
|
||||
stepType: WorkflowTriggerType | WorkflowActionType;
|
||||
rawVariableName: string;
|
||||
isFullRecord: boolean;
|
||||
}) => {
|
||||
if (isRecordStepOutputSchema(stepType, stepOutputSchema.outputSchema)) {
|
||||
return searchVariableThroughRecordOutputSchema({
|
||||
stepName: stepOutputSchema.name,
|
||||
recordOutputSchema: stepOutputSchema.outputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
});
|
||||
}
|
||||
|
||||
if (isManualTriggerOutputSchema(stepType, stepOutputSchema.outputSchema)) {
|
||||
return searchVariableThroughManualTriggerOutputSchema({
|
||||
stepName: stepOutputSchema.name,
|
||||
manualTriggerOutputSchema: stepOutputSchema.outputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isDatabaseEventTriggerOutputSchema(stepType, stepOutputSchema.outputSchema)
|
||||
) {
|
||||
return searchVariableThroughRecordEventOutputSchema({
|
||||
stepName: stepOutputSchema.name,
|
||||
recordOutputSchema: stepOutputSchema.outputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
});
|
||||
}
|
||||
|
||||
if (isFindRecordsOutputSchema(stepType, stepOutputSchema.outputSchema)) {
|
||||
return searchVariableThroughFindRecordsOutputSchema({
|
||||
stepName: stepOutputSchema.name,
|
||||
searchRecordOutputSchema: stepOutputSchema.outputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
stepNameLabel: stepOutputSchema.objectName,
|
||||
});
|
||||
}
|
||||
|
||||
if (isFormOutputSchema(stepType, stepOutputSchema.outputSchema)) {
|
||||
return searchVariableThroughFormOutputSchema({
|
||||
stepName: stepOutputSchema.name,
|
||||
formOutputSchema: stepOutputSchema.outputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
});
|
||||
}
|
||||
|
||||
if (isCodeOutputSchema(stepType, stepOutputSchema.outputSchema)) {
|
||||
return searchVariableThroughCodeOutputSchema({
|
||||
stepName: stepOutputSchema.name,
|
||||
codeOutputSchema: stepOutputSchema.outputSchema,
|
||||
rawVariableName,
|
||||
});
|
||||
}
|
||||
|
||||
if (isIteratorOutputSchema(stepType, stepOutputSchema.outputSchema)) {
|
||||
return searchVariableThroughIteratorOutputSchema({
|
||||
stepName: stepOutputSchema.name,
|
||||
iteratorOutputSchema: stepOutputSchema.outputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
});
|
||||
}
|
||||
|
||||
return searchVariableThroughBaseOutputSchema({
|
||||
}): VariableSearchResult => {
|
||||
return searchVariableInOutputSchema({
|
||||
schema: stepOutputSchema.outputSchema,
|
||||
stepType,
|
||||
stepName: stepOutputSchema.name,
|
||||
baseOutputSchema: stepOutputSchema.outputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
stepNameLabel: stepOutputSchema.objectName,
|
||||
});
|
||||
};
|
||||
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
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';
|
||||
import {
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
parseVariablePath,
|
||||
} from 'twenty-shared/workflow';
|
||||
|
||||
/**
|
||||
* 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 = parseVariablePath(variableWithoutBrackets);
|
||||
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 next 3 parts to get the event prefix (properties, after/before, objectName)
|
||||
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,
|
||||
});
|
||||
};
|
||||
-219
@@ -1,219 +0,0 @@
|
||||
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/types/guards/isRecordOutputSchemaV2';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
parseVariablePath,
|
||||
} from 'twenty-shared/workflow';
|
||||
|
||||
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 isIdFieldName = (fieldName: string) => {
|
||||
return (
|
||||
fieldName === 'id' ||
|
||||
// For database events, id field will have a prefix such as properties.after.id
|
||||
fieldName.endsWith('.id')
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
stepNameLabel?: string,
|
||||
): 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) &&
|
||||
isIdFieldName(targetFieldName)
|
||||
? 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 basePath = fullPathSegments.join(' > ');
|
||||
const variablePathLabel = stepNameLabel
|
||||
? `${basePath} (${stepNameLabel})`
|
||||
: basePath;
|
||||
|
||||
return {
|
||||
variableLabel,
|
||||
variablePathLabel,
|
||||
variableType: targetField?.type,
|
||||
fieldMetadataId: targetField?.fieldMetadataId,
|
||||
compositeFieldSubFieldName: getCompositeSubFieldName(
|
||||
targetSchema,
|
||||
targetFieldName,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const searchRecordOutputSchema = ({
|
||||
stepName,
|
||||
recordOutputSchema,
|
||||
path,
|
||||
selectedField,
|
||||
isFullRecord,
|
||||
stepNameLabel,
|
||||
}: {
|
||||
stepName: string;
|
||||
recordOutputSchema: RecordOutputSchemaV2;
|
||||
path: string[];
|
||||
selectedField: string;
|
||||
isFullRecord: boolean;
|
||||
stepNameLabel?: string;
|
||||
}): VariableSearchResult => {
|
||||
const navigationResult = navigateToTargetField(recordOutputSchema, path);
|
||||
|
||||
if (!navigationResult) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
variableType: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return buildVariableResult(
|
||||
stepName,
|
||||
navigationResult.pathLabels,
|
||||
navigationResult.schema,
|
||||
selectedField,
|
||||
isFullRecord,
|
||||
stepNameLabel,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 = parseVariablePath(variableWithoutBrackets);
|
||||
|
||||
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,
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user