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:
@@ -79,6 +79,7 @@ export type {
|
||||
InputSchema,
|
||||
} from './types/InputSchema';
|
||||
export type { StepIfElseBranch } from './types/StepIfElseBranch';
|
||||
export { WorkflowActionType } from './types/WorkflowActionType';
|
||||
export type { WorkflowAttachment } from './types/WorkflowAttachment';
|
||||
export type { BodyType } from './types/workflowHttpRequestStep';
|
||||
export type {
|
||||
@@ -104,6 +105,33 @@ export {
|
||||
joinVariablePath,
|
||||
parseVariablePath,
|
||||
} from './utils/variable-path.util';
|
||||
export { isIfElseStepInput } from './validation/guards/isIfElseStepInput';
|
||||
export { isIteratorStepInput } from './validation/guards/isIteratorStepInput';
|
||||
export type {
|
||||
IfElseStepInput,
|
||||
IteratorStepInput,
|
||||
WorkflowValidationSeverity,
|
||||
WorkflowValidationIssueCode,
|
||||
WorkflowValidationIssue,
|
||||
WorkflowValidationResult,
|
||||
ValidatableWorkflowStep,
|
||||
ValidatableWorkflowTrigger,
|
||||
ValidatableWorkflow,
|
||||
} from './validation/types/workflow-validation.type';
|
||||
export type { WorkflowGraph } from './validation/utils/build-workflow-graph.util';
|
||||
export { buildWorkflowGraph } from './validation/utils/build-workflow-graph.util';
|
||||
export { extractVariablesFromInput } from './validation/utils/extract-variables-from-input.util';
|
||||
export { getEditDistance } from './validation/utils/get-edit-distance.util';
|
||||
export {
|
||||
getStepInput,
|
||||
getStepOutgoingStepIds,
|
||||
} from './validation/utils/get-step-outgoing-step-ids.util';
|
||||
export { getVariablePathSuggestions } from './validation/utils/get-variable-path-suggestions.util';
|
||||
export { validateWorkflowGraph } from './validation/utils/validate-workflow-graph.util';
|
||||
export { validateWorkflowStepParams } from './validation/utils/validate-workflow-step-params.util';
|
||||
export { validateWorkflowVariableReferences } from './validation/utils/validate-workflow-variable-references.util';
|
||||
export { validateWorkflowStructure } from './validation/validate-workflow-structure.util';
|
||||
export { isBaseOutputSchemaV2 } from './workflow-schema/guards/isBaseOutputSchemaV2';
|
||||
export type {
|
||||
LeafType,
|
||||
NodeType,
|
||||
@@ -111,7 +139,38 @@ export type {
|
||||
Node,
|
||||
BaseOutputSchemaV2,
|
||||
} from './workflow-schema/types/base-output-schema.type';
|
||||
export { navigateOutputSchemaProperty } from './workflow-schema/utils/navigateOutputSchemaProperty';
|
||||
export type {
|
||||
RecordFieldLeaf,
|
||||
RecordFieldNode,
|
||||
RecordFieldNodeValue,
|
||||
FieldOutputSchemaV2,
|
||||
RecordOutputSchemaV2,
|
||||
RecordNode,
|
||||
FindRecordsOutputSchema,
|
||||
IteratorOutputSchema,
|
||||
FormFieldLeaf,
|
||||
FormFieldNode,
|
||||
FormOutputSchema,
|
||||
LinkOutputSchema,
|
||||
CodeOutputSchema,
|
||||
ManualTriggerOutputSchema,
|
||||
OutputSchemaV2,
|
||||
VariableSearchResult,
|
||||
} from './workflow-schema/types/output-schema.type';
|
||||
export { collectOutputSchemaPaths } from './workflow-schema/utils/collect-output-schema-paths';
|
||||
export type { OutputSchemaPathFailure } from './workflow-schema/utils/find-output-schema-path-failure';
|
||||
export { findOutputSchemaPathFailure } from './workflow-schema/utils/find-output-schema-path-failure';
|
||||
export { navigateOutputSchemaProperty } from './workflow-schema/utils/navigate-output-schema-property';
|
||||
export type { ResolvedVariable } from './workflow-schema/utils/resolve-variable-path-in-output-schema';
|
||||
export {
|
||||
resolveInSchema,
|
||||
resolveVariablePathInOutputSchema,
|
||||
collectOutputSchemaVariablePaths,
|
||||
} from './workflow-schema/utils/resolve-variable-path-in-output-schema';
|
||||
export {
|
||||
searchRecordOutputSchema,
|
||||
searchVariableInOutputSchema,
|
||||
} from './workflow-schema/utils/search-variable-in-output-schema';
|
||||
export type {
|
||||
GlobalAvailability,
|
||||
SingleRecordAvailability,
|
||||
|
||||
@@ -2,9 +2,9 @@ import { z } from 'zod';
|
||||
|
||||
export const baseWorkflowActionSchema = z.object({
|
||||
id: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe(
|
||||
'Unique identifier for the workflow step. Must be unique within the workflow.',
|
||||
'Unique UUID identifier for the workflow step. Must be a valid UUID v4, unique within the workflow.',
|
||||
),
|
||||
name: z
|
||||
.string()
|
||||
@@ -17,7 +17,7 @@ export const baseWorkflowActionSchema = z.object({
|
||||
'Whether the step configuration is valid. Set to true when all required fields are properly configured.',
|
||||
),
|
||||
nextStepIds: z
|
||||
.array(z.string())
|
||||
.array(z.uuid())
|
||||
.optional()
|
||||
.nullable()
|
||||
.describe(
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export enum WorkflowActionType {
|
||||
CODE = 'CODE',
|
||||
LOGIC_FUNCTION = 'LOGIC_FUNCTION',
|
||||
SEND_EMAIL = 'SEND_EMAIL',
|
||||
DRAFT_EMAIL = 'DRAFT_EMAIL',
|
||||
CREATE_RECORD = 'CREATE_RECORD',
|
||||
UPDATE_RECORD = 'UPDATE_RECORD',
|
||||
DELETE_RECORD = 'DELETE_RECORD',
|
||||
UPSERT_RECORD = 'UPSERT_RECORD',
|
||||
FIND_RECORDS = 'FIND_RECORDS',
|
||||
FORM = 'FORM',
|
||||
FILTER = 'FILTER',
|
||||
IF_ELSE = 'IF_ELSE',
|
||||
HTTP_REQUEST = 'HTTP_REQUEST',
|
||||
AI_AGENT = 'AI_AGENT',
|
||||
ITERATOR = 'ITERATOR',
|
||||
EMPTY = 'EMPTY',
|
||||
DELAY = 'DELAY',
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { type ValidatableWorkflow } from '@/workflow/validation/types/workflow-validation.type';
|
||||
import { validateWorkflowStructure } from '../validate-workflow-structure.util';
|
||||
|
||||
const getCodes = (workflow: ValidatableWorkflow): string[] => {
|
||||
const result = validateWorkflowStructure(workflow);
|
||||
|
||||
return [...result.errors, ...result.warnings].map((issue) => issue.code);
|
||||
};
|
||||
|
||||
describe('validateWorkflowStructure', () => {
|
||||
it('should flag a missing trigger and missing steps', () => {
|
||||
const result = validateWorkflowStructure({
|
||||
trigger: undefined,
|
||||
steps: [],
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.map((issue) => issue.code)).toEqual(
|
||||
expect.arrayContaining(['MISSING_TRIGGER', 'NO_STEPS']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should flag a trigger without a type', () => {
|
||||
expect(getCodes({ trigger: {}, steps: [] })).toContain(
|
||||
'MISSING_TRIGGER_TYPE',
|
||||
);
|
||||
});
|
||||
|
||||
it('should flag a workflow without steps', () => {
|
||||
expect(getCodes({ trigger: { type: 'MANUAL' }, steps: [] })).toContain(
|
||||
'NO_STEPS',
|
||||
);
|
||||
});
|
||||
|
||||
it('should aggregate graph issues such as unreachable steps', () => {
|
||||
const result = validateWorkflowStructure({
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1'] },
|
||||
steps: [
|
||||
{ id: 's1', type: 'CODE' },
|
||||
{ id: 'orphan', type: 'CODE' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.map((issue) => issue.code)).toContain(
|
||||
'UNREACHABLE_STEP',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { isObject } from '@sniptt/guards';
|
||||
|
||||
import { WorkflowActionType } from '@/workflow/types/WorkflowActionType';
|
||||
import {
|
||||
type IfElseStepInput,
|
||||
type ValidatableWorkflowStep,
|
||||
} from '@/workflow/validation/types/workflow-validation.type';
|
||||
|
||||
export const isIfElseStepInput = (
|
||||
step: ValidatableWorkflowStep,
|
||||
): step is ValidatableWorkflowStep & {
|
||||
settings: { input: Partial<IfElseStepInput> };
|
||||
} => {
|
||||
const input = step.settings?.input;
|
||||
|
||||
return (
|
||||
step.type === WorkflowActionType.IF_ELSE &&
|
||||
isObject(input) &&
|
||||
'branches' in input &&
|
||||
Array.isArray(input.branches)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { isNonEmptyArray, isObject } from '@sniptt/guards';
|
||||
|
||||
import { WorkflowActionType } from '@/workflow/types/WorkflowActionType';
|
||||
import {
|
||||
type IteratorStepInput,
|
||||
type ValidatableWorkflowStep,
|
||||
} from '@/workflow/validation/types/workflow-validation.type';
|
||||
|
||||
export const isIteratorStepInput = (
|
||||
step: ValidatableWorkflowStep,
|
||||
): step is ValidatableWorkflowStep & {
|
||||
settings: { input: Partial<IteratorStepInput> };
|
||||
} => {
|
||||
const input = step.settings?.input;
|
||||
|
||||
return (
|
||||
step.type === WorkflowActionType.ITERATOR &&
|
||||
isObject(input) &&
|
||||
'initialLoopStepIds' in input &&
|
||||
isNonEmptyArray(input.initialLoopStepIds)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { type workflowIfElseActionSettingsSchema } from '@/workflow/schemas/if-else-action-settings-schema';
|
||||
import { type workflowIteratorActionSettingsSchema } from '@/workflow/schemas/iterator-action-settings-schema';
|
||||
import { type z } from 'zod';
|
||||
|
||||
export type IfElseStepInput = z.infer<
|
||||
typeof workflowIfElseActionSettingsSchema
|
||||
>['input'];
|
||||
export type IteratorStepInput = z.infer<
|
||||
typeof workflowIteratorActionSettingsSchema
|
||||
>['input'];
|
||||
|
||||
export type WorkflowValidationSeverity = 'error' | 'warning';
|
||||
|
||||
export type WorkflowValidationIssueCode =
|
||||
| 'MISSING_TRIGGER'
|
||||
| 'MISSING_TRIGGER_TYPE'
|
||||
| 'NO_STEPS'
|
||||
| 'TRIGGER_HAS_NO_NEXT_STEP'
|
||||
| 'DUPLICATE_STEP_ID'
|
||||
| 'DANGLING_REFERENCE'
|
||||
| 'UNREACHABLE_STEP'
|
||||
| 'INVALID_TRIGGER_PARAMS'
|
||||
| 'INVALID_STEP_PARAMS'
|
||||
| 'IF_ELSE_INSUFFICIENT_BRANCHES'
|
||||
| 'IF_ELSE_BRANCH_HAS_NO_NEXT_STEP'
|
||||
| 'ITERATOR_MISSING_LOOP_BODY'
|
||||
| 'VARIABLE_INVALID_PATH'
|
||||
| 'VARIABLE_UNKNOWN_STEP'
|
||||
| 'VARIABLE_NOT_UPSTREAM'
|
||||
| 'VARIABLE_MISSING_OUTPUT_SCHEMA'
|
||||
| 'VARIABLE_PATH_NOT_FOUND'
|
||||
| 'CODE_STEP_MISSING_OUTPUT_SCHEMA'
|
||||
| 'AI_AGENT_MISSING_AGENT'
|
||||
| 'AI_AGENT_MISSING_OUTPUT_VARIABLE';
|
||||
|
||||
export type WorkflowValidationIssue = {
|
||||
severity: WorkflowValidationSeverity;
|
||||
code: WorkflowValidationIssueCode;
|
||||
message: string;
|
||||
stepId?: string;
|
||||
path?: string;
|
||||
hint?: string;
|
||||
suggestions?: string[];
|
||||
availablePaths?: string[];
|
||||
};
|
||||
|
||||
export type WorkflowValidationResult = {
|
||||
valid: boolean;
|
||||
errors: WorkflowValidationIssue[];
|
||||
warnings: WorkflowValidationIssue[];
|
||||
};
|
||||
|
||||
export type ValidatableWorkflowStep = {
|
||||
id: string;
|
||||
name?: string;
|
||||
type: string;
|
||||
valid?: boolean;
|
||||
nextStepIds?: string[] | null;
|
||||
settings?: {
|
||||
input?: unknown;
|
||||
outputSchema?: unknown;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type ValidatableWorkflowTrigger = {
|
||||
type?: string;
|
||||
name?: string;
|
||||
settings?: {
|
||||
input?: unknown;
|
||||
outputSchema?: unknown;
|
||||
} | null;
|
||||
nextStepIds?: string[] | null;
|
||||
};
|
||||
|
||||
export type ValidatableWorkflow = {
|
||||
trigger: ValidatableWorkflowTrigger | null | undefined;
|
||||
steps: ValidatableWorkflowStep[] | null | undefined;
|
||||
};
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId';
|
||||
import {
|
||||
type ValidatableWorkflow,
|
||||
type ValidatableWorkflowStep,
|
||||
} from '@/workflow/validation/types/workflow-validation.type';
|
||||
import { buildWorkflowGraph } from '../build-workflow-graph.util';
|
||||
|
||||
const buildStep = (
|
||||
id: string,
|
||||
nextStepIds: string[] = [],
|
||||
): ValidatableWorkflowStep => ({ id, type: 'CODE', nextStepIds });
|
||||
|
||||
const ancestorsOf = (
|
||||
graph: ReturnType<typeof buildWorkflowGraph>,
|
||||
stepId: string,
|
||||
): string[] => [...(graph.ancestorsByStepId.get(stepId) ?? [])];
|
||||
|
||||
describe('buildWorkflowGraph', () => {
|
||||
it('should map trigger children, reachability and ancestors for a linear flow', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1'] },
|
||||
steps: [buildStep('s1', ['s2']), buildStep('s2')],
|
||||
};
|
||||
|
||||
const graph = buildWorkflowGraph(workflow);
|
||||
|
||||
expect(graph.childrenByStepId.get(TRIGGER_STEP_ID)).toEqual(['s1']);
|
||||
expect(graph.reachableFromTrigger.has('s1')).toBe(true);
|
||||
expect(graph.reachableFromTrigger.has('s2')).toBe(true);
|
||||
expect(ancestorsOf(graph, 's1')).toEqual([TRIGGER_STEP_ID]);
|
||||
expect(ancestorsOf(graph, 's2')).toEqual(
|
||||
expect.arrayContaining([TRIGGER_STEP_ID, 's1']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not mark disconnected steps as reachable', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1'] },
|
||||
steps: [buildStep('s1'), buildStep('orphan')],
|
||||
};
|
||||
|
||||
const graph = buildWorkflowGraph(workflow);
|
||||
|
||||
expect(graph.reachableFromTrigger.has('s1')).toBe(true);
|
||||
expect(graph.reachableFromTrigger.has('orphan')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle a trigger without nextStepIds', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL' },
|
||||
steps: [buildStep('s1')],
|
||||
};
|
||||
|
||||
const graph = buildWorkflowGraph(workflow);
|
||||
|
||||
expect(graph.childrenByStepId.get(TRIGGER_STEP_ID)).toEqual([]);
|
||||
expect(graph.reachableFromTrigger.has('s1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not loop forever on cyclic graphs', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1'] },
|
||||
steps: [buildStep('s1', ['s2']), buildStep('s2', ['s1'])],
|
||||
};
|
||||
|
||||
const graph = buildWorkflowGraph(workflow);
|
||||
|
||||
expect(graph.reachableFromTrigger.has('s1')).toBe(true);
|
||||
expect(graph.reachableFromTrigger.has('s2')).toBe(true);
|
||||
expect(ancestorsOf(graph, 's1')).toEqual(
|
||||
expect.arrayContaining([TRIGGER_STEP_ID, 's1', 's2']),
|
||||
);
|
||||
});
|
||||
});
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { extractVariablesFromInput } from '../extract-variables-from-input.util';
|
||||
|
||||
describe('extractVariablesFromInput', () => {
|
||||
it('should return an empty array for non-string, non-object input', () => {
|
||||
expect(extractVariablesFromInput(undefined)).toEqual([]);
|
||||
expect(extractVariablesFromInput(null)).toEqual([]);
|
||||
expect(extractVariablesFromInput(42)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should extract a single variable from a string', () => {
|
||||
expect(extractVariablesFromInput('Hello {{step1.name}}')).toEqual([
|
||||
'step1.name',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should extract every variable across nested objects and arrays', () => {
|
||||
const input = {
|
||||
greeting: 'Hi {{trigger.email}}',
|
||||
nested: { message: 'X {{step2.value}} Y {{step3.id}}' },
|
||||
list: ['{{step4.foo}}'],
|
||||
};
|
||||
|
||||
expect(extractVariablesFromInput(input)).toEqual([
|
||||
'trigger.email',
|
||||
'step2.value',
|
||||
'step3.id',
|
||||
'step4.foo',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return an empty array when no variables are present', () => {
|
||||
expect(extractVariablesFromInput({ a: 'plain text', b: 5 })).toEqual([]);
|
||||
});
|
||||
});
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { getEditDistance } from '../get-edit-distance.util';
|
||||
|
||||
describe('getEditDistance', () => {
|
||||
it('should return 0 for identical strings', () => {
|
||||
expect(getEditDistance('name', 'name')).toBe(0);
|
||||
});
|
||||
|
||||
it('should count a single deletion', () => {
|
||||
expect(getEditDistance('name', 'nme')).toBe(1);
|
||||
});
|
||||
|
||||
it('should count a single insertion', () => {
|
||||
expect(getEditDistance('nme', 'name')).toBe(1);
|
||||
});
|
||||
|
||||
it('should count a single substitution', () => {
|
||||
expect(getEditDistance('firstName', 'firstname')).toBe(1);
|
||||
});
|
||||
|
||||
it('should compute the classic kitten/sitting distance', () => {
|
||||
expect(getEditDistance('kitten', 'sitting')).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
expect(getEditDistance('', 'name')).toBe(4);
|
||||
expect(getEditDistance('name', '')).toBe(4);
|
||||
expect(getEditDistance('', '')).toBe(0);
|
||||
});
|
||||
});
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { WorkflowActionType } from '@/workflow/types/WorkflowActionType';
|
||||
import { type ValidatableWorkflowStep } from '@/workflow/validation/types/workflow-validation.type';
|
||||
import {
|
||||
getStepInput,
|
||||
getStepOutgoingStepIds,
|
||||
} from '../get-step-outgoing-step-ids.util';
|
||||
|
||||
describe('getStepInput', () => {
|
||||
it('should return the input object when present', () => {
|
||||
const step: ValidatableWorkflowStep = {
|
||||
id: 'step-1',
|
||||
type: 'CODE',
|
||||
settings: { input: { foo: 'bar' } },
|
||||
};
|
||||
|
||||
expect(getStepInput(step)).toEqual({ foo: 'bar' });
|
||||
});
|
||||
|
||||
it('should return undefined when input is missing or not an object', () => {
|
||||
expect(getStepInput({ id: 'step-1', type: 'CODE' })).toBeUndefined();
|
||||
expect(
|
||||
getStepInput({ id: 'step-1', type: 'CODE', settings: { input: 'text' } }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStepOutgoingStepIds', () => {
|
||||
it('should return the nextStepIds of a regular step', () => {
|
||||
expect(
|
||||
getStepOutgoingStepIds({
|
||||
id: 'step-1',
|
||||
type: 'CODE',
|
||||
nextStepIds: ['a', 'b'],
|
||||
}),
|
||||
).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('should deduplicate outgoing step ids', () => {
|
||||
expect(
|
||||
getStepOutgoingStepIds({
|
||||
id: 'step-1',
|
||||
type: 'CODE',
|
||||
nextStepIds: ['a', 'a'],
|
||||
}),
|
||||
).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('should include if-else branch nextStepIds', () => {
|
||||
const step: ValidatableWorkflowStep = {
|
||||
id: 'step-1',
|
||||
type: WorkflowActionType.IF_ELSE,
|
||||
nextStepIds: ['x'],
|
||||
settings: {
|
||||
input: { branches: [{ nextStepIds: ['b1'] }, { nextStepIds: ['b2'] }] },
|
||||
},
|
||||
};
|
||||
|
||||
expect(getStepOutgoingStepIds(step).sort()).toEqual(['b1', 'b2', 'x']);
|
||||
});
|
||||
|
||||
it('should include iterator initialLoopStepIds', () => {
|
||||
const step: ValidatableWorkflowStep = {
|
||||
id: 'step-1',
|
||||
type: WorkflowActionType.ITERATOR,
|
||||
settings: { input: { initialLoopStepIds: ['loop-1'] } },
|
||||
};
|
||||
|
||||
expect(getStepOutgoingStepIds(step)).toEqual(['loop-1']);
|
||||
});
|
||||
|
||||
it('should fall back to nextStepIds when input is not an object', () => {
|
||||
expect(
|
||||
getStepOutgoingStepIds({
|
||||
id: 'step-1',
|
||||
type: 'CODE',
|
||||
nextStepIds: ['a'],
|
||||
settings: { input: undefined },
|
||||
}),
|
||||
).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
import { type BaseOutputSchemaV2 } from '@/workflow/workflow-schema/types/base-output-schema.type';
|
||||
|
||||
import { getVariablePathSuggestions } from '../get-variable-path-suggestions.util';
|
||||
|
||||
describe('getVariablePathSuggestions', () => {
|
||||
const schema: BaseOutputSchemaV2 = {
|
||||
user: {
|
||||
isLeaf: false,
|
||||
type: 'object',
|
||||
label: 'user',
|
||||
value: {
|
||||
name: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'name',
|
||||
value: 'Test',
|
||||
},
|
||||
email: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'email',
|
||||
value: 'test@test.com',
|
||||
},
|
||||
},
|
||||
},
|
||||
id: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'id',
|
||||
value: '1',
|
||||
},
|
||||
};
|
||||
|
||||
it('should suggest the closest sibling for a nested typo (strategy A)', () => {
|
||||
expect(
|
||||
getVariablePathSuggestions({
|
||||
schema,
|
||||
propertyPath: ['user', 'naem'],
|
||||
referencedStepId: 'step-1',
|
||||
}),
|
||||
).toEqual(['step-1.user.name']);
|
||||
});
|
||||
|
||||
it('should suggest a structurally correct path when the prefix is wrong (strategy B)', () => {
|
||||
expect(
|
||||
getVariablePathSuggestions({
|
||||
schema,
|
||||
propertyPath: ['name'],
|
||||
referencedStepId: 'step-1',
|
||||
}),
|
||||
).toContain('step-1.user.name');
|
||||
});
|
||||
|
||||
it('should return no suggestions for an unrelated segment', () => {
|
||||
expect(
|
||||
getVariablePathSuggestions({
|
||||
schema,
|
||||
propertyPath: ['completelyDifferentThing'],
|
||||
referencedStepId: 'step-1',
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return no suggestions when the path resolves', () => {
|
||||
expect(
|
||||
getVariablePathSuggestions({
|
||||
schema,
|
||||
propertyPath: ['user', 'name'],
|
||||
referencedStepId: 'step-1',
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('should suggest record field names (not internal keys) for FIND_RECORDS schemas', () => {
|
||||
const findRecordsSchema = {
|
||||
first: {
|
||||
isLeaf: false,
|
||||
label: 'First',
|
||||
value: {
|
||||
object: {
|
||||
objectMetadataId: 'company-metadata-id',
|
||||
label: 'Company',
|
||||
},
|
||||
fields: {
|
||||
name: {
|
||||
isLeaf: true,
|
||||
type: 'TEXT',
|
||||
label: 'Company Name',
|
||||
value: 'Acme Corp',
|
||||
fieldMetadataId: 'company-name-metadata-id',
|
||||
isCompositeSubField: false,
|
||||
},
|
||||
revenue: {
|
||||
isLeaf: true,
|
||||
type: 'NUMBER',
|
||||
label: 'Revenue',
|
||||
value: 1000000,
|
||||
fieldMetadataId: 'company-revenue-metadata-id',
|
||||
isCompositeSubField: false,
|
||||
},
|
||||
},
|
||||
_outputSchemaType: 'RECORD',
|
||||
},
|
||||
},
|
||||
all: {
|
||||
isLeaf: true,
|
||||
label: 'All',
|
||||
value: 'Returns an array of records',
|
||||
type: 'array',
|
||||
},
|
||||
totalCount: {
|
||||
isLeaf: true,
|
||||
label: 'Total Count',
|
||||
value: 42,
|
||||
type: 'number',
|
||||
},
|
||||
};
|
||||
|
||||
const suggestions = getVariablePathSuggestions({
|
||||
schema: findRecordsSchema,
|
||||
propertyPath: ['first', 'naem'],
|
||||
referencedStepId: 'step-1',
|
||||
});
|
||||
|
||||
expect(suggestions).toContain('step-1.first.name');
|
||||
expect(
|
||||
suggestions.some(
|
||||
(suggestion) =>
|
||||
suggestion.includes('object') ||
|
||||
suggestion.includes('fields') ||
|
||||
suggestion.includes('_outputSchemaType'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should suggest record field names (not internal keys) for FORM schemas', () => {
|
||||
const formSchema = {
|
||||
companyName: {
|
||||
isLeaf: true,
|
||||
type: 'TEXT',
|
||||
label: 'Company Name',
|
||||
value: 'Acme Corp',
|
||||
},
|
||||
person: {
|
||||
isLeaf: false,
|
||||
label: 'Person',
|
||||
value: {
|
||||
object: {
|
||||
objectMetadataId: 'person-metadata-id',
|
||||
label: 'Person',
|
||||
},
|
||||
fields: {
|
||||
firstName: {
|
||||
isLeaf: true,
|
||||
type: 'TEXT',
|
||||
label: 'First Name',
|
||||
value: 'John',
|
||||
fieldMetadataId: 'person-firstName-metadata-id',
|
||||
isCompositeSubField: false,
|
||||
},
|
||||
},
|
||||
_outputSchemaType: 'RECORD',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const suggestions = getVariablePathSuggestions({
|
||||
schema: formSchema,
|
||||
propertyPath: ['person', 'firstNaem'],
|
||||
referencedStepId: 'step-1',
|
||||
});
|
||||
|
||||
expect(suggestions).toContain('step-1.person.firstName');
|
||||
expect(
|
||||
suggestions.some(
|
||||
(suggestion) =>
|
||||
suggestion.includes('object') ||
|
||||
suggestion.includes('fields') ||
|
||||
suggestion.includes('_outputSchemaType'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import { WorkflowActionType } from '@/workflow/types/WorkflowActionType';
|
||||
import {
|
||||
type ValidatableWorkflow,
|
||||
type WorkflowValidationIssueCode,
|
||||
} from '@/workflow/validation/types/workflow-validation.type';
|
||||
import { buildWorkflowGraph } from '../build-workflow-graph.util';
|
||||
import { validateWorkflowGraph } from '../validate-workflow-graph.util';
|
||||
|
||||
const getCodes = (
|
||||
workflow: ValidatableWorkflow,
|
||||
): WorkflowValidationIssueCode[] =>
|
||||
validateWorkflowGraph({
|
||||
workflow,
|
||||
graph: buildWorkflowGraph(workflow),
|
||||
}).map((issue) => issue.code);
|
||||
|
||||
describe('validateWorkflowGraph', () => {
|
||||
it('should return no issues for a valid linear workflow', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1'] },
|
||||
steps: [{ id: 's1', type: 'CODE', nextStepIds: [] }],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should flag duplicate step ids', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1'] },
|
||||
steps: [
|
||||
{ id: 's1', type: 'CODE' },
|
||||
{ id: 's1', type: 'CODE' },
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toContain('DUPLICATE_STEP_ID');
|
||||
});
|
||||
|
||||
it('should flag a trigger with no connected step', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL' },
|
||||
steps: [{ id: 's1', type: 'CODE' }],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toContain('TRIGGER_HAS_NO_NEXT_STEP');
|
||||
});
|
||||
|
||||
it('should flag a dangling trigger reference', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['ghost'] },
|
||||
steps: [{ id: 's1', type: 'CODE' }],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toContain('DANGLING_REFERENCE');
|
||||
});
|
||||
|
||||
it('should flag unreachable steps', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1'] },
|
||||
steps: [
|
||||
{ id: 's1', type: 'CODE' },
|
||||
{ id: 'orphan', type: 'CODE' },
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toContain('UNREACHABLE_STEP');
|
||||
});
|
||||
|
||||
it('should flag an if-else step with fewer than two branches', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['if'] },
|
||||
steps: [
|
||||
{
|
||||
id: 'if',
|
||||
type: WorkflowActionType.IF_ELSE,
|
||||
settings: { input: { branches: [{ nextStepIds: ['end'] }] } },
|
||||
},
|
||||
{ id: 'end', type: 'CODE' },
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toContain('IF_ELSE_INSUFFICIENT_BRANCHES');
|
||||
});
|
||||
|
||||
it('should flag an if-else branch with no connected step', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['if'] },
|
||||
steps: [
|
||||
{
|
||||
id: 'if',
|
||||
type: WorkflowActionType.IF_ELSE,
|
||||
settings: {
|
||||
input: {
|
||||
branches: [{ nextStepIds: ['end'] }, { nextStepIds: [] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: 'end', type: 'CODE' },
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toContain('IF_ELSE_BRANCH_HAS_NO_NEXT_STEP');
|
||||
});
|
||||
|
||||
it('should flag an iterator with items but no loop body', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['iterator'] },
|
||||
steps: [
|
||||
{
|
||||
id: 'iterator',
|
||||
type: WorkflowActionType.ITERATOR,
|
||||
settings: {
|
||||
input: { items: '{{trigger.items}}', initialLoopStepIds: [] },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toContain('ITERATOR_MISSING_LOOP_BODY');
|
||||
});
|
||||
});
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { type ValidatableWorkflow } from '@/workflow/validation/types/workflow-validation.type';
|
||||
import { validateWorkflowStepParams } from '../validate-workflow-step-params.util';
|
||||
|
||||
describe('validateWorkflowStepParams', () => {
|
||||
it('should return no issues when there is no trigger and no steps', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: undefined,
|
||||
steps: undefined,
|
||||
};
|
||||
|
||||
expect(validateWorkflowStepParams(workflow)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should flag an invalid trigger configuration', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'NOT_A_REAL_TRIGGER' },
|
||||
steps: [],
|
||||
};
|
||||
|
||||
const issues = validateWorkflowStepParams(workflow);
|
||||
|
||||
expect(
|
||||
issues.some((issue) => issue.code === 'INVALID_TRIGGER_PARAMS'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should flag an invalid step configuration with its step id', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: undefined,
|
||||
steps: [{ id: 'step-1', type: 'NOT_A_REAL_ACTION' }],
|
||||
};
|
||||
|
||||
const issues = validateWorkflowStepParams(workflow);
|
||||
|
||||
expect(
|
||||
issues.some(
|
||||
(issue) =>
|
||||
issue.code === 'INVALID_STEP_PARAMS' && issue.stepId === 'step-1',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
import {
|
||||
type ValidatableWorkflow,
|
||||
type ValidatableWorkflowStep,
|
||||
type WorkflowValidationIssueCode,
|
||||
} from '@/workflow/validation/types/workflow-validation.type';
|
||||
import { buildWorkflowGraph } from '../build-workflow-graph.util';
|
||||
import { validateWorkflowVariableReferences } from '../validate-workflow-variable-references.util';
|
||||
|
||||
const OUTPUT_SCHEMA = {
|
||||
name: { isLeaf: true, type: 'string', label: 'name', value: 'John' },
|
||||
};
|
||||
|
||||
const getCodes = (
|
||||
workflow: ValidatableWorkflow,
|
||||
): WorkflowValidationIssueCode[] => {
|
||||
const steps = workflow.steps ?? [];
|
||||
|
||||
return validateWorkflowVariableReferences({
|
||||
workflow,
|
||||
graph: buildWorkflowGraph(workflow),
|
||||
stepsById: new Map<string, ValidatableWorkflowStep>(
|
||||
steps.map((step) => [step.id, step]),
|
||||
),
|
||||
}).map((issue) => issue.code);
|
||||
};
|
||||
|
||||
describe('validateWorkflowVariableReferences', () => {
|
||||
it('should return no issues for a valid upstream reference that resolves', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1'] },
|
||||
steps: [
|
||||
{
|
||||
id: 's1',
|
||||
type: 'CODE',
|
||||
nextStepIds: ['s2'],
|
||||
settings: { outputSchema: OUTPUT_SCHEMA },
|
||||
},
|
||||
{
|
||||
id: 's2',
|
||||
type: 'CODE',
|
||||
settings: { input: { value: '{{s1.name}}' } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should flag a variable with an invalid path', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1'] },
|
||||
steps: [
|
||||
{ id: 's1', type: 'CODE', settings: { input: { value: '{{.}}' } } },
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toContain('VARIABLE_INVALID_PATH');
|
||||
});
|
||||
|
||||
it('should flag a reference to an unknown step', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1'] },
|
||||
steps: [
|
||||
{
|
||||
id: 's1',
|
||||
type: 'CODE',
|
||||
settings: { input: { value: '{{ghost.name}}' } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toContain('VARIABLE_UNKNOWN_STEP');
|
||||
});
|
||||
|
||||
it('should flag a reference to a step that does not run before it', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1', 's2'] },
|
||||
steps: [
|
||||
{
|
||||
id: 's1',
|
||||
type: 'CODE',
|
||||
settings: { input: { value: '{{s2.name}}' } },
|
||||
},
|
||||
{ id: 's2', type: 'CODE', settings: { outputSchema: OUTPUT_SCHEMA } },
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toContain('VARIABLE_NOT_UPSTREAM');
|
||||
});
|
||||
|
||||
it('should flag an upstream reference whose path is not found in the output schema', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1'] },
|
||||
steps: [
|
||||
{
|
||||
id: 's1',
|
||||
type: 'CODE',
|
||||
nextStepIds: ['s2'],
|
||||
settings: { outputSchema: OUTPUT_SCHEMA },
|
||||
},
|
||||
{
|
||||
id: 's2',
|
||||
type: 'CODE',
|
||||
settings: { input: { value: '{{s1.unknownField}}' } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toContain('VARIABLE_PATH_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('should resolve a valid trigger reference against the trigger output schema', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: {
|
||||
type: 'MANUAL',
|
||||
nextStepIds: ['s1'],
|
||||
settings: { outputSchema: OUTPUT_SCHEMA },
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
id: 's1',
|
||||
type: 'CODE',
|
||||
settings: { input: { value: '{{trigger.name}}' } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should flag an invalid path in the trigger output schema', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: {
|
||||
type: 'MANUAL',
|
||||
nextStepIds: ['s1'],
|
||||
settings: { outputSchema: OUTPUT_SCHEMA },
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
id: 's1',
|
||||
type: 'CODE',
|
||||
settings: { input: { value: '{{trigger.unknownField}}' } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toContain('VARIABLE_PATH_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('should not flag a self-reference as not-upstream', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1'] },
|
||||
steps: [
|
||||
{
|
||||
id: 's1',
|
||||
type: 'CODE',
|
||||
settings: {
|
||||
input: { value: '{{s1.name}}' },
|
||||
outputSchema: OUTPUT_SCHEMA,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).not.toContain('VARIABLE_NOT_UPSTREAM');
|
||||
});
|
||||
|
||||
// Regression: a trigger reference must never be flagged as not-upstream, even
|
||||
// when the trigger has no outgoing connections (so it is not in any ancestor set).
|
||||
it('should not flag a trigger reference as not-upstream when the trigger is disconnected', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL' },
|
||||
steps: [
|
||||
{
|
||||
id: 's1',
|
||||
type: 'CODE',
|
||||
settings: { input: { value: '{{trigger.foo}}' } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { isDefined } from '@/utils';
|
||||
import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId';
|
||||
import { type ValidatableWorkflow } from '@/workflow/validation/types/workflow-validation.type';
|
||||
import { getStepOutgoingStepIds } from '@/workflow/validation/utils/get-step-outgoing-step-ids.util';
|
||||
|
||||
export type WorkflowGraph = {
|
||||
childrenByStepId: Map<string, string[]>;
|
||||
reachableFromTrigger: Set<string>;
|
||||
ancestorsByStepId: Map<string, Set<string>>;
|
||||
};
|
||||
|
||||
export const buildWorkflowGraph = ({
|
||||
trigger,
|
||||
steps,
|
||||
}: ValidatableWorkflow): WorkflowGraph => {
|
||||
const childrenByStepId = new Map<string, string[]>();
|
||||
|
||||
const triggerNextStepIds = isDefined(trigger?.nextStepIds)
|
||||
? trigger.nextStepIds.filter(isDefined)
|
||||
: [];
|
||||
|
||||
childrenByStepId.set(TRIGGER_STEP_ID, triggerNextStepIds);
|
||||
|
||||
for (const step of steps ?? []) {
|
||||
childrenByStepId.set(step.id, getStepOutgoingStepIds(step));
|
||||
}
|
||||
|
||||
const reachableFromTrigger = new Set<string>();
|
||||
const queue: string[] = [TRIGGER_STEP_ID];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentStepId = queue.shift();
|
||||
|
||||
if (!isDefined(currentStepId) || reachableFromTrigger.has(currentStepId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
reachableFromTrigger.add(currentStepId);
|
||||
|
||||
for (const nextStepId of childrenByStepId.get(currentStepId) ?? []) {
|
||||
if (!reachableFromTrigger.has(nextStepId)) {
|
||||
queue.push(nextStepId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ancestorsByStepId = computeAncestors(childrenByStepId);
|
||||
|
||||
return { childrenByStepId, reachableFromTrigger, ancestorsByStepId };
|
||||
};
|
||||
|
||||
const computeAncestors = (
|
||||
childrenByStepId: Map<string, string[]>,
|
||||
): Map<string, Set<string>> => {
|
||||
const parentsByStepId = new Map<string, Set<string>>();
|
||||
|
||||
for (const [stepId, nextStepIds] of childrenByStepId.entries()) {
|
||||
for (const nextStepId of nextStepIds) {
|
||||
const parents = parentsByStepId.get(nextStepId) ?? new Set<string>();
|
||||
|
||||
parents.add(stepId);
|
||||
parentsByStepId.set(nextStepId, parents);
|
||||
}
|
||||
}
|
||||
|
||||
const ancestorsByStepId = new Map<string, Set<string>>();
|
||||
|
||||
for (const stepId of childrenByStepId.keys()) {
|
||||
if (ancestorsByStepId.has(stepId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ancestors = new Set<string>();
|
||||
const queue = [...(parentsByStepId.get(stepId) ?? [])];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const ancestorStepId = queue.shift()!;
|
||||
|
||||
if (ancestors.has(ancestorStepId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ancestors.add(ancestorStepId);
|
||||
|
||||
for (const grandParentStepId of parentsByStepId.get(ancestorStepId) ??
|
||||
[]) {
|
||||
if (!ancestors.has(grandParentStepId)) {
|
||||
queue.push(grandParentStepId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ancestorsByStepId.set(stepId, ancestors);
|
||||
}
|
||||
|
||||
return ancestorsByStepId;
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { isObject, isString } from '@sniptt/guards';
|
||||
|
||||
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from '@/workflow/constants/CaptureAllVariableTagInnerRegex';
|
||||
|
||||
function* resolveVariables(value: unknown): Generator<string> {
|
||||
if (isString(value)) {
|
||||
for (const [, variablePath] of value.matchAll(
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
)) {
|
||||
yield variablePath;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isObject(value)) {
|
||||
for (const nestedValue of Object.values(value)) {
|
||||
yield* resolveVariables(nestedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const extractVariablesFromInput = (input: unknown): string[] => {
|
||||
return [...resolveVariables(input)];
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
// Levenshtein edit distance
|
||||
export const getEditDistance = (source: string, target: string): number => {
|
||||
const rowCount = source.length + 1;
|
||||
const columnCount = target.length + 1;
|
||||
|
||||
const matrix: number[][] = Array.from({ length: rowCount }, () =>
|
||||
new Array<number>(columnCount).fill(0),
|
||||
);
|
||||
|
||||
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
|
||||
matrix[rowIndex][0] = rowIndex;
|
||||
}
|
||||
|
||||
for (let columnIndex = 0; columnIndex < columnCount; columnIndex++) {
|
||||
matrix[0][columnIndex] = columnIndex;
|
||||
}
|
||||
|
||||
for (let rowIndex = 1; rowIndex < rowCount; rowIndex++) {
|
||||
for (let columnIndex = 1; columnIndex < columnCount; columnIndex++) {
|
||||
const substitutionCost =
|
||||
source[rowIndex - 1] === target[columnIndex - 1] ? 0 : 1;
|
||||
|
||||
matrix[rowIndex][columnIndex] = Math.min(
|
||||
matrix[rowIndex - 1][columnIndex] + 1,
|
||||
matrix[rowIndex][columnIndex - 1] + 1,
|
||||
matrix[rowIndex - 1][columnIndex - 1] + substitutionCost,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[source.length][target.length];
|
||||
};
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { isDefined } from '@/utils';
|
||||
import { isIfElseStepInput } from '@/workflow/validation/guards/isIfElseStepInput';
|
||||
import { isIteratorStepInput } from '@/workflow/validation/guards/isIteratorStepInput';
|
||||
import { type ValidatableWorkflowStep } from '@/workflow/validation/types/workflow-validation.type';
|
||||
import { isObject } from '@sniptt/guards';
|
||||
|
||||
export const getStepInput = (
|
||||
step: ValidatableWorkflowStep,
|
||||
): Record<string, unknown> | undefined => {
|
||||
const input = step.settings?.input;
|
||||
|
||||
if (isDefined(input) && isObject(input)) {
|
||||
return input as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getStepOutgoingStepIds = (
|
||||
step: ValidatableWorkflowStep,
|
||||
): string[] => {
|
||||
const outgoingStepIds = new Set<string>(step.nextStepIds ?? []);
|
||||
|
||||
if (isIfElseStepInput(step)) {
|
||||
for (const branch of step.settings.input.branches ?? []) {
|
||||
for (const nextStepId of branch?.nextStepIds ?? []) {
|
||||
outgoingStepIds.add(nextStepId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isIteratorStepInput(step)) {
|
||||
for (const nextStepId of step.settings.input.initialLoopStepIds ?? []) {
|
||||
outgoingStepIds.add(nextStepId);
|
||||
}
|
||||
}
|
||||
|
||||
return [...outgoingStepIds];
|
||||
};
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { isNonEmptyArray } from '@sniptt/guards';
|
||||
|
||||
import { isDefined, isPlainObject } from '@/utils';
|
||||
import { getEditDistance } from '@/workflow/validation/utils/get-edit-distance.util';
|
||||
import { isBaseOutputSchemaV2 } from '@/workflow/workflow-schema/guards/isBaseOutputSchemaV2';
|
||||
import { collectOutputSchemaPaths } from '@/workflow/workflow-schema/utils/collect-output-schema-paths';
|
||||
import { findOutputSchemaPathFailure } from '@/workflow/workflow-schema/utils/find-output-schema-path-failure';
|
||||
import { collectOutputSchemaVariablePaths } from '@/workflow/workflow-schema/utils/resolve-variable-path-in-output-schema';
|
||||
|
||||
const MAX_SUGGESTIONS = 3;
|
||||
|
||||
const containsRecordOutputSchema = (value: unknown): boolean => {
|
||||
if (!isPlainObject(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value['_outputSchemaType'] === 'RECORD') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Object.values(value).some(
|
||||
(entry) =>
|
||||
isPlainObject(entry) && containsRecordOutputSchema(entry['value']),
|
||||
);
|
||||
};
|
||||
|
||||
const rankClosestCandidates = (
|
||||
target: string,
|
||||
candidates: string[],
|
||||
): string[] =>
|
||||
candidates
|
||||
.map((candidate) => ({
|
||||
candidate,
|
||||
distance: getEditDistance(target, candidate),
|
||||
}))
|
||||
.filter(
|
||||
({ candidate, distance }) => distance <= Math.ceil(candidate.length / 2),
|
||||
)
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
.slice(0, MAX_SUGGESTIONS)
|
||||
.map(({ candidate }) => candidate);
|
||||
|
||||
export const getVariablePathSuggestions = ({
|
||||
schema,
|
||||
propertyPath,
|
||||
referencedStepId,
|
||||
}: {
|
||||
schema: unknown;
|
||||
propertyPath: string[];
|
||||
referencedStepId: string;
|
||||
}): string[] => {
|
||||
if (!isBaseOutputSchemaV2(schema) || containsRecordOutputSchema(schema)) {
|
||||
const allPaths = collectOutputSchemaVariablePaths(schema);
|
||||
|
||||
return rankClosestCandidates(propertyPath.join('.'), allPaths).map((path) =>
|
||||
[referencedStepId, path].join('.'),
|
||||
);
|
||||
}
|
||||
|
||||
const failure = findOutputSchemaPathFailure({ schema, propertyPath });
|
||||
|
||||
if (!isDefined(failure)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const localMatches = rankClosestCandidates(
|
||||
failure.failedSegment,
|
||||
failure.availableKeys,
|
||||
).map((key) => [referencedStepId, ...failure.validPrefix, key].join('.'));
|
||||
|
||||
if (isNonEmptyArray(localMatches)) {
|
||||
return localMatches;
|
||||
}
|
||||
|
||||
const allPaths = collectOutputSchemaPaths(schema);
|
||||
|
||||
return rankClosestCandidates(propertyPath.join('.'), allPaths).map((path) =>
|
||||
[referencedStepId, path].join('.'),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
import { isDefined } from '@/utils';
|
||||
import { WorkflowActionType } from '@/workflow/types/WorkflowActionType';
|
||||
import {
|
||||
type IfElseStepInput,
|
||||
type IteratorStepInput,
|
||||
type ValidatableWorkflow,
|
||||
type ValidatableWorkflowStep,
|
||||
type WorkflowValidationIssue,
|
||||
} from '@/workflow/validation/types/workflow-validation.type';
|
||||
import { type WorkflowGraph } from '@/workflow/validation/utils/build-workflow-graph.util';
|
||||
import { getStepInput } from '@/workflow/validation/utils/get-step-outgoing-step-ids.util';
|
||||
|
||||
export const validateWorkflowGraph = ({
|
||||
workflow,
|
||||
graph,
|
||||
}: {
|
||||
workflow: ValidatableWorkflow;
|
||||
graph: WorkflowGraph;
|
||||
}): WorkflowValidationIssue[] => {
|
||||
const issues: WorkflowValidationIssue[] = [];
|
||||
const steps = workflow.steps ?? [];
|
||||
const stepIds = new Set(steps.map((step) => step.id));
|
||||
|
||||
const seenStepIds = new Set<string>();
|
||||
|
||||
for (const step of steps) {
|
||||
if (seenStepIds.has(step.id)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'DUPLICATE_STEP_ID',
|
||||
message: `Duplicate step id "${step.id}". Every step id must be unique within the workflow.`,
|
||||
stepId: step.id,
|
||||
});
|
||||
}
|
||||
seenStepIds.add(step.id);
|
||||
}
|
||||
|
||||
const triggerNextStepIds = (workflow.trigger?.nextStepIds ?? []).filter(
|
||||
isDefined,
|
||||
);
|
||||
|
||||
if (steps.length > 0 && triggerNextStepIds.length === 0) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'TRIGGER_HAS_NO_NEXT_STEP',
|
||||
message: `The trigger is not connected to any step. The trigger must have a "nextStepIds" array pointing to the first step (e.g. nextStepIds: ["${steps[0].id}"]). If you used edges, also set trigger.nextStepIds.`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const triggerNextStepId of triggerNextStepIds) {
|
||||
if (!stepIds.has(triggerNextStepId)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'DANGLING_REFERENCE',
|
||||
message: `The trigger references a non-existent step "${triggerNextStepId}".`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const step of steps) {
|
||||
for (const outgoingStepId of graph.childrenByStepId.get(step.id) ?? []) {
|
||||
if (!stepIds.has(outgoingStepId)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'DANGLING_REFERENCE',
|
||||
message: `Step "${step.name ?? step.id}" references a non-existent step "${outgoingStepId}".`,
|
||||
stepId: step.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
issues.push(...validateBranchingStep(step));
|
||||
}
|
||||
|
||||
for (const step of steps) {
|
||||
if (!graph.reachableFromTrigger.has(step.id)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'UNREACHABLE_STEP',
|
||||
message: `Step "${step.name ?? step.id}" is not reachable from the trigger. Ensure a chain of nextStepIds connects the trigger to this step. Check that the preceding step includes this step's id ("${step.id}") in its nextStepIds array.`,
|
||||
stepId: step.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
};
|
||||
|
||||
const validateBranchingStep = (
|
||||
step: ValidatableWorkflowStep,
|
||||
): WorkflowValidationIssue[] => {
|
||||
const issues: WorkflowValidationIssue[] = [];
|
||||
const input = getStepInput(step);
|
||||
|
||||
if (step.type === WorkflowActionType.IF_ELSE) {
|
||||
const branchList = (input as Partial<IfElseStepInput> | undefined)
|
||||
?.branches;
|
||||
const branches = Array.isArray(branchList) ? branchList : [];
|
||||
|
||||
if (branches.length < 2) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'IF_ELSE_INSUFFICIENT_BRANCHES',
|
||||
message: `If/Else step "${step.name ?? step.id}" must have at least two branches (a condition branch and an else branch).`,
|
||||
stepId: step.id,
|
||||
});
|
||||
}
|
||||
|
||||
for (const branch of branches) {
|
||||
const branchNextStepIds = branch?.nextStepIds;
|
||||
|
||||
if (!Array.isArray(branchNextStepIds) || branchNextStepIds.length === 0) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'IF_ELSE_BRANCH_HAS_NO_NEXT_STEP',
|
||||
message: `A branch of If/Else step "${step.name ?? step.id}" is not connected to any step.`,
|
||||
stepId: step.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (step.type === WorkflowActionType.ITERATOR) {
|
||||
const iteratorInput = input as Partial<IteratorStepInput> | undefined;
|
||||
const items = iteratorInput?.items;
|
||||
const hasConfiguredItems =
|
||||
(typeof items === 'string' && items.length > 0) ||
|
||||
(Array.isArray(items) && items.length > 0);
|
||||
const initialLoopStepIds = iteratorInput?.initialLoopStepIds;
|
||||
const hasLoopBody =
|
||||
Array.isArray(initialLoopStepIds) && initialLoopStepIds.length > 0;
|
||||
|
||||
if (hasConfiguredItems && !hasLoopBody) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'ITERATOR_MISSING_LOOP_BODY',
|
||||
message: `Iterator step "${step.name ?? step.id}" has items to iterate over but no steps inside the loop.`,
|
||||
stepId: step.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
};
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { workflowActionSchema } from '@/workflow/schemas/workflow-action-schema';
|
||||
import { workflowTriggerSchema } from '@/workflow/schemas/workflow-trigger-schema';
|
||||
import { isDefined } from '@/utils';
|
||||
import {
|
||||
type ValidatableWorkflow,
|
||||
type WorkflowValidationIssue,
|
||||
} from '@/workflow/validation/types/workflow-validation.type';
|
||||
import { type z } from 'zod';
|
||||
|
||||
const formatZodPath = (path: PropertyKey[]): string =>
|
||||
path.map((segment) => String(segment)).join('.');
|
||||
|
||||
const formatZodIssue = (issue: z.ZodIssue): string => {
|
||||
const path = formatZodPath(issue.path);
|
||||
const base = path.length > 0 ? `${path}: ${issue.message}` : issue.message;
|
||||
|
||||
if (issue.code === 'invalid_type' && path.length > 0) {
|
||||
return `${base}. Ensure the field "${path}" exists at the correct nesting level in the step object (not inside "input").`;
|
||||
}
|
||||
|
||||
return base;
|
||||
};
|
||||
|
||||
const formatZodIssues = (zodError: z.ZodError): string[] =>
|
||||
zodError.issues.map(formatZodIssue);
|
||||
|
||||
export const validateWorkflowStepParams = ({
|
||||
trigger,
|
||||
steps,
|
||||
}: ValidatableWorkflow): WorkflowValidationIssue[] => {
|
||||
const issues: WorkflowValidationIssue[] = [];
|
||||
|
||||
if (isDefined(trigger)) {
|
||||
const triggerResult = workflowTriggerSchema.safeParse(trigger);
|
||||
|
||||
if (!triggerResult.success) {
|
||||
for (const message of formatZodIssues(triggerResult.error)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'INVALID_TRIGGER_PARAMS',
|
||||
message: `Trigger configuration is invalid - ${message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const step of steps ?? []) {
|
||||
const stepResult = workflowActionSchema.safeParse(step);
|
||||
|
||||
if (!stepResult.success) {
|
||||
for (const message of formatZodIssues(stepResult.error)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'INVALID_STEP_PARAMS',
|
||||
message: `Step "${step.name ?? step.id}" configuration is invalid - ${message}`,
|
||||
stepId: step.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
};
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
import { isNonEmptyArray, isObject } from '@sniptt/guards';
|
||||
|
||||
import { isDefined } from '@/utils';
|
||||
import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId';
|
||||
import { parseVariablePath } from '@/workflow/utils/variable-path.util';
|
||||
import {
|
||||
type ValidatableWorkflow,
|
||||
type ValidatableWorkflowStep,
|
||||
type WorkflowValidationIssue,
|
||||
} from '@/workflow/validation/types/workflow-validation.type';
|
||||
import { type WorkflowGraph } from '@/workflow/validation/utils/build-workflow-graph.util';
|
||||
import { extractVariablesFromInput } from '@/workflow/validation/utils/extract-variables-from-input.util';
|
||||
import { getVariablePathSuggestions } from '@/workflow/validation/utils/get-variable-path-suggestions.util';
|
||||
import {
|
||||
collectOutputSchemaVariablePaths,
|
||||
resolveVariablePathInOutputSchema,
|
||||
} from '@/workflow/workflow-schema/utils/resolve-variable-path-in-output-schema';
|
||||
|
||||
export const validateWorkflowVariableReferences = ({
|
||||
workflow,
|
||||
graph,
|
||||
stepsById,
|
||||
}: {
|
||||
workflow: ValidatableWorkflow;
|
||||
graph: WorkflowGraph;
|
||||
stepsById: Map<string, ValidatableWorkflowStep>;
|
||||
}): WorkflowValidationIssue[] => {
|
||||
const issues: WorkflowValidationIssue[] = [];
|
||||
const stepIds = new Set(workflow.steps?.map((step) => step.id) ?? []);
|
||||
|
||||
for (const step of workflow.steps ?? []) {
|
||||
const variables = extractVariablesFromInput(step.settings?.input);
|
||||
const ancestors = graph.ancestorsByStepId.get(step.id) ?? new Set<string>();
|
||||
|
||||
for (const variable of variables) {
|
||||
const pathSegments = parseVariablePath(variable);
|
||||
const referencedStepId = pathSegments[0];
|
||||
|
||||
if (!isDefined(referencedStepId)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'VARIABLE_INVALID_PATH',
|
||||
message: `Step "${step.name ?? step.id}" has a variable "{{${variable}}}" with an invalid path. Variable references must start with a step ID, e.g. "{{stepId.property}}".`,
|
||||
stepId: step.id,
|
||||
path: variable,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const isTriggerReference = referencedStepId === TRIGGER_STEP_ID;
|
||||
|
||||
if (!isTriggerReference && !stepIds.has(referencedStepId)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'VARIABLE_UNKNOWN_STEP',
|
||||
message: `Step "${step.name ?? step.id}" references variable "{{${variable}}}" from an unknown step "${referencedStepId}".`,
|
||||
stepId: step.id,
|
||||
path: variable,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const isSelfReference = referencedStepId === step.id;
|
||||
|
||||
// The trigger always runs before every step, so a trigger reference is
|
||||
// upstream by definition even when it is not present in the ancestor set.
|
||||
if (
|
||||
!isTriggerReference &&
|
||||
!isSelfReference &&
|
||||
!ancestors.has(referencedStepId)
|
||||
) {
|
||||
const referencedStep = stepsById.get(referencedStepId);
|
||||
const referencedStepLabel = referencedStep?.name ?? referencedStepId;
|
||||
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'VARIABLE_NOT_UPSTREAM',
|
||||
message: `Step "${step.name ?? step.id}" references variable "{{${variable}}}" from step "${referencedStepLabel}", which does not run before it. Ensure step "${referencedStepLabel}" is an ancestor (connected via nextStepIds chain from the trigger, before this step).`,
|
||||
stepId: step.id,
|
||||
path: variable,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
issues.push(
|
||||
...validateVariablePathAgainstOutputSchema({
|
||||
step,
|
||||
variable,
|
||||
pathSegments,
|
||||
referencedStepId,
|
||||
isTriggerReference,
|
||||
trigger: workflow.trigger,
|
||||
stepsById,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
};
|
||||
|
||||
const validateVariablePathAgainstOutputSchema = ({
|
||||
step,
|
||||
variable,
|
||||
pathSegments,
|
||||
referencedStepId,
|
||||
isTriggerReference,
|
||||
trigger,
|
||||
stepsById,
|
||||
}: {
|
||||
step: ValidatableWorkflowStep;
|
||||
variable: string;
|
||||
pathSegments: string[];
|
||||
referencedStepId: string;
|
||||
isTriggerReference: boolean;
|
||||
trigger: ValidatableWorkflow['trigger'];
|
||||
stepsById: Map<string, ValidatableWorkflowStep>;
|
||||
}): WorkflowValidationIssue[] => {
|
||||
const propertyPath = pathSegments.slice(1);
|
||||
|
||||
if (propertyPath.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const outputSchema = isTriggerReference
|
||||
? trigger?.settings?.outputSchema
|
||||
: stepsById.get(referencedStepId)?.settings?.outputSchema;
|
||||
|
||||
const isEmptyOutputSchema =
|
||||
isDefined(outputSchema) &&
|
||||
isObject(outputSchema) &&
|
||||
!Array.isArray(outputSchema) &&
|
||||
Object.keys(outputSchema).length === 0;
|
||||
|
||||
if (!isDefined(outputSchema) || isEmptyOutputSchema) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resolved = resolveVariablePathInOutputSchema({
|
||||
schema: outputSchema,
|
||||
propertyPath,
|
||||
});
|
||||
|
||||
if (!resolved.found) {
|
||||
const suggestions = getVariablePathSuggestions({
|
||||
schema: outputSchema,
|
||||
propertyPath,
|
||||
referencedStepId,
|
||||
});
|
||||
|
||||
const availablePaths = collectAvailablePaths(
|
||||
outputSchema,
|
||||
referencedStepId,
|
||||
);
|
||||
|
||||
const hint = isNonEmptyArray(suggestions)
|
||||
? `Did you mean "{{${suggestions[0]}}}"?${
|
||||
suggestions.length > 1
|
||||
? ` Other options: ${suggestions
|
||||
.slice(1)
|
||||
.map((suggestion) => `{{${suggestion}}}`)
|
||||
.join(', ')}.`
|
||||
: ''
|
||||
}`
|
||||
: isNonEmptyArray(availablePaths)
|
||||
? `Available paths: ${availablePaths.map((path) => `{{${path}}}`).join(', ')}.`
|
||||
: undefined;
|
||||
|
||||
return [
|
||||
{
|
||||
severity: 'error',
|
||||
code: 'VARIABLE_PATH_NOT_FOUND',
|
||||
message: `Step "${step.name ?? step.id}" references variable "{{${variable}}}" but the path "${propertyPath.join('.')}" was not found in the output of step "${referencedStepId}".`,
|
||||
stepId: step.id,
|
||||
path: variable,
|
||||
...(isDefined(hint) ? { hint } : {}),
|
||||
...(isNonEmptyArray(suggestions) ? { suggestions } : {}),
|
||||
...(isNonEmptyArray(availablePaths) ? { availablePaths } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const MAX_AVAILABLE_PATHS = 20;
|
||||
|
||||
const collectAvailablePaths = (
|
||||
outputSchema: unknown,
|
||||
referencedStepId: string,
|
||||
): string[] =>
|
||||
collectOutputSchemaVariablePaths(outputSchema)
|
||||
.slice(0, MAX_AVAILABLE_PATHS)
|
||||
.map((path) => `${referencedStepId}.${path}`);
|
||||
@@ -0,0 +1,74 @@
|
||||
import { isDefined } from '@/utils';
|
||||
import {
|
||||
type ValidatableWorkflow,
|
||||
type ValidatableWorkflowStep,
|
||||
type WorkflowValidationIssue,
|
||||
type WorkflowValidationResult,
|
||||
} from '@/workflow/validation/types/workflow-validation.type';
|
||||
import { buildWorkflowGraph } from '@/workflow/validation/utils/build-workflow-graph.util';
|
||||
import { validateWorkflowGraph } from '@/workflow/validation/utils/validate-workflow-graph.util';
|
||||
import { validateWorkflowStepParams } from '@/workflow/validation/utils/validate-workflow-step-params.util';
|
||||
import { validateWorkflowVariableReferences } from '@/workflow/validation/utils/validate-workflow-variable-references.util';
|
||||
|
||||
const buildResult = (
|
||||
issues: WorkflowValidationIssue[],
|
||||
): WorkflowValidationResult => {
|
||||
const errors = issues.filter((issue) => issue.severity === 'error');
|
||||
const warnings = issues.filter((issue) => issue.severity === 'warning');
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
};
|
||||
|
||||
export const validateWorkflowStructure = (
|
||||
workflow: ValidatableWorkflow,
|
||||
): WorkflowValidationResult => {
|
||||
const issues: WorkflowValidationIssue[] = [];
|
||||
|
||||
if (!isDefined(workflow.trigger)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'MISSING_TRIGGER',
|
||||
message: 'The workflow has no trigger.',
|
||||
});
|
||||
} else if (!isDefined(workflow.trigger.type)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'MISSING_TRIGGER_TYPE',
|
||||
message: 'The workflow trigger has no type.',
|
||||
});
|
||||
}
|
||||
|
||||
const steps = workflow.steps ?? [];
|
||||
|
||||
if (steps.length === 0) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'NO_STEPS',
|
||||
message: 'The workflow has no steps.',
|
||||
});
|
||||
|
||||
return buildResult(issues);
|
||||
}
|
||||
|
||||
const stepsById = new Map<string, ValidatableWorkflowStep>(
|
||||
steps.map((step) => [step.id, step]),
|
||||
);
|
||||
|
||||
const graph = buildWorkflowGraph(workflow);
|
||||
|
||||
issues.push(...validateWorkflowGraph({ workflow, graph }));
|
||||
issues.push(...validateWorkflowStepParams(workflow));
|
||||
issues.push(
|
||||
...validateWorkflowVariableReferences({
|
||||
workflow,
|
||||
graph,
|
||||
stepsById,
|
||||
}),
|
||||
);
|
||||
|
||||
return buildResult(issues);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { isDefined } from '@/utils';
|
||||
import { type BaseOutputSchemaV2 } from '@/workflow/workflow-schema/types/base-output-schema.type';
|
||||
import { isBoolean, isObject } from 'class-validator';
|
||||
|
||||
export const isBaseOutputSchemaV2 = (
|
||||
value: unknown,
|
||||
): value is BaseOutputSchemaV2 => {
|
||||
if (!isDefined(value) || !isObject(value) || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const entries = Object.values(value as Record<string, unknown>);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return entries.every(
|
||||
(entry) =>
|
||||
isDefined(entry) &&
|
||||
isObject(entry) &&
|
||||
isBoolean((entry as Record<string, unknown>)['isLeaf']),
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export { isBaseOutputSchemaV2 } from './guards/isBaseOutputSchemaV2';
|
||||
export type {
|
||||
BaseOutputSchemaV2,
|
||||
Leaf,
|
||||
@@ -5,4 +6,36 @@ export type {
|
||||
Node,
|
||||
NodeType,
|
||||
} from './types/base-output-schema.type';
|
||||
export { navigateOutputSchemaProperty } from './utils/navigateOutputSchemaProperty';
|
||||
export { collectOutputSchemaPaths } from './utils/collect-output-schema-paths';
|
||||
export {
|
||||
findOutputSchemaPathFailure,
|
||||
type OutputSchemaPathFailure,
|
||||
} from './utils/find-output-schema-path-failure';
|
||||
export { navigateOutputSchemaProperty } from './utils/navigate-output-schema-property';
|
||||
export {
|
||||
collectOutputSchemaVariablePaths,
|
||||
resolveVariablePathInOutputSchema,
|
||||
type ResolvedVariable,
|
||||
} from './utils/resolve-variable-path-in-output-schema';
|
||||
export type {
|
||||
CodeOutputSchema,
|
||||
FieldOutputSchemaV2,
|
||||
FindRecordsOutputSchema,
|
||||
FormFieldLeaf,
|
||||
FormFieldNode,
|
||||
FormOutputSchema,
|
||||
IteratorOutputSchema,
|
||||
LinkOutputSchema,
|
||||
ManualTriggerOutputSchema,
|
||||
OutputSchemaV2,
|
||||
RecordFieldLeaf,
|
||||
RecordFieldNode,
|
||||
RecordFieldNodeValue,
|
||||
RecordNode,
|
||||
RecordOutputSchemaV2,
|
||||
VariableSearchResult,
|
||||
} from './types/output-schema.type';
|
||||
export {
|
||||
searchRecordOutputSchema,
|
||||
searchVariableInOutputSchema,
|
||||
} from './utils/search-variable-in-output-schema';
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { type FieldMetadataType } from '@/types/FieldMetadataType';
|
||||
|
||||
import {
|
||||
type BaseOutputSchemaV2,
|
||||
type Leaf,
|
||||
type Node,
|
||||
} from './base-output-schema.type';
|
||||
|
||||
export type RecordFieldLeaf = {
|
||||
isLeaf: true;
|
||||
icon?: string;
|
||||
type: FieldMetadataType;
|
||||
label: string;
|
||||
value: any;
|
||||
fieldMetadataId: string;
|
||||
isCompositeSubField: boolean;
|
||||
};
|
||||
|
||||
export type RecordFieldNode = {
|
||||
isLeaf: false;
|
||||
icon?: string;
|
||||
type: FieldMetadataType;
|
||||
label: string;
|
||||
value: RecordFieldNodeValue;
|
||||
fieldMetadataId: string;
|
||||
};
|
||||
|
||||
export type RecordFieldNodeValue =
|
||||
| RecordOutputSchemaV2
|
||||
| Record<string, RecordFieldLeaf>;
|
||||
|
||||
export type FieldOutputSchemaV2 = RecordFieldLeaf | RecordFieldNode;
|
||||
|
||||
export type RecordOutputSchemaV2 = {
|
||||
object: {
|
||||
icon?: string;
|
||||
label: string;
|
||||
objectMetadataId: string;
|
||||
isRelationField?: boolean;
|
||||
fieldIdName?: string;
|
||||
};
|
||||
fields: Record<string, FieldOutputSchemaV2>;
|
||||
_outputSchemaType: 'RECORD';
|
||||
};
|
||||
|
||||
export type RecordNode = {
|
||||
isLeaf: false;
|
||||
icon?: string;
|
||||
label: string;
|
||||
value: RecordOutputSchemaV2;
|
||||
};
|
||||
|
||||
export type FindRecordsOutputSchema = {
|
||||
first: RecordNode;
|
||||
all: Leaf | undefined;
|
||||
totalCount: Leaf;
|
||||
};
|
||||
|
||||
export type IteratorOutputSchema = {
|
||||
currentItem: RecordNode | Leaf | Node;
|
||||
currentItemIndex: number;
|
||||
hasProcessedAllItems: boolean;
|
||||
};
|
||||
|
||||
export type FormFieldLeaf = {
|
||||
isLeaf: true;
|
||||
type: FieldMetadataType;
|
||||
label: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
export type FormFieldNode = {
|
||||
isLeaf: false;
|
||||
label: string;
|
||||
value: RecordOutputSchemaV2;
|
||||
};
|
||||
|
||||
export type FormOutputSchema = Record<string, FormFieldLeaf | FormFieldNode>;
|
||||
|
||||
export type LinkOutputSchema = {
|
||||
link: { isLeaf: true; tab?: string; label?: string };
|
||||
_outputSchemaType: 'LINK';
|
||||
};
|
||||
|
||||
export type CodeOutputSchema = LinkOutputSchema | BaseOutputSchemaV2;
|
||||
|
||||
export type ManualTriggerOutputSchema =
|
||||
| BaseOutputSchemaV2
|
||||
| RecordOutputSchemaV2;
|
||||
|
||||
export type OutputSchemaV2 =
|
||||
| BaseOutputSchemaV2
|
||||
| CodeOutputSchema
|
||||
| FindRecordsOutputSchema
|
||||
| FormOutputSchema
|
||||
| RecordOutputSchemaV2
|
||||
| ManualTriggerOutputSchema
|
||||
| IteratorOutputSchema;
|
||||
|
||||
export type VariableSearchResult = {
|
||||
variableLabel: string | undefined;
|
||||
variablePathLabel: string | undefined;
|
||||
variableType?: string;
|
||||
fieldMetadataId?: string;
|
||||
compositeFieldSubFieldName?: string;
|
||||
};
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { type BaseOutputSchemaV2 } from '../../types/base-output-schema.type';
|
||||
import { collectOutputSchemaPaths } from '../collect-output-schema-paths';
|
||||
|
||||
describe('collectOutputSchemaPaths', () => {
|
||||
const testSchema: BaseOutputSchemaV2 = {
|
||||
user: {
|
||||
isLeaf: false,
|
||||
type: 'object',
|
||||
label: 'user',
|
||||
value: {
|
||||
name: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'name',
|
||||
value: 'Test',
|
||||
},
|
||||
},
|
||||
},
|
||||
id: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'id',
|
||||
value: '1',
|
||||
},
|
||||
};
|
||||
|
||||
it('should enumerate both intermediate nodes and leaves', () => {
|
||||
expect(collectOutputSchemaPaths(testSchema)).toEqual([
|
||||
'user',
|
||||
'user.name',
|
||||
'id',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return an empty array for an empty schema', () => {
|
||||
expect(collectOutputSchemaPaths({})).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not throw when a non-leaf node has a null value', () => {
|
||||
const schemaWithNullNode = {
|
||||
data: {
|
||||
isLeaf: false,
|
||||
type: 'object',
|
||||
label: 'Data',
|
||||
value: null,
|
||||
},
|
||||
id: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'id',
|
||||
value: '1',
|
||||
},
|
||||
} as unknown as BaseOutputSchemaV2;
|
||||
|
||||
expect(collectOutputSchemaPaths(schemaWithNullNode)).toEqual([
|
||||
'data',
|
||||
'id',
|
||||
]);
|
||||
});
|
||||
});
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { type BaseOutputSchemaV2 } from '../../types/base-output-schema.type';
|
||||
import { findOutputSchemaPathFailure } from '../find-output-schema-path-failure';
|
||||
|
||||
describe('findOutputSchemaPathFailure', () => {
|
||||
const testSchema: BaseOutputSchemaV2 = {
|
||||
user: {
|
||||
isLeaf: false,
|
||||
type: 'object',
|
||||
label: 'user',
|
||||
value: {
|
||||
name: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'name',
|
||||
value: 'Test',
|
||||
},
|
||||
email: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'email',
|
||||
value: 'test@test.com',
|
||||
},
|
||||
},
|
||||
},
|
||||
id: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'id',
|
||||
value: '1',
|
||||
},
|
||||
};
|
||||
|
||||
it('should return undefined when the path resolves fully', () => {
|
||||
expect(
|
||||
findOutputSchemaPathFailure({
|
||||
schema: testSchema,
|
||||
propertyPath: ['user', 'name'],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should report a failing top-level segment with its siblings', () => {
|
||||
expect(
|
||||
findOutputSchemaPathFailure({
|
||||
schema: testSchema,
|
||||
propertyPath: ['usr'],
|
||||
}),
|
||||
).toEqual({
|
||||
validPrefix: [],
|
||||
failedSegment: 'usr',
|
||||
availableKeys: ['user', 'id'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should report a failing nested segment with sibling keys at that level', () => {
|
||||
expect(
|
||||
findOutputSchemaPathFailure({
|
||||
schema: testSchema,
|
||||
propertyPath: ['user', 'naem'],
|
||||
}),
|
||||
).toEqual({
|
||||
validPrefix: ['user'],
|
||||
failedSegment: 'naem',
|
||||
availableKeys: ['name', 'email'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should report a failure when descending past a leaf', () => {
|
||||
expect(
|
||||
findOutputSchemaPathFailure({
|
||||
schema: testSchema,
|
||||
propertyPath: ['id', 'deeper'],
|
||||
}),
|
||||
).toEqual({
|
||||
validPrefix: ['id'],
|
||||
failedSegment: 'deeper',
|
||||
availableKeys: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not throw when descending into a non-leaf node with a null value', () => {
|
||||
const schemaWithNullNode = {
|
||||
data: {
|
||||
isLeaf: false,
|
||||
type: 'object',
|
||||
label: 'Data',
|
||||
value: null,
|
||||
},
|
||||
} as unknown as BaseOutputSchemaV2;
|
||||
|
||||
expect(
|
||||
findOutputSchemaPathFailure({
|
||||
schema: schemaWithNullNode,
|
||||
propertyPath: ['data', 'missingChild'],
|
||||
}),
|
||||
).toEqual({
|
||||
validPrefix: ['data'],
|
||||
failedSegment: 'missingChild',
|
||||
availableKeys: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { type BaseOutputSchemaV2 } from '../../types/base-output-schema.type';
|
||||
import { navigateOutputSchemaProperty } from '../navigateOutputSchemaProperty';
|
||||
import { navigateOutputSchemaProperty } from '../navigate-output-schema-property';
|
||||
|
||||
describe('navigateOutputSchemaProperty', () => {
|
||||
const testSchema: BaseOutputSchemaV2 = {
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
import { resolveVariablePathInOutputSchema } from '../resolve-variable-path-in-output-schema';
|
||||
|
||||
const databaseEventTriggerSchema = {
|
||||
object: {
|
||||
label: 'Task',
|
||||
objectMetadataId: 'object-1',
|
||||
fieldIdName: 'properties.after.id',
|
||||
},
|
||||
fields: {
|
||||
'properties.after.status': {
|
||||
isLeaf: true,
|
||||
type: 'SELECT',
|
||||
label: 'Status',
|
||||
value: 'My text',
|
||||
fieldMetadataId: 'field-status',
|
||||
},
|
||||
'properties.after.name': {
|
||||
isLeaf: false,
|
||||
type: 'FULL_NAME',
|
||||
label: 'Name',
|
||||
fieldMetadataId: 'field-name',
|
||||
value: {
|
||||
firstName: {
|
||||
isLeaf: true,
|
||||
type: 'TEXT',
|
||||
label: 'First Name',
|
||||
value: 'Tim',
|
||||
fieldMetadataId: 'field-name',
|
||||
isCompositeSubField: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_outputSchemaType: 'RECORD',
|
||||
};
|
||||
|
||||
describe('resolveVariablePathInOutputSchema', () => {
|
||||
describe('database event record schema', () => {
|
||||
it('should resolve a dotted event-prefixed field path', () => {
|
||||
const result = resolveVariablePathInOutputSchema({
|
||||
schema: databaseEventTriggerSchema,
|
||||
propertyPath: ['properties', 'after', 'status'],
|
||||
});
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.type).toBe('SELECT');
|
||||
expect(result.label).toBe('Status');
|
||||
});
|
||||
|
||||
it('should resolve a composite sub-field under an event prefix', () => {
|
||||
const result = resolveVariablePathInOutputSchema({
|
||||
schema: databaseEventTriggerSchema,
|
||||
propertyPath: ['properties', 'after', 'name', 'firstName'],
|
||||
});
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.type).toBe('TEXT');
|
||||
});
|
||||
|
||||
it('should not resolve an "object.*" path that does not match the real keys', () => {
|
||||
const result = resolveVariablePathInOutputSchema({
|
||||
schema: databaseEventTriggerSchema,
|
||||
propertyPath: ['object', 'status'],
|
||||
});
|
||||
|
||||
expect(result.found).toBe(false);
|
||||
});
|
||||
|
||||
it('should not resolve an unknown field', () => {
|
||||
const result = resolveVariablePathInOutputSchema({
|
||||
schema: databaseEventTriggerSchema,
|
||||
propertyPath: ['properties', 'after', 'statuss'],
|
||||
});
|
||||
|
||||
expect(result.found).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('base output schema', () => {
|
||||
const baseSchema = {
|
||||
success: { isLeaf: true, type: 'boolean', label: 'Success', value: true },
|
||||
};
|
||||
|
||||
it('should resolve a top-level leaf', () => {
|
||||
expect(
|
||||
resolveVariablePathInOutputSchema({
|
||||
schema: baseSchema,
|
||||
propertyPath: ['success'],
|
||||
}).found,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should not resolve a missing leaf', () => {
|
||||
expect(
|
||||
resolveVariablePathInOutputSchema({
|
||||
schema: baseSchema,
|
||||
propertyPath: ['failure'],
|
||||
}).found,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('find records schema', () => {
|
||||
const findRecordsSchema = {
|
||||
first: {
|
||||
isLeaf: false,
|
||||
label: 'First Task',
|
||||
value: {
|
||||
object: {
|
||||
label: 'Task',
|
||||
objectMetadataId: 'object-1',
|
||||
fieldIdName: 'id',
|
||||
},
|
||||
fields: {
|
||||
title: {
|
||||
isLeaf: true,
|
||||
type: 'TEXT',
|
||||
label: 'Title',
|
||||
value: 'My text',
|
||||
fieldMetadataId: 'field-title',
|
||||
},
|
||||
},
|
||||
_outputSchemaType: 'RECORD',
|
||||
},
|
||||
},
|
||||
all: {
|
||||
isLeaf: true,
|
||||
type: 'array',
|
||||
label: 'All Records',
|
||||
value: 'Returns an array of records',
|
||||
},
|
||||
totalCount: {
|
||||
isLeaf: true,
|
||||
type: 'number',
|
||||
label: 'Total Count',
|
||||
value: 'Count of matching records',
|
||||
},
|
||||
};
|
||||
|
||||
it('should resolve a field under "first"', () => {
|
||||
expect(
|
||||
resolveVariablePathInOutputSchema({
|
||||
schema: findRecordsSchema,
|
||||
propertyPath: ['first', 'title'],
|
||||
}).found,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should resolve the terminal "first" node', () => {
|
||||
const result = resolveVariablePathInOutputSchema({
|
||||
schema: findRecordsSchema,
|
||||
propertyPath: ['first'],
|
||||
});
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.label).toBe('First Task');
|
||||
});
|
||||
|
||||
it('should resolve totalCount', () => {
|
||||
expect(
|
||||
resolveVariablePathInOutputSchema({
|
||||
schema: findRecordsSchema,
|
||||
propertyPath: ['totalCount'],
|
||||
}).found,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should not resolve a missing field under "first"', () => {
|
||||
expect(
|
||||
resolveVariablePathInOutputSchema({
|
||||
schema: findRecordsSchema,
|
||||
propertyPath: ['first', 'missing'],
|
||||
}).found,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('code step schema that mimics find records keys', () => {
|
||||
// A CODE step can return an arbitrary object whose keys happen to match
|
||||
// "first" and "totalCount". Because "first.value" is not a RECORD output
|
||||
// schema, it must be treated as a generic map, not a Find Records schema.
|
||||
const codeStepSchema = {
|
||||
first: { isLeaf: true, type: 'string', label: 'First', value: 'a' },
|
||||
totalCount: { isLeaf: true, type: 'number', label: 'Count', value: 1 },
|
||||
foo: { isLeaf: true, type: 'string', label: 'Foo', value: 'bar' },
|
||||
};
|
||||
|
||||
it('should resolve the terminal "first" leaf', () => {
|
||||
expect(
|
||||
resolveVariablePathInOutputSchema({
|
||||
schema: codeStepSchema,
|
||||
propertyPath: ['first'],
|
||||
}).found,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should resolve other top-level fields not handled by find records logic', () => {
|
||||
expect(
|
||||
resolveVariablePathInOutputSchema({
|
||||
schema: codeStepSchema,
|
||||
propertyPath: ['foo'],
|
||||
}).found,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
import { type BaseOutputSchemaV2 } from '../../types/base-output-schema.type';
|
||||
import { searchVariableInOutputSchema } from '../search-variable-in-output-schema';
|
||||
|
||||
// Pins the dispatcher to a non-record step type so it resolves through the base schema branch
|
||||
const searchVariableThroughBaseOutputSchema = ({
|
||||
stepName,
|
||||
baseOutputSchema,
|
||||
rawVariableName,
|
||||
}: {
|
||||
stepName: string;
|
||||
baseOutputSchema: BaseOutputSchemaV2;
|
||||
rawVariableName: string;
|
||||
}) =>
|
||||
searchVariableInOutputSchema({
|
||||
schema: baseOutputSchema,
|
||||
stepType: 'CODE',
|
||||
stepName,
|
||||
rawVariableName,
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
describe('searchVariableInOutputSchema - base output schema', () => {
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
import { type BaseOutputSchemaV2 } from '../../types/base-output-schema.type';
|
||||
import { type CodeOutputSchema } from '../../types/output-schema.type';
|
||||
import { searchVariableInOutputSchema } from '../search-variable-in-output-schema';
|
||||
|
||||
const searchVariableThroughCodeOutputSchema = ({
|
||||
stepName,
|
||||
codeOutputSchema,
|
||||
rawVariableName,
|
||||
}: {
|
||||
stepName: string;
|
||||
codeOutputSchema: CodeOutputSchema;
|
||||
rawVariableName: string;
|
||||
}) =>
|
||||
searchVariableInOutputSchema({
|
||||
schema: codeOutputSchema,
|
||||
stepType: 'CODE',
|
||||
stepName,
|
||||
rawVariableName,
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
describe('searchVariableInOutputSchema - code output schema', () => {
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
import { FieldMetadataType } from '@/types/FieldMetadataType';
|
||||
import {
|
||||
type FindRecordsOutputSchema,
|
||||
type RecordOutputSchemaV2,
|
||||
} from '../../types/output-schema.type';
|
||||
import { searchVariableInOutputSchema } from '../search-variable-in-output-schema';
|
||||
|
||||
const searchVariableThroughFindRecordsOutputSchema = ({
|
||||
stepName,
|
||||
searchRecordOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
}: {
|
||||
stepName: string;
|
||||
searchRecordOutputSchema: FindRecordsOutputSchema;
|
||||
rawVariableName: string;
|
||||
isFullRecord: boolean;
|
||||
}) =>
|
||||
searchVariableInOutputSchema({
|
||||
schema: searchRecordOutputSchema,
|
||||
stepType: 'FIND_RECORDS',
|
||||
stepName,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
});
|
||||
|
||||
describe('searchVariableInOutputSchema - find records output schema', () => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
import { FieldMetadataType } from '@/types/FieldMetadataType';
|
||||
import {
|
||||
type FormOutputSchema,
|
||||
type RecordOutputSchemaV2,
|
||||
} from '../../types/output-schema.type';
|
||||
import { searchVariableInOutputSchema } from '../search-variable-in-output-schema';
|
||||
|
||||
const searchVariableThroughFormOutputSchema = ({
|
||||
stepName,
|
||||
formOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
}: {
|
||||
stepName: string;
|
||||
formOutputSchema: FormOutputSchema;
|
||||
rawVariableName: string;
|
||||
isFullRecord: boolean;
|
||||
}) =>
|
||||
searchVariableInOutputSchema({
|
||||
schema: formOutputSchema,
|
||||
stepType: 'FORM',
|
||||
stepName,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
});
|
||||
|
||||
describe('searchVariableInOutputSchema - form output schema', () => {
|
||||
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', () => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
import { FieldMetadataType } from '@/types/FieldMetadataType';
|
||||
import {
|
||||
type IteratorOutputSchema,
|
||||
type RecordOutputSchemaV2,
|
||||
} from '../../types/output-schema.type';
|
||||
import { searchVariableInOutputSchema } from '../search-variable-in-output-schema';
|
||||
|
||||
const searchVariableThroughIteratorOutputSchema = ({
|
||||
stepName,
|
||||
iteratorOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
}: {
|
||||
stepName: string;
|
||||
iteratorOutputSchema: IteratorOutputSchema;
|
||||
rawVariableName: string;
|
||||
isFullRecord: boolean;
|
||||
}) =>
|
||||
searchVariableInOutputSchema({
|
||||
schema: iteratorOutputSchema,
|
||||
stepType: 'ITERATOR',
|
||||
stepName,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
});
|
||||
|
||||
describe('searchVariableInOutputSchema - iterator output schema', () => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+510
@@ -0,0 +1,510 @@
|
||||
import { FieldMetadataType } from '@/types/FieldMetadataType';
|
||||
import { type RecordOutputSchemaV2 } from '../../types/output-schema.type';
|
||||
import { searchVariableInOutputSchema } from '../search-variable-in-output-schema';
|
||||
|
||||
const searchVariableThroughRecordEventOutputSchema = ({
|
||||
stepName,
|
||||
recordOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
}: {
|
||||
stepName: string;
|
||||
recordOutputSchema: RecordOutputSchemaV2;
|
||||
rawVariableName: string;
|
||||
isFullRecord: boolean;
|
||||
}) =>
|
||||
searchVariableInOutputSchema({
|
||||
schema: recordOutputSchema,
|
||||
stepType: 'DATABASE_EVENT',
|
||||
stepName,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
});
|
||||
|
||||
describe('searchVariableInOutputSchema - record event output schema', () => {
|
||||
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,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
import { FieldMetadataType } from '@/types/FieldMetadataType';
|
||||
import { type RecordOutputSchemaV2 } from '../../types/output-schema.type';
|
||||
import { searchVariableInOutputSchema } from '../search-variable-in-output-schema';
|
||||
|
||||
const searchVariableThroughRecordOutputSchema = ({
|
||||
stepName,
|
||||
recordOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
}: {
|
||||
stepName: string;
|
||||
recordOutputSchema: RecordOutputSchemaV2;
|
||||
rawVariableName: string;
|
||||
isFullRecord: boolean;
|
||||
}) =>
|
||||
searchVariableInOutputSchema({
|
||||
schema: recordOutputSchema,
|
||||
stepType: 'CREATE_RECORD',
|
||||
stepName,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
});
|
||||
|
||||
describe('searchVariableInOutputSchema - record output schema', () => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { isObject } from '@sniptt/guards';
|
||||
|
||||
import { type BaseOutputSchemaV2 } from '@/workflow/workflow-schema/types/base-output-schema.type';
|
||||
|
||||
export const collectOutputSchemaPaths = (
|
||||
schema: BaseOutputSchemaV2,
|
||||
prefix: string[] = [],
|
||||
): string[] => {
|
||||
const paths: string[] = [];
|
||||
|
||||
if (!isObject(schema)) {
|
||||
return paths;
|
||||
}
|
||||
|
||||
for (const [key, field] of Object.entries(schema)) {
|
||||
if (!isObject(field)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentPath = [...prefix, key];
|
||||
|
||||
paths.push(currentPath.join('.'));
|
||||
|
||||
if (!field.isLeaf && isObject(field.value)) {
|
||||
paths.push(...collectOutputSchemaPaths(field.value, currentPath));
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
};
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { isObject } from '@sniptt/guards';
|
||||
|
||||
import { isDefined } from '@/utils';
|
||||
import { type BaseOutputSchemaV2 } from '@/workflow/workflow-schema/types/base-output-schema.type';
|
||||
|
||||
export type OutputSchemaPathFailure = {
|
||||
validPrefix: string[];
|
||||
failedSegment: string;
|
||||
availableKeys: string[];
|
||||
};
|
||||
|
||||
export const findOutputSchemaPathFailure = ({
|
||||
schema,
|
||||
propertyPath,
|
||||
}: {
|
||||
schema: BaseOutputSchemaV2;
|
||||
propertyPath: string[];
|
||||
}): OutputSchemaPathFailure | undefined => {
|
||||
let currentSchema: BaseOutputSchemaV2 = schema;
|
||||
|
||||
for (let index = 0; index < propertyPath.length; index++) {
|
||||
if (!isObject(currentSchema)) {
|
||||
return {
|
||||
validPrefix: propertyPath.slice(0, index),
|
||||
failedSegment: propertyPath[index],
|
||||
availableKeys: [],
|
||||
};
|
||||
}
|
||||
|
||||
const segment = propertyPath[index];
|
||||
const field = currentSchema[segment];
|
||||
|
||||
if (!isDefined(field)) {
|
||||
return {
|
||||
validPrefix: propertyPath.slice(0, index),
|
||||
failedSegment: segment,
|
||||
availableKeys: Object.keys(currentSchema),
|
||||
};
|
||||
}
|
||||
|
||||
if (field.isLeaf) {
|
||||
const isLastSegment = index === propertyPath.length - 1;
|
||||
|
||||
if (!isLastSegment) {
|
||||
return {
|
||||
validPrefix: propertyPath.slice(0, index + 1),
|
||||
failedSegment: propertyPath[index + 1],
|
||||
availableKeys: [],
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
currentSchema = field.value;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
import { isDefined, isPlainObject } from '@/utils';
|
||||
import { isBoolean, isString } from 'class-validator';
|
||||
|
||||
export type ResolvedVariable = {
|
||||
found: boolean;
|
||||
type?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const NOT_FOUND: ResolvedVariable = { found: false };
|
||||
|
||||
type SchemaField = {
|
||||
isLeaf: boolean;
|
||||
type?: string;
|
||||
label?: string;
|
||||
value?: unknown;
|
||||
};
|
||||
|
||||
const isSchemaField = (value: unknown): value is SchemaField =>
|
||||
isPlainObject(value) && isBoolean(value.isLeaf);
|
||||
|
||||
const isRecordOutputSchema = (
|
||||
value: unknown,
|
||||
): value is { fields: Record<string, unknown> } =>
|
||||
isPlainObject(value) &&
|
||||
value['_outputSchemaType'] === 'RECORD' &&
|
||||
isPlainObject(value['fields']);
|
||||
|
||||
const isFindRecordsOutputSchema = (
|
||||
value: unknown,
|
||||
): value is { first: SchemaField; all?: unknown; totalCount: SchemaField } =>
|
||||
isPlainObject(value) &&
|
||||
!('_outputSchemaType' in value) &&
|
||||
isSchemaField(value['first']) &&
|
||||
value['first'].isLeaf === false &&
|
||||
isRecordOutputSchema(value['first'].value) &&
|
||||
isSchemaField(value['totalCount']);
|
||||
|
||||
const fieldResult = (field: SchemaField): ResolvedVariable => ({
|
||||
found: true,
|
||||
type: isString(field.type) ? field.type : undefined,
|
||||
label: isString(field.label) ? field.label : undefined,
|
||||
});
|
||||
|
||||
const descendIntoField = (
|
||||
field: SchemaField,
|
||||
segments: string[],
|
||||
): ResolvedVariable => {
|
||||
if (segments.length === 0) {
|
||||
return fieldResult(field);
|
||||
}
|
||||
|
||||
return resolveInSchema(field.value, segments);
|
||||
};
|
||||
|
||||
const resolveInFieldsMap = (
|
||||
fields: Record<string, unknown>,
|
||||
segments: string[],
|
||||
): ResolvedVariable => {
|
||||
for (let length = 1; length <= segments.length; length++) {
|
||||
const candidateKey = segments.slice(0, length).join('.');
|
||||
const field = fields[candidateKey];
|
||||
|
||||
if (isSchemaField(field)) {
|
||||
return descendIntoField(field, segments.slice(length));
|
||||
}
|
||||
}
|
||||
|
||||
return NOT_FOUND;
|
||||
};
|
||||
|
||||
const resolveInFindRecords = (
|
||||
schema: { first: SchemaField; totalCount: unknown },
|
||||
segments: string[],
|
||||
): ResolvedVariable => {
|
||||
const [searchResultKey, ...rest] = segments;
|
||||
|
||||
if (searchResultKey === 'first') {
|
||||
if (rest.length === 0) {
|
||||
return fieldResult(schema.first);
|
||||
}
|
||||
|
||||
return resolveInSchema(schema.first.value, rest);
|
||||
}
|
||||
|
||||
if (searchResultKey === 'all' || searchResultKey === 'totalCount') {
|
||||
const field = (schema as Record<string, unknown>)[searchResultKey];
|
||||
|
||||
if (rest.length === 0 && isSchemaField(field)) {
|
||||
return fieldResult(field);
|
||||
}
|
||||
|
||||
return NOT_FOUND;
|
||||
}
|
||||
|
||||
return NOT_FOUND;
|
||||
};
|
||||
|
||||
const resolveInGenericMap = (
|
||||
map: Record<string, unknown>,
|
||||
segments: string[],
|
||||
): ResolvedVariable => {
|
||||
const [segment, ...rest] = segments;
|
||||
const field = map[segment];
|
||||
|
||||
if (isSchemaField(field)) {
|
||||
return descendIntoField(field, rest);
|
||||
}
|
||||
|
||||
if (isDefined(field)) {
|
||||
if (rest.length === 0) {
|
||||
return { found: true };
|
||||
}
|
||||
|
||||
if (isPlainObject(field)) {
|
||||
return resolveInGenericMap(field, rest);
|
||||
}
|
||||
|
||||
return NOT_FOUND;
|
||||
}
|
||||
|
||||
return NOT_FOUND;
|
||||
};
|
||||
|
||||
export const resolveInSchema = (
|
||||
schema: unknown,
|
||||
segments: string[],
|
||||
): ResolvedVariable => {
|
||||
if (segments.length === 0 || !isPlainObject(schema)) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
|
||||
if (isRecordOutputSchema(schema)) {
|
||||
return resolveInFieldsMap(schema.fields, segments);
|
||||
}
|
||||
|
||||
if (isFindRecordsOutputSchema(schema)) {
|
||||
return resolveInFindRecords(schema, segments);
|
||||
}
|
||||
|
||||
return resolveInGenericMap(schema, segments);
|
||||
};
|
||||
|
||||
export const resolveVariablePathInOutputSchema = ({
|
||||
schema,
|
||||
propertyPath,
|
||||
}: {
|
||||
schema: unknown;
|
||||
propertyPath: string[];
|
||||
}): ResolvedVariable => resolveInSchema(schema, propertyPath);
|
||||
|
||||
const collectFromFieldsMap = (fields: Record<string, unknown>): string[] => {
|
||||
const paths: string[] = [];
|
||||
|
||||
for (const [key, field] of Object.entries(fields)) {
|
||||
if (isSchemaField(field)) {
|
||||
paths.push(key);
|
||||
|
||||
if (field.isLeaf) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = field.value;
|
||||
|
||||
if (isRecordOutputSchema(value)) {
|
||||
for (const sub of collectFromFieldsMap(value.fields)) {
|
||||
paths.push(`${key}.${sub}`);
|
||||
}
|
||||
} else if (isPlainObject(value)) {
|
||||
for (const sub of collectFromFieldsMap(value)) {
|
||||
paths.push(`${key}.${sub}`);
|
||||
}
|
||||
}
|
||||
} else if (isDefined(field)) {
|
||||
paths.push(key);
|
||||
|
||||
if (isPlainObject(field)) {
|
||||
for (const sub of collectFromFieldsMap(field)) {
|
||||
paths.push(`${key}.${sub}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
};
|
||||
|
||||
export const collectOutputSchemaVariablePaths = (schema: unknown): string[] => {
|
||||
if (!isPlainObject(schema)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isRecordOutputSchema(schema)) {
|
||||
return collectFromFieldsMap(schema.fields);
|
||||
}
|
||||
|
||||
if (isFindRecordsOutputSchema(schema)) {
|
||||
const paths: string[] = [];
|
||||
|
||||
if (isSchemaField(schema.first)) {
|
||||
paths.push('first');
|
||||
|
||||
for (const sub of collectOutputSchemaVariablePaths(schema.first.value)) {
|
||||
paths.push(`first.${sub}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (isDefined(schema.all)) {
|
||||
paths.push('all');
|
||||
}
|
||||
|
||||
paths.push('totalCount');
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
return collectFromFieldsMap(schema);
|
||||
};
|
||||
+694
@@ -0,0 +1,694 @@
|
||||
import { isDefined } from '@/utils';
|
||||
import { isObject } from 'class-validator';
|
||||
import { FieldMetadataType } from '@/types/FieldMetadataType';
|
||||
|
||||
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from '../../constants/CaptureAllVariableTagInnerRegex';
|
||||
import { parseVariablePath } from '../../utils/variable-path.util';
|
||||
import { type BaseOutputSchemaV2 } from '../types/base-output-schema.type';
|
||||
import {
|
||||
type FieldOutputSchemaV2,
|
||||
type FindRecordsOutputSchema,
|
||||
type FormOutputSchema,
|
||||
type IteratorOutputSchema,
|
||||
type RecordFieldLeaf,
|
||||
type RecordFieldNodeValue,
|
||||
type RecordOutputSchemaV2,
|
||||
type VariableSearchResult,
|
||||
} from '../types/output-schema.type';
|
||||
|
||||
const EMPTY_RESULT: VariableSearchResult = {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
|
||||
const RECORD_STEP_TYPES = [
|
||||
'CREATE_RECORD',
|
||||
'UPDATE_RECORD',
|
||||
'DELETE_RECORD',
|
||||
'UPSERT_RECORD',
|
||||
];
|
||||
|
||||
const isRecordOutputSchemaV2 = (
|
||||
schema: unknown,
|
||||
): schema is RecordOutputSchemaV2 =>
|
||||
isObject(schema) &&
|
||||
'_outputSchemaType' in schema &&
|
||||
schema._outputSchemaType === 'RECORD';
|
||||
|
||||
const isBaseOutputSchemaV2Shape = (schema: unknown): boolean => {
|
||||
if (!isDefined(schema) || !isObject(schema) || Array.isArray(schema)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !(isObject(schema) && '_outputSchemaType' in schema);
|
||||
};
|
||||
|
||||
const stripBrackets = (rawVariableName: string): string =>
|
||||
rawVariableName.replace(
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
(_, variableName) => variableName,
|
||||
);
|
||||
|
||||
// Record output schema navigation
|
||||
|
||||
const getFieldFromSchema = (
|
||||
fieldKey: string,
|
||||
recordSchema: RecordFieldNodeValue,
|
||||
): FieldOutputSchemaV2 | undefined =>
|
||||
isRecordOutputSchemaV2(recordSchema)
|
||||
? recordSchema.fields[fieldKey]
|
||||
: (recordSchema as Record<string, RecordFieldLeaf>)[fieldKey];
|
||||
|
||||
const getCompositeSubFieldName = (
|
||||
recordSchema: RecordFieldNodeValue,
|
||||
fieldKey: string,
|
||||
): string | undefined => {
|
||||
if (isRecordOutputSchemaV2(recordSchema)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const field = (recordSchema as Record<string, RecordFieldLeaf>)[fieldKey];
|
||||
|
||||
return field?.isCompositeSubField ? fieldKey : undefined;
|
||||
};
|
||||
|
||||
const isIdFieldName = (fieldName: string): boolean =>
|
||||
fieldName === '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;
|
||||
}
|
||||
|
||||
if (isDefined(field.label)) {
|
||||
pathLabels.push(field.label);
|
||||
}
|
||||
|
||||
const nextSchema = field.value;
|
||||
|
||||
if (!isDefined(nextSchema)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
currentSchema = nextSchema as RecordFieldNodeValue;
|
||||
}
|
||||
|
||||
return { schema: currentSchema, pathLabels };
|
||||
};
|
||||
|
||||
const buildRecordVariableResult = (
|
||||
stepName: string,
|
||||
pathLabels: string[],
|
||||
targetSchema: RecordFieldNodeValue,
|
||||
targetFieldName: string,
|
||||
isFullRecord: boolean,
|
||||
stepNameLabel?: string,
|
||||
): VariableSearchResult => {
|
||||
const targetField = getFieldFromSchema(targetFieldName, targetSchema);
|
||||
const variableLabel =
|
||||
isFullRecord &&
|
||||
isRecordOutputSchemaV2(targetSchema) &&
|
||||
isIdFieldName(targetFieldName)
|
||||
? targetSchema.object.label
|
||||
: targetField?.label;
|
||||
|
||||
if (!variableLabel) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
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 EMPTY_RESULT;
|
||||
}
|
||||
|
||||
return buildRecordVariableResult(
|
||||
stepName,
|
||||
navigationResult.pathLabels,
|
||||
navigationResult.schema,
|
||||
selectedField,
|
||||
isFullRecord,
|
||||
stepNameLabel,
|
||||
);
|
||||
};
|
||||
|
||||
// Base output schema navigation
|
||||
|
||||
const navigateBaseToTargetField = (
|
||||
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;
|
||||
}
|
||||
|
||||
if (!isDefined(field.value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
pathLabels.push(field.label);
|
||||
currentSchema = field.value;
|
||||
}
|
||||
|
||||
return { schema: currentSchema, pathLabels };
|
||||
};
|
||||
|
||||
const searchBaseOutputSchema = ({
|
||||
stepName,
|
||||
baseOutputSchema,
|
||||
path,
|
||||
selectedField,
|
||||
}: {
|
||||
stepName: string;
|
||||
baseOutputSchema: BaseOutputSchemaV2;
|
||||
path: string[];
|
||||
selectedField: string;
|
||||
}): VariableSearchResult => {
|
||||
const navigationResult = navigateBaseToTargetField(baseOutputSchema, path);
|
||||
|
||||
if (!navigationResult) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
const targetField = navigationResult.schema[selectedField];
|
||||
|
||||
if (!isDefined(targetField)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
const fullPathSegments = [
|
||||
stepName,
|
||||
...navigationResult.pathLabels,
|
||||
targetField.label,
|
||||
];
|
||||
const variablePathLabel = fullPathSegments.join(' > ');
|
||||
|
||||
return {
|
||||
variableLabel: targetField.label,
|
||||
variablePathLabel,
|
||||
variableType: targetField.type,
|
||||
};
|
||||
};
|
||||
|
||||
// Per-schema-type search functions
|
||||
|
||||
const searchThroughRecordOutputSchema = ({
|
||||
stepName,
|
||||
recordOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
}: {
|
||||
stepName: string;
|
||||
recordOutputSchema: RecordOutputSchemaV2;
|
||||
rawVariableName: string;
|
||||
isFullRecord: boolean;
|
||||
}): VariableSearchResult => {
|
||||
if (!isDefined(recordOutputSchema)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
const parts = parseVariablePath(stripBrackets(rawVariableName));
|
||||
const stepId = parts[0];
|
||||
const fieldName = parts[parts.length - 1];
|
||||
const pathSegments = parts.slice(1, -1);
|
||||
|
||||
if (!isDefined(stepId) || !isDefined(fieldName)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
return searchRecordOutputSchema({
|
||||
stepName,
|
||||
recordOutputSchema,
|
||||
selectedField: fieldName,
|
||||
path: pathSegments,
|
||||
isFullRecord,
|
||||
});
|
||||
};
|
||||
|
||||
const searchThroughRecordEventOutputSchema = ({
|
||||
stepName,
|
||||
recordOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
}: {
|
||||
stepName: string;
|
||||
recordOutputSchema: RecordOutputSchemaV2;
|
||||
rawVariableName: string;
|
||||
isFullRecord: boolean;
|
||||
}): VariableSearchResult => {
|
||||
if (!isDefined(recordOutputSchema)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
const parts = parseVariablePath(stripBrackets(rawVariableName));
|
||||
const stepId = parts[0];
|
||||
const firstFieldWithEventPrefix = parts.slice(1, 4).join('.');
|
||||
const remainingParts = parts.slice(4);
|
||||
const partsWithoutStepId = [firstFieldWithEventPrefix, ...remainingParts];
|
||||
const fieldName = partsWithoutStepId[partsWithoutStepId.length - 1];
|
||||
const pathSegments = partsWithoutStepId.slice(0, -1);
|
||||
|
||||
if (!isDefined(stepId) || !isDefined(fieldName)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
return searchRecordOutputSchema({
|
||||
stepName,
|
||||
recordOutputSchema,
|
||||
selectedField: fieldName,
|
||||
path: pathSegments,
|
||||
isFullRecord,
|
||||
});
|
||||
};
|
||||
|
||||
const searchThroughBaseOutputSchema = ({
|
||||
stepName,
|
||||
baseOutputSchema,
|
||||
rawVariableName,
|
||||
}: {
|
||||
stepName: string;
|
||||
baseOutputSchema: BaseOutputSchemaV2;
|
||||
rawVariableName: string;
|
||||
}): VariableSearchResult => {
|
||||
if (!isDefined(baseOutputSchema)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
const parts = parseVariablePath(stripBrackets(rawVariableName));
|
||||
const stepId = parts[0];
|
||||
const targetFieldName = parts[parts.length - 1];
|
||||
const pathSegments = parts.slice(1, -1);
|
||||
|
||||
if (!isDefined(stepId) || !isDefined(targetFieldName)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
return searchBaseOutputSchema({
|
||||
stepName,
|
||||
baseOutputSchema,
|
||||
path: pathSegments,
|
||||
selectedField: targetFieldName,
|
||||
});
|
||||
};
|
||||
|
||||
const searchThroughFindRecordsOutputSchema = ({
|
||||
stepName,
|
||||
findRecordsOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
stepNameLabel,
|
||||
}: {
|
||||
stepName: string;
|
||||
findRecordsOutputSchema: FindRecordsOutputSchema;
|
||||
rawVariableName: string;
|
||||
isFullRecord: boolean;
|
||||
stepNameLabel?: string;
|
||||
}): VariableSearchResult => {
|
||||
if (!isDefined(findRecordsOutputSchema)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
const parts = parseVariablePath(stripBrackets(rawVariableName));
|
||||
const stepId = parts[0];
|
||||
const searchResultKey = parts[1] as 'first' | 'all' | 'totalCount';
|
||||
const remainingParts = parts.slice(2);
|
||||
|
||||
if (!isDefined(stepId) || !isDefined(searchResultKey)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
if (searchResultKey === 'first') {
|
||||
const recordSchema = findRecordsOutputSchema.first?.value;
|
||||
const fieldName = remainingParts[remainingParts.length - 1];
|
||||
const pathSegments = remainingParts.slice(0, -1);
|
||||
|
||||
if (!isDefined(recordSchema) || !isDefined(fieldName)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
return searchRecordOutputSchema({
|
||||
stepName: `${stepName} > ${findRecordsOutputSchema.first?.label ?? 'First'}`,
|
||||
recordOutputSchema: recordSchema,
|
||||
selectedField: fieldName,
|
||||
path: pathSegments,
|
||||
isFullRecord,
|
||||
stepNameLabel,
|
||||
});
|
||||
}
|
||||
|
||||
if (searchResultKey === 'totalCount') {
|
||||
const label = findRecordsOutputSchema.totalCount?.label ?? 'Total Count';
|
||||
const basePath = `${stepName} > ${label}`;
|
||||
|
||||
return {
|
||||
variableLabel: label,
|
||||
variablePathLabel: stepNameLabel
|
||||
? `${basePath} (${stepNameLabel})`
|
||||
: basePath,
|
||||
variableType: FieldMetadataType.NUMBER,
|
||||
};
|
||||
}
|
||||
|
||||
if (searchResultKey === 'all') {
|
||||
const allField = findRecordsOutputSchema.all;
|
||||
const label = allField?.label ?? 'All Records';
|
||||
const basePath = `${stepName} > ${label}`;
|
||||
|
||||
return {
|
||||
variableLabel: label,
|
||||
variablePathLabel: stepNameLabel
|
||||
? `${basePath} (${stepNameLabel})`
|
||||
: basePath,
|
||||
variableType: FieldMetadataType.ARRAY,
|
||||
};
|
||||
}
|
||||
|
||||
return EMPTY_RESULT;
|
||||
};
|
||||
|
||||
const searchThroughFormOutputSchema = ({
|
||||
stepName,
|
||||
formOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
}: {
|
||||
stepName: string;
|
||||
formOutputSchema: FormOutputSchema;
|
||||
rawVariableName: string;
|
||||
isFullRecord: boolean;
|
||||
}): VariableSearchResult => {
|
||||
if (!isDefined(formOutputSchema)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
const parts = parseVariablePath(stripBrackets(rawVariableName));
|
||||
const stepId = parts[0];
|
||||
const fieldName = parts[1];
|
||||
const remainingParts = parts.slice(2);
|
||||
const recordFieldName = remainingParts[remainingParts.length - 1];
|
||||
const pathSegments = remainingParts.slice(0, -1);
|
||||
|
||||
if (!isDefined(stepId) || !isDefined(fieldName)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
const formField = formOutputSchema[fieldName];
|
||||
|
||||
if (!isDefined(formField)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
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 EMPTY_RESULT;
|
||||
};
|
||||
|
||||
const searchThroughCodeOutputSchema = ({
|
||||
stepName,
|
||||
codeOutputSchema,
|
||||
rawVariableName,
|
||||
}: {
|
||||
stepName: string;
|
||||
codeOutputSchema: unknown;
|
||||
rawVariableName: string;
|
||||
}): VariableSearchResult => {
|
||||
if (!isDefined(codeOutputSchema)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
if (
|
||||
isObject(codeOutputSchema) &&
|
||||
'_outputSchemaType' in codeOutputSchema &&
|
||||
codeOutputSchema._outputSchemaType === 'LINK'
|
||||
) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
return searchThroughBaseOutputSchema({
|
||||
stepName,
|
||||
baseOutputSchema: codeOutputSchema as BaseOutputSchemaV2,
|
||||
rawVariableName,
|
||||
});
|
||||
};
|
||||
|
||||
const searchThroughIteratorOutputSchema = ({
|
||||
stepName,
|
||||
iteratorOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
}: {
|
||||
stepName: string;
|
||||
iteratorOutputSchema: IteratorOutputSchema;
|
||||
rawVariableName: string;
|
||||
isFullRecord: boolean;
|
||||
}): VariableSearchResult => {
|
||||
if (!isDefined(iteratorOutputSchema)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
const parts = parseVariablePath(stripBrackets(rawVariableName));
|
||||
const stepId = parts[0];
|
||||
const iteratorResultKey = parts[1] as
|
||||
| 'currentItem'
|
||||
| 'currentItemIndex'
|
||||
| 'hasProcessedAllItems';
|
||||
const remainingParts = parts.slice(2);
|
||||
|
||||
if (!isDefined(stepId) || !isDefined(iteratorResultKey)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
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 EMPTY_RESULT;
|
||||
}
|
||||
|
||||
const fieldName = remainingParts[remainingParts.length - 1];
|
||||
const pathSegments = remainingParts.slice(0, -1);
|
||||
|
||||
if (isRecordOutputSchemaV2(schema) && isDefined(fieldName)) {
|
||||
return searchRecordOutputSchema({
|
||||
stepName: `${stepName} > Current Item`,
|
||||
recordOutputSchema: schema,
|
||||
path: pathSegments,
|
||||
selectedField: fieldName,
|
||||
isFullRecord,
|
||||
});
|
||||
}
|
||||
|
||||
if (isBaseOutputSchemaV2Shape(schema) && isDefined(fieldName)) {
|
||||
return searchBaseOutputSchema({
|
||||
stepName,
|
||||
baseOutputSchema: schema as BaseOutputSchemaV2,
|
||||
path: pathSegments,
|
||||
selectedField: fieldName,
|
||||
});
|
||||
}
|
||||
|
||||
const currentItem = iteratorOutputSchema.currentItem;
|
||||
|
||||
return {
|
||||
variableLabel: currentItem.label,
|
||||
variablePathLabel: `${stepName} > ${currentItem.label}`,
|
||||
variableType: currentItem.isLeaf ? currentItem.type : 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
return EMPTY_RESULT;
|
||||
};
|
||||
|
||||
const searchThroughManualTriggerOutputSchema = ({
|
||||
stepName,
|
||||
manualTriggerOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
}: {
|
||||
stepName: string;
|
||||
manualTriggerOutputSchema: unknown;
|
||||
rawVariableName: string;
|
||||
isFullRecord: boolean;
|
||||
}): VariableSearchResult => {
|
||||
if (isRecordOutputSchemaV2(manualTriggerOutputSchema)) {
|
||||
return searchThroughRecordOutputSchema({
|
||||
stepName,
|
||||
recordOutputSchema: manualTriggerOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
});
|
||||
}
|
||||
|
||||
return searchThroughBaseOutputSchema({
|
||||
stepName,
|
||||
baseOutputSchema: manualTriggerOutputSchema as BaseOutputSchemaV2,
|
||||
rawVariableName,
|
||||
});
|
||||
};
|
||||
|
||||
// Main dispatcher
|
||||
|
||||
export const searchVariableInOutputSchema = ({
|
||||
schema,
|
||||
stepType,
|
||||
stepName,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
stepNameLabel,
|
||||
}: {
|
||||
schema: unknown;
|
||||
stepType: string;
|
||||
stepName: string;
|
||||
rawVariableName: string;
|
||||
isFullRecord: boolean;
|
||||
stepNameLabel?: string;
|
||||
}): VariableSearchResult => {
|
||||
if (RECORD_STEP_TYPES.includes(stepType)) {
|
||||
return searchThroughRecordOutputSchema({
|
||||
stepName,
|
||||
recordOutputSchema: schema as RecordOutputSchemaV2,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
});
|
||||
}
|
||||
|
||||
if (stepType === 'MANUAL') {
|
||||
return searchThroughManualTriggerOutputSchema({
|
||||
stepName,
|
||||
manualTriggerOutputSchema: schema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
});
|
||||
}
|
||||
|
||||
if (stepType === 'DATABASE_EVENT') {
|
||||
return searchThroughRecordEventOutputSchema({
|
||||
stepName,
|
||||
recordOutputSchema: schema as RecordOutputSchemaV2,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
});
|
||||
}
|
||||
|
||||
if (stepType === 'FIND_RECORDS') {
|
||||
return searchThroughFindRecordsOutputSchema({
|
||||
stepName,
|
||||
findRecordsOutputSchema: schema as FindRecordsOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
stepNameLabel,
|
||||
});
|
||||
}
|
||||
|
||||
if (stepType === 'FORM') {
|
||||
return searchThroughFormOutputSchema({
|
||||
stepName,
|
||||
formOutputSchema: schema as FormOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
});
|
||||
}
|
||||
|
||||
if (stepType === 'CODE') {
|
||||
return searchThroughCodeOutputSchema({
|
||||
stepName,
|
||||
codeOutputSchema: schema,
|
||||
rawVariableName,
|
||||
});
|
||||
}
|
||||
|
||||
if (stepType === 'ITERATOR') {
|
||||
return searchThroughIteratorOutputSchema({
|
||||
stepName,
|
||||
iteratorOutputSchema: schema as IteratorOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
});
|
||||
}
|
||||
|
||||
return searchThroughBaseOutputSchema({
|
||||
stepName,
|
||||
baseOutputSchema: schema as BaseOutputSchemaV2,
|
||||
rawVariableName,
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user