diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface.ts index c25860347..d69c9a692 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface.ts @@ -51,6 +51,7 @@ type FieldMetadataSettingsMapping = { [FieldMetadataType.DATE_TIME]: FieldMetadataDateTimeSettings; [FieldMetadataType.TEXT]: FieldMetadataTextSettings; [FieldMetadataType.RELATION]: FieldMetadataRelationSettings; + [FieldMetadataType.MORPH_RELATION]: FieldMetadataRelationSettings; }; export type FieldMetadataSettings< diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface.ts index 9b3d45bce..210e84150 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface.ts @@ -5,6 +5,8 @@ import { FieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface'; import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface'; +import { RelationDTO } from 'src/engine/metadata-modules/field-metadata/dtos/relation.dto'; + export interface FieldMetadataInterface< T extends FieldMetadataType = FieldMetadataType, > { @@ -25,6 +27,7 @@ export interface FieldMetadataInterface< relationTargetFieldMetadata?: FieldMetadataInterface; relationTargetObjectMetadataId?: string; relationTargetObjectMetadata?: ObjectMetadataInterface; + relation?: RelationDTO; isCustom?: boolean; isSystem?: boolean; isActive?: boolean; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts index ff9462902..1d9bd3bde 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts @@ -584,7 +584,9 @@ export class FieldMetadataService extends TypeOrmQueryService - isFieldMetadataInterfaceOfType(field, FieldMetadataType.RELATION), - ) as FieldMetadataEntity[]; + const relationFields = objectMetadata.fields.filter( + (field) => + isFieldMetadataInterfaceOfType(field, FieldMetadataType.RELATION) || + isFieldMetadataInterfaceOfType(field, FieldMetadataType.MORPH_RELATION), + ) as FieldMetadataEntity< + FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION + >[]; const relationFieldsToDelete = [ ...relationFields, ...(relationFields.map( (relation) => relation.relationTargetFieldMetadata, - ) as FieldMetadataEntity[]), + ) as FieldMetadataEntity< + FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION + >[]), ]; - await this.fieldMetadataRepository.delete( - relationFieldsToDelete.map((relation) => relation.id), - ); + if (relationFieldsToDelete.length !== 0) { + await this.fieldMetadataRepository.delete( + relationFieldsToDelete.map((relation) => relation.id), + ); + } for (const relationToDelete of relationFieldsToDelete) { if ( @@ -272,28 +280,30 @@ export class ObjectMetadataMigrationService { ); } - await this.workspaceMigrationService.createCustomMigration( - generateMigrationName( - `delete-${RELATION_MIGRATION_PRIORITY_PREFIX}-${relationToDelete.name}`, - ), - workspaceId, - [ - { - name: computeTableName( - relationToDelete.object.nameSingular, - relationToDelete.object.isCustom, - ), - action: WorkspaceMigrationTableActionType.ALTER, - columns: [ - { - action: WorkspaceMigrationColumnActionType.DROP, - columnName: joinColumnName, - } satisfies WorkspaceMigrationColumnDrop, - ], - }, - ], - queryRunner, - ); + if (relationToDelete.type !== FieldMetadataType.MORPH_RELATION) { + await this.workspaceMigrationService.createCustomMigration( + generateMigrationName( + `delete-${RELATION_MIGRATION_PRIORITY_PREFIX}-${relationToDelete.name}`, + ), + workspaceId, + [ + { + name: computeTableName( + relationToDelete.object.nameSingular, + relationToDelete.object.isCustom, + ), + action: WorkspaceMigrationTableActionType.ALTER, + columns: [ + { + action: WorkspaceMigrationColumnActionType.DROP, + columnName: joinColumnName, + } satisfies WorkspaceMigrationColumnDrop, + ], + }, + ], + queryRunner, + ); + } } await this.workspaceMigrationService.createCustomMigration( diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/failing-field-metadata-relation-creation.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/failing-field-metadata-relation-creation.integration-spec.ts.snap deleted file mode 100644 index ca527c906..000000000 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/failing-field-metadata-relation-creation.integration-spec.ts.snap +++ /dev/null @@ -1,66 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Field metadata relation creation should fail relation when targetFieldLabel conflicts with an existing field on target object metadata id 1`] = ` -[ - { - "extensions": { - "code": "BAD_USER_INPUT", - "userFriendlyMessage": "An error occurred.", - }, - "message": "Name "collisionfieldlabel" is not available", - "name": "UserInputError", - }, -] -`; - -exports[`Field metadata relation creation should fail relation when targetFieldLabel contains only whitespace 1`] = ` -[ - { - "extensions": { - "code": "BAD_USER_INPUT", - "userFriendlyMessage": "An error occurred.", - }, - "message": "Invalid label: " "", - "name": "UserInputError", - }, -] -`; - -exports[`Field metadata relation creation should fail relation when targetFieldLabel exceeds maximum length 1`] = ` -[ - { - "extensions": { - "code": "BAD_USER_INPUT", - "userFriendlyMessage": "An error occurred.", - }, - "message": "Name is too long: it exceeds the 63 characters limit.", - "name": "UserInputError", - }, -] -`; - -exports[`Field metadata relation creation should fail relation when targetFieldLabel is empty 1`] = ` -[ - { - "extensions": { - "code": "BAD_USER_INPUT", - "userFriendlyMessage": "An error occurred.", - }, - "message": "Input is too short: """, - "name": "UserInputError", - }, -] -`; - -exports[`Field metadata relation creation should fail relation when targetObjectMetadataId is unknown 1`] = ` -[ - { - "extensions": { - "code": "INTERNAL_SERVER_ERROR", - "exceptionEventId": "mocked-exception-id", - "userFriendlyMessage": "An error occurred.", - }, - "message": "Object metadata relation target not found for relation creation payload", - }, -] -`; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/create-one-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/create-one-field-metadata.integration-spec.ts index e9606dff3..291092346 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/create-one-field-metadata.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/create-one-field-metadata.integration-spec.ts @@ -1,296 +1,108 @@ -import { CreateOneFieldFactoryInput } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata-query-factory.util'; import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util'; -import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util'; -import { findManyFieldsMetadataQueryFactory } from 'test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata-query-factory.util'; import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; -import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; import { FieldMetadataType } from 'twenty-shared/types'; -import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; +describe('createOne FieldMetadataService name/label sync', () => { + let createdObjectMetadataId = ''; -describe('createOne', () => { - describe('FieldMetadataService name/label sync', () => { - let createdObjectMetadataId = ''; - - beforeEach(async () => { - const { - data: { - createOneObject: { id: objectMetadataId }, - }, - } = await createOneObjectMetadata({ - input: { - nameSingular: 'myTestObject', - namePlural: 'myTestObjects', - labelSingular: 'My Test Object', - labelPlural: 'My Test Objects', - icon: 'Icon123', - }, - }); - - createdObjectMetadataId = objectMetadataId; + beforeEach(async () => { + const { + data: { + createOneObject: { id: objectMetadataId }, + }, + } = await createOneObjectMetadata({ + input: { + nameSingular: 'myTestObject', + namePlural: 'myTestObjects', + labelSingular: 'My Test Object', + labelPlural: 'My Test Objects', + icon: 'Icon123', + }, }); - afterEach(async () => { - await deleteOneObjectMetadata({ - input: { idToDelete: createdObjectMetadataId }, - }); - }); - it('should create a field when name and label are synced correctly', async () => { - // Arrange - const FIELD_NAME = 'testField'; - const createFieldInput = { - name: FIELD_NAME, - label: 'Test Field', - type: FieldMetadataType.TEXT, - objectMetadataId: createdObjectMetadataId, - isLabelSyncedWithName: true, - }; - // Act - const { data } = await createOneFieldMetadata({ - input: createFieldInput, - gqlFields: ` + createdObjectMetadataId = objectMetadataId; + }); + afterEach(async () => { + await deleteOneObjectMetadata({ + input: { idToDelete: createdObjectMetadataId }, + }); + }); + it('should create a field when name and label are synced correctly', async () => { + // Arrange + const FIELD_NAME = 'testField'; + const createFieldInput = { + name: FIELD_NAME, + label: 'Test Field', + type: FieldMetadataType.TEXT, + objectMetadataId: createdObjectMetadataId, + isLabelSyncedWithName: true, + }; + + // Act + const { data } = await createOneFieldMetadata({ + input: createFieldInput, + gqlFields: ` id name label isLabelSyncedWithName `, - }); - - // Assert - expect(data.createOneField.name).toBe(FIELD_NAME); }); - it('should set isLabelSyncWithName to false if not in input', async () => { - // Arrange - const createFieldInput = { - name: 'testField', - label: 'Test Field', - type: FieldMetadataType.TEXT, - objectMetadataId: createdObjectMetadataId, - }; - - // Act - const { data } = await createOneFieldMetadata({ - input: createFieldInput, - gqlFields: ` - id - name - label - isLabelSyncedWithName - `, - }); - - // Assert - expect(data.createOneField.isLabelSyncedWithName).toBe(false); - }); - - it('should return an error when name and label are not synced but isLabelSyncedWithName is true', async () => { - // Arrange - const createFieldInput = { - name: 'testField', - label: 'Different Label', - type: FieldMetadataType.TEXT, - objectMetadataId: createdObjectMetadataId, - isLabelSyncedWithName: true, - }; - - // Act - const { errors } = await createOneFieldMetadata({ - input: createFieldInput, - gqlFields: ` - id - name - label - isLabelSyncedWithName - `, - expectToFail: true, - }); - - // Assert - expect(errors[0].message).toBe( - 'Name is not synced with label. Expected name: "differentLabel", got testField', - ); - }); + // Assert + expect(data.createOneField.name).toBe(FIELD_NAME); }); - describe('FieldMetadataService relation fields', () => { - let createdObjectMetadataPersonId = ''; - let createdObjectMetadataOpportunityId = ''; - let createdObjectMetadataCompanyId = ''; - beforeEach(async () => { - const { - data: { - createOneObject: { id: objectMetadataPersonId }, - }, - } = await createOneObjectMetadata({ - input: { - nameSingular: 'personForRelation', - namePlural: 'peopleForRelation', - labelSingular: 'Person For Relation', - labelPlural: 'People For Relation', - icon: 'IconPerson', - }, - }); + it('should set isLabelSyncWithName to false if not in input', async () => { + // Arrange + const createFieldInput = { + name: 'testField', + label: 'Test Field', + type: FieldMetadataType.TEXT, + objectMetadataId: createdObjectMetadataId, + }; - createdObjectMetadataPersonId = objectMetadataPersonId; - - const { - data: { - createOneObject: { id: objectMetadataCompanyId }, - }, - } = await createOneObjectMetadata({ - input: { - nameSingular: 'companyForRelation', - namePlural: 'companiesForRelation', - labelSingular: 'Company For Relation', - labelPlural: 'Companies For Relation', - icon: 'IconCompany', - }, - }); - - createdObjectMetadataCompanyId = objectMetadataCompanyId; - - const { - data: { - createOneObject: { id: objectMetadataOpportunityId }, - }, - } = await createOneObjectMetadata({ - input: { - nameSingular: 'opportunityForRelation', - namePlural: 'opportunitiesForRelation', - labelSingular: 'Opportunity For Relation', - labelPlural: 'Opportunities For Relation', - icon: 'IconOpportunity', - }, - }); - - createdObjectMetadataOpportunityId = objectMetadataOpportunityId; - }); - afterEach(async () => { - await deleteOneObjectMetadata({ - input: { idToDelete: createdObjectMetadataPersonId }, - }); - await deleteOneObjectMetadata({ - input: { idToDelete: createdObjectMetadataOpportunityId }, - }); - await deleteOneObjectMetadata({ - input: { idToDelete: createdObjectMetadataCompanyId }, - }); - }); - - it('should create a RELATION field type', async () => { - const createFieldInput: CreateOneFieldFactoryInput = { - name: 'person', - label: 'person field', - type: FieldMetadataType.RELATION, - objectMetadataId: createdObjectMetadataOpportunityId, - isLabelSyncedWithName: false, - relationCreationPayload: { - targetObjectMetadataId: createdObjectMetadataPersonId, - targetFieldLabel: 'opportunity', - targetFieldIcon: 'IconListOpportunity', - type: RelationType.MANY_TO_ONE, - }, - }; - - const { data: createdFieldPerson } = await createOneFieldMetadata({ - input: createFieldInput, - gqlFields: ` - id - name - label - isLabelSyncedWithName - `, - expectToFail: false, - }); - - expect(createdFieldPerson.createOneField.name).toBe('person'); - - // TODO : find a way to filter by objectmetadataid toavoid loading all fieldMetadata objects - const findOpportunityOperation = findManyFieldsMetadataQueryFactory({ - gqlFields: ` - id - name - object { + // Act + const { data } = await createOneFieldMetadata({ + input: createFieldInput, + gqlFields: ` id - nameSingular - } - relation { - type - } - settings - `, - input: { - filter: {}, - paging: { first: 10000 }, - }, - }); - - const opportunityFieldsResponse = await makeMetadataAPIRequest( - findOpportunityOperation, - ); - - const allFields = opportunityFieldsResponse.body.data.fields.edges; - const opportunityFieldOnPerson = allFields.find( - (field: any) => - field.node?.object?.id === createdObjectMetadataPersonId && - field.node?.name === - createFieldInput.relationCreationPayload?.targetFieldLabel, - ).node; - - expect(opportunityFieldOnPerson.object.nameSingular).toBe( - 'personForRelation', - ); - expect(opportunityFieldOnPerson.relation.type).toBe( - RelationType.ONE_TO_MANY, - ); - - await deleteOneFieldMetadata({ - input: { idToDelete: createdFieldPerson.createOneField.id }, - }); + name + label + isLabelSyncedWithName + `, }); - // TODO: replace xit by it once the Morph works - xit('should create a MORPH_RELATION field type', async () => { - const createFieldInput: CreateOneFieldFactoryInput = { - name: 'owner', - label: 'owner field', - type: FieldMetadataType.MORPH_RELATION, - objectMetadataId: createdObjectMetadataOpportunityId, - isLabelSyncedWithName: false, - morphRelationsCreationPayload: [ - { - targetObjectMetadataId: createdObjectMetadataPersonId, - targetFieldLabel: 'opportunity', - targetFieldIcon: 'IconListOpportunity', - type: RelationType.MANY_TO_ONE, - }, - { - targetObjectMetadataId: createdObjectMetadataCompanyId, - targetFieldLabel: 'opportunity', - targetFieldIcon: 'IconListOpportunity', - type: RelationType.MANY_TO_ONE, - }, - ], - }; + // Assert + expect(data.createOneField.isLabelSyncedWithName).toBe(false); + }); - const { data: createdFieldOwner } = await createOneFieldMetadata({ - input: createFieldInput, - gqlFields: ` - id - name - label - isLabelSyncedWithName - `, - expectToFail: false, - }); + it('should return an error when name and label are not synced but isLabelSyncedWithName is true', async () => { + // Arrange + const createFieldInput = { + name: 'testField', + label: 'Different Label', + type: FieldMetadataType.TEXT, + objectMetadataId: createdObjectMetadataId, + isLabelSyncedWithName: true, + }; - // expect(createdFieldOwner.createOneField.name).toBe('owner'); - - await deleteOneFieldMetadata({ - input: { idToDelete: createdFieldOwner.createOneField.id }, - }); + // Act + const { errors } = await createOneFieldMetadata({ + input: createFieldInput, + gqlFields: ` + id + name + label + isLabelSyncedWithName + `, + expectToFail: true, }); + + // Assert + expect(errors[0].message).toBe( + 'Name is not synced with label. Expected name: "differentLabel", got testField', + ); }); }); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/enum/create-one-field-metadata-enum.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/enum/create-one-field-metadata-enum.integration-spec.ts index 2d19e0aab..40b4c7316 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/enum/create-one-field-metadata-enum.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/enum/create-one-field-metadata-enum.integration-spec.ts @@ -1,3 +1,4 @@ +import { CREATE_ENUM_FIELD_METADATA_TEST_CASES } from 'test/integration/metadata/suites/field-metadata/enum/create-enum-field-metadata-test-cases'; import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util'; import { LISTING_NAME_PLURAL, @@ -5,13 +6,8 @@ import { } from 'test/integration/metadata/suites/object-metadata/constants/test-object-names.constant'; import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; import { forceCreateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/force-create-one-object-metadata.util'; -import { CREATE_ENUM_FIELD_METADATA_TEST_CASES } from 'test/integration/metadata/suites/field-metadata/enum/create-enum-field-metadata-test-cases'; import { isDefined } from 'twenty-shared/utils'; -import { - FieldMetadataComplexOption, - FieldMetadataDefaultOption, -} from 'src/engine/metadata-modules/field-metadata/dtos/options.input'; import { fieldMetadataEnumTypes } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util'; describe.each(fieldMetadataEnumTypes)( @@ -51,7 +47,9 @@ describe.each(fieldMetadataEnumTypes)( test.each(successfulTestCases)( 'Create $title', async ({ context: { input, expectedOptions } }) => { - const { data, errors } = await createOneFieldMetadata({ + const { data, errors } = await createOneFieldMetadata< + typeof testedFieldMetadataType + >({ input: { objectMetadataId: createdObjectMetadataId, type: testedFieldMetadataType, @@ -71,15 +69,13 @@ describe.each(fieldMetadataEnumTypes)( expect(data).not.toBeNull(); expect(data.createOneField).toBeDefined(); expect(data.createOneField.type).toEqual(testedFieldMetadataType); - const createdOptions: - | FieldMetadataDefaultOption[] - | FieldMetadataComplexOption[] = data.createOneField.options; + const createdOptions = data.createOneField.options; const optionsToCompare = expectedOptions ?? input.options; expect(errors).toBeUndefined(); - expect(createdOptions.length).toBe(optionsToCompare.length); - createdOptions.forEach((option) => expect(option.id).toBeDefined()); + expect(createdOptions?.length).toBe(optionsToCompare.length); + createdOptions?.forEach((option) => expect(option.id).toBeDefined()); expect(createdOptions).toMatchObject(optionsToCompare); if (isDefined(input.defaultValue)) { diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/morph-relation/__snapshots__/failing-field-metadata-morph-relation-creation.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/field-metadata/morph-relation/__snapshots__/failing-field-metadata-morph-relation-creation.integration-spec.ts.snap new file mode 100644 index 000000000..a06842223 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/morph-relation/__snapshots__/failing-field-metadata-morph-relation-creation.integration-spec.ts.snap @@ -0,0 +1,187 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Field metadata morph relation creation should fail relation MANY_TO_ONE when targetFieldLabel conflicts with an existing field on target object metadata id 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Name "collisionfieldlabel" is not available", + "name": "UserInputError", + }, +] +`; + +exports[`Field metadata morph relation creation should fail relation MANY_TO_ONE when targetFieldLabel contains only whitespace 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Invalid label: " "", + "name": "UserInputError", + }, +] +`; + +exports[`Field metadata morph relation creation should fail relation MANY_TO_ONE when targetFieldLabel exceeds maximum length 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Name is too long: it exceeds the 63 characters limit.", + "name": "UserInputError", + }, +] +`; + +exports[`Field metadata morph relation creation should fail relation MANY_TO_ONE when targetFieldLabel is empty 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Input is too short: """, + "name": "UserInputError", + }, +] +`; + +exports[`Field metadata morph relation creation should fail relation MANY_TO_ONE when targetObjectMetadataId is unknown 1`] = ` +[ + { + "extensions": { + "code": "INTERNAL_SERVER_ERROR", + "exceptionEventId": "mocked-exception-id", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Object metadata relation target not found for relation creation payload", + }, +] +`; + +exports[`Field metadata morph relation creation should fail relation MANY_TO_ONE when type is a wrong value 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "INVALID_FIELD_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Relation creation payload is invalid: type must be one of the following values: ONE_TO_MANY, MANY_TO_ONE", + "name": "UserInputError", + }, +] +`; + +exports[`Field metadata morph relation creation should fail relation MANY_TO_ONE when type is not provided 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "INVALID_FIELD_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Relation creation payload is invalid: type must be one of the following values: ONE_TO_MANY, MANY_TO_ONE", + "name": "UserInputError", + }, +] +`; + +exports[`Field metadata morph relation creation should fail relation ONE_TO_MANY when targetFieldLabel conflicts with an existing field on target object metadata id 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Name "collisionfieldlabel" is not available", + "name": "UserInputError", + }, +] +`; + +exports[`Field metadata morph relation creation should fail relation ONE_TO_MANY when targetFieldLabel contains only whitespace 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Invalid label: " "", + "name": "UserInputError", + }, +] +`; + +exports[`Field metadata morph relation creation should fail relation ONE_TO_MANY when targetFieldLabel exceeds maximum length 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Name is too long: it exceeds the 63 characters limit.", + "name": "UserInputError", + }, +] +`; + +exports[`Field metadata morph relation creation should fail relation ONE_TO_MANY when targetFieldLabel is empty 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Input is too short: """, + "name": "UserInputError", + }, +] +`; + +exports[`Field metadata morph relation creation should fail relation ONE_TO_MANY when targetObjectMetadataId is unknown 1`] = ` +[ + { + "extensions": { + "code": "INTERNAL_SERVER_ERROR", + "exceptionEventId": "mocked-exception-id", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Object metadata relation target not found for relation creation payload", + }, +] +`; + +exports[`Field metadata morph relation creation should fail relation ONE_TO_MANY when type is a wrong value 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "INVALID_FIELD_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Relation creation payload is invalid: type must be one of the following values: ONE_TO_MANY, MANY_TO_ONE", + "name": "UserInputError", + }, +] +`; + +exports[`Field metadata morph relation creation should fail relation ONE_TO_MANY when type is not provided 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "INVALID_FIELD_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Relation creation payload is invalid: type must be one of the following values: ONE_TO_MANY, MANY_TO_ONE", + "name": "UserInputError", + }, +] +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/morph-relation/create-one-field-metadata-morph-relation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/morph-relation/create-one-field-metadata-morph-relation.integration-spec.ts new file mode 100644 index 000000000..509221844 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/morph-relation/create-one-field-metadata-morph-relation.integration-spec.ts @@ -0,0 +1,171 @@ +import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util'; +import { createMorphRelationBetweenObjects } from 'test/integration/metadata/suites/object-metadata/utils/create-morph-relation-between-objects.util'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; +import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { EachTestingContext } from 'twenty-shared/testing'; +import { FieldMetadataType } from 'twenty-shared/types'; + +import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; + +describe('createOne FieldMetadataService morph relation fields', () => { + let createdObjectMetadataPersonId = ''; + let createdObjectMetadataOpportunityId = ''; + let createdObjectMetadataCompanyId = ''; + + beforeEach(async () => { + const { + data: { + createOneObject: { id: objectMetadataPersonId }, + }, + } = await createOneObjectMetadata({ + input: { + nameSingular: 'personForMorphRelation', + namePlural: 'peopleForMorphRelation', + labelSingular: 'Person For Morph Relation', + labelPlural: 'People For Morph Relation', + icon: 'IconPerson', + }, + }); + + createdObjectMetadataPersonId = objectMetadataPersonId; + + const { + data: { + createOneObject: { id: objectMetadataCompanyId }, + }, + } = await createOneObjectMetadata({ + input: { + nameSingular: 'companyForMorphRelation', + namePlural: 'companiesForMorphRelation', + labelSingular: 'Company For Morph Relation', + labelPlural: 'Companies For Morph Relation', + icon: 'IconCompany', + }, + }); + + createdObjectMetadataCompanyId = objectMetadataCompanyId; + + const { + data: { + createOneObject: { id: objectMetadataOpportunityId }, + }, + } = await createOneObjectMetadata({ + input: { + nameSingular: 'opportunityForMorphRelation', + namePlural: 'opportunitiesForMorphRelation', + labelSingular: 'Opportunity For Morph Relation', + labelPlural: 'Opportunities For Morph Relation', + icon: 'IconOpportunity', + }, + }); + + createdObjectMetadataOpportunityId = objectMetadataOpportunityId; + }); + afterEach(async () => { + await deleteOneObjectMetadata({ + input: { idToDelete: createdObjectMetadataPersonId }, + }); + await deleteOneObjectMetadata({ + input: { idToDelete: createdObjectMetadataOpportunityId }, + }); + await deleteOneObjectMetadata({ + input: { idToDelete: createdObjectMetadataCompanyId }, + }); + }); + + type EachTestingContextArray = EachTestingContext< + | { + relationType: RelationType; + objectMetadataId: string; + firstTargetObjectMetadataId: string; + secondTargetObjectMetadataId: string; + type: FieldMetadataType; + } + | ((args: { + objectMetadataId: string; + firstTargetObjectMetadataId: string; + secondTargetObjectMetadataId: string; + }) => { + relationType: RelationType; + objectMetadataId: string; + firstTargetObjectMetadataId: string; + secondTargetObjectMetadataId: string; + type: FieldMetadataType; + }) + >[]; + + const eachTestingContextArray: EachTestingContextArray = [ + { + title: 'should create a MORPH_RELATION field type MANY_TO_ONE', + context: ({ + objectMetadataId, + firstTargetObjectMetadataId, + secondTargetObjectMetadataId, + }) => ({ + relationType: RelationType.MANY_TO_ONE, + objectMetadataId, + firstTargetObjectMetadataId, + secondTargetObjectMetadataId, + type: FieldMetadataType.MORPH_RELATION, + }), + }, + { + title: 'should create a MORPH_RELATION field type ONE_TO_MANY', + context: ({ + objectMetadataId, + firstTargetObjectMetadataId, + secondTargetObjectMetadataId, + }) => ({ + relationType: RelationType.ONE_TO_MANY, + objectMetadataId, + firstTargetObjectMetadataId, + secondTargetObjectMetadataId, + type: FieldMetadataType.MORPH_RELATION, + }), + }, + ]; + + it.each(eachTestingContextArray)('$title', async ({ context }) => { + const contextPayload = + typeof context === 'function' + ? context({ + objectMetadataId: createdObjectMetadataOpportunityId, + firstTargetObjectMetadataId: createdObjectMetadataPersonId, + secondTargetObjectMetadataId: createdObjectMetadataCompanyId, + }) + : context; + + const createdField = await createMorphRelationBetweenObjects({ + objectMetadataId: contextPayload.objectMetadataId, + firstTargetObjectMetadataId: contextPayload.firstTargetObjectMetadataId, + secondTargetObjectMetadataId: contextPayload.secondTargetObjectMetadataId, + type: contextPayload.type, + relationType: contextPayload.relationType, + }); + + expect(createdField.id).toBeDefined(); + expect(createdField.name).toBe('owner'); + // expect(createdField.relation).toBeUndefined(); + // expect(createdField.morphRelations[0].type).toBe( + // contextPayload.relationType, + // ); + // expect(createdField.morphRelations[0].targetFieldMetadata.id).toBeDefined(); + + const isManyToOne = + contextPayload.relationType === RelationType.MANY_TO_ONE; + + if (isManyToOne) { + expect(createdField.settings?.joinColumnName).toBe( + 'ownerOpportunityForMorphRelationId', + ); + } else { + expect(createdField.settings?.joinColumnName).toBeUndefined(); + } + + // TODO: check the morphrelation targets are created correctly (wait for Query Morph Relations) + + await deleteOneFieldMetadata({ + input: { idToDelete: createdField.id }, + }).catch(); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/morph-relation/failing-field-metadata-morph-relation-creation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/morph-relation/failing-field-metadata-morph-relation-creation.integration-spec.ts new file mode 100644 index 000000000..2ed27f824 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/morph-relation/failing-field-metadata-morph-relation-creation.integration-spec.ts @@ -0,0 +1,229 @@ +import { faker } from '@faker-js/faker/.'; +import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; +import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { getMockCreateObjectInput } from 'test/integration/metadata/suites/object-metadata/utils/generate-mock-create-object-metadata-input'; +import { EachTestingContext } from 'twenty-shared/testing'; +import { FieldMetadataType } from 'twenty-shared/types'; + +import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; + +import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input'; + +type GlobalTestContext = { + objectMetadataIds: { + firstTargetObjectId: string; + secondTargetObjectId: string; + sourceObjectId: string; + }; + targetFieldLabel: string; + type: FieldMetadataType; + targetFieldIcon: string; + collisionFieldLabel: string; +}; +const globalTestContext: GlobalTestContext = { + objectMetadataIds: { + firstTargetObjectId: '', + secondTargetObjectId: '', + sourceObjectId: '', + }, + targetFieldLabel: 'defaultTargetFieldLabel', + type: FieldMetadataType.MORPH_RELATION, + targetFieldIcon: 'IconBuildingSkyscraper', + collisionFieldLabel: 'collisionfieldlabel', +}; + +type TestedRelationCreationPayload = Partial< + NonNullable +>; + +type CreateOneObjectMetadataItemTestingContext = EachTestingContext< + | TestedRelationCreationPayload + | ((context: GlobalTestContext) => TestedRelationCreationPayload) +>[]; +describe('Field metadata morph relation creation should fail', () => { + const failingLabelsCreationTestsUseCase: CreateOneObjectMetadataItemTestingContext = + [ + { + title: 'when targetFieldLabel is empty', + context: { targetFieldLabel: '' }, + }, + { + title: 'when targetFieldLabel exceeds maximum length', + context: { + targetFieldLabel: 'A'.repeat(64), + }, + }, + { + // Not handled gracefully should be refactored + title: 'when targetObjectMetadataId is unknown', + context: { + targetObjectMetadataId: faker.string.uuid(), + }, + }, + { + title: 'when targetFieldLabel contains only whitespace', + context: { targetFieldLabel: ' ' }, + }, + { + title: + 'when targetFieldLabel conflicts with an existing field on target object metadata id', + context: ({ collisionFieldLabel }) => ({ + targetFieldLabel: collisionFieldLabel, + }), + }, + { + title: 'when type is not provided', + context: { type: undefined }, + }, + { + title: 'when type is a wrong value', + context: { type: 'wrong' as RelationType }, + }, + ]; + + beforeAll(async () => { + const { + data: { + createOneObject: { id: sourceObjectId }, + }, + } = await createOneObjectMetadata({ + input: getMockCreateObjectInput({ + namePlural: 'sourceRelations', + nameSingular: 'sourceRelation', + }), + }); + + const { + data: { + createOneObject: { id: firstTargetObjectId }, + }, + } = await createOneObjectMetadata({ + input: getMockCreateObjectInput({ + namePlural: 'firstTargetRelations', + nameSingular: 'firstTargetRelation', + }), + }); + + const { + data: { + createOneObject: { id: secondTargetObjectId }, + }, + } = await createOneObjectMetadata({ + input: getMockCreateObjectInput({ + namePlural: 'secondTargetRelations', + nameSingular: 'secondTargetRelation', + }), + }); + + globalTestContext.objectMetadataIds = { + sourceObjectId, + firstTargetObjectId, + secondTargetObjectId, + }; + + const { data } = await createOneFieldMetadata({ + input: { + objectMetadataId: firstTargetObjectId, + name: globalTestContext.collisionFieldLabel, + label: 'LabelThatCouldBeAnything', + isLabelSyncedWithName: false, + type: FieldMetadataType.TEXT, + }, + }); + + expect(data).toBeDefined(); + }); + + afterAll(async () => { + for (const objectMetadataId of Object.values( + globalTestContext.objectMetadataIds, + )) { + await deleteOneObjectMetadata({ + input: { + idToDelete: objectMetadataId, + }, + }); + } + }); + + it.each(failingLabelsCreationTestsUseCase)( + 'relation ONE_TO_MANY $title', + async ({ context }) => { + const computedContext = + typeof context === 'function' ? context(globalTestContext) : context; + + const { errors } = await createOneFieldMetadata({ + expectToFail: true, + input: { + objectMetadataId: globalTestContext.objectMetadataIds.sourceObjectId, + name: 'owner', + label: 'owner field', + isLabelSyncedWithName: false, + type: FieldMetadataType.MORPH_RELATION, + morphRelationsCreationPayload: [ + { + targetFieldLabel: 'defaultFirstTargetFieldLabel', + type: RelationType.ONE_TO_MANY, + targetObjectMetadataId: + globalTestContext.objectMetadataIds.firstTargetObjectId, + targetFieldIcon: 'IconBuildingSkyscraper', + ...computedContext, + }, + { + targetFieldLabel: 'defaultSecondTargetFieldLabel', + type: RelationType.ONE_TO_MANY, + targetObjectMetadataId: + globalTestContext.objectMetadataIds.secondTargetObjectId, + targetFieldIcon: 'IconBuildingSkyscraper', + ...computedContext, + }, + ], + }, + }); + + expect(errors).toBeDefined(); + expect(errors).toMatchSnapshot(); + }, + ); + + it.each(failingLabelsCreationTestsUseCase)( + 'relation MANY_TO_ONE $title', + async ({ context }) => { + const computedContext = + typeof context === 'function' ? context(globalTestContext) : context; + + const { errors } = await createOneFieldMetadata({ + expectToFail: true, + input: { + objectMetadataId: globalTestContext.objectMetadataIds.sourceObjectId, + name: 'owner', + label: 'owner field', + isLabelSyncedWithName: false, + type: FieldMetadataType.MORPH_RELATION, + morphRelationsCreationPayload: [ + { + targetFieldLabel: 'defaultFirstTargetFieldLabel', + type: RelationType.MANY_TO_ONE, + targetObjectMetadataId: + globalTestContext.objectMetadataIds.firstTargetObjectId, + targetFieldIcon: 'IconBuildingSkyscraper', + ...computedContext, + }, + { + targetFieldLabel: 'defaultSecondTargetFieldLabel', + type: RelationType.MANY_TO_ONE, + targetObjectMetadataId: + globalTestContext.objectMetadataIds.secondTargetObjectId, + targetFieldIcon: 'IconBuildingSkyscraper', + ...computedContext, + }, + ], + }, + }); + + expect(errors).toBeDefined(); + expect(errors).toMatchSnapshot(); + }, + ); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/__snapshots__/failing-field-metadata-relation-creation.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/__snapshots__/failing-field-metadata-relation-creation.integration-spec.ts.snap new file mode 100644 index 000000000..d0c1e7f4a --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/__snapshots__/failing-field-metadata-relation-creation.integration-spec.ts.snap @@ -0,0 +1,187 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Field metadata relation creation should fail relation MANY_TO_ONE when targetFieldLabel conflicts with an existing field on target object metadata id 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Name "collisionfieldlabel" is not available", + "name": "UserInputError", + }, +] +`; + +exports[`Field metadata relation creation should fail relation MANY_TO_ONE when targetFieldLabel contains only whitespace 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Invalid label: " "", + "name": "UserInputError", + }, +] +`; + +exports[`Field metadata relation creation should fail relation MANY_TO_ONE when targetFieldLabel exceeds maximum length 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Name is too long: it exceeds the 63 characters limit.", + "name": "UserInputError", + }, +] +`; + +exports[`Field metadata relation creation should fail relation MANY_TO_ONE when targetFieldLabel is empty 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Input is too short: """, + "name": "UserInputError", + }, +] +`; + +exports[`Field metadata relation creation should fail relation MANY_TO_ONE when targetObjectMetadataId is unknown 1`] = ` +[ + { + "extensions": { + "code": "INTERNAL_SERVER_ERROR", + "exceptionEventId": "mocked-exception-id", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Object metadata relation target not found for relation creation payload", + }, +] +`; + +exports[`Field metadata relation creation should fail relation MANY_TO_ONE when type is a wrong value 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "INVALID_FIELD_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Relation creation payload is invalid: type must be one of the following values: ONE_TO_MANY, MANY_TO_ONE", + "name": "UserInputError", + }, +] +`; + +exports[`Field metadata relation creation should fail relation MANY_TO_ONE when type is not provided 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "INVALID_FIELD_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Relation creation payload is invalid: type must be one of the following values: ONE_TO_MANY, MANY_TO_ONE", + "name": "UserInputError", + }, +] +`; + +exports[`Field metadata relation creation should fail relation ONE_TO_MANY when targetFieldLabel conflicts with an existing field on target object metadata id 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Name "collisionfieldlabel" is not available", + "name": "UserInputError", + }, +] +`; + +exports[`Field metadata relation creation should fail relation ONE_TO_MANY when targetFieldLabel contains only whitespace 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Invalid label: " "", + "name": "UserInputError", + }, +] +`; + +exports[`Field metadata relation creation should fail relation ONE_TO_MANY when targetFieldLabel exceeds maximum length 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Name is too long: it exceeds the 63 characters limit.", + "name": "UserInputError", + }, +] +`; + +exports[`Field metadata relation creation should fail relation ONE_TO_MANY when targetFieldLabel is empty 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Input is too short: """, + "name": "UserInputError", + }, +] +`; + +exports[`Field metadata relation creation should fail relation ONE_TO_MANY when targetObjectMetadataId is unknown 1`] = ` +[ + { + "extensions": { + "code": "INTERNAL_SERVER_ERROR", + "exceptionEventId": "mocked-exception-id", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Object metadata relation target not found for relation creation payload", + }, +] +`; + +exports[`Field metadata relation creation should fail relation ONE_TO_MANY when type is a wrong value 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "INVALID_FIELD_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Relation creation payload is invalid: type must be one of the following values: ONE_TO_MANY, MANY_TO_ONE", + "name": "UserInputError", + }, +] +`; + +exports[`Field metadata relation creation should fail relation ONE_TO_MANY when type is not provided 1`] = ` +[ + { + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "INVALID_FIELD_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Relation creation payload is invalid: type must be one of the following values: ONE_TO_MANY, MANY_TO_ONE", + "name": "UserInputError", + }, +] +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/failing-field-metadata-relation-update.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/__snapshots__/failing-field-metadata-relation-update.integration-spec.ts.snap similarity index 100% rename from packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/failing-field-metadata-relation-update.integration-spec.ts.snap rename to packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/__snapshots__/failing-field-metadata-relation-update.integration-spec.ts.snap diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/create-one-field-metadata-relation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/create-one-field-metadata-relation.integration-spec.ts new file mode 100644 index 000000000..61bea3b02 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/create-one-field-metadata-relation.integration-spec.ts @@ -0,0 +1,196 @@ +import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util'; +import { findManyFieldsMetadataQueryFactory } from 'test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata-query-factory.util'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; +import { createRelationBetweenObjects } from 'test/integration/metadata/suites/object-metadata/utils/create-relation-between-objects.util'; +import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { EachTestingContext } from 'twenty-shared/testing'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; + +describe('createOne FieldMetadataService relation fields', () => { + let createdObjectMetadataPersonId = ''; + let createdObjectMetadataOpportunityId = ''; + + beforeEach(async () => { + const { + data: { + createOneObject: { id: objectMetadataPersonId }, + }, + } = await createOneObjectMetadata({ + input: { + nameSingular: 'personForRelation', + namePlural: 'peopleForRelation', + labelSingular: 'Person For Relation', + labelPlural: 'People For Relation', + icon: 'IconPerson', + }, + }); + + createdObjectMetadataPersonId = objectMetadataPersonId; + + const { + data: { + createOneObject: { id: objectMetadataOpportunityId }, + }, + } = await createOneObjectMetadata({ + input: { + nameSingular: 'opportunityForRelation', + namePlural: 'opportunitiesForRelation', + labelSingular: 'Opportunity For Relation', + labelPlural: 'Opportunities For Relation', + icon: 'IconOpportunity', + }, + }); + + createdObjectMetadataOpportunityId = objectMetadataOpportunityId; + }); + afterEach(async () => { + await deleteOneObjectMetadata({ + input: { idToDelete: createdObjectMetadataPersonId }, + }); + await deleteOneObjectMetadata({ + input: { idToDelete: createdObjectMetadataOpportunityId }, + }); + }); + + type EachTestingContextArray = EachTestingContext< + | { + relationType: RelationType; + objectMetadataId: string; + targetObjectMetadataId: string; + type: FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION; + } + | ((args: { objectMetadataId: string; targetObjectMetadataId: string }) => { + relationType: RelationType; + objectMetadataId: string; + targetObjectMetadataId: string; + type: FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION; + }) + >[]; + + const eachTestingContextArray: EachTestingContextArray = [ + { + title: 'should create a RELATION field type MANY_TO_ONE', + context: ({ objectMetadataId, targetObjectMetadataId }) => ({ + relationType: RelationType.MANY_TO_ONE, + objectMetadataId, + targetObjectMetadataId, + type: FieldMetadataType.RELATION, + }), + }, + { + title: 'should create a RELATION field type ONE_TO_MANY', + context: ({ objectMetadataId, targetObjectMetadataId }) => ({ + relationType: RelationType.ONE_TO_MANY, + objectMetadataId, + targetObjectMetadataId, + type: FieldMetadataType.RELATION, + }), + }, + ]; + + it.each(eachTestingContextArray)('$title', async ({ context }) => { + const contextPayload = + typeof context === 'function' + ? context({ + objectMetadataId: createdObjectMetadataOpportunityId, + targetObjectMetadataId: createdObjectMetadataPersonId, + }) + : context; + + const createdField = await createRelationBetweenObjects({ + objectMetadataId: contextPayload.objectMetadataId, + targetObjectMetadataId: contextPayload.targetObjectMetadataId, + type: contextPayload.type, + relationType: contextPayload.relationType, + }); + + expect(createdField.id).toBeDefined(); + expect(createdField.name).toBe('person'); + expect(createdField.relation?.type).toBe(contextPayload.relationType); + expect(createdField.relation?.targetFieldMetadata.id).toBeDefined(); + // TODO: expect(createdField.morphRelations).toBeUndefined(); + const isManyToOne = + contextPayload.relationType === RelationType.MANY_TO_ONE; + + if (isManyToOne) { + expect(createdField.settings?.joinColumnName).toBe('personId'); + } else { + expect(createdField.settings?.joinColumnName).toBeUndefined(); + } + + if (!isDefined(createdField.relation?.targetFieldMetadata?.id)) { + throw new Error('targetFieldMetadata.id is not defined'); + } + + const opportunityFieldOnPerson = await findFieldMetadata({ + fieldMetadataId: createdField.relation.targetFieldMetadata.id, + }); + + expect(opportunityFieldOnPerson.object.nameSingular).toBe( + 'personForRelation', + ); + expect(opportunityFieldOnPerson.relation.type).toBe( + isManyToOne ? RelationType.ONE_TO_MANY : RelationType.MANY_TO_ONE, + ); + expect( + opportunityFieldOnPerson.relation.targetFieldMetadata.id, + ).toBeDefined(); + expect( + opportunityFieldOnPerson.relation.targetObjectMetadata.id, + ).toBeDefined(); + + if (!isManyToOne) { + expect(opportunityFieldOnPerson.settings?.joinColumnName).toBe( + 'opportunityId', + ); + } else { + expect(opportunityFieldOnPerson.settings?.joinColumnName).toBeUndefined(); + } + + await deleteOneFieldMetadata({ + input: { idToDelete: createdField.id }, + }).catch(); + }); +}); + +const findFieldMetadata = async ({ + fieldMetadataId, +}: { + fieldMetadataId: string; +}) => { + const operation = findManyFieldsMetadataQueryFactory({ + gqlFields: ` + id + name + object { + id + nameSingular + } + relation { + type + targetFieldMetadata { + id + } + targetObjectMetadata { + id + } + } + settings + `, + input: { + filter: { + id: { eq: fieldMetadataId }, + }, + paging: { first: 10 }, + }, + }); + + const fields = await makeMetadataAPIRequest(operation); + const field = fields.body.data.fields.edges?.[0]?.node; + + return field; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/failing-field-metadata-relation-creation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/failing-field-metadata-relation-creation.integration-spec.ts similarity index 79% rename from packages/twenty-server/test/integration/metadata/suites/field-metadata/failing-field-metadata-relation-creation.integration-spec.ts rename to packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/failing-field-metadata-relation-creation.integration-spec.ts index aaea040ca..ad89a48b1 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/failing-field-metadata-relation-creation.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/failing-field-metadata-relation-creation.integration-spec.ts @@ -62,6 +62,14 @@ describe('Field metadata relation creation should fail', () => { targetFieldLabel: collisionFieldLabel, }), }, + { + title: 'when type is not provided', + context: { type: undefined }, + }, + { + title: 'when type is a wrong value', + context: { type: 'wrong' as RelationType }, + }, ]; beforeAll(async () => { @@ -118,7 +126,7 @@ describe('Field metadata relation creation should fail', () => { }); it.each(failingLabelsCreationTestsUseCase)( - 'relation $title', + 'relation ONE_TO_MANY $title', async ({ context }) => { const computedContext = typeof context === 'function' ? context(globalTestContext) : context; @@ -146,4 +154,34 @@ describe('Field metadata relation creation should fail', () => { expect(errors).toMatchSnapshot(); }, ); + + it.each(failingLabelsCreationTestsUseCase)( + 'relation MANY_TO_ONE $title', + async ({ context }) => { + const computedContext = + typeof context === 'function' ? context(globalTestContext) : context; + + const { errors } = await createOneFieldMetadata({ + expectToFail: true, + input: { + objectMetadataId: globalTestContext.objectMetadataIds.sourceObjectId, + name: 'fieldname', + label: 'Relation field', + isLabelSyncedWithName: false, + type: FieldMetadataType.RELATION, + relationCreationPayload: { + targetFieldLabel: 'defaultTargetFieldLabel', + type: RelationType.MANY_TO_ONE, + targetObjectMetadataId: + globalTestContext.objectMetadataIds.targetObjectId, + targetFieldIcon: 'IconBuildingSkyscraper', + ...computedContext, + }, + }, + }); + + expect(errors).toBeDefined(); + expect(errors).toMatchSnapshot(); + }, + ); }); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/failing-field-metadata-relation-update.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/failing-field-metadata-relation-update.integration-spec.ts similarity index 100% rename from packages/twenty-server/test/integration/metadata/suites/field-metadata/failing-field-metadata-relation-update.integration-spec.ts rename to packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/failing-field-metadata-relation-update.integration-spec.ts diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-related-record.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-related-record.integration-spec.ts index 3d868d12a..de0220ee1 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-related-record.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-related-record.integration-spec.ts @@ -1,5 +1,4 @@ import { faker } from '@faker-js/faker'; -import { isDefined } from 'class-validator'; import { createOneOperation } from 'test/integration/graphql/utils/create-one-operation.util'; import { findOneOperation } from 'test/integration/graphql/utils/find-one-operation.util'; import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util'; @@ -9,7 +8,7 @@ import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object import { getMockCreateObjectInput } from 'test/integration/metadata/suites/object-metadata/utils/generate-mock-create-object-metadata-input'; import { EachTestingContext } from 'twenty-shared/testing'; import { FieldMetadataType } from 'twenty-shared/types'; -import { parseJson } from 'twenty-shared/utils'; +import { isDefined, parseJson } from 'twenty-shared/utils'; import { FieldMetadataComplexOption, @@ -85,7 +84,7 @@ describe('update-one-field-metadata-related-record', () => { const { data: { createOneField }, - } = await createOneFieldMetadata({ + } = await createOneFieldMetadata({ input: { objectMetadataId: createOneObject.id, type: fieldMetadataType, @@ -258,6 +257,10 @@ describe('update-one-field-metadata-related-record', () => { }); const optionsWithIds = createOneField.options; + + if (!isDefined(optionsWithIds)) { + throw new Error('optionsWithIds is not defined'); + } const updatedOptions = updateOptions(optionsWithIds); await updateOneFieldMetadata({ @@ -358,6 +361,10 @@ describe('update-one-field-metadata-related-record', () => { }); const optionsWithIds = createOneField.options; + + if (!isDefined(optionsWithIds)) { + throw new Error('optionsWithIds is not defined'); + } const updatePayload = { options: optionsWithIds.map((option) => updateOption(option)), }; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util.ts index 0a49d10f5..ff7528658 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util.ts @@ -6,15 +6,16 @@ import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/m import { CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; import { PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; +import { FieldMetadataType } from 'twenty-shared/types'; -import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; -export const createOneFieldMetadata = async ({ +export const createOneFieldMetadata = async ({ input, gqlFields, expectToFail = false, }: PerformMetadataQueryParams): CommonResponseBody<{ - createOneField: FieldMetadataEntity; + createOneField: FieldMetadataInterface; }> => { const graphqlOperation = createOneFieldMetadataQueryFactory({ input, diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/create-morph-relation-between-objects.util.ts b/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/create-morph-relation-between-objects.util.ts new file mode 100644 index 000000000..d1dae1455 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/create-morph-relation-between-objects.util.ts @@ -0,0 +1,80 @@ +import { CreateOneFieldFactoryInput } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata-query-factory.util'; +import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util'; +import { FieldMetadataType } from 'twenty-shared/types'; + +import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; + +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; + +export const createMorphRelationBetweenObjects = async ({ + objectMetadataId, + firstTargetObjectMetadataId, + secondTargetObjectMetadataId, + type, + relationType, + name, + label, + isLabelSyncedWithName, + targetFieldLabel, + targetFieldIcon, +}: { + objectMetadataId: string; + firstTargetObjectMetadataId: string; + secondTargetObjectMetadataId: string; + type: FieldMetadataType; + relationType: RelationType; + name?: string; + label?: string; + isLabelSyncedWithName?: boolean; + targetFieldLabel?: string; + targetFieldIcon?: string; +}) => { + const createFieldInput: CreateOneFieldFactoryInput = { + name: name || 'owner', + label: label || 'owner field', + type, + objectMetadataId, + isLabelSyncedWithName: isLabelSyncedWithName || false, + morphRelationsCreationPayload: [ + { + targetObjectMetadataId: firstTargetObjectMetadataId, + targetFieldLabel: targetFieldLabel || 'opportunity', + targetFieldIcon: targetFieldIcon || 'IconListOpportunity', + type: relationType, + }, + { + targetObjectMetadataId: secondTargetObjectMetadataId, + targetFieldLabel: targetFieldLabel || 'opportunity', + targetFieldIcon: targetFieldIcon || 'IconListOpportunity', + type: relationType, + }, + ], + }; + + // TODO: add morphRelations to the query once available + // morphRelations { + // type + // targetFieldMetadata { + // id + // } + // } + const { + data: { createOneField: createdFieldPerson }, + } = await createOneFieldMetadata({ + input: createFieldInput, + gqlFields: ` + id + name + label + isLabelSyncedWithName + settings + object { + id + nameSingular + } + `, + expectToFail: false, + }); + + return createdFieldPerson as FieldMetadataEntity; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/create-relation-between-objects.util.ts b/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/create-relation-between-objects.util.ts new file mode 100644 index 000000000..dd28c1249 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/create-relation-between-objects.util.ts @@ -0,0 +1,67 @@ +import { CreateOneFieldFactoryInput } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata-query-factory.util'; +import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util'; +import { FieldMetadataType } from 'twenty-shared/types'; + +import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; + +export const createRelationBetweenObjects = async ({ + objectMetadataId, + targetObjectMetadataId, + type, + relationType, + name, + label, + isLabelSyncedWithName, + targetFieldLabel, + targetFieldIcon, +}: { + objectMetadataId: string; + targetObjectMetadataId: string; + type: FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION; + relationType: RelationType; + name?: string; + label?: string; + isLabelSyncedWithName?: boolean; + targetFieldLabel?: string; + targetFieldIcon?: string; +}) => { + const createFieldInput: CreateOneFieldFactoryInput = { + name: name || 'person', + label: label || 'person field', + type: type, + objectMetadataId: objectMetadataId, + isLabelSyncedWithName: isLabelSyncedWithName || false, + relationCreationPayload: { + targetObjectMetadataId: targetObjectMetadataId, + targetFieldLabel: targetFieldLabel || 'opportunity', + targetFieldIcon: targetFieldIcon || 'IconListOpportunity', + type: relationType, + }, + }; + + const { + data: { createOneField: createdFieldPerson }, + } = await createOneFieldMetadata({ + input: createFieldInput, + gqlFields: ` + id + name + label + isLabelSyncedWithName + relation { + type + targetFieldMetadata { + id + } + } + settings + object { + id + nameSingular + } + `, + expectToFail: false, + }); + + return createdFieldPerson; +};