Compute output schema on frontend (#16530)

Fixes https://github.com/twentyhq/core-team-issues/issues/1382

Current issue : all step output schemas are computed and stored on
backend side. Which means that, when the database schema is updated -
like a field creation - steps needs to be deleted an recreated. Which is
invisible to users.

Solution : schema generation is moved on frontend side

1. Coming on the page the first time, the schema is populated for all
steps except a few ones that are handled differently (Code, Webhook,
http node, Agent)

2. A separated state allow to determine if a step needs a recomputation.

3. The user only needs a refresh to see the whole schema re-computed

Follow-up:
- check if remaining backend steps could be moved to runtime
computation. But Code will still require storage.
- Clean backend service that is not used anymore
This commit is contained in:
Thomas Trompette
2025-12-15 16:20:35 +01:00
committed by GitHub
parent 2e104c8e76
commit 95e0793f81
43 changed files with 3152 additions and 209 deletions
@@ -0,0 +1,156 @@
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { type WorkflowVersion } from '@/workflow/types/Workflow';
import { getStepOutputSchemaFamilyStateKey } from '@/workflow/utils/getStepOutputSchemaFamilyStateKey';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
import { shouldRecomputeOutputSchemaFamilyState } from '@/workflow/workflow-variables/states/shouldRecomputeOutputSchemaFamilyState';
import { stepsOutputSchemaFamilyState } from '@/workflow/workflow-variables/states/stepsOutputSchemaFamilyState';
import {
type OutputSchemaV2,
type StepOutputSchemaV2,
} from '@/workflow/workflow-variables/types/StepOutputSchemaV2';
import {
computeStepOutputSchema,
shouldComputeOutputSchemaOnFrontend,
} from '@/workflow/workflow-variables/utils/generate/computeStepOutputSchema';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
export const useStepsOutputSchema = () => {
const populateStepsOutputSchema = useRecoilCallback(
({ set, snapshot }) =>
(workflowVersion: WorkflowVersion) => {
const objectMetadataItems = snapshot
.getLoadable(objectMetadataItemsState)
.getValue();
workflowVersion.steps?.forEach((step) => {
const stepKey = getStepOutputSchemaFamilyStateKey(
workflowVersion.id,
step.id,
);
const shouldRecompute = snapshot
.getLoadable(shouldRecomputeOutputSchemaFamilyState(stepKey))
.getValue();
const shouldComputeOnFrontend = shouldComputeOutputSchemaOnFrontend(
step.type,
);
if (!shouldRecompute) {
return;
}
const outputSchema = shouldComputeOnFrontend
? computeStepOutputSchema({
step,
objectMetadataItems,
})
: step.settings?.outputSchema;
const stepOutputSchema: StepOutputSchemaV2 = {
id: step.id,
name: step.name,
type: step.type,
icon: getActionIcon(step.type),
outputSchema: (outputSchema ?? {}) as OutputSchemaV2,
};
set(stepsOutputSchemaFamilyState(stepKey), stepOutputSchema);
set(shouldRecomputeOutputSchemaFamilyState(stepKey), false);
});
const trigger = workflowVersion.trigger;
if (isDefined(trigger)) {
const triggerKey = getStepOutputSchemaFamilyStateKey(
workflowVersion.id,
TRIGGER_STEP_ID,
);
const shouldRecompute = snapshot
.getLoadable(shouldRecomputeOutputSchemaFamilyState(triggerKey))
.getValue();
const shouldComputeOnFrontend = shouldComputeOutputSchemaOnFrontend(
trigger.type,
);
if (!shouldRecompute) {
return;
}
const triggerIconKey = getTriggerIcon(trigger);
const outputSchema = shouldComputeOnFrontend
? computeStepOutputSchema({
step: trigger,
objectMetadataItems,
})
: trigger.settings?.outputSchema;
const triggerOutputSchema: StepOutputSchemaV2 = {
id: TRIGGER_STEP_ID,
name: isDefined(trigger.name)
? trigger.name
: getTriggerDefaultLabel(trigger),
type: trigger.type,
icon: triggerIconKey,
outputSchema: (outputSchema ?? {}) as OutputSchemaV2,
};
set(stepsOutputSchemaFamilyState(triggerKey), triggerOutputSchema);
set(shouldRecomputeOutputSchemaFamilyState(triggerKey), false);
}
},
[],
);
const markStepForRecomputation = useRecoilCallback(
({ set }) =>
({
stepId,
workflowVersionId,
}: {
stepId: string;
workflowVersionId: string;
}) => {
const stepKey = getStepOutputSchemaFamilyStateKey(
workflowVersionId,
stepId,
);
set(shouldRecomputeOutputSchemaFamilyState(stepKey), true);
},
[],
);
const deleteStepsOutputSchema = useRecoilCallback(
({ set }) =>
({
stepIds,
workflowVersionId,
}: {
stepIds: string[];
workflowVersionId: string;
}) => {
stepIds.forEach((stepId) => {
const stepKey = getStepOutputSchemaFamilyStateKey(
workflowVersionId,
stepId,
);
set(stepsOutputSchemaFamilyState(stepKey), null);
set(shouldRecomputeOutputSchemaFamilyState(stepKey), true);
});
},
[],
);
return {
populateStepsOutputSchema,
markStepForRecomputation,
deleteStepsOutputSchema,
};
};
@@ -0,0 +1,9 @@
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';
export const shouldRecomputeOutputSchemaFamilyState = createFamilyState<
boolean,
string | undefined
>({
key: 'shouldRecomputeOutputSchemaFamilyState',
defaultValue: true,
});
@@ -0,0 +1,10 @@
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';
import { type StepOutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2';
export const stepsOutputSchemaFamilyState = createFamilyState<
StepOutputSchemaV2 | null,
string | undefined
>({
key: 'stepsOutputSchemaFamilyState',
defaultValue: null,
});
@@ -2,6 +2,7 @@ import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/R
export type RecordNode = {
isLeaf: false;
icon?: string;
label: string;
value: RecordOutputSchemaV2;
};
@@ -2,6 +2,7 @@ import { type FieldMetadataType } from 'twenty-shared/types';
export type RecordFieldLeaf = {
isLeaf: true;
icon?: string;
type: FieldMetadataType;
label: string;
value: any;
@@ -11,6 +12,7 @@ export type RecordFieldLeaf = {
export type RecordFieldNode = {
isLeaf: false;
icon?: string;
type: FieldMetadataType;
label: string;
value: RecordFieldNodeValue;
@@ -25,6 +27,7 @@ export type FieldOutputSchemaV2 = RecordFieldLeaf | RecordFieldNode;
export type RecordOutputSchemaV2 = {
object: {
icon?: string;
label: string;
objectMetadataId: string;
isRelationField?: boolean;
@@ -0,0 +1,482 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
computeStepOutputSchema,
shouldComputeOutputSchemaOnFrontend,
} from '@/workflow/workflow-variables/utils/generate/computeStepOutputSchema';
import { FieldMetadataType } from 'twenty-shared/types';
const mockCompanyObjectMetadataItem: ObjectMetadataItem = {
id: 'company-metadata-id',
nameSingular: 'company',
namePlural: 'companies',
labelSingular: 'Company',
labelPlural: 'Companies',
icon: 'IconBuildingSkyscraper',
fields: [
{
id: 'name-field-id',
name: 'name',
label: 'Name',
type: FieldMetadataType.TEXT,
isActive: true,
isSystem: false,
},
],
} as ObjectMetadataItem;
describe('computeStepOutputSchema', () => {
describe('PERSISTED_OUTPUT_SCHEMA_TYPES', () => {
it('should return undefined for CODE step type', () => {
const result = computeStepOutputSchema({
step: { type: 'CODE', settings: {} } as any,
objectMetadataItems: [],
});
expect(result).toBeUndefined();
});
it('should return undefined for HTTP_REQUEST step type', () => {
const result = computeStepOutputSchema({
step: { type: 'HTTP_REQUEST', settings: {} } as any,
objectMetadataItems: [],
});
expect(result).toBeUndefined();
});
it('should return undefined for AI_AGENT step type', () => {
const result = computeStepOutputSchema({
step: { type: 'AI_AGENT', settings: {} } as any,
objectMetadataItems: [],
});
expect(result).toBeUndefined();
});
it('should return undefined for WEBHOOK step type', () => {
const result = computeStepOutputSchema({
step: { type: 'WEBHOOK', settings: {} } as any,
objectMetadataItems: [],
});
expect(result).toBeUndefined();
});
it('should return undefined for ITERATOR step type', () => {
const result = computeStepOutputSchema({
step: { type: 'ITERATOR', settings: {} } as any,
objectMetadataItems: [],
});
expect(result).toBeUndefined();
});
});
describe('DATABASE_EVENT trigger', () => {
it('should return empty object when eventName is not defined', () => {
const result = computeStepOutputSchema({
step: { type: 'DATABASE_EVENT', settings: {} } as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toEqual({});
});
it('should return empty object when eventName cannot be parsed', () => {
const result = computeStepOutputSchema({
step: {
type: 'DATABASE_EVENT',
settings: { eventName: 'invalid' },
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toEqual({});
});
it('should return empty object when object metadata is not found', () => {
const result = computeStepOutputSchema({
step: {
type: 'DATABASE_EVENT',
settings: { eventName: 'unknownObject.created' },
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toEqual({});
});
it('should return record event output schema for valid eventName', () => {
const result = computeStepOutputSchema({
step: {
type: 'DATABASE_EVENT',
settings: { eventName: 'company.created' },
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toHaveProperty('_outputSchemaType', 'RECORD');
expect(result).toHaveProperty('object');
expect(result).toHaveProperty('fields');
});
it('should return empty object for invalid action string', () => {
const result = computeStepOutputSchema({
step: {
type: 'DATABASE_EVENT',
settings: { eventName: 'company.invalidAction' },
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toEqual({});
});
it.each(['updated', 'deleted', 'upserted'])(
'should return record event output schema for %s action',
(action) => {
const result = computeStepOutputSchema({
step: {
type: 'DATABASE_EVENT',
settings: { eventName: `company.${action}` },
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toHaveProperty('_outputSchemaType', 'RECORD');
expect(result).toHaveProperty('object');
},
);
});
describe('MANUAL trigger', () => {
it('should return empty object when availability is not defined', () => {
const result = computeStepOutputSchema({
step: { type: 'MANUAL', settings: {} } as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toEqual({});
});
it('should return empty object for GLOBAL availability', () => {
const result = computeStepOutputSchema({
step: {
type: 'MANUAL',
settings: { availability: { type: 'GLOBAL' } },
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toEqual({});
});
it('should return record output schema for SINGLE_RECORD availability', () => {
const result = computeStepOutputSchema({
step: {
type: 'MANUAL',
settings: {
availability: {
type: 'SINGLE_RECORD',
objectNameSingular: 'company',
},
},
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toHaveProperty('_outputSchemaType', 'RECORD');
expect(result).toHaveProperty('object');
});
it('should return array indicator for BULK_RECORDS availability', () => {
const result = computeStepOutputSchema({
step: {
type: 'MANUAL',
settings: {
availability: {
type: 'BULK_RECORDS',
objectNameSingular: 'company',
},
},
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toHaveProperty('companies');
expect((result as any).companies).toMatchObject({
isLeaf: true,
label: 'Companies',
type: 'array',
});
});
it('should return empty object when object metadata is not found for SINGLE_RECORD', () => {
const result = computeStepOutputSchema({
step: {
type: 'MANUAL',
settings: {
availability: {
type: 'SINGLE_RECORD',
objectNameSingular: 'unknownObject',
},
},
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toEqual({});
});
it('should return empty object when object metadata is not found for BULK_RECORDS', () => {
const result = computeStepOutputSchema({
step: {
type: 'MANUAL',
settings: {
availability: {
type: 'BULK_RECORDS',
objectNameSingular: 'unknownObject',
},
},
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toEqual({});
});
});
describe('CRON trigger', () => {
it('should return empty object', () => {
const result = computeStepOutputSchema({
step: { type: 'CRON', settings: {} } as any,
objectMetadataItems: [],
});
expect(result).toEqual({});
});
});
describe('Record action steps', () => {
it.each([
'CREATE_RECORD',
'UPDATE_RECORD',
'DELETE_RECORD',
'UPSERT_RECORD',
])(
'should return empty object for %s when objectName is not defined',
(stepType) => {
const result = computeStepOutputSchema({
step: { type: stepType, settings: { input: {} } } as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toEqual({});
},
);
it.each([
'CREATE_RECORD',
'UPDATE_RECORD',
'DELETE_RECORD',
'UPSERT_RECORD',
])(
'should return empty object for %s when object metadata is not found',
(stepType) => {
const result = computeStepOutputSchema({
step: {
type: stepType,
settings: { input: { objectName: 'unknownObject' } },
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toEqual({});
},
);
it.each([
'CREATE_RECORD',
'UPDATE_RECORD',
'DELETE_RECORD',
'UPSERT_RECORD',
])(
'should return record output schema for %s with valid objectName',
(stepType) => {
const result = computeStepOutputSchema({
step: {
type: stepType,
settings: { input: { objectName: 'company' } },
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toHaveProperty('_outputSchemaType', 'RECORD');
expect(result).toHaveProperty('object');
},
);
});
describe('FIND_RECORDS step', () => {
it('should return empty object when objectName is not defined', () => {
const result = computeStepOutputSchema({
step: { type: 'FIND_RECORDS', settings: { input: {} } } as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toEqual({});
});
it('should return empty object when object metadata is not found', () => {
const result = computeStepOutputSchema({
step: {
type: 'FIND_RECORDS',
settings: { input: { objectName: 'unknownObject' } },
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toEqual({});
});
it('should return find records output schema with valid objectName', () => {
const result = computeStepOutputSchema({
step: {
type: 'FIND_RECORDS',
settings: { input: { objectName: 'company' } },
} as any,
objectMetadataItems: [mockCompanyObjectMetadataItem],
});
expect(result).toHaveProperty('first');
expect(result).toHaveProperty('all');
expect(result).toHaveProperty('totalCount');
});
});
describe('FORM step', () => {
it('should return empty object when form fields are not defined', () => {
const result = computeStepOutputSchema({
step: { type: 'FORM', settings: {} } as any,
objectMetadataItems: [],
});
expect(result).toEqual({});
});
it('should return empty object when form fields are empty', () => {
const result = computeStepOutputSchema({
step: { type: 'FORM', settings: { input: [] } } as any,
objectMetadataItems: [],
});
expect(result).toEqual({});
});
it('should return form output schema with valid form fields', () => {
const result = computeStepOutputSchema({
step: {
type: 'FORM',
settings: {
input: [
{
id: 'field-1',
name: 'firstName',
label: 'First Name',
type: 'TEXT',
},
],
},
} as any,
objectMetadataItems: [],
});
expect(result).toHaveProperty('firstName');
expect((result as any).firstName).toMatchObject({
isLeaf: true,
label: 'First Name',
});
});
});
describe('SEND_EMAIL step', () => {
it('should return success boolean schema', () => {
const result = computeStepOutputSchema({
step: { type: 'SEND_EMAIL', settings: {} } as any,
objectMetadataItems: [],
});
expect(result).toEqual({
success: {
isLeaf: true,
type: FieldMetadataType.BOOLEAN,
label: 'Success',
value: true,
},
});
});
});
describe('Empty output schema steps', () => {
it.each(['FILTER', 'DELAY', 'EMPTY'])(
'should return empty object for %s step type',
(stepType) => {
const result = computeStepOutputSchema({
step: { type: stepType, settings: {} } as any,
objectMetadataItems: [],
});
expect(result).toEqual({});
},
);
});
describe('Unknown step type', () => {
it('should return empty object for unknown step type', () => {
const result = computeStepOutputSchema({
step: { type: 'UNKNOWN_TYPE', settings: {} } as any,
objectMetadataItems: [],
});
expect(result).toEqual({});
});
});
});
describe('shouldComputeOutputSchemaOnFrontend', () => {
it('should return false for CODE', () => {
expect(shouldComputeOutputSchemaOnFrontend('CODE')).toBe(false);
});
it('should return false for HTTP_REQUEST', () => {
expect(shouldComputeOutputSchemaOnFrontend('HTTP_REQUEST')).toBe(false);
});
it('should return false for AI_AGENT', () => {
expect(shouldComputeOutputSchemaOnFrontend('AI_AGENT')).toBe(false);
});
it('should return false for WEBHOOK', () => {
expect(shouldComputeOutputSchemaOnFrontend('WEBHOOK')).toBe(false);
});
it('should return false for ITERATOR', () => {
expect(shouldComputeOutputSchemaOnFrontend('ITERATOR')).toBe(false);
});
it('should return true for DATABASE_EVENT', () => {
expect(shouldComputeOutputSchemaOnFrontend('DATABASE_EVENT')).toBe(true);
});
it('should return true for CREATE_RECORD', () => {
expect(shouldComputeOutputSchemaOnFrontend('CREATE_RECORD')).toBe(true);
});
it('should return true for FIND_RECORDS', () => {
expect(shouldComputeOutputSchemaOnFrontend('FIND_RECORDS')).toBe(true);
});
it('should return true for SEND_EMAIL', () => {
expect(shouldComputeOutputSchemaOnFrontend('SEND_EMAIL')).toBe(true);
});
});
@@ -0,0 +1,196 @@
import { generateFakeValue } from '@/workflow/workflow-variables/utils/generate/generateFakeValue';
import { FieldMetadataType } from 'twenty-shared/types';
describe('generateFakeValue', () => {
describe('Primitive classification', () => {
it('should generate string value', () => {
const result = generateFakeValue('string', 'Primitive');
expect(result).toBe('My text');
});
it('should generate number value', () => {
const result = generateFakeValue('number', 'Primitive');
expect(result).toBe(20);
});
it('should generate boolean value', () => {
const result = generateFakeValue('boolean', 'Primitive');
expect(result).toBe(true);
});
it('should generate Date value', () => {
const result = generateFakeValue('Date', 'Primitive');
expect(result).toBeInstanceOf(Date);
});
it('should generate array of strings', () => {
const result = generateFakeValue('string[]', 'Primitive');
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(3);
expect((result as string[])[0]).toBe('My text');
});
it('should generate array of numbers', () => {
const result = generateFakeValue('number[]', 'Primitive');
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(3);
expect((result as number[])[0]).toBe(20);
});
it('should generate object with properties', () => {
const result = generateFakeValue(
'{name: string; age: number}',
'Primitive',
) as Record<string, any>;
expect(result).toEqual({
name: 'My text',
age: 20,
});
});
it('should return null for unknown primitive type', () => {
const result = generateFakeValue('unknownType', 'Primitive');
expect(result).toBeNull();
});
it('should use Primitive as default classification', () => {
const result = generateFakeValue('string');
expect(result).toBe('My text');
});
});
describe('FieldMetadataType classification', () => {
it('should generate TEXT value', () => {
const result = generateFakeValue(
FieldMetadataType.TEXT,
'FieldMetadataType',
);
expect(result).toBe('My text');
});
it('should generate NUMBER value', () => {
const result = generateFakeValue(
FieldMetadataType.NUMBER,
'FieldMetadataType',
);
expect(result).toBe(20);
});
it('should generate BOOLEAN value', () => {
const result = generateFakeValue(
FieldMetadataType.BOOLEAN,
'FieldMetadataType',
);
expect(result).toBe(true);
});
it('should generate DATE value', () => {
const result = generateFakeValue(
FieldMetadataType.DATE,
'FieldMetadataType',
);
expect(result).toBe('01/23/2025');
});
it('should generate DATE_TIME value', () => {
const result = generateFakeValue(
FieldMetadataType.DATE_TIME,
'FieldMetadataType',
);
expect(result).toBe('01/23/2025 15:16');
});
it('should generate ADDRESS value', () => {
const result = generateFakeValue(
FieldMetadataType.ADDRESS,
'FieldMetadataType',
);
expect(result).toBe('123 Main St, Anytown, CA 12345');
});
it('should generate FULL_NAME value', () => {
const result = generateFakeValue(
FieldMetadataType.FULL_NAME,
'FieldMetadataType',
);
expect(result).toBe('Tim Cook');
});
it('should generate RAW_JSON value as null', () => {
const result = generateFakeValue(
FieldMetadataType.RAW_JSON,
'FieldMetadataType',
);
expect(result).toBeNull();
});
it('should generate RICH_TEXT value', () => {
const result = generateFakeValue(
FieldMetadataType.RICH_TEXT,
'FieldMetadataType',
);
expect(result).toBe('My rich text');
});
it('should generate UUID value', () => {
const result = generateFakeValue(
FieldMetadataType.UUID,
'FieldMetadataType',
);
expect(result).toBe('123e4567-e89b-12d3-a456-426614174000');
});
it('should return null for unknown FieldMetadataType', () => {
const result = generateFakeValue('UNKNOWN_TYPE', 'FieldMetadataType');
expect(result).toBeNull();
});
it.each([
FieldMetadataType.CURRENCY,
FieldMetadataType.LINKS,
FieldMetadataType.PHONES,
FieldMetadataType.EMAILS,
FieldMetadataType.RATING,
FieldMetadataType.SELECT,
FieldMetadataType.MULTI_SELECT,
FieldMetadataType.ARRAY,
FieldMetadataType.RELATION,
FieldMetadataType.ACTOR,
])(
'should return null for unsupported FieldMetadataType %s',
(fieldType) => {
const result = generateFakeValue(fieldType, 'FieldMetadataType');
expect(result).toBeNull();
},
);
});
describe('Unknown classification', () => {
it('should return null for unknown classification', () => {
const result = generateFakeValue('string', 'Unknown' as any);
expect(result).toBeNull();
});
});
});
@@ -0,0 +1,119 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { generateFindRecordsOutputSchema } from '@/workflow/workflow-variables/utils/generate/generateFindRecordsOutputSchema';
const createMockObjectMetadataItem = (
overrides: Partial<ObjectMetadataItem> = {},
): ObjectMetadataItem =>
({
id: 'test-object-id',
nameSingular: 'testObject',
namePlural: 'testObjects',
labelSingular: 'Test Object',
labelPlural: 'Test Objects',
icon: 'IconTest',
fields: [],
...overrides,
}) as ObjectMetadataItem;
describe('generateFindRecordsOutputSchema', () => {
it('should generate schema with first, all, and totalCount properties', () => {
const objectMetadataItem = createMockObjectMetadataItem();
const result = generateFindRecordsOutputSchema(objectMetadataItem);
expect(result).toHaveProperty('first');
expect(result).toHaveProperty('all');
expect(result).toHaveProperty('totalCount');
});
describe('first property', () => {
it('should be a non-leaf node with record schema as value', () => {
const objectMetadataItem = createMockObjectMetadataItem({
labelSingular: 'Company',
});
const result = generateFindRecordsOutputSchema(objectMetadataItem);
expect(result.first).toMatchObject({
isLeaf: false,
icon: 'IconAlpha',
label: 'First Company',
});
expect(result.first.value).toHaveProperty('_outputSchemaType', 'RECORD');
});
it('should use default label when labelSingular is undefined', () => {
const objectMetadataItem = createMockObjectMetadataItem({
labelSingular: undefined as any,
});
const result = generateFindRecordsOutputSchema(objectMetadataItem);
expect(result.first.label).toBe('First Record');
});
});
describe('all property', () => {
it('should be a leaf node with array type', () => {
const objectMetadataItem = createMockObjectMetadataItem({
labelPlural: 'Companies',
});
const result = generateFindRecordsOutputSchema(objectMetadataItem);
expect(result.all).toMatchObject({
isLeaf: true,
icon: 'IconListDetails',
label: 'All Companies',
type: 'array',
value: 'Returns an array of records',
});
});
it('should use default label when labelPlural is undefined', () => {
const objectMetadataItem = createMockObjectMetadataItem({
labelPlural: undefined as any,
});
const result = generateFindRecordsOutputSchema(objectMetadataItem);
expect(result.all?.label).toBe('All Records');
});
});
describe('totalCount property', () => {
it('should be a leaf node with number type', () => {
const objectMetadataItem = createMockObjectMetadataItem();
const result = generateFindRecordsOutputSchema(objectMetadataItem);
expect(result.totalCount).toMatchObject({
isLeaf: true,
icon: 'IconSum',
label: 'Total Count',
type: 'number',
value: 'Count of matching records',
});
});
});
it('should include record fields in first value', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'name-field-id',
name: 'name',
label: 'Name',
type: 'TEXT',
isActive: true,
isSystem: false,
},
] as any,
});
const result = generateFindRecordsOutputSchema(objectMetadataItem);
expect(result.first.value).toHaveProperty('fields');
expect((result.first.value as any).fields).toHaveProperty('name');
});
});
@@ -0,0 +1,232 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField';
import { generateFormOutputSchema } from '@/workflow/workflow-variables/utils/generate/generateFormOutputSchema';
import { FieldMetadataType } from 'twenty-shared/types';
const createMockObjectMetadataItem = (
overrides: Partial<ObjectMetadataItem> = {},
): ObjectMetadataItem =>
({
id: 'test-object-id',
nameSingular: 'testObject',
namePlural: 'testObjects',
labelSingular: 'Test Object',
labelPlural: 'Test Objects',
icon: 'IconTest',
fields: [],
...overrides,
}) as ObjectMetadataItem;
describe('generateFormOutputSchema', () => {
describe('Non-RECORD fields', () => {
it('should generate leaf node for TEXT field', () => {
const formFields: WorkflowFormActionField[] = [
{
id: 'field-1',
name: 'firstName',
label: 'First Name',
type: FieldMetadataType.TEXT,
},
];
const result = generateFormOutputSchema(formFields, []);
expect(result).toHaveProperty('firstName');
expect(result.firstName).toMatchObject({
isLeaf: true,
type: 'TEXT',
label: 'First Name',
});
});
it('should use placeholder as value when defined', () => {
const formFields: WorkflowFormActionField[] = [
{
id: 'field-1',
name: 'email',
label: 'Email',
type: FieldMetadataType.TEXT,
placeholder: 'Enter your email',
},
];
const result = generateFormOutputSchema(formFields, []);
expect(result.email).toMatchObject({
value: 'Enter your email',
});
});
it('should generate fake value when placeholder is not defined', () => {
const formFields: WorkflowFormActionField[] = [
{
id: 'field-1',
name: 'name',
label: 'Name',
type: FieldMetadataType.TEXT,
},
];
const result = generateFormOutputSchema(formFields, []);
expect(result.name.value).toBe('My text');
});
it('should handle NUMBER field type', () => {
const formFields: WorkflowFormActionField[] = [
{
id: 'field-1',
name: 'quantity',
label: 'Quantity',
type: FieldMetadataType.NUMBER,
},
];
const result = generateFormOutputSchema(formFields, []);
expect(result.quantity).toMatchObject({
isLeaf: true,
type: FieldMetadataType.NUMBER,
label: 'Quantity',
value: 20,
});
});
});
describe('RECORD fields', () => {
it('should generate non-leaf node for RECORD field with valid objectName', () => {
const formFields: WorkflowFormActionField[] = [
{
id: 'field-1',
name: 'selectedCompany',
label: 'Selected Company',
type: 'RECORD',
settings: {
objectName: 'company',
},
},
];
const objectMetadataItems = [
createMockObjectMetadataItem({
nameSingular: 'company',
labelSingular: 'Company',
}),
];
const result = generateFormOutputSchema(formFields, objectMetadataItems);
expect(result).toHaveProperty('selectedCompany');
expect(result.selectedCompany).toMatchObject({
isLeaf: false,
label: 'Selected Company',
});
expect((result.selectedCompany as any).value).toHaveProperty(
'_outputSchemaType',
'RECORD',
);
});
it('should skip RECORD field when objectName is not defined', () => {
const formFields: WorkflowFormActionField[] = [
{
id: 'field-1',
name: 'selectedCompany',
label: 'Selected Company',
type: 'RECORD',
settings: {},
},
];
const result = generateFormOutputSchema(formFields, []);
expect(result).not.toHaveProperty('selectedCompany');
});
it('should skip RECORD field when settings is undefined', () => {
const formFields: WorkflowFormActionField[] = [
{
id: 'field-1',
name: 'selectedCompany',
label: 'Selected Company',
type: 'RECORD',
},
];
const result = generateFormOutputSchema(formFields, []);
expect(result).not.toHaveProperty('selectedCompany');
});
it('should skip RECORD field when objectMetadataItem is not found', () => {
const formFields: WorkflowFormActionField[] = [
{
id: 'field-1',
name: 'selectedCompany',
label: 'Selected Company',
type: 'RECORD',
settings: {
objectName: 'unknownObject',
},
},
];
const objectMetadataItems = [
createMockObjectMetadataItem({
nameSingular: 'company',
}),
];
const result = generateFormOutputSchema(formFields, objectMetadataItems);
expect(result).not.toHaveProperty('selectedCompany');
});
});
describe('Multiple fields', () => {
it('should handle multiple fields of different types', () => {
const formFields: WorkflowFormActionField[] = [
{
id: 'field-1',
name: 'firstName',
label: 'First Name',
type: FieldMetadataType.TEXT,
},
{
id: 'field-2',
name: 'age',
label: 'Age',
type: FieldMetadataType.NUMBER,
},
{
id: 'field-3',
name: 'company',
label: 'Company',
type: 'RECORD',
settings: {
objectName: 'company',
},
},
];
const objectMetadataItems = [
createMockObjectMetadataItem({
nameSingular: 'company',
labelSingular: 'Company',
}),
];
const result = generateFormOutputSchema(formFields, objectMetadataItems);
expect(Object.keys(result)).toHaveLength(3);
expect(result).toHaveProperty('firstName');
expect(result).toHaveProperty('age');
expect(result).toHaveProperty('company');
});
});
describe('Empty input', () => {
it('should return empty object for empty form fields', () => {
const result = generateFormOutputSchema([], []);
expect(result).toEqual({});
});
});
});
@@ -0,0 +1,370 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { generateRecordEventOutputSchema } from '@/workflow/workflow-variables/utils/generate/generateRecordEventOutputSchema';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { DatabaseEventAction } from '~/generated/graphql';
const createMockObjectMetadataItem = (
overrides: Partial<ObjectMetadataItem> = {},
): ObjectMetadataItem =>
({
id: 'test-object-id',
nameSingular: 'testObject',
namePlural: 'testObjects',
labelSingular: 'Test Object',
labelPlural: 'Test Objects',
icon: 'IconTest',
fields: [],
...overrides,
}) as ObjectMetadataItem;
describe('generateRecordEventOutputSchema', () => {
describe('CREATED action', () => {
it('should generate schema with properties.after prefix', () => {
const objectMetadataItem = createMockObjectMetadataItem({
id: 'company-id',
labelSingular: 'Company',
icon: 'IconBuilding',
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.CREATED,
);
expect(result).toEqual({
object: {
icon: 'IconBuilding',
label: 'Company',
objectMetadataId: 'company-id',
fieldIdName: 'properties.after.id',
},
fields: {},
_outputSchemaType: 'RECORD',
});
});
it('should prefix field names with properties.after', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'name-field-id',
name: 'name',
label: 'Name',
type: FieldMetadataType.TEXT,
isActive: true,
isSystem: false,
icon: 'IconText',
},
] as any,
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.CREATED,
);
expect(Object.keys(result.fields)).toContain('properties.after.name');
});
});
describe('UPDATED action', () => {
it('should generate schema with properties.after prefix', () => {
const objectMetadataItem = createMockObjectMetadataItem({
id: 'company-id',
labelSingular: 'Company',
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.UPDATED,
);
expect(result.object.fieldIdName).toBe('properties.after.id');
});
});
describe('DELETED action', () => {
it('should generate schema with properties.before prefix', () => {
const objectMetadataItem = createMockObjectMetadataItem({
id: 'company-id',
labelSingular: 'Company',
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.DELETED,
);
expect(result.object.fieldIdName).toBe('properties.before.id');
});
it('should prefix field names with properties.before', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'name-field-id',
name: 'name',
label: 'Name',
type: FieldMetadataType.TEXT,
isActive: true,
isSystem: false,
},
] as any,
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.DELETED,
);
expect(Object.keys(result.fields)).toContain('properties.before.name');
});
});
describe('DESTROYED action', () => {
it('should generate schema with properties.before prefix', () => {
const objectMetadataItem = createMockObjectMetadataItem({
id: 'company-id',
labelSingular: 'Company',
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.DESTROYED,
);
expect(result.object.fieldIdName).toBe('properties.before.id');
});
});
describe('UPSERTED action', () => {
it('should generate schema with properties.after prefix', () => {
const objectMetadataItem = createMockObjectMetadataItem({
id: 'company-id',
labelSingular: 'Company',
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.UPSERTED,
);
expect(result.object.fieldIdName).toBe('properties.after.id');
});
});
describe('Field handling', () => {
it('should exclude inactive fields', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'inactive-field-id',
name: 'inactiveField',
label: 'Inactive Field',
type: FieldMetadataType.TEXT,
isActive: false,
isSystem: false,
},
] as any,
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.CREATED,
);
expect(Object.keys(result.fields)).toHaveLength(0);
});
it('should exclude searchVector system field', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'search-vector-id',
name: 'searchVector',
label: 'Search Vector',
type: FieldMetadataType.TEXT,
isActive: true,
isSystem: true,
},
] as any,
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.CREATED,
);
expect(Object.keys(result.fields)).not.toContain(
'properties.after.searchVector',
);
});
it('should convert relation fields to prefixed UUID id fields', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'company-relation-id',
name: 'company',
label: 'Company',
type: FieldMetadataType.RELATION,
isActive: true,
isSystem: false,
icon: 'IconBuilding',
relation: {
type: RelationType.MANY_TO_ONE,
},
},
] as any,
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.CREATED,
);
expect(Object.keys(result.fields)).toContain(
'properties.after.companyId',
);
expect(result.fields['properties.after.companyId']).toMatchObject({
isLeaf: true,
type: FieldMetadataType.UUID,
});
});
it('should generate composite fields with prefix', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'address-field-id',
name: 'address',
label: 'Address',
type: FieldMetadataType.ADDRESS,
isActive: true,
isSystem: false,
icon: 'IconMap',
},
] as any,
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.CREATED,
);
expect(Object.keys(result.fields)).toContain('properties.after.address');
const addressField = result.fields['properties.after.address'];
expect(addressField).toMatchObject({
isLeaf: false,
type: FieldMetadataType.ADDRESS,
});
});
it('should convert MORPH_RELATION fields to prefixed UUID id fields when MANY_TO_ONE', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'morph-relation-id',
name: 'target',
label: 'Target',
type: FieldMetadataType.MORPH_RELATION,
isActive: true,
isSystem: false,
icon: 'IconLink',
settings: {
relationType: RelationType.MANY_TO_ONE,
},
},
] as any,
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.CREATED,
);
expect(Object.keys(result.fields)).toContain('properties.after.targetId');
expect(result.fields['properties.after.targetId']).toMatchObject({
isLeaf: true,
type: FieldMetadataType.UUID,
});
});
it('should exclude MORPH_RELATION fields when not MANY_TO_ONE', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'morph-relation-id',
name: 'targets',
label: 'Targets',
type: FieldMetadataType.MORPH_RELATION,
isActive: true,
isSystem: false,
settings: {
relationType: RelationType.ONE_TO_MANY,
},
},
] as any,
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.CREATED,
);
expect(Object.keys(result.fields)).not.toContain(
'properties.after.targets',
);
expect(Object.keys(result.fields)).not.toContain(
'properties.after.targetsId',
);
});
it('should exclude one-to-many relations', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'people-relation-id',
name: 'people',
label: 'People',
type: FieldMetadataType.RELATION,
isActive: true,
isSystem: false,
relation: {
type: RelationType.ONE_TO_MANY,
},
},
] as any,
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
DatabaseEventAction.CREATED,
);
expect(Object.keys(result.fields)).not.toContain(
'properties.after.people',
);
expect(Object.keys(result.fields)).not.toContain(
'properties.after.peopleId',
);
});
});
describe('Default action handling', () => {
it('should default to properties.after for unknown action', () => {
const objectMetadataItem = createMockObjectMetadataItem({
id: 'company-id',
labelSingular: 'Company',
});
const result = generateRecordEventOutputSchema(
objectMetadataItem,
'UNKNOWN_ACTION' as DatabaseEventAction,
);
expect(result.object.fieldIdName).toBe('properties.after.id');
});
});
});
@@ -0,0 +1,309 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { generateRecordOutputSchema } from '@/workflow/workflow-variables/utils/generate/generateRecordOutputSchema';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
const createMockObjectMetadataItem = (
overrides: Partial<ObjectMetadataItem> = {},
): ObjectMetadataItem =>
({
id: 'test-object-id',
nameSingular: 'testObject',
namePlural: 'testObjects',
labelSingular: 'Test Object',
labelPlural: 'Test Objects',
icon: 'IconTest',
fields: [],
...overrides,
}) as ObjectMetadataItem;
describe('generateRecordOutputSchema', () => {
it('should generate schema with correct object metadata', () => {
const objectMetadataItem = createMockObjectMetadataItem({
id: 'company-id',
labelSingular: 'Company',
icon: 'IconBuilding',
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result).toEqual({
object: {
icon: 'IconBuilding',
label: 'Company',
objectMetadataId: 'company-id',
fieldIdName: 'id',
},
fields: {},
_outputSchemaType: 'RECORD',
});
});
it('should generate fields for active non-system fields', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'name-field-id',
name: 'name',
label: 'Name',
type: FieldMetadataType.TEXT,
isActive: true,
isSystem: false,
icon: 'IconText',
},
] as any,
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result.fields).toHaveProperty('name');
expect(result.fields.name).toMatchObject({
isLeaf: true,
type: FieldMetadataType.TEXT,
label: 'Name',
fieldMetadataId: 'name-field-id',
});
});
it('should exclude inactive fields', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'inactive-field-id',
name: 'inactiveField',
label: 'Inactive Field',
type: FieldMetadataType.TEXT,
isActive: false,
isSystem: false,
},
] as any,
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result.fields).not.toHaveProperty('inactiveField');
});
it('should exclude searchVector system field', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'search-vector-id',
name: 'searchVector',
label: 'Search Vector',
type: FieldMetadataType.TEXT,
isActive: true,
isSystem: true,
},
] as any,
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result.fields).not.toHaveProperty('searchVector');
});
it('should exclude position system field', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'position-id',
name: 'position',
label: 'Position',
type: FieldMetadataType.NUMBER,
isActive: true,
isSystem: true,
},
] as any,
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result.fields).not.toHaveProperty('position');
});
it('should include non-excluded system fields', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'created-at-id',
name: 'createdAt',
label: 'Created At',
type: FieldMetadataType.DATE_TIME,
isActive: true,
isSystem: true,
icon: 'IconCalendar',
},
] as any,
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result.fields).toHaveProperty('createdAt');
});
it('should generate composite field with subfields for ADDRESS type', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'address-field-id',
name: 'address',
label: 'Address',
type: FieldMetadataType.ADDRESS,
isActive: true,
isSystem: false,
icon: 'IconMap',
},
] as any,
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result.fields).toHaveProperty('address');
expect(result.fields.address).toMatchObject({
isLeaf: false,
type: FieldMetadataType.ADDRESS,
label: 'Address',
fieldMetadataId: 'address-field-id',
});
expect((result.fields.address as any).value).toHaveProperty(
'addressStreet1',
);
expect((result.fields.address as any).value).toHaveProperty('addressCity');
});
it('should convert relation fields to UUID id fields', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'company-relation-id',
name: 'company',
label: 'Company',
type: FieldMetadataType.RELATION,
isActive: true,
isSystem: false,
icon: 'IconBuilding',
relation: {
type: RelationType.MANY_TO_ONE,
},
},
] as any,
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result.fields).not.toHaveProperty('company');
expect(result.fields).toHaveProperty('companyId');
expect(result.fields.companyId).toMatchObject({
isLeaf: true,
type: FieldMetadataType.UUID,
label: 'Company Id',
});
});
it('should exclude one-to-many relations', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'people-relation-id',
name: 'people',
label: 'People',
type: FieldMetadataType.RELATION,
isActive: true,
isSystem: false,
relation: {
type: RelationType.ONE_TO_MANY,
},
},
] as any,
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result.fields).not.toHaveProperty('people');
expect(result.fields).not.toHaveProperty('peopleId');
});
it('should handle object without icon', () => {
const objectMetadataItem = createMockObjectMetadataItem({
icon: null as any,
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result.object.icon).toBeUndefined();
});
it('should convert MORPH_RELATION fields to UUID id fields when MANY_TO_ONE', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'morph-relation-id',
name: 'target',
label: 'Target',
type: FieldMetadataType.MORPH_RELATION,
isActive: true,
isSystem: false,
icon: 'IconLink',
settings: {
relationType: RelationType.MANY_TO_ONE,
},
},
] as any,
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result.fields).not.toHaveProperty('target');
expect(result.fields).toHaveProperty('targetId');
expect(result.fields.targetId).toMatchObject({
isLeaf: true,
type: FieldMetadataType.UUID,
label: 'Target Id',
});
});
it('should exclude MORPH_RELATION fields when not MANY_TO_ONE', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'morph-relation-id',
name: 'targets',
label: 'Targets',
type: FieldMetadataType.MORPH_RELATION,
isActive: true,
isSystem: false,
settings: {
relationType: RelationType.ONE_TO_MANY,
},
},
] as any,
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result.fields).not.toHaveProperty('targets');
expect(result.fields).not.toHaveProperty('targetsId');
});
it('should handle field without icon', () => {
const objectMetadataItem = createMockObjectMetadataItem({
fields: [
{
id: 'field-no-icon',
name: 'noIcon',
label: 'No Icon',
type: FieldMetadataType.TEXT,
isActive: true,
isSystem: false,
icon: null,
},
] as any,
});
const result = generateRecordOutputSchema(objectMetadataItem);
expect(result.fields.noIcon).toMatchObject({
icon: undefined,
});
});
});
@@ -0,0 +1,221 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
type WorkflowAction,
type WorkflowTrigger,
} from '@/workflow/types/Workflow';
import { type WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField';
import { type OutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2';
import { generateFindRecordsOutputSchema } from '@/workflow/workflow-variables/utils/generate/generateFindRecordsOutputSchema';
import { generateFormOutputSchema } from '@/workflow/workflow-variables/utils/generate/generateFormOutputSchema';
import { generateRecordEventOutputSchema } from '@/workflow/workflow-variables/utils/generate/generateRecordEventOutputSchema';
import { generateRecordOutputSchema } from '@/workflow/workflow-variables/utils/generate/generateRecordOutputSchema';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { DatabaseEventAction } from '~/generated/graphql';
const PERSISTED_OUTPUT_SCHEMA_TYPES = [
'CODE',
'HTTP_REQUEST',
'AI_AGENT',
'WEBHOOK',
'ITERATOR',
];
const findObjectMetadataItemByName = (
objectMetadataItems: ObjectMetadataItem[],
objectName: string,
): ObjectMetadataItem | undefined => {
return objectMetadataItems.find((item) => item.nameSingular === objectName);
};
const parseEventName = (
eventName: string,
): { objectName: string; action: DatabaseEventAction } | undefined => {
const [objectName, actionString] = eventName.split('.');
if (!objectName || !actionString) {
return undefined;
}
const actionMap: Record<string, DatabaseEventAction> = {
created: DatabaseEventAction.CREATED,
updated: DatabaseEventAction.UPDATED,
deleted: DatabaseEventAction.DELETED,
upserted: DatabaseEventAction.UPSERTED,
};
const action = actionMap[actionString.toLowerCase()];
if (!action) {
return undefined;
}
return { objectName, action };
};
export const computeStepOutputSchema = ({
step,
objectMetadataItems,
}: {
step: WorkflowTrigger | WorkflowAction;
objectMetadataItems: ObjectMetadataItem[];
}): OutputSchemaV2 | undefined => {
const stepType = step.type;
if (PERSISTED_OUTPUT_SCHEMA_TYPES.includes(stepType)) {
return undefined;
}
switch (stepType) {
case 'DATABASE_EVENT': {
const eventName = step.settings?.eventName;
if (!isDefined(eventName)) {
return {};
}
const parsed = parseEventName(eventName);
if (!parsed) {
return {};
}
const objectMetadataItem = findObjectMetadataItemByName(
objectMetadataItems,
parsed.objectName,
);
if (!objectMetadataItem) {
return {};
}
return generateRecordEventOutputSchema(objectMetadataItem, parsed.action);
}
case 'MANUAL': {
const availability = step.settings?.availability;
if (!isDefined(availability)) {
return {};
}
if (availability.type === 'GLOBAL') {
return {};
}
if (
availability.type === 'SINGLE_RECORD' ||
availability.type === 'BULK_RECORDS'
) {
const objectMetadataItem = findObjectMetadataItemByName(
objectMetadataItems,
availability.objectNameSingular,
);
if (!objectMetadataItem) {
return {};
}
if (availability.type === 'SINGLE_RECORD') {
return generateRecordOutputSchema(objectMetadataItem);
}
// BULK_RECORDS - return array indicator
return {
[objectMetadataItem.namePlural]: {
isLeaf: true,
label: objectMetadataItem.labelPlural,
type: 'array',
value: `Array of ${objectMetadataItem.labelPlural}`,
},
};
}
return {};
}
case 'CRON': {
return {};
}
case 'CREATE_RECORD':
case 'UPDATE_RECORD':
case 'DELETE_RECORD':
case 'UPSERT_RECORD': {
const objectName = step.settings?.input?.objectName;
if (!isDefined(objectName)) {
return {};
}
const objectMetadataItem = findObjectMetadataItemByName(
objectMetadataItems,
objectName,
);
if (!objectMetadataItem) {
return {};
}
return generateRecordOutputSchema(objectMetadataItem);
}
case 'FIND_RECORDS': {
const objectName = step.settings?.input?.objectName;
if (!isDefined(objectName)) {
return {};
}
const objectMetadataItem = findObjectMetadataItemByName(
objectMetadataItems,
objectName,
);
if (!objectMetadataItem) {
return {};
}
return generateFindRecordsOutputSchema(objectMetadataItem);
}
case 'FORM': {
const formFields = step.settings?.input as
| WorkflowFormActionField[]
| undefined;
if (!isDefined(formFields) || formFields.length === 0) {
return {};
}
return generateFormOutputSchema(formFields, objectMetadataItems);
}
case 'SEND_EMAIL': {
return {
success: {
isLeaf: true,
type: FieldMetadataType.BOOLEAN,
label: 'Success',
value: true,
},
};
}
case 'FILTER':
case 'DELAY':
case 'EMPTY': {
return {};
}
default: {
return {};
}
}
};
export const shouldComputeOutputSchemaOnFrontend = (
stepType: string,
): boolean => {
return !PERSISTED_OUTPUT_SCHEMA_TYPES.includes(stepType);
};
@@ -0,0 +1,92 @@
import { FieldMetadataType } from 'twenty-shared/types';
export type FakeValueTypes =
| string
| number
| boolean
| Date
| FakeValueTypes[]
| FieldMetadataType
| { [key: string]: FakeValueTypes }
| null;
type TypeClassification = 'Primitive' | 'FieldMetadataType';
const generatePrimitiveValue = (valueType: string): FakeValueTypes => {
if (valueType === 'string') {
return 'My text';
} else if (valueType === 'number') {
return 20;
} else if (valueType === 'boolean') {
return true;
} else if (valueType === 'Date') {
return new Date();
} else if (valueType.endsWith('[]')) {
const elementType = valueType.replace('[]', '');
return Array.from({ length: 3 }, () => generateFakeValue(elementType));
} else if (valueType.startsWith('{') && valueType.endsWith('}')) {
const objData: Record<string, FakeValueTypes> = {};
const properties = valueType
.slice(1, -1)
.split(';')
.map((property) => property.trim())
.filter((property) => property);
properties.forEach((property) => {
const [key, propertyValueType] = property
.split(':')
.map((segment) => segment.trim());
objData[key] = generateFakeValue(propertyValueType);
});
return objData;
} else {
return null;
}
};
const generateFieldMetadataTypeValue = (
valueType: string,
): FakeValueTypes | null => {
switch (valueType) {
case FieldMetadataType.TEXT:
return 'My text';
case FieldMetadataType.NUMBER:
return 20;
case FieldMetadataType.BOOLEAN:
return true;
case FieldMetadataType.DATE:
return '01/23/2025';
case FieldMetadataType.DATE_TIME:
return '01/23/2025 15:16';
case FieldMetadataType.ADDRESS:
return '123 Main St, Anytown, CA 12345';
case FieldMetadataType.FULL_NAME:
return 'Tim Cook';
case FieldMetadataType.RAW_JSON:
return null;
case FieldMetadataType.RICH_TEXT:
return 'My rich text';
case FieldMetadataType.UUID:
return '123e4567-e89b-12d3-a456-426614174000';
default:
return null;
}
};
export const generateFakeValue = (
valueType: string,
classification: TypeClassification = 'Primitive',
): FakeValueTypes => {
switch (classification) {
case 'Primitive':
return generatePrimitiveValue(valueType);
case 'FieldMetadataType':
return generateFieldMetadataTypeValue(valueType);
default:
return null;
}
};
@@ -0,0 +1,32 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type FindRecordsOutputSchema } from '@/workflow/workflow-variables/types/FindRecordsOutputSchema';
import { generateRecordOutputSchema } from '@/workflow/workflow-variables/utils/generate/generateRecordOutputSchema';
export const generateFindRecordsOutputSchema = (
objectMetadataItem: ObjectMetadataItem,
): FindRecordsOutputSchema => {
const recordOutputSchema = generateRecordOutputSchema(objectMetadataItem);
return {
first: {
isLeaf: false,
icon: 'IconAlpha',
label: `First ${objectMetadataItem.labelSingular ?? 'Record'}`,
value: recordOutputSchema,
},
all: {
isLeaf: true,
icon: 'IconListDetails',
label: `All ${objectMetadataItem.labelPlural ?? 'Records'}`,
type: 'array',
value: 'Returns an array of records',
},
totalCount: {
isLeaf: true,
icon: 'IconSum',
label: 'Total Count',
type: 'number',
value: 'Count of matching records',
},
};
};
@@ -0,0 +1,49 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField';
import { type FormOutputSchema } from '@/workflow/workflow-variables/types/FormOutputSchema';
import { generateFakeValue } from '@/workflow/workflow-variables/utils/generate/generateFakeValue';
import { generateRecordOutputSchema } from '@/workflow/workflow-variables/utils/generate/generateRecordOutputSchema';
import { type FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const generateFormOutputSchema = (
formFields: WorkflowFormActionField[],
objectMetadataItems: ObjectMetadataItem[],
): FormOutputSchema => {
const result: FormOutputSchema = {};
for (const formField of formFields) {
if (formField.type === 'RECORD') {
const objectName = formField.settings?.objectName;
if (!isDefined(objectName)) {
continue;
}
const objectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === objectName,
);
if (!isDefined(objectMetadataItem)) {
continue;
}
result[formField.name] = {
isLeaf: false,
label: formField.label,
value: generateRecordOutputSchema(objectMetadataItem),
};
} else {
result[formField.name] = {
isLeaf: true,
type: formField.type as FieldMetadataType,
label: formField.label,
value:
formField.placeholder ??
generateFakeValue(formField.type, 'FieldMetadataType'),
};
}
}
return result;
};
@@ -0,0 +1,186 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type DatabaseEventTriggerOutputSchema } from '@/workflow/workflow-variables/types/DatabaseEventTriggerOutputSchema';
import {
type FieldOutputSchemaV2,
type RecordFieldLeaf,
type RecordOutputSchemaV2,
} from '@/workflow/workflow-variables/types/RecordOutputSchemaV2';
import { generateFakeValue } from '@/workflow/workflow-variables/utils/generate/generateFakeValue';
import {
compositeTypeDefinitions,
FieldMetadataType,
RelationType,
} from 'twenty-shared/types';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { DatabaseEventAction } from '~/generated/graphql';
const camelToTitleCase = (camelCaseText: string): string =>
capitalize(
camelCaseText
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (str) => str.toUpperCase()),
);
const EXCLUDED_SYSTEM_FIELDS = ['searchVector', 'position'];
const shouldGenerateFieldOutput = (
fieldMetadataItem: ObjectMetadataItem['fields'][number],
): boolean => {
if (!fieldMetadataItem.isActive) {
return false;
}
const isExcludedSystemField =
(fieldMetadataItem.isSystem &&
EXCLUDED_SYSTEM_FIELDS.includes(fieldMetadataItem.name)) ??
false;
if (isExcludedSystemField) {
return false;
}
if (
fieldMetadataItem.type === FieldMetadataType.RELATION &&
fieldMetadataItem.relation?.type !== RelationType.MANY_TO_ONE
) {
return false;
}
if (
fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION &&
fieldMetadataItem.settings?.relationType !== RelationType.MANY_TO_ONE
) {
return false;
}
return true;
};
const generatePrefixedRecordField = (
fieldMetadataItem: ObjectMetadataItem['fields'][number],
prefix: string,
): Record<string, FieldOutputSchemaV2> => {
const compositeType = compositeTypeDefinitions.get(fieldMetadataItem.type);
const icon = fieldMetadataItem.icon ?? undefined;
if (isDefined(compositeType)) {
const prefixedValue = compositeType.properties.reduce(
(acc, property) => {
acc[property.name] = {
isLeaf: true,
type: property.type,
label: camelToTitleCase(property.name),
value: generateFakeValue(property.type, 'FieldMetadataType'),
fieldMetadataId: fieldMetadataItem.id,
isCompositeSubField: true,
};
return acc;
},
{} as Record<string, RecordFieldLeaf>,
);
return {
[`${prefix}.${fieldMetadataItem.name}`]: {
isLeaf: false,
icon,
type: fieldMetadataItem.type,
label: fieldMetadataItem.label,
fieldMetadataId: fieldMetadataItem.id,
value: prefixedValue,
},
};
}
return {
[`${prefix}.${fieldMetadataItem.name}`]: {
isLeaf: true,
icon,
type: fieldMetadataItem.type,
label: fieldMetadataItem.label,
value: generateFakeValue(fieldMetadataItem.type, 'FieldMetadataType'),
fieldMetadataId: fieldMetadataItem.id,
isCompositeSubField: false,
},
};
};
const generatePrefixedRecordFields = (
objectMetadataItem: ObjectMetadataItem,
prefix: string,
): Record<string, FieldOutputSchemaV2> => {
const result: Record<string, FieldOutputSchemaV2> = {};
for (const fieldMetadataItem of objectMetadataItem.fields) {
if (!shouldGenerateFieldOutput(fieldMetadataItem)) {
continue;
}
const isRelationField =
fieldMetadataItem.type === FieldMetadataType.RELATION ||
fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION;
if (isRelationField) {
const relationIdFieldName = `${fieldMetadataItem.name}Id`;
const relationIdFieldLabel = camelToTitleCase(relationIdFieldName);
result[`${prefix}.${relationIdFieldName}`] = {
isLeaf: true,
icon: fieldMetadataItem.icon ?? undefined,
type: FieldMetadataType.UUID,
label: relationIdFieldLabel,
value: generateFakeValue(FieldMetadataType.UUID, 'FieldMetadataType'),
fieldMetadataId: fieldMetadataItem.id,
isCompositeSubField: false,
};
} else {
Object.assign(
result,
generatePrefixedRecordField(fieldMetadataItem, prefix),
);
}
}
return result;
};
const generateRecordEventWithPrefix = (
objectMetadataItem: ObjectMetadataItem,
prefix: string,
): RecordOutputSchemaV2 => {
return {
object: {
icon: objectMetadataItem.icon ?? undefined,
label: objectMetadataItem.labelSingular,
objectMetadataId: objectMetadataItem.id,
fieldIdName: `${prefix}.id`,
},
fields: generatePrefixedRecordFields(objectMetadataItem, prefix),
_outputSchemaType: 'RECORD',
};
};
export const generateRecordEventOutputSchema = (
objectMetadataItem: ObjectMetadataItem,
action: DatabaseEventAction,
): DatabaseEventTriggerOutputSchema => {
switch (action) {
case DatabaseEventAction.CREATED:
case DatabaseEventAction.UPDATED:
return generateRecordEventWithPrefix(
objectMetadataItem,
'properties.after',
);
case DatabaseEventAction.DELETED:
case DatabaseEventAction.DESTROYED:
return generateRecordEventWithPrefix(
objectMetadataItem,
'properties.before',
);
default:
return generateRecordEventWithPrefix(
objectMetadataItem,
'properties.after',
);
}
};
@@ -0,0 +1,148 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
type FieldOutputSchemaV2,
type RecordFieldLeaf,
type RecordOutputSchemaV2,
} from '@/workflow/workflow-variables/types/RecordOutputSchemaV2';
import { generateFakeValue } from '@/workflow/workflow-variables/utils/generate/generateFakeValue';
import {
compositeTypeDefinitions,
FieldMetadataType,
RelationType,
} from 'twenty-shared/types';
import { capitalize, isDefined } from 'twenty-shared/utils';
const camelToTitleCase = (camelCaseText: string): string =>
capitalize(
camelCaseText
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (str) => str.toUpperCase()),
);
const EXCLUDED_SYSTEM_FIELDS = ['searchVector', 'position'];
const shouldGenerateFieldOutput = (
fieldMetadataItem: FieldMetadataItem,
): boolean => {
if (!fieldMetadataItem.isActive) {
return false;
}
const isExcludedSystemField =
(fieldMetadataItem.isSystem &&
EXCLUDED_SYSTEM_FIELDS.includes(fieldMetadataItem.name)) ??
false;
if (isExcludedSystemField) {
return false;
}
if (
fieldMetadataItem.type === FieldMetadataType.RELATION &&
fieldMetadataItem.relation?.type !== RelationType.MANY_TO_ONE
) {
return false;
}
if (
fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION &&
fieldMetadataItem.settings?.relationType !== RelationType.MANY_TO_ONE
) {
return false;
}
return true;
};
const generateRecordField = (
fieldMetadataItem: FieldMetadataItem,
): FieldOutputSchemaV2 => {
const compositeType = compositeTypeDefinitions.get(fieldMetadataItem.type);
const icon = fieldMetadataItem.icon ?? undefined;
if (isDefined(compositeType)) {
return {
isLeaf: false,
icon,
type: fieldMetadataItem.type,
label: fieldMetadataItem.label,
fieldMetadataId: fieldMetadataItem.id,
value: compositeType.properties.reduce(
(acc, property) => {
acc[property.name] = {
isLeaf: true,
type: property.type,
label: camelToTitleCase(property.name),
value: generateFakeValue(property.type, 'FieldMetadataType'),
fieldMetadataId: fieldMetadataItem.id,
isCompositeSubField: true,
};
return acc;
},
{} as Record<string, RecordFieldLeaf>,
),
};
}
return {
isLeaf: true,
icon,
type: fieldMetadataItem.type,
label: fieldMetadataItem.label,
value: generateFakeValue(fieldMetadataItem.type, 'FieldMetadataType'),
fieldMetadataId: fieldMetadataItem.id,
isCompositeSubField: false,
};
};
const generateRecordFields = (
objectMetadataItem: ObjectMetadataItem,
): Record<string, FieldOutputSchemaV2> => {
const result: Record<string, FieldOutputSchemaV2> = {};
for (const fieldMetadataItem of objectMetadataItem.fields) {
if (!shouldGenerateFieldOutput(fieldMetadataItem)) {
continue;
}
const isRelationField =
fieldMetadataItem.type === FieldMetadataType.RELATION ||
fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION;
if (isRelationField) {
const relationIdFieldName = `${fieldMetadataItem.name}Id`;
const relationIdFieldLabel = camelToTitleCase(relationIdFieldName);
result[relationIdFieldName] = {
isLeaf: true,
icon: fieldMetadataItem.icon ?? undefined,
type: FieldMetadataType.UUID,
label: relationIdFieldLabel,
value: generateFakeValue(FieldMetadataType.UUID, 'FieldMetadataType'),
fieldMetadataId: fieldMetadataItem.id,
isCompositeSubField: false,
};
} else {
result[fieldMetadataItem.name] = generateRecordField(fieldMetadataItem);
}
}
return result;
};
export const generateRecordOutputSchema = (
objectMetadataItem: ObjectMetadataItem,
): RecordOutputSchemaV2 => {
return {
object: {
icon: objectMetadataItem.icon ?? undefined,
label: objectMetadataItem.labelSingular,
objectMetadataId: objectMetadataItem.id,
fieldIdName: 'id',
},
fields: generateRecordFields(objectMetadataItem),
_outputSchemaType: 'RECORD',
};
};