diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index bf364e63b..6b9d9e5b0 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -463,6 +463,7 @@ export type CreateFieldInput = { isSystem?: InputMaybe; isUnique?: InputMaybe; label: Scalars['String']; + morphRelationsCreationPayload?: InputMaybe>; name: Scalars['String']; objectMetadataId: Scalars['String']; options?: InputMaybe; @@ -679,6 +680,7 @@ export enum FeatureFlagKey { IS_AI_ENABLED = 'IS_AI_ENABLED', IS_IMAP_ENABLED = 'IS_IMAP_ENABLED', IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', + IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED', IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED', IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED', IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED', @@ -747,6 +749,7 @@ export enum FieldMetadataType { EMAILS = 'EMAILS', FULL_NAME = 'FULL_NAME', LINKS = 'LINKS', + MORPH_RELATION = 'MORPH_RELATION', MULTI_SELECT = 'MULTI_SELECT', NUMBER = 'NUMBER', NUMERIC = 'NUMERIC', diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index c99eb5678..b13566479 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -459,6 +459,7 @@ export type CreateFieldInput = { isSystem?: InputMaybe; isUnique?: InputMaybe; label: Scalars['String']; + morphRelationsCreationPayload?: InputMaybe>; name: Scalars['String']; objectMetadataId: Scalars['String']; options?: InputMaybe; @@ -643,6 +644,7 @@ export enum FeatureFlagKey { IS_AI_ENABLED = 'IS_AI_ENABLED', IS_IMAP_ENABLED = 'IS_IMAP_ENABLED', IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', + IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED', IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED', IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED', IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED', @@ -711,6 +713,7 @@ export enum FieldMetadataType { EMAILS = 'EMAILS', FULL_NAME = 'FULL_NAME', LINKS = 'LINKS', + MORPH_RELATION = 'MORPH_RELATION', MULTI_SELECT = 'MULTI_SELECT', NUMBER = 'NUMBER', NUMERIC = 'NUMERIC', diff --git a/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts b/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts index 5230625c2..de113906e 100644 --- a/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts +++ b/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts @@ -56,7 +56,8 @@ export const generateEmptyFieldValue = ({ case FieldMetadataType.BOOLEAN: { return true; } - case FieldMetadataType.RELATION: { + case FieldMetadataType.RELATION: + case FieldMetadataType.MORPH_RELATION: { if (fieldMetadataItem.relation?.type === RelationType.MANY_TO_ONE) { return null; } diff --git a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsNonCompositeFieldTypeConfigs.ts b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsNonCompositeFieldTypeConfigs.ts index d0f33a001..66fe96e18 100644 --- a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsNonCompositeFieldTypeConfigs.ts +++ b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsNonCompositeFieldTypeConfigs.ts @@ -122,6 +122,11 @@ export const SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS: SettingsNonCompositeFiel Icon: IllustrationIconOneToMany, category: 'Relation', } as const satisfies SettingsFieldTypeConfig>, + [FieldMetadataType.MORPH_RELATION]: { + label: 'Morph Relation', + Icon: IllustrationIconOneToMany, + category: 'Relation', + } as const satisfies SettingsFieldTypeConfig>, [FieldMetadataType.RATING]: { label: 'Rating', Icon: IllustrationIconStar, diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsObjectNewFieldSelector.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsObjectNewFieldSelector.tsx index 78221a4d3..fadefd87b 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsObjectNewFieldSelector.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsObjectNewFieldSelector.tsx @@ -11,6 +11,7 @@ import { FieldType } from '@/settings/data-model/types/FieldType'; import { SettingsFieldType } from '@/settings/data-model/types/SettingsFieldType'; import { SettingsPath } from '@/types/SettingsPath'; import { TextInput } from '@/ui/input/components/TextInput'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { t } from '@lingui/core/macro'; @@ -20,6 +21,7 @@ import { Controller, useFormContext } from 'react-hook-form'; import { H2Title, IconSearch } from 'twenty-ui/display'; import { UndecoratedLink } from 'twenty-ui/navigation'; import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { FeatureFlagKey } from '~/generated/graphql'; import { SettingsDataModelFieldTypeFormValues } from '~/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldSelect'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; @@ -102,7 +104,9 @@ export const SettingsObjectNewFieldSelector = ({ break; } }; - + const isMorphRelationEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_MORPH_RELATION_ENABLED, + ); return ( <> {' '} @@ -131,6 +135,12 @@ export const SettingsObjectNewFieldSelector = ({ {fieldTypeConfigs .filter(([, config]) => config.category === category) + .filter(([key]) => { + return ( + key !== FieldMetadataType.MORPH_RELATION || + isMorphRelationEnabled + ); + }) .map(([key, config]) => ( = { [FieldMetadataType.RATING]: 'IconStar', [FieldMetadataType.RAW_JSON]: 'IconBraces', [FieldMetadataType.RELATION]: 'IconRelationOneToMany', + [FieldMetadataType.MORPH_RELATION]: 'IconRelationOneToMany', [FieldMetadataType.SELECT]: 'IconTag', [FieldMetadataType.TEXT]: 'IconTypography', [FieldMetadataType.UUID]: 'IconId', diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1751558024634-morph-index-update.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1751558024634-morph-index-update.ts new file mode 100644 index 000000000..c6821fc7a --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1751558024634-morph-index-update.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class MorphIndexUpdate1751558024634 implements MigrationInterface { + name = 'MorphIndexUpdate1751558024634'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."fieldMetadata" DROP CONSTRAINT "IDX_FIELD_METADATA_NAME_OBJECT_METADATA_ID_WORKSPACE_ID_UNIQUE"`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_FIELD_METADATA_NAME_OBJMID_WORKSPACE_ID_EXCEPT_MORPH_UNIQUE" ON "core"."fieldMetadata" ("name", "objectMetadataId", "workspaceId") WHERE "type" <> 'MORPH_RELATION'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "core"."IDX_FIELD_METADATA_NAME_OBJMID_WORKSPACE_ID_EXCEPT_MORPH_UNIQUE"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."fieldMetadata" ADD CONSTRAINT "IDX_FIELD_METADATA_NAME_OBJECT_METADATA_ID_WORKSPACE_ID_UNIQUE" UNIQUE ("name", "objectMetadataId", "workspaceId")`, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/extend-object-type-definition-v2.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/extend-object-type-definition-v2.factory.ts index 04c8febc7..07b70c484 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/extend-object-type-definition-v2.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/extend-object-type-definition-v2.factory.ts @@ -112,24 +112,29 @@ export class ExtendObjectTypeDefinitionV2Factory { for (const fieldMetadata of objectMetadata.fields) { // Ignore non-relation fields as they are already defined - if ( - !isFieldMetadataInterfaceOfType( + const isRelation = + isFieldMetadataInterfaceOfType( fieldMetadata, FieldMetadataType.RELATION, - ) - ) { + ) || + isFieldMetadataInterfaceOfType( + fieldMetadata, + FieldMetadataType.MORPH_RELATION, + ); + + if (!isRelation) { continue; } if (!fieldMetadata.settings) { throw new Error( - `Field Metadata of type RELATION with id ${fieldMetadata.id} has no settings`, + `Field Metadata of type RELATION or MORPH_RELATION with id ${fieldMetadata.id} has no settings`, ); } if (!fieldMetadata.relationTargetObjectMetadataId) { throw new Error( - `Field Metadata of type RELATION with id ${fieldMetadata.id} has no relation target object metadata id`, + `Field Metadata of type RELATION or MORPH_RELATION with id ${fieldMetadata.id} has no relation target object metadata id`, ); } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/relation-type-v2.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/relation-type-v2.factory.ts index 613f0c368..2775576f1 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/relation-type-v2.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/relation-type-v2.factory.ts @@ -19,17 +19,19 @@ export class RelationTypeV2Factory { ) {} public create( - fieldMetadata: FieldMetadataInterface, + fieldMetadata: FieldMetadataInterface< + FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION + >, ): GraphQLOutputType { if (!fieldMetadata.settings) { throw new Error( - `Field Metadata of type RELATION with id ${fieldMetadata.id} has no settings`, + `Field Metadata of type RELATION or MORPH_RELATION with id ${fieldMetadata.id} has no settings`, ); } if (!fieldMetadata.relationTargetObjectMetadataId) { throw new Error( - `Field Metadata of type RELATION with id ${fieldMetadata.id} has no relation target object metadata id`, + `Field Metadata of type RELATION or MORPH_RELATION with id ${fieldMetadata.id} has no relation target object metadata id`, ); } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts index 414e41642..0ab99edfe 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts @@ -15,7 +15,10 @@ import { } from 'graphql'; import { FieldMetadataType } from 'twenty-shared/types'; -import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface'; +import { + FieldMetadataSettings, + NumberDataType, +} from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface'; import { OrderByDirectionType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/enum'; import { @@ -62,7 +65,11 @@ export class TypeMapperService { settings?: FieldMetadataSettings, isIdField?: boolean, ): GraphQLScalarType | undefined { - if (isIdField || fieldMetadataType === FieldMetadataType.RELATION) { + if ( + isIdField || + fieldMetadataType === FieldMetadataType.RELATION || + fieldMetadataType === FieldMetadataType.MORPH_RELATION + ) { return GraphQLID; } const typeScalarMapping = new Map([ @@ -75,7 +82,7 @@ export class TypeMapperService { FieldMetadataType.NUMBER, getNumberScalarType( (settings as FieldMetadataSettings) - ?.dataType, + ?.dataType ?? NumberDataType.FLOAT, ), ], [FieldMetadataType.NUMERIC, BigFloatScalarType], @@ -97,7 +104,11 @@ export class TypeMapperService { settings?: FieldMetadataSettings, isIdField?: boolean, ): GraphQLInputObjectType | GraphQLScalarType | undefined { - if (isIdField || fieldMetadataType === FieldMetadataType.RELATION) { + if ( + isIdField || + fieldMetadataType === FieldMetadataType.RELATION || + fieldMetadataType === FieldMetadataType.MORPH_RELATION + ) { return UUIDFilterType; } @@ -137,6 +148,7 @@ export class TypeMapperService { const typeOrderByMapping = new Map([ [FieldMetadataType.UUID, OrderByDirectionType], [FieldMetadataType.RELATION, OrderByDirectionType], + [FieldMetadataType.MORPH_RELATION, OrderByDirectionType], [FieldMetadataType.TEXT, OrderByDirectionType], [FieldMetadataType.DATE_TIME, OrderByDirectionType], [FieldMetadataType.DATE, OrderByDirectionType], diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts index 0e53e4c0e..8423cabaa 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts @@ -64,9 +64,17 @@ export const generateFields = < for (const fieldMetadata of objectMetadata.fields) { let generatedField; - if ( - isFieldMetadataInterfaceOfType(fieldMetadata, FieldMetadataType.RELATION) - ) { + const isRelation = + isFieldMetadataInterfaceOfType( + fieldMetadata, + FieldMetadataType.RELATION, + ) || + isFieldMetadataInterfaceOfType( + fieldMetadata, + FieldMetadataType.MORPH_RELATION, + ); + + if (isRelation) { generatedField = generateRelationField({ fieldMetadata, kind, @@ -162,7 +170,9 @@ const generateRelationField = < typeFactory, isRelationConnectEnabled, }: { - fieldMetadata: FieldMetadataInterface; + fieldMetadata: FieldMetadataInterface< + FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION + >; kind: T; options: WorkspaceBuildSchemaOptions; typeFactory: TypeFactory; diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/__tests__/map-field-metadata-to-graphql-query.utils.spec.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/__tests__/map-field-metadata-to-graphql-query.utils.spec.ts index a8e4fff5f..90057c1f2 100644 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/__tests__/map-field-metadata-to-graphql-query.utils.spec.ts +++ b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/__tests__/map-field-metadata-to-graphql-query.utils.spec.ts @@ -130,6 +130,12 @@ describe('mapFieldMetadataToGraphqlQuery', () => { } as FieldMetadataDefaultSettings; } + if (fieldMetadataType === FieldMetadataType.MORPH_RELATION) { + field.settings = { + relationType: RelationType.MANY_TO_ONE, + } as FieldMetadataDefaultSettings; + } + expect( mapFieldMetadataToGraphqlQuery(objectMetadataMapsMock, field), ).toBeDefined(); diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils.ts index aa5b54aae..59aeaefd5 100644 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils.ts +++ b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils.ts @@ -38,11 +38,15 @@ export const mapFieldMetadataToGraphqlQuery = ( FieldMetadataType.TS_VECTOR, ].includes(fieldType); + const isRelation = + isFieldMetadataInterfaceOfType(field, FieldMetadataType.RELATION) || + isFieldMetadataInterfaceOfType(field, FieldMetadataType.MORPH_RELATION); + if (fieldIsSimpleValue) { return field.name; } else if ( maxDepthForRelations > 0 && - isFieldMetadataInterfaceOfType(field, FieldMetadataType.RELATION) && + isRelation && field.settings?.relationType === RelationType.MANY_TO_ONE ) { const targetObjectMetadataId = field.relationTargetObjectMetadataId; @@ -69,7 +73,7 @@ export const mapFieldMetadataToGraphqlQuery = ( }`; } else if ( maxDepthForRelations > 0 && - isFieldMetadataInterfaceOfType(field, FieldMetadataType.RELATION) && + isRelation && field.settings?.relationType === RelationType.ONE_TO_MANY ) { const targetObjectMetadataId = field.relationTargetObjectMetadataId; diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index b4ac0175a..3c2bc881a 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -6,6 +6,7 @@ export enum FeatureFlagKey { IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', IS_AI_ENABLED = 'IS_AI_ENABLED', IS_IMAP_ENABLED = 'IS_IMAP_ENABLED', + IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED', IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED', IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED', } diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts index 0918a2380..3818f07c9 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts @@ -5,7 +5,7 @@ import { capitalize } from 'twenty-shared/utils'; import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface'; import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; -import { generateRandomFieldValue } from 'src/engine/core-modules/open-api/utils/generate-random-field-value.utils'; +import { generateRandomFieldValue } from 'src/engine/core-modules/open-api/utils/generate-random-field-value.util'; import { computeDepthParameters, computeEndingBeforeParameters, diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/generate-random-field-value.utils.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/generate-random-field-value.util.ts similarity index 97% rename from packages/twenty-server/src/engine/core-modules/open-api/utils/generate-random-field-value.utils.ts rename to packages/twenty-server/src/engine/core-modules/open-api/utils/generate-random-field-value.util.ts index 425e31edc..c8ea7ebed 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/utils/generate-random-field-value.utils.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/generate-random-field-value.util.ts @@ -1,7 +1,7 @@ -import { FieldMetadataType } from 'twenty-shared/types'; -import { v4 } from 'uuid'; import { faker } from '@faker-js/faker'; +import { FieldMetadataType } from 'twenty-shared/types'; import { assertUnreachable, isDefined } from 'twenty-shared/utils'; +import { v4 } from 'uuid'; import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface'; @@ -88,7 +88,8 @@ export const generateRandomFieldValue = ({ return isDefined(field.options[0].value) ? [field.options[0].value] : []; } - case FieldMetadataType.RELATION: { + case FieldMetadataType.RELATION: + case FieldMetadataType.MORPH_RELATION: { return null; } diff --git a/packages/twenty-server/src/engine/dataloaders/dataloader.service.ts b/packages/twenty-server/src/engine/dataloaders/dataloader.service.ts index d556fee94..7508aa7d8 100644 --- a/packages/twenty-server/src/engine/dataloaders/dataloader.service.ts +++ b/packages/twenty-server/src/engine/dataloaders/dataloader.service.ts @@ -11,8 +11,8 @@ import { IndexMetadataInterface } from 'src/engine/metadata-modules/index-metada import { IDataloaders } from 'src/engine/dataloaders/dataloader.interface'; import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; -import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service'; -import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/relation/field-metadata-relation.service'; +import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service'; +import { resolveOverridableString } from 'src/engine/metadata-modules/field-metadata/utils/resolve-overridable-string.util'; import { IndexFieldMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-field-metadata.dto'; import { IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; @@ -59,7 +59,6 @@ export type IndexFieldMetadataLoaderPayload = { export class DataloaderService { constructor( private readonly fieldMetadataRelationService: FieldMetadataRelationService, - private readonly fieldMetadataService: FieldMetadataService, private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService, ) {} @@ -166,7 +165,7 @@ export class DataloaderService { >( (acc, field) => ({ ...acc, - [field]: this.fieldMetadataService.resolveOverridableString( + [field]: resolveOverridableString( fieldMetadata, field, dataLoaderParams[0].locale, diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/create-field.input.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/create-field.input.ts index 1734be2c8..20fa00948 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/create-field.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/create-field.input.ts @@ -8,6 +8,12 @@ import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfa import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; +export type RelationCreationPayload = { + targetObjectMetadataId: string; + targetFieldLabel: string; + targetFieldIcon: string; + type: RelationType; +}; @InputType() export class CreateFieldInput extends OmitType( FieldMetadataDTO, @@ -25,12 +31,11 @@ export class CreateFieldInput extends OmitType( // TODO @prastoin implement validation for this with validate nested and dedicated class instance @IsOptional() @Field(() => GraphQLJSON, { nullable: true }) - relationCreationPayload?: { - targetObjectMetadataId: string; - targetFieldLabel: string; - targetFieldIcon: string; - type: RelationType; - }; + relationCreationPayload?: RelationCreationPayload; + + @IsOptional() + @Field(() => [GraphQLJSON], { nullable: true }) + morphRelationsCreationPayload?: RelationCreationPayload[]; } @InputType() diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata-validation.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata-validation.service.ts deleted file mode 100644 index a6ce1c3ea..000000000 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata-validation.service.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { ClassConstructor, plainToInstance } from 'class-transformer'; -import { - IsEnum, - IsInt, - IsOptional, - IsString, - IsUUID, - Max, - Min, - ValidationError, - validateOrReject, -} from 'class-validator'; -import { FieldMetadataType } from 'twenty-shared/types'; - -import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface'; -import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; - -import { - FieldMetadataException, - FieldMetadataExceptionCode, -} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; - -enum ValueType { - PERCENTAGE = 'percentage', - NUMBER = 'number', - SHORT_NUMBER = 'shortNumber', -} - -class NumberSettingsValidation { - @IsOptional() - @IsInt() - @Min(0) - decimals?: number; - - @IsOptional() - @IsEnum(ValueType) - type?: 'percentage' | 'number' | 'shortNumber'; -} - -class TextSettingsValidation { - @IsOptional() - @IsInt() - @Min(0) - @Max(100) - displayedMaxRows?: number; -} - -export class RelationCreationPayloadValidation { - @IsUUID() - targetObjectMetadataId?: string; - - @IsString() - targetFieldLabel: string; - - @IsString() - targetFieldIcon: string; - - @IsEnum(RelationType) - type: RelationType; -} - -@Injectable() -export class FieldMetadataValidationService< - T extends FieldMetadataType = FieldMetadataType, -> { - constructor() {} - - async validateRelationCreationPayloadOrThrow( - relationCreationPayload: RelationCreationPayloadValidation, - ) { - try { - const relationCreationPayloadInstance = plainToInstance( - RelationCreationPayloadValidation, - relationCreationPayload, - ); - - await validateOrReject(relationCreationPayloadInstance); - } catch (error) { - const errorMessages = Array.isArray(error) - ? error - .map((err: ValidationError) => Object.values(err.constraints ?? {})) - .flat() - .join(', ') - : error.message; - - throw new FieldMetadataException( - `Relation creation payload is invalid: ${errorMessages}`, - FieldMetadataExceptionCode.INVALID_FIELD_INPUT, - ); - } - } - - async validateSettingsOrThrow({ - fieldType, - settings, - }: { - fieldType: FieldMetadataType; - settings: FieldMetadataSettings; - }) { - switch (fieldType) { - case FieldMetadataType.NUMBER: - await this.validateSettings( - NumberSettingsValidation, - settings, - ); - break; - case FieldMetadataType.TEXT: - await this.validateSettings( - TextSettingsValidation, - settings, - ); - break; - default: - break; - } - } - - private async validateSettings( - validator: ClassConstructor< - Type extends FieldMetadataType.NUMBER - ? NumberSettingsValidation - : Type extends FieldMetadataType.TEXT - ? TextSettingsValidation - : never - >, - settings: FieldMetadataSettings, - ) { - try { - const settingsInstance = plainToInstance(validator, settings); - - await validateOrReject(settingsInstance); - } catch (error) { - const errorMessages = Array.isArray(error) - ? error - .map((err: ValidationError) => Object.values(err.constraints ?? {})) - .flat() - .join(', ') - : error.message; - - throw new FieldMetadataException( - `Value for settings is invalid: ${errorMessages}`, - FieldMetadataExceptionCode.INVALID_FIELD_INPUT, - ); - } - } -} diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts index 5a1460654..510b606f2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts @@ -10,7 +10,6 @@ import { OneToOne, PrimaryGeneratedColumn, Relation, - Unique, UpdateDateColumn, } from 'typeorm'; @@ -23,11 +22,15 @@ import { IndexFieldMetadataEntity } from 'src/engine/metadata-modules/index-meta import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; @Entity('fieldMetadata') -@Unique('IDX_FIELD_METADATA_NAME_OBJECT_METADATA_ID_WORKSPACE_ID_UNIQUE', [ - 'name', - 'objectMetadataId', - 'workspaceId', -]) +// max length of index is 63 characters +@Index( + 'IDX_FIELD_METADATA_NAME_OBJMID_WORKSPACE_ID_EXCEPT_MORPH_UNIQUE', + ['name', 'objectMetadataId', 'workspaceId'], + { + unique: true, + where: `"type" <> ''MORPH_RELATION''`, + }, +) @Index('IDX_FIELD_METADATA_RELATION_TARGET_FIELD_METADATA_ID', [ 'relationTargetFieldMetadataId', ]) diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts index 2a423aebb..83dadc7d9 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts @@ -9,16 +9,18 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { ActorModule } from 'src/engine/core-modules/actor/actor.module'; +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; -import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/field-metadata-validation.service'; import { FieldMetadataResolver } from 'src/engine/metadata-modules/field-metadata/field-metadata.resolver'; import { BeforeUpdateOneField } from 'src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook'; import { FieldMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/field-metadata/interceptors/field-metadata-graphql-api-exception.interceptor'; -import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/relation/field-metadata-relation.service'; import { FieldMetadataEnumValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service'; +import { FieldMetadataMorphRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-morph-relation.service'; import { FieldMetadataRelatedRecordsService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service'; +import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service'; +import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service'; import { IsFieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-default-value.validator'; import { IsFieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-options.validator'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; @@ -32,10 +34,10 @@ import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/wor import { ViewModule } from 'src/modules/view/view.module'; import { FieldMetadataEntity } from './field-metadata.entity'; -import { FieldMetadataService } from './field-metadata.service'; import { CreateFieldInput } from './dtos/create-field.input'; import { UpdateFieldInput } from './dtos/update-field.input'; +import { FieldMetadataService } from './services/field-metadata.service'; @Module({ imports: [ @@ -53,6 +55,7 @@ import { UpdateFieldInput } from './dtos/update-field.input'; DataSourceModule, TypeORMModule, ActorModule, + FeatureFlagModule, ViewModule, PermissionsModule, WorkspaceMetadataCacheModule, @@ -61,6 +64,8 @@ import { UpdateFieldInput } from './dtos/update-field.input'; IsFieldMetadataDefaultValue, FieldMetadataService, FieldMetadataRelatedRecordsService, + FieldMetadataMorphRelationService, + FieldMetadataRelationService, FieldMetadataValidationService, FieldMetadataEnumValidationService, ], @@ -98,6 +103,7 @@ import { UpdateFieldInput } from './dtos/update-field.input'; FieldMetadataService, FieldMetadataRelationService, FieldMetadataRelatedRecordsService, + FieldMetadataMorphRelationService, FieldMetadataValidationService, FieldMetadataEnumValidationService, FieldMetadataResolver, diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts index ca0b7fed8..34c13a853 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts @@ -35,8 +35,8 @@ import { FieldMetadataException, FieldMetadataExceptionCode, } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; -import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service'; import { BeforeUpdateOneField } from 'src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook'; +import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service'; import { fieldMetadataGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/field-metadata/utils/field-metadata-graphql-api-exception-handler.util'; import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/hooks/__tests__/before-update-one-field.hook.spec.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/hooks/__tests__/before-update-one-field.hook.spec.ts index 6f4a682e7..3486ae424 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/hooks/__tests__/before-update-one-field.hook.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/hooks/__tests__/before-update-one-field.hook.spec.ts @@ -9,8 +9,8 @@ import { } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; import { UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; -import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service'; import { BeforeUpdateOneField } from 'src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook'; +import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service'; jest.mock('@lingui/core', () => ({ i18n: { diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook.ts index 2529f5e42..31b9278cc 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook.ts @@ -16,7 +16,7 @@ import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMe import { FieldStandardOverridesDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto'; import { UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; -import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service'; +import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service'; interface StandardFieldUpdate extends Partial { standardOverrides?: FieldStandardOverridesDTO; 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 65391e8a4..c25860347 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 @@ -22,7 +22,7 @@ export enum DateDisplayFormat { export type FieldNumberVariant = 'number' | 'percentage'; export type FieldMetadataNumberSettings = { - dataType: NumberDataType; + dataType?: NumberDataType; decimals?: number; type?: FieldNumberVariant; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/relation/field-metadata-relation.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/relation/field-metadata-relation.service.ts deleted file mode 100644 index fd304b8e2..000000000 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/relation/field-metadata-relation.service.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; - -import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; -import { - FieldMetadataException, - FieldMetadataExceptionCode, -} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; -import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; -import { getObjectMetadataFromObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/utils/get-object-metadata-from-object-metadata-Item-with-field-maps'; -import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; - -@Injectable() -export class FieldMetadataRelationService { - constructor( - private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, - ) {} - - async findCachedFieldMetadataRelation( - fieldMetadataItems: Array< - Pick< - FieldMetadataInterface, - | 'id' - | 'type' - | 'objectMetadataId' - | 'relationTargetFieldMetadataId' - | 'relationTargetObjectMetadataId' - > - >, - workspaceId: string, - ): Promise< - Array<{ - sourceObjectMetadata: ObjectMetadataEntity; - sourceFieldMetadata: FieldMetadataEntity; - targetObjectMetadata: ObjectMetadataEntity; - targetFieldMetadata: FieldMetadataEntity; - }> - > { - const objectMetadataMaps = - await this.workspaceCacheStorageService.getObjectMetadataMapsOrThrow( - workspaceId, - ); - - return fieldMetadataItems.map((fieldMetadataItem) => { - const { - id, - objectMetadataId, - relationTargetFieldMetadataId, - relationTargetObjectMetadataId, - } = fieldMetadataItem; - - if (!relationTargetObjectMetadataId || !relationTargetFieldMetadataId) { - throw new FieldMetadataException( - `Relation target object metadata id or relation target field metadata id not found for field metadata ${id}`, - FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, - ); - } - - const sourceObjectMetadata = objectMetadataMaps.byId[objectMetadataId]; - const targetObjectMetadata = - objectMetadataMaps.byId[relationTargetObjectMetadataId]; - const sourceFieldMetadata = sourceObjectMetadata?.fieldsById[id]; - const targetFieldMetadata = - targetObjectMetadata?.fieldsById[relationTargetFieldMetadataId]; - - if ( - !sourceObjectMetadata || - !targetObjectMetadata || - !sourceFieldMetadata || - !targetFieldMetadata - ) { - throw new FieldMetadataException( - `Field relation metadata not found for field metadata ${id}`, - FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, - ); - } - - return { - sourceObjectMetadata: - getObjectMetadataFromObjectMetadataItemWithFieldMaps( - sourceObjectMetadata, - ) as ObjectMetadataEntity, - sourceFieldMetadata: sourceFieldMetadata as FieldMetadataEntity, - targetObjectMetadata: - getObjectMetadataFromObjectMetadataItemWithFieldMaps( - targetObjectMetadata, - ) as ObjectMetadataEntity, - targetFieldMetadata: targetFieldMetadata as FieldMetadataEntity, - }; - }); - } -} diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-morph-relation.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-morph-relation.service.ts new file mode 100644 index 000000000..3f6fe1ca8 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-morph-relation.service.ts @@ -0,0 +1,138 @@ +import { Injectable } from '@nestjs/common'; + +import { isDefined } from 'class-validator'; +import omit from 'lodash.omit'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { Repository } from 'typeorm'; +import { v4 } from 'uuid'; + +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'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { + FieldMetadataException, + FieldMetadataExceptionCode, +} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; +import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service'; +import { prepareCustomFieldMetadataForCreation } from 'src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util'; +import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; +import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; +import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util'; + +@Injectable() +export class FieldMetadataMorphRelationService { + constructor( + private readonly fieldMetadataRelationService: FieldMetadataRelationService, + ) {} + + async createMorphRelationFieldMetadataItems({ + fieldMetadataForCreate, + morphRelationsCreationPayload, + objectMetadata, + fieldMetadataRepository, + objectMetadataMaps, + }: { + fieldMetadataForCreate: CreateFieldInput; + morphRelationsCreationPayload: CreateFieldInput['morphRelationsCreationPayload']; + objectMetadata: ObjectMetadataItemWithFieldMaps; + fieldMetadataRepository: Repository; + objectMetadataMaps: ObjectMetadataMaps; + }): Promise { + if ( + !isDefined(morphRelationsCreationPayload) || + !Array.isArray(morphRelationsCreationPayload) + ) { + throw new FieldMetadataException( + 'Morph relations creation payload is not defined', + FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, + ); + } + + if (morphRelationsCreationPayload.length < 1) { + throw new FieldMetadataException( + 'Morph relations creation payload must not be empty', + FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, + ); + } + + const fieldsCreated: FieldMetadataEntity[] = []; + + for (const relation of morphRelationsCreationPayload) { + const relationFieldMetadataForCreate = + await this.fieldMetadataRelationService.addCustomRelationFieldMetadataForCreation( + { + fieldMetadataInput: fieldMetadataForCreate, + relationCreationPayload: relation, + objectMetadata, + }, + ); + + await this.fieldMetadataRelationService.validateFieldMetadataRelationSpecifics( + { + fieldMetadataInput: relationFieldMetadataForCreate, + fieldMetadataType: relationFieldMetadataForCreate.type, + objectMetadataMaps, + }, + ); + + const createdFieldMetadataItem = await fieldMetadataRepository.save( + omit(relationFieldMetadataForCreate, 'id'), + ); + + const targetFieldMetadataName = computeMetadataNameFromLabel( + relation.targetFieldLabel, + ); + + const targetFieldMetadataToCreate = prepareCustomFieldMetadataForCreation( + { + objectMetadataId: relation.targetObjectMetadataId, + type: FieldMetadataType.RELATION, + name: targetFieldMetadataName, + label: relation.targetFieldLabel, + icon: relation.targetFieldIcon, + workspaceId: fieldMetadataForCreate.workspaceId, + settings: fieldMetadataForCreate.settings, + }, + ); + + const targetFieldMetadataToCreateWithRelation = + await this.fieldMetadataRelationService.addCustomRelationFieldMetadataForCreation( + { + fieldMetadataInput: targetFieldMetadataToCreate, + relationCreationPayload: { + targetObjectMetadataId: objectMetadata.id, + targetFieldLabel: fieldMetadataForCreate.label, + targetFieldIcon: fieldMetadataForCreate.icon ?? 'Icon123', + type: + relation.type === RelationType.ONE_TO_MANY + ? RelationType.MANY_TO_ONE + : RelationType.ONE_TO_MANY, + }, + objectMetadata, + }, + ); + + // todo better type + const targetFieldMetadataToCreateWithRelationWithId = { + id: v4(), + ...targetFieldMetadataToCreateWithRelation, + }; + + const targetFieldMetadata = await fieldMetadataRepository.save({ + ...targetFieldMetadataToCreateWithRelationWithId, + relationTargetFieldMetadataId: createdFieldMetadataItem.id, + }); + + const createdFieldMetadataItemUpdated = + await fieldMetadataRepository.save({ + ...createdFieldMetadataItem, + relationTargetFieldMetadataId: targetFieldMetadata.id, + }); + + fieldsCreated.push(createdFieldMetadataItemUpdated, targetFieldMetadata); + } + + return fieldsCreated; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service.ts index 3027dfc70..a7c47e395 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service.ts @@ -1,19 +1,23 @@ import { Injectable } from '@nestjs/common'; +import isEmpty from 'lodash.isempty'; import { MAX_OPTIONS_TO_DISPLAY } from 'twenty-shared/constants'; import { isDefined, parseJson } from 'twenty-shared/utils'; import { In } from 'typeorm'; +import { settings } from 'src/engine/constants/settings'; import { FieldMetadataComplexOption, FieldMetadataDefaultOption, } from 'src/engine/metadata-modules/field-metadata/dtos/options.input'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { FieldMetadataException, FieldMetadataExceptionCode, } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; import { isSelectFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-select-field-metadata-type.util'; import { SelectOrMultiSelectFieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/utils/is-select-or-multi-select-field-metadata.util'; +import { WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager'; import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { ViewFilterWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter.workspace-entity'; @@ -310,4 +314,74 @@ export class FieldMetadataRelatedRecordsService { private getMaxPosition(viewGroups: ViewGroupWorkspaceEntity[]): number { return viewGroups.reduce((max, group) => Math.max(max, group.position), 0); } + + async createViewAndViewFields( + createdFieldMetadatas: FieldMetadataEntity[], + workspaceId: string, + ) { + const workspaceDataSource = + await this.twentyORMGlobalManager.getDataSourceForWorkspace({ + workspaceId, + }); + + await workspaceDataSource.transaction( + async (workspaceEntityManager: WorkspaceEntityManager) => { + const viewsRepository = workspaceEntityManager.getRepository('view', { + shouldBypassPermissionChecks: true, + }); + + const viewFieldsRepository = workspaceEntityManager.getRepository( + 'viewField', + { + shouldBypassPermissionChecks: true, + }, + ); + + for (const createdFieldMetadata of createdFieldMetadatas) { + const views = await viewsRepository.find({ + where: { + objectMetadataId: createdFieldMetadata.objectMetadataId, + }, + }); + + if (!isEmpty(views)) { + const view = views[0]; + const existingViewFields = await viewFieldsRepository.find({ + where: { + viewId: view.id, + }, + }); + + const isVisible = + existingViewFields.length < settings.maxVisibleViewFields; + + const createdFieldIsAlreadyInView = existingViewFields.some( + (existingViewField) => + existingViewField.fieldMetadataId === createdFieldMetadata.id, + ); + + if (!createdFieldIsAlreadyInView) { + const lastPosition = existingViewFields + .map((viewField) => viewField.position) + .reduce((acc, position) => { + if (position > acc) { + return position; + } + + return acc; + }, -1); + + await viewFieldsRepository.insert({ + fieldMetadataId: createdFieldMetadata.id, + position: lastPosition + 1, + isVisible, + size: 180, + viewId: view.id, + }); + } + } + } + }, + ); + } } diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service.ts new file mode 100644 index 000000000..c1ac6ad21 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service.ts @@ -0,0 +1,330 @@ +import { Injectable, ValidationError } from '@nestjs/common'; + +import { plainToInstance } from 'class-transformer'; +import { IsEnum, IsString, IsUUID, validateOrReject } from 'class-validator'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { capitalize, isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; +import { v4 } from 'uuid'; + +import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; +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'; +import { UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { + FieldMetadataException, + FieldMetadataExceptionCode, +} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; +import { prepareCustomFieldMetadataForCreation } from 'src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { RelationOnDeleteAction } from 'src/engine/metadata-modules/relation-metadata/relation-on-delete-action.type'; +import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; +import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; +import { getObjectMetadataFromObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/utils/get-object-metadata-from-object-metadata-Item-with-field-maps'; +import { validateFieldNameAvailabilityOrThrow } from 'src/engine/metadata-modules/utils/validate-field-name-availability.utils'; +import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils'; +import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util'; +import { isFieldMetadataInterfaceOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; +import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; + +export class RelationCreationPayloadValidation { + @IsUUID() + targetObjectMetadataId?: string; + + @IsString() + targetFieldLabel: string; + + @IsString() + targetFieldIcon: string; + + @IsEnum(RelationType) + type: RelationType; +} + +type ValidateFieldMetadataArgs = + { + fieldMetadataType: FieldMetadataType; + fieldMetadataInput: T; + objectMetadata: ObjectMetadataItemWithFieldMaps; + existingFieldMetadata?: FieldMetadataInterface; + objectMetadataMaps: ObjectMetadataMaps; + }; + +@Injectable() +export class FieldMetadataRelationService { + constructor( + private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, + ) {} + + async createRelationFieldMetadataItems({ + fieldMetadataInput, + objectMetadata, + fieldMetadataRepository, + }: { + fieldMetadataInput: CreateFieldInput; + objectMetadata: ObjectMetadataItemWithFieldMaps; + fieldMetadataRepository: Repository; + }): Promise { + const createdFieldMetadataItem = + await fieldMetadataRepository.save(fieldMetadataInput); + + const relationCreationPayload = fieldMetadataInput.relationCreationPayload; + + if (!isDefined(relationCreationPayload)) { + throw new FieldMetadataException( + 'Relation creation payload is not defined', + FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, + ); + } + const targetFieldMetadataName = computeMetadataNameFromLabel( + relationCreationPayload.targetFieldLabel, + ); + + const targetFieldMetadataToCreate = prepareCustomFieldMetadataForCreation({ + objectMetadataId: relationCreationPayload.targetObjectMetadataId, + type: fieldMetadataInput.type, + name: targetFieldMetadataName, + label: relationCreationPayload.targetFieldLabel, + icon: relationCreationPayload.targetFieldIcon, + workspaceId: fieldMetadataInput.workspaceId, + }); + + const targetFieldMetadataToCreateWithRelation = + await this.addCustomRelationFieldMetadataForCreation({ + fieldMetadataInput: targetFieldMetadataToCreate, + relationCreationPayload: { + targetObjectMetadataId: objectMetadata.id, + targetFieldLabel: fieldMetadataInput.label, + targetFieldIcon: fieldMetadataInput.icon ?? 'Icon123', + type: + relationCreationPayload.type === RelationType.ONE_TO_MANY + ? RelationType.MANY_TO_ONE + : RelationType.ONE_TO_MANY, + }, + objectMetadata, + }); + + // todo better type + const targetFieldMetadataToCreateWithRelationWithId = { + id: v4(), + ...targetFieldMetadataToCreateWithRelation, + }; + + const targetFieldMetadata = await fieldMetadataRepository.save({ + ...targetFieldMetadataToCreateWithRelationWithId, + relationTargetFieldMetadataId: createdFieldMetadataItem.id, + }); + + const createdFieldMetadataItemUpdated = await fieldMetadataRepository.save({ + ...createdFieldMetadataItem, + relationTargetFieldMetadataId: targetFieldMetadata.id, + }); + + return [createdFieldMetadataItemUpdated, targetFieldMetadata]; + } + + async validateFieldMetadataRelationSpecifics< + T extends UpdateFieldInput | CreateFieldInput, + >({ + fieldMetadataInput, + fieldMetadataType, + objectMetadataMaps, + }: Pick< + ValidateFieldMetadataArgs, + 'fieldMetadataInput' | 'fieldMetadataType' | 'objectMetadataMaps' + >): Promise { + // TODO: clean typings, we should try to validate both update and create inputs in the same function + const isRelation = + fieldMetadataType === FieldMetadataType.RELATION || + fieldMetadataType === FieldMetadataType.MORPH_RELATION; + + if ( + isRelation && + isDefined( + (fieldMetadataInput as unknown as CreateFieldInput) + .relationCreationPayload, + ) + ) { + const relationCreationPayload = ( + fieldMetadataInput as unknown as CreateFieldInput + ).relationCreationPayload; + + if (isDefined(relationCreationPayload)) { + await this.validateRelationCreationPayloadOrThrow( + relationCreationPayload, + ); + const computedMetadataNameFromLabel = computeMetadataNameFromLabel( + relationCreationPayload.targetFieldLabel, + ); + + validateMetadataNameOrThrow(computedMetadataNameFromLabel); + + const objectMetadataTarget = + objectMetadataMaps.byId[ + relationCreationPayload.targetObjectMetadataId + ]; + + validateFieldNameAvailabilityOrThrow( + computedMetadataNameFromLabel, + objectMetadataTarget, + ); + } + } + + return fieldMetadataInput; + } + + private async validateRelationCreationPayloadOrThrow( + relationCreationPayload: RelationCreationPayloadValidation, + ) { + try { + const relationCreationPayloadInstance = plainToInstance( + RelationCreationPayloadValidation, + relationCreationPayload, + ); + + await validateOrReject(relationCreationPayloadInstance); + } catch (error) { + const errorMessages = Array.isArray(error) + ? error + .map((err: ValidationError) => Object.values(err.constraints ?? {})) + .flat() + .join(', ') + : error.message; + + throw new FieldMetadataException( + `Relation creation payload is invalid: ${errorMessages}`, + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + ); + } + } + + async findCachedFieldMetadataRelation( + fieldMetadataItems: Array< + Pick< + FieldMetadataInterface, + | 'id' + | 'type' + | 'objectMetadataId' + | 'relationTargetFieldMetadataId' + | 'relationTargetObjectMetadataId' + > + >, + workspaceId: string, + ): Promise< + Array<{ + sourceObjectMetadata: ObjectMetadataEntity; + sourceFieldMetadata: FieldMetadataEntity; + targetObjectMetadata: ObjectMetadataEntity; + targetFieldMetadata: FieldMetadataEntity; + }> + > { + const objectMetadataMaps = + await this.workspaceCacheStorageService.getObjectMetadataMapsOrThrow( + workspaceId, + ); + + return fieldMetadataItems.map((fieldMetadataItem) => { + const { + id, + objectMetadataId, + relationTargetFieldMetadataId, + relationTargetObjectMetadataId, + } = fieldMetadataItem; + + if (!relationTargetObjectMetadataId || !relationTargetFieldMetadataId) { + throw new FieldMetadataException( + `Relation target object metadata id or relation target field metadata id not found for field metadata ${id}`, + FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, + ); + } + + const sourceObjectMetadata = objectMetadataMaps.byId[objectMetadataId]; + const targetObjectMetadata = + objectMetadataMaps.byId[relationTargetObjectMetadataId]; + const sourceFieldMetadata = sourceObjectMetadata?.fieldsById[id]; + const targetFieldMetadata = + targetObjectMetadata?.fieldsById[relationTargetFieldMetadataId]; + + if ( + !sourceObjectMetadata || + !targetObjectMetadata || + !sourceFieldMetadata || + !targetFieldMetadata + ) { + throw new FieldMetadataException( + `Field relation metadata not found for field metadata ${id}`, + FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, + ); + } + + return { + sourceObjectMetadata: + getObjectMetadataFromObjectMetadataItemWithFieldMaps( + sourceObjectMetadata, + ) as ObjectMetadataEntity, + sourceFieldMetadata: sourceFieldMetadata as FieldMetadataEntity, + targetObjectMetadata: + getObjectMetadataFromObjectMetadataItemWithFieldMaps( + targetObjectMetadata, + ) as ObjectMetadataEntity, + targetFieldMetadata: targetFieldMetadata as FieldMetadataEntity, + }; + }); + } + + addCustomRelationFieldMetadataForCreation({ + fieldMetadataInput, + relationCreationPayload, + objectMetadata, + }: { + fieldMetadataInput: CreateFieldInput; + relationCreationPayload: CreateFieldInput['relationCreationPayload']; + objectMetadata: ObjectMetadataItemWithFieldMaps; + }) { + const isRelation = + isFieldMetadataInterfaceOfType( + fieldMetadataInput, + FieldMetadataType.RELATION, + ) || + isFieldMetadataInterfaceOfType( + fieldMetadataInput, + FieldMetadataType.MORPH_RELATION, + ); + + const isManyToOne = + isRelation && relationCreationPayload?.type === RelationType.MANY_TO_ONE; + + const isOneToMany = + isRelation && relationCreationPayload?.type === RelationType.ONE_TO_MANY; + + const defaultIcon = 'IconRelationOneToMany'; + + const joinColumnName = `${fieldMetadataInput.name}${capitalize(objectMetadata.nameSingular)}Id`; + + return { + ...fieldMetadataInput, + icon: fieldMetadataInput.icon ?? defaultIcon, + relationCreationPayload, + relationTargetObjectMetadataId: + relationCreationPayload?.targetObjectMetadataId, + settings: { + ...fieldMetadataInput.settings, + ...(isOneToMany + ? { + relationType: RelationType.ONE_TO_MANY, + } + : {}), + ...(isManyToOne + ? { + relationType: RelationType.MANY_TO_ONE, + onDelete: RelationOnDeleteAction.SET_NULL, + joinColumnName, + } + : {}), + }, + }; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service.ts new file mode 100644 index 000000000..3a11328ed --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service.ts @@ -0,0 +1,191 @@ +import { Injectable } from '@nestjs/common'; + +import { t } from '@lingui/core/macro'; +import { ClassConstructor, plainToInstance } from 'class-transformer'; +import { + IsEnum, + IsInt, + IsOptional, + Max, + Min, + ValidationError, + isDefined, + validateOrReject, +} from 'class-validator'; +import { FieldMetadataType } from 'twenty-shared/types'; + +import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface'; +import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; + +import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input'; +import { UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input'; +import { + FieldMetadataException, + FieldMetadataExceptionCode, +} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; +import { FieldMetadataEnumValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service'; +import { isEnumFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util'; +import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; +import { InvalidMetadataException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception'; +import { validateFieldNameAvailabilityOrThrow } from 'src/engine/metadata-modules/utils/validate-field-name-availability.utils'; +import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils'; + +type ValidateFieldMetadataArgs = { + fieldMetadataType: FieldMetadataType; + fieldMetadataInput: CreateFieldInput | UpdateFieldInput; + objectMetadata: ObjectMetadataItemWithFieldMaps; + existingFieldMetadata?: FieldMetadataInterface; +}; + +enum ValueType { + PERCENTAGE = 'percentage', + NUMBER = 'number', + SHORT_NUMBER = 'shortNumber', +} + +class NumberSettingsValidation { + @IsOptional() + @IsInt() + @Min(0) + decimals?: number; + + @IsOptional() + @IsEnum(ValueType) + type?: 'percentage' | 'number' | 'shortNumber'; +} + +class TextSettingsValidation { + @IsOptional() + @IsInt() + @Min(0) + @Max(100) + displayedMaxRows?: number; +} + +@Injectable() +export class FieldMetadataValidationService { + constructor( + private readonly fieldMetadataEnumValidationService: FieldMetadataEnumValidationService, + ) {} + + async validateSettingsOrThrow({ + fieldType, + settings, + }: { + fieldType: FieldMetadataType; + settings: FieldMetadataSettings; + }) { + switch (fieldType) { + case FieldMetadataType.NUMBER: + await this.validateSettings( + NumberSettingsValidation, + settings, + ); + break; + case FieldMetadataType.TEXT: + await this.validateSettings( + TextSettingsValidation, + settings, + ); + break; + default: + break; + } + } + + private async validateSettings( + validator: ClassConstructor< + Type extends FieldMetadataType.NUMBER + ? NumberSettingsValidation + : Type extends FieldMetadataType.TEXT + ? TextSettingsValidation + : never + >, + settings: FieldMetadataSettings, + ) { + try { + const settingsInstance = plainToInstance(validator, settings); + + await validateOrReject(settingsInstance); + } catch (error) { + const errorMessages = Array.isArray(error) + ? error + .map((err: ValidationError) => Object.values(err.constraints ?? {})) + .flat() + .join(', ') + : error.message; + + throw new FieldMetadataException( + `Value for settings is invalid: ${errorMessages}`, + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + ); + } + } + + async validateFieldMetadata({ + fieldMetadataInput, + fieldMetadataType, + objectMetadata, + existingFieldMetadata, + }: ValidateFieldMetadataArgs): Promise { + if (fieldMetadataInput.name) { + try { + validateMetadataNameOrThrow(fieldMetadataInput.name); + } catch (error) { + if (error instanceof InvalidMetadataException) { + throw new FieldMetadataException( + error.message, + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + ); + } + + throw error; + } + + try { + validateFieldNameAvailabilityOrThrow( + fieldMetadataInput.name, + objectMetadata, + ); + } catch (error) { + if (error instanceof InvalidMetadataException) { + throw new FieldMetadataException( + `Name "${fieldMetadataInput.name}" is not available, check that it is not duplicating another field's name.`, + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + { + userFriendlyMessage: t`Name is not available, it may be duplicating another field's name.`, + }, + ); + } + + throw error; + } + } + + if (fieldMetadataInput.isNullable === false) { + if (!isDefined(fieldMetadataInput.defaultValue)) { + throw new FieldMetadataException( + 'Default value is required for non nullable fields', + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + ); + } + } + + if (isEnumFieldMetadataType(fieldMetadataType)) { + await this.fieldMetadataEnumValidationService.validateEnumFieldMetadataInput( + { + fieldMetadataInput, + fieldMetadataType, + existingFieldMetadata, + }, + ); + } + + if (fieldMetadataInput.settings) { + await this.validateSettingsOrThrow({ + fieldType: fieldMetadataType, + settings: fieldMetadataInput.settings, + }); + } + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts similarity index 60% rename from packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts rename to packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts index d35e46233..6cc2057ae 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts @@ -1,58 +1,46 @@ import { Injectable } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; -import { i18n } from '@lingui/core'; import { t } from '@lingui/core/macro'; import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; -import isEmpty from 'lodash.isempty'; -import { APP_LOCALES } from 'twenty-shared/translations'; import { FieldMetadataType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { DataSource, FindOneOptions, In, Repository } from 'typeorm'; -import { v4 as uuidV4, v4 } from 'uuid'; import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; -import { settings } from 'src/engine/constants/settings'; -import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input'; import { DeleteOneFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/delete-field.input'; -import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; -import { FieldStandardOverridesDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto'; -import { - FieldMetadataComplexOption, - FieldMetadataDefaultOption, -} from 'src/engine/metadata-modules/field-metadata/dtos/options.input'; import { UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { FieldMetadataException, FieldMetadataExceptionCode, } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; -import { FieldMetadataEnumValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service'; +import { FieldMetadataMorphRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-morph-relation.service'; import { FieldMetadataRelatedRecordsService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service'; +import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service'; +import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service'; import { assertDoesNotNullifyDefaultValueForNonNullableField } from 'src/engine/metadata-modules/field-metadata/utils/assert-does-not-nullify-default-value-for-non-nullable-field.util'; +import { buildUpdatableStandardFieldInput } from 'src/engine/metadata-modules/field-metadata/utils/build-updatable-standard-field-input.util'; import { checkCanDeactivateFieldOrThrow } from 'src/engine/metadata-modules/field-metadata/utils/check-can-deactivate-field-or-throw'; import { computeColumnName, computeCompositeColumnName, } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; -import { generateNullable } from 'src/engine/metadata-modules/field-metadata/utils/generate-nullable'; +import { generateRatingOptions } from 'src/engine/metadata-modules/field-metadata/utils/generate-rating-optionts.util'; import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; -import { isEnumFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util'; import { isSelectOrMultiSelectFieldMetadata } from 'src/engine/metadata-modules/field-metadata/utils/is-select-or-multi-select-field-metadata.util'; +import { prepareCustomFieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/utils/prepare-custom-field-metadata-for-options.util'; +import { prepareCustomFieldMetadataForCreation } from 'src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util'; import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util'; -import { RelationOnDeleteAction } from 'src/engine/metadata-modules/relation-metadata/relation-on-delete-action.type'; import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; -import { InvalidMetadataException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception'; -import { validateFieldNameAvailabilityOrThrow } from 'src/engine/metadata-modules/utils/validate-field-name-availability.utils'; -import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils'; -import { - computeMetadataNameFromLabel, - validateNameAndLabelAreSyncOrThrow, -} from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util'; +import { validateNameAndLabelAreSyncOrThrow } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util'; import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service'; import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service'; import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util'; @@ -64,28 +52,11 @@ import { } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity'; import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.factory'; import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service'; -import { WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util'; import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service'; import { ViewService } from 'src/modules/view/services/view.service'; -import { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from 'src/utils/trim-and-remove-duplicated-whitespaces-from-object-string-properties'; - -import { FieldMetadataValidationService } from './field-metadata-validation.service'; -import { FieldMetadataEntity } from './field-metadata.entity'; - -import { generateDefaultValue } from './utils/generate-default-value'; -import { generateRatingOptions } from './utils/generate-rating-optionts.util'; - -type ValidateFieldMetadataArgs = - { - fieldMetadataType: FieldMetadataType; - fieldMetadataInput: T; - objectMetadata: ObjectMetadataItemWithFieldMaps; - existingFieldMetadata?: FieldMetadataInterface; - objectMetadataMaps: ObjectMetadataMaps; - }; @Injectable() export class FieldMetadataService extends TypeOrmQueryService { @@ -97,13 +68,15 @@ export class FieldMetadataService extends TypeOrmQueryService, - ) { - const updatableStandardFieldInput: UpdateFieldInput & { - standardOverrides?: FieldStandardOverridesDTO; - } = { - id: fieldMetadataInput.id, - isActive: fieldMetadataInput.isActive, - workspaceId: fieldMetadataInput.workspaceId, - defaultValue: fieldMetadataInput.defaultValue, - settings: fieldMetadataInput.settings, - isLabelSyncedWithName: fieldMetadataInput.isLabelSyncedWithName, - }; - - if ('standardOverrides' in fieldMetadataInput) { - updatableStandardFieldInput.standardOverrides = - fieldMetadataInput.standardOverrides as FieldStandardOverridesDTO; - } - - if ( - existingFieldMetadata.type === FieldMetadataType.SELECT || - existingFieldMetadata.type === FieldMetadataType.MULTI_SELECT - ) { - return { - ...updatableStandardFieldInput, - options: fieldMetadataInput.options, - }; - } - - return updatableStandardFieldInput; - } - - private async validateFieldMetadata< - T extends UpdateFieldInput | CreateFieldInput, - >({ - fieldMetadataInput, - fieldMetadataType, - objectMetadata, - existingFieldMetadata, - objectMetadataMaps, - }: ValidateFieldMetadataArgs): Promise { - if (fieldMetadataInput.name) { - try { - validateMetadataNameOrThrow(fieldMetadataInput.name); - } catch (error) { - if (error instanceof InvalidMetadataException) { - throw new FieldMetadataException( - error.message, - FieldMetadataExceptionCode.INVALID_FIELD_INPUT, - ); - } - - throw error; - } - - try { - validateFieldNameAvailabilityOrThrow( - fieldMetadataInput.name, - objectMetadata, - ); - } catch (error) { - if (error instanceof InvalidMetadataException) { - throw new FieldMetadataException( - `Name "${fieldMetadataInput.name}" is not available, check that it is not duplicating another field's name.`, - FieldMetadataExceptionCode.INVALID_FIELD_INPUT, - { - userFriendlyMessage: t`Name is not available, it may be duplicating another field's name.`, - }, - ); - } - - throw error; - } - } - - if (fieldMetadataInput.isNullable === false) { - if (!isDefined(fieldMetadataInput.defaultValue)) { - throw new FieldMetadataException( - 'Default value is required for non nullable fields', - FieldMetadataExceptionCode.INVALID_FIELD_INPUT, - ); - } - } - - if (isEnumFieldMetadataType(fieldMetadataType)) { - await this.fieldMetadataEnumValidationService.validateEnumFieldMetadataInput( - { - fieldMetadataInput, - fieldMetadataType, - existingFieldMetadata, - }, - ); - } - - if (fieldMetadataInput.settings) { - await this.fieldMetadataValidationService.validateSettingsOrThrow({ - fieldType: fieldMetadataType, - settings: fieldMetadataInput.settings, - }); - } - - // TODO: clean typings, we should try to validate both update and create inputs in the same function - if ( - fieldMetadataType === FieldMetadataType.RELATION && - isDefined( - (fieldMetadataInput as unknown as CreateFieldInput) - .relationCreationPayload, - ) - ) { - const relationCreationPayload = ( - fieldMetadataInput as unknown as CreateFieldInput - ).relationCreationPayload; - - if (isDefined(relationCreationPayload)) { - await this.fieldMetadataValidationService.validateRelationCreationPayloadOrThrow( - relationCreationPayload, - ); - const computedMetadataNameFromLabel = computeMetadataNameFromLabel( - relationCreationPayload.targetFieldLabel, - ); - - validateMetadataNameOrThrow(computedMetadataNameFromLabel); - - const objectMetadataTarget = - objectMetadataMaps.byId[ - relationCreationPayload.targetObjectMetadataId - ]; - - validateFieldNameAvailabilityOrThrow( - computedMetadataNameFromLabel, - objectMetadataTarget, - ); - } - } - - return fieldMetadataInput; - } - - resolveOverridableString( - fieldMetadata: Pick< - FieldMetadataDTO, - 'label' | 'description' | 'icon' | 'isCustom' | 'standardOverrides' - >, - labelKey: 'label' | 'description' | 'icon', - locale: keyof typeof APP_LOCALES | undefined, - ): string { - if (fieldMetadata.isCustom) { - return fieldMetadata[labelKey] ?? ''; - } - - const translationValue = - // @ts-expect-error legacy noImplicitAny - fieldMetadata.standardOverrides?.translations?.[locale]?.[labelKey]; - - if (isDefined(translationValue)) { - return translationValue; - } - - const messageId = generateMessageId(fieldMetadata[labelKey] ?? ''); - const translatedMessage = i18n._(messageId); - - if (translatedMessage === messageId) { - return fieldMetadata[labelKey] ?? ''; - } - - return translatedMessage; - } - - private prepareCustomFieldMetadataOptions( - options: FieldMetadataDefaultOption[] | FieldMetadataComplexOption[], - ): undefined | Pick { - return { - options: options.map((option) => ({ - id: uuidV4(), - ...trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties( - option, - ['label', 'value', 'id'], - ), - })), - }; - } - - private prepareCustomFieldMetadataForCreation( - fieldMetadataInput: CreateFieldInput, - ) { - const options = fieldMetadataInput.options - ? this.prepareCustomFieldMetadataOptions(fieldMetadataInput.options) - : undefined; - const defaultValue = - fieldMetadataInput.defaultValue ?? - generateDefaultValue(fieldMetadataInput.type); - - const relationCreationPayload = fieldMetadataInput.relationCreationPayload; - - return { - id: v4(), - createdAt: new Date(), - updatedAt: new Date(), - name: fieldMetadataInput.name, - label: fieldMetadataInput.label, - icon: fieldMetadataInput.icon, - type: fieldMetadataInput.type, - isLabelSyncedWithName: fieldMetadataInput.isLabelSyncedWithName, - objectMetadataId: fieldMetadataInput.objectMetadataId, - workspaceId: fieldMetadataInput.workspaceId, - isNullable: generateNullable( - fieldMetadataInput.type, - fieldMetadataInput.isNullable, - fieldMetadataInput.isRemoteCreation, - ), - relationTargetObjectMetadataId: - relationCreationPayload?.targetObjectMetadataId, - defaultValue, - ...options, - isActive: true, - isCustom: true, - settings: { - ...fieldMetadataInput.settings, - ...(fieldMetadataInput.type === FieldMetadataType.RELATION && - relationCreationPayload && - relationCreationPayload.type === RelationType.ONE_TO_MANY - ? { - relationType: RelationType.ONE_TO_MANY, - } - : {}), - ...(fieldMetadataInput.type === FieldMetadataType.RELATION && - relationCreationPayload && - relationCreationPayload.type === RelationType.MANY_TO_ONE - ? { - relationType: RelationType.MANY_TO_ONE, - onDelete: RelationOnDeleteAction.SET_NULL, - joinColumnName: `${fieldMetadataInput.name}Id`, - } - : {}), - }, - }; - } - private groupFieldInputsByObjectId( fieldMetadataInputs: CreateFieldInput[], ): Record { @@ -766,137 +496,6 @@ export class FieldMetadataService extends TypeOrmQueryService, - objectMetadataMaps: ObjectMetadataMaps, - ): Promise { - if (!fieldMetadataInput.isRemoteCreation) { - assertMutationNotOnRemoteObject(objectMetadata); - } - - if (fieldMetadataInput.type === FieldMetadataType.RATING) { - fieldMetadataInput.options = generateRatingOptions(); - } - - const fieldMetadataForCreate = - this.prepareCustomFieldMetadataForCreation(fieldMetadataInput); - - await this.validateFieldMetadata({ - fieldMetadataType: fieldMetadataForCreate.type, - fieldMetadataInput: { - ...fieldMetadataForCreate, - relationCreationPayload: fieldMetadataInput.relationCreationPayload, - }, - objectMetadata, - objectMetadataMaps, - }); - - if (fieldMetadataForCreate.isLabelSyncedWithName === true) { - validateNameAndLabelAreSyncOrThrow( - fieldMetadataForCreate.label, - fieldMetadataForCreate.name, - ); - } - - const createdFieldMetadataItem = await fieldMetadataRepository.save( - fieldMetadataForCreate, - ); - - if (fieldMetadataForCreate.type !== FieldMetadataType.RELATION) { - return [createdFieldMetadataItem]; - } - - const relationCreationPayload = fieldMetadataInput.relationCreationPayload; - - if (!isDefined(relationCreationPayload)) { - throw new FieldMetadataException( - 'Relation creation payload is not defined', - FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, - ); - } - - const targetFieldMetadataName = computeMetadataNameFromLabel( - relationCreationPayload.targetFieldLabel, - ); - - const targetFieldMetadataToCreate = - this.prepareCustomFieldMetadataForCreation({ - objectMetadataId: relationCreationPayload.targetObjectMetadataId, - type: FieldMetadataType.RELATION, - name: targetFieldMetadataName, - label: relationCreationPayload.targetFieldLabel, - icon: relationCreationPayload.targetFieldIcon, - workspaceId: fieldMetadataForCreate.workspaceId, - relationCreationPayload: { - targetObjectMetadataId: objectMetadata.id, - targetFieldLabel: fieldMetadataInput.label, - targetFieldIcon: fieldMetadataInput.icon ?? 'Icon123', - type: - relationCreationPayload.type === RelationType.ONE_TO_MANY - ? RelationType.MANY_TO_ONE - : RelationType.ONE_TO_MANY, - }, - }); - - const targetFieldMetadata = await fieldMetadataRepository.save({ - ...targetFieldMetadataToCreate, - relationTargetFieldMetadataId: createdFieldMetadataItem.id, - }); - - const createdFieldMetadataItemUpdated = await fieldMetadataRepository.save({ - ...createdFieldMetadataItem, - relationTargetFieldMetadataId: targetFieldMetadata.id, - }); - - return [createdFieldMetadataItemUpdated, targetFieldMetadata]; - } - - private async createMigrationActions({ - createdFieldMetadataItems, - objectMetadataMap, - isRemoteCreation, - }: { - createdFieldMetadataItems: FieldMetadataEntity[]; - objectMetadataMap: Record; - isRemoteCreation: boolean; - }): Promise { - if (isRemoteCreation) { - return []; - } - - const migrationActions: WorkspaceMigrationTableAction[] = []; - - for (const createdFieldMetadata of createdFieldMetadataItems) { - if ( - isFieldMetadataEntityOfType( - createdFieldMetadata, - FieldMetadataType.RELATION, - ) - ) { - const relationType = createdFieldMetadata.settings?.relationType; - - if (relationType === RelationType.ONE_TO_MANY) { - continue; - } - } - - migrationActions.push({ - name: computeObjectTargetTable( - objectMetadataMap[createdFieldMetadata.objectMetadataId], - ), - action: WorkspaceMigrationTableActionType.ALTER, - columns: this.workspaceMigrationFactory.createColumnActions( - WorkspaceMigrationColumnActionType.CREATE, - createdFieldMetadata, - ), - }); - } - - return migrationActions; - } - async createMany( fieldMetadataInputs: CreateFieldInput[], ): Promise { @@ -910,6 +509,25 @@ export class FieldMetadataService extends TypeOrmQueryService + fieldMetadataInput.type === FieldMetadataType.MORPH_RELATION, + ); + + if (isSomeFieldMetadatInputsMorph && !isMorphRelationEnabled) { + throw new FieldMetadataException( + 'Morph Relation feature is not enabled for this workspace', + FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR, + ); + } + const queryRunner = this.coreDataSource.createQueryRunner(); await queryRunner.connect(); @@ -977,7 +595,10 @@ export class FieldMetadataService extends TypeOrmQueryService, + objectMetadataMaps: ObjectMetadataMaps, + ): Promise { + if (!fieldMetadataInput.isRemoteCreation) { + assertMutationNotOnRemoteObject(objectMetadata); + } - await workspaceDataSource.transaction( - async (workspaceEntityManager: WorkspaceEntityManager) => { - const viewsRepository = workspaceEntityManager.getRepository('view', { - shouldBypassPermissionChecks: true, - }); + if (fieldMetadataInput.type === FieldMetadataType.RATING) { + fieldMetadataInput.options = generateRatingOptions(); + } - const viewFieldsRepository = workspaceEntityManager.getRepository( - 'viewField', + if (fieldMetadataInput.isLabelSyncedWithName === true) { + validateNameAndLabelAreSyncOrThrow( + fieldMetadataInput.label, + fieldMetadataInput.name, + ); + } + + const fieldMetadataForCreate = + prepareCustomFieldMetadataForCreation(fieldMetadataInput); + + await this.fieldMetadataValidationService.validateFieldMetadata({ + fieldMetadataType: fieldMetadataForCreate.type, + fieldMetadataInput: fieldMetadataForCreate, + objectMetadata, + }); + + const isRelation = + fieldMetadataInput.type === FieldMetadataType.RELATION || + fieldMetadataInput.type === FieldMetadataType.MORPH_RELATION; + + if (!isRelation) { + const createdFieldMetadataItem = await fieldMetadataRepository.save( + fieldMetadataForCreate, + ); + + return [createdFieldMetadataItem]; + } + + if (fieldMetadataInput.type === FieldMetadataType.RELATION) { + const relationFieldMetadataForCreate = + await this.fieldMetadataRelationService.addCustomRelationFieldMetadataForCreation( { - shouldBypassPermissionChecks: true, + fieldMetadataInput: fieldMetadataForCreate, + relationCreationPayload: fieldMetadataInput.relationCreationPayload, + objectMetadata, }, ); - for (const createdFieldMetadata of createdFieldMetadatas) { - const views = await viewsRepository.find({ - where: { - objectMetadataId: createdFieldMetadata.objectMetadataId, - }, - }); + await this.fieldMetadataRelationService.validateFieldMetadataRelationSpecifics( + { + fieldMetadataInput: relationFieldMetadataForCreate, + fieldMetadataType: fieldMetadataForCreate.type, + objectMetadataMaps, + }, + ); - if (!isEmpty(views)) { - const view = views[0]; - const existingViewFields = await viewFieldsRepository.find({ - where: { - viewId: view.id, - }, - }); + return await this.fieldMetadataRelationService.createRelationFieldMetadataItems( + { + fieldMetadataInput: relationFieldMetadataForCreate, + objectMetadata, + fieldMetadataRepository, + }, + ); + } - const isVisible = - existingViewFields.length < settings.maxVisibleViewFields; - - const createdFieldIsAlreadyInView = existingViewFields.some( - (existingViewField) => - existingViewField.fieldMetadataId === createdFieldMetadata.id, - ); - - if (!createdFieldIsAlreadyInView) { - const lastPosition = existingViewFields - .map((viewField) => viewField.position) - .reduce((acc, position) => { - if (position > acc) { - return position; - } - - return acc; - }, -1); - - await viewFieldsRepository.insert({ - fieldMetadataId: createdFieldMetadata.id, - position: lastPosition + 1, - isVisible, - size: 180, - viewId: view.id, - }); - } - } - } + return await this.fieldMetadataMorphRelationService.createMorphRelationFieldMetadataItems( + { + fieldMetadataForCreate, + morphRelationsCreationPayload: + fieldMetadataInput.morphRelationsCreationPayload, + objectMetadata, + fieldMetadataRepository, + objectMetadataMaps, }, ); } - async getFieldMetadataItemsByBatch( - objectMetadataIds: string[], - workspaceId: string, - ) { - const fieldMetadataItems = await this.fieldMetadataRepository.find({ - where: { objectMetadataId: In(objectMetadataIds), workspaceId }, - }); + private async createMigrationActions({ + createdFieldMetadataItems, + objectMetadataMap, + isRemoteCreation, + }: { + createdFieldMetadataItems: FieldMetadataEntity[]; + objectMetadataMap: Record; + isRemoteCreation: boolean; + }): Promise { + if (isRemoteCreation) { + return []; + } - return objectMetadataIds.map((objectMetadataId) => - fieldMetadataItems.filter( - (fieldMetadataItem) => - fieldMetadataItem.objectMetadataId === objectMetadataId, - ), - ); + const migrationActions: WorkspaceMigrationTableAction[] = []; + + for (const createdFieldMetadata of createdFieldMetadataItems) { + if ( + isFieldMetadataEntityOfType( + createdFieldMetadata, + FieldMetadataType.RELATION, + ) + ) { + const relationType = createdFieldMetadata.settings?.relationType; + + if (relationType === RelationType.ONE_TO_MANY) { + continue; + } + } + + migrationActions.push({ + name: computeObjectTargetTable( + objectMetadataMap[createdFieldMetadata.objectMetadataId], + ), + action: WorkspaceMigrationTableActionType.ALTER, + columns: this.workspaceMigrationFactory.createColumnActions( + WorkspaceMigrationColumnActionType.CREATE, + createdFieldMetadata, + ), + }); + } + + return migrationActions; } } diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/field-metadata-validation.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/field-metadata-validation.service.spec.ts index 8b2820a20..4de8fb537 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/field-metadata-validation.service.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/field-metadata-validation.service.spec.ts @@ -1,15 +1,30 @@ +import { Test, TestingModule } from '@nestjs/testing'; + import { FieldMetadataType } from 'twenty-shared/types'; import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface'; -import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/field-metadata-validation.service'; import { FieldMetadataException } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; +import { FieldMetadataEnumValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service'; +import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service'; describe('FieldMetadataValidationService', () => { let service: FieldMetadataValidationService; - beforeAll(() => { - service = new FieldMetadataValidationService(); + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FieldMetadataValidationService, + { + provide: FieldMetadataEnumValidationService, + useValue: {}, + }, + ], + }).compile(); + + service = module.get( + FieldMetadataValidationService, + ); }); it('should validate NUMBER settings successfully', async () => { diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/build-updatable-standard-field-input.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/build-updatable-standard-field-input.util.ts new file mode 100644 index 000000000..32147b52e --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/build-updatable-standard-field-input.util.ts @@ -0,0 +1,42 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; + +import { FieldStandardOverridesDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto'; +import { UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input'; + +export const buildUpdatableStandardFieldInput = ( + fieldMetadataInput: UpdateFieldInput, + existingFieldMetadata: Pick< + FieldMetadataInterface, + 'type' | 'isNullable' | 'defaultValue' | 'options' + >, +) => { + const updatableStandardFieldInput: UpdateFieldInput & { + standardOverrides?: FieldStandardOverridesDTO; + } = { + id: fieldMetadataInput.id, + isActive: fieldMetadataInput.isActive, + workspaceId: fieldMetadataInput.workspaceId, + defaultValue: fieldMetadataInput.defaultValue, + settings: fieldMetadataInput.settings, + isLabelSyncedWithName: fieldMetadataInput.isLabelSyncedWithName, + }; + + if ('standardOverrides' in fieldMetadataInput) { + updatableStandardFieldInput.standardOverrides = + fieldMetadataInput.standardOverrides as FieldStandardOverridesDTO; + } + + if ( + existingFieldMetadata.type === FieldMetadataType.SELECT || + existingFieldMetadata.type === FieldMetadataType.MULTI_SELECT + ) { + return { + ...updatableStandardFieldInput, + options: fieldMetadataInput.options, + }; + } + + return updatableStandardFieldInput; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/prepare-custom-field-metadata-for-options.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/prepare-custom-field-metadata-for-options.util.ts new file mode 100644 index 000000000..9cd9eb226 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/prepare-custom-field-metadata-for-options.util.ts @@ -0,0 +1,23 @@ +import { v4 } from 'uuid'; + +import { + FieldMetadataComplexOption, + FieldMetadataDefaultOption, +} from 'src/engine/metadata-modules/field-metadata/dtos/options.input'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from 'src/utils/trim-and-remove-duplicated-whitespaces-from-object-string-properties'; + +export const prepareCustomFieldMetadataOptions = ( + options: FieldMetadataDefaultOption[] | FieldMetadataComplexOption[], +): undefined | Pick => { + return { + options: options.map((option) => ({ + id: v4(), + ...trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(option, [ + 'label', + 'value', + 'id', + ]), + })), + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util.ts new file mode 100644 index 000000000..6a7fcdeb9 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util.ts @@ -0,0 +1,42 @@ +import { v4 } from 'uuid'; + +import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input'; +import { generateDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/generate-default-value'; +import { generateNullable } from 'src/engine/metadata-modules/field-metadata/utils/generate-nullable'; +import { prepareCustomFieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/utils/prepare-custom-field-metadata-for-options.util'; + +export const prepareCustomFieldMetadataForCreation = ( + fieldMetadataInput: CreateFieldInput, +) => { + const options = fieldMetadataInput.options + ? prepareCustomFieldMetadataOptions(fieldMetadataInput.options) + : undefined; + const defaultValue = + fieldMetadataInput.defaultValue ?? + generateDefaultValue(fieldMetadataInput.type); + + return { + id: v4(), + createdAt: new Date(), + updatedAt: new Date(), + name: fieldMetadataInput.name, + label: fieldMetadataInput.label, + icon: fieldMetadataInput.icon, + type: fieldMetadataInput.type, + isLabelSyncedWithName: fieldMetadataInput.isLabelSyncedWithName, + objectMetadataId: fieldMetadataInput.objectMetadataId, + workspaceId: fieldMetadataInput.workspaceId, + isNullable: generateNullable( + fieldMetadataInput.type, + fieldMetadataInput.isNullable, + fieldMetadataInput.isRemoteCreation, + ), + relationTargetObjectMetadataId: + fieldMetadataInput?.relationCreationPayload?.targetObjectMetadataId, + defaultValue, + ...options, + isActive: true, + isCustom: true, + settings: fieldMetadataInput.settings, + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/resolve-overridable-string.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/resolve-overridable-string.util.ts new file mode 100644 index 000000000..5803aa6a2 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/resolve-overridable-string.util.ts @@ -0,0 +1,36 @@ +import { i18n } from '@lingui/core'; +import { isDefined } from 'class-validator'; +import { APP_LOCALES } from 'twenty-shared/translations'; + +import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId'; +import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; + +export const resolveOverridableString = ( + fieldMetadata: Pick< + FieldMetadataDTO, + 'label' | 'description' | 'icon' | 'isCustom' | 'standardOverrides' + >, + labelKey: 'label' | 'description' | 'icon', + locale: keyof typeof APP_LOCALES | undefined, +): string => { + if (fieldMetadata.isCustom) { + return fieldMetadata[labelKey] ?? ''; + } + + const translationValue = + // @ts-expect-error legacy noImplicitAny + fieldMetadata.standardOverrides?.translations?.[locale]?.[labelKey]; + + if (isDefined(translationValue)) { + return translationValue; + } + + const messageId = generateMessageId(fieldMetadata[labelKey] ?? ''); + const translatedMessage = i18n._(messageId); + + if (translatedMessage === messageId) { + return fieldMetadata[labelKey] ?? ''; + } + + return translatedMessage; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-table/remote-table.service.ts b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-table/remote-table.service.ts index ce2ccaf23..075923168 100644 --- a/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-table/remote-table.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-table/remote-table.service.ts @@ -8,7 +8,7 @@ import { Repository } from 'typeorm'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; -import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service'; +import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service'; import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; import { diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/factories.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/factories.ts index f64f7b64f..8244049ff 100644 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/factories.ts +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/factories.ts @@ -1,6 +1,7 @@ import { BasicColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/basic-column-action.factory'; import { CompositeColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory'; import { EnumColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/enum-column-action.factory'; +import { MorphRelationColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/morph-relation-column-action.factory'; import { RelationColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/relation-column-action.factory'; import { TsVectorColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/ts-vector-column-action.factory'; @@ -10,4 +11,5 @@ export const workspaceColumnActionFactories = [ EnumColumnActionFactory, CompositeColumnActionFactory, RelationColumnActionFactory, + MorphRelationColumnActionFactory, ]; diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/morph-relation-column-action.factory.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/morph-relation-column-action.factory.ts new file mode 100644 index 000000000..5083b101a --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/morph-relation-column-action.factory.ts @@ -0,0 +1,32 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { FieldMetadataType } from 'twenty-shared/types'; + +import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; +import { WorkspaceColumnActionOptions } from 'src/engine/metadata-modules/workspace-migration/interfaces/workspace-column-action-options.interface'; + +import { ColumnActionAbstractFactory } from 'src/engine/metadata-modules/workspace-migration/factories/column-action-abstract.factory'; +import { + WorkspaceMigrationColumnAlter, + WorkspaceMigrationColumnCreate, +} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity'; + +@Injectable() +export class MorphRelationColumnActionFactory extends ColumnActionAbstractFactory { + protected readonly logger = new Logger(MorphRelationColumnActionFactory.name); + + protected handleCreateAction( + _fieldMetadata: FieldMetadataInterface, + _options?: WorkspaceColumnActionOptions, + ): WorkspaceMigrationColumnCreate[] { + return []; + } + + protected handleAlterAction( + _currentFieldMetadata: FieldMetadataInterface, + _alteredFieldMetadata: FieldMetadataInterface, + _options?: WorkspaceColumnActionOptions, + ): WorkspaceMigrationColumnAlter[] { + return []; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.factory.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.factory.ts index e4e7e07ff..1d8f73631 100644 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.factory.ts @@ -9,6 +9,7 @@ import { WorkspaceColumnActionOptions } from 'src/engine/metadata-modules/worksp import { BasicColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/basic-column-action.factory'; import { CompositeColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory'; import { EnumColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/enum-column-action.factory'; +import { MorphRelationColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/morph-relation-column-action.factory'; import { RelationColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/relation-column-action.factory'; import { TsVectorColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/ts-vector-column-action.factory'; import { @@ -38,6 +39,7 @@ export class WorkspaceMigrationFactory { private readonly enumColumnActionFactory: EnumColumnActionFactory, private readonly compositeColumnActionFactory: CompositeColumnActionFactory, private readonly relationColumnActionFactory: RelationColumnActionFactory, + private readonly morphRelationColumnActionFactory: MorphRelationColumnActionFactory, ) { this.factoriesMap = new Map< FieldMetadataType, @@ -106,6 +108,10 @@ export class WorkspaceMigrationFactory { FieldMetadataType.RELATION, { factory: this.relationColumnActionFactory }, ], + [ + FieldMetadataType.MORPH_RELATION, + { factory: this.morphRelationColumnActionFactory }, + ], ]); } diff --git a/packages/twenty-server/src/engine/twenty-orm/factories/entity-schema-column.factory.ts b/packages/twenty-server/src/engine/twenty-orm/factories/entity-schema-column.factory.ts index 00e948c8e..620746cd4 100644 --- a/packages/twenty-server/src/engine/twenty-orm/factories/entity-schema-column.factory.ts +++ b/packages/twenty-server/src/engine/twenty-orm/factories/entity-schema-column.factory.ts @@ -38,12 +38,17 @@ export class EntitySchemaColumnFactory { for (const fieldMetadata of fieldMetadataCollection) { const key = fieldMetadata.name; - if ( + const isRelation = isFieldMetadataInterfaceOfType( fieldMetadata, FieldMetadataType.RELATION, - ) - ) { + ) || + isFieldMetadataInterfaceOfType( + fieldMetadata, + FieldMetadataType.MORPH_RELATION, + ); + + if (isRelation) { const isManyToOneRelation = fieldMetadata.settings?.relationType === RelationType.MANY_TO_ONE; const joinColumnName = fieldMetadata.settings?.joinColumnName; diff --git a/packages/twenty-server/src/engine/twenty-orm/factories/entity-schema-relation.factory.ts b/packages/twenty-server/src/engine/twenty-orm/factories/entity-schema-relation.factory.ts index ec6c96246..814aeb5dc 100644 --- a/packages/twenty-server/src/engine/twenty-orm/factories/entity-schema-relation.factory.ts +++ b/packages/twenty-server/src/engine/twenty-orm/factories/entity-schema-relation.factory.ts @@ -27,12 +27,17 @@ export class EntitySchemaRelationFactory { ); for (const fieldMetadata of fieldMetadataCollection) { - if ( - !isFieldMetadataInterfaceOfType( + const isRelation = + isFieldMetadataInterfaceOfType( fieldMetadata, FieldMetadataType.RELATION, - ) - ) { + ) || + isFieldMetadataInterfaceOfType( + fieldMetadata, + FieldMetadataType.MORPH_RELATION, + ); + + if (!isRelation) { continue; } diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/determine-schema-relation-details.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/determine-schema-relation-details.util.ts index bfabbe686..f14d68fec 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/determine-schema-relation-details.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/determine-schema-relation-details.util.ts @@ -18,7 +18,9 @@ interface RelationDetails { } export async function determineSchemaRelationDetails( - fieldMetadata: FieldMetadataInterface, + fieldMetadata: FieldMetadataInterface< + FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION + >, objectMetadataMaps: ObjectMetadataMaps, ): Promise { if (!fieldMetadata.settings) { diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts index 44c9a953d..f8f32439d 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts @@ -50,6 +50,11 @@ export const seedFeatureFlags = async ( workspaceId: workspaceId, value: true, }, + { + key: FeatureFlagKey.IS_MORPH_RELATION_ENABLED, + workspaceId: workspaceId, + value: true, + }, ]) .execute(); }; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/metadata/services/dev-seeder-metadata.service.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/metadata/services/dev-seeder-metadata.service.ts index b6ae5ef11..dd4648850 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/metadata/services/dev-seeder-metadata.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/metadata/services/dev-seeder-metadata.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity'; -import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service'; +import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; import { SEED_APPLE_WORKSPACE_ID, 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 30ba4f926..e9606dff3 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,8 +1,14 @@ +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', () => { describe('FieldMetadataService name/label sync', () => { let createdObjectMetadataId = ''; @@ -107,4 +113,184 @@ describe('createOne', () => { ); }); }); + 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', + }, + }); + + 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 { + 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 }, + }); + }); + + // 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, + }, + ], + }; + + const { data: createdFieldOwner } = await createOneFieldMetadata({ + input: createFieldInput, + gqlFields: ` + id + name + label + isLabelSyncedWithName + `, + expectToFail: false, + }); + + // expect(createdFieldOwner.createOneField.name).toBe('owner'); + + await deleteOneFieldMetadata({ + input: { idToDelete: createdFieldOwner.createOneField.id }, + }); + }); + }); }); diff --git a/packages/twenty-shared/src/types/FieldMetadataType.ts b/packages/twenty-shared/src/types/FieldMetadataType.ts index c8cddee59..287d2c59d 100644 --- a/packages/twenty-shared/src/types/FieldMetadataType.ts +++ b/packages/twenty-shared/src/types/FieldMetadataType.ts @@ -15,6 +15,7 @@ export enum FieldMetadataType { SELECT = 'SELECT', MULTI_SELECT = 'MULTI_SELECT', RELATION = 'RELATION', + MORPH_RELATION = 'MORPH_RELATION', POSITION = 'POSITION', ADDRESS = 'ADDRESS', RAW_JSON = 'RAW_JSON',