fieldmetadatatype + featurelfag creation (#13021)
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
@ -463,6 +463,7 @@ export type CreateFieldInput = {
|
||||
isSystem?: InputMaybe<Scalars['Boolean']>;
|
||||
isUnique?: InputMaybe<Scalars['Boolean']>;
|
||||
label: Scalars['String'];
|
||||
morphRelationsCreationPayload?: InputMaybe<Array<Scalars['JSON']>>;
|
||||
name: Scalars['String'];
|
||||
objectMetadataId: Scalars['String'];
|
||||
options?: InputMaybe<Scalars['JSON']>;
|
||||
@ -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',
|
||||
|
||||
@ -459,6 +459,7 @@ export type CreateFieldInput = {
|
||||
isSystem?: InputMaybe<Scalars['Boolean']>;
|
||||
isUnique?: InputMaybe<Scalars['Boolean']>;
|
||||
label: Scalars['String'];
|
||||
morphRelationsCreationPayload?: InputMaybe<Array<Scalars['JSON']>>;
|
||||
name: Scalars['String'];
|
||||
objectMetadataId: Scalars['String'];
|
||||
options?: InputMaybe<Scalars['JSON']>;
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -122,6 +122,11 @@ export const SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS: SettingsNonCompositeFiel
|
||||
Icon: IllustrationIconOneToMany,
|
||||
category: 'Relation',
|
||||
} as const satisfies SettingsFieldTypeConfig<FieldRelationValue<any>>,
|
||||
[FieldMetadataType.MORPH_RELATION]: {
|
||||
label: 'Morph Relation',
|
||||
Icon: IllustrationIconOneToMany,
|
||||
category: 'Relation',
|
||||
} as const satisfies SettingsFieldTypeConfig<FieldRelationValue<any>>,
|
||||
[FieldMetadataType.RATING]: {
|
||||
label: 'Rating',
|
||||
Icon: IllustrationIconStar,
|
||||
|
||||
@ -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 = ({
|
||||
<StyledContainer>
|
||||
{fieldTypeConfigs
|
||||
.filter(([, config]) => config.category === category)
|
||||
.filter(([key]) => {
|
||||
return (
|
||||
key !== FieldMetadataType.MORPH_RELATION ||
|
||||
isMorphRelationEnabled
|
||||
);
|
||||
})
|
||||
.map(([key, config]) => (
|
||||
<StyledCardContainer key={key}>
|
||||
<UndecoratedLink
|
||||
|
||||
@ -12,6 +12,7 @@ export const DEFAULT_ICONS_BY_FIELD_TYPE: Record<FieldMetadataType, string> = {
|
||||
[FieldMetadataType.RATING]: 'IconStar',
|
||||
[FieldMetadataType.RAW_JSON]: 'IconBraces',
|
||||
[FieldMetadataType.RELATION]: 'IconRelationOneToMany',
|
||||
[FieldMetadataType.MORPH_RELATION]: 'IconRelationOneToMany',
|
||||
[FieldMetadataType.SELECT]: 'IconTag',
|
||||
[FieldMetadataType.TEXT]: 'IconTypography',
|
||||
[FieldMetadataType.UUID]: 'IconId',
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class MorphIndexUpdate1751558024634 implements MigrationInterface {
|
||||
name = 'MorphIndexUpdate1751558024634';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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<void> {
|
||||
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")`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -19,17 +19,19 @@ export class RelationTypeV2Factory {
|
||||
) {}
|
||||
|
||||
public create(
|
||||
fieldMetadata: FieldMetadataInterface<FieldMetadataType.RELATION>,
|
||||
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`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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<FieldMetadataType>,
|
||||
isIdField?: boolean,
|
||||
): GraphQLScalarType | undefined {
|
||||
if (isIdField || fieldMetadataType === FieldMetadataType.RELATION) {
|
||||
if (
|
||||
isIdField ||
|
||||
fieldMetadataType === FieldMetadataType.RELATION ||
|
||||
fieldMetadataType === FieldMetadataType.MORPH_RELATION
|
||||
) {
|
||||
return GraphQLID;
|
||||
}
|
||||
const typeScalarMapping = new Map<FieldMetadataType, GraphQLScalarType>([
|
||||
@ -75,7 +82,7 @@ export class TypeMapperService {
|
||||
FieldMetadataType.NUMBER,
|
||||
getNumberScalarType(
|
||||
(settings as FieldMetadataSettings<FieldMetadataType.NUMBER>)
|
||||
?.dataType,
|
||||
?.dataType ?? NumberDataType.FLOAT,
|
||||
),
|
||||
],
|
||||
[FieldMetadataType.NUMERIC, BigFloatScalarType],
|
||||
@ -97,7 +104,11 @@ export class TypeMapperService {
|
||||
settings?: FieldMetadataSettings<FieldMetadataType>,
|
||||
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, GraphQLEnumType>([
|
||||
[FieldMetadataType.UUID, OrderByDirectionType],
|
||||
[FieldMetadataType.RELATION, OrderByDirectionType],
|
||||
[FieldMetadataType.MORPH_RELATION, OrderByDirectionType],
|
||||
[FieldMetadataType.TEXT, OrderByDirectionType],
|
||||
[FieldMetadataType.DATE_TIME, OrderByDirectionType],
|
||||
[FieldMetadataType.DATE, OrderByDirectionType],
|
||||
|
||||
@ -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<FieldMetadataType.RELATION>;
|
||||
fieldMetadata: FieldMetadataInterface<
|
||||
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
|
||||
>;
|
||||
kind: T;
|
||||
options: WorkspaceBuildSchemaOptions;
|
||||
typeFactory: TypeFactory<T>;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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',
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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<T>;
|
||||
}) {
|
||||
switch (fieldType) {
|
||||
case FieldMetadataType.NUMBER:
|
||||
await this.validateSettings<FieldMetadataType.NUMBER>(
|
||||
NumberSettingsValidation,
|
||||
settings,
|
||||
);
|
||||
break;
|
||||
case FieldMetadataType.TEXT:
|
||||
await this.validateSettings<FieldMetadataType.TEXT>(
|
||||
TextSettingsValidation,
|
||||
settings,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async validateSettings<Type extends FieldMetadataType>(
|
||||
validator: ClassConstructor<
|
||||
Type extends FieldMetadataType.NUMBER
|
||||
? NumberSettingsValidation
|
||||
: Type extends FieldMetadataType.TEXT
|
||||
? TextSettingsValidation
|
||||
: never
|
||||
>,
|
||||
settings: FieldMetadataSettings<T>,
|
||||
) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
])
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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<UpdateFieldInput> {
|
||||
standardOverrides?: FieldStandardOverridesDTO;
|
||||
|
||||
@ -22,7 +22,7 @@ export enum DateDisplayFormat {
|
||||
export type FieldNumberVariant = 'number' | 'percentage';
|
||||
|
||||
export type FieldMetadataNumberSettings = {
|
||||
dataType: NumberDataType;
|
||||
dataType?: NumberDataType;
|
||||
decimals?: number;
|
||||
type?: FieldNumberVariant;
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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<FieldMetadataEntity>;
|
||||
objectMetadataMaps: ObjectMetadataMaps;
|
||||
}): Promise<FieldMetadataEntity[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<T extends UpdateFieldInput | CreateFieldInput> =
|
||||
{
|
||||
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<FieldMetadataEntity>;
|
||||
}): Promise<FieldMetadataEntity[]> {
|
||||
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<T>,
|
||||
'fieldMetadataInput' | 'fieldMetadataType' | 'objectMetadataMaps'
|
||||
>): Promise<T> {
|
||||
// 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,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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<T extends FieldMetadataType>({
|
||||
fieldType,
|
||||
settings,
|
||||
}: {
|
||||
fieldType: FieldMetadataType;
|
||||
settings: FieldMetadataSettings<T>;
|
||||
}) {
|
||||
switch (fieldType) {
|
||||
case FieldMetadataType.NUMBER:
|
||||
await this.validateSettings<FieldMetadataType.NUMBER>(
|
||||
NumberSettingsValidation,
|
||||
settings,
|
||||
);
|
||||
break;
|
||||
case FieldMetadataType.TEXT:
|
||||
await this.validateSettings<FieldMetadataType.TEXT>(
|
||||
TextSettingsValidation,
|
||||
settings,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async validateSettings<Type extends FieldMetadataType>(
|
||||
validator: ClassConstructor<
|
||||
Type extends FieldMetadataType.NUMBER
|
||||
? NumberSettingsValidation
|
||||
: Type extends FieldMetadataType.TEXT
|
||||
? TextSettingsValidation
|
||||
: never
|
||||
>,
|
||||
settings: FieldMetadataSettings<Type>,
|
||||
) {
|
||||
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<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<T extends UpdateFieldInput | CreateFieldInput> =
|
||||
{
|
||||
fieldMetadataType: FieldMetadataType;
|
||||
fieldMetadataInput: T;
|
||||
objectMetadata: ObjectMetadataItemWithFieldMaps;
|
||||
existingFieldMetadata?: FieldMetadataInterface;
|
||||
objectMetadataMaps: ObjectMetadataMaps;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntity> {
|
||||
@ -97,13 +68,15 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
||||
private readonly workspaceMigrationFactory: WorkspaceMigrationFactory,
|
||||
private readonly workspaceMigrationService: WorkspaceMigrationService,
|
||||
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
|
||||
private readonly fieldMetadataEnumValidationService: FieldMetadataEnumValidationService,
|
||||
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
private readonly fieldMetadataValidationService: FieldMetadataValidationService,
|
||||
private readonly fieldMetadataRelatedRecordsService: FieldMetadataRelatedRecordsService,
|
||||
private readonly viewService: ViewService,
|
||||
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly fieldMetadataValidationService: FieldMetadataValidationService,
|
||||
private readonly fieldMetadataMorphRelationService: FieldMetadataMorphRelationService,
|
||||
private readonly fieldMetadataRelationService: FieldMetadataRelationService,
|
||||
) {
|
||||
super(fieldMetadataRepository);
|
||||
}
|
||||
@ -191,14 +164,14 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
||||
|
||||
const updatableFieldInput =
|
||||
existingFieldMetadata.isCustom === false
|
||||
? this.buildUpdatableStandardFieldInput(
|
||||
? buildUpdatableStandardFieldInput(
|
||||
fieldMetadataInput,
|
||||
existingFieldMetadata,
|
||||
)
|
||||
: fieldMetadataInput;
|
||||
|
||||
const optionsForUpdate = isDefined(fieldMetadataInput.options)
|
||||
? this.prepareCustomFieldMetadataOptions(fieldMetadataInput.options)
|
||||
? prepareCustomFieldMetadataOptions(fieldMetadataInput.options)
|
||||
: undefined;
|
||||
const defaultValueForUpdate =
|
||||
updatableFieldInput.defaultValue !== undefined
|
||||
@ -211,12 +184,11 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
||||
...optionsForUpdate,
|
||||
};
|
||||
|
||||
await this.validateFieldMetadata({
|
||||
await this.fieldMetadataValidationService.validateFieldMetadata({
|
||||
fieldMetadataType: existingFieldMetadata.type,
|
||||
existingFieldMetadata,
|
||||
fieldMetadataInput: fieldMetadataForUpdate,
|
||||
objectMetadata: objectMetadataItemWithFieldMaps,
|
||||
objectMetadataMaps,
|
||||
});
|
||||
|
||||
const isLabelSyncedWithName =
|
||||
@ -508,248 +480,6 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
||||
return fieldMetadata;
|
||||
}
|
||||
|
||||
private 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;
|
||||
}
|
||||
|
||||
private async validateFieldMetadata<
|
||||
T extends UpdateFieldInput | CreateFieldInput,
|
||||
>({
|
||||
fieldMetadataInput,
|
||||
fieldMetadataType,
|
||||
objectMetadata,
|
||||
existingFieldMetadata,
|
||||
objectMetadataMaps,
|
||||
}: ValidateFieldMetadataArgs<T>): Promise<T> {
|
||||
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<FieldMetadataEntity, 'options'> {
|
||||
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<string, CreateFieldInput[]> {
|
||||
@ -766,137 +496,6 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
||||
);
|
||||
}
|
||||
|
||||
private async validateAndCreateFieldMetadataItems(
|
||||
fieldMetadataInput: CreateFieldInput,
|
||||
objectMetadata: ObjectMetadataItemWithFieldMaps,
|
||||
fieldMetadataRepository: Repository<FieldMetadataEntity>,
|
||||
objectMetadataMaps: ObjectMetadataMaps,
|
||||
): Promise<FieldMetadataEntity[]> {
|
||||
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<string, ObjectMetadataItemWithFieldMaps>;
|
||||
isRemoteCreation: boolean;
|
||||
}): Promise<WorkspaceMigrationTableAction[]> {
|
||||
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<FieldMetadataEntity[]> {
|
||||
@ -910,6 +509,25 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
||||
);
|
||||
|
||||
const workspaceId = fieldMetadataInputs[0].workspaceId;
|
||||
|
||||
const isMorphRelationEnabled =
|
||||
await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_MORPH_RELATION_ENABLED,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const isSomeFieldMetadatInputsMorph = fieldMetadataInputs.some(
|
||||
(fieldMetadataInput) =>
|
||||
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<FieldMetadataEntit
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
await this.createViewAndViewFields(createdFieldMetadatas, workspaceId);
|
||||
await this.fieldMetadataRelatedRecordsService.createViewAndViewFields(
|
||||
createdFieldMetadatas,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
await this.workspaceMetadataVersionService.incrementMetadataVersion(
|
||||
workspaceId,
|
||||
@ -995,89 +616,128 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
||||
}
|
||||
}
|
||||
|
||||
private async createViewAndViewFields(
|
||||
createdFieldMetadatas: FieldMetadataEntity[],
|
||||
workspaceId: string,
|
||||
) {
|
||||
const workspaceDataSource =
|
||||
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
|
||||
workspaceId,
|
||||
});
|
||||
private async validateAndCreateFieldMetadataItems(
|
||||
fieldMetadataInput: CreateFieldInput,
|
||||
objectMetadata: ObjectMetadataItemWithFieldMaps,
|
||||
fieldMetadataRepository: Repository<FieldMetadataEntity>,
|
||||
objectMetadataMaps: ObjectMetadataMaps,
|
||||
): Promise<FieldMetadataEntity[]> {
|
||||
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<string, ObjectMetadataItemWithFieldMaps>;
|
||||
isRemoteCreation: boolean;
|
||||
}): Promise<WorkspaceMigrationTableAction[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -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>(
|
||||
FieldMetadataValidationService,
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate NUMBER settings successfully', async () => {
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -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<FieldMetadataEntity, 'options'> => {
|
||||
return {
|
||||
options: options.map((option) => ({
|
||||
id: v4(),
|
||||
...trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(option, [
|
||||
'label',
|
||||
'value',
|
||||
'id',
|
||||
]),
|
||||
})),
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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<FieldMetadataType.MORPH_RELATION> {
|
||||
protected readonly logger = new Logger(MorphRelationColumnActionFactory.name);
|
||||
|
||||
protected handleCreateAction(
|
||||
_fieldMetadata: FieldMetadataInterface<FieldMetadataType.MORPH_RELATION>,
|
||||
_options?: WorkspaceColumnActionOptions,
|
||||
): WorkspaceMigrationColumnCreate[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
protected handleAlterAction(
|
||||
_currentFieldMetadata: FieldMetadataInterface<FieldMetadataType.MORPH_RELATION>,
|
||||
_alteredFieldMetadata: FieldMetadataInterface<FieldMetadataType.MORPH_RELATION>,
|
||||
_options?: WorkspaceColumnActionOptions,
|
||||
): WorkspaceMigrationColumnAlter[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -18,7 +18,9 @@ interface RelationDetails {
|
||||
}
|
||||
|
||||
export async function determineSchemaRelationDetails(
|
||||
fieldMetadata: FieldMetadataInterface<FieldMetadataType.RELATION>,
|
||||
fieldMetadata: FieldMetadataInterface<
|
||||
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
|
||||
>,
|
||||
objectMetadataMaps: ObjectMetadataMaps,
|
||||
): Promise<RelationDetails> {
|
||||
if (!fieldMetadata.settings) {
|
||||
|
||||
@ -50,6 +50,11 @@ export const seedFeatureFlags = async (
|
||||
workspaceId: workspaceId,
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
key: FeatureFlagKey.IS_MORPH_RELATION_ENABLED,
|
||||
workspaceId: workspaceId,
|
||||
value: true,
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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',
|
||||
|
||||
Reference in New Issue
Block a user