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:
+156
@@ -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,
|
||||
};
|
||||
};
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';
|
||||
|
||||
export const shouldRecomputeOutputSchemaFamilyState = createFamilyState<
|
||||
boolean,
|
||||
string | undefined
|
||||
>({
|
||||
key: 'shouldRecomputeOutputSchemaFamilyState',
|
||||
defaultValue: true,
|
||||
});
|
||||
+10
@@ -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;
|
||||
};
|
||||
|
||||
+3
@@ -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;
|
||||
|
||||
+482
@@ -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);
|
||||
});
|
||||
});
|
||||
+196
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+119
@@ -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');
|
||||
});
|
||||
});
|
||||
+232
@@ -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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
+370
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+309
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+221
@@ -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);
|
||||
};
|
||||
+92
@@ -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;
|
||||
}
|
||||
};
|
||||
+32
@@ -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',
|
||||
},
|
||||
};
|
||||
};
|
||||
+49
@@ -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;
|
||||
};
|
||||
+186
@@ -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',
|
||||
);
|
||||
}
|
||||
};
|
||||
+148
@@ -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',
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user