Generate fake form from metadata (#10641)

- add name to form field metadata
- extract field generation from object record schema
- use field generation to generate field from metadata
- add tests
This commit is contained in:
Thomas Trompette
2025-03-04 13:25:29 +01:00
committed by GitHub
parent aba20dae11
commit d151b1329c
7 changed files with 370 additions and 30 deletions

View File

@ -0,0 +1,217 @@
import { FieldMetadataType } from 'twenty-shared';
import { generateFakeValue } from 'src/engine/utils/generate-fake-value';
import { generateFakeField } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-field';
import { camelToTitleCase } from 'src/utils/camel-to-title-case';
jest.mock('src/engine/utils/generate-fake-value');
jest.mock('src/utils/camel-to-title-case');
jest.mock('src/engine/metadata-modules/field-metadata/composite-types', () => {
const mockCompositeTypeDefinitions = new Map();
mockCompositeTypeDefinitions.set(FieldMetadataType.LINKS, {
properties: [
{ name: 'label', type: FieldMetadataType.TEXT },
{ name: 'url', type: FieldMetadataType.TEXT },
],
});
mockCompositeTypeDefinitions.set(FieldMetadataType.CURRENCY, {
properties: [
{ name: 'amount', type: FieldMetadataType.NUMBER },
{ name: 'currencyCode', type: FieldMetadataType.TEXT },
],
});
return {
compositeTypeDefinitions: mockCompositeTypeDefinitions,
};
});
describe('generateFakeField', () => {
beforeEach(() => {
jest.clearAllMocks();
// Default mock implementations
(generateFakeValue as jest.Mock).mockImplementation(
(type) => `fake-${type}`,
);
(camelToTitleCase as jest.Mock).mockImplementation((str) => `Title ${str}`);
});
describe('for simple field types', () => {
it('should generate a leaf node for TEXT type', () => {
(generateFakeValue as jest.Mock).mockReturnValueOnce('Fake Text');
const result = generateFakeField({
type: FieldMetadataType.TEXT,
label: 'Text Field',
});
expect(result).toEqual({
isLeaf: true,
type: FieldMetadataType.TEXT,
icon: undefined,
label: 'Text Field',
value: 'Fake Text',
});
expect(generateFakeValue).toHaveBeenCalledWith(
FieldMetadataType.TEXT,
'FieldMetadataType',
);
});
it('should generate a leaf node for NUMBER type with icon', () => {
(generateFakeValue as jest.Mock).mockReturnValueOnce(42);
const result = generateFakeField({
type: FieldMetadataType.NUMBER,
label: 'Number Field',
icon: 'IconNumber',
});
expect(result).toEqual({
isLeaf: true,
type: FieldMetadataType.NUMBER,
icon: 'IconNumber',
label: 'Number Field',
value: 42,
});
});
it('should generate a leaf node for DATE type', () => {
const fakeDate = new Date('2023-01-01');
(generateFakeValue as jest.Mock).mockReturnValueOnce(fakeDate);
const result = generateFakeField({
type: FieldMetadataType.DATE,
label: 'Date Field',
});
expect(result).toEqual({
isLeaf: true,
type: FieldMetadataType.DATE,
icon: undefined,
label: 'Date Field',
value: fakeDate,
});
});
});
describe('for composite field types', () => {
it('should generate a node with properties for LINKS type', () => {
(generateFakeValue as jest.Mock)
.mockReturnValueOnce('Fake Label')
.mockReturnValueOnce('https://example.com');
(camelToTitleCase as jest.Mock)
.mockReturnValueOnce('Label')
.mockReturnValueOnce('Url');
const result = generateFakeField({
type: FieldMetadataType.LINKS,
label: 'Links Field',
});
expect(result).toEqual({
isLeaf: false,
icon: undefined,
label: 'Links Field',
value: {
label: {
isLeaf: true,
type: FieldMetadataType.TEXT,
label: 'Label',
value: 'Fake Label',
},
url: {
isLeaf: true,
type: FieldMetadataType.TEXT,
label: 'Url',
value: 'https://example.com',
},
},
});
expect(generateFakeValue).toHaveBeenCalledTimes(2);
expect(camelToTitleCase).toHaveBeenCalledWith('label');
expect(camelToTitleCase).toHaveBeenCalledWith('url');
});
it('should generate a node with properties for CURRENCY type', () => {
(generateFakeValue as jest.Mock)
.mockReturnValueOnce(100)
.mockReturnValueOnce('USD');
(camelToTitleCase as jest.Mock)
.mockReturnValueOnce('Amount')
.mockReturnValueOnce('Currency Code');
const result = generateFakeField({
type: FieldMetadataType.CURRENCY,
label: 'Currency Field',
icon: 'IconCurrency',
});
expect(result).toEqual({
isLeaf: false,
icon: 'IconCurrency',
label: 'Currency Field',
value: {
amount: {
isLeaf: true,
type: FieldMetadataType.NUMBER,
label: 'Amount',
value: 100,
},
currencyCode: {
isLeaf: true,
type: FieldMetadataType.TEXT,
label: 'Currency Code',
value: 'USD',
},
},
});
});
});
describe('edge cases', () => {
it('should handle unknown field types as leaf nodes', () => {
const unknownType = 'UNKNOWN_TYPE' as FieldMetadataType;
(generateFakeValue as jest.Mock).mockReturnValueOnce('Unknown Value');
const result = generateFakeField({
type: unknownType,
label: 'Unknown Field',
});
expect(result).toEqual({
isLeaf: true,
type: unknownType,
icon: undefined,
label: 'Unknown Field',
value: 'Unknown Value',
});
});
it('should handle empty label', () => {
(generateFakeValue as jest.Mock).mockReturnValueOnce('Fake Boolean');
const result = generateFakeField({
type: FieldMetadataType.BOOLEAN,
label: '',
});
expect(result).toEqual({
isLeaf: true,
type: FieldMetadataType.BOOLEAN,
icon: undefined,
label: '',
value: 'Fake Boolean',
});
});
});
});

View File

@ -0,0 +1,63 @@
import { FieldMetadataType } from 'twenty-shared';
import { generateFakeFormResponse } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-form-response';
describe('generateFakeFormResponse', () => {
it('should generate fake responses for a form schema', () => {
const schema = [
{
name: 'name',
type: FieldMetadataType.TEXT,
label: 'Name',
},
{
name: 'age',
type: FieldMetadataType.NUMBER,
label: 'Age',
},
{
name: 'email',
type: FieldMetadataType.EMAILS,
label: 'Email',
},
];
const result = generateFakeFormResponse(schema);
expect(result).toEqual({
email: {
isLeaf: false,
label: 'Email',
value: {
additionalEmails: {
isLeaf: true,
label: ' Additional Emails',
type: FieldMetadataType.RAW_JSON,
value: null,
},
primaryEmail: {
isLeaf: true,
label: ' Primary Email',
type: FieldMetadataType.TEXT,
value: 'My text',
},
},
icon: undefined,
},
name: {
isLeaf: true,
label: 'Name',
type: FieldMetadataType.TEXT,
value: 'My text',
icon: undefined,
},
age: {
isLeaf: true,
label: 'Age',
type: FieldMetadataType.NUMBER,
value: 20,
icon: undefined,
},
});
});
});

View File

@ -0,0 +1,47 @@
import { FieldMetadataType } from 'twenty-shared';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { generateFakeValue } from 'src/engine/utils/generate-fake-value';
import {
Leaf,
Node,
} from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
import { camelToTitleCase } from 'src/utils/camel-to-title-case';
export const generateFakeField = ({
type,
label,
icon,
}: {
type: FieldMetadataType;
label: string;
icon?: string;
}): Leaf | Node => {
const compositeType = compositeTypeDefinitions.get(type);
if (!compositeType) {
return {
isLeaf: true,
type: type,
icon: icon,
label: label,
value: generateFakeValue(type, 'FieldMetadataType'),
};
} else {
return {
isLeaf: false,
icon: icon,
label: label,
value: compositeType.properties.reduce((acc, property) => {
acc[property.name] = {
isLeaf: true,
type: property.type,
label: camelToTitleCase(property.name),
value: generateFakeValue(property.type, 'FieldMetadataType'),
};
return acc;
}, {}),
};
}
};

View File

@ -0,0 +1,19 @@
import {
Leaf,
Node,
} from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
import { generateFakeField } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-field';
import { FormFieldMetadata } from 'src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type';
export const generateFakeFormResponse = (
formMetadata: FormFieldMetadata[],
): Record<string, Leaf | Node> => {
return formMetadata.reduce((acc, formFieldMetadata) => {
acc[formFieldMetadata.name] = generateFakeField({
type: formFieldMetadata.type,
label: formFieldMetadata.label,
});
return acc;
}, {});
};

View File

@ -1,13 +1,11 @@
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { generateFakeValue } from 'src/engine/utils/generate-fake-value';
import {
Leaf,
Node,
RecordOutputSchema,
} from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
import { generateFakeField } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-field';
import { shouldGenerateFieldFakeValue } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/should-generate-field-fake-value';
import { camelToTitleCase } from 'src/utils/camel-to-title-case';
const generateObjectRecordFields = (
objectMetadataEntity: ObjectMetadataEntity,
@ -17,33 +15,12 @@ const generateObjectRecordFields = (
if (!shouldGenerateFieldFakeValue(field)) {
return acc;
}
const compositeType = compositeTypeDefinitions.get(field.type);
if (!compositeType) {
acc[field.name] = {
isLeaf: true,
type: field.type,
icon: field.icon,
label: field.label,
value: generateFakeValue(field.type, 'FieldMetadataType'),
};
} else {
acc[field.name] = {
isLeaf: false,
icon: field.icon,
label: field.label,
value: compositeType.properties.reduce((acc, property) => {
acc[property.name] = {
isLeaf: true,
type: property.type,
label: camelToTitleCase(property.name),
value: generateFakeValue(property.type, 'FieldMetadataType'),
};
return acc;
}, {}),
};
}
acc[field.name] = generateFakeField({
type: field.type,
label: field.label,
icon: field.icon,
});
return acc;
},

View File

@ -9,8 +9,10 @@ import { checkStringIsDatabaseEventAction } from 'src/engine/api/graphql/graphql
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { generateFakeValue } from 'src/engine/utils/generate-fake-value';
import { OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
import { generateFakeFormResponse } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-form-response';
import { generateFakeObjectRecord } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-object-record';
import { generateFakeObjectRecordEvent } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-object-record-event';
import { FormFieldMetadata } from 'src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type';
import {
WorkflowAction,
WorkflowActionType,
@ -77,6 +79,10 @@ export class WorkflowSchemaWorkspaceService {
workspaceId,
objectMetadataRepository: this.objectMetadataRepository,
});
case WorkflowActionType.FORM:
return this.computeFormActionOutputSchema({
formMetadata: step.settings.input,
});
case WorkflowActionType.CODE: // StepOutput schema is computed on serverlessFunction draft execution
default:
return {};
@ -174,4 +180,12 @@ export class WorkflowSchemaWorkspaceService {
private computeSendEmailActionOutputSchema(): OutputSchema {
return { success: { isLeaf: true, type: 'boolean', value: true } };
}
private computeFormActionOutputSchema({
formMetadata,
}: {
formMetadata: FormFieldMetadata[];
}): OutputSchema {
return generateFakeFormResponse(formMetadata);
}
}

View File

@ -1,8 +1,11 @@
import { FieldMetadataType } from 'twenty-shared';
import { BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
export type FormFieldMetadata = {
label: string;
type: string;
name: string;
type: FieldMetadataType;
placeholder?: string;
settings?: Record<string, any>;
};