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']>;
|
isSystem?: InputMaybe<Scalars['Boolean']>;
|
||||||
isUnique?: InputMaybe<Scalars['Boolean']>;
|
isUnique?: InputMaybe<Scalars['Boolean']>;
|
||||||
label: Scalars['String'];
|
label: Scalars['String'];
|
||||||
|
morphRelationsCreationPayload?: InputMaybe<Array<Scalars['JSON']>>;
|
||||||
name: Scalars['String'];
|
name: Scalars['String'];
|
||||||
objectMetadataId: Scalars['String'];
|
objectMetadataId: Scalars['String'];
|
||||||
options?: InputMaybe<Scalars['JSON']>;
|
options?: InputMaybe<Scalars['JSON']>;
|
||||||
@ -679,6 +680,7 @@ export enum FeatureFlagKey {
|
|||||||
IS_AI_ENABLED = 'IS_AI_ENABLED',
|
IS_AI_ENABLED = 'IS_AI_ENABLED',
|
||||||
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
|
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
|
||||||
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_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_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
|
||||||
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
|
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
|
||||||
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
|
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
|
||||||
@ -747,6 +749,7 @@ export enum FieldMetadataType {
|
|||||||
EMAILS = 'EMAILS',
|
EMAILS = 'EMAILS',
|
||||||
FULL_NAME = 'FULL_NAME',
|
FULL_NAME = 'FULL_NAME',
|
||||||
LINKS = 'LINKS',
|
LINKS = 'LINKS',
|
||||||
|
MORPH_RELATION = 'MORPH_RELATION',
|
||||||
MULTI_SELECT = 'MULTI_SELECT',
|
MULTI_SELECT = 'MULTI_SELECT',
|
||||||
NUMBER = 'NUMBER',
|
NUMBER = 'NUMBER',
|
||||||
NUMERIC = 'NUMERIC',
|
NUMERIC = 'NUMERIC',
|
||||||
|
|||||||
@ -459,6 +459,7 @@ export type CreateFieldInput = {
|
|||||||
isSystem?: InputMaybe<Scalars['Boolean']>;
|
isSystem?: InputMaybe<Scalars['Boolean']>;
|
||||||
isUnique?: InputMaybe<Scalars['Boolean']>;
|
isUnique?: InputMaybe<Scalars['Boolean']>;
|
||||||
label: Scalars['String'];
|
label: Scalars['String'];
|
||||||
|
morphRelationsCreationPayload?: InputMaybe<Array<Scalars['JSON']>>;
|
||||||
name: Scalars['String'];
|
name: Scalars['String'];
|
||||||
objectMetadataId: Scalars['String'];
|
objectMetadataId: Scalars['String'];
|
||||||
options?: InputMaybe<Scalars['JSON']>;
|
options?: InputMaybe<Scalars['JSON']>;
|
||||||
@ -643,6 +644,7 @@ export enum FeatureFlagKey {
|
|||||||
IS_AI_ENABLED = 'IS_AI_ENABLED',
|
IS_AI_ENABLED = 'IS_AI_ENABLED',
|
||||||
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
|
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
|
||||||
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_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_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
|
||||||
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
|
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
|
||||||
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
|
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
|
||||||
@ -711,6 +713,7 @@ export enum FieldMetadataType {
|
|||||||
EMAILS = 'EMAILS',
|
EMAILS = 'EMAILS',
|
||||||
FULL_NAME = 'FULL_NAME',
|
FULL_NAME = 'FULL_NAME',
|
||||||
LINKS = 'LINKS',
|
LINKS = 'LINKS',
|
||||||
|
MORPH_RELATION = 'MORPH_RELATION',
|
||||||
MULTI_SELECT = 'MULTI_SELECT',
|
MULTI_SELECT = 'MULTI_SELECT',
|
||||||
NUMBER = 'NUMBER',
|
NUMBER = 'NUMBER',
|
||||||
NUMERIC = 'NUMERIC',
|
NUMERIC = 'NUMERIC',
|
||||||
|
|||||||
@ -56,7 +56,8 @@ export const generateEmptyFieldValue = ({
|
|||||||
case FieldMetadataType.BOOLEAN: {
|
case FieldMetadataType.BOOLEAN: {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case FieldMetadataType.RELATION: {
|
case FieldMetadataType.RELATION:
|
||||||
|
case FieldMetadataType.MORPH_RELATION: {
|
||||||
if (fieldMetadataItem.relation?.type === RelationType.MANY_TO_ONE) {
|
if (fieldMetadataItem.relation?.type === RelationType.MANY_TO_ONE) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -122,6 +122,11 @@ export const SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS: SettingsNonCompositeFiel
|
|||||||
Icon: IllustrationIconOneToMany,
|
Icon: IllustrationIconOneToMany,
|
||||||
category: 'Relation',
|
category: 'Relation',
|
||||||
} as const satisfies SettingsFieldTypeConfig<FieldRelationValue<any>>,
|
} as const satisfies SettingsFieldTypeConfig<FieldRelationValue<any>>,
|
||||||
|
[FieldMetadataType.MORPH_RELATION]: {
|
||||||
|
label: 'Morph Relation',
|
||||||
|
Icon: IllustrationIconOneToMany,
|
||||||
|
category: 'Relation',
|
||||||
|
} as const satisfies SettingsFieldTypeConfig<FieldRelationValue<any>>,
|
||||||
[FieldMetadataType.RATING]: {
|
[FieldMetadataType.RATING]: {
|
||||||
label: 'Rating',
|
label: 'Rating',
|
||||||
Icon: IllustrationIconStar,
|
Icon: IllustrationIconStar,
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { FieldType } from '@/settings/data-model/types/FieldType';
|
|||||||
import { SettingsFieldType } from '@/settings/data-model/types/SettingsFieldType';
|
import { SettingsFieldType } from '@/settings/data-model/types/SettingsFieldType';
|
||||||
import { SettingsPath } from '@/types/SettingsPath';
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
import { TextInput } from '@/ui/input/components/TextInput';
|
import { TextInput } from '@/ui/input/components/TextInput';
|
||||||
|
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||||
import { useTheme } from '@emotion/react';
|
import { useTheme } from '@emotion/react';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { t } from '@lingui/core/macro';
|
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 { H2Title, IconSearch } from 'twenty-ui/display';
|
||||||
import { UndecoratedLink } from 'twenty-ui/navigation';
|
import { UndecoratedLink } from 'twenty-ui/navigation';
|
||||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||||
|
import { FeatureFlagKey } from '~/generated/graphql';
|
||||||
import { SettingsDataModelFieldTypeFormValues } from '~/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldSelect';
|
import { SettingsDataModelFieldTypeFormValues } from '~/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldSelect';
|
||||||
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||||
|
|
||||||
@ -102,7 +104,9 @@ export const SettingsObjectNewFieldSelector = ({
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const isMorphRelationEnabled = useIsFeatureEnabled(
|
||||||
|
FeatureFlagKey.IS_MORPH_RELATION_ENABLED,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{' '}
|
{' '}
|
||||||
@ -131,6 +135,12 @@ export const SettingsObjectNewFieldSelector = ({
|
|||||||
<StyledContainer>
|
<StyledContainer>
|
||||||
{fieldTypeConfigs
|
{fieldTypeConfigs
|
||||||
.filter(([, config]) => config.category === category)
|
.filter(([, config]) => config.category === category)
|
||||||
|
.filter(([key]) => {
|
||||||
|
return (
|
||||||
|
key !== FieldMetadataType.MORPH_RELATION ||
|
||||||
|
isMorphRelationEnabled
|
||||||
|
);
|
||||||
|
})
|
||||||
.map(([key, config]) => (
|
.map(([key, config]) => (
|
||||||
<StyledCardContainer key={key}>
|
<StyledCardContainer key={key}>
|
||||||
<UndecoratedLink
|
<UndecoratedLink
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export const DEFAULT_ICONS_BY_FIELD_TYPE: Record<FieldMetadataType, string> = {
|
|||||||
[FieldMetadataType.RATING]: 'IconStar',
|
[FieldMetadataType.RATING]: 'IconStar',
|
||||||
[FieldMetadataType.RAW_JSON]: 'IconBraces',
|
[FieldMetadataType.RAW_JSON]: 'IconBraces',
|
||||||
[FieldMetadataType.RELATION]: 'IconRelationOneToMany',
|
[FieldMetadataType.RELATION]: 'IconRelationOneToMany',
|
||||||
|
[FieldMetadataType.MORPH_RELATION]: 'IconRelationOneToMany',
|
||||||
[FieldMetadataType.SELECT]: 'IconTag',
|
[FieldMetadataType.SELECT]: 'IconTag',
|
||||||
[FieldMetadataType.TEXT]: 'IconTypography',
|
[FieldMetadataType.TEXT]: 'IconTypography',
|
||||||
[FieldMetadataType.UUID]: 'IconId',
|
[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) {
|
for (const fieldMetadata of objectMetadata.fields) {
|
||||||
// Ignore non-relation fields as they are already defined
|
// Ignore non-relation fields as they are already defined
|
||||||
if (
|
const isRelation =
|
||||||
!isFieldMetadataInterfaceOfType(
|
isFieldMetadataInterfaceOfType(
|
||||||
fieldMetadata,
|
fieldMetadata,
|
||||||
FieldMetadataType.RELATION,
|
FieldMetadataType.RELATION,
|
||||||
)
|
) ||
|
||||||
) {
|
isFieldMetadataInterfaceOfType(
|
||||||
|
fieldMetadata,
|
||||||
|
FieldMetadataType.MORPH_RELATION,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isRelation) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fieldMetadata.settings) {
|
if (!fieldMetadata.settings) {
|
||||||
throw new Error(
|
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) {
|
if (!fieldMetadata.relationTargetObjectMetadataId) {
|
||||||
throw new Error(
|
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(
|
public create(
|
||||||
fieldMetadata: FieldMetadataInterface<FieldMetadataType.RELATION>,
|
fieldMetadata: FieldMetadataInterface<
|
||||||
|
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
|
||||||
|
>,
|
||||||
): GraphQLOutputType {
|
): GraphQLOutputType {
|
||||||
if (!fieldMetadata.settings) {
|
if (!fieldMetadata.settings) {
|
||||||
throw new Error(
|
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) {
|
if (!fieldMetadata.relationTargetObjectMetadataId) {
|
||||||
throw new Error(
|
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';
|
} from 'graphql';
|
||||||
import { FieldMetadataType } from 'twenty-shared/types';
|
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 { OrderByDirectionType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/enum';
|
||||||
import {
|
import {
|
||||||
@ -62,7 +65,11 @@ export class TypeMapperService {
|
|||||||
settings?: FieldMetadataSettings<FieldMetadataType>,
|
settings?: FieldMetadataSettings<FieldMetadataType>,
|
||||||
isIdField?: boolean,
|
isIdField?: boolean,
|
||||||
): GraphQLScalarType | undefined {
|
): GraphQLScalarType | undefined {
|
||||||
if (isIdField || fieldMetadataType === FieldMetadataType.RELATION) {
|
if (
|
||||||
|
isIdField ||
|
||||||
|
fieldMetadataType === FieldMetadataType.RELATION ||
|
||||||
|
fieldMetadataType === FieldMetadataType.MORPH_RELATION
|
||||||
|
) {
|
||||||
return GraphQLID;
|
return GraphQLID;
|
||||||
}
|
}
|
||||||
const typeScalarMapping = new Map<FieldMetadataType, GraphQLScalarType>([
|
const typeScalarMapping = new Map<FieldMetadataType, GraphQLScalarType>([
|
||||||
@ -75,7 +82,7 @@ export class TypeMapperService {
|
|||||||
FieldMetadataType.NUMBER,
|
FieldMetadataType.NUMBER,
|
||||||
getNumberScalarType(
|
getNumberScalarType(
|
||||||
(settings as FieldMetadataSettings<FieldMetadataType.NUMBER>)
|
(settings as FieldMetadataSettings<FieldMetadataType.NUMBER>)
|
||||||
?.dataType,
|
?.dataType ?? NumberDataType.FLOAT,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
[FieldMetadataType.NUMERIC, BigFloatScalarType],
|
[FieldMetadataType.NUMERIC, BigFloatScalarType],
|
||||||
@ -97,7 +104,11 @@ export class TypeMapperService {
|
|||||||
settings?: FieldMetadataSettings<FieldMetadataType>,
|
settings?: FieldMetadataSettings<FieldMetadataType>,
|
||||||
isIdField?: boolean,
|
isIdField?: boolean,
|
||||||
): GraphQLInputObjectType | GraphQLScalarType | undefined {
|
): GraphQLInputObjectType | GraphQLScalarType | undefined {
|
||||||
if (isIdField || fieldMetadataType === FieldMetadataType.RELATION) {
|
if (
|
||||||
|
isIdField ||
|
||||||
|
fieldMetadataType === FieldMetadataType.RELATION ||
|
||||||
|
fieldMetadataType === FieldMetadataType.MORPH_RELATION
|
||||||
|
) {
|
||||||
return UUIDFilterType;
|
return UUIDFilterType;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,6 +148,7 @@ export class TypeMapperService {
|
|||||||
const typeOrderByMapping = new Map<FieldMetadataType, GraphQLEnumType>([
|
const typeOrderByMapping = new Map<FieldMetadataType, GraphQLEnumType>([
|
||||||
[FieldMetadataType.UUID, OrderByDirectionType],
|
[FieldMetadataType.UUID, OrderByDirectionType],
|
||||||
[FieldMetadataType.RELATION, OrderByDirectionType],
|
[FieldMetadataType.RELATION, OrderByDirectionType],
|
||||||
|
[FieldMetadataType.MORPH_RELATION, OrderByDirectionType],
|
||||||
[FieldMetadataType.TEXT, OrderByDirectionType],
|
[FieldMetadataType.TEXT, OrderByDirectionType],
|
||||||
[FieldMetadataType.DATE_TIME, OrderByDirectionType],
|
[FieldMetadataType.DATE_TIME, OrderByDirectionType],
|
||||||
[FieldMetadataType.DATE, OrderByDirectionType],
|
[FieldMetadataType.DATE, OrderByDirectionType],
|
||||||
|
|||||||
@ -64,9 +64,17 @@ export const generateFields = <
|
|||||||
for (const fieldMetadata of objectMetadata.fields) {
|
for (const fieldMetadata of objectMetadata.fields) {
|
||||||
let generatedField;
|
let generatedField;
|
||||||
|
|
||||||
if (
|
const isRelation =
|
||||||
isFieldMetadataInterfaceOfType(fieldMetadata, FieldMetadataType.RELATION)
|
isFieldMetadataInterfaceOfType(
|
||||||
) {
|
fieldMetadata,
|
||||||
|
FieldMetadataType.RELATION,
|
||||||
|
) ||
|
||||||
|
isFieldMetadataInterfaceOfType(
|
||||||
|
fieldMetadata,
|
||||||
|
FieldMetadataType.MORPH_RELATION,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isRelation) {
|
||||||
generatedField = generateRelationField({
|
generatedField = generateRelationField({
|
||||||
fieldMetadata,
|
fieldMetadata,
|
||||||
kind,
|
kind,
|
||||||
@ -162,7 +170,9 @@ const generateRelationField = <
|
|||||||
typeFactory,
|
typeFactory,
|
||||||
isRelationConnectEnabled,
|
isRelationConnectEnabled,
|
||||||
}: {
|
}: {
|
||||||
fieldMetadata: FieldMetadataInterface<FieldMetadataType.RELATION>;
|
fieldMetadata: FieldMetadataInterface<
|
||||||
|
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
|
||||||
|
>;
|
||||||
kind: T;
|
kind: T;
|
||||||
options: WorkspaceBuildSchemaOptions;
|
options: WorkspaceBuildSchemaOptions;
|
||||||
typeFactory: TypeFactory<T>;
|
typeFactory: TypeFactory<T>;
|
||||||
|
|||||||
@ -130,6 +130,12 @@ describe('mapFieldMetadataToGraphqlQuery', () => {
|
|||||||
} as FieldMetadataDefaultSettings;
|
} as FieldMetadataDefaultSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fieldMetadataType === FieldMetadataType.MORPH_RELATION) {
|
||||||
|
field.settings = {
|
||||||
|
relationType: RelationType.MANY_TO_ONE,
|
||||||
|
} as FieldMetadataDefaultSettings;
|
||||||
|
}
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
mapFieldMetadataToGraphqlQuery(objectMetadataMapsMock, field),
|
mapFieldMetadataToGraphqlQuery(objectMetadataMapsMock, field),
|
||||||
).toBeDefined();
|
).toBeDefined();
|
||||||
|
|||||||
@ -38,11 +38,15 @@ export const mapFieldMetadataToGraphqlQuery = (
|
|||||||
FieldMetadataType.TS_VECTOR,
|
FieldMetadataType.TS_VECTOR,
|
||||||
].includes(fieldType);
|
].includes(fieldType);
|
||||||
|
|
||||||
|
const isRelation =
|
||||||
|
isFieldMetadataInterfaceOfType(field, FieldMetadataType.RELATION) ||
|
||||||
|
isFieldMetadataInterfaceOfType(field, FieldMetadataType.MORPH_RELATION);
|
||||||
|
|
||||||
if (fieldIsSimpleValue) {
|
if (fieldIsSimpleValue) {
|
||||||
return field.name;
|
return field.name;
|
||||||
} else if (
|
} else if (
|
||||||
maxDepthForRelations > 0 &&
|
maxDepthForRelations > 0 &&
|
||||||
isFieldMetadataInterfaceOfType(field, FieldMetadataType.RELATION) &&
|
isRelation &&
|
||||||
field.settings?.relationType === RelationType.MANY_TO_ONE
|
field.settings?.relationType === RelationType.MANY_TO_ONE
|
||||||
) {
|
) {
|
||||||
const targetObjectMetadataId = field.relationTargetObjectMetadataId;
|
const targetObjectMetadataId = field.relationTargetObjectMetadataId;
|
||||||
@ -69,7 +73,7 @@ export const mapFieldMetadataToGraphqlQuery = (
|
|||||||
}`;
|
}`;
|
||||||
} else if (
|
} else if (
|
||||||
maxDepthForRelations > 0 &&
|
maxDepthForRelations > 0 &&
|
||||||
isFieldMetadataInterfaceOfType(field, FieldMetadataType.RELATION) &&
|
isRelation &&
|
||||||
field.settings?.relationType === RelationType.ONE_TO_MANY
|
field.settings?.relationType === RelationType.ONE_TO_MANY
|
||||||
) {
|
) {
|
||||||
const targetObjectMetadataId = field.relationTargetObjectMetadataId;
|
const targetObjectMetadataId = field.relationTargetObjectMetadataId;
|
||||||
|
|||||||
@ -6,6 +6,7 @@ export enum FeatureFlagKey {
|
|||||||
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
|
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
|
||||||
IS_AI_ENABLED = 'IS_AI_ENABLED',
|
IS_AI_ENABLED = 'IS_AI_ENABLED',
|
||||||
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
|
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
|
||||||
|
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
|
||||||
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED',
|
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED',
|
||||||
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_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 { 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 { 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 {
|
import {
|
||||||
computeDepthParameters,
|
computeDepthParameters,
|
||||||
computeEndingBeforeParameters,
|
computeEndingBeforeParameters,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { FieldMetadataType } from 'twenty-shared/types';
|
|
||||||
import { v4 } from 'uuid';
|
|
||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
|
import { FieldMetadataType } from 'twenty-shared/types';
|
||||||
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
|
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';
|
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] : [];
|
return isDefined(field.options[0].value) ? [field.options[0].value] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
case FieldMetadataType.RELATION: {
|
case FieldMetadataType.RELATION:
|
||||||
|
case FieldMetadataType.MORPH_RELATION: {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -11,8 +11,8 @@ import { IndexMetadataInterface } from 'src/engine/metadata-modules/index-metada
|
|||||||
import { IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
|
import { IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
|
||||||
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
|
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 { 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/services/field-metadata-relation.service';
|
||||||
import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/relation/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 { 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 { IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto';
|
||||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||||
@ -59,7 +59,6 @@ export type IndexFieldMetadataLoaderPayload = {
|
|||||||
export class DataloaderService {
|
export class DataloaderService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly fieldMetadataRelationService: FieldMetadataRelationService,
|
private readonly fieldMetadataRelationService: FieldMetadataRelationService,
|
||||||
private readonly fieldMetadataService: FieldMetadataService,
|
|
||||||
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
|
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -166,7 +165,7 @@ export class DataloaderService {
|
|||||||
>(
|
>(
|
||||||
(acc, field) => ({
|
(acc, field) => ({
|
||||||
...acc,
|
...acc,
|
||||||
[field]: this.fieldMetadataService.resolveOverridableString(
|
[field]: resolveOverridableString(
|
||||||
fieldMetadata,
|
fieldMetadata,
|
||||||
field,
|
field,
|
||||||
dataLoaderParams[0].locale,
|
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';
|
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()
|
@InputType()
|
||||||
export class CreateFieldInput extends OmitType(
|
export class CreateFieldInput extends OmitType(
|
||||||
FieldMetadataDTO,
|
FieldMetadataDTO,
|
||||||
@ -25,12 +31,11 @@ export class CreateFieldInput extends OmitType(
|
|||||||
// TODO @prastoin implement validation for this with validate nested and dedicated class instance
|
// TODO @prastoin implement validation for this with validate nested and dedicated class instance
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Field(() => GraphQLJSON, { nullable: true })
|
@Field(() => GraphQLJSON, { nullable: true })
|
||||||
relationCreationPayload?: {
|
relationCreationPayload?: RelationCreationPayload;
|
||||||
targetObjectMetadataId: string;
|
|
||||||
targetFieldLabel: string;
|
@IsOptional()
|
||||||
targetFieldIcon: string;
|
@Field(() => [GraphQLJSON], { nullable: true })
|
||||||
type: RelationType;
|
morphRelationsCreationPayload?: RelationCreationPayload[];
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@InputType()
|
@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,
|
OneToOne,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
Relation,
|
Relation,
|
||||||
Unique,
|
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} 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';
|
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||||
|
|
||||||
@Entity('fieldMetadata')
|
@Entity('fieldMetadata')
|
||||||
@Unique('IDX_FIELD_METADATA_NAME_OBJECT_METADATA_ID_WORKSPACE_ID_UNIQUE', [
|
// max length of index is 63 characters
|
||||||
'name',
|
@Index(
|
||||||
'objectMetadataId',
|
'IDX_FIELD_METADATA_NAME_OBJMID_WORKSPACE_ID_EXCEPT_MORPH_UNIQUE',
|
||||||
'workspaceId',
|
['name', 'objectMetadataId', 'workspaceId'],
|
||||||
])
|
{
|
||||||
|
unique: true,
|
||||||
|
where: `"type" <> ''MORPH_RELATION''`,
|
||||||
|
},
|
||||||
|
)
|
||||||
@Index('IDX_FIELD_METADATA_RELATION_TARGET_FIELD_METADATA_ID', [
|
@Index('IDX_FIELD_METADATA_RELATION_TARGET_FIELD_METADATA_ID', [
|
||||||
'relationTargetFieldMetadataId',
|
'relationTargetFieldMetadataId',
|
||||||
])
|
])
|
||||||
|
|||||||
@ -9,16 +9,18 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
|||||||
|
|
||||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||||
import { ActorModule } from 'src/engine/core-modules/actor/actor.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 { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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';
|
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 { ViewModule } from 'src/modules/view/view.module';
|
||||||
|
|
||||||
import { FieldMetadataEntity } from './field-metadata.entity';
|
import { FieldMetadataEntity } from './field-metadata.entity';
|
||||||
import { FieldMetadataService } from './field-metadata.service';
|
|
||||||
|
|
||||||
import { CreateFieldInput } from './dtos/create-field.input';
|
import { CreateFieldInput } from './dtos/create-field.input';
|
||||||
import { UpdateFieldInput } from './dtos/update-field.input';
|
import { UpdateFieldInput } from './dtos/update-field.input';
|
||||||
|
import { FieldMetadataService } from './services/field-metadata.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -53,6 +55,7 @@ import { UpdateFieldInput } from './dtos/update-field.input';
|
|||||||
DataSourceModule,
|
DataSourceModule,
|
||||||
TypeORMModule,
|
TypeORMModule,
|
||||||
ActorModule,
|
ActorModule,
|
||||||
|
FeatureFlagModule,
|
||||||
ViewModule,
|
ViewModule,
|
||||||
PermissionsModule,
|
PermissionsModule,
|
||||||
WorkspaceMetadataCacheModule,
|
WorkspaceMetadataCacheModule,
|
||||||
@ -61,6 +64,8 @@ import { UpdateFieldInput } from './dtos/update-field.input';
|
|||||||
IsFieldMetadataDefaultValue,
|
IsFieldMetadataDefaultValue,
|
||||||
FieldMetadataService,
|
FieldMetadataService,
|
||||||
FieldMetadataRelatedRecordsService,
|
FieldMetadataRelatedRecordsService,
|
||||||
|
FieldMetadataMorphRelationService,
|
||||||
|
FieldMetadataRelationService,
|
||||||
FieldMetadataValidationService,
|
FieldMetadataValidationService,
|
||||||
FieldMetadataEnumValidationService,
|
FieldMetadataEnumValidationService,
|
||||||
],
|
],
|
||||||
@ -98,6 +103,7 @@ import { UpdateFieldInput } from './dtos/update-field.input';
|
|||||||
FieldMetadataService,
|
FieldMetadataService,
|
||||||
FieldMetadataRelationService,
|
FieldMetadataRelationService,
|
||||||
FieldMetadataRelatedRecordsService,
|
FieldMetadataRelatedRecordsService,
|
||||||
|
FieldMetadataMorphRelationService,
|
||||||
FieldMetadataValidationService,
|
FieldMetadataValidationService,
|
||||||
FieldMetadataEnumValidationService,
|
FieldMetadataEnumValidationService,
|
||||||
FieldMetadataResolver,
|
FieldMetadataResolver,
|
||||||
|
|||||||
@ -35,8 +35,8 @@ import {
|
|||||||
FieldMetadataException,
|
FieldMetadataException,
|
||||||
FieldMetadataExceptionCode,
|
FieldMetadataExceptionCode,
|
||||||
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
|
} 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 { 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 { 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 { 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';
|
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';
|
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||||
import { UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-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 { 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 { 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', () => ({
|
jest.mock('@lingui/core', () => ({
|
||||||
i18n: {
|
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 { 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 { 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 { 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> {
|
interface StandardFieldUpdate extends Partial<UpdateFieldInput> {
|
||||||
standardOverrides?: FieldStandardOverridesDTO;
|
standardOverrides?: FieldStandardOverridesDTO;
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export enum DateDisplayFormat {
|
|||||||
export type FieldNumberVariant = 'number' | 'percentage';
|
export type FieldNumberVariant = 'number' | 'percentage';
|
||||||
|
|
||||||
export type FieldMetadataNumberSettings = {
|
export type FieldMetadataNumberSettings = {
|
||||||
dataType: NumberDataType;
|
dataType?: NumberDataType;
|
||||||
decimals?: number;
|
decimals?: number;
|
||||||
type?: FieldNumberVariant;
|
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 { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import isEmpty from 'lodash.isempty';
|
||||||
import { MAX_OPTIONS_TO_DISPLAY } from 'twenty-shared/constants';
|
import { MAX_OPTIONS_TO_DISPLAY } from 'twenty-shared/constants';
|
||||||
import { isDefined, parseJson } from 'twenty-shared/utils';
|
import { isDefined, parseJson } from 'twenty-shared/utils';
|
||||||
import { In } from 'typeorm';
|
import { In } from 'typeorm';
|
||||||
|
|
||||||
|
import { settings } from 'src/engine/constants/settings';
|
||||||
import {
|
import {
|
||||||
FieldMetadataComplexOption,
|
FieldMetadataComplexOption,
|
||||||
FieldMetadataDefaultOption,
|
FieldMetadataDefaultOption,
|
||||||
} from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
|
} from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
|
||||||
|
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||||
import {
|
import {
|
||||||
FieldMetadataException,
|
FieldMetadataException,
|
||||||
FieldMetadataExceptionCode,
|
FieldMetadataExceptionCode,
|
||||||
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
|
} 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 { 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 { 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 { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
|
||||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||||
import { ViewFilterWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter.workspace-entity';
|
import { ViewFilterWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter.workspace-entity';
|
||||||
@ -310,4 +314,74 @@ export class FieldMetadataRelatedRecordsService {
|
|||||||
private getMaxPosition(viewGroups: ViewGroupWorkspaceEntity[]): number {
|
private getMaxPosition(viewGroups: ViewGroupWorkspaceEntity[]): number {
|
||||||
return viewGroups.reduce((max, group) => Math.max(max, group.position), 0);
|
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 { Injectable } from '@nestjs/common';
|
||||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { i18n } from '@lingui/core';
|
|
||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
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 { FieldMetadataType } from 'twenty-shared/types';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
import { DataSource, FindOneOptions, In, Repository } from 'typeorm';
|
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 { 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 { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
|
||||||
|
|
||||||
import { settings } from 'src/engine/constants/settings';
|
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||||
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
|
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 { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
|
||||||
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
|
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 { 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 { 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 {
|
import {
|
||||||
FieldMetadataException,
|
FieldMetadataException,
|
||||||
FieldMetadataExceptionCode,
|
FieldMetadataExceptionCode,
|
||||||
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
|
} 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 { 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 { 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 { checkCanDeactivateFieldOrThrow } from 'src/engine/metadata-modules/field-metadata/utils/check-can-deactivate-field-or-throw';
|
||||||
import {
|
import {
|
||||||
computeColumnName,
|
computeColumnName,
|
||||||
computeCompositeColumnName,
|
computeCompositeColumnName,
|
||||||
} from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
|
} 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 { 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 { 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 { 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 { 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 { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
|
||||||
import { InvalidMetadataException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
|
import { validateNameAndLabelAreSyncOrThrow } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
|
||||||
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 { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
|
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 { 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';
|
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';
|
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
|
||||||
import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.factory';
|
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 { 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 { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||||
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
|
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
|
||||||
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.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 { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
|
||||||
import { ViewService } from 'src/modules/view/services/view.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()
|
@Injectable()
|
||||||
export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntity> {
|
export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntity> {
|
||||||
@ -97,13 +68,15 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
|||||||
private readonly workspaceMigrationFactory: WorkspaceMigrationFactory,
|
private readonly workspaceMigrationFactory: WorkspaceMigrationFactory,
|
||||||
private readonly workspaceMigrationService: WorkspaceMigrationService,
|
private readonly workspaceMigrationService: WorkspaceMigrationService,
|
||||||
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
|
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
|
||||||
private readonly fieldMetadataEnumValidationService: FieldMetadataEnumValidationService,
|
|
||||||
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
|
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
|
||||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||||
private readonly fieldMetadataValidationService: FieldMetadataValidationService,
|
|
||||||
private readonly fieldMetadataRelatedRecordsService: FieldMetadataRelatedRecordsService,
|
private readonly fieldMetadataRelatedRecordsService: FieldMetadataRelatedRecordsService,
|
||||||
private readonly viewService: ViewService,
|
private readonly viewService: ViewService,
|
||||||
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
|
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
|
||||||
|
private readonly featureFlagService: FeatureFlagService,
|
||||||
|
private readonly fieldMetadataValidationService: FieldMetadataValidationService,
|
||||||
|
private readonly fieldMetadataMorphRelationService: FieldMetadataMorphRelationService,
|
||||||
|
private readonly fieldMetadataRelationService: FieldMetadataRelationService,
|
||||||
) {
|
) {
|
||||||
super(fieldMetadataRepository);
|
super(fieldMetadataRepository);
|
||||||
}
|
}
|
||||||
@ -191,14 +164,14 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
|||||||
|
|
||||||
const updatableFieldInput =
|
const updatableFieldInput =
|
||||||
existingFieldMetadata.isCustom === false
|
existingFieldMetadata.isCustom === false
|
||||||
? this.buildUpdatableStandardFieldInput(
|
? buildUpdatableStandardFieldInput(
|
||||||
fieldMetadataInput,
|
fieldMetadataInput,
|
||||||
existingFieldMetadata,
|
existingFieldMetadata,
|
||||||
)
|
)
|
||||||
: fieldMetadataInput;
|
: fieldMetadataInput;
|
||||||
|
|
||||||
const optionsForUpdate = isDefined(fieldMetadataInput.options)
|
const optionsForUpdate = isDefined(fieldMetadataInput.options)
|
||||||
? this.prepareCustomFieldMetadataOptions(fieldMetadataInput.options)
|
? prepareCustomFieldMetadataOptions(fieldMetadataInput.options)
|
||||||
: undefined;
|
: undefined;
|
||||||
const defaultValueForUpdate =
|
const defaultValueForUpdate =
|
||||||
updatableFieldInput.defaultValue !== undefined
|
updatableFieldInput.defaultValue !== undefined
|
||||||
@ -211,12 +184,11 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
|||||||
...optionsForUpdate,
|
...optionsForUpdate,
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.validateFieldMetadata({
|
await this.fieldMetadataValidationService.validateFieldMetadata({
|
||||||
fieldMetadataType: existingFieldMetadata.type,
|
fieldMetadataType: existingFieldMetadata.type,
|
||||||
existingFieldMetadata,
|
existingFieldMetadata,
|
||||||
fieldMetadataInput: fieldMetadataForUpdate,
|
fieldMetadataInput: fieldMetadataForUpdate,
|
||||||
objectMetadata: objectMetadataItemWithFieldMaps,
|
objectMetadata: objectMetadataItemWithFieldMaps,
|
||||||
objectMetadataMaps,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const isLabelSyncedWithName =
|
const isLabelSyncedWithName =
|
||||||
@ -508,248 +480,6 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
|||||||
return fieldMetadata;
|
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(
|
private groupFieldInputsByObjectId(
|
||||||
fieldMetadataInputs: CreateFieldInput[],
|
fieldMetadataInputs: CreateFieldInput[],
|
||||||
): Record<string, 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(
|
async createMany(
|
||||||
fieldMetadataInputs: CreateFieldInput[],
|
fieldMetadataInputs: CreateFieldInput[],
|
||||||
): Promise<FieldMetadataEntity[]> {
|
): Promise<FieldMetadataEntity[]> {
|
||||||
@ -910,6 +509,25 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
|||||||
);
|
);
|
||||||
|
|
||||||
const workspaceId = fieldMetadataInputs[0].workspaceId;
|
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();
|
const queryRunner = this.coreDataSource.createQueryRunner();
|
||||||
|
|
||||||
await queryRunner.connect();
|
await queryRunner.connect();
|
||||||
@ -977,7 +595,10 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
|||||||
|
|
||||||
await queryRunner.commitTransaction();
|
await queryRunner.commitTransaction();
|
||||||
|
|
||||||
await this.createViewAndViewFields(createdFieldMetadatas, workspaceId);
|
await this.fieldMetadataRelatedRecordsService.createViewAndViewFields(
|
||||||
|
createdFieldMetadatas,
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
await this.workspaceMetadataVersionService.incrementMetadataVersion(
|
await this.workspaceMetadataVersionService.incrementMetadataVersion(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@ -995,89 +616,128 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createViewAndViewFields(
|
private async validateAndCreateFieldMetadataItems(
|
||||||
createdFieldMetadatas: FieldMetadataEntity[],
|
fieldMetadataInput: CreateFieldInput,
|
||||||
workspaceId: string,
|
objectMetadata: ObjectMetadataItemWithFieldMaps,
|
||||||
) {
|
fieldMetadataRepository: Repository<FieldMetadataEntity>,
|
||||||
const workspaceDataSource =
|
objectMetadataMaps: ObjectMetadataMaps,
|
||||||
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
|
): Promise<FieldMetadataEntity[]> {
|
||||||
workspaceId,
|
if (!fieldMetadataInput.isRemoteCreation) {
|
||||||
});
|
assertMutationNotOnRemoteObject(objectMetadata);
|
||||||
|
}
|
||||||
|
|
||||||
await workspaceDataSource.transaction(
|
if (fieldMetadataInput.type === FieldMetadataType.RATING) {
|
||||||
async (workspaceEntityManager: WorkspaceEntityManager) => {
|
fieldMetadataInput.options = generateRatingOptions();
|
||||||
const viewsRepository = workspaceEntityManager.getRepository('view', {
|
}
|
||||||
shouldBypassPermissionChecks: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const viewFieldsRepository = workspaceEntityManager.getRepository(
|
if (fieldMetadataInput.isLabelSyncedWithName === true) {
|
||||||
'viewField',
|
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) {
|
await this.fieldMetadataRelationService.validateFieldMetadataRelationSpecifics(
|
||||||
const views = await viewsRepository.find({
|
{
|
||||||
where: {
|
fieldMetadataInput: relationFieldMetadataForCreate,
|
||||||
objectMetadataId: createdFieldMetadata.objectMetadataId,
|
fieldMetadataType: fieldMetadataForCreate.type,
|
||||||
},
|
objectMetadataMaps,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (!isEmpty(views)) {
|
return await this.fieldMetadataRelationService.createRelationFieldMetadataItems(
|
||||||
const view = views[0];
|
{
|
||||||
const existingViewFields = await viewFieldsRepository.find({
|
fieldMetadataInput: relationFieldMetadataForCreate,
|
||||||
where: {
|
objectMetadata,
|
||||||
viewId: view.id,
|
fieldMetadataRepository,
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const isVisible =
|
return await this.fieldMetadataMorphRelationService.createMorphRelationFieldMetadataItems(
|
||||||
existingViewFields.length < settings.maxVisibleViewFields;
|
{
|
||||||
|
fieldMetadataForCreate,
|
||||||
const createdFieldIsAlreadyInView = existingViewFields.some(
|
morphRelationsCreationPayload:
|
||||||
(existingViewField) =>
|
fieldMetadataInput.morphRelationsCreationPayload,
|
||||||
existingViewField.fieldMetadataId === createdFieldMetadata.id,
|
objectMetadata,
|
||||||
);
|
fieldMetadataRepository,
|
||||||
|
objectMetadataMaps,
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFieldMetadataItemsByBatch(
|
private async createMigrationActions({
|
||||||
objectMetadataIds: string[],
|
createdFieldMetadataItems,
|
||||||
workspaceId: string,
|
objectMetadataMap,
|
||||||
) {
|
isRemoteCreation,
|
||||||
const fieldMetadataItems = await this.fieldMetadataRepository.find({
|
}: {
|
||||||
where: { objectMetadataId: In(objectMetadataIds), workspaceId },
|
createdFieldMetadataItems: FieldMetadataEntity[];
|
||||||
});
|
objectMetadataMap: Record<string, ObjectMetadataItemWithFieldMaps>;
|
||||||
|
isRemoteCreation: boolean;
|
||||||
|
}): Promise<WorkspaceMigrationTableAction[]> {
|
||||||
|
if (isRemoteCreation) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return objectMetadataIds.map((objectMetadataId) =>
|
const migrationActions: WorkspaceMigrationTableAction[] = [];
|
||||||
fieldMetadataItems.filter(
|
|
||||||
(fieldMetadataItem) =>
|
for (const createdFieldMetadata of createdFieldMetadataItems) {
|
||||||
fieldMetadataItem.objectMetadataId === objectMetadataId,
|
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 { FieldMetadataType } from 'twenty-shared/types';
|
||||||
|
|
||||||
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
|
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 { 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', () => {
|
describe('FieldMetadataValidationService', () => {
|
||||||
let service: FieldMetadataValidationService;
|
let service: FieldMetadataValidationService;
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(async () => {
|
||||||
service = new FieldMetadataValidationService();
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
FieldMetadataValidationService,
|
||||||
|
{
|
||||||
|
provide: FieldMetadataEnumValidationService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<FieldMetadataValidationService>(
|
||||||
|
FieldMetadataValidationService,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should validate NUMBER settings successfully', async () => {
|
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 { 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 { 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 { 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 { 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 { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { BasicColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/basic-column-action.factory';
|
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 { 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 { 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 { 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 { TsVectorColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/ts-vector-column-action.factory';
|
||||||
|
|
||||||
@ -10,4 +11,5 @@ export const workspaceColumnActionFactories = [
|
|||||||
EnumColumnActionFactory,
|
EnumColumnActionFactory,
|
||||||
CompositeColumnActionFactory,
|
CompositeColumnActionFactory,
|
||||||
RelationColumnActionFactory,
|
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 { 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 { 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 { 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 { 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 { TsVectorColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/ts-vector-column-action.factory';
|
||||||
import {
|
import {
|
||||||
@ -38,6 +39,7 @@ export class WorkspaceMigrationFactory {
|
|||||||
private readonly enumColumnActionFactory: EnumColumnActionFactory,
|
private readonly enumColumnActionFactory: EnumColumnActionFactory,
|
||||||
private readonly compositeColumnActionFactory: CompositeColumnActionFactory,
|
private readonly compositeColumnActionFactory: CompositeColumnActionFactory,
|
||||||
private readonly relationColumnActionFactory: RelationColumnActionFactory,
|
private readonly relationColumnActionFactory: RelationColumnActionFactory,
|
||||||
|
private readonly morphRelationColumnActionFactory: MorphRelationColumnActionFactory,
|
||||||
) {
|
) {
|
||||||
this.factoriesMap = new Map<
|
this.factoriesMap = new Map<
|
||||||
FieldMetadataType,
|
FieldMetadataType,
|
||||||
@ -106,6 +108,10 @@ export class WorkspaceMigrationFactory {
|
|||||||
FieldMetadataType.RELATION,
|
FieldMetadataType.RELATION,
|
||||||
{ factory: this.relationColumnActionFactory },
|
{ factory: this.relationColumnActionFactory },
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
FieldMetadataType.MORPH_RELATION,
|
||||||
|
{ factory: this.morphRelationColumnActionFactory },
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -38,12 +38,17 @@ export class EntitySchemaColumnFactory {
|
|||||||
for (const fieldMetadata of fieldMetadataCollection) {
|
for (const fieldMetadata of fieldMetadataCollection) {
|
||||||
const key = fieldMetadata.name;
|
const key = fieldMetadata.name;
|
||||||
|
|
||||||
if (
|
const isRelation =
|
||||||
isFieldMetadataInterfaceOfType(
|
isFieldMetadataInterfaceOfType(
|
||||||
fieldMetadata,
|
fieldMetadata,
|
||||||
FieldMetadataType.RELATION,
|
FieldMetadataType.RELATION,
|
||||||
)
|
) ||
|
||||||
) {
|
isFieldMetadataInterfaceOfType(
|
||||||
|
fieldMetadata,
|
||||||
|
FieldMetadataType.MORPH_RELATION,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isRelation) {
|
||||||
const isManyToOneRelation =
|
const isManyToOneRelation =
|
||||||
fieldMetadata.settings?.relationType === RelationType.MANY_TO_ONE;
|
fieldMetadata.settings?.relationType === RelationType.MANY_TO_ONE;
|
||||||
const joinColumnName = fieldMetadata.settings?.joinColumnName;
|
const joinColumnName = fieldMetadata.settings?.joinColumnName;
|
||||||
|
|||||||
@ -27,12 +27,17 @@ export class EntitySchemaRelationFactory {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const fieldMetadata of fieldMetadataCollection) {
|
for (const fieldMetadata of fieldMetadataCollection) {
|
||||||
if (
|
const isRelation =
|
||||||
!isFieldMetadataInterfaceOfType(
|
isFieldMetadataInterfaceOfType(
|
||||||
fieldMetadata,
|
fieldMetadata,
|
||||||
FieldMetadataType.RELATION,
|
FieldMetadataType.RELATION,
|
||||||
)
|
) ||
|
||||||
) {
|
isFieldMetadataInterfaceOfType(
|
||||||
|
fieldMetadata,
|
||||||
|
FieldMetadataType.MORPH_RELATION,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isRelation) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,9 @@ interface RelationDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function determineSchemaRelationDetails(
|
export async function determineSchemaRelationDetails(
|
||||||
fieldMetadata: FieldMetadataInterface<FieldMetadataType.RELATION>,
|
fieldMetadata: FieldMetadataInterface<
|
||||||
|
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
|
||||||
|
>,
|
||||||
objectMetadataMaps: ObjectMetadataMaps,
|
objectMetadataMaps: ObjectMetadataMaps,
|
||||||
): Promise<RelationDetails> {
|
): Promise<RelationDetails> {
|
||||||
if (!fieldMetadata.settings) {
|
if (!fieldMetadata.settings) {
|
||||||
|
|||||||
@ -50,6 +50,11 @@ export const seedFeatureFlags = async (
|
|||||||
workspaceId: workspaceId,
|
workspaceId: workspaceId,
|
||||||
value: true,
|
value: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: FeatureFlagKey.IS_MORPH_RELATION_ENABLED,
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
])
|
])
|
||||||
.execute();
|
.execute();
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
|
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 { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||||
import {
|
import {
|
||||||
SEED_APPLE_WORKSPACE_ID,
|
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 { 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 { 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 { 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 { FieldMetadataType } from 'twenty-shared/types';
|
||||||
|
|
||||||
|
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
|
||||||
|
|
||||||
describe('createOne', () => {
|
describe('createOne', () => {
|
||||||
describe('FieldMetadataService name/label sync', () => {
|
describe('FieldMetadataService name/label sync', () => {
|
||||||
let createdObjectMetadataId = '';
|
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',
|
SELECT = 'SELECT',
|
||||||
MULTI_SELECT = 'MULTI_SELECT',
|
MULTI_SELECT = 'MULTI_SELECT',
|
||||||
RELATION = 'RELATION',
|
RELATION = 'RELATION',
|
||||||
|
MORPH_RELATION = 'MORPH_RELATION',
|
||||||
POSITION = 'POSITION',
|
POSITION = 'POSITION',
|
||||||
ADDRESS = 'ADDRESS',
|
ADDRESS = 'ADDRESS',
|
||||||
RAW_JSON = 'RAW_JSON',
|
RAW_JSON = 'RAW_JSON',
|
||||||
|
|||||||
Reference in New Issue
Block a user