[BREAKING_CHANGE/GRAPHQL/OBJECT_METADATA_CREATE_ONE] Remove object/fields/view-fields v1 implementation (#15823)

# Introduction
Remove the v2 feature flag for view-field field-metadata and
object-metadata metadata entities

## Some details
- Disabled nestjs-query for object metadata creation and explicitly
calling it
- removed all v1 integration tests files

## Remarks
Not remove v2 referencing in both filenaming right now will handle that
globally later

## Breaking change
Due to object metadata resolver createOne standardization had to rename
the input from `CreateObjectInput` to `CreateOneObjectInput`
This commit is contained in:
Paul Rastoin
2025-11-17 10:55:19 +01:00
committed by GitHub
parent c1dcda6475
commit a39efeb1ab
75 changed files with 425 additions and 15441 deletions
@@ -852,7 +852,7 @@ export type CreateOneFieldMetadataInput = {
};
export type CreateOneObjectInput = {
/** The record to create */
/** The object to create */
object: CreateObjectInput;
};
@@ -800,11 +800,30 @@ export type CreateFieldInput = {
type: FieldMetadataType;
};
export type CreateObjectInput = {
description?: InputMaybe<Scalars['String']>;
icon?: InputMaybe<Scalars['String']>;
isLabelSyncedWithName?: InputMaybe<Scalars['Boolean']>;
isRemote?: InputMaybe<Scalars['Boolean']>;
labelPlural: Scalars['String'];
labelSingular: Scalars['String'];
namePlural: Scalars['String'];
nameSingular: Scalars['String'];
primaryKeyColumnType?: InputMaybe<Scalars['String']>;
primaryKeyFieldMetadataSettings?: InputMaybe<Scalars['JSON']>;
shortcut?: InputMaybe<Scalars['String']>;
};
export type CreateOneFieldMetadataInput = {
/** The record to create */
field: CreateFieldInput;
};
export type CreateOneObjectInput = {
/** The object to create */
object: CreateObjectInput;
};
export type CreatePageLayoutInput = {
name: Scalars['String'];
objectMetadataId?: InputMaybe<Scalars['UUID']>;
@@ -1999,6 +2018,11 @@ export type MutationCreateOneFieldArgs = {
};
export type MutationCreateOneObjectArgs = {
input: CreateOneObjectInput;
};
export type MutationCreateOneRoleArgs = {
createRoleInput: CreateRoleInput;
};
@@ -62,7 +62,7 @@ export class MakeSureDashboardNamingAvailableCommand extends ActiveOrSuspendedWo
`Updating the dashboard object metadata for workspace ${workspaceId}...`,
);
await this.objectMetadataServiceV2.updateOne({
await this.objectMetadataServiceV2.updateOneObject({
workspaceId,
updateObjectInput: {
id: potentialCustomDashboardObjectMetadata.id,
@@ -7,7 +7,7 @@ import { WorkspaceSchemaFactory } from 'src/engine/api/graphql/workspace-schema.
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@@ -23,7 +23,7 @@ describe('WorkspaceSchemaFactory', () => {
useValue: {},
},
{
provide: ObjectMetadataService,
provide: ObjectMetadataServiceV2,
useValue: {},
},
{
@@ -6,7 +6,7 @@ import { DeleteRecordService } from 'src/engine/core-modules/record-crud/service
import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@@ -57,7 +57,7 @@ describe('ToolService', () => {
},
},
{
provide: ObjectMetadataService,
provide: ObjectMetadataServiceV2,
useValue: {
findManyWithinWorkspace: jest.fn().mockResolvedValue([testObject]),
},
@@ -17,7 +17,7 @@ import {
type ToolHints,
type ToolOperation,
} from 'src/engine/metadata-modules/ai-router/types/tool-hints.interface';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
@@ -29,7 +29,7 @@ export class ToolService {
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly objectMetadataService: ObjectMetadataService,
private readonly objectMetadataService: ObjectMetadataServiceV2,
protected readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
private readonly createRecordService: CreateRecordService,
private readonly updateRecordService: UpdateRecordService,
@@ -219,7 +219,7 @@ export class ApplicationSyncService {
);
for (const fieldToDelete of fieldsToDelete) {
await this.fieldMetadataServiceV2.updateOne({
await this.fieldMetadataServiceV2.updateOneField({
updateFieldInput: {
id: fieldToDelete.id,
isActive: false,
@@ -256,7 +256,7 @@ export class ApplicationSyncService {
isNullable: fieldToSync.isNullable ?? true,
};
await this.fieldMetadataServiceV2.updateOne({
await this.fieldMetadataServiceV2.updateOneField({
updateFieldInput,
workspaceId,
});
@@ -281,7 +281,7 @@ export class ApplicationSyncService {
workspaceId,
};
await this.fieldMetadataServiceV2.createOne({
await this.fieldMetadataServiceV2.createOneField({
createFieldInput,
workspaceId,
});
@@ -341,7 +341,7 @@ export class ApplicationSyncService {
);
for (const objectToDelete of objectsToDelete) {
await this.objectMetadataServiceV2.deleteOne({
await this.objectMetadataServiceV2.deleteOneObject({
deleteObjectInput: { id: objectToDelete.id },
workspaceId,
isSystemBuild: true,
@@ -372,7 +372,7 @@ export class ApplicationSyncService {
},
};
await this.objectMetadataServiceV2.updateOne({
await this.objectMetadataServiceV2.updateOneObject({
updateObjectInput,
workspaceId,
});
@@ -404,7 +404,7 @@ export class ApplicationSyncService {
applicationId,
};
const createdObject = await this.objectMetadataServiceV2.createOne({
const createdObject = await this.objectMetadataServiceV2.createOneObject({
createObjectInput,
workspaceId,
});
@@ -4,7 +4,7 @@ import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { OpenApiService } from 'src/engine/core-modules/open-api/open-api.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
describe('OpenApiService', () => {
let service: OpenApiService;
@@ -18,7 +18,7 @@ describe('OpenApiService', () => {
useValue: {},
},
{
provide: ObjectMetadataService,
provide: ObjectMetadataServiceV2,
useValue: {},
},
{
@@ -47,8 +47,8 @@ import {
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects';
import { shouldExcludeFromWorkspaceApi } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/should-exclude-from-workspace-api.util';
import { getServerUrl } from 'src/utils/get-server-url';
@@ -58,7 +58,7 @@ export class OpenApiService {
constructor(
private readonly accessTokenService: AccessTokenService,
private readonly twentyConfigService: TwentyConfigService,
private readonly objectMetadataService: ObjectMetadataService,
private readonly objectMetadataService: ObjectMetadataServiceV2,
private readonly featureFlagService: FeatureFlagService,
) {}
@@ -1,215 +0,0 @@
import { Transform } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsEnum,
IsNumber,
IsString,
} from 'class-validator';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum';
import { applyBasicValidators } from 'src/engine/core-modules/twenty-config/utils/apply-basic-validators.util';
import { configTransformers } from 'src/engine/core-modules/twenty-config/utils/config-transformers.util';
jest.mock('class-transformer', () => ({
Transform: jest.fn(),
}));
jest.mock('class-validator', () => ({
IsBoolean: jest.fn().mockReturnValue(jest.fn()),
IsNumber: jest.fn().mockReturnValue(jest.fn()),
IsString: jest.fn().mockReturnValue(jest.fn()),
IsEnum: jest.fn().mockReturnValue(jest.fn()),
IsArray: jest.fn().mockReturnValue(jest.fn()),
}));
jest.mock(
'src/engine/core-modules/twenty-config/utils/config-transformers.util',
() => ({
configTransformers: {
boolean: jest.fn(),
number: jest.fn(),
},
}),
);
describe('applyBasicValidators', () => {
const mockTarget = {};
const mockPropertyKey = 'testProperty';
beforeEach(() => {
jest.clearAllMocks();
});
describe('boolean type', () => {
it('should apply boolean transformers and validators', () => {
let capturedTransformFn;
(Transform as jest.Mock).mockImplementation((transformFn) => {
capturedTransformFn = transformFn;
return jest.fn();
});
applyBasicValidators(
ConfigVariableType.BOOLEAN,
mockTarget,
mockPropertyKey,
);
expect(Transform).toHaveBeenCalled();
expect(IsBoolean).toHaveBeenCalled();
const transformFn = capturedTransformFn;
const mockTransformParams = { value: 'true' };
(configTransformers.boolean as jest.Mock).mockReturnValueOnce(true);
// @ts-expect-error legacy noImplicitAny
const result1 = transformFn(mockTransformParams);
expect(configTransformers.boolean).toHaveBeenCalledWith('true');
expect(result1).toBe(true);
(configTransformers.boolean as jest.Mock).mockReturnValueOnce(undefined);
// @ts-expect-error legacy noImplicitAny
const result2 = transformFn(mockTransformParams);
expect(result2).toBe('true');
});
});
describe('number type', () => {
it('should apply number transformers and validators', () => {
let capturedTransformFn;
(Transform as jest.Mock).mockImplementation((transformFn) => {
capturedTransformFn = transformFn;
return jest.fn();
});
applyBasicValidators(
ConfigVariableType.NUMBER,
mockTarget,
mockPropertyKey,
);
expect(Transform).toHaveBeenCalled();
expect(IsNumber).toHaveBeenCalled();
const transformFn = capturedTransformFn;
const mockTransformParams = { value: '42' };
(configTransformers.number as jest.Mock).mockReturnValueOnce(42);
// @ts-expect-error legacy noImplicitAny
const result1 = transformFn(mockTransformParams);
expect(configTransformers.number).toHaveBeenCalledWith('42');
expect(result1).toBe(42);
(configTransformers.number as jest.Mock).mockReturnValueOnce(undefined);
// @ts-expect-error legacy noImplicitAny
const result2 = transformFn(mockTransformParams);
expect(result2).toBe('42');
});
});
describe('string type', () => {
it('should apply string validator', () => {
applyBasicValidators(
ConfigVariableType.STRING,
mockTarget,
mockPropertyKey,
);
expect(IsString).toHaveBeenCalled();
expect(Transform).not.toHaveBeenCalled();
});
});
describe('enum type', () => {
it('should apply enum validator with string array options', () => {
const enumOptions = ['option1', 'option2', 'option3'];
applyBasicValidators(
ConfigVariableType.ENUM,
mockTarget,
mockPropertyKey,
enumOptions,
);
expect(IsEnum).toHaveBeenCalledWith(enumOptions);
expect(Transform).not.toHaveBeenCalled();
});
it('should apply enum validator with enum object options', () => {
enum TestEnum {
Option1 = 'value1',
Option2 = 'value2',
Option3 = 'value3',
}
jest.mock(
'src/engine/core-modules/twenty-config/utils/type-transformers.registry',
() => ({
typeTransformers: {
enum: {
getValidators: jest.fn().mockImplementation((options) => {
if (options && Object.keys(options).length > 0) {
return [IsEnum(options)];
}
return [];
}),
getTransformers: jest.fn().mockReturnValue([]),
},
},
}),
{ virtual: true },
);
applyBasicValidators(
ConfigVariableType.ENUM,
mockTarget,
mockPropertyKey,
TestEnum,
);
expect(IsEnum).toHaveBeenCalledWith(TestEnum);
expect(Transform).not.toHaveBeenCalled();
});
it('should not apply enum validator without options', () => {
applyBasicValidators(
ConfigVariableType.ENUM,
mockTarget,
mockPropertyKey,
);
expect(IsEnum).not.toHaveBeenCalled();
expect(Transform).not.toHaveBeenCalled();
});
});
describe('array type', () => {
it('should apply array validator', () => {
applyBasicValidators(
ConfigVariableType.ARRAY,
mockTarget,
mockPropertyKey,
);
expect(IsArray).toHaveBeenCalled();
expect(Transform).not.toHaveBeenCalled();
});
});
describe('unsupported type', () => {
it('should throw error for unsupported types', () => {
expect(() => {
applyBasicValidators('unsupported' as any, mockTarget, mockPropertyKey);
}).toThrow('Unsupported config variable type: unsupported');
});
});
});
@@ -15,9 +15,9 @@ import { AI_TELEMETRY_CONFIG } from 'src/engine/core-modules/ai/constants/ai-tel
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/agent/utils/is-workflow-related-object.util';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { DATA_MANIPULATOR_AGENT } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/data-manipulator-agent';
import { HELPER_AGENT } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/helper-agent';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { type ToolHints } from './types/tool-hints.interface';
@@ -47,7 +47,7 @@ export class AiRouterService {
@InjectRepository(AgentEntity)
private readonly agentRepository: Repository<AgentEntity>,
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly objectMetadataService: ObjectMetadataService,
private readonly objectMetadataService: ObjectMetadataServiceV2,
) {}
// Routes a user message to the most appropriate agent
@@ -14,11 +14,9 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { FieldMetadataResolver } from 'src/engine/metadata-modules/field-metadata/field-metadata.resolver';
import { BeforeUpdateOneField } from 'src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook';
import { FieldMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/field-metadata/interceptors/field-metadata-graphql-api-exception.interceptor';
import { FieldMetadataEnumValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service';
import { FieldMetadataMorphRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-morph-relation.service';
import { FieldMetadataRelatedRecordsService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service';
import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service';
import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service';
import { FieldMetadataServiceV2 } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2';
@@ -45,7 +43,6 @@ import { FieldMetadataEntity } from './field-metadata.entity';
import { CreateFieldInput } from './dtos/create-field.input';
import { UpdateFieldInput } from './dtos/update-field.input';
import { FieldMetadataService } from './services/field-metadata.service';
@Module({
imports: [
@@ -77,9 +74,7 @@ import { FieldMetadataService } from './services/field-metadata.service';
],
services: [
IsFieldMetadataDefaultValue,
FieldMetadataService,
FieldMetadataServiceV2,
FieldMetadataRelatedRecordsService,
FieldMetadataMorphRelationService,
FieldMetadataRelationService,
FieldMetadataValidationService,
@@ -91,20 +86,16 @@ import { FieldMetadataService } from './services/field-metadata.service';
DTOClass: FieldMetadataDTO,
CreateDTOClass: CreateFieldInput,
UpdateDTOClass: UpdateFieldInput,
ServiceClass: FieldMetadataService,
ServiceClass: FieldMetadataServiceV2,
pagingStrategy: PagingStrategies.CURSOR,
read: {
defaultSort: [{ field: 'id', direction: SortDirection.DESC }],
},
create: {
// Manually created because of the async validation
one: { disabled: true },
many: { disabled: true },
disabled: true,
},
update: {
// Manually created because of the async validation
one: { disabled: true },
many: { disabled: true },
disabled: true,
},
delete: { disabled: true },
guards: [WorkspaceAuthGuard],
@@ -116,22 +107,17 @@ import { FieldMetadataService } from './services/field-metadata.service';
providers: [
IsFieldMetadataDefaultValue,
IsFieldMetadataOptions,
FieldMetadataService,
FieldMetadataServiceV2,
FieldMetadataRelationService,
FieldMetadataRelatedRecordsService,
FieldMetadataMorphRelationService,
FieldMetadataValidationService,
FieldMetadataEnumValidationService,
FieldMetadataResolver,
BeforeUpdateOneField,
],
exports: [
FieldMetadataService,
FieldMetadataServiceV2,
FieldMetadataRelationService,
FieldMetadataMorphRelationService,
FieldMetadataRelatedRecordsService,
FieldMetadataEnumValidationService,
FieldMetadataValidationService,
],
@@ -10,14 +10,9 @@ import {
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import {
ForbiddenError,
ValidationError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { ForbiddenError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -29,14 +24,10 @@ import { CreateOneFieldMetadataInput } from 'src/engine/metadata-modules/field-m
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 { RelationDTO } from 'src/engine/metadata-modules/field-metadata/dtos/relation.dto';
import {
UpdateOneFieldMetadataInput,
type UpdateFieldInput,
} from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
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 { UpdateOneFieldMetadataInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
import { FieldMetadataServiceV2 } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2';
import { fieldMetadataGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/field-metadata/utils/field-metadata-graphql-api-exception-handler.util';
import { fromFlatFieldMetadataToFieldMetadataDto } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-flat-field-metadata-to-field-metadata-dto.util';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
@@ -49,9 +40,6 @@ import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-module
)
export class FieldMetadataResolver {
constructor(
private readonly fieldMetadataService: FieldMetadataService,
private readonly beforeUpdateOneField: BeforeUpdateOneField<UpdateFieldInput>,
private readonly featureFlagService: FeatureFlagService,
private readonly fieldMetadataServiceV2: FieldMetadataServiceV2,
private readonly i18nService: I18nService,
) {}
@@ -64,10 +52,13 @@ export class FieldMetadataResolver {
@Context() context: I18nContext,
) {
try {
return await this.fieldMetadataService.createOne({
...input.field,
workspaceId,
});
const flatFieldMetadata =
await this.fieldMetadataServiceV2.createOneField({
createFieldInput: input.field,
workspaceId,
});
return fromFlatFieldMetadataToFieldMetadataDto(flatFieldMetadata);
} catch (error) {
return fieldMetadataGraphqlApiExceptionHandler(
error,
@@ -83,29 +74,14 @@ export class FieldMetadataResolver {
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Context() context: I18nContext,
) {
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
workspaceId,
);
try {
if (isWorkspaceMigrationV2Enabled) {
return await this.fieldMetadataServiceV2.updateOne({
const flatFieldMetadata =
await this.fieldMetadataServiceV2.updateOneField({
updateFieldInput: { ...input.update, id: input.id },
workspaceId,
});
}
const updatedInput = (await this.beforeUpdateOneField.run(input, {
workspaceId,
locale: context.req.locale,
})) as UpdateOneFieldMetadataInput;
return await this.fieldMetadataService.updateOne(updatedInput.id, {
...updatedInput.update,
workspaceId,
});
return fromFlatFieldMetadataToFieldMetadataDto(flatFieldMetadata);
} catch (error) {
fieldMetadataGraphqlApiExceptionHandler(
error,
@@ -117,7 +93,7 @@ export class FieldMetadataResolver {
@UseGuards(SettingsPermissionGuard(PermissionFlagType.DATA_MODEL))
@Mutation(() => FieldMetadataDTO)
async deleteOneField(
@Args('input') input: DeleteOneFieldInput,
@Args('input') deleteOneFieldInput: DeleteOneFieldInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Context() context: I18nContext,
) {
@@ -126,46 +102,13 @@ export class FieldMetadataResolver {
}
try {
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
workspaceId,
);
if (isWorkspaceMigrationV2Enabled) {
return await this.fieldMetadataServiceV2.deleteOneField({
deleteOneFieldInput: input,
const flatFieldMetadata =
await this.fieldMetadataServiceV2.deleteOneField({
deleteOneFieldInput,
workspaceId,
});
}
} catch (error) {
fieldMetadataGraphqlApiExceptionHandler(
error,
this.i18nService.getI18nInstance(context.req.locale),
);
}
const fieldMetadata =
await this.fieldMetadataService.findOneWithinWorkspace(workspaceId, {
where: {
id: input.id.toString(),
},
});
if (!isDefined(fieldMetadata)) {
throw new ValidationError('Field does not exist');
}
if (!fieldMetadata.isCustom) {
throw new ValidationError("Standard Fields can't be deleted");
}
if (fieldMetadata.isActive) {
throw new ValidationError("Active fields can't be deleted");
}
try {
return await this.fieldMetadataService.deleteOneField(input, workspaceId);
return fromFlatFieldMetadataToFieldMetadataDto(flatFieldMetadata);
} catch (error) {
fieldMetadataGraphqlApiExceptionHandler(
error,
@@ -177,7 +120,6 @@ export class FieldMetadataResolver {
@ResolveField(() => RelationDTO, { nullable: true })
async relation(
@AuthWorkspace() workspace: WorkspaceEntity,
@Parent()
{
id: fieldMetadataId,
@@ -1,684 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { type UpdateOneInputType } from '@ptc-org/nestjs-query-graphql';
import { FieldMetadataType } from 'twenty-shared/types';
import {
ForbiddenError,
ValidationError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
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 { type IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { getMockFieldMetadataEntity } from 'src/utils/__test__/get-field-metadata-entity.mock';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
// Create a type that omits id and workspaceId from UpdateFieldInput
type UpdateFieldInputForTest = Omit<UpdateFieldInput, 'id' | 'workspaceId'>;
describe('BeforeUpdateOneField', () => {
let hook: BeforeUpdateOneField<UpdateFieldInput>;
let fieldMetadataService: FieldMetadataService;
let objectMetadataService: ObjectMetadataService;
const mockWorkspaceId = 'workspace-id';
const mockFieldId = 'field-id';
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
BeforeUpdateOneField,
{
provide: FieldMetadataService,
useValue: {
findOneWithinWorkspace: jest.fn(),
},
},
{
provide: ObjectMetadataService,
useValue: {
findOneWithinWorkspace: jest.fn(),
},
},
{
provide: I18nService,
useValue: {
getI18nInstance: jest.fn().mockReturnValue({
_: jest.fn().mockReturnValue('mocked-translation'),
}),
},
},
],
}).compile();
hook =
module.get<BeforeUpdateOneField<UpdateFieldInput>>(BeforeUpdateOneField);
fieldMetadataService =
module.get<FieldMetadataService>(FieldMetadataService);
objectMetadataService = module.get<ObjectMetadataService>(
ObjectMetadataService,
);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should throw ForbiddenError if workspaceId is not provided', async () => {
const instance: UpdateOneInputType<UpdateFieldInputForTest> = {
id: mockFieldId,
update: {
isActive: true,
},
};
await expect(
hook.run(instance as UpdateOneInputType<UpdateFieldInput>, {
workspaceId: '',
locale: undefined,
}),
).rejects.toThrow(ForbiddenError);
});
it('should throw ValidationError if field does not exist', async () => {
const instance: UpdateOneInputType<UpdateFieldInputForTest> = {
id: mockFieldId,
update: {
isActive: true,
},
};
jest
.spyOn(fieldMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(null as unknown as FieldMetadataEntity);
await expect(
hook.run(instance as UpdateOneInputType<UpdateFieldInput>, {
workspaceId: mockWorkspaceId,
locale: undefined,
}),
).rejects.toThrow(ValidationError);
});
it('should not affect custom fields', async () => {
const instance: UpdateOneInputType<UpdateFieldInputForTest> = {
id: mockFieldId,
update: {
isActive: true,
name: 'newName', // Custom fields can update all properties
},
};
const mockField = getMockFieldMetadataEntity({
workspaceId: mockWorkspaceId,
objectMetadataId: '20202020-0000-0000-0000-000000000002',
id: mockFieldId,
type: FieldMetadataType.TEXT,
name: 'oldName',
label: 'Old Name',
isNullable: true,
isCustom: true,
isLabelSyncedWithName: true,
createdAt: new Date(),
updatedAt: new Date(),
});
jest
.spyOn(fieldMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockField);
const result = await hook.run(
instance as UpdateOneInputType<UpdateFieldInput>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
expect(result).toEqual(instance);
});
it('should throw ValidationError when trying to update non-updatable fields on standard fields', async () => {
const instance: UpdateOneInputType<UpdateFieldInputForTest> = {
id: mockFieldId,
update: {
name: 'newName', // Not allowed for standard fields
},
};
const mockField = getMockFieldMetadataEntity({
workspaceId: mockWorkspaceId,
objectMetadataId: '20202020-0000-0000-0000-000000000002',
id: mockFieldId,
type: FieldMetadataType.TEXT,
name: 'oldName',
label: 'Old Name',
isNullable: true,
isCustom: false,
isLabelSyncedWithName: true,
createdAt: new Date(),
updatedAt: new Date(),
});
jest
.spyOn(fieldMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockField);
await expect(
hook.run(instance as UpdateOneInputType<UpdateFieldInput>, {
workspaceId: mockWorkspaceId,
locale: undefined,
}),
).rejects.toThrow(ValidationError);
});
it('should handle isActive updates for standard fields', async () => {
const instance: UpdateOneInputType<UpdateFieldInputForTest> = {
id: mockFieldId,
update: {
isActive: false,
},
};
const mockField = getMockFieldMetadataEntity({
workspaceId: mockWorkspaceId,
objectMetadataId: '20202020-0000-0000-0000-000000000002',
id: mockFieldId,
type: FieldMetadataType.TEXT,
name: 'oldName',
label: 'Old Name',
isNullable: true,
isCustom: false,
isActive: true,
isLabelSyncedWithName: true,
createdAt: new Date(),
updatedAt: new Date(),
});
jest
.spyOn(fieldMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockField);
const result = await hook.run(
instance as UpdateOneInputType<UpdateFieldInput>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
const expectedResult = {
id: mockFieldId,
update: {
isActive: false,
standardOverrides: {},
},
};
expect(result).toEqual(expectedResult);
});
it('should handle isLabelSyncedWithName updates for standard fields', async () => {
const instance: UpdateOneInputType<UpdateFieldInputForTest> = {
id: mockFieldId,
update: {
isLabelSyncedWithName: true,
},
};
const mockField = getMockFieldMetadataEntity({
workspaceId: mockWorkspaceId,
objectMetadataId: '20202020-0000-0000-0000-000000000002',
id: mockFieldId,
type: FieldMetadataType.TEXT,
name: 'oldName',
label: 'Old Name',
isNullable: true,
isCustom: false,
isLabelSyncedWithName: false,
standardOverrides: {
label: 'Custom Label',
},
createdAt: new Date(),
updatedAt: new Date(),
});
jest
.spyOn(fieldMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockField);
const result = await hook.run(
instance as UpdateOneInputType<UpdateFieldInput>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
const expectedResult = {
id: mockFieldId,
update: {
isLabelSyncedWithName: true,
standardOverrides: {
label: null,
},
},
};
expect(result).toEqual(expectedResult);
});
it('should handle label override when not synced with name', async () => {
const instance: UpdateOneInputType<UpdateFieldInputForTest> = {
id: mockFieldId,
update: {
label: 'New Label',
},
};
const mockField = getMockFieldMetadataEntity({
workspaceId: mockWorkspaceId,
objectMetadataId: '20202020-0000-0000-0000-000000000002',
id: mockFieldId,
type: FieldMetadataType.TEXT,
name: 'oldName',
label: 'Default Label',
isNullable: true,
isCustom: false,
isLabelSyncedWithName: false,
createdAt: new Date(),
updatedAt: new Date(),
});
jest
.spyOn(fieldMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockField);
const result = await hook.run(
instance as UpdateOneInputType<UpdateFieldInput>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
const expectedResult = {
id: mockFieldId,
update: {
standardOverrides: {
label: 'New Label',
},
},
};
expect(result).toEqual(expectedResult);
});
it('should reset label override when it matches the original value', async () => {
const instance: UpdateOneInputType<UpdateFieldInputForTest> = {
id: mockFieldId,
update: {
label: 'Default Label',
},
};
const mockField: Partial<FieldMetadataEntity> = {
id: mockFieldId,
isCustom: false,
isLabelSyncedWithName: false,
label: 'Default Label',
standardOverrides: {
label: 'Custom Label',
},
};
jest
.spyOn(fieldMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockField as FieldMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateFieldInput>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
const expectedResult = {
id: mockFieldId,
update: {
standardOverrides: {
label: null,
},
},
};
expect(result).toEqual(expectedResult);
});
it('should handle description override', async () => {
const instance: UpdateOneInputType<UpdateFieldInputForTest> = {
id: mockFieldId,
update: {
description: 'New Description',
},
};
const mockField: Partial<FieldMetadataEntity> = {
id: mockFieldId,
isCustom: false,
description: 'Default Description',
};
jest
.spyOn(fieldMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockField as FieldMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateFieldInput>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
const expectedResult = {
id: mockFieldId,
update: {
standardOverrides: {
description: 'New Description',
},
},
};
expect(result).toEqual(expectedResult);
});
it('should handle icon override', async () => {
const instance: UpdateOneInputType<UpdateFieldInputForTest> = {
id: mockFieldId,
update: {
icon: 'IconStar',
},
};
const mockField: Partial<FieldMetadataEntity> = {
id: mockFieldId,
isCustom: false,
icon: 'IconCircle',
};
jest
.spyOn(fieldMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockField as FieldMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateFieldInput>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
const expectedResult = {
id: mockFieldId,
update: {
standardOverrides: {
icon: 'IconStar',
},
},
};
expect(result).toEqual(expectedResult);
});
it('should handle locale-specific translations', async () => {
const instance: UpdateOneInputType<UpdateFieldInputForTest> = {
id: mockFieldId,
update: {
label: 'Étiquette',
description: 'Description en français',
},
};
const mockField: Partial<FieldMetadataEntity> = {
id: mockFieldId,
isCustom: false,
isLabelSyncedWithName: false,
label: 'Label',
description: 'Description',
};
jest
.spyOn(fieldMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockField as FieldMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateFieldInput>,
{
workspaceId: mockWorkspaceId,
locale: 'fr-FR',
},
);
// The expected result returned by the hook
const expectedResult = {
id: mockFieldId,
update: {
standardOverrides: {
translations: {
'fr-FR': {
label: 'Étiquette',
description: 'Description en français',
},
},
},
},
};
expect(result).toEqual(expectedResult);
});
it('should reset locale-specific translations when they match translated defaults', async () => {
const translatedLabel = 'mocked-translation';
const instance: UpdateOneInputType<UpdateFieldInputForTest> = {
id: mockFieldId,
update: {
label: translatedLabel,
},
};
const mockField: Partial<FieldMetadataEntity> = {
id: mockFieldId,
isCustom: false,
isLabelSyncedWithName: false,
label: 'Label',
standardOverrides: {
translations: {
'fr-FR': {
label: 'Ancienne Étiquette',
},
},
},
};
jest
.spyOn(fieldMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockField as FieldMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateFieldInput>,
{
workspaceId: mockWorkspaceId,
locale: 'fr-FR',
},
);
const expectedResult = {
id: mockFieldId,
update: {
standardOverrides: {
translations: {
'fr-FR': {
label: null,
},
},
},
},
};
expect(result).toEqual(expectedResult);
});
it('should handle multiple updates together', async () => {
const instance: UpdateOneInputType<UpdateFieldInputForTest> = {
id: mockFieldId,
update: {
isActive: false,
isLabelSyncedWithName: false,
label: 'New Label',
icon: 'IconStar',
description: 'New Description',
},
};
const mockField: Partial<FieldMetadataEntity> = {
id: mockFieldId,
isCustom: false,
isActive: true,
isLabelSyncedWithName: false,
label: 'Default Label',
icon: 'IconCircle',
description: 'Default Description',
};
jest
.spyOn(fieldMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockField as FieldMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateFieldInput>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
const expectedResult = {
id: mockFieldId,
update: {
isActive: false,
isLabelSyncedWithName: false,
standardOverrides: {
label: 'New Label',
icon: 'IconStar',
description: 'New Description',
},
},
};
expect(result).toEqual(expectedResult);
});
it('should throw ValidationError if isUnique is updated for a standard field with a standard unique index', async () => {
const mockField: Partial<FieldMetadataEntity> = {
id: mockFieldId,
isCustom: false,
isUnique: true,
};
jest
.spyOn(fieldMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockField as FieldMetadataEntity);
const mockObjectMetadata: Partial<ObjectMetadataEntity> = {
id: mockWorkspaceId,
indexMetadatas: [
{
isUnique: true,
isCustom: false,
indexFieldMetadatas: [{ fieldMetadataId: mockFieldId }],
} as IndexMetadataEntity,
],
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObjectMetadata as ObjectMetadataEntity);
const instance: UpdateOneInputType<UpdateFieldInputForTest> = {
id: mockFieldId,
update: {
isUnique: false,
},
};
await expect(
hook.run(instance as UpdateOneInputType<UpdateFieldInput>, {
workspaceId: mockWorkspaceId,
locale: undefined,
}),
).rejects.toThrow(ValidationError);
});
it('should not throw ValidationError if isUnique is updated for a standard field without a standard unique index', async () => {
const mockField: Partial<FieldMetadataEntity> = {
id: mockFieldId,
isCustom: false,
isUnique: true,
};
jest
.spyOn(fieldMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockField as FieldMetadataEntity);
const mockObjectMetadata: Partial<ObjectMetadataEntity> = {
id: mockWorkspaceId,
indexMetadatas: [
{
isUnique: false,
isCustom: false,
indexFieldMetadatas: [{ fieldMetadataId: mockFieldId }],
} as IndexMetadataEntity,
],
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObjectMetadata as ObjectMetadataEntity);
const instance: UpdateOneInputType<UpdateFieldInputForTest> = {
id: mockFieldId,
update: {
isUnique: false,
},
};
jest
.spyOn(fieldMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockField as FieldMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateFieldInput>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
const expectedResult = {
id: mockFieldId,
update: {
isUnique: false,
standardOverrides: {},
},
};
expect(result).toEqual(expectedResult);
});
});
@@ -1,448 +0,0 @@
import { Injectable } from '@nestjs/common';
import {
type BeforeUpdateOneHook,
type UpdateOneInputType,
} from '@ptc-org/nestjs-query-graphql';
import { SOURCE_LOCALE, type APP_LOCALES } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import {
ForbiddenError,
ValidationError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { type FieldStandardOverridesDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto';
import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
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';
interface StandardFieldUpdate extends Partial<UpdateFieldInput> {
standardOverrides?: FieldStandardOverridesDTO;
}
@Injectable()
export class BeforeUpdateOneField<T extends UpdateFieldInput>
implements BeforeUpdateOneHook<T>
{
constructor(
readonly fieldMetadataService: FieldMetadataService,
readonly objectMetadataService: ObjectMetadataService,
private readonly i18nService: I18nService,
) {}
async run(
instance: UpdateOneInputType<T>,
{
workspaceId,
locale,
}: {
workspaceId: string;
locale: keyof typeof APP_LOCALES | undefined;
},
): Promise<UpdateOneInputType<T>> {
if (!workspaceId) {
throw new ForbiddenError('Could not retrieve workspace ID');
}
const fieldMetadata = await this.getFieldMetadata(instance, workspaceId);
if (!fieldMetadata.isCustom) {
return await this.handleStandardFieldUpdate(
instance,
fieldMetadata,
locale,
);
}
return instance;
}
private async getFieldMetadata(
instance: UpdateOneInputType<T>,
workspaceId: string,
) {
const fieldMetadata =
await this.fieldMetadataService.findOneWithinWorkspace(workspaceId, {
where: {
id: instance.id.toString(),
},
});
if (!fieldMetadata) {
throw new ValidationError('Field does not exist');
}
return fieldMetadata;
}
private async handleStandardFieldUpdate(
instance: UpdateOneInputType<T>,
fieldMetadata: FieldMetadataEntity,
locale?: keyof typeof APP_LOCALES,
): Promise<UpdateOneInputType<T>> {
const update: StandardFieldUpdate = {};
const updatableFields = [
'isActive',
'isLabelSyncedWithName',
'options',
'settings',
'defaultValue',
'isUnique',
];
const overridableFields = ['label', 'icon', 'description'];
const nonUpdatableFields = Object.keys(instance.update).filter(
(key) =>
!updatableFields.includes(key) && !overridableFields.includes(key),
);
if (nonUpdatableFields.length > 0) {
throw new ValidationError(
`Only isActive, isLabelSyncedWithName, label, icon, description, isUnique and defaultValue fields can be updated for standard fields. Invalid fields: ${nonUpdatableFields.join(', ')}`,
);
}
// Preserve existing overrides
update.standardOverrides = fieldMetadata.standardOverrides
? { ...fieldMetadata.standardOverrides }
: {};
this.handleActiveField(instance, update);
this.handleLabelSyncedWithNameField(instance, update);
this.handleStandardOverrides(instance, fieldMetadata, update, locale);
this.handleOptionsField(instance, update);
this.handleSettingsField(instance, update);
this.handleDefaultValueField(instance, update);
await this.handleIsUniqueField(instance, fieldMetadata, update);
return {
id: instance.id,
update: update as T,
};
}
private async handleIsUniqueField(
instance: UpdateOneInputType<T>,
fieldMetadata: FieldMetadataEntity,
update: StandardFieldUpdate,
) {
if (!isDefined(instance.update.isUnique)) {
return;
}
const objectMetadata =
await this.objectMetadataService.findOneWithinWorkspace(
fieldMetadata.workspaceId,
{
where: {
id: fieldMetadata.objectMetadataId,
},
},
);
const hasStandardUniqueIndex = objectMetadata?.indexMetadatas.some(
(index) =>
index.isUnique &&
!index.isCustom &&
index.indexFieldMetadatas?.some(
(field) => field.fieldMetadataId === fieldMetadata.id,
),
);
if (hasStandardUniqueIndex && instance.update.isUnique === false) {
throw new ValidationError(
'Unique standard field cannot be updated to non-unique.',
);
}
update.isUnique = instance.update.isUnique;
}
private handleDefaultValueField(
instance: UpdateOneInputType<T>,
update: StandardFieldUpdate,
): void {
if (!isDefined(instance.update.defaultValue)) {
return;
}
update.defaultValue = instance.update.defaultValue;
}
private handleOptionsField(
instance: UpdateOneInputType<T>,
update: StandardFieldUpdate,
): void {
if (!isDefined(instance.update.options)) {
return;
}
update.options = instance.update.options;
}
private handleSettingsField(
instance: UpdateOneInputType<T>,
update: StandardFieldUpdate,
): void {
if (!isDefined(instance.update.settings)) {
return;
}
update.settings = instance.update.settings;
}
private handleActiveField(
instance: UpdateOneInputType<T>,
update: StandardFieldUpdate,
): void {
if (!isDefined(instance.update.isActive)) {
return;
}
update.isActive = instance.update.isActive;
}
private handleLabelSyncedWithNameField(
instance: UpdateOneInputType<T>,
update: StandardFieldUpdate,
): void {
if (!isDefined(instance.update.isLabelSyncedWithName)) {
return;
}
update.isLabelSyncedWithName = instance.update.isLabelSyncedWithName;
if (instance.update.isLabelSyncedWithName === false) {
return;
}
update.standardOverrides = update.standardOverrides || {};
update.standardOverrides.label = null;
}
private handleStandardOverrides(
instance: UpdateOneInputType<T>,
fieldMetadata: FieldMetadataEntity,
update: StandardFieldUpdate,
locale?: keyof typeof APP_LOCALES,
): void {
const hasStandardOverrides =
isDefined(instance.update.description) ||
isDefined(instance.update.icon) ||
isDefined(instance.update.label);
if (!hasStandardOverrides) {
return;
}
update.standardOverrides = update.standardOverrides || {};
this.handleDescriptionOverride(instance, fieldMetadata, update, locale);
this.handleIconOverride(instance, fieldMetadata, update);
this.handleLabelOverride(instance, fieldMetadata, update, locale);
}
private resetOverrideIfMatchesOriginal({
update,
overrideKey,
newValue,
originalValue,
locale,
}: {
update: StandardFieldUpdate;
overrideKey: 'label' | 'description' | 'icon';
newValue: string;
originalValue: string | null;
locale?: keyof typeof APP_LOCALES | undefined;
}): boolean {
// Handle localized overrides
if (locale && locale !== SOURCE_LOCALE) {
const wasOverrideReset = this.resetLocalizedOverride(
update,
overrideKey,
newValue,
originalValue,
locale,
);
return wasOverrideReset;
}
// Handle default language overrides
const wasOverrideReset = this.resetDefaultOverride(
update,
overrideKey,
newValue,
originalValue,
);
return wasOverrideReset;
}
private resetLocalizedOverride(
update: StandardFieldUpdate,
overrideKey: 'label' | 'description' | 'icon',
newValue: string,
originalValue: string | null,
locale: keyof typeof APP_LOCALES,
): boolean {
const messageId = generateMessageId(originalValue ?? '');
const i18n = this.i18nService.getI18nInstance(locale);
const translatedMessage = i18n._(messageId);
if (newValue !== translatedMessage) {
return false;
}
// Initialize the translations structure if needed
update.standardOverrides = update.standardOverrides || {};
update.standardOverrides.translations =
update.standardOverrides.translations || {};
update.standardOverrides.translations[locale] =
update.standardOverrides.translations[locale] || {};
// Reset the override by setting it to null
const localeTranslations = update.standardOverrides.translations[locale];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(localeTranslations as Record<string, any>)[overrideKey] = null;
return true;
}
private resetDefaultOverride(
update: StandardFieldUpdate,
overrideKey: 'label' | 'description' | 'icon',
newValue: string,
originalValue: string | null,
): boolean {
if (newValue !== originalValue) {
return false;
}
update.standardOverrides = update.standardOverrides || {};
update.standardOverrides[overrideKey] = null;
return true;
}
private setOverrideValue(
update: StandardFieldUpdate,
overrideKey: 'label' | 'description' | 'icon',
value: string,
locale?: keyof typeof APP_LOCALES,
): void {
update.standardOverrides = update.standardOverrides || {};
const shouldSetLocalizedOverride =
locale && locale !== SOURCE_LOCALE && overrideKey !== 'icon';
if (!shouldSetLocalizedOverride) {
update.standardOverrides[overrideKey] = value;
return;
}
this.setLocalizedOverrideValue(update, overrideKey, value, locale);
}
private setLocalizedOverrideValue(
update: StandardFieldUpdate,
overrideKey: 'label' | 'description' | 'icon',
value: string,
locale: keyof typeof APP_LOCALES,
): void {
update.standardOverrides = update.standardOverrides || {};
update.standardOverrides.translations =
update.standardOverrides.translations || {};
update.standardOverrides.translations[locale] =
update.standardOverrides.translations[locale] || {};
const localeTranslations = update.standardOverrides.translations[locale];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(localeTranslations as Record<string, any>)[overrideKey] = value;
}
private handleDescriptionOverride(
instance: UpdateOneInputType<T>,
fieldMetadata: FieldMetadataEntity,
update: StandardFieldUpdate,
locale?: keyof typeof APP_LOCALES,
): void {
if (!isDefined(instance.update.description)) {
return;
}
if (
this.resetOverrideIfMatchesOriginal({
update,
overrideKey: 'description',
newValue: instance.update.description,
originalValue: fieldMetadata.description,
locale,
})
) {
return;
}
this.setOverrideValue(
update,
'description',
instance.update.description,
locale,
);
}
private handleIconOverride(
instance: UpdateOneInputType<T>,
fieldMetadata: FieldMetadataEntity,
update: StandardFieldUpdate,
): void {
if (!isDefined(instance.update.icon)) {
return;
}
if (
this.resetOverrideIfMatchesOriginal({
update,
overrideKey: 'icon',
newValue: instance.update.icon,
originalValue: fieldMetadata.icon,
locale: undefined,
})
) {
return;
}
this.setOverrideValue(update, 'icon', instance.update.icon);
}
private handleLabelOverride(
instance: UpdateOneInputType<T>,
fieldMetadata: FieldMetadataEntity,
update: StandardFieldUpdate,
locale?: keyof typeof APP_LOCALES,
): void {
if (!isDefined(instance.update.label)) {
return;
}
if (
this.resetOverrideIfMatchesOriginal({
update,
overrideKey: 'label',
newValue: instance.update.label,
originalValue: fieldMetadata.label,
locale,
})
) {
return;
}
this.setOverrideValue(update, 'label', instance.update.label, locale);
}
}
@@ -1,357 +0,0 @@
import { type EachTestingContext } from 'twenty-shared/testing';
import { type FieldMetadataDefaultOption } from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
import { FieldMetadataRelatedRecordsService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service';
import { type ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
import { type ViewFilterService } from 'src/engine/metadata-modules/view-filter/services/view-filter.service';
import { type ViewGroupService } from 'src/engine/metadata-modules/view-group/services/view-group.service';
import { type ViewService } from 'src/engine/metadata-modules/view/services/view.service';
type GetOptionsDifferencesTestContext = EachTestingContext<{
oldOptions: FieldMetadataDefaultOption[];
newOptions: FieldMetadataDefaultOption[];
compareLabel?: boolean;
expected: {
created: FieldMetadataDefaultOption[];
updated: {
old: FieldMetadataDefaultOption;
new: FieldMetadataDefaultOption;
}[];
deleted: FieldMetadataDefaultOption[];
};
}>;
describe('FieldMetadataRelatedRecordsService', () => {
describe('getOptionsDifferences', () => {
let service: FieldMetadataRelatedRecordsService;
let viewService: ViewService = {} as ViewService;
let viewFieldService: ViewFieldService = {} as ViewFieldService;
let viewFilterService: ViewFilterService = {} as ViewFilterService;
let viewGroupService: ViewGroupService = {} as ViewGroupService;
beforeEach(() => {
service = new FieldMetadataRelatedRecordsService(
viewService,
viewFieldService,
viewFilterService,
viewGroupService,
);
});
const testCases: GetOptionsDifferencesTestContext[] = [
{
title: 'should identify created options',
context: {
oldOptions: [
{ id: '1', label: 'Option 1', value: 'value1', position: 0 },
],
newOptions: [
{ id: '1', label: 'Option 1', value: 'value1', position: 0 },
{ id: '2', label: 'Option 2', value: 'value2', position: 1 },
],
expected: {
created: [
{ id: '2', label: 'Option 2', value: 'value2', position: 1 },
],
updated: [],
deleted: [],
},
},
},
{
title: 'should identify updated options',
context: {
oldOptions: [
{ id: '1', label: 'Option 1', value: 'value1', position: 0 },
],
newOptions: [
{
id: '1',
label: 'Option 1',
value: 'updated-value1',
position: 0,
},
],
expected: {
created: [],
updated: [
{
old: {
id: '1',
label: 'Option 1',
value: 'value1',
position: 0,
},
new: {
id: '1',
label: 'Option 1',
value: 'updated-value1',
position: 0,
},
},
],
deleted: [],
},
},
},
{
title: 'should identify deleted options',
context: {
oldOptions: [
{ id: '1', label: 'Option 1', value: 'value1', position: 0 },
{ id: '2', label: 'Option 2', value: 'value2', position: 1 },
],
newOptions: [
{ id: '1', label: 'Option 1', value: 'value1', position: 0 },
],
expected: {
created: [],
updated: [],
deleted: [
{ id: '2', label: 'Option 2', value: 'value2', position: 1 },
],
},
},
},
{
title: 'should identify all types of changes',
context: {
oldOptions: [
{ id: '1', label: 'Option 1', value: 'value1', position: 0 },
{ id: '2', label: 'Option 2', value: 'value2', position: 1 },
{ id: '3', label: 'Option 3', value: 'value3', position: 2 },
],
newOptions: [
{
id: '1',
label: 'Option 1',
value: 'updated-value1',
position: 0,
},
{ id: '3', label: 'Option 3', value: 'value3', position: 1 },
{ id: '4', label: 'Option 4', value: 'value4', position: 2 },
],
expected: {
created: [
{ id: '4', label: 'Option 4', value: 'value4', position: 2 },
],
updated: [
{
old: {
id: '1',
label: 'Option 1',
value: 'value1',
position: 0,
},
new: {
id: '1',
label: 'Option 1',
value: 'updated-value1',
position: 0,
},
},
],
deleted: [
{ id: '2', label: 'Option 2', value: 'value2', position: 1 },
],
},
},
},
{
title: 'should handle empty arrays',
context: {
oldOptions: [],
newOptions: [],
expected: {
created: [],
updated: [],
deleted: [],
},
},
},
{
title: 'should handle all new options',
context: {
oldOptions: [],
newOptions: [
{ id: '1', label: 'Option 1', value: 'value1', position: 0 },
{ id: '2', label: 'Option 2', value: 'value2', position: 1 },
],
expected: {
created: [
{ id: '1', label: 'Option 1', value: 'value1', position: 0 },
{ id: '2', label: 'Option 2', value: 'value2', position: 1 },
],
updated: [],
deleted: [],
},
},
},
{
title: 'should handle all deleted options',
context: {
oldOptions: [
{ id: '1', label: 'Option 1', value: 'value1', position: 0 },
{ id: '2', label: 'Option 2', value: 'value2', position: 1 },
],
newOptions: [],
expected: {
created: [],
updated: [],
deleted: [
{ id: '1', label: 'Option 1', value: 'value1', position: 0 },
{ id: '2', label: 'Option 2', value: 'value2', position: 1 },
],
},
},
},
{
title:
'should not consider changes to label as updates when value remains the same',
context: {
oldOptions: [
{
id: 'f86eaffd-b773-4c9a-957b-86dca4a62731',
label: 'Option 0',
value: 'option0',
position: 1,
},
{
id: '28d80b3c-79bd-4a1b-a868-9616534de0fa',
label: 'Option 1',
value: 'option1',
position: 2,
},
{
id: '25a05cd8-256f-4652-9e4a-6d9ca0b96f4d',
label: 'Option 2',
value: 'option2',
position: 3,
},
],
newOptions: [
{
id: 'f86eaffd-b773-4c9a-957b-86dca4a62731',
label: 'Option 0_UPDATED', // Label changed but value remains the same
value: 'option0',
position: 1,
},
{
id: '28d80b3c-79bd-4a1b-a868-9616534de0fa',
label: 'Option 1', // No change
value: 'option1',
position: 2,
},
{
id: '25a05cd8-256f-4652-9e4a-6d9ca0b96f4d',
label: 'Option 2_UPDATED', // Label changed but value remains the same
value: 'option2',
position: 3,
},
],
expected: {
created: [],
updated: [], // No updates because only labels changed, not values
deleted: [],
},
},
},
{
title:
'should consider changes to label as updates when value remains the same if compareLabel is true',
context: {
oldOptions: [
{
id: 'f86eaffd-b773-4c9a-957b-86dca4a62731',
label: 'Option 0',
value: 'option0',
position: 1,
},
{
id: '28d80b3c-79bd-4a1b-a868-9616534de0fa',
label: 'Option 1',
value: 'option1',
position: 2,
},
{
id: '25a05cd8-256f-4652-9e4a-6d9ca0b96f4d',
label: 'Option 2',
value: 'option2',
position: 3,
},
],
newOptions: [
{
id: 'f86eaffd-b773-4c9a-957b-86dca4a62731',
label: 'Option 0_UPDATED', // Label changed but value remains the same
value: 'option0',
position: 1,
},
{
id: '28d80b3c-79bd-4a1b-a868-9616534de0fa',
label: 'Option 1', // No change
value: 'option1',
position: 2,
},
{
id: '25a05cd8-256f-4652-9e4a-6d9ca0b96f4d',
label: 'Option 2_UPDATED', // Label changed but value remains the same
value: 'option2',
position: 3,
},
],
expected: {
created: [],
updated: [
{
new: {
id: 'f86eaffd-b773-4c9a-957b-86dca4a62731',
label: 'Option 0_UPDATED',
position: 1,
value: 'option0',
},
old: {
id: 'f86eaffd-b773-4c9a-957b-86dca4a62731',
label: 'Option 0',
position: 1,
value: 'option0',
},
},
{
new: {
id: '25a05cd8-256f-4652-9e4a-6d9ca0b96f4d',
label: 'Option 2_UPDATED',
position: 3,
value: 'option2',
},
old: {
id: '25a05cd8-256f-4652-9e4a-6d9ca0b96f4d',
label: 'Option 2',
position: 3,
value: 'option2',
},
},
],
deleted: [],
},
compareLabel: true,
},
},
];
test.each(testCases)(
'$title',
({ context: { oldOptions, newOptions, expected, compareLabel } }) => {
const result = service.getOptionsDifferences(
oldOptions,
newOptions,
compareLabel,
);
expect(result.created).toEqual(expected.created);
expect(result.updated).toEqual(expected.updated);
expect(result.deleted).toEqual(expected.deleted);
},
);
});
});
@@ -1,390 +0,0 @@
import { Injectable } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined, parseJson } from 'twenty-shared/utils';
import { settings } from 'src/engine/constants/settings';
import {
type FieldMetadataComplexOption,
type FieldMetadataDefaultOption,
} from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { isSelectFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-select-field-metadata-type.util';
import { type SelectOrMultiSelectFieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/utils/is-select-or-multi-select-field-metadata.util';
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
import { ViewFilterService } from 'src/engine/metadata-modules/view-filter/services/view-filter.service';
import { ViewGroupEntity } from 'src/engine/metadata-modules/view-group/entities/view-group.entity';
import { ViewGroupService } from 'src/engine/metadata-modules/view-group/services/view-group.service';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { ViewKey } from 'src/engine/metadata-modules/view/enums/view-key.enum';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/constants/DEFAULT_VIEW_FIELD_SIZE';
type Differences<T> = {
created: T[];
updated: { old: T; new: T }[];
deleted: T[];
};
type GetOptionsDifferences = Differences<
FieldMetadataDefaultOption | FieldMetadataComplexOption
>;
@Injectable()
export class FieldMetadataRelatedRecordsService {
constructor(
private readonly viewService: ViewService,
private readonly viewFieldService: ViewFieldService,
private readonly viewFilterService: ViewFilterService,
private readonly viewGroupService: ViewGroupService,
) {}
public async updateRelatedViewGroups(
oldFieldMetadata: SelectOrMultiSelectFieldMetadataEntity,
newFieldMetadata: SelectOrMultiSelectFieldMetadataEntity,
): Promise<void> {
// TODO legacy should support multi-select and rating ?
if (
!isSelectFieldMetadataType(newFieldMetadata.type) ||
!isSelectFieldMetadataType(oldFieldMetadata.type)
) {
return;
}
const objectMetadataViews = await this.viewService.findByObjectMetadataId(
newFieldMetadata.workspaceId,
newFieldMetadata.objectMetadataId,
);
const { created, updated, deleted } = this.getOptionsDifferences(
oldFieldMetadata.options ?? [],
newFieldMetadata.options ?? [],
);
for (const view of objectMetadataViews) {
if (view.viewGroups.length === 0) {
continue;
}
const valuesToDelete = deleted.map((option) => option.value);
if (valuesToDelete.length > 0) {
for (const valueToDelete of valuesToDelete) {
const viewGroupsToDelete = view.viewGroups.filter(
(group) => group.fieldValue === valueToDelete,
);
for (const viewGroup of viewGroupsToDelete) {
await this.viewGroupService.destroy(
viewGroup.id,
newFieldMetadata.workspaceId,
);
}
}
}
const maxPosition = this.getMaxPosition(view.viewGroups);
for (const [index, option] of created.entries()) {
this.viewGroupService.create({
fieldMetadataId: newFieldMetadata.id,
fieldValue: option.value,
position: maxPosition + index,
isVisible: true,
viewId: view.id,
workspaceId: newFieldMetadata.workspaceId,
});
}
for (const { old: oldOption, new: newOption } of updated) {
const existingViewGroup = view.viewGroups.find(
(group) => group.fieldValue === oldOption.value,
);
if (!existingViewGroup) {
continue;
}
this.viewGroupService.update(
existingViewGroup.id,
newFieldMetadata.workspaceId,
{ fieldValue: newOption.value },
);
}
await this.syncNoValueViewGroup(newFieldMetadata, view);
}
await this.viewService.flushGraphQLCache(newFieldMetadata.workspaceId);
}
public async resetViewKanbanAggregateOperation(
fieldMetadata: Pick<
FieldMetadataEntity,
'id' | 'workspaceId' | 'objectMetadataId'
>,
): Promise<void> {
const views = await this.viewService.findByObjectMetadataId(
fieldMetadata.workspaceId,
fieldMetadata.objectMetadataId,
);
const viewsHavingFieldAsAggregateOperation = views.filter(
(view) =>
view.kanbanAggregateOperationFieldMetadataId === fieldMetadata.id,
);
for (const view of viewsHavingFieldAsAggregateOperation) {
await this.viewService.update(view.id, fieldMetadata.workspaceId, {
kanbanAggregateOperationFieldMetadataId: null,
kanbanAggregateOperation: null,
});
}
await this.viewService.flushGraphQLCache(fieldMetadata.workspaceId);
}
public async updateRelatedViewFilters(
oldFieldMetadata: SelectOrMultiSelectFieldMetadataEntity,
newFieldMetadata: SelectOrMultiSelectFieldMetadataEntity,
): Promise<void> {
const views = await this.viewService.findByObjectMetadataId(
newFieldMetadata.workspaceId,
newFieldMetadata.objectMetadataId,
);
const fieldMetadataViews = views.filter((view) =>
view.viewFilters.some(
(filter) => filter.fieldMetadataId === newFieldMetadata.id,
),
);
const alsoCompareLabel = true;
const {
updated: updatedFieldMetadataOptions,
deleted: deletedFieldMetadataOptions,
} = this.getOptionsDifferences(
oldFieldMetadata.options,
newFieldMetadata.options,
alsoCompareLabel,
);
if (
updatedFieldMetadataOptions.length === 0 &&
deletedFieldMetadataOptions.length === 0
) {
return;
}
for (const view of fieldMetadataViews) {
if (view.viewFilters.length === 0) {
continue;
}
for (const viewFilter of view.viewFilters) {
if (!isDefined(viewFilter.value)) {
continue;
}
// TODO: all view filter value should be stored as JSON, this is ongoing work (we are missing a command to migrate the data)
const parsedValue = isNonEmptyString(viewFilter.value)
? parseJson(viewFilter.value)
: viewFilter.value;
if (!isDefined(parsedValue) || !Array.isArray(parsedValue)) {
throw new FieldMetadataException(
`Unexpected invalid view filter value for filter ${viewFilter.id}`,
FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const viewFilterOptions = parsedValue
.map((value) => {
if (!isDefined(oldFieldMetadata.options)) {
return undefined;
}
return oldFieldMetadata.options.find(
(option) => option.value === value,
);
})
.filter(isDefined);
const afterDeleteViewFilterOptions = viewFilterOptions.filter(
(viewFilterOption) =>
!deletedFieldMetadataOptions.some(
(option) => option.value === viewFilterOption.value,
),
);
if (afterDeleteViewFilterOptions.length === 0) {
await this.viewFilterService.destroy(
viewFilter.id,
newFieldMetadata.workspaceId,
);
continue;
}
const afterUpdateAndDeleteViewFilterOptions =
afterDeleteViewFilterOptions.map((viewFilterOption) => {
const updatedOption = updatedFieldMetadataOptions.find(
({ old }) => viewFilterOption.value === old.value,
);
return isDefined(updatedOption)
? updatedOption.new
: viewFilterOption;
});
const value = afterUpdateAndDeleteViewFilterOptions.map(
(option) => option.value,
);
await this.viewFilterService.update(
viewFilter.id,
newFieldMetadata.workspaceId,
{
value,
},
);
}
}
await this.viewService.flushGraphQLCache(newFieldMetadata.workspaceId);
}
async syncNoValueViewGroup(
fieldMetadata: SelectOrMultiSelectFieldMetadataEntity,
view: ViewEntity,
): Promise<void> {
const noValueGroup = view.viewGroups.find(
(group) => group.fieldValue === '',
);
if (fieldMetadata.isNullable && !noValueGroup) {
const maxPosition = this.getMaxPosition(view.viewGroups);
this.viewGroupService.create({
fieldMetadataId: fieldMetadata.id,
fieldValue: '',
position: maxPosition + 1,
isVisible: true,
viewId: view.id,
workspaceId: fieldMetadata.workspaceId,
});
} else if (!fieldMetadata.isNullable && noValueGroup) {
await this.viewGroupService.destroy(
noValueGroup.id,
fieldMetadata.workspaceId,
);
}
await this.viewService.flushGraphQLCache(fieldMetadata.workspaceId);
}
public getOptionsDifferences(
rawOldOptions:
| (FieldMetadataDefaultOption | FieldMetadataComplexOption)[]
| null,
rawNewOptions:
| (FieldMetadataDefaultOption | FieldMetadataComplexOption)[]
| null,
compareLabel = false,
): GetOptionsDifferences {
const differences: Differences<
FieldMetadataDefaultOption | FieldMetadataComplexOption
> = {
created: [],
updated: [],
deleted: [],
};
const oldOptions = rawOldOptions ?? [];
const newOptions = rawNewOptions ?? [];
const oldOptionsMap = new Map(oldOptions.map((opt) => [opt.id, opt]));
for (const newOption of newOptions) {
const oldOption = oldOptionsMap.get(newOption.id);
if (!isDefined(oldOption)) {
differences.created.push(newOption);
continue;
}
if (
oldOption.value !== newOption.value ||
(compareLabel && oldOption.label !== newOption.label)
) {
differences.updated.push({ old: oldOption, new: newOption });
continue;
}
}
const newOptionsMap = new Map(newOptions.map((opt) => [opt.id, opt]));
for (const oldOption of oldOptions) {
if (!newOptionsMap.has(oldOption.id)) {
differences.deleted.push(oldOption);
}
}
return differences;
}
private getMaxPosition(viewGroups: ViewGroupEntity[]): number {
return viewGroups.reduce((max, group) => Math.max(max, group.position), 0);
}
async createViewAndViewFields(
createdFieldMetadatas: FieldMetadataEntity[],
workspaceId: string,
) {
const views = await this.viewService.findByWorkspaceId(workspaceId);
for (const createdFieldMetadata of createdFieldMetadatas) {
const objectViews = views.filter(
(view) =>
view.objectMetadataId === createdFieldMetadata.objectMetadataId,
);
if (objectViews.length === 0) {
return;
}
const indexView = objectViews.find((view) => view.key === ViewKey.INDEX);
if (!indexView) {
return;
}
const isVisible =
indexView.viewFields.length < settings.maxVisibleViewFields;
const createdFieldIsAlreadyInView = indexView.viewFields.some(
(existingViewField) =>
existingViewField.fieldMetadataId === createdFieldMetadata.id,
);
if (createdFieldIsAlreadyInView) {
continue;
}
const lastPosition = indexView.viewFields
.map((viewField) => viewField.position)
.reduce((acc, position) => {
if (position > acc) {
return position;
}
return acc;
}, -1);
await this.viewFieldService.create({
fieldMetadataId: createdFieldMetadata.id,
position: lastPosition + 1,
isVisible,
size: DEFAULT_VIEW_FIELD_SIZE,
viewId: indexView.id,
workspaceId: createdFieldMetadata.workspaceId,
});
}
}
}
@@ -1,12 +1,12 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { isDefined } from 'twenty-shared/utils';
import { In, Repository } from 'typeorm';
import { FindOneOptions, Repository } from 'typeorm';
import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { type 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 { type 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 {
@@ -15,31 +15,35 @@ import {
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { computeFlatEntityMapsFromTo } from 'src/engine/metadata-modules/flat-entity/utils/compute-flat-entity-maps-from-to.util';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { fromCreateFieldInputToFlatFieldMetadatasToCreate } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-create-field-input-to-flat-field-metadatas-to-create.util';
import { fromDeleteFieldInputToFlatFieldMetadatasToDelete } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-delete-field-input-to-flat-field-metadatas-to-delete.util';
import { fromFlatFieldMetadataToFieldMetadataDto } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-flat-field-metadata-to-field-metadata-dto.util';
import { fromUpdateFieldInputToFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-update-field-input-to-flat-field-metadata.util';
import { throwOnFieldInputTranspilationsError } from 'src/engine/metadata-modules/flat-field-metadata/utils/throw-on-field-input-transpilations-error.util';
import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-validate-build-and-run-service';
@Injectable()
export class FieldMetadataServiceV2 {
export class FieldMetadataServiceV2 extends TypeOrmQueryService<FieldMetadataEntity> {
constructor(
@InjectRepository(FieldMetadataEntity)
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
) {}
) {
super(fieldMetadataRepository);
}
async createOne({
async createOneField({
createFieldInput,
workspaceId,
}: {
createFieldInput: Omit<CreateFieldInput, 'workspaceId'>;
workspaceId: string;
}): Promise<FieldMetadataEntity> {
const [createdFieldMetadata] = await this.createMany({
}): Promise<FlatFieldMetadata> {
const [createdFieldMetadata] = await this.createManyFields({
workspaceId,
createFieldInputs: [createFieldInput],
});
@@ -54,7 +58,7 @@ export class FieldMetadataServiceV2 {
return createdFieldMetadata;
}
public async deleteOneField({
async deleteOneField({
deleteOneFieldInput,
workspaceId,
isSystemBuild = false,
@@ -62,7 +66,7 @@ export class FieldMetadataServiceV2 {
deleteOneFieldInput: DeleteOneFieldInput;
workspaceId: string;
isSystemBuild?: boolean;
}): Promise<FieldMetadataDTO> {
}): Promise<FlatFieldMetadata> {
const {
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
flatIndexMaps: existingFlatIndexMaps,
@@ -127,18 +131,16 @@ export class FieldMetadataServiceV2 {
);
}
return fromFlatFieldMetadataToFieldMetadataDto(
flatFieldMetadatasToDelete[0],
);
return flatFieldMetadatasToDelete[0];
}
async updateOne({
async updateOneField({
updateFieldInput,
workspaceId,
}: {
updateFieldInput: Omit<UpdateFieldInput, 'workspaceId'>;
workspaceId: string;
}): Promise<FieldMetadataEntity> {
}): Promise<FlatFieldMetadata> {
const {
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
flatIndexMaps: existingFlatIndexMaps,
@@ -264,21 +266,27 @@ export class FieldMetadataServiceV2 {
);
}
return this.fieldMetadataRepository.findOneOrFail({
where: {
id: flatFieldMetadatasToUpdate[0].id,
workspaceId,
},
const { flatFieldMetadataMaps: recomputedFlatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps'],
},
);
return findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: flatFieldMetadatasToUpdate[0].id,
flatEntityMaps: recomputedFlatFieldMetadataMaps,
});
}
async createMany({
async createManyFields({
createFieldInputs,
workspaceId,
}: {
createFieldInputs: Omit<CreateFieldInput, 'workspaceId'>[];
workspaceId: string;
}): Promise<FieldMetadataEntity[]> {
}): Promise<FlatFieldMetadata[]> {
if (createFieldInputs.length === 0) {
return [];
}
@@ -365,15 +373,34 @@ export class FieldMetadataServiceV2 {
);
}
return this.fieldMetadataRepository.find({
const { flatFieldMetadataMaps: recomputedFlatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps'],
},
);
return findManyFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityIds: allTranspiledTranspilationInputs.map(
({ result: { flatFieldMetadatas } }) => flatFieldMetadatas[0].id,
),
flatEntityMaps: recomputedFlatFieldMetadataMaps,
});
}
public async findOneWithinWorkspace(
workspaceId: string,
options: FindOneOptions<FieldMetadataEntity>,
) {
const [fieldMetadata] = await this.fieldMetadataRepository.find({
...options,
where: {
id: In(
allTranspiledTranspilationInputs.map(
({ result: { flatFieldMetadatas } }) => flatFieldMetadatas[0].id,
),
),
...options.where,
workspaceId,
},
});
return fieldMetadata;
}
}
@@ -3,7 +3,6 @@ import { Injectable } from '@nestjs/common';
import { msg } from '@lingui/core/macro';
import { isDefined } from 'class-validator';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { type FlatFieldMetadataTypeValidator } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-type-validator.type';
import { FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
@@ -17,7 +16,7 @@ const DEFAULT_NO_VALIDATION = async (): Promise<
@Injectable()
export class FlatFieldMetadataTypeValidatorService {
constructor(private readonly featureFlagService: FeatureFlagService) {}
constructor() {}
private readonly FIELD_METADATA_TYPE_VALIDATOR_HASHMAP: FlatFieldMetadataTypeValidator =
{
@@ -15,7 +15,7 @@ import { buildDefaultRelationFlatFieldMetadatasForCustomObject } from 'src/engin
type FromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCreateArgs =
{
createObjectInput: Omit<CreateObjectInput, 'workspaceId'>;
createObjectInput: CreateObjectInput;
workspaceId: string;
} & Pick<AllFlatEntityMaps, 'flatObjectMetadataMaps'>;
export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCreate =
@@ -1,18 +1,22 @@
import { Field, HideField, InputType } from '@nestjs/graphql';
import { BeforeCreateOne } from '@ptc-org/nestjs-query-graphql';
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
import GraphQLJSON from 'graphql-type-json';
import {
type FieldMetadataType,
type FieldMetadataSettings,
type FieldMetadataType,
} from 'twenty-shared/types';
import { Type } from 'class-transformer';
import { IsValidMetadataName } from 'src/engine/decorators/metadata/is-valid-metadata-name.decorator';
import { BeforeCreateOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-create-one-object.hook';
@InputType()
@BeforeCreateOne(BeforeCreateOneObject)
export class CreateObjectInput {
@IsString()
@IsNotEmpty()
@@ -54,9 +58,6 @@ export class CreateObjectInput {
@HideField()
dataSourceId: string;
@HideField()
workspaceId: string;
@HideField()
applicationId?: string;
@@ -81,3 +82,13 @@ export class CreateObjectInput {
@Field({ nullable: true }) // Not nullable to me
isLabelSyncedWithName?: boolean;
}
@InputType()
export class CreateOneObjectInput {
@Type(() => CreateObjectInput)
@ValidateNested()
@Field(() => CreateObjectInput, {
description: 'The object to create',
})
object!: CreateObjectInput;
}
@@ -1,13 +1,11 @@
import { InputType } from '@nestjs/graphql';
import { BeforeDeleteOne, IDField } from '@ptc-org/nestjs-query-graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { IsUUID } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { BeforeDeleteOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-delete-one-object.hook';
@InputType()
@BeforeDeleteOne(BeforeDeleteOneObject)
export class DeleteOneObjectInput {
@IDField(() => UUIDScalarType, {
description: 'The id of the record to delete.',
@@ -2,7 +2,6 @@ import { Field, HideField, ObjectType } from '@nestjs/graphql';
import {
Authorize,
BeforeDeleteOne,
CursorConnection,
FilterableField,
IDField,
@@ -14,7 +13,6 @@ import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto';
import { ObjectStandardOverridesDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-standard-overrides.dto';
import { BeforeDeleteOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-delete-one-object.hook';
@ObjectType('Object')
@Authorize({
@@ -28,7 +26,6 @@ import { BeforeDeleteOneObject } from 'src/engine/metadata-modules/object-metada
disableSort: true,
maxResultsSize: 1000,
})
@BeforeDeleteOne(BeforeDeleteOneObject)
@CursorConnection('fields', () => FieldMetadataDTO)
@CursorConnection('indexMetadatas', () => IndexMetadataDTO)
export class ObjectMetadataDTO {
@@ -1,6 +1,5 @@
import { Field, InputType } from '@nestjs/graphql';
import { BeforeUpdateOne } from '@ptc-org/nestjs-query-graphql';
import { Type } from 'class-transformer';
import {
IsBoolean,
@@ -13,7 +12,6 @@ import {
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { IsValidMetadataName } from 'src/engine/decorators/metadata/is-valid-metadata-name.decorator';
import { BeforeUpdateOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-update-one-object.hook';
@InputType()
export class UpdateObjectPayload {
@@ -76,7 +74,6 @@ export class UpdateObjectPayload {
}
@InputType()
@BeforeUpdateOne(BeforeUpdateOneObject)
export class UpdateOneObjectInput {
@Type(() => UpdateObjectPayload)
@ValidateNested()
@@ -1,697 +0,0 @@
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type UpdateOneInputType } from '@ptc-org/nestjs-query-graphql';
import { type Repository } from 'typeorm';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { type UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import { BeforeUpdateOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-update-one-object.hook';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
type UpdateObjectPayloadForTest = Omit<
UpdateObjectPayload,
'id' | 'workspaceId'
>;
describe('BeforeUpdateOneObject', () => {
let hook: BeforeUpdateOneObject<UpdateObjectPayload>;
let objectMetadataService: ObjectMetadataService;
let fieldMetadataRepository: Repository<FieldMetadataEntity>;
const mockWorkspaceId = 'workspace-id';
const mockObjectId = 'object-id';
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
BeforeUpdateOneObject,
{
provide: ObjectMetadataService,
useValue: {
findOneWithinWorkspace: jest.fn(),
},
},
{
provide: getRepositoryToken(FieldMetadataEntity),
useValue: {
findBy: jest.fn(),
},
},
{
provide: I18nService,
useValue: {
getI18nInstance: jest.fn().mockReturnValue({
_: jest.fn().mockReturnValue('mocked-translation'),
}),
},
},
],
}).compile();
hook = module.get<BeforeUpdateOneObject<UpdateObjectPayload>>(
BeforeUpdateOneObject,
);
objectMetadataService = module.get<ObjectMetadataService>(
ObjectMetadataService,
);
fieldMetadataRepository = module.get<Repository<FieldMetadataEntity>>(
getRepositoryToken(FieldMetadataEntity),
);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should throw UnauthorizedException if workspaceId is not provided', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
isActive: true,
},
};
await expect(
hook.run(instance as UpdateOneInputType<UpdateObjectPayload>, {
workspaceId: '',
locale: undefined,
}),
).rejects.toThrow(UnauthorizedException);
});
it('should throw BadRequestException if object does not exist', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
isActive: true,
},
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(null as unknown as ObjectMetadataEntity);
await expect(
hook.run(instance as UpdateOneInputType<UpdateObjectPayload>, {
workspaceId: mockWorkspaceId,
locale: undefined,
}),
).rejects.toThrow(BadRequestException);
});
it('should not affect custom objects', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
nameSingular: 'newName',
isActive: true,
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: true,
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
jest.spyOn(fieldMetadataRepository, 'findBy').mockResolvedValue([]);
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
expect(result).toEqual(instance);
});
it('should throw BadRequestException when trying to update non-updatable fields on standard objects', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
nameSingular: 'newName',
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: false,
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
await expect(
hook.run(instance as UpdateOneInputType<UpdateObjectPayload>, {
workspaceId: mockWorkspaceId,
locale: undefined,
}),
).rejects.toThrow(BadRequestException);
});
it('should throw BadRequestException when trying to update labels when they are synced with name', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
labelSingular: 'New Label',
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: false,
isLabelSyncedWithName: true,
labelSingular: 'Old Label',
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
await expect(
hook.run(instance as UpdateOneInputType<UpdateObjectPayload>, {
workspaceId: mockWorkspaceId,
locale: undefined,
}),
).rejects.toThrow(BadRequestException);
});
it('should handle isActive updates for standard objects', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
isActive: false,
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: false,
isActive: true,
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
const expectedResult = {
id: mockObjectId,
update: {
isActive: false,
standardOverrides: {},
},
};
expect(result).toEqual(expectedResult);
});
it('should handle isLabelSyncedWithName updates for standard objects', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
isLabelSyncedWithName: true,
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: false,
isLabelSyncedWithName: false,
standardOverrides: {
labelSingular: 'Custom Label',
labelPlural: 'Custom Labels',
},
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
const expectedResult = {
id: mockObjectId,
update: {
isLabelSyncedWithName: true,
standardOverrides: {
labelSingular: null,
labelPlural: null,
},
},
};
expect(result).toEqual(expectedResult);
});
it('should handle labelSingular override when not synced with name', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
labelSingular: 'New Label',
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: false,
isLabelSyncedWithName: false,
labelSingular: 'Default Label',
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
const expectedResult = {
id: mockObjectId,
update: {
standardOverrides: {
labelSingular: 'New Label',
},
},
};
expect(result).toEqual(expectedResult);
});
it('should handle labelPlural override when not synced with name', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
labelPlural: 'New Labels',
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: false,
isLabelSyncedWithName: false,
labelPlural: 'Default Labels',
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
const expectedResult = {
id: mockObjectId,
update: {
standardOverrides: {
labelPlural: 'New Labels',
},
},
};
expect(result).toEqual(expectedResult);
});
it('should reset labelSingular override when it matches the original value', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
labelSingular: 'Default Label',
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: false,
isLabelSyncedWithName: false,
labelSingular: 'Default Label',
standardOverrides: {
labelSingular: 'Custom Label',
},
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
const expectedResult = {
id: mockObjectId,
update: {
standardOverrides: {
labelSingular: null,
},
},
};
expect(result).toEqual(expectedResult);
});
it('should handle description override', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
description: 'New Description',
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: false,
description: 'Default Description',
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
const expectedResult = {
id: mockObjectId,
update: {
standardOverrides: {
description: 'New Description',
},
},
};
expect(result).toEqual(expectedResult);
});
it('should handle icon override', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
icon: 'IconStar',
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: false,
icon: 'IconCircle',
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
const expectedResult = {
id: mockObjectId,
update: {
standardOverrides: {
icon: 'IconStar',
},
},
};
expect(result).toEqual(expectedResult);
});
it('should handle locale-specific translations', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
labelSingular: 'Étiquette',
labelPlural: 'Étiquettes',
description: 'Description en français',
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: false,
isLabelSyncedWithName: false,
labelSingular: 'Label',
labelPlural: 'Labels',
description: 'Description',
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: 'fr-FR',
},
);
const expectedResult = {
id: mockObjectId,
update: {
standardOverrides: {
translations: {
'fr-FR': {
labelSingular: 'Étiquette',
labelPlural: 'Étiquettes',
description: 'Description en français',
},
},
},
},
};
expect(result).toEqual(expectedResult);
});
it('should reset locale-specific translations when they match translated defaults', async () => {
const translatedLabel = 'mocked-translation';
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
labelSingular: translatedLabel,
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: false,
isLabelSyncedWithName: false,
labelSingular: 'Label',
standardOverrides: {
translations: {
'fr-FR': {
labelSingular: 'Ancienne Étiquette',
},
},
},
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: 'fr-FR',
},
);
const expectedResult = {
id: mockObjectId,
update: {
standardOverrides: {
translations: {
'fr-FR': {
labelSingular: null,
},
},
},
},
};
expect(result).toEqual(expectedResult);
});
it('should handle multiple updates together', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
isActive: false,
isLabelSyncedWithName: false,
labelSingular: 'New Label',
labelPlural: 'New Labels',
icon: 'IconStar',
description: 'New Description',
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: false,
isActive: true,
isLabelSyncedWithName: false,
labelSingular: 'Default Label',
labelPlural: 'Default Labels',
icon: 'IconCircle',
description: 'Default Description',
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
const expectedResult = {
id: mockObjectId,
update: {
isActive: false,
isLabelSyncedWithName: false,
standardOverrides: {
labelSingular: 'New Label',
labelPlural: 'New Labels',
icon: 'IconStar',
description: 'New Description',
},
},
};
expect(result).toEqual(expectedResult);
});
it('should validate label identifier field correctly for custom objects', async () => {
const labelIdentifierFieldId = 'label-field-id';
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
labelIdentifierFieldMetadataId: labelIdentifierFieldId,
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: true,
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
jest
.spyOn(fieldMetadataRepository, 'findBy')
.mockImplementation(async (criteria) => {
expect(criteria).toHaveProperty('workspaceId');
expect(criteria).toHaveProperty('objectMetadataId');
expect(criteria).toHaveProperty('id');
return [{ id: labelIdentifierFieldId } as FieldMetadataEntity];
});
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
expect(result).toEqual(instance);
});
it('should validate image identifier field correctly for custom objects', async () => {
const imageIdentifierFieldId = 'image-field-id';
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
imageIdentifierFieldMetadataId: imageIdentifierFieldId,
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: true,
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
jest
.spyOn(fieldMetadataRepository, 'findBy')
.mockImplementation(async (criteria) => {
expect(criteria).toHaveProperty('workspaceId');
expect(criteria).toHaveProperty('objectMetadataId');
expect(criteria).toHaveProperty('id');
return [{ id: imageIdentifierFieldId } as FieldMetadataEntity];
});
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
expect(result).toEqual(instance);
});
});
@@ -1,29 +0,0 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import {
type BeforeCreateOneHook,
type CreateOneInputType,
} from '@ptc-org/nestjs-query-graphql';
import { type CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
@Injectable()
export class BeforeCreateOneObject<T extends CreateObjectInput>
implements BeforeCreateOneHook<T>
{
async run(
instance: CreateOneInputType<T>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: any,
): Promise<CreateOneInputType<T>> {
const workspaceId = context?.req?.workspace?.id;
if (!workspaceId) {
throw new UnauthorizedException();
}
instance.input.workspaceId = workspaceId;
return instance;
}
}
@@ -1,50 +0,0 @@
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import {
type BeforeDeleteOneHook,
type DeleteOneInputType,
} from '@ptc-org/nestjs-query-graphql';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
@Injectable()
export class BeforeDeleteOneObject implements BeforeDeleteOneHook {
constructor(readonly objectMetadataService: ObjectMetadataService) {}
async run(
instance: DeleteOneInputType,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: any,
): Promise<DeleteOneInputType> {
const workspaceId = context?.req?.workspace?.id;
if (!workspaceId) {
throw new UnauthorizedException();
}
const objectMetadata =
await this.objectMetadataService.findOneWithinWorkspace(workspaceId, {
where: {
id: instance.id.toString(),
},
});
if (!objectMetadata) {
throw new BadRequestException('Object does not exist');
}
if (!objectMetadata.isCustom) {
throw new BadRequestException("Standard Objects can't be deleted");
}
if (objectMetadata.isActive) {
throw new BadRequestException("Active objects can't be deleted");
}
return instance;
}
}
@@ -1,434 +0,0 @@
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import {
type BeforeUpdateOneHook,
type UpdateOneInputType,
} from '@ptc-org/nestjs-query-graphql';
import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { type ObjectStandardOverridesDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-standard-overrides.dto';
import { type UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
interface StandardObjectUpdate extends Partial<UpdateObjectPayload> {
standardOverrides?: ObjectStandardOverridesDTO;
}
@Injectable()
export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
implements BeforeUpdateOneHook<T>
{
constructor(
readonly objectMetadataService: ObjectMetadataService,
private readonly i18nService: I18nService,
) {}
// TODO: this logic could be moved to a policy guard
async run(
instance: UpdateOneInputType<T>,
{
workspaceId,
locale,
}: {
workspaceId: string;
locale: keyof typeof APP_LOCALES | undefined;
},
): Promise<UpdateOneInputType<T>> {
if (!workspaceId) {
throw new UnauthorizedException();
}
const objectMetadata = await this.getObjectMetadata(instance, workspaceId);
if (!objectMetadata.isCustom) {
return this.handleStandardObjectUpdate(instance, objectMetadata, locale);
}
return instance;
}
private async getObjectMetadata(
instance: UpdateOneInputType<T>,
workspaceId: string,
) {
const objectMetadata =
await this.objectMetadataService.findOneWithinWorkspace(workspaceId, {
where: {
id: instance.id.toString(),
},
});
if (!objectMetadata) {
throw new BadRequestException('Object does not exist');
}
return objectMetadata;
}
private handleStandardObjectUpdate(
instance: UpdateOneInputType<T>,
objectMetadata: ObjectMetadataEntity,
locale: keyof typeof APP_LOCALES | undefined,
): UpdateOneInputType<T> {
const update: StandardObjectUpdate = {};
const updatableFields = ['isActive', 'isLabelSyncedWithName'];
const overridableFields = [
'labelSingular',
'labelPlural',
'icon',
'description',
];
const nonUpdatableFields = Object.keys(instance.update).filter(
(key) =>
!updatableFields.includes(key) && !overridableFields.includes(key),
);
const isUpdatingLabelsWhenSynced =
(instance.update.labelSingular || instance.update.labelPlural) &&
objectMetadata.isLabelSyncedWithName &&
instance.update.isLabelSyncedWithName !== false &&
(instance.update.labelSingular !== objectMetadata.labelSingular ||
instance.update.labelPlural !== objectMetadata.labelPlural);
if (isUpdatingLabelsWhenSynced) {
throw new BadRequestException(
'Cannot update labels when they are synced with name',
);
}
if (nonUpdatableFields.length > 0) {
throw new BadRequestException(
`Only isActive, isLabelSyncedWithName, labelSingular, labelPlural, icon and description fields can be updated for standard objects. Disallowed fields: ${nonUpdatableFields.join(', ')}`,
);
}
// preserve existing overrides
update.standardOverrides = objectMetadata.standardOverrides
? { ...objectMetadata.standardOverrides }
: {};
this.handleActiveField(instance, update);
this.handleLabelSyncedWithNameField(instance, update);
this.handleStandardOverrides(instance, objectMetadata, update, locale);
return {
id: instance.id,
update: update as T,
};
}
private handleActiveField(
instance: UpdateOneInputType<T>,
update: StandardObjectUpdate,
): void {
if (!isDefined(instance.update.isActive)) {
return;
}
update.isActive = instance.update.isActive;
}
private handleLabelSyncedWithNameField(
instance: UpdateOneInputType<T>,
update: StandardObjectUpdate,
): void {
if (!isDefined(instance.update.isLabelSyncedWithName)) {
return;
}
update.isLabelSyncedWithName = instance.update.isLabelSyncedWithName;
if (instance.update.isLabelSyncedWithName === false) {
return;
}
// If setting isLabelSyncedWithName to true, clear label overrides
update.standardOverrides = update.standardOverrides || {};
update.standardOverrides.labelSingular = null;
update.standardOverrides.labelPlural = null;
}
private handleStandardOverrides(
instance: UpdateOneInputType<T>,
objectMetadata: ObjectMetadataEntity,
update: StandardObjectUpdate,
locale: keyof typeof APP_LOCALES | undefined,
): void {
const hasStandardOverrides =
isDefined(instance.update.description) ||
isDefined(instance.update.icon) ||
isDefined(instance.update.labelSingular) ||
isDefined(instance.update.labelPlural);
if (!hasStandardOverrides) {
return;
}
update.standardOverrides = update.standardOverrides || {};
this.handleIconOverride(instance, objectMetadata, update);
this.handleDescriptionOverride(instance, objectMetadata, update, locale);
this.handleLabelOverrides(instance, objectMetadata, update, locale);
}
private resetOverrideIfMatchesOriginal({
update,
overrideKey,
newValue,
originalValue,
locale,
}: {
update: StandardObjectUpdate;
overrideKey: 'labelSingular' | 'labelPlural' | 'description' | 'icon';
newValue: string;
originalValue: string | null;
locale?: keyof typeof APP_LOCALES | undefined;
}): boolean {
if (locale && locale !== SOURCE_LOCALE) {
const wasOverrideReset = this.resetLocalizedOverride(
update,
overrideKey,
newValue,
originalValue,
locale,
);
return wasOverrideReset;
}
const wasOverrideReset = this.resetDefaultOverride(
update,
overrideKey,
newValue,
originalValue,
);
return wasOverrideReset;
}
private resetLocalizedOverride(
update: StandardObjectUpdate,
overrideKey: 'labelSingular' | 'labelPlural' | 'description' | 'icon',
newValue: string,
originalValue: string | null,
locale: keyof typeof APP_LOCALES,
): boolean {
const messageId = generateMessageId(originalValue ?? '');
const i18n = this.i18nService.getI18nInstance(locale);
const translatedMessage = i18n._(messageId);
if (newValue !== translatedMessage) {
return false;
}
// Initialize the translations structure if needed
update.standardOverrides = update.standardOverrides || {};
update.standardOverrides.translations =
update.standardOverrides.translations || {};
update.standardOverrides.translations[locale] =
update.standardOverrides.translations[locale] || {};
// Reset the override by setting it to null
const localeTranslations = update.standardOverrides.translations[locale];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(localeTranslations as Record<string, any>)[overrideKey] = null;
return true;
}
private resetDefaultOverride(
update: StandardObjectUpdate,
overrideKey: 'labelSingular' | 'labelPlural' | 'description' | 'icon',
newValue: string,
originalValue: string | null,
): boolean {
if (newValue !== originalValue) {
return false;
}
update.standardOverrides = update.standardOverrides || {};
update.standardOverrides[overrideKey] = null;
return true;
}
private setOverrideValue(
update: StandardObjectUpdate,
overrideKey: 'labelSingular' | 'labelPlural' | 'description' | 'icon',
value: string,
locale?: keyof typeof APP_LOCALES,
): void {
update.standardOverrides = update.standardOverrides || {};
const shouldSetLocalizedOverride =
locale && locale !== SOURCE_LOCALE && overrideKey !== 'icon';
if (!shouldSetLocalizedOverride) {
update.standardOverrides[overrideKey] = value;
return;
}
this.setLocalizedOverrideValue(update, overrideKey, value, locale);
}
private setLocalizedOverrideValue(
update: StandardObjectUpdate,
overrideKey: 'labelSingular' | 'labelPlural' | 'description' | 'icon',
value: string,
locale: keyof typeof APP_LOCALES,
): void {
update.standardOverrides = update.standardOverrides || {};
update.standardOverrides.translations =
update.standardOverrides.translations || {};
update.standardOverrides.translations[locale] =
update.standardOverrides.translations[locale] || {};
const localeTranslations = update.standardOverrides.translations[locale];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(localeTranslations as Record<string, any>)[overrideKey] = value;
}
private handleDescriptionOverride(
instance: UpdateOneInputType<T>,
objectMetadata: ObjectMetadataEntity,
update: StandardObjectUpdate,
locale?: keyof typeof APP_LOCALES,
): void {
if (!isDefined(instance.update.description)) {
return;
}
if (
this.resetOverrideIfMatchesOriginal({
update,
overrideKey: 'description',
newValue: instance.update.description,
originalValue: objectMetadata.description,
locale,
})
) {
return;
}
this.setOverrideValue(
update,
'description',
instance.update.description,
locale,
);
}
private handleIconOverride(
instance: UpdateOneInputType<T>,
objectMetadata: ObjectMetadataEntity,
update: StandardObjectUpdate,
): void {
if (!isDefined(instance.update.icon)) {
return;
}
if (
this.resetOverrideIfMatchesOriginal({
update,
overrideKey: 'icon',
newValue: instance.update.icon,
originalValue: objectMetadata.icon,
locale: undefined,
})
) {
return;
}
this.setOverrideValue(update, 'icon', instance.update.icon);
}
private handleLabelOverrides(
instance: UpdateOneInputType<T>,
objectMetadata: ObjectMetadataEntity,
update: StandardObjectUpdate,
locale?: keyof typeof APP_LOCALES,
): void {
// Skip label updates if labels are synced with name or will be synced
if (
objectMetadata.isLabelSyncedWithName ||
update.isLabelSyncedWithName === true
) {
return;
}
this.handleLabelSingularOverride(instance, objectMetadata, update, locale);
this.handleLabelPluralOverride(instance, objectMetadata, update, locale);
}
private handleLabelSingularOverride(
instance: UpdateOneInputType<T>,
objectMetadata: ObjectMetadataEntity,
update: StandardObjectUpdate,
locale?: keyof typeof APP_LOCALES,
): void {
if (!isDefined(instance.update.labelSingular)) {
return;
}
if (
this.resetOverrideIfMatchesOriginal({
update,
overrideKey: 'labelSingular',
newValue: instance.update.labelSingular,
originalValue: objectMetadata.labelSingular,
locale,
})
) {
return;
}
this.setOverrideValue(
update,
'labelSingular',
instance.update.labelSingular,
locale,
);
}
private handleLabelPluralOverride(
instance: UpdateOneInputType<T>,
objectMetadata: ObjectMetadataEntity,
update: StandardObjectUpdate,
locale?: keyof typeof APP_LOCALES,
): void {
if (!isDefined(instance.update.labelPlural)) {
return;
}
if (
this.resetOverrideIfMatchesOriginal({
update,
overrideKey: 'labelPlural',
newValue: instance.update.labelPlural,
originalValue: objectMetadata.labelPlural,
locale,
})
) {
return;
}
this.setOverrideValue(
update,
'labelPlural',
instance.update.labelPlural,
locale,
);
}
}
@@ -1,6 +1,9 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { fromArrayToUniqueKeyRecord, isDefined } from 'twenty-shared/utils';
import { FindManyOptions, FindOneOptions, Repository } from 'typeorm';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { computeFlatEntityMapsFromTo } from 'src/engine/metadata-modules/flat-entity/utils/compute-flat-entity-maps-from-to.util';
@@ -10,15 +13,14 @@ import { FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metada
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCreate } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata-and-flat-field-metadatas-to-create.util';
import { fromDeleteObjectInputToFlatFieldMetadatasToDelete } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-delete-object-input-to-flat-field-metadatas-to-delete.util';
import { fromFlatObjectMetadataToObjectMetadataDto } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-to-object-metadata-dto.util';
import { fromUpdateObjectInputToFlatObjectMetadataAndRelatedFlatEntities } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-update-object-input-to-flat-object-metadata-and-related-flat-entities.util';
import { fromCreateViewFieldInputToFlatViewFieldToCreate } from 'src/engine/metadata-modules/flat-view-field/utils/from-create-view-field-input-to-flat-view-field-to-create.util';
import { FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
import { fromCreateViewInputToFlatViewToCreate } from 'src/engine/metadata-modules/flat-view/utils/from-create-view-input-to-flat-view-to-create.util';
import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
import { DeleteOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/delete-object.input';
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import { UpdateOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
@@ -33,21 +35,25 @@ import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspa
import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
@Injectable()
export class ObjectMetadataServiceV2 {
export class ObjectMetadataServiceV2 extends TypeOrmQueryService<ObjectMetadataEntity> {
constructor(
@InjectRepository(ObjectMetadataEntity)
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
) {
super(objectMetadataRepository);
}
async updateOne({
async updateOneObject({
updateObjectInput,
workspaceId,
}: {
workspaceId: string;
updateObjectInput: UpdateOneObjectInput;
}): Promise<ObjectMetadataDTO> {
}): Promise<FlatObjectMetadata> {
const {
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
flatIndexMaps: existingFlatIndexMaps,
@@ -154,10 +160,10 @@ export class ObjectMetadataServiceV2 {
);
}
return fromFlatObjectMetadataToObjectMetadataDto(updatedFlatObjectMetadata);
return updatedFlatObjectMetadata;
}
async deleteOne({
async deleteOneObject({
deleteObjectInput,
workspaceId,
isSystemBuild = false,
@@ -165,7 +171,7 @@ export class ObjectMetadataServiceV2 {
deleteObjectInput: DeleteOneObjectInput;
workspaceId: string;
isSystemBuild?: boolean;
}): Promise<ObjectMetadataDTO> {
}): Promise<FlatObjectMetadata> {
const deletedObjectMetadataDtos = await this.deleteManyObjectMetadatas({
deleteObjectInputs: [deleteObjectInput],
workspaceId,
@@ -192,7 +198,7 @@ export class ObjectMetadataServiceV2 {
deleteObjectInputs: DeleteOneObjectInput[];
workspaceId: string;
isSystemBuild?: boolean;
}): Promise<ObjectMetadataDTO[]> {
}): Promise<FlatObjectMetadata[]> {
if (deleteObjectInputs.length === 0) {
return [];
}
@@ -311,16 +317,14 @@ export class ObjectMetadataServiceV2 {
);
}
return flatObjectMetadatasToDelete.map(
fromFlatObjectMetadataToObjectMetadataDto,
);
return flatObjectMetadatasToDelete;
}
async createOne({
async createOneObject({
createObjectInput,
workspaceId,
}: {
createObjectInput: Omit<CreateObjectInput, 'workspaceId'>;
createObjectInput: CreateObjectInput;
workspaceId: string;
}): Promise<FlatObjectMetadata> {
const {
@@ -549,4 +553,43 @@ export class ObjectMetadataServiceV2 {
isSystemBuild: true,
});
}
public async findOneWithinWorkspace(
workspaceId: string,
options: FindOneOptions<ObjectMetadataEntity>,
): Promise<ObjectMetadataEntity | null> {
return this.objectMetadataRepository.findOne({
relations: [
'fields',
'indexMetadatas',
'indexMetadatas.indexFieldMetadatas',
],
...options,
where: {
...options.where,
workspaceId,
},
});
}
public async findManyWithinWorkspace(
workspaceId: string,
options?: FindManyOptions<ObjectMetadataEntity>,
) {
return this.objectMetadataRepository.find({
relations: [
'fields.object',
'fields',
'fields.relationTargetObjectMetadata',
],
...options,
where: {
...options?.where,
workspaceId,
},
order: {
...options?.order,
},
});
}
}
@@ -11,7 +11,6 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
@@ -21,20 +20,13 @@ import { IndexMetadataModule } from 'src/engine/metadata-modules/index-metadata/
import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import { UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import { BeforeUpdateOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-update-one-object.hook';
import { ObjectMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/object-metadata/interceptors/object-metadata-graphql-api-exception.interceptor';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataResolver } from 'src/engine/metadata-modules/object-metadata/object-metadata.resolver';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { ObjectMetadataFieldRelationService } from 'src/engine/metadata-modules/object-metadata/services/object-metadata-field-relation.service';
import { ObjectMetadataMigrationService } from 'src/engine/metadata-modules/object-metadata/services/object-metadata-migration.service';
import { ObjectMetadataRelatedRecordsService } from 'src/engine/metadata-modules/object-metadata/services/object-metadata-related-records.service';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { RemoteTableRelationsModule } from 'src/engine/metadata-modules/remote-server/remote-table/remote-table-relations/remote-table-relations.module';
import { SearchVectorModule } from 'src/engine/metadata-modules/search-vector/search-vector.module';
import { ViewFieldModule } from 'src/engine/metadata-modules/view-field/view-field.module';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
@@ -63,7 +55,6 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa
WorkspaceMigrationRunnerModule,
WorkspaceMetadataVersionModule,
RemoteTableRelationsModule,
SearchVectorModule,
IndexMetadataModule,
PermissionsModule,
WorkspacePermissionsCacheModule,
@@ -77,13 +68,9 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
services: [
ObjectMetadataService,
ObjectMetadataServiceV2,
FlatFieldMetadataValidatorService,
FlatFieldMetadataTypeValidatorService,
ObjectMetadataMigrationService,
ObjectMetadataFieldRelationService,
ObjectMetadataRelatedRecordsService,
],
resolvers: [
{
@@ -91,14 +78,13 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa
DTOClass: ObjectMetadataDTO,
CreateDTOClass: CreateObjectInput,
UpdateDTOClass: UpdateObjectPayload,
ServiceClass: ObjectMetadataService,
ServiceClass: ObjectMetadataServiceV2,
pagingStrategy: PagingStrategies.CURSOR,
read: {
defaultSort: [{ field: 'id', direction: SortDirection.DESC }],
},
create: {
many: { disabled: true },
guards: [SettingsPermissionGuard(PermissionFlagType.DATA_MODEL)],
disabled: true,
},
update: { disabled: true },
delete: { disabled: true },
@@ -109,12 +95,7 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa
],
}),
],
providers: [
ObjectMetadataService,
ObjectMetadataServiceV2,
ObjectMetadataResolver,
BeforeUpdateOneObject,
],
exports: [ObjectMetadataService, ObjectMetadataServiceV2],
providers: [ObjectMetadataServiceV2, ObjectMetadataResolver],
exports: [ObjectMetadataServiceV2],
})
export class ObjectMetadataModule {}
@@ -8,8 +8,6 @@ import {
Resolver,
} from '@nestjs/graphql';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
@@ -20,16 +18,13 @@ import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorat
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { fromFlatObjectMetadataToObjectMetadataDto } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-to-object-metadata-dto.util';
import { IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto';
import { CreateOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
import { DeleteOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/delete-object.input';
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import {
UpdateOneObjectInput,
type UpdateObjectPayload,
} from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import { BeforeUpdateOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-update-one-object.hook';
import { UpdateOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { objectMetadataGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/object-metadata/utils/object-metadata-graphql-api-exception-handler.util';
import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
@@ -44,9 +39,6 @@ import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-module
)
export class ObjectMetadataResolver {
constructor(
private readonly objectMetadataService: ObjectMetadataService,
private readonly beforeUpdateOneObject: BeforeUpdateOneObject<UpdateObjectPayload>,
private readonly featureFlagService: FeatureFlagService,
private readonly objectMetadataServiceV2: ObjectMetadataServiceV2,
private readonly i18nService: I18nService,
) {}
@@ -114,16 +106,42 @@ export class ObjectMetadataResolver {
@UseGuards(SettingsPermissionGuard(PermissionFlagType.DATA_MODEL))
@Mutation(() => ObjectMetadataDTO)
async deleteOneObject(
@Args('input') input: DeleteOneObjectInput,
async createOneObject(
@Args('input') input: CreateOneObjectInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Context() context: I18nContext,
) {
try {
return await this.objectMetadataService.deleteOneObject(
input,
workspaceId,
const flatobjectMetadata =
await this.objectMetadataServiceV2.createOneObject({
createObjectInput: input.object,
workspaceId,
});
return fromFlatObjectMetadataToObjectMetadataDto(flatobjectMetadata);
} catch (error) {
objectMetadataGraphqlApiExceptionHandler(
error,
this.i18nService.getI18nInstance(context.req.locale),
);
}
}
@UseGuards(SettingsPermissionGuard(PermissionFlagType.DATA_MODEL))
@Mutation(() => ObjectMetadataDTO)
async deleteOneObject(
@Args('input') deleteObjectInput: DeleteOneObjectInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Context() context: I18nContext,
) {
try {
const flatobjectMetadata =
await this.objectMetadataServiceV2.deleteOneObject({
deleteObjectInput,
workspaceId,
});
return fromFlatObjectMetadataToObjectMetadataDto(flatobjectMetadata);
} catch (error) {
objectMetadataGraphqlApiExceptionHandler(
error,
@@ -139,39 +157,14 @@ export class ObjectMetadataResolver {
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Context() context: I18nContext,
) {
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
workspaceId,
);
if (isWorkspaceMigrationV2Enabled) {
try {
return await this.objectMetadataServiceV2.updateOne({
try {
const flatobjectMetadata =
await this.objectMetadataServiceV2.updateOneObject({
updateObjectInput,
workspaceId,
});
} catch (error) {
objectMetadataGraphqlApiExceptionHandler(
error,
this.i18nService.getI18nInstance(context.req.locale),
);
}
}
try {
const updatedInput = (await this.beforeUpdateOneObject.run(
updateObjectInput,
{
workspaceId,
locale: context.req.locale,
},
)) as UpdateOneObjectInput;
return await this.objectMetadataService.updateOneObject(
updatedInput,
workspaceId,
);
return fromFlatObjectMetadataToObjectMetadataDto(flatobjectMetadata);
} catch (error) {
objectMetadataGraphqlApiExceptionHandler(
error,
@@ -1,792 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { type Query, type QueryOptions } from '@ptc-org/nestjs-query-core';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { FieldMetadataType } from 'twenty-shared/types';
import { capitalize, isDefined } from 'twenty-shared/utils';
import {
DataSource,
In,
Repository,
type FindManyOptions,
type FindOneOptions,
type QueryRunner,
} from 'typeorm';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { IndexMetadataService } from 'src/engine/metadata-modules/index-metadata/index-metadata.service';
import { type DeleteOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/delete-object.input';
import {
type UpdateObjectPayload,
type UpdateOneObjectInput,
} from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { ObjectMetadataFieldRelationService } from 'src/engine/metadata-modules/object-metadata/services/object-metadata-field-relation.service';
import { ObjectMetadataMigrationService } from 'src/engine/metadata-modules/object-metadata/services/object-metadata-migration.service';
import { ObjectMetadataRelatedRecordsService } from 'src/engine/metadata-modules/object-metadata/services/object-metadata-related-records.service';
import { buildDefaultFieldsForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-default-fields-for-custom-object.util';
import {
validateLowerCasedAndTrimmedStringsAreDifferentOrThrow,
validateObjectMetadataInputLabelsOrThrow,
validateObjectMetadataInputNamesOrThrow,
} from 'src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-input.util';
import { SearchVectorService } from 'src/engine/metadata-modules/search-vector/search-vector.service';
import { type 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 { validateMetadataIdentifierFieldMetadataIds } from 'src/engine/metadata-modules/utils/validate-metadata-identifier-field-metadata-id.utils';
import { validateNameAndLabelAreSyncOrThrow } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
import { validatesNoOtherObjectWithSameNameExistsOrThrows } from 'src/engine/metadata-modules/utils/validate-no-other-object-with-same-name-exists-or-throw.util';
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
import { CUSTOM_OBJECT_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { isSearchableFieldType } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-searchable-field.util';
import { ObjectMetadataEntity } from './object-metadata.entity';
import { type CreateObjectInput } from './dtos/create-object.input';
@Injectable()
export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEntity> {
constructor(
@InjectRepository(ObjectMetadataEntity)
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
@InjectRepository(FieldMetadataEntity)
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
private readonly dataSourceService: DataSourceService,
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
private readonly searchVectorService: SearchVectorService,
private readonly objectMetadataFieldRelationService: ObjectMetadataFieldRelationService,
private readonly objectMetadataMigrationService: ObjectMetadataMigrationService,
private readonly objectMetadataRelatedRecordsService: ObjectMetadataRelatedRecordsService,
private readonly indexMetadataService: IndexMetadataService,
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
private readonly featureFlagService: FeatureFlagService,
private readonly objectMetadataServiceV2: ObjectMetadataServiceV2,
) {
super(objectMetadataRepository);
}
override async query(
query: Query<ObjectMetadataEntity>,
opts?: QueryOptions<ObjectMetadataEntity> | undefined,
): Promise<ObjectMetadataEntity[]> {
const start = performance.now();
const result = super.query(query, opts);
const end = performance.now();
// eslint-disable-next-line no-console
console.log(`metadata query time: ${end - start} ms`);
return result;
}
override async createOne(
createObjectInput: CreateObjectInput,
): Promise<ObjectMetadataEntity> {
const { workspaceId } = createObjectInput;
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
workspaceId,
);
if (isWorkspaceMigrationV2Enabled) {
const flatObjectMetadata = await this.objectMetadataServiceV2.createOne({
createObjectInput,
workspaceId,
});
const createdObjectMetadata = await this.objectMetadataRepository.findOne(
{
where: {
id: flatObjectMetadata.id,
workspaceId,
},
},
);
if (!isDefined(createdObjectMetadata)) {
throw new ObjectMetadataException(
'Created object metadata not found',
ObjectMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
}
return createdObjectMetadata;
}
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const objectMetadataRepository =
queryRunner.manager.getRepository(ObjectMetadataEntity);
const { objectMetadataMaps } =
await this.workspaceMetadataCacheService.getExistingOrRecomputeMetadataMaps(
{
workspaceId,
},
);
const lastDataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
workspaceId,
);
createObjectInput.labelSingular = capitalize(
createObjectInput.labelSingular,
);
createObjectInput.labelPlural = capitalize(createObjectInput.labelPlural);
validateObjectMetadataInputNamesOrThrow(createObjectInput);
validateObjectMetadataInputLabelsOrThrow(createObjectInput);
validateLowerCasedAndTrimmedStringsAreDifferentOrThrow({
inputs: [createObjectInput.nameSingular, createObjectInput.namePlural],
message:
'The singular and plural names cannot be the same for an object',
});
validateLowerCasedAndTrimmedStringsAreDifferentOrThrow({
inputs: [
createObjectInput.labelPlural,
createObjectInput.labelSingular,
],
message:
'The singular and plural labels cannot be the same for an object',
});
if (createObjectInput.isLabelSyncedWithName === true) {
validateNameAndLabelAreSyncOrThrow({
label: createObjectInput.labelSingular,
name: createObjectInput.nameSingular,
});
validateNameAndLabelAreSyncOrThrow({
label: createObjectInput.labelPlural,
name: createObjectInput.namePlural,
});
}
validatesNoOtherObjectWithSameNameExistsOrThrows({
objectMetadataNamePlural: createObjectInput.namePlural,
objectMetadataNameSingular: createObjectInput.nameSingular,
objectMetadataMaps,
});
const baseCustomFields = buildDefaultFieldsForCustomObject(workspaceId);
const labelIdentifierFieldMetadataId = baseCustomFields.find(
(field) => field.standardId === CUSTOM_OBJECT_STANDARD_FIELD_IDS.name,
)?.id;
if (!isDefined(labelIdentifierFieldMetadataId)) {
throw new ObjectMetadataException(
'Label identifier field metadata not created properly',
ObjectMetadataExceptionCode.MISSING_CUSTOM_OBJECT_DEFAULT_LABEL_IDENTIFIER_FIELD,
);
}
const createdObjectMetadata = await objectMetadataRepository.save({
...createObjectInput,
dataSourceId: lastDataSourceMetadata.id,
targetTableName: 'DEPRECATED',
isActive: true,
isCustom: !createObjectInput.isRemote,
isSystem: false,
isRemote: createObjectInput.isRemote,
isSearchable: !createObjectInput.isRemote,
fields: createObjectInput.isRemote ? [] : baseCustomFields,
labelIdentifierFieldMetadataId,
});
if (createObjectInput.isRemote) {
throw new Error('Remote objects are not supported yet');
} else {
const fieldsById = createdObjectMetadata.fields.reduce(
(acc, field) => ({
...acc,
[field.id]: field,
}),
{},
);
const createdRelatedObjectMetadataCollection =
await this.objectMetadataFieldRelationService.createRelationsAndForeignKeysMetadata(
workspaceId,
{ ...createdObjectMetadata, fieldsById },
objectMetadataMaps,
queryRunner,
);
await this.objectMetadataMigrationService.createTableMigration(
createdObjectMetadata,
queryRunner,
);
await this.objectMetadataMigrationService.createColumnsMigrations(
createdObjectMetadata,
createdObjectMetadata.fields,
queryRunner,
);
await this.objectMetadataMigrationService.createRelationMigrations(
createdObjectMetadata,
createdRelatedObjectMetadataCollection,
queryRunner,
);
await this.searchVectorService.createSearchVectorFieldForObject(
createObjectInput,
createdObjectMetadata,
queryRunner,
);
}
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrationsWithinTransaction(
createdObjectMetadata.workspaceId,
queryRunner,
);
await queryRunner.commitTransaction();
// After commit, do non-transactional work
await this.workspacePermissionsCacheService.recomputeRolesPermissionsCache(
{
workspaceId,
},
);
await this.objectMetadataRelatedRecordsService.createObjectRelatedRecords(
createdObjectMetadata,
);
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
await this.flatEntityMapsCacheService.invalidateFlatEntityMaps({
workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps', 'flatObjectMetadataMaps'],
});
return createdObjectMetadata;
} catch (error) {
if (queryRunner.isTransactionActive) {
try {
await queryRunner.rollbackTransaction();
} catch (error) {
// eslint-disable-next-line no-console
console.trace(`Failed to rollback transaction: ${error.message}`);
}
}
throw error;
} finally {
await queryRunner.release();
}
}
public async updateOneObject(
updateObjectInput: UpdateOneObjectInput,
workspaceId: string,
): Promise<ObjectMetadataEntity> {
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const objectMetadataRepository =
queryRunner.manager.getRepository(ObjectMetadataEntity);
const { objectMetadataMaps } =
await this.workspaceMetadataCacheService.getExistingOrRecomputeMetadataMaps(
{ workspaceId },
);
const inputId = updateObjectInput.id;
const inputPayload = {
...updateObjectInput.update,
...(isDefined(updateObjectInput.update.labelSingular)
? {
labelSingular: capitalize(updateObjectInput.update.labelSingular),
}
: {}),
...(isDefined(updateObjectInput.update.labelPlural)
? { labelPlural: capitalize(updateObjectInput.update.labelPlural) }
: {}),
};
validateObjectMetadataInputNamesOrThrow(inputPayload);
const existingObjectMetadata = objectMetadataMaps.byId[inputId];
if (!existingObjectMetadata) {
throw new ObjectMetadataException(
'Object does not exist',
ObjectMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
}
const existingObjectMetadataCombinedWithUpdateInput = {
...existingObjectMetadata,
...inputPayload,
};
validatesNoOtherObjectWithSameNameExistsOrThrows({
objectMetadataNameSingular:
existingObjectMetadataCombinedWithUpdateInput.nameSingular,
objectMetadataNamePlural:
existingObjectMetadataCombinedWithUpdateInput.namePlural,
existingObjectMetadataId:
existingObjectMetadataCombinedWithUpdateInput.id,
objectMetadataMaps,
});
if (existingObjectMetadataCombinedWithUpdateInput.isLabelSyncedWithName) {
validateNameAndLabelAreSyncOrThrow({
label: existingObjectMetadataCombinedWithUpdateInput.labelSingular,
name: existingObjectMetadataCombinedWithUpdateInput.nameSingular,
});
validateNameAndLabelAreSyncOrThrow({
label: existingObjectMetadataCombinedWithUpdateInput.labelPlural,
name: existingObjectMetadataCombinedWithUpdateInput.namePlural,
});
}
if (
isDefined(inputPayload.nameSingular) ||
isDefined(inputPayload.namePlural)
) {
validateLowerCasedAndTrimmedStringsAreDifferentOrThrow({
inputs: [
existingObjectMetadataCombinedWithUpdateInput.nameSingular,
existingObjectMetadataCombinedWithUpdateInput.namePlural,
],
message:
'The singular and plural names cannot be the same for an object',
});
}
validateMetadataIdentifierFieldMetadataIds({
fieldMetadataItems: Object.values(existingObjectMetadata.fieldsById),
labelIdentifierFieldMetadataId:
inputPayload.labelIdentifierFieldMetadataId,
imageIdentifierFieldMetadataId:
inputPayload.imageIdentifierFieldMetadataId,
});
const updatedObject = await objectMetadataRepository.save({
...existingObjectMetadata,
...inputPayload,
});
const { didUpdateLabelOrIcon } =
await this.handleObjectNameAndLabelUpdates({
existingObjectMetadata,
objectMetadataForUpdate:
existingObjectMetadataCombinedWithUpdateInput,
inputPayload,
queryRunner,
objectMetadataMaps,
});
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrationsWithinTransaction(
workspaceId,
queryRunner,
);
if (inputPayload.labelIdentifierFieldMetadataId) {
const labelIdentifierFieldMetadata =
existingObjectMetadata.fieldsById[
inputPayload.labelIdentifierFieldMetadataId
];
if (isSearchableFieldType(labelIdentifierFieldMetadata.type)) {
await this.searchVectorService.updateSearchVector(
inputId,
[
{
name: labelIdentifierFieldMetadata.name,
type: labelIdentifierFieldMetadata.type,
},
],
workspaceId,
queryRunner,
);
}
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrationsWithinTransaction(
workspaceId,
queryRunner,
);
}
await queryRunner.commitTransaction();
// After commit, do non-transactional work
await this.workspacePermissionsCacheService.recomputeRolesPermissionsCache(
{
workspaceId,
},
);
if (didUpdateLabelOrIcon) {
await this.objectMetadataRelatedRecordsService.updateObjectViews(
updatedObject,
workspaceId,
);
}
if (
isDefined(inputPayload.labelIdentifierFieldMetadataId) &&
inputPayload.labelIdentifierFieldMetadataId !==
existingObjectMetadata.labelIdentifierFieldMetadataId
) {
const labelIdentifierFieldMetadata =
existingObjectMetadata.fieldsById[
inputPayload.labelIdentifierFieldMetadataId
];
await this.objectMetadataRelatedRecordsService.updateLabelMetadataIdentifierInObjectViews(
{
newLabelMetadataIdentifierFieldMetadata:
labelIdentifierFieldMetadata,
},
);
}
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
await this.flatEntityMapsCacheService.invalidateFlatEntityMaps({
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
});
const formattedUpdatedObject = {
...updatedObject,
createdAt: new Date(updatedObject.createdAt),
updatedAt: new Date(updatedObject.updatedAt),
};
return formattedUpdatedObject;
} catch (error) {
if (queryRunner.isTransactionActive) {
try {
await queryRunner.rollbackTransaction();
} catch (error) {
// eslint-disable-next-line no-console
console.trace(`Failed to rollback transaction: ${error.message}`);
}
}
throw error;
} finally {
await queryRunner.release();
}
}
public async deleteOneObject(
deleteObjectInput: DeleteOneObjectInput,
workspaceId: string,
): Promise<Partial<ObjectMetadataEntity>> {
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
workspaceId,
);
if (isWorkspaceMigrationV2Enabled) {
return await this.objectMetadataServiceV2.deleteOne({
deleteObjectInput,
workspaceId,
});
}
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const objectMetadataRepository =
queryRunner.manager.getRepository(ObjectMetadataEntity);
const fieldMetadataRepository =
queryRunner.manager.getRepository(FieldMetadataEntity);
const objectMetadata = await objectMetadataRepository.findOne({
relations: [
'fields',
'fields.object',
'fields.relationTargetFieldMetadata',
'fields.relationTargetFieldMetadata.object',
],
where: {
id: deleteObjectInput.id,
workspaceId,
},
});
if (!objectMetadata) {
throw new ObjectMetadataException(
'Object does not exist',
ObjectMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
}
if (objectMetadata.isRemote) {
throw new ObjectMetadataException(
'Remote objects are not supported yet',
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
);
} else {
await this.objectMetadataMigrationService.deleteAllRelationsAndDropTable(
objectMetadata,
workspaceId,
queryRunner,
);
}
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrationsWithinTransaction(
workspaceId,
queryRunner,
);
const fieldMetadataIds = objectMetadata.fields.map((field) => field.id);
const relationMetadataIds = objectMetadata.fields.flatMap((field) => {
if (
isFieldMetadataEntityOfType(field, FieldMetadataType.MORPH_RELATION)
) {
return field.relationTargetFieldMetadata.id;
}
return [];
});
await fieldMetadataRepository.delete({
id: In(fieldMetadataIds.concat(relationMetadataIds)),
});
await objectMetadataRepository.delete(objectMetadata.id);
await queryRunner.commitTransaction();
// After commit, do non-transactional work
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
await this.flatEntityMapsCacheService.invalidateFlatEntityMaps({
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
});
await this.workspacePermissionsCacheService.recomputeRolesPermissionsCache(
{
workspaceId,
},
);
await this.objectMetadataRelatedRecordsService.deleteObjectViews(
objectMetadata,
workspaceId,
);
return objectMetadata;
} catch (error) {
if (queryRunner.isTransactionActive) {
try {
await queryRunner.rollbackTransaction();
} catch (error) {
// eslint-disable-next-line no-console
console.trace(`Failed to rollback transaction: ${error.message}`);
}
}
throw error;
} finally {
await queryRunner.release();
}
}
public async findOneWithinWorkspace(
workspaceId: string,
options: FindOneOptions<ObjectMetadataEntity>,
): Promise<ObjectMetadataEntity | null> {
return this.objectMetadataRepository.findOne({
relations: [
'fields',
'indexMetadatas',
'indexMetadatas.indexFieldMetadatas',
],
...options,
where: {
...options.where,
workspaceId,
},
});
}
public async findManyWithinWorkspace(
workspaceId: string,
options?: FindManyOptions<ObjectMetadataEntity>,
) {
return this.objectMetadataRepository.find({
relations: [
'fields.object',
'fields',
'fields.relationTargetObjectMetadata',
],
...options,
where: {
...options?.where,
workspaceId,
},
order: {
...options?.order,
},
});
}
public async deleteObjectsMetadata(workspaceId: string) {
const objectsMetadata = await this.objectMetadataRepository.find({
where: {
workspaceId,
},
});
await this.fieldMetadataRepository.delete({
workspaceId,
type: In([FieldMetadataType.MORPH_RELATION, FieldMetadataType.RELATION]),
});
for (const objectMetadata of objectsMetadata) {
await this.objectMetadataRepository.delete({
id: objectMetadata.id,
});
}
}
private async handleObjectNameAndLabelUpdates({
existingObjectMetadata,
objectMetadataForUpdate,
inputPayload,
queryRunner,
objectMetadataMaps,
}: {
existingObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'nameSingular' | 'isCustom' | 'id' | 'labelPlural' | 'icon' | 'fieldsById'
>;
objectMetadataForUpdate: Pick<
ObjectMetadataItemWithFieldMaps,
| 'nameSingular'
| 'isCustom'
| 'workspaceId'
| 'id'
| 'labelSingular'
| 'labelPlural'
| 'icon'
| 'fieldsById'
>;
inputPayload: UpdateObjectPayload;
queryRunner: QueryRunner;
objectMetadataMaps: ObjectMetadataMaps;
}): Promise<{ didUpdateLabelOrIcon: boolean }> {
const newTargetTableName = computeObjectTargetTable(
objectMetadataForUpdate,
);
const existingTargetTableName = computeObjectTargetTable(
existingObjectMetadata,
);
if (newTargetTableName !== existingTargetTableName) {
await this.objectMetadataMigrationService.createRenameTableMigration(
existingObjectMetadata,
objectMetadataForUpdate,
objectMetadataForUpdate.workspaceId,
queryRunner,
);
const relationMetadataCollection =
await this.objectMetadataFieldRelationService.updateRelationsAndForeignKeysMetadata(
{
workspaceId: objectMetadataForUpdate.workspaceId,
updatedObjectMetadata: objectMetadataForUpdate,
queryRunner,
objectMetadataMaps,
},
);
await this.objectMetadataMigrationService.updateRelationMigrations(
existingObjectMetadata,
objectMetadataForUpdate,
relationMetadataCollection,
objectMetadataForUpdate.workspaceId,
queryRunner,
);
const morphRelationFieldMetadataToUpdate =
await this.objectMetadataFieldRelationService.updateMorphRelationsJoinColumnName(
{
existingObjectMetadata,
objectMetadataForUpdate,
queryRunner,
},
);
await this.objectMetadataMigrationService.updateMorphRelationMigrations({
workspaceId: objectMetadataForUpdate.workspaceId,
morphRelationFieldMetadataToUpdate: morphRelationFieldMetadataToUpdate,
queryRunner,
});
await this.objectMetadataMigrationService.recomputeEnumNames(
objectMetadataForUpdate,
objectMetadataForUpdate.workspaceId,
queryRunner,
);
const recomputedIndexes =
await this.indexMetadataService.recomputeIndexMetadataForObject(
objectMetadataForUpdate.workspaceId,
objectMetadataForUpdate,
queryRunner,
);
await this.indexMetadataService.createIndexRecomputeMigrations(
objectMetadataForUpdate.workspaceId,
objectMetadataForUpdate,
recomputedIndexes,
queryRunner,
);
if (
(inputPayload.labelPlural || inputPayload.icon) &&
(inputPayload.labelPlural !== existingObjectMetadata.labelPlural ||
inputPayload.icon !== existingObjectMetadata.icon)
) {
return {
didUpdateLabelOrIcon: true,
};
}
}
return {
didUpdateLabelOrIcon: false,
};
}
}
@@ -1,617 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { FieldMetadataType, RelationOnDeleteAction } from 'twenty-shared/types';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { type QueryRunner, Repository } from 'typeorm';
import { v4 as uuidV4 } from 'uuid';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { buildDescriptionForRelationFieldMetadataOnFromField } from 'src/engine/metadata-modules/object-metadata/utils/build-description-for-relation-field-on-from-field.util';
import { buildDescriptionForRelationFieldMetadataOnToField } from 'src/engine/metadata-modules/object-metadata/utils/build-description-for-relation-field-on-to-field.util';
import { type FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { InvalidMetadataException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
import { validateFieldNameAvailabilityOrThrow } from 'src/engine/metadata-modules/utils/validate-field-name-availability.utils';
import {
CUSTOM_OBJECT_STANDARD_FIELD_IDS,
STANDARD_OBJECT_FIELD_IDS,
} from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-icons';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { createRelationDeterministicUuid } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/create-deterministic-uuid.util';
export const DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS = [
STANDARD_OBJECT_IDS.timelineActivity,
STANDARD_OBJECT_IDS.favorite,
STANDARD_OBJECT_IDS.attachment,
STANDARD_OBJECT_IDS.noteTarget,
STANDARD_OBJECT_IDS.taskTarget,
];
type PartialRelationFieldMetadata = Partial<
FieldMetadataEntity<FieldMetadataType.RELATION>
> &
Required<Pick<FieldMetadataEntity<FieldMetadataType.RELATION>, 'name'>>;
@Injectable()
export class ObjectMetadataFieldRelationService {
constructor(
@InjectRepository(ObjectMetadataEntity)
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
@InjectRepository(FieldMetadataEntity)
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
) {}
public async createRelationsAndForeignKeysMetadata(
workspaceId: string,
sourceObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'id' | 'nameSingular' | 'labelSingular' | 'fieldsById'
>,
objectMetadataMaps: ObjectMetadataMaps,
queryRunner?: QueryRunner,
) {
const relatedObjectMetadataCollection = await Promise.all(
DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS.map(
async (relationObjectMetadataStandardId) =>
this.createRelationAndForeignKeyMetadata({
workspaceId,
sourceObjectMetadata,
relationObjectMetadataStandardId,
objectMetadataMaps,
queryRunner,
}),
),
);
return relatedObjectMetadataCollection;
}
private async createRelationAndForeignKeyMetadata({
workspaceId,
sourceObjectMetadata,
relationObjectMetadataStandardId,
objectMetadataMaps,
queryRunner,
}: {
workspaceId: string;
sourceObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'id' | 'nameSingular' | 'labelSingular' | 'fieldsById'
>;
objectMetadataMaps: ObjectMetadataMaps;
relationObjectMetadataStandardId: string;
queryRunner?: QueryRunner;
}) {
const targetObjectMetadata = Object.values(objectMetadataMaps.byId)
.filter(isDefined)
.find(
(objectMetadata) =>
objectMetadata.standardId === relationObjectMetadataStandardId,
);
if (!targetObjectMetadata) {
throw new Error(
`Target object metadata not found for standard ID: ${relationObjectMetadataStandardId}`,
);
}
await this.createFieldMetadataRelation(
workspaceId,
sourceObjectMetadata,
targetObjectMetadata,
queryRunner,
);
return targetObjectMetadata;
}
private async createFieldMetadataRelation(
workspaceId: string,
sourceObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'id' | 'nameSingular' | 'labelSingular' | 'fieldsById'
>,
targetObjectMetadata: ObjectMetadataItemWithFieldMaps,
queryRunner?: QueryRunner,
): Promise<FieldMetadataEntity<FieldMetadataType.RELATION>[]> {
const sourceFieldMetadata = this.createSourceFieldMetadata(
workspaceId,
sourceObjectMetadata,
targetObjectMetadata,
);
this.validateFieldNameAvailabilityOrThrow({
name: sourceFieldMetadata.name,
fieldMetadataMapById: sourceObjectMetadata.fieldsById,
});
const targetFieldMetadata = this.createTargetFieldMetadata(
workspaceId,
sourceObjectMetadata,
targetObjectMetadata,
);
this.validateFieldNameAvailabilityOrThrow({
name: targetFieldMetadata.name,
fieldMetadataMapById: sourceObjectMetadata.fieldsById,
});
const fieldMetadataRepository = queryRunner
? queryRunner.manager.getRepository(FieldMetadataEntity)
: this.fieldMetadataRepository;
return fieldMetadataRepository.save([
{
...sourceFieldMetadata,
settings: {
relationType: RelationType.ONE_TO_MANY,
},
relationTargetObjectMetadataId: targetObjectMetadata.id,
relationTargetFieldMetadataId: targetFieldMetadata.id,
} as Partial<FieldMetadataEntity<FieldMetadataType.RELATION>>,
{
...targetFieldMetadata,
settings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: RelationOnDeleteAction.CASCADE,
joinColumnName: `${sourceObjectMetadata.nameSingular}Id`,
},
relationTargetObjectMetadataId: sourceObjectMetadata.id,
relationTargetFieldMetadataId: sourceFieldMetadata.id,
} as Partial<FieldMetadataEntity<FieldMetadataType.RELATION>>,
]);
}
public async updateRelationsAndForeignKeysMetadata({
workspaceId,
updatedObjectMetadata,
objectMetadataMaps,
queryRunner,
}: {
workspaceId: string;
updatedObjectMetadata: Pick<
ObjectMetadataEntity,
'nameSingular' | 'isCustom' | 'id' | 'labelSingular'
>;
objectMetadataMaps: ObjectMetadataMaps;
queryRunner?: QueryRunner;
}): Promise<
{
targetObjectMetadata: ObjectMetadataItemWithFieldMaps;
targetFieldMetadata: FieldMetadataEntity;
sourceFieldMetadata: FieldMetadataEntity;
}[]
> {
return await Promise.all(
DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS.map(
async (relationObjectMetadataStandardId) =>
this.updateRelationAndForeignKeyMetadata({
workspaceId,
sourceObjectMetadata: updatedObjectMetadata,
targetObjectMetadataStandardId: relationObjectMetadataStandardId,
objectMetadataMaps,
queryRunner,
}),
),
);
}
private async updateRelationAndForeignKeyMetadata({
workspaceId,
sourceObjectMetadata,
targetObjectMetadataStandardId,
objectMetadataMaps,
queryRunner,
}: {
workspaceId: string;
sourceObjectMetadata: Pick<
ObjectMetadataEntity,
'nameSingular' | 'id' | 'isCustom' | 'labelSingular'
>;
targetObjectMetadataStandardId: string;
objectMetadataMaps: ObjectMetadataMaps;
queryRunner?: QueryRunner;
}): Promise<{
targetObjectMetadata: ObjectMetadataItemWithFieldMaps;
targetFieldMetadata: FieldMetadataEntity;
sourceFieldMetadata: FieldMetadataEntity;
}> {
const fieldMetadataRepository = queryRunner
? queryRunner.manager.getRepository(FieldMetadataEntity)
: this.fieldMetadataRepository;
const targetObjectMetadataId = Object.values(objectMetadataMaps.byId).find(
(objectMetadata) =>
objectMetadata?.standardId === targetObjectMetadataStandardId &&
objectMetadata.isCustom === false,
)?.id;
if (!targetObjectMetadataId) {
throw new Error(
`Target object metadata id not found for standard ID: ${targetObjectMetadataStandardId}`,
);
}
const targetObjectMetadata =
objectMetadataMaps.byId[targetObjectMetadataId];
if (!targetObjectMetadata) {
throw new Error(
`Target object metadata not found for id: ${targetObjectMetadataId}`,
);
}
const targetFieldMetadataUpdateData = this.updateTargetFieldMetadata(
sourceObjectMetadata,
targetObjectMetadata,
);
const targetFieldMetadataToUpdate =
await fieldMetadataRepository.findOneByOrFail({
standardId: createRelationDeterministicUuid({
objectId: sourceObjectMetadata.id,
standardId:
// @ts-expect-error legacy noImplicitAny
STANDARD_OBJECT_FIELD_IDS[targetObjectMetadata.nameSingular].custom,
}),
objectMetadataId: targetObjectMetadata.id,
workspaceId: workspaceId,
});
const nameIsUpdated =
targetFieldMetadataUpdateData.name !== targetFieldMetadataToUpdate.name;
if (nameIsUpdated) {
const targetObjectMetadataFieldsById =
objectMetadataMaps.byId[targetObjectMetadata.id]?.fieldsById;
if (!targetObjectMetadataFieldsById) {
throw new Error(
`Target object metadata fields not found for ${targetObjectMetadata.id}`,
);
}
this.validateFieldNameAvailabilityOrThrow({
name: targetFieldMetadataUpdateData.name,
fieldMetadataMapById: targetObjectMetadataFieldsById,
});
}
const isTargetFieldMetadataManyToOneRelation =
(
targetFieldMetadataToUpdate as FieldMetadataEntity<FieldMetadataType.RELATION>
).settings?.relationType === RelationType.MANY_TO_ONE;
const targetFieldMetadata = await fieldMetadataRepository.save({
id: targetFieldMetadataToUpdate.id,
...targetFieldMetadataUpdateData,
settings: {
...targetFieldMetadataToUpdate.settings,
...(isTargetFieldMetadataManyToOneRelation
? {
joinColumnName: `${sourceObjectMetadata.nameSingular}Id`,
}
: {}),
},
});
const sourceFieldMetadataUpdateData = this.updateSourceFieldMetadata(
sourceObjectMetadata,
targetObjectMetadata,
);
const sourceFieldMetadataToUpdate =
await fieldMetadataRepository.findOneByOrFail({
standardId:
// @ts-expect-error legacy noImplicitAny
CUSTOM_OBJECT_STANDARD_FIELD_IDS[targetObjectMetadata.namePlural],
objectMetadataId: sourceObjectMetadata.id,
workspaceId: workspaceId,
});
const isSourceFieldMetadataManyToOneRelation =
(
sourceFieldMetadataToUpdate as FieldMetadataEntity<FieldMetadataType.RELATION>
).settings?.relationType === RelationType.MANY_TO_ONE;
const sourceFieldMetadata = await fieldMetadataRepository.save({
id: sourceFieldMetadataToUpdate.id,
...sourceFieldMetadataUpdateData,
settings: {
...sourceFieldMetadataToUpdate.settings,
...(isSourceFieldMetadataManyToOneRelation
? {
joinColumnName: `${targetObjectMetadata.nameSingular}Id`,
}
: {}),
},
});
return {
targetObjectMetadata,
targetFieldMetadata,
sourceFieldMetadata,
};
}
private createSourceFieldMetadata(
workspaceId: string,
sourceObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'labelSingular' | 'id'
>,
targetObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'namePlural' | 'labelSingular'
>,
): PartialRelationFieldMetadata {
const relationObjectMetadataNamePlural = targetObjectMetadata.namePlural;
const { description } = buildDescriptionForRelationFieldMetadataOnFromField(
{
relationObjectMetadataNamePlural,
targetObjectLabelSingular: sourceObjectMetadata.labelSingular,
},
);
return {
id: uuidV4(),
standardId:
// @ts-expect-error legacy noImplicitAny
CUSTOM_OBJECT_STANDARD_FIELD_IDS[relationObjectMetadataNamePlural],
objectMetadataId: sourceObjectMetadata.id,
workspaceId: workspaceId,
isCustom: false,
isActive: true,
isSystem: true,
type: FieldMetadataType.RELATION,
name: targetObjectMetadata.namePlural,
label: capitalize(relationObjectMetadataNamePlural),
description,
icon:
// @ts-expect-error legacy noImplicitAny
STANDARD_OBJECT_ICONS[targetObjectMetadata.nameSingular] ||
'IconBuildingSkyscraper',
isNullable: true,
};
}
private updateSourceFieldMetadata(
sourceObjectMetadata: Pick<ObjectMetadataEntity, 'labelSingular'>,
targetObjectMetadata: Pick<ObjectMetadataEntity, 'namePlural'>,
) {
const relationObjectMetadataNamePlural = targetObjectMetadata.namePlural;
const { description } = buildDescriptionForRelationFieldMetadataOnFromField(
{
relationObjectMetadataNamePlural,
targetObjectLabelSingular: sourceObjectMetadata.labelSingular,
},
);
return {
description,
};
}
private createTargetFieldMetadata(
workspaceId: string,
sourceObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'labelSingular' | 'id' | 'nameSingular'
>,
targetObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'namePlural' | 'labelSingular' | 'id' | 'nameSingular'
>,
): PartialRelationFieldMetadata {
const customStandardFieldId =
// @ts-expect-error legacy noImplicitAny
STANDARD_OBJECT_FIELD_IDS[targetObjectMetadata.nameSingular].custom;
if (!customStandardFieldId) {
throw new Error(
`Custom standard field ID not found for ${targetObjectMetadata.nameSingular}`,
);
}
const { description } = buildDescriptionForRelationFieldMetadataOnToField({
relationObjectMetadataNamePlural: targetObjectMetadata.namePlural,
targetObjectLabelSingular: sourceObjectMetadata.labelSingular,
});
return {
id: uuidV4(),
name: sourceObjectMetadata.nameSingular,
label: sourceObjectMetadata.labelSingular,
description,
standardId: createRelationDeterministicUuid({
objectId: sourceObjectMetadata.id,
standardId: customStandardFieldId,
}),
objectMetadataId: targetObjectMetadata.id,
workspaceId: workspaceId,
isCustom: false,
isActive: true,
isSystem: true,
type: FieldMetadataType.RELATION,
icon: 'IconBuildingSkyscraper',
isNullable: true,
};
}
private updateTargetFieldMetadata(
sourceObjectMetadata: Pick<
ObjectMetadataEntity,
'nameSingular' | 'labelSingular'
>,
targetObjectMetadata: Pick<
ObjectMetadataEntity,
'nameSingular' | 'namePlural'
>,
) {
const customStandardFieldId =
// @ts-expect-error legacy noImplicitAny
STANDARD_OBJECT_FIELD_IDS[targetObjectMetadata.nameSingular].custom;
if (!customStandardFieldId) {
throw new Error(
`Custom standard field ID not found for ${targetObjectMetadata.nameSingular}`,
);
}
const { description } = buildDescriptionForRelationFieldMetadataOnToField({
relationObjectMetadataNamePlural: targetObjectMetadata.namePlural,
targetObjectLabelSingular: sourceObjectMetadata.labelSingular,
});
return {
name: sourceObjectMetadata.nameSingular,
label: sourceObjectMetadata.labelSingular,
description,
};
}
private validateFieldMetadataTypeIsMorphRelation = (
fieldMetadatas: FieldMetadataEntity[],
): fieldMetadatas is Array<
FieldMetadataEntity & FieldMetadataEntity<FieldMetadataType.MORPH_RELATION>
> => {
return fieldMetadatas.every(
(fieldMetadata) =>
fieldMetadata.type === FieldMetadataType.MORPH_RELATION,
);
};
public async findTargetMorphRelationFieldMetadatas(
objectMetadataId: string,
): Promise<FieldMetadataEntity<FieldMetadataType.MORPH_RELATION>[]> {
const fieldMetadatas = await this.fieldMetadataRepository.find({
where: {
relationTargetObjectMetadataId: objectMetadataId,
type: FieldMetadataType.MORPH_RELATION,
},
relations: {
relationTargetObjectMetadata: true,
object: true,
},
});
if (!this.validateFieldMetadataTypeIsMorphRelation(fieldMetadatas)) {
throw new ObjectMetadataException(
'Invalid field metadata type. Expected MORPH_RELATION only',
ObjectMetadataExceptionCode.INVALID_ORM_OUTPUT,
);
}
return fieldMetadatas;
}
// Not maintained on v1, this side effect is broken and will duplicated field name references on
// object update
// It's functional on v2
public async updateMorphRelationsJoinColumnName({
existingObjectMetadata,
objectMetadataForUpdate: _,
queryRunner,
}: {
existingObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'nameSingular' | 'isCustom' | 'id' | 'labelPlural' | 'icon' | 'fieldsById'
>;
objectMetadataForUpdate: Pick<
ObjectMetadataItemWithFieldMaps,
| 'nameSingular'
| 'isCustom'
| 'workspaceId'
| 'id'
| 'labelSingular'
| 'labelPlural'
| 'icon'
| 'fieldsById'
>;
queryRunner: QueryRunner;
}): Promise<
{
fieldMetadata: FieldMetadataEntity<FieldMetadataType.MORPH_RELATION>;
newJoinColumnName: string;
}[]
> {
const fieldMetadataRepository =
queryRunner.manager.getRepository(FieldMetadataEntity);
const morphRelationFieldMetadataTargets =
await this.findTargetMorphRelationFieldMetadatas(
existingObjectMetadata.id,
);
const morphRelationFieldMetadataToUpdate =
morphRelationFieldMetadataTargets.filter(
(morphRelationFieldMetadata) =>
morphRelationFieldMetadata.settings?.relationType ===
RelationType.MANY_TO_ONE,
);
const morphRelationFieldMetadataToUpdateWithNewJoinColumnName = [];
if (morphRelationFieldMetadataToUpdate.length > 0) {
for (const morphRelationFieldMetadata of morphRelationFieldMetadataToUpdate) {
const newJoinColumnName = computeMorphOrRelationFieldJoinColumnName({
name: morphRelationFieldMetadata.name,
});
await fieldMetadataRepository.save({
...morphRelationFieldMetadata,
settings: {
...morphRelationFieldMetadata.settings,
joinColumnName: newJoinColumnName,
},
});
morphRelationFieldMetadataToUpdateWithNewJoinColumnName.push({
fieldMetadata: morphRelationFieldMetadata,
newJoinColumnName,
});
}
}
return morphRelationFieldMetadataToUpdateWithNewJoinColumnName;
}
private validateFieldNameAvailabilityOrThrow({
name,
fieldMetadataMapById,
}: {
name: string;
fieldMetadataMapById: FieldMetadataMap;
}) {
try {
validateFieldNameAvailabilityOrThrow({
name,
fieldMetadataMapById,
});
} catch (error) {
if (error instanceof InvalidMetadataException) {
throw new ObjectMetadataException(
`Name "${name}" is not available.`,
ObjectMetadataExceptionCode.NAME_CONFLICT,
{
userFriendlyMessage: msg`Name "${name}" is not available.`,
},
);
}
throw error;
}
}
}
@@ -1,424 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FieldMetadataType } from 'twenty-shared/types';
import { type QueryRunner, Repository } from 'typeorm';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { buildMigrationsForCustomObjectRelations } from 'src/engine/metadata-modules/object-metadata/utils/build-migrations-for-custom-object-relations.util';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { fieldMetadataTypeToColumnType } from 'src/engine/metadata-modules/workspace-migration/utils/field-metadata-type-to-column-type.util';
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
import {
WorkspaceMigrationColumnActionType,
type WorkspaceMigrationColumnDrop,
type WorkspaceMigrationTableAction,
WorkspaceMigrationTableActionType,
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.factory';
import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { computeTableName } from 'src/engine/utils/compute-table-name.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
import { RELATION_MIGRATION_PRIORITY_PREFIX } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
@Injectable()
export class ObjectMetadataMigrationService {
constructor(
@InjectRepository(FieldMetadataEntity)
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
private readonly workspaceMigrationService: WorkspaceMigrationService,
private readonly workspaceMigrationFactory: WorkspaceMigrationFactory,
) {}
public async createTableMigration(
createdObjectMetadata: ObjectMetadataEntity,
queryRunner?: QueryRunner,
) {
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`create-${createdObjectMetadata.nameSingular}`),
createdObjectMetadata.workspaceId,
[
{
name: computeObjectTargetTable(createdObjectMetadata),
action: WorkspaceMigrationTableActionType.CREATE,
} satisfies WorkspaceMigrationTableAction,
],
queryRunner,
);
}
public async createColumnsMigrations(
createdObjectMetadata: ObjectMetadataEntity,
fieldMetadataCollection: FieldMetadataEntity[],
queryRunner?: QueryRunner,
) {
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(
`create-${createdObjectMetadata.nameSingular}-fields`,
),
createdObjectMetadata.workspaceId,
[
{
name: computeObjectTargetTable(createdObjectMetadata),
action: WorkspaceMigrationTableActionType.ALTER,
columns: fieldMetadataCollection.flatMap((fieldMetadata) =>
this.workspaceMigrationFactory.createColumnActions(
WorkspaceMigrationColumnActionType.CREATE,
fieldMetadata,
),
),
},
],
queryRunner,
);
}
public async createRelationMigrations(
createdObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'nameSingular' | 'workspaceId' | 'isCustom'
>,
relatedObjectMetadataCollection: Pick<
ObjectMetadataItemWithFieldMaps,
'nameSingular' | 'isCustom'
>[],
queryRunner?: QueryRunner,
) {
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(
`create-${createdObjectMetadata.nameSingular}-relations`,
),
createdObjectMetadata.workspaceId,
buildMigrationsForCustomObjectRelations(
createdObjectMetadata,
relatedObjectMetadataCollection,
),
queryRunner,
);
}
public async createRenameTableMigration(
existingObjectMetadata: Pick<
ObjectMetadataEntity,
'nameSingular' | 'isCustom'
>,
objectMetadataForUpdate: Pick<
ObjectMetadataEntity,
'nameSingular' | 'isCustom'
>,
workspaceId: string,
queryRunner?: QueryRunner,
) {
const newTargetTableName = computeObjectTargetTable(
objectMetadataForUpdate,
);
const existingTargetTableName = computeObjectTargetTable(
existingObjectMetadata,
);
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`rename-${existingObjectMetadata.nameSingular}`),
workspaceId,
[
{
name: existingTargetTableName,
newName: newTargetTableName,
action: WorkspaceMigrationTableActionType.ALTER,
},
],
queryRunner,
);
}
public async updateRelationMigrations(
currentObjectMetadata: Pick<ObjectMetadataEntity, 'nameSingular'>,
alteredObjectMetadata: Pick<ObjectMetadataEntity, 'nameSingular'>,
relationMetadataCollection: {
targetObjectMetadata: ObjectMetadataItemWithFieldMaps;
targetFieldMetadata: FieldMetadataEntity;
sourceFieldMetadata: FieldMetadataEntity;
}[],
workspaceId: string,
queryRunner?: QueryRunner,
) {
for (const { targetObjectMetadata } of relationMetadataCollection) {
const targetTableName = computeObjectTargetTable(targetObjectMetadata);
const columnName = `${currentObjectMetadata.nameSingular}Id`;
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(
`rename-${currentObjectMetadata.nameSingular}-to-${alteredObjectMetadata.nameSingular}-in-${targetObjectMetadata.nameSingular}`,
),
workspaceId,
[
{
name: targetTableName,
action: WorkspaceMigrationTableActionType.ALTER,
columns: [
{
action: WorkspaceMigrationColumnActionType.ALTER,
currentColumnDefinition: {
columnName,
columnType: 'uuid',
isNullable: true,
defaultValue: null,
},
alteredColumnDefinition: {
columnName: `${alteredObjectMetadata.nameSingular}Id`,
columnType: 'uuid',
isNullable: true,
defaultValue: null,
},
},
],
},
],
queryRunner,
);
}
}
public async createUpdateForeignKeysMigrations(
existingObjectMetadata: ObjectMetadataEntity,
updatedObjectMetadata: ObjectMetadataEntity,
relationsAndForeignKeysMetadata: {
relatedObjectMetadata: ObjectMetadataEntity;
foreignKeyFieldMetadata: FieldMetadataEntity;
}[],
workspaceId: string,
queryRunner?: QueryRunner,
) {
for (const {
relatedObjectMetadata,
foreignKeyFieldMetadata,
} of relationsAndForeignKeysMetadata) {
const relatedObjectTableName = computeObjectTargetTable(
relatedObjectMetadata,
);
const columnName = `${existingObjectMetadata.nameSingular}Id`;
const columnType = fieldMetadataTypeToColumnType(
foreignKeyFieldMetadata.type,
);
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(
`rename-${existingObjectMetadata.nameSingular}-to-${updatedObjectMetadata.nameSingular}-in-${relatedObjectMetadata.nameSingular}`,
),
workspaceId,
[
{
name: relatedObjectTableName,
action: WorkspaceMigrationTableActionType.ALTER,
columns: [
{
action: WorkspaceMigrationColumnActionType.ALTER,
currentColumnDefinition: {
columnName,
columnType,
isNullable: true,
defaultValue: null,
},
alteredColumnDefinition: {
columnName: `${updatedObjectMetadata.nameSingular}Id`,
columnType,
isNullable: true,
defaultValue: null,
},
},
],
},
],
queryRunner,
);
}
}
public async deleteAllRelationsAndDropTable(
objectMetadata: ObjectMetadataEntity,
workspaceId: string,
queryRunner?: QueryRunner,
) {
const relationFields = objectMetadata.fields.filter(
(field) =>
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) ||
isFieldMetadataEntityOfType(field, FieldMetadataType.MORPH_RELATION),
) as FieldMetadataEntity<
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
>[];
const relationFieldsToDelete = [
...relationFields,
...(relationFields.map(
(relation) => relation.relationTargetFieldMetadata,
) as FieldMetadataEntity<
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
>[]),
];
if (relationFieldsToDelete.length !== 0) {
await this.fieldMetadataRepository.delete(
relationFieldsToDelete.map((relation) => relation.id),
);
}
for (const relationToDelete of relationFieldsToDelete) {
if (
relationToDelete.settings?.relationType === RelationType.ONE_TO_MANY
) {
continue;
}
const joinColumnName = relationToDelete.settings?.joinColumnName;
if (!joinColumnName) {
throw new Error(
`Join column name is not set for relation field ${relationToDelete.name}`,
);
}
if (relationToDelete.type !== FieldMetadataType.MORPH_RELATION) {
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(
`delete-${RELATION_MIGRATION_PRIORITY_PREFIX}-${relationToDelete.name}`,
),
workspaceId,
[
{
name: computeTableName(
relationToDelete.object.nameSingular,
relationToDelete.object.isCustom,
),
action: WorkspaceMigrationTableActionType.ALTER,
columns: [
{
action: WorkspaceMigrationColumnActionType.DROP,
columnName: joinColumnName,
} satisfies WorkspaceMigrationColumnDrop,
],
},
],
queryRunner,
);
}
}
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`delete-${objectMetadata.nameSingular}`),
workspaceId,
[
{
name: computeObjectTargetTable(objectMetadata),
action: WorkspaceMigrationTableActionType.DROP,
},
],
queryRunner,
);
}
public async recomputeEnumNames(
updatedObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'nameSingular' | 'isCustom' | 'id' | 'fieldsById'
>,
workspaceId: string,
queryRunner?: QueryRunner,
) {
const enumFieldMetadataTypes = [
FieldMetadataType.SELECT,
FieldMetadataType.MULTI_SELECT,
FieldMetadataType.RATING,
FieldMetadataType.ACTOR,
];
const fieldMetadataToUpdate = Object.values(
updatedObjectMetadata.fieldsById,
).filter((field) => enumFieldMetadataTypes.includes(field.type));
for (const fieldMetadata of fieldMetadataToUpdate) {
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`update-${fieldMetadata.name}-enum-name`),
workspaceId,
[
{
name: computeTableName(
updatedObjectMetadata.nameSingular,
updatedObjectMetadata.isCustom,
),
action: WorkspaceMigrationTableActionType.ALTER,
columns: this.workspaceMigrationFactory.createColumnActions(
WorkspaceMigrationColumnActionType.ALTER,
fieldMetadata,
fieldMetadata,
),
},
],
queryRunner,
);
}
}
public async updateMorphRelationMigrations({
workspaceId,
morphRelationFieldMetadataToUpdate,
queryRunner,
}: {
workspaceId: string;
morphRelationFieldMetadataToUpdate: {
fieldMetadata: FieldMetadataEntity<FieldMetadataType.MORPH_RELATION>;
newJoinColumnName: string;
}[];
queryRunner?: QueryRunner;
}) {
for (const morphRelationFieldMetadata of morphRelationFieldMetadataToUpdate) {
if (!morphRelationFieldMetadata.fieldMetadata.settings?.joinColumnName) {
throw new ObjectMetadataException(
`Settings for morph relation field should be defined ${morphRelationFieldMetadata.fieldMetadata.name}`,
ObjectMetadataExceptionCode.INVALID_ORM_OUTPUT,
);
}
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(
`rename-join-column-name-${morphRelationFieldMetadata.fieldMetadata.name}-to-${morphRelationFieldMetadata.newJoinColumnName}-in-${morphRelationFieldMetadata.fieldMetadata.object.nameSingular}`,
),
workspaceId,
[
{
name: computeObjectTargetTable(
morphRelationFieldMetadata.fieldMetadata.object,
),
action: WorkspaceMigrationTableActionType.ALTER,
columns: [
{
action: WorkspaceMigrationColumnActionType.ALTER,
currentColumnDefinition: {
columnName:
morphRelationFieldMetadata.fieldMetadata.settings
?.joinColumnName,
columnType: 'uuid',
isNullable: true,
defaultValue: null,
},
alteredColumnDefinition: {
columnName: `${morphRelationFieldMetadata.newJoinColumnName}`,
columnType: 'uuid',
isNullable: true,
defaultValue: null,
},
},
],
},
],
queryRunner,
);
}
}
}
@@ -1,176 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
import { ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { ViewKey } from 'src/engine/metadata-modules/view/enums/view-key.enum';
import { ViewType } from 'src/engine/metadata-modules/view/enums/view-type.enum';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/constants/DEFAULT_VIEW_FIELD_SIZE';
import { type FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
@Injectable()
export class ObjectMetadataRelatedRecordsService {
constructor(
private readonly viewService: ViewService,
private readonly viewFieldService: ViewFieldService,
@InjectRepository(ViewEntity)
private readonly viewRepository: Repository<ViewEntity>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
public async createObjectRelatedRecords(
objectMetadata: ObjectMetadataEntity,
) {
const view = await this.createView(objectMetadata);
await this.createViewFields(objectMetadata, view.id);
await this.createViewWorkspaceFavorite(objectMetadata.workspaceId, view.id);
await this.viewService.flushGraphQLCache(objectMetadata.workspaceId);
}
public async updateLabelMetadataIdentifierInObjectViews({
newLabelMetadataIdentifierFieldMetadata,
}: {
newLabelMetadataIdentifierFieldMetadata: FieldMetadataEntity;
}) {
const objectMetadataViews = await this.viewService.findByObjectMetadataId(
newLabelMetadataIdentifierFieldMetadata.workspaceId,
newLabelMetadataIdentifierFieldMetadata.objectMetadataId,
);
for (const view of objectMetadataViews) {
let labelMetadataIdentifierViewField = view.viewFields.find(
(viewField) =>
viewField.fieldMetadataId ===
newLabelMetadataIdentifierFieldMetadata.id,
);
const currentMinPositionAmongViewFields = this.getViewFieldsMinPosition(
view.viewFields,
);
if (!labelMetadataIdentifierViewField) {
await this.viewFieldService.create({
fieldMetadataId: newLabelMetadataIdentifierFieldMetadata.id,
position: currentMinPositionAmongViewFields - 1,
isVisible: true,
size: DEFAULT_VIEW_FIELD_SIZE,
viewId: view.id,
workspaceId: newLabelMetadataIdentifierFieldMetadata.workspaceId,
});
continue;
}
await this.viewFieldService.update(
labelMetadataIdentifierViewField.id,
newLabelMetadataIdentifierFieldMetadata.workspaceId,
{
position: currentMinPositionAmongViewFields - 1,
isVisible: true,
},
);
}
}
private getViewFieldsMinPosition(viewFields: ViewFieldEntity[]): number {
if (viewFields.length === 0) {
return 0;
}
return viewFields.reduce((min, field) => Math.min(min, field.position), 0);
}
private async createView(
objectMetadata: ObjectMetadataEntity,
): Promise<ViewDTO> {
return await this.viewService.create({
objectMetadataId: objectMetadata.id,
type: ViewType.TABLE,
name: `All ${objectMetadata.labelPlural}`,
key: ViewKey.INDEX,
icon: 'IconList',
workspaceId: objectMetadata.workspaceId,
});
}
private async createViewFields(
objectMetadata: ObjectMetadataEntity,
viewId: string,
): Promise<void> {
const viewFields = objectMetadata.fields
.filter((field) => field.name !== 'id' && field.name !== 'deletedAt')
.map((field, index) => ({
fieldMetadataId: field.id,
position: index,
isVisible: true,
size: DEFAULT_VIEW_FIELD_SIZE,
viewId: viewId,
workspaceId: objectMetadata.workspaceId,
}));
for (const viewField of viewFields) {
await this.viewFieldService.create(viewField);
}
}
public async createViewWorkspaceFavorite(
workspaceId: string,
viewId: string,
): Promise<void> {
const favoriteRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<FavoriteWorkspaceEntity>(
workspaceId,
'favorite',
);
const favoriteCount = await favoriteRepository.count();
await favoriteRepository.insert(
favoriteRepository.create({
viewId: viewId,
position: favoriteCount,
}),
);
}
public async updateObjectViews(
updatedObjectMetadata: Pick<
ObjectMetadataEntity,
'id' | 'labelPlural' | 'icon'
>,
workspaceId: string,
) {
await this.viewRepository.update(
{
objectMetadataId: updatedObjectMetadata.id,
key: ViewKey.INDEX,
workspaceId,
},
{
name: `All ${updatedObjectMetadata.labelPlural}`,
...(isDefined(updatedObjectMetadata.icon)
? { icon: updatedObjectMetadata.icon }
: {}),
},
);
}
public async deleteObjectViews(
objectMetadata: ObjectMetadataEntity,
workspaceId: string,
) {
await this.viewRepository.delete({
objectMetadataId: objectMetadata.id,
workspaceId,
});
}
}
@@ -6,11 +6,9 @@ import { plural } from 'pluralize';
import { DataSource, Repository } from 'typeorm';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
import { type 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 { FieldMetadataServiceV2 } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import {
RemoteServerEntity,
type RemoteServerType,
@@ -57,8 +55,8 @@ export class RemoteTableService {
>,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
private readonly dataSourceService: DataSourceService,
private readonly objectMetadataService: ObjectMetadataService,
private readonly fieldMetadataService: FieldMetadataService,
private readonly objectMetadataService: ObjectMetadataServiceV2,
private readonly fieldMetadataService: FieldMetadataServiceV2,
private readonly distantTableService: DistantTableService,
private readonly foreignTableService: ForeignTableService,
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
@@ -421,10 +419,10 @@ export class RemoteTableService {
});
if (objectMetadata) {
await this.objectMetadataService.deleteOneObject(
{ id: objectMetadata.id },
await this.objectMetadataService.deleteOneObject({
deleteObjectInput: { id: objectMetadata.id },
workspaceId,
);
});
}
await this.foreignTableService.deleteForeignTable(
@@ -455,21 +453,23 @@ export class RemoteTableService {
? `${plural(localTableBaseName)}${localTableSuffix}`
: plural(localTableBaseName);
const objectMetadata = await this.objectMetadataService.createOne({
nameSingular: camelCase(localTableNameSingular),
namePlural: camelCase(localTableNamePlural),
labelSingular: camelToTitleCase(camelCase(localTableBaseName)),
labelPlural: camelToTitleCase(plural(camelCase(localTableBaseName))),
description: 'Remote table',
dataSourceId: dataSourceMetadataId,
const objectMetadata = await this.objectMetadataService.createOneObject({
createObjectInput: {
nameSingular: camelCase(localTableNameSingular),
namePlural: camelCase(localTableNamePlural),
labelSingular: camelToTitleCase(camelCase(localTableBaseName)),
labelPlural: camelToTitleCase(plural(camelCase(localTableBaseName))),
description: 'Remote table',
dataSourceId: dataSourceMetadataId,
icon: 'IconPlug',
isRemote: true,
primaryKeyColumnType: distantTableIdColumn.udtName,
primaryKeyFieldMetadataSettings: mapUdtNameToFieldSettings(
distantTableIdColumn.udtName,
),
},
workspaceId: workspaceId,
icon: 'IconPlug',
isRemote: true,
primaryKeyColumnType: distantTableIdColumn.udtName,
primaryKeyFieldMetadataSettings: mapUdtNameToFieldSettings(
distantTableIdColumn.udtName,
),
} satisfies CreateObjectInput);
});
for (const column of distantTableColumns) {
const columnName = camelCase(column.columnName);
@@ -484,8 +484,14 @@ export class RemoteTableService {
);
if (columnName === 'id') {
await this.objectMetadataService.updateOne(objectMetadata.id, {
labelIdentifierFieldMetadataId: field.id,
await this.objectMetadataService.updateOneObject({
workspaceId,
updateObjectInput: {
id: objectMetadata.id,
update: {
labelIdentifierFieldMetadataId: field.id,
},
},
});
}
} catch (error) {
@@ -589,7 +595,10 @@ export class RemoteTableService {
);
}
await this.fieldMetadataService.deleteOne(fieldMetadataToDelete.id);
await this.fieldMetadataService.deleteOneField({
deleteOneFieldInput: { id: fieldMetadataToDelete.id },
workspaceId,
});
}
}
@@ -598,18 +607,20 @@ export class RemoteTableService {
columnName: string,
columnType: string,
objectMetadataId: string,
): Promise<FieldMetadataEntity> {
return this.fieldMetadataService.createOne({
name: columnName,
label: camelToTitleCase(columnName),
description: 'Field of remote',
type: mapUdtNameToFieldType(columnType),
): Promise<FlatFieldMetadata> {
return this.fieldMetadataService.createOneField({
createFieldInput: {
name: columnName,
label: camelToTitleCase(columnName),
description: 'Field of remote',
type: mapUdtNameToFieldType(columnType),
objectMetadataId: objectMetadataId,
isRemoteCreation: true,
isNullable: true,
icon: 'IconPlug',
settings: mapUdtNameToFieldSettings(columnType),
},
workspaceId: workspaceId,
objectMetadataId: objectMetadataId,
isRemoteCreation: true,
isNullable: true,
icon: 'IconPlug',
settings: mapUdtNameToFieldSettings(columnType),
} satisfies CreateFieldInput);
});
}
}
@@ -1,23 +0,0 @@
import { Module } from '@nestjs/common';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { IndexMetadataModule } from 'src/engine/metadata-modules/index-metadata/index-metadata.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { SearchVectorService } from 'src/engine/metadata-modules/search-vector/search-vector.service';
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
@Module({
imports: [
NestjsQueryTypeOrmModule.forFeature([
ObjectMetadataEntity,
FieldMetadataEntity,
]),
IndexMetadataModule,
WorkspaceMigrationModule,
],
providers: [SearchVectorService],
exports: [SearchVectorService],
})
export class SearchVectorModule {}
@@ -1,191 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type QueryRunner, Repository } from 'typeorm';
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { IndexMetadataService } from 'src/engine/metadata-modules/index-metadata/index-metadata.service';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types';
import { type CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
import { DEFAULT_LABEL_IDENTIFIER_FIELD_NAME } from 'src/engine/metadata-modules/object-metadata/object-metadata.constants';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import {
TsVectorColumnActionFactory,
type TsVectorFieldMetadata,
} from 'src/engine/metadata-modules/workspace-migration/factories/ts-vector-column-action.factory';
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
import {
WorkspaceMigrationColumnActionType,
WorkspaceMigrationTableActionType,
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.factory';
import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service';
import { computeTableName } from 'src/engine/utils/compute-table-name.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
import { CUSTOM_OBJECT_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import {
type FieldTypeAndNameMetadata,
getTsVectorColumnExpressionFromFields,
} from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util';
import { type SearchableFieldType } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-searchable-field.util';
@Injectable()
export class SearchVectorService {
constructor(
@InjectRepository(ObjectMetadataEntity)
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
private readonly tsVectorColumnActionFactory: TsVectorColumnActionFactory,
private readonly indexMetadataService: IndexMetadataService,
@InjectRepository(FieldMetadataEntity)
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
private readonly workspaceMigrationService: WorkspaceMigrationService,
private readonly workspaceMigrationFactory: WorkspaceMigrationFactory,
) {}
public async createSearchVectorFieldForObject(
objectMetadataInput: CreateObjectInput,
createdObjectMetadata: ObjectMetadataEntity,
queryRunner?: QueryRunner,
) {
const repository = queryRunner
? queryRunner.manager.getRepository(FieldMetadataEntity)
: this.fieldMetadataRepository;
const searchVectorFieldMetadata = await repository.save({
standardId: CUSTOM_OBJECT_STANDARD_FIELD_IDS.searchVector,
objectMetadataId: createdObjectMetadata.id,
workspaceId: objectMetadataInput.workspaceId,
isCustom: false,
isActive: false,
isSystem: true,
type: FieldMetadataType.TS_VECTOR,
name: SEARCH_VECTOR_FIELD.name,
label: SEARCH_VECTOR_FIELD.label.message ?? '',
description: SEARCH_VECTOR_FIELD.description.message ?? '',
isNullable: true,
});
if (
!isFieldMetadataEntityOfType(
searchVectorFieldMetadata,
FieldMetadataType.TS_VECTOR,
)
) {
throw new Error(
'Should never occur, created searchVectorFieldMetadata is not a TS_VECTOR field',
);
}
const searchableFieldForCustomObject =
createdObjectMetadata.labelIdentifierFieldMetadataId
? createdObjectMetadata.fields.find(
(field) =>
field.id === createdObjectMetadata.labelIdentifierFieldMetadataId,
)
: createdObjectMetadata.fields.find(
(field) => field.name === DEFAULT_LABEL_IDENTIFIER_FIELD_NAME,
);
if (!isDefined(searchableFieldForCustomObject)) {
throw new Error(
`No searchable field found for custom object (object name: ${createdObjectMetadata.nameSingular})`,
);
}
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`create-${createdObjectMetadata.nameSingular}`),
createdObjectMetadata.workspaceId,
[
{
name: computeTableName(
createdObjectMetadata.nameSingular,
createdObjectMetadata.isCustom,
),
action: WorkspaceMigrationTableActionType.ALTER,
columns: this.tsVectorColumnActionFactory.handleCreateAction({
...searchVectorFieldMetadata,
generatedType: 'STORED',
asExpression: getTsVectorColumnExpressionFromFields([
{
type: searchableFieldForCustomObject.type as SearchableFieldType,
name: searchableFieldForCustomObject.name,
},
]),
}),
},
],
queryRunner,
);
await this.indexMetadataService.createIndex({
workspaceId: objectMetadataInput.workspaceId,
objectMetadata: createdObjectMetadata,
fieldMetadataToIndex: [searchVectorFieldMetadata],
isUnique: false,
isCustom: false,
indexType: IndexType.GIN,
queryRunner,
});
}
public async updateSearchVector(
objectMetadataId: string,
fieldMetadataNameAndTypeForSearch: FieldTypeAndNameMetadata[],
workspaceId: string,
queryRunner?: QueryRunner,
) {
const repository = queryRunner
? queryRunner.manager.getRepository(ObjectMetadataEntity)
: this.objectMetadataRepository;
const objectMetadata = await repository.findOneByOrFail({
id: objectMetadataId,
});
const existingSearchVectorFieldMetadata =
await this.fieldMetadataRepository.findOneByOrFail({
name: SEARCH_VECTOR_FIELD.name,
objectMetadataId,
});
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`update-${objectMetadata.nameSingular}`),
workspaceId,
[
{
name: computeTableName(
objectMetadata.nameSingular,
objectMetadata.isCustom,
),
action: WorkspaceMigrationTableActionType.ALTER,
columns: this.workspaceMigrationFactory.createColumnActions(
WorkspaceMigrationColumnActionType.ALTER,
existingSearchVectorFieldMetadata,
{
...existingSearchVectorFieldMetadata,
asExpression: getTsVectorColumnExpressionFromFields(
fieldMetadataNameAndTypeForSearch,
),
generatedType: 'STORED', // Not stored on fieldMetadata
options: null,
} as TsVectorFieldMetadata,
),
},
],
queryRunner,
);
// index needs to be recreated as typeorm deletes then recreates searchVector column at alter
await this.indexMetadataService.createIndexCreationMigration({
workspaceId,
objectMetadata,
fieldMetadataToIndex: [existingSearchVectorFieldMetadata],
isUnique: false,
indexType: IndexType.GIN,
queryRunner,
});
}
}
@@ -13,8 +13,6 @@ import {
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
@@ -32,7 +30,6 @@ import {
} from 'src/engine/metadata-modules/view-field/exceptions/view-field.exception';
import { ViewFieldRestApiExceptionFilter } from 'src/engine/metadata-modules/view-field/filters/view-field-rest-api-exception.filter';
import { ViewFieldV2Service } from 'src/engine/metadata-modules/view-field/services/view-field-v2.service';
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
import { CreateViewFieldPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/create-view-field-permission.guard';
import { DeleteViewFieldPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/delete-view-field-permission.guard';
import { UpdateViewFieldPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/update-view-field-permission.guard';
@@ -41,11 +38,7 @@ import { UpdateViewFieldPermissionGuard } from 'src/engine/metadata-modules/view
@UseGuards(WorkspaceAuthGuard)
@UseFilters(ViewFieldRestApiExceptionFilter)
export class ViewFieldController {
constructor(
private readonly viewFieldService: ViewFieldService,
private readonly viewFieldV2Service: ViewFieldV2Service,
private readonly featureFlagService: FeatureFlagService,
) {}
constructor(private readonly viewFieldV2Service: ViewFieldV2Service) {}
@Get()
@UseGuards(NoPermissionGuard)
@@ -54,10 +47,10 @@ export class ViewFieldController {
@Query('viewId') viewId?: string,
): Promise<ViewFieldEntity[]> {
if (viewId) {
return this.viewFieldService.findByViewId(workspace.id, viewId);
return this.viewFieldV2Service.findByViewId(workspace.id, viewId);
}
return this.viewFieldService.findByWorkspaceId(workspace.id);
return this.viewFieldV2Service.findByWorkspaceId(workspace.id);
}
@Get(':id')
@@ -66,7 +59,7 @@ export class ViewFieldController {
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ViewFieldEntity> {
const viewField = await this.viewFieldService.findById(id, workspace.id);
const viewField = await this.viewFieldV2Service.findById(id, workspace.id);
if (!isDefined(viewField)) {
throw new ViewFieldException(
@@ -93,39 +86,10 @@ export class ViewFieldController {
@Body() input: UpdateViewFieldInput['update'],
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ViewFieldDTO> {
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
workspace.id,
);
if (isWorkspaceMigrationV2Enabled) {
return await this.viewFieldV2Service.updateOne({
updateViewFieldInput: { id, update: input },
workspaceId: workspace.id,
});
}
const updatedViewField = await this.viewFieldService.update(
id,
workspace.id,
input,
);
// Convert ViewFieldEntity to ViewFieldDTO for consistency
return {
id: updatedViewField.id,
fieldMetadataId: updatedViewField.fieldMetadataId,
isVisible: updatedViewField.isVisible,
size: updatedViewField.size,
position: updatedViewField.position,
aggregateOperation: updatedViewField.aggregateOperation,
viewId: updatedViewField.viewId,
workspaceId: updatedViewField.workspaceId,
createdAt: updatedViewField.createdAt,
updatedAt: updatedViewField.updatedAt,
deletedAt: updatedViewField.deletedAt,
};
return await this.viewFieldV2Service.updateOne({
updateViewFieldInput: { id, update: input },
workspaceId: workspace.id,
});
}
@Post()
@@ -134,38 +98,10 @@ export class ViewFieldController {
@Body() input: CreateViewFieldInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ViewFieldDTO> {
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
workspace.id,
);
if (isWorkspaceMigrationV2Enabled) {
return await this.viewFieldV2Service.createOne({
createViewFieldInput: input,
workspaceId: workspace.id,
});
}
const createdViewField = await this.viewFieldService.create({
...input,
return await this.viewFieldV2Service.createOne({
createViewFieldInput: input,
workspaceId: workspace.id,
});
// Convert ViewFieldEntity to ViewFieldDTO for consistency
return {
id: createdViewField.id,
fieldMetadataId: createdViewField.fieldMetadataId,
isVisible: createdViewField.isVisible,
size: createdViewField.size,
position: createdViewField.position,
aggregateOperation: createdViewField.aggregateOperation,
viewId: createdViewField.viewId,
workspaceId: createdViewField.workspaceId,
createdAt: createdViewField.createdAt,
updatedAt: createdViewField.updatedAt,
deletedAt: createdViewField.deletedAt,
};
}
@Delete(':id')
@@ -174,25 +110,10 @@ export class ViewFieldController {
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<{ success: boolean }> {
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
workspace.id,
);
if (isWorkspaceMigrationV2Enabled) {
const deletedViewField = await this.viewFieldV2Service.deleteOne({
deleteViewFieldInput: { id },
workspaceId: workspace.id,
});
return { success: isDefined(deletedViewField) };
}
const deletedViewField = await this.viewFieldService.delete(
id,
workspace.id,
);
const deletedViewField = await this.viewFieldV2Service.deleteOne({
deleteViewFieldInput: { id },
workspaceId: workspace.id,
});
return { success: isDefined(deletedViewField) };
}
@@ -1,8 +1,6 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
@@ -13,12 +11,7 @@ import { DestroyViewFieldInput } from 'src/engine/metadata-modules/view-field/dt
import { UpdateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/update-view-field.input';
import { ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import {
ViewFieldException,
ViewFieldExceptionCode,
} from 'src/engine/metadata-modules/view-field/exceptions/view-field.exception';
import { ViewFieldV2Service } from 'src/engine/metadata-modules/view-field/services/view-field-v2.service';
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
import { CreateViewFieldPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/create-view-field-permission.guard';
import { DeleteViewFieldPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/delete-view-field-permission.guard';
import { DestroyViewFieldPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/destroy-view-field-permission.guard';
@@ -29,11 +22,7 @@ import { ViewGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/view/
@UseFilters(ViewGraphqlApiExceptionFilter)
@UseGuards(WorkspaceAuthGuard)
export class ViewFieldResolver {
constructor(
private readonly viewFieldService: ViewFieldService,
private readonly featureFlagService: FeatureFlagService,
private readonly viewFieldV2Service: ViewFieldV2Service,
) {}
constructor(private readonly viewFieldV2Service: ViewFieldV2Service) {}
@Query(() => [ViewFieldDTO])
@UseGuards(NoPermissionGuard)
@@ -41,7 +30,7 @@ export class ViewFieldResolver {
@Args('viewId', { type: () => String }) viewId: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ViewFieldEntity[]> {
return this.viewFieldService.findByViewId(workspace.id, viewId);
return this.viewFieldV2Service.findByViewId(workspace.id, viewId);
}
@Query(() => ViewFieldDTO, { nullable: true })
@@ -50,7 +39,7 @@ export class ViewFieldResolver {
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ViewFieldEntity | null> {
return this.viewFieldService.findById(id, workspace.id);
return this.viewFieldV2Service.findById(id, workspace.id);
}
@Mutation(() => ViewFieldDTO)
@@ -59,24 +48,10 @@ export class ViewFieldResolver {
@Args('input') updateViewFieldInput: UpdateViewFieldInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ViewFieldDTO> {
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
workspaceId,
);
if (isWorkspaceMigrationV2Enabled) {
return await this.viewFieldV2Service.updateOne({
updateViewFieldInput,
workspaceId,
});
}
return this.viewFieldService.update(
updateViewFieldInput.id,
return await this.viewFieldV2Service.updateOne({
updateViewFieldInput,
workspaceId,
updateViewFieldInput.update,
);
});
}
@Mutation(() => ViewFieldDTO)
@@ -85,21 +60,8 @@ export class ViewFieldResolver {
@Args('input') createViewFieldInput: CreateViewFieldInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ViewFieldDTO> {
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
workspaceId,
);
if (isWorkspaceMigrationV2Enabled) {
return await this.viewFieldV2Service.createOne({
createViewFieldInput,
workspaceId,
});
}
return this.viewFieldService.create({
...createViewFieldInput,
return await this.viewFieldV2Service.createOne({
createViewFieldInput,
workspaceId,
});
}
@@ -111,19 +73,6 @@ export class ViewFieldResolver {
createViewFieldInputs: CreateViewFieldInput[],
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ViewFieldDTO[]> {
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
workspaceId,
);
if (!isWorkspaceMigrationV2Enabled) {
throw new ViewFieldException(
'Not implemented in v1, please active IS_WORKSPACE_MIGRATION_V2_ENABLED',
ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
);
}
return await this.viewFieldV2Service.createMany({
createViewFieldInputs,
workspaceId,
@@ -136,20 +85,10 @@ export class ViewFieldResolver {
@Args('input') deleteViewFieldInput: DeleteViewFieldInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ViewFieldDTO> {
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
workspaceId,
);
if (isWorkspaceMigrationV2Enabled) {
return await this.viewFieldV2Service.deleteOne({
deleteViewFieldInput,
workspaceId,
});
}
return this.viewFieldService.delete(deleteViewFieldInput.id, workspaceId);
return await this.viewFieldV2Service.deleteOne({
deleteViewFieldInput,
workspaceId,
});
}
@Mutation(() => ViewFieldDTO)
@@ -158,19 +97,9 @@ export class ViewFieldResolver {
@Args('input') destroyViewFieldInput: DestroyViewFieldInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ViewFieldDTO> {
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
workspaceId,
);
if (isWorkspaceMigrationV2Enabled) {
return await this.viewFieldV2Service.destroyOne({
destroyViewFieldInput,
workspaceId,
});
}
return this.viewFieldService.destroy(destroyViewFieldInput.id, workspaceId);
return await this.viewFieldV2Service.destroyOne({
destroyViewFieldInput,
workspaceId,
});
}
}
@@ -1,532 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { UserInputError } from 'apollo-server-core';
import { type Repository } from 'typeorm';
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import {
ViewFieldException,
ViewFieldExceptionCode,
ViewFieldExceptionMessageKey,
generateViewFieldExceptionMessage,
generateViewFieldUserFriendlyExceptionMessage,
} from 'src/engine/metadata-modules/view-field/exceptions/view-field.exception';
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
describe('ViewFieldService', () => {
let viewFieldService: ViewFieldService;
let viewFieldRepository: Repository<ViewFieldEntity>;
const mockViewField = {
id: 'view-field-id',
fieldMetadataId: 'field-id',
viewId: 'view-id',
workspaceId: 'workspace-id',
position: 0,
isVisible: true,
size: 100,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
} as ViewFieldEntity;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ViewFieldService,
{
provide: getRepositoryToken(ViewFieldEntity),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
softDelete: jest.fn(),
delete: jest.fn(),
},
},
{
provide: getRepositoryToken(ViewEntity),
useValue: {
findOne: jest.fn(),
},
},
{
provide: WorkspaceCacheStorageService,
useValue: {
flushGraphQLOperation: jest.fn(),
},
},
],
}).compile();
viewFieldService = module.get<ViewFieldService>(ViewFieldService);
viewFieldRepository = module.get<Repository<ViewFieldEntity>>(
getRepositoryToken(ViewFieldEntity),
);
});
it('should be defined', () => {
expect(viewFieldService).toBeDefined();
});
describe('findByWorkspaceId', () => {
it('should return view fields for a workspace', async () => {
const workspaceId = 'workspace-id';
const expectedViewFields = [mockViewField];
jest
.spyOn(viewFieldRepository, 'find')
.mockResolvedValue(expectedViewFields);
const result = await viewFieldService.findByWorkspaceId(workspaceId);
expect(viewFieldRepository.find).toHaveBeenCalledWith({
where: {
workspaceId,
deletedAt: expect.anything(),
},
order: { position: 'ASC' },
relations: ['workspace', 'view'],
});
expect(result).toEqual(expectedViewFields);
});
});
describe('findByViewId', () => {
it('should return view fields for a view', async () => {
const workspaceId = 'workspace-id';
const viewId = 'view-id';
const expectedViewFields = [mockViewField];
jest
.spyOn(viewFieldRepository, 'find')
.mockResolvedValue(expectedViewFields);
const result = await viewFieldService.findByViewId(workspaceId, viewId);
expect(viewFieldRepository.find).toHaveBeenCalledWith({
where: {
workspaceId,
viewId,
deletedAt: expect.anything(),
},
order: { position: 'ASC' },
relations: ['workspace', 'view'],
});
expect(result).toEqual(expectedViewFields);
});
});
describe('findById', () => {
it('should return a view field by id', async () => {
const id = 'view-field-id';
const workspaceId = 'workspace-id';
jest
.spyOn(viewFieldRepository, 'findOne')
.mockResolvedValue(mockViewField);
const result = await viewFieldService.findById(id, workspaceId);
expect(viewFieldRepository.findOne).toHaveBeenCalledWith({
where: {
id,
workspaceId,
deletedAt: expect.anything(),
},
relations: ['workspace', 'view'],
});
expect(result).toEqual(mockViewField);
});
it('should return null when view field is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewFieldRepository, 'findOne').mockResolvedValue(null);
const result = await viewFieldService.findById(id, workspaceId);
expect(result).toBeNull();
});
});
describe('create', () => {
const validViewFieldData = {
fieldMetadataId: 'field-id',
viewId: 'view-id',
workspaceId: 'workspace-id',
position: 0,
isVisible: true,
size: 100,
};
it('should throw exception when workspaceId is missing', async () => {
const invalidData = { ...validViewFieldData, workspaceId: undefined };
await expect(viewFieldService.create(invalidData)).rejects.toThrow(
new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
{
userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
ViewFieldExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
},
),
);
});
it('should throw exception when viewId is missing', async () => {
const invalidData = { ...validViewFieldData, viewId: undefined };
await expect(viewFieldService.create(invalidData)).rejects.toThrow(
new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_ID_REQUIRED,
),
ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
{
userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_ID_REQUIRED,
),
},
),
);
});
it('should throw exception when fieldMetadataId is missing', async () => {
const invalidData = { ...validViewFieldData, fieldMetadataId: undefined };
await expect(viewFieldService.create(invalidData)).rejects.toThrow(
new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
),
ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
{
userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
ViewFieldExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
),
},
),
);
});
it('should throw exception if position is lower than label metadata identifier', async () => {
const labelIdentifierFieldMetadataId =
'label-identifier-field-matadata-id';
const labelIdentifierViewFieldId =
'view-field-for-label-metadata-identifier-id';
const labelIdentifierViewField = {
...mockViewField,
id: labelIdentifierViewFieldId,
fieldMetadataId: labelIdentifierFieldMetadataId,
position: 0,
};
const mockView = {
id: 'view-id',
objectMetadata: {
labelIdentifierFieldMetadataId,
},
viewFields: [
labelIdentifierViewField,
{ ...mockViewField, position: 1 },
],
} as ViewEntity;
jest.spyOn(viewFieldService, 'findById').mockImplementation((id) => {
if (id === mockViewField.id) {
return Promise.resolve(mockViewField);
}
return Promise.resolve(null);
});
jest
.spyOn(viewFieldService, 'findViewByIdWithRelations')
.mockResolvedValue(mockView);
const invalidData = { ...validViewFieldData, position: -1 };
await expect(viewFieldService.create(invalidData)).rejects.toThrow(
new UserInputError(
'Label metadata identifier must keep the minimal position in the view.',
{
userFriendlyMessage:
'Record text must be in first position of the view.',
},
),
);
});
});
describe('update', () => {
it('should update a view field successfully', async () => {
const id = 'view-field-id';
const workspaceId = 'workspace-id';
const updateData = { position: 1 };
const updatedViewField = { ...mockViewField, ...updateData };
const mockView = {
id: 'view-id',
objectMetadata: {
labelIdentifierFieldMetadataId: mockViewField.fieldMetadataId,
},
viewFields: [mockViewField],
} as ViewEntity;
jest.spyOn(viewFieldService, 'findById').mockResolvedValue(mockViewField);
jest
.spyOn(viewFieldRepository, 'save')
.mockResolvedValue(updatedViewField);
jest
.spyOn(viewFieldService, 'findViewByIdWithRelations')
.mockResolvedValue(mockView);
const result = await viewFieldService.update(id, workspaceId, updateData);
expect(viewFieldService.findById).toHaveBeenCalledWith(id, workspaceId);
expect(viewFieldRepository.save).toHaveBeenCalledWith({
id,
...updateData,
});
expect(result).toEqual({ ...mockViewField, ...updatedViewField });
});
it('should throw exception when view field is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
const updateData = { position: 1 };
jest.spyOn(viewFieldService, 'findById').mockResolvedValue(null);
await expect(
viewFieldService.update(id, workspaceId, updateData),
).rejects.toThrow(
new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
id,
),
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
),
);
});
it('should throw exception when label metadata identifier is not in first position (label metadata identifier field update case)', async () => {
const workspaceId = 'workspace-id';
const updateData = { position: 2 };
const labelIdentifierFieldMetadataId =
'label-identifier-field-matadata-id';
const labelIdentifierViewFieldId =
'view-field-for-label-metadata-identifier-id';
const labelIdentifierViewField = {
...mockViewField,
id: labelIdentifierViewFieldId,
fieldMetadataId: labelIdentifierFieldMetadataId,
position: 0,
};
const mockView = {
id: 'view-id',
objectMetadata: {
labelIdentifierFieldMetadataId,
},
viewFields: [
labelIdentifierViewField,
{ ...mockViewField, position: 1 },
],
} as ViewEntity;
jest.spyOn(viewFieldService, 'findById').mockImplementation((id) => {
if (id === labelIdentifierViewFieldId) {
return Promise.resolve(labelIdentifierViewField);
}
return Promise.resolve(null);
});
jest
.spyOn(viewFieldService, 'findViewByIdWithRelations')
.mockResolvedValue(mockView);
await expect(
viewFieldService.update(
labelIdentifierViewFieldId,
workspaceId,
updateData,
),
).rejects.toThrow(
new UserInputError(
'Label metadata identifier must keep the minimal position in the view.',
{
userFriendlyMessage:
'Record text must be in first position of the view.',
},
),
);
});
it('should throw exception when label metadata identifier is not in first position (regular field update case)', async () => {
const workspaceId = 'workspace-id';
const updateData = { position: -1 };
const labelIdentifierFieldMetadataId =
'label-identifier-field-matadata-id';
const labelIdentifierViewFieldId =
'view-field-for-label-metadata-identifier-id';
const labelIdentifierViewField = {
...mockViewField,
id: labelIdentifierViewFieldId,
fieldMetadataId: labelIdentifierFieldMetadataId,
position: 0,
};
const mockView = {
id: 'view-id',
objectMetadata: {
labelIdentifierFieldMetadataId,
},
viewFields: [
labelIdentifierViewField,
{ ...mockViewField, position: 1 },
],
} as ViewEntity;
jest.spyOn(viewFieldService, 'findById').mockImplementation((id) => {
if (id === mockViewField.id) {
return Promise.resolve(mockViewField);
}
return Promise.resolve(null);
});
jest
.spyOn(viewFieldService, 'findViewByIdWithRelations')
.mockResolvedValue(mockView);
await expect(
viewFieldService.update(mockViewField.id, workspaceId, updateData),
).rejects.toThrow(
new UserInputError(
'Label metadata identifier must keep the minimal position in the view.',
{
userFriendlyMessage:
'Record text must be in first position of the view.',
},
),
);
});
it('should throw exception when attempting to make label metadata identifier invisible', async () => {
const workspaceId = 'workspace-id';
const updateData = { isVisible: false };
const labelIdentifierFieldMetadataId =
'label-identifier-field-matadata-id';
const labelIdentifierViewFieldId =
'view-field-for-label-metadata-identifier-id';
const labelIdentifierViewField = {
...mockViewField,
id: labelIdentifierViewFieldId,
fieldMetadataId: labelIdentifierFieldMetadataId,
position: 0,
};
const mockView = {
id: 'view-id',
objectMetadata: {
labelIdentifierFieldMetadataId,
},
viewFields: [labelIdentifierViewField, mockViewField],
} as ViewEntity;
jest.spyOn(viewFieldService, 'findById').mockImplementation((id) => {
if (id === labelIdentifierViewFieldId) {
return Promise.resolve(labelIdentifierViewField);
}
return Promise.resolve(null);
});
jest
.spyOn(viewFieldService, 'findViewByIdWithRelations')
.mockResolvedValue(mockView);
await expect(
viewFieldService.update(
labelIdentifierViewField.id,
workspaceId,
updateData,
),
).rejects.toThrow(
new UserInputError('Label metadata identifier must stay visible.', {
userFriendlyMessage: 'Record text must stay visible.',
}),
);
});
});
describe('delete', () => {
it('should delete a view field successfully', async () => {
const id = 'view-field-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewFieldService, 'findById').mockResolvedValue(mockViewField);
jest
.spyOn(viewFieldRepository, 'softDelete')
.mockResolvedValue({} as any);
const result = await viewFieldService.delete(id, workspaceId);
expect(viewFieldService.findById).toHaveBeenCalledWith(id, workspaceId);
expect(viewFieldRepository.softDelete).toHaveBeenCalledWith(id);
expect(result).toEqual(mockViewField);
});
it('should throw exception when view field is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewFieldService, 'findById').mockResolvedValue(null);
await expect(viewFieldService.delete(id, workspaceId)).rejects.toThrow(
new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
id,
),
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
),
);
});
});
describe('destroy', () => {
it('should destroy a view field successfully', async () => {
const id = 'view-field-id';
const workspaceId = 'workspace-id';
jest
.spyOn(viewFieldRepository, 'findOne')
.mockResolvedValue(mockViewField);
jest.spyOn(viewFieldRepository, 'delete').mockResolvedValue({} as any);
const result = await viewFieldService.destroy(id, workspaceId);
expect(viewFieldRepository.findOne).toHaveBeenCalledWith({
where: { id, workspaceId },
relations: ['workspace', 'view'],
withDeleted: true,
});
expect(viewFieldRepository.delete).toHaveBeenCalledWith(id);
expect(result).toBeDefined();
});
});
});
@@ -1,6 +1,8 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { computeFlatEntityMapsFromTo } from 'src/engine/metadata-modules/flat-entity/utils/compute-flat-entity-maps-from-to.util';
@@ -15,6 +17,7 @@ import { DeleteViewFieldInput } from 'src/engine/metadata-modules/view-field/dto
import { DestroyViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/destroy-view-field.input';
import { UpdateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/update-view-field.input';
import { ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import {
ViewFieldException,
ViewFieldExceptionCode,
@@ -26,7 +29,9 @@ import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspa
export class ViewFieldV2Service {
constructor(
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
@InjectRepository(ViewFieldEntity)
private readonly viewFieldRepository: Repository<ViewFieldEntity>,
) {}
async createOne({
@@ -67,7 +72,7 @@ export class ViewFieldV2Service {
flatViewMaps,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
} = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
} = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: [
@@ -118,7 +123,7 @@ export class ViewFieldV2Service {
}
const { flatViewFieldMaps: recomputedExistingFlatViewFieldMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatViewFieldMaps'],
@@ -142,7 +147,7 @@ export class ViewFieldV2Service {
flatViewFieldMaps: existingFlatViewFieldMaps,
flatObjectMetadataMaps,
flatViewMaps,
} = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
} = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: [
@@ -189,7 +194,7 @@ export class ViewFieldV2Service {
}
const { flatViewFieldMaps: recomputedExistingFlatViewFieldMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatViewFieldMaps'],
@@ -213,7 +218,7 @@ export class ViewFieldV2Service {
flatViewFieldMaps: existingFlatViewFieldMaps,
flatObjectMetadataMaps,
flatViewMaps,
} = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
} = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: [
@@ -260,7 +265,7 @@ export class ViewFieldV2Service {
}
const { flatViewFieldMaps: recomputedExistingFlatViewFieldMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatViewFieldMaps'],
@@ -285,7 +290,7 @@ export class ViewFieldV2Service {
flatViewMaps: existingFlatViewMaps,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
} = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
} = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: [
@@ -338,4 +343,46 @@ export class ViewFieldV2Service {
return existingViewFieldToDelete;
}
async findByWorkspaceId(workspaceId: string): Promise<ViewFieldEntity[]> {
return this.viewFieldRepository.find({
where: {
workspaceId,
deletedAt: IsNull(),
},
order: { position: 'ASC' },
relations: ['workspace', 'view'],
});
}
async findByViewId(
workspaceId: string,
viewId: string,
): Promise<ViewFieldEntity[]> {
return this.viewFieldRepository.find({
where: {
workspaceId,
viewId,
deletedAt: IsNull(),
},
order: { position: 'ASC' },
relations: ['workspace', 'view'],
});
}
async findById(
id: string,
workspaceId: string,
): Promise<ViewFieldEntity | null> {
const viewField = await this.viewFieldRepository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
relations: ['workspace', 'view'],
});
return viewField || null;
}
}
@@ -1,469 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import {
ViewFieldException,
ViewFieldExceptionCode,
ViewFieldExceptionMessageKey,
generateViewFieldExceptionMessage,
generateViewFieldUserFriendlyExceptionMessage,
} from 'src/engine/metadata-modules/view-field/exceptions/view-field.exception';
import { FIND_ALL_CORE_VIEWS_GRAPHQL_OPERATION } from 'src/engine/metadata-modules/view/constants/find-all-core-views-graphql-operation.constant';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@Injectable()
export class ViewFieldService {
constructor(
@InjectRepository(ViewFieldEntity)
private readonly viewFieldRepository: Repository<ViewFieldEntity>,
@InjectRepository(ViewEntity)
private readonly viewRepository: Repository<ViewEntity>,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
) {}
async findByWorkspaceId(workspaceId: string): Promise<ViewFieldEntity[]> {
return this.viewFieldRepository.find({
where: {
workspaceId,
deletedAt: IsNull(),
},
order: { position: 'ASC' },
relations: ['workspace', 'view'],
});
}
async findByViewId(
workspaceId: string,
viewId: string,
): Promise<ViewFieldEntity[]> {
return this.viewFieldRepository.find({
where: {
workspaceId,
viewId,
deletedAt: IsNull(),
},
order: { position: 'ASC' },
relations: ['workspace', 'view'],
});
}
async findById(
id: string,
workspaceId: string,
): Promise<ViewFieldEntity | null> {
const viewField = await this.viewFieldRepository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
relations: ['workspace', 'view'],
});
return viewField || null;
}
async create(
viewFieldData: Partial<ViewFieldEntity>,
): Promise<ViewFieldEntity> {
if (this.hasRequiredFields(viewFieldData)) {
try {
if (isDefined(viewFieldData.position)) {
const viewFieldDataWithPosition =
viewFieldData as typeof viewFieldData & { position: number };
await this.verifyLabelMetadataIdentifierIsInFirstPositionOrThrow(
viewFieldDataWithPosition,
viewFieldData.workspaceId,
);
}
await this.verifyLabelMetadataIdentifierIsVisibleOrThrow(
viewFieldData,
viewFieldData.workspaceId,
);
const viewFieldDataWithPosition = await this.formatViewFieldData(
viewFieldData,
viewFieldData.workspaceId,
);
const viewField = this.viewFieldRepository.create(
viewFieldDataWithPosition,
);
const savedViewField = await this.viewFieldRepository.save(viewField);
await this.flushGraphQLCache(viewFieldData.workspaceId);
const createdViewField = await this.findById(
savedViewField.id,
viewFieldData.workspaceId,
);
if (!isDefined(createdViewField)) {
throw new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
),
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
{
userFriendlyMessage:
generateViewFieldUserFriendlyExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
),
},
);
}
return createdViewField;
} catch (error) {
if (
error.message.includes(
'duplicate key value violates unique constraint',
)
) {
throw new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_ALREADY_EXISTS,
),
ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
{
userFriendlyMessage:
generateViewFieldUserFriendlyExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_ALREADY_EXISTS,
),
},
);
}
throw error;
}
} else {
if (!isDefined(viewFieldData.workspaceId)) {
throw new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
{
userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
ViewFieldExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
},
);
}
if (!isDefined(viewFieldData.viewId)) {
throw new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_ID_REQUIRED,
),
ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
{
userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_ID_REQUIRED,
),
},
);
}
throw new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
),
ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
{
userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
ViewFieldExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
),
},
);
}
}
async update(
id: string,
workspaceId: string,
updateData: Partial<ViewFieldEntity>,
): Promise<ViewFieldEntity> {
const existingViewField = await this.findById(id, workspaceId);
if (!isDefined(existingViewField)) {
throw new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
id,
),
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
);
}
const viewId = existingViewField.viewId;
if (this.updatesPosition(updateData)) {
await this.verifyLabelMetadataIdentifierIsInFirstPositionOrThrow(
{
...updateData,
viewId,
id,
fieldMetadataId: existingViewField.fieldMetadataId,
},
workspaceId,
);
}
if (this.disablesVisibility(updateData)) {
await this.verifyLabelMetadataIdentifierIsVisibleOrThrow(
{ ...updateData, viewId, id },
workspaceId,
);
}
const updatedViewField = await this.viewFieldRepository.save({
id,
...updateData,
});
await this.flushGraphQLCache(workspaceId);
return { ...existingViewField, ...updatedViewField };
}
async delete(id: string, workspaceId: string): Promise<ViewFieldEntity> {
const viewField = await this.findById(id, workspaceId);
if (!isDefined(viewField)) {
throw new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
id,
),
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
);
}
await this.viewFieldRepository.softDelete(id);
await this.flushGraphQLCache(workspaceId);
return viewField;
}
async destroy(id: string, workspaceId: string): Promise<ViewFieldEntity> {
const viewField = await this.viewFieldRepository.findOne({
where: {
id,
workspaceId,
},
relations: ['workspace', 'view'],
withDeleted: true,
});
if (!isDefined(viewField)) {
throw new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
id,
),
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
);
}
await this.viewFieldRepository.delete(id);
await this.flushGraphQLCache(workspaceId);
return viewField;
}
private updatesPosition(
data: Partial<ViewFieldEntity>,
): data is Partial<ViewFieldEntity> & { position: number } {
return isDefined(data.position);
}
private disablesVisibility(
data: Partial<ViewFieldEntity>,
): data is Partial<ViewFieldEntity> & { isVisible: boolean } {
return data.isVisible === false;
}
private async verifyLabelMetadataIdentifierIsVisibleOrThrow(
newOrUpdatedViewField: Partial<ViewFieldEntity> & {
viewId: string;
},
workspaceId: string,
) {
const view = await this.findViewByIdWithRelations(
newOrUpdatedViewField.viewId,
workspaceId,
);
if (!isDefined(view)) {
throw new Error(`View not found: ${newOrUpdatedViewField.viewId}`);
}
const labelMetadataIdentifierFieldMetadataId =
view.objectMetadata.labelIdentifierFieldMetadataId;
const labelMetadataIdentifierViewField = view.viewFields.find(
(viewField) =>
viewField.fieldMetadataId === labelMetadataIdentifierFieldMetadataId,
);
if (
!isDefined(labelMetadataIdentifierViewField) ||
labelMetadataIdentifierViewField.id !== newOrUpdatedViewField.id
) {
return;
}
if (newOrUpdatedViewField.isVisible === false) {
throw new UserInputError('Label metadata identifier must stay visible.', {
userFriendlyMessage: msg`Record text must stay visible.`,
});
}
}
private async verifyLabelMetadataIdentifierIsInFirstPositionOrThrow(
newOrUpdatedViewField: Partial<ViewFieldEntity> & { viewId: string } & {
position: number;
},
workspaceId: string,
) {
const view = await this.findViewByIdWithRelations(
newOrUpdatedViewField.viewId,
workspaceId,
);
if (!isDefined(view)) {
throw new Error(`View not found: ${newOrUpdatedViewField.viewId}`);
}
const viewFieldsWithoutUpdatedViewField = view.viewFields.filter(
(viewField) => viewField.id !== newOrUpdatedViewField?.id,
);
if (viewFieldsWithoutUpdatedViewField.length === 0) {
return;
}
const labelMetadataIdentifierFieldMetadataId =
view.objectMetadata.labelIdentifierFieldMetadataId;
if (
labelMetadataIdentifierFieldMetadataId ===
newOrUpdatedViewField.fieldMetadataId
) {
const minPositionInViewWithoutUpdatedViewField =
viewFieldsWithoutUpdatedViewField.reduce(
(minViewField, viewField) =>
viewField.position < minViewField.position
? viewField
: minViewField,
viewFieldsWithoutUpdatedViewField[0],
).position;
if (
newOrUpdatedViewField.position >=
minPositionInViewWithoutUpdatedViewField
) {
throw new UserInputError(
'Label metadata identifier must keep the minimal position in the view.',
{
userFriendlyMessage: msg`Record text must be in first position of the view.`,
},
);
}
} else {
const labelMetadataIdentifierViewFieldPosition = view.viewFields.find(
(viewField) =>
viewField.fieldMetadataId === labelMetadataIdentifierFieldMetadataId,
)?.position;
if (
isDefined(labelMetadataIdentifierViewFieldPosition) &&
newOrUpdatedViewField.position <=
labelMetadataIdentifierViewFieldPosition
) {
throw new UserInputError(
'Label metadata identifier must keep the minimal position in the view.',
{
userFriendlyMessage: msg`Record text must be in first position of the view.`,
},
);
}
}
}
private async formatViewFieldData(
viewFieldData: Partial<ViewFieldEntity> & { viewId: string },
workspaceId: string,
): Promise<Partial<ViewFieldEntity>> {
if (!isDefined(viewFieldData.position)) {
const view = await this.findViewByIdWithRelations(
viewFieldData.viewId,
workspaceId,
);
if (!isDefined(view)) {
throw new Error(`View not found: ${viewFieldData.viewId}`);
}
const highestPositionInView = view.viewFields.reduce(
(maxViewField, viewField) =>
viewField.position > maxViewField.position ? viewField : maxViewField,
view.viewFields[0],
);
return { ...viewFieldData, position: highestPositionInView.position + 1 };
} else {
return viewFieldData;
}
}
private hasRequiredFields(
data: Partial<ViewFieldEntity>,
): data is Partial<ViewFieldEntity> & {
viewId: string;
fieldMetadataId: string;
workspaceId: string;
} {
return (
isDefined(data.viewId) &&
isDefined(data.fieldMetadataId) &&
isDefined(data.workspaceId)
);
}
public async findViewByIdWithRelations(
id: string,
workspaceId: string,
): Promise<ViewEntity | null> {
const view = await this.viewRepository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
relations: ['workspace', 'objectMetadata', 'viewFields'],
});
return view || null;
}
private async flushGraphQLCache(workspaceId: string): Promise<void> {
await this.workspaceCacheStorageService.flushGraphQLOperation({
operationName: FIND_ALL_CORE_VIEWS_GRAPHQL_OPERATION,
workspaceId,
});
}
}
@@ -1,14 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { ViewFieldController } from 'src/engine/metadata-modules/view-field/controllers/view-field.controller';
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import { ViewFieldResolver } from 'src/engine/metadata-modules/view-field/resolvers/view-field.resolver';
import { ViewFieldV2Service } from 'src/engine/metadata-modules/view-field/services/view-field-v2.service';
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
import { ViewPermissionsModule } from 'src/engine/metadata-modules/view-permissions/view-permissions.module';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@@ -18,14 +16,13 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa
imports: [
TypeOrmModule.forFeature([ViewFieldEntity, ViewEntity]),
WorkspaceCacheStorageModule,
FeatureFlagModule,
PermissionsModule,
WorkspaceMigrationV2Module,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
ViewPermissionsModule,
],
controllers: [ViewFieldController],
providers: [ViewFieldService, ViewFieldResolver, ViewFieldV2Service],
exports: [ViewFieldService, ViewFieldV2Service],
providers: [ViewFieldResolver, ViewFieldV2Service],
exports: [ViewFieldV2Service],
})
export class ViewFieldModule {}
@@ -24,9 +24,8 @@ import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
import { ViewFieldV2Service } from 'src/engine/metadata-modules/view-field/services/view-field-v2.service';
import { ViewFilterGroupDTO } from 'src/engine/metadata-modules/view-filter-group/dtos/view-filter-group.dto';
import { ViewFilterGroupService } from 'src/engine/metadata-modules/view-filter-group/services/view-filter-group.service';
import { ViewFilterDTO } from 'src/engine/metadata-modules/view-filter/dtos/view-filter.dto';
@@ -54,7 +53,6 @@ import { ViewGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/view/
export class ViewResolver {
constructor(
private readonly viewService: ViewService,
private readonly viewFieldService: ViewFieldService,
private readonly viewFilterService: ViewFilterService,
private readonly viewFilterGroupService: ViewFilterGroupService,
private readonly viewSortService: ViewSortService,
@@ -62,7 +60,8 @@ export class ViewResolver {
private readonly i18nService: I18nService,
private readonly featureFlagService: FeatureFlagService,
private readonly viewV2Service: ViewV2Service,
private readonly userRoleService: UserRoleService,
private readonly viewFieldV2Service: ViewFieldV2Service,
) {}
@ResolveField(() => String)
@@ -265,7 +264,7 @@ export class ViewResolver {
return view.viewFields;
}
return this.viewFieldService.findByViewId(workspace.id, view.id);
return this.viewFieldV2Service.findByViewId(workspace.id, view.id);
}
@ResolveField(() => [ViewFilterDTO])
@@ -7,11 +7,11 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata
import { FlatViewModule } from 'src/engine/metadata-modules/flat-view/flat-view.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { ViewPermissionsModule } from 'src/engine/metadata-modules/view-permissions/view-permissions.module';
import { ViewFieldModule } from 'src/engine/metadata-modules/view-field/view-field.module';
import { ViewFilterGroupModule } from 'src/engine/metadata-modules/view-filter-group/view-filter-group.module';
import { ViewFilterModule } from 'src/engine/metadata-modules/view-filter/view-filter.module';
import { ViewGroupModule } from 'src/engine/metadata-modules/view-group/view-group.module';
import { ViewPermissionsModule } from 'src/engine/metadata-modules/view-permissions/view-permissions.module';
import { ViewSortModule } from 'src/engine/metadata-modules/view-sort/view-sort.module';
import { ViewController } from 'src/engine/metadata-modules/view/controllers/view.controller';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
@@ -1,15 +1,15 @@
import { msg } from '@lingui/core/macro';
import {
ActorMetadata,
FieldMetadataType,
RelationOnDeleteAction,
ActorMetadata,
} from 'twenty-shared/types';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types';
import { DEFAULT_LABEL_IDENTIFIER_FIELD_NAME } from 'src/engine/metadata-modules/object-metadata/object-metadata.constants';
import { DEFAULT_LABEL_IDENTIFIER_FIELD_NAME } from 'src/engine/metadata-modules/object-metadata/constants/object-metadata.constants';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceCustomEntity } from 'src/engine/twenty-orm/decorators/workspace-custom-entity.decorator';
import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator';
@@ -13,7 +13,6 @@ import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
@@ -28,13 +27,13 @@ import { WorkspaceSyncMetadataService } from 'src/engine/workspace-manager/works
describe('WorkspaceManagerService', () => {
let service: WorkspaceManagerService;
let objectMetadataServiceV2: ObjectMetadataServiceV2;
let workspaceMigrationRepository: Repository<WorkspaceMigrationEntity>;
let dataSourceRepository: Repository<DataSourceEntity>;
let workspaceDataSourceService: WorkspaceDataSourceService;
let roleTargetsRepository: Repository<RoleTargetsEntity>;
let roleRepository: Repository<RoleEntity>;
let mockDataSource: jest.Mocked<DataSource>;
let objectMetadataServiceV2: ObjectMetadataServiceV2;
beforeEach(async () => {
mockDataSource = {
@@ -102,10 +101,6 @@ describe('WorkspaceManagerService', () => {
provide: FeatureFlagService,
useValue: {},
},
{
provide: ObjectMetadataService,
useValue: {},
},
{
provide: RoleService,
useValue: {},
@@ -154,9 +149,6 @@ describe('WorkspaceManagerService', () => {
}).compile();
service = module.get<WorkspaceManagerService>(WorkspaceManagerService);
objectMetadataServiceV2 = module.get<ObjectMetadataServiceV2>(
ObjectMetadataServiceV2,
);
workspaceMigrationRepository = module.get<
Repository<WorkspaceMigrationEntity>
>(getRepositoryToken(WorkspaceMigrationEntity));
@@ -172,6 +164,9 @@ describe('WorkspaceManagerService', () => {
roleRepository = module.get<Repository<RoleEntity>>(
getRepositoryToken(RoleEntity),
);
objectMetadataServiceV2 = module.get<ObjectMetadataServiceV2>(
ObjectMetadataServiceV2,
);
});
it('should be defined', () => {
@@ -8,8 +8,8 @@ import { DataSource } from 'typeorm';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { computeTableName } from 'src/engine/utils/compute-table-name.util';
import {
@@ -265,7 +265,7 @@ export class DevSeederDataService {
constructor(
@InjectDataSource()
private readonly coreDataSource: DataSource,
private readonly objectMetadataService: ObjectMetadataService,
private readonly objectMetadataService: ObjectMetadataServiceV2,
private readonly timelineActivitySeederService: TimelineActivitySeederService,
private readonly fileStorageService: FileStorageService,
) {}
@@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common';
import chunk from 'lodash.chunk';
import { ObjectRecord } from 'twenty-shared/types';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { CALENDAR_EVENT_DATA_SEEDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/calendar-event-data-seeds.constant';
import {
@@ -107,7 +107,9 @@ export class TimelineActivitySeederService {
'message',
]);
constructor(private readonly objectMetadataService: ObjectMetadataService) {}
constructor(
private readonly objectMetadataService: ObjectMetadataServiceV2,
) {}
private getLinkedActivityName(activityType: string): string {
// Notes and tasks use the legacy format: linked-{type}.created
@@ -8,7 +8,6 @@ import { type DataSourceEntity } from 'src/engine/metadata-modules/data-source/d
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { FieldMetadataServiceV2 } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
import {
@@ -30,7 +29,6 @@ import { prefillCoreViews } from 'src/engine/workspace-manager/standard-objects-
@Injectable()
export class DevSeederMetadataService {
constructor(
private readonly objectMetadataService: ObjectMetadataService,
private readonly objectMetadataServiceV2: ObjectMetadataServiceV2,
private readonly fieldMetadataServiceV2: FieldMetadataServiceV2,
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
@@ -140,7 +138,7 @@ export class DevSeederMetadataService {
workspaceId: string;
objectMetadataSeed: ObjectMetadataSeed;
}): Promise<void> {
await this.objectMetadataServiceV2.createOne({
await this.objectMetadataServiceV2.createOneObject({
createObjectInput: {
...objectMetadataSeed,
dataSourceId,
@@ -159,7 +157,7 @@ export class DevSeederMetadataService {
fieldMetadataSeeds: FieldMetadataSeed[];
}): Promise<void> {
const objectMetadata =
await this.objectMetadataService.findOneWithinWorkspace(workspaceId, {
await this.objectMetadataServiceV2.findOneWithinWorkspace(workspaceId, {
where: { nameSingular: objectMetadataNameSingular },
});
@@ -173,7 +171,7 @@ export class DevSeederMetadataService {
objectMetadataId: objectMetadata.id,
}));
await this.fieldMetadataServiceV2.createMany({
await this.fieldMetadataServiceV2.createManyFields({
createFieldInputs,
workspaceId,
});
@@ -189,7 +187,7 @@ export class DevSeederMetadataService {
featureFlags?: Record<string, boolean>;
}): Promise<void> {
const createdObjectMetadata =
await this.objectMetadataService.findManyWithinWorkspace(workspaceId);
await this.objectMetadataServiceV2.findManyWithinWorkspace(workspaceId);
await prefillCoreViews({
coreDataSource: this.coreDataSource,
@@ -243,7 +241,7 @@ export class DevSeederMetadataService {
objectMetadataMaps,
});
await this.fieldMetadataServiceV2.createMany({
await this.fieldMetadataServiceV2.createManyFields({
createFieldInputs,
workspaceId,
});
@@ -11,7 +11,7 @@ import {
} from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-options.interface';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { WorkspaceMigrationEntity } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
@@ -29,7 +29,7 @@ export class WorkspaceHealthService {
@InjectDataSource()
private readonly coreDataSource: DataSource,
private readonly dataSourceService: DataSourceService,
private readonly objectMetadataService: ObjectMetadataService,
private readonly objectMetadataService: ObjectMetadataServiceV2,
private readonly databaseStructureService: DatabaseStructureService,
private readonly objectMetadataHealthService: ObjectMetadataHealthService,
private readonly fieldMetadataHealthService: FieldMetadataHealthService,
@@ -10,7 +10,6 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { type DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
@@ -32,7 +31,6 @@ export class WorkspaceManagerService {
private readonly coreDataSource: DataSource,
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
private readonly workspaceMigrationService: WorkspaceMigrationService,
private readonly objectMetadataService: ObjectMetadataService,
private readonly objectMetadataServiceV2: ObjectMetadataServiceV2,
private readonly dataSourceService: DataSourceService,
private readonly workspaceSyncMetadataService: WorkspaceSyncMetadataService,
@@ -121,7 +119,7 @@ export class WorkspaceManagerService {
featureFlags: Record<string, boolean>,
) {
const createdObjectMetadata =
await this.objectMetadataService.findManyWithinWorkspace(workspaceId);
await this.objectMetadataServiceV2.findManyWithinWorkspace(workspaceId);
await standardObjectsPrefillData(
this.coreDataSource,
@@ -1,359 +0,0 @@
import { findViewFieldsOperationFactory } from 'test/integration/graphql/utils/find-view-fields-operation-factory.util';
import {
assertGraphQLErrorResponse,
assertGraphQLSuccessfulResponse,
} from 'test/integration/graphql/utils/graphql-test-assertions.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import {
createViewFieldData,
updateViewFieldData,
} from 'test/integration/graphql/utils/view-data-factory.util';
import { createTestViewWithGraphQL } from 'test/integration/graphql/utils/view-graphql.util';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { createOneCoreViewField } from 'test/integration/metadata/suites/view-field/utils/create-one-core-view-field.util';
import { deleteOneCoreViewField } from 'test/integration/metadata/suites/view-field/utils/delete-one-core-view-field.util';
import { destroyOneCoreViewField } from 'test/integration/metadata/suites/view-field/utils/destroy-one-core-view-field.util';
import { updateOneCoreViewField } from 'test/integration/metadata/suites/view-field/utils/update-one-core-view-field.util';
import { destroyOneCoreView } from 'test/integration/metadata/suites/view/utils/destroy-one-core-view.util';
import { assertViewFieldStructure } from 'test/integration/utils/view-test.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { type CreateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/create-view-field.input';
import {
generateViewFieldExceptionMessage,
ViewFieldExceptionMessageKey,
} from 'src/engine/metadata-modules/view-field/exceptions/view-field.exception';
const TEST_NOT_EXISTING_VIEW_FIELD_ID = '20202020-0000-4000-8000-000000000001';
describe('View Field Resolver', () => {
let testViewId: string;
let testObjectMetadataId: string;
let testFieldMetadataId: string;
beforeAll(async () => {
await updateFeatureFlag({
expectToFail: false,
featureFlag: FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
value: false,
});
});
beforeAll(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'myFieldTestObject',
namePlural: 'myFieldTestObjects',
labelSingular: 'My Field Test Object',
labelPlural: 'My Field Test Objects',
icon: 'Icon123',
},
});
testObjectMetadataId = objectMetadataId;
const createFieldInput = {
name: 'testField',
label: 'Test Field',
type: FieldMetadataType.TEXT,
objectMetadataId: testObjectMetadataId,
isLabelSyncedWithName: true,
};
const {
data: {
createOneField: { id: fieldMetadataId },
},
} = await createOneFieldMetadata({
expectToFail: false,
input: createFieldInput,
gqlFields: `
id
name
label
isLabelSyncedWithName
`,
});
testFieldMetadataId = fieldMetadataId;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: testObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: testObjectMetadataId },
});
});
afterAll(async () => {
await updateFeatureFlag({
expectToFail: false,
featureFlag: FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
value: true,
});
});
beforeEach(async () => {
const view = await createTestViewWithGraphQL({
name: 'Test View for Fields',
objectMetadataId: testObjectMetadataId,
});
testViewId = view.id;
});
afterEach(async () => {
await destroyOneCoreView({
viewId: testViewId,
expectToFail: false,
});
});
describe('getCoreViewFields', () => {
it('should return empty array when no view fields exist', async () => {
const operation = findViewFieldsOperationFactory({ viewId: testViewId });
const response = await makeGraphqlAPIRequest(operation);
assertGraphQLSuccessfulResponse(response);
expect(response.body.data.getCoreViewFields).toEqual([]);
});
it('should return view fields for a specific view', async () => {
const fieldData = createViewFieldData(testViewId, {
position: 0,
isVisible: true,
size: 150,
fieldMetadataId: testFieldMetadataId,
});
await createOneCoreViewField({
input: fieldData as CreateViewFieldInput,
expectToFail: false,
});
const getOperation = findViewFieldsOperationFactory({
viewId: testViewId,
});
const response = await makeGraphqlAPIRequest(getOperation);
assertGraphQLSuccessfulResponse(response);
expect(response.body.data.getCoreViewFields).toHaveLength(1);
assertViewFieldStructure(response.body.data.getCoreViewFields[0], {
fieldMetadataId: testFieldMetadataId,
position: 0,
isVisible: true,
size: 150,
viewId: testViewId,
});
});
});
describe('createCoreViewField', () => {
it('should create a new view field', async () => {
const fieldData = createViewFieldData(testViewId, {
position: 1,
isVisible: true,
size: 200,
fieldMetadataId: testFieldMetadataId,
});
const response = await createOneCoreViewField({
input: fieldData as CreateViewFieldInput,
expectToFail: false,
});
assertGraphQLSuccessfulResponse({ body: response, status: 200 });
assertViewFieldStructure(response.data.createCoreViewField, {
fieldMetadataId: testFieldMetadataId,
position: 1,
isVisible: true,
size: 200,
viewId: testViewId,
});
});
it('should create a hidden view field', async () => {
const fieldData = {
fieldMetadataId: testFieldMetadataId,
position: 2,
isVisible: false,
size: 100,
viewId: testViewId,
};
const response = await createOneCoreViewField({
input: fieldData,
expectToFail: false,
});
assertGraphQLSuccessfulResponse({ body: response, status: 200 });
assertViewFieldStructure(response.data.createCoreViewField, {
fieldMetadataId: testFieldMetadataId,
position: 2,
isVisible: false,
size: 100,
viewId: testViewId,
});
});
});
describe('updateCoreViewField', () => {
it('should update an existing view field', async () => {
const fieldData = createViewFieldData(testViewId, {
position: 0,
isVisible: true,
size: 150,
fieldMetadataId: testFieldMetadataId,
});
const createResponse = await createOneCoreViewField({
input: fieldData as CreateViewFieldInput,
expectToFail: false,
});
const viewField = createResponse.data.createCoreViewField;
const updateInput = updateViewFieldData({
position: 5,
isVisible: false,
size: 300,
});
const response = await updateOneCoreViewField({
input: {
id: viewField.id,
update: updateInput,
},
expectToFail: false,
});
assertGraphQLSuccessfulResponse({ body: response, status: 200 });
expect(response.data.updateCoreViewField).toMatchObject({
id: viewField.id,
position: 5,
isVisible: false,
size: 300,
});
});
it('should throw an error when updating non-existent view field', async () => {
const response = await updateOneCoreViewField({
input: {
id: TEST_NOT_EXISTING_VIEW_FIELD_ID,
update: {
position: 1,
},
},
expectToFail: true,
});
assertGraphQLErrorResponse(
{ body: response, status: 200 },
ErrorCode.NOT_FOUND,
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
TEST_NOT_EXISTING_VIEW_FIELD_ID,
),
);
});
});
describe('deleteCoreViewField', () => {
it('should delete an existing view field', async () => {
const fieldData = createViewFieldData(testViewId, {
fieldMetadataId: testFieldMetadataId,
});
const createResponse = await createOneCoreViewField({
input: fieldData as CreateViewFieldInput,
expectToFail: false,
});
const viewField = createResponse.data.createCoreViewField;
const response = await deleteOneCoreViewField({
input: { id: viewField.id },
expectToFail: false,
});
assertGraphQLSuccessfulResponse({ body: response, status: 200 });
expect(response.data.deleteCoreViewField).toMatchObject({
id: viewField.id,
});
});
it('should throw an error when deleting non-existent view field', async () => {
const response = await deleteOneCoreViewField({
input: { id: TEST_NOT_EXISTING_VIEW_FIELD_ID },
expectToFail: true,
});
assertGraphQLErrorResponse(
{ body: response, status: 200 },
ErrorCode.NOT_FOUND,
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
TEST_NOT_EXISTING_VIEW_FIELD_ID,
),
);
});
});
describe('destroyCoreViewField', () => {
it('should destroy an existing view field', async () => {
const fieldData = createViewFieldData(testViewId, {
fieldMetadataId: testFieldMetadataId,
});
const createResponse = await createOneCoreViewField({
input: fieldData as CreateViewFieldInput,
expectToFail: false,
});
const viewField = createResponse.data.createCoreViewField;
const response = await destroyOneCoreViewField({
input: {
id: viewField.id,
},
expectToFail: false,
});
assertGraphQLSuccessfulResponse({ body: response, status: 200 });
expect(response.data.destroyCoreViewField).toMatchObject({
id: viewField.id,
});
});
it('should throw an error when destroying non-existent view field', async () => {
const response = await destroyOneCoreViewField({
input: {
id: TEST_NOT_EXISTING_VIEW_FIELD_ID,
},
expectToFail: true,
});
assertGraphQLErrorResponse(
{ body: response, status: 200 },
ErrorCode.NOT_FOUND,
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
TEST_NOT_EXISTING_VIEW_FIELD_ID,
),
);
});
});
});
@@ -20,8 +20,8 @@ import { AgentHandoffService } from 'src/engine/metadata-modules/agent/agent-han
import { AgentToolGeneratorService } from 'src/engine/metadata-modules/agent/agent-tool-generator.service';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
@@ -36,7 +36,7 @@ export interface AgentToolTestContext {
module: TestingModule;
agentToolService: AgentToolGeneratorService;
agentService: AgentService;
objectMetadataService: ObjectMetadataService;
objectMetadataService: ObjectMetadataServiceV2;
roleRepository: Repository<RoleEntity>;
workspacePermissionsCacheService: WorkspacePermissionsCacheService;
twentyORMGlobalManager: TwentyORMGlobalManager;
@@ -78,7 +78,7 @@ export const createAgentToolTestModule =
},
},
{
provide: ObjectMetadataService,
provide: ObjectMetadataServiceV2,
useValue: {
findManyWithinWorkspace: jest.fn(),
findOneWithinWorkspace: jest.fn(),
@@ -215,8 +215,8 @@ export const createAgentToolTestModule =
AgentToolGeneratorService,
);
const agentService = module.get<AgentService>(AgentService);
const objectMetadataService = module.get<ObjectMetadataService>(
ObjectMetadataService,
const objectMetadataService = module.get<ObjectMetadataServiceV2>(
ObjectMetadataServiceV2,
);
const roleRepository = module.get<Repository<RoleEntity>>(
getRepositoryToken(RoleEntity),
@@ -1,221 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`update-one-field-metadata-related-record MULTI_SELECT should handle adding new options while maintaining existing view filter 1`] = `
{
"operand": "IS",
"positionInViewFilterGroup": null,
"subFieldName": null,
"value": [
"OPTION_0",
"OPTION_1",
],
"viewFilterGroupId": null,
}
`;
exports[`update-one-field-metadata-related-record MULTI_SELECT should handle no changes update of options while maintaining existing view filter values 1`] = `
{
"operand": "IS",
"positionInViewFilterGroup": null,
"subFieldName": null,
"value": [
"OPTION_0",
"OPTION_1",
"OPTION_2",
"OPTION_3",
"OPTION_4",
"OPTION_5",
"OPTION_6",
"OPTION_7",
"OPTION_8",
"OPTION_9",
],
"viewFilterGroupId": null,
}
`;
exports[`update-one-field-metadata-related-record MULTI_SELECT should handle partial deletion of selected options in view filter 1`] = `
{
"operand": "IS",
"positionInViewFilterGroup": null,
"subFieldName": null,
"value": [
"OPTION_4",
"OPTION_5",
"OPTION_6",
"OPTION_7",
"OPTION_8",
"OPTION_9",
],
"viewFilterGroupId": null,
}
`;
exports[`update-one-field-metadata-related-record MULTI_SELECT should handle reordering of options while maintaining view filter values 1`] = `
{
"operand": "IS",
"positionInViewFilterGroup": null,
"subFieldName": null,
"value": [
"OPTION_0",
"OPTION_1",
],
"viewFilterGroupId": null,
}
`;
exports[`update-one-field-metadata-related-record MULTI_SELECT should throw error if view filter value is not a stringified JSON array 1`] = `
[
{
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"exceptionEventId": "mocked-exception-id",
"userFriendlyMessage": "An error occurred.",
},
"message": "Unexpected invalid view filter value for filter 20202020-e3b5-4fa7-85aa-9b1950fc7bf5",
},
]
`;
exports[`update-one-field-metadata-related-record MULTI_SELECT should update related multi selected options view filter 1`] = `
{
"operand": "IS",
"positionInViewFilterGroup": null,
"subFieldName": null,
"value": [
"OPTION_0_UPDATED",
"OPTION_1",
"OPTION_2_UPDATED",
"OPTION_3",
"OPTION_4_UPDATED",
"OPTION_5",
"OPTION_6_UPDATED",
"OPTION_7",
"OPTION_8_UPDATED",
"OPTION_9",
],
"viewFilterGroupId": null,
}
`;
exports[`update-one-field-metadata-related-record MULTI_SELECT should update related solo selected option view filter 1`] = `
{
"operand": "IS",
"positionInViewFilterGroup": null,
"subFieldName": null,
"value": [
"OPTION_5_UPDATED",
],
"viewFilterGroupId": null,
}
`;
exports[`update-one-field-metadata-related-record SELECT should handle adding new options while maintaining existing view filter 1`] = `
{
"operand": "IS",
"positionInViewFilterGroup": null,
"subFieldName": null,
"value": [
"OPTION_0",
"OPTION_1",
],
"viewFilterGroupId": null,
}
`;
exports[`update-one-field-metadata-related-record SELECT should handle no changes update of options while maintaining existing view filter values 1`] = `
{
"operand": "IS",
"positionInViewFilterGroup": null,
"subFieldName": null,
"value": [
"OPTION_0",
"OPTION_1",
"OPTION_2",
"OPTION_3",
"OPTION_4",
"OPTION_5",
"OPTION_6",
"OPTION_7",
"OPTION_8",
"OPTION_9",
],
"viewFilterGroupId": null,
}
`;
exports[`update-one-field-metadata-related-record SELECT should handle partial deletion of selected options in view filter 1`] = `
{
"operand": "IS",
"positionInViewFilterGroup": null,
"subFieldName": null,
"value": [
"OPTION_4",
"OPTION_5",
"OPTION_6",
"OPTION_7",
"OPTION_8",
"OPTION_9",
],
"viewFilterGroupId": null,
}
`;
exports[`update-one-field-metadata-related-record SELECT should handle reordering of options while maintaining view filter values 1`] = `
{
"operand": "IS",
"positionInViewFilterGroup": null,
"subFieldName": null,
"value": [
"OPTION_0",
"OPTION_1",
],
"viewFilterGroupId": null,
}
`;
exports[`update-one-field-metadata-related-record SELECT should throw error if view filter value is not a stringified JSON array 1`] = `
[
{
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"exceptionEventId": "mocked-exception-id",
"userFriendlyMessage": "An error occurred.",
},
"message": "Unexpected invalid view filter value for filter 20202020-e3b5-4fa7-85aa-9b1950fc7bf5",
},
]
`;
exports[`update-one-field-metadata-related-record SELECT should update related multi selected options view filter 1`] = `
{
"operand": "IS",
"positionInViewFilterGroup": null,
"subFieldName": null,
"value": [
"OPTION_0_UPDATED",
"OPTION_1",
"OPTION_2_UPDATED",
"OPTION_3",
"OPTION_4_UPDATED",
"OPTION_5",
"OPTION_6_UPDATED",
"OPTION_7",
"OPTION_8_UPDATED",
"OPTION_9",
],
"viewFilterGroupId": null,
}
`;
exports[`update-one-field-metadata-related-record SELECT should update related solo selected option view filter 1`] = `
{
"operand": "IS",
"positionInViewFilterGroup": null,
"subFieldName": null,
"value": [
"OPTION_5_UPDATED",
],
"viewFilterGroupId": null,
}
`;
@@ -1,136 +0,0 @@
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 { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import {
createTestViewWithRestApi,
findViewByIdWithRestApi,
} from 'test/integration/rest/utils/view-rest-api.util';
import { generateRecordName } from 'test/integration/utils/generate-record-name';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { ViewType } from 'src/engine/metadata-modules/view/enums/view-type.enum';
describe('deleteOne', () => {
beforeAll(async () => {
await updateFeatureFlag({
expectToFail: false,
featureFlag: FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
value: false,
});
});
afterAll(async () => {
await updateFeatureFlag({
expectToFail: false,
featureFlag: FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
value: true,
});
});
describe('Kanban aggregate operation', () => {
let listingObjectId = '';
let testFieldId = '';
let viewId = '';
beforeEach(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'listingv2',
namePlural: 'listingsv2',
labelSingular: 'Listing',
labelPlural: 'Listings',
icon: 'IconBuildingSkyscraper',
},
});
listingObjectId = data.createOneObject.id;
const { data: createdFieldData } = await createOneFieldMetadata({
expectToFail: false,
input: {
name: 'house',
type: FieldMetadataType.TEXT,
label: 'House',
objectMetadataId: listingObjectId,
},
});
testFieldId = createdFieldData.createOneField.id;
const createdView = await createTestViewWithRestApi({
name: generateRecordName('By Type'),
objectMetadataId: listingObjectId,
type: ViewType.KANBAN,
kanbanAggregateOperationFieldMetadataId: testFieldId,
kanbanAggregateOperation: AggregateOperations.MAX,
icon: 'IconLayoutKanban',
});
viewId = createdView.id;
});
afterEach(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: listingObjectId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: listingObjectId },
});
});
it('should delete kanban aggregate operation when deleting a field used as kanbanAggregateOperationFieldMetadataId', async () => {
const viewThatShouldBeUpdated = await findViewByIdWithRestApi(viewId);
if (!isDefined(viewThatShouldBeUpdated)) {
throw new Error('View not found, this should not happen');
}
expect(
viewThatShouldBeUpdated.kanbanAggregateOperationFieldMetadataId,
).toBe(testFieldId);
expect(viewThatShouldBeUpdated.kanbanAggregateOperation).toBe('MAX');
await updateOneFieldMetadata({
expectToFail: false,
input: {
idToUpdate: testFieldId,
updatePayload: { isActive: false },
},
gqlFields: `
id
isActive
`,
});
const { data } = await deleteOneFieldMetadata({
expectToFail: false,
input: { idToDelete: testFieldId },
});
expect(data.deleteOneField.id).toBe(testFieldId);
const updatedViewResponse = await findViewByIdWithRestApi(viewId);
if (!isDefined(updatedViewResponse)) {
throw new Error('View not found, this should not happen');
}
expect(
updatedViewResponse.kanbanAggregateOperationFieldMetadataId,
).toBeNull();
expect(updatedViewResponse.kanbanAggregateOperation).toBe(null);
});
});
});
@@ -1,518 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with an invalid default value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "Default value for multi-select must be an array",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with an unknown default value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "Default value for multi-select must be an array",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with comma in option label 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Label must not contain a comma",
},
"message": "Label must not contain a comma",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with duplicated option ids 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Duplicated option id",
},
"message": "Duplicated option {field}",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with duplicated option positions 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Duplicated option position",
},
"message": "Duplicated option {field}",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with duplicated option values 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Duplicated option value",
},
"message": "Duplicated option {field}",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with duplicated trimmed option values 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Duplicated option value",
},
"message": "Duplicated option {field}",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with empty multi-select default values array 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "If defined default value must contain at least one value",
},
"message": "If defined default value must contain at least one value",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with empty options 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "Options are required for enum fields",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with empty string defaultValue 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Default value should be as quoted string",
},
"message": "Default value should be as quoted string",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with empty string id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option id is invalid",
},
"message": "Option id is invalid",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with empty string label 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option label "" is beneath 1 character",
},
"message": "Option label "{sanitizedLabel}" is beneath 1 character",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with empty string value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option value "" is beneath 1 character",
},
"message": "Option value "{sanitizedValue}" is beneath 1 character",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with invalid multi-select default value format 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Default value should be as quoted string",
},
"message": "Default value should be as quoted string",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with invalid option id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option id is invalid",
},
"message": "Option id is invalid",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with invalid option value format 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Value must be in UPPER_CASE and follow snake_case "Option 1 and some other things, /"",
},
"message": "Value must be in UPPER_CASE and follow snake_case "{sanitizedValue}"",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with non stringified array default value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "Default value for multi-select must be an array",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with not a string defaultValue 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "Default value for multi-select must be a stringified array",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with not a string id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option id is invalid",
},
"message": "Option id is invalid",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with not a string label 1`] = `
[
{
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"exceptionEventId": "mocked-exception-id",
"userFriendlyMessage": "An error occurred.",
},
"message": "label.includes is not a function",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with not a string value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Value must be in UPPER_CASE and follow snake_case "22222"",
},
"message": "Value must be in UPPER_CASE and follow snake_case "{sanitizedValue}"",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with null id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option id is required",
},
"message": "Option id is required",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with null label 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option label is required",
},
"message": "Option label is required",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with null options 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "Options are required for enum fields",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with null value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option value is required",
},
"message": "Option value is required",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with only white spaces defaultValue 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Default value should be as quoted string",
},
"message": "Default value should be as quoted string",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with only white spaces id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option id is invalid",
},
"message": "Option id is invalid",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with only white spaces label 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option label "" is beneath 1 character",
},
"message": "Option label "{sanitizedLabel}" is beneath 1 character",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with only white spaces value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option value "" is beneath 1 character",
},
"message": "Option value "{sanitizedValue}" is beneath 1 character",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with too long defaultValue 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Default value should be as quoted string",
},
"message": "Default value should be as quoted string",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with too long id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option id is invalid",
},
"message": "Option id is invalid",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with too long label 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option label exceeds 63 characters",
},
"message": "Option label exceeds 63 characters",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with too long value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option value exceeds 63 characters",
},
"message": "Option value exceeds 63 characters",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with undefined option label 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option label is required",
},
"message": "Option label is required",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with undefined option value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option value is required",
},
"message": "Option value is required",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with undefined options 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "Options are required for enum fields",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata MULTI_SELECT tests suite Create should fail with unknown multi-select default values 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Default value "'UNKNOWN_OPTION'" must be one of the option values",
},
"message": "Default value "{defaultValue}" must be one of the option values",
"name": "UserInputError",
},
]
`;
@@ -1,462 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Failing create field metadata RATING tests suite Create should fail with an invalid default value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Default value should be as quoted string",
},
"message": "Default value should be as quoted string",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with an unknown default value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Default value "'OPTION_424242'" must be one of the option values",
},
"message": "Default value "{defaultValue}" must be one of the option values",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with comma in option label 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Label must not contain a comma",
},
"message": "Label must not contain a comma",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with duplicated option ids 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Duplicated option id",
},
"message": "Duplicated option {field}",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with duplicated option positions 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Duplicated option position",
},
"message": "Duplicated option {field}",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with duplicated option values 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Duplicated option value",
},
"message": "Duplicated option {field}",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with duplicated trimmed option values 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Duplicated option value",
},
"message": "Duplicated option {field}",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with empty options 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "Options are required for enum fields",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with empty string defaultValue 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Default value should be as quoted string",
},
"message": "Default value should be as quoted string",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with empty string id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option id is invalid",
},
"message": "Option id is invalid",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with empty string label 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option label "" is beneath 1 character",
},
"message": "Option label "{sanitizedLabel}" is beneath 1 character",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with empty string value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option value "" is beneath 1 character",
},
"message": "Option value "{sanitizedValue}" is beneath 1 character",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with invalid option id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option id is invalid",
},
"message": "Option id is invalid",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with invalid option value format 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Value must be in UPPER_CASE and follow snake_case "Option 1 and some other things, /"",
},
"message": "Value must be in UPPER_CASE and follow snake_case "{sanitizedValue}"",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with not a string defaultValue 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "Default value for multi-select must be a stringified array",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with not a string id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option id is invalid",
},
"message": "Option id is invalid",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with not a string label 1`] = `
[
{
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"exceptionEventId": "mocked-exception-id",
"userFriendlyMessage": "An error occurred.",
},
"message": "label.includes is not a function",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with not a string value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Value must be in UPPER_CASE and follow snake_case "22222"",
},
"message": "Value must be in UPPER_CASE and follow snake_case "{sanitizedValue}"",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with null id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option id is required",
},
"message": "Option id is required",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with null label 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option label is required",
},
"message": "Option label is required",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with null options 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "Options are required for enum fields",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with null value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option value is required",
},
"message": "Option value is required",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with only white spaces defaultValue 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Default value should be as quoted string",
},
"message": "Default value should be as quoted string",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with only white spaces id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option id is invalid",
},
"message": "Option id is invalid",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with only white spaces label 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option label "" is beneath 1 character",
},
"message": "Option label "{sanitizedLabel}" is beneath 1 character",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with only white spaces value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option value "" is beneath 1 character",
},
"message": "Option value "{sanitizedValue}" is beneath 1 character",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with too long defaultValue 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Default value should be as quoted string",
},
"message": "Default value should be as quoted string",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with too long id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option id is invalid",
},
"message": "Option id is invalid",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with too long label 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option label exceeds 63 characters",
},
"message": "Option label exceeds 63 characters",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with too long value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option value exceeds 63 characters",
},
"message": "Option value exceeds 63 characters",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with undefined option label 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option label is required",
},
"message": "Option label is required",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with undefined option value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option value is required",
},
"message": "Option value is required",
"name": "UserInputError",
},
]
`;
exports[`Failing create field metadata RATING tests suite Create should fail with undefined options 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "Options are required for enum fields",
"name": "UserInputError",
},
]
`;
@@ -1,951 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with an invalid default value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "Default value for multi-select must be an array",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with an unknown default value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "Default value for multi-select must be an array",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with comma in option label 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Label must not contain a comma",
},
"message": "Label must not contain a comma",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with duplicated option ids 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Duplicated option id",
},
"message": "Duplicated option {field}",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with duplicated option positions 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Duplicated option position",
},
"message": "Duplicated option {field}",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with duplicated option values 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Duplicated option value",
},
"message": "Duplicated option {field}",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with duplicated trimmed option values 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Duplicated option value",
},
"message": "Duplicated option {field}",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with empty multi-select default values array 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "If defined default value must contain at least one value",
},
"message": "If defined default value must contain at least one value",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with empty options 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "Options are required for enum fields",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with empty string defaultValue 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Default value should be as quoted string",
},
"message": "Default value should be as quoted string",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with empty string id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option id is invalid",
},
"message": "Option id is invalid",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with empty string label 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option label "" is beneath 1 character",
},
"message": "Option label "{sanitizedLabel}" is beneath 1 character",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with empty string value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option value "" is beneath 1 character",
},
"message": "Option value "{sanitizedValue}" is beneath 1 character",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with invalid multi-select default value format 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Default value should be as quoted string",
},
"message": "Default value should be as quoted string",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with invalid option id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option id is invalid",
},
"message": "Option id is invalid",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with invalid option value format 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Value must be in UPPER_CASE and follow snake_case "Option 1 and some other things, /"",
},
"message": "Value must be in UPPER_CASE and follow snake_case "{sanitizedValue}"",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with non stringified array default value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "Default value for multi-select must be an array",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with not a string defaultValue 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "Default value for multi-select must be a stringified array",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with not a string id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option id is invalid",
},
"message": "Option id is invalid",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with not a string label 1`] = `
[
{
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"exceptionEventId": "mocked-exception-id",
"userFriendlyMessage": "An error occurred.",
},
"message": "label.includes is not a function",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with not a string value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Value must be in UPPER_CASE and follow snake_case "22222"",
},
"message": "Value must be in UPPER_CASE and follow snake_case "{sanitizedValue}"",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with null id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option id is required",
},
"message": "Option id is required",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with null label 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option label is required",
},
"message": "Option label is required",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with null value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option value is required",
},
"message": "Option value is required",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with only white spaces defaultValue 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Default value should be as quoted string",
},
"message": "Default value should be as quoted string",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with only white spaces id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option id is invalid",
},
"message": "Option id is invalid",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with only white spaces label 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option label "" is beneath 1 character",
},
"message": "Option label "{sanitizedLabel}" is beneath 1 character",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with only white spaces value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option value "" is beneath 1 character",
},
"message": "Option value "{sanitizedValue}" is beneath 1 character",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with too long defaultValue 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Default value should be as quoted string",
},
"message": "Default value should be as quoted string",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with too long id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option id is invalid",
},
"message": "Option id is invalid",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with too long label 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option label exceeds 63 characters",
},
"message": "Option label exceeds 63 characters",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with too long value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option value exceeds 63 characters",
},
"message": "Option value exceeds 63 characters",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with undefined option label 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option label is required",
},
"message": "Option label is required",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with undefined option value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option value is required",
},
"message": "Option value is required",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with unknown default value and no options 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Default value "'OPTION_42'" must be one of the option values",
},
"message": "Default value "{defaultValue}" must be one of the option values",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata MULTI_SELECT tests suite Update should fail with unknown multi-select default values 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Default value "'UNKNOWN_OPTION'" must be one of the option values",
},
"message": "Default value "{defaultValue}" must be one of the option values",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with an invalid default value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Default value should be as quoted string",
},
"message": "Default value should be as quoted string",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with an unknown default value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Default value "'OPTION_424242'" must be one of the option values",
},
"message": "Default value "{defaultValue}" must be one of the option values",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with comma in option label 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Label must not contain a comma",
},
"message": "Label must not contain a comma",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with duplicated option ids 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Duplicated option id",
},
"message": "Duplicated option {field}",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with duplicated option positions 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Duplicated option position",
},
"message": "Duplicated option {field}",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with duplicated option values 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Duplicated option value",
},
"message": "Duplicated option {field}",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with duplicated trimmed option values 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Duplicated option value",
},
"message": "Duplicated option {field}",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with empty options 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "Options are required for enum fields",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with empty string defaultValue 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Default value should be as quoted string",
},
"message": "Default value should be as quoted string",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with empty string id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option id is invalid",
},
"message": "Option id is invalid",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with empty string label 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option label "" is beneath 1 character",
},
"message": "Option label "{sanitizedLabel}" is beneath 1 character",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with empty string value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option value "" is beneath 1 character",
},
"message": "Option value "{sanitizedValue}" is beneath 1 character",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with invalid option id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option id is invalid",
},
"message": "Option id is invalid",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with invalid option value format 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Value must be in UPPER_CASE and follow snake_case "Option 1 and some other things, /"",
},
"message": "Value must be in UPPER_CASE and follow snake_case "{sanitizedValue}"",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with not a string defaultValue 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "Default value for multi-select must be a stringified array",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with not a string id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option id is invalid",
},
"message": "Option id is invalid",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with not a string label 1`] = `
[
{
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"exceptionEventId": "mocked-exception-id",
"userFriendlyMessage": "An error occurred.",
},
"message": "label.includes is not a function",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with not a string value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Value must be in UPPER_CASE and follow snake_case "22222"",
},
"message": "Value must be in UPPER_CASE and follow snake_case "{sanitizedValue}"",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with null id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option id is required",
},
"message": "Option id is required",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with null label 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option label is required",
},
"message": "Option label is required",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with null value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option value is required",
},
"message": "Option value is required",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with only white spaces defaultValue 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Default value should be as quoted string",
},
"message": "Default value should be as quoted string",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with only white spaces id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option id is invalid",
},
"message": "Option id is invalid",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with only white spaces label 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option label "" is beneath 1 character",
},
"message": "Option label "{sanitizedLabel}" is beneath 1 character",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with only white spaces value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option value "" is beneath 1 character",
},
"message": "Option value "{sanitizedValue}" is beneath 1 character",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with too long defaultValue 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Default value should be as quoted string",
},
"message": "Default value should be as quoted string",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with too long id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option id is invalid",
},
"message": "Option id is invalid",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with too long label 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option label exceeds 63 characters",
},
"message": "Option label exceeds 63 characters",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with too long value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option value exceeds 63 characters",
},
"message": "Option value exceeds 63 characters",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with undefined option label 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option label is required",
},
"message": "Option label is required",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with undefined option value 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Option value is required",
},
"message": "Option value is required",
"name": "UserInputError",
},
]
`;
exports[`Failing update field metadata SELECT tests suite Update should fail with unknown default value and no options 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_FIELD_INPUT",
"userFriendlyMessage": "Default value "'OPTION_42'" must be one of the option values",
},
"message": "Default value "{defaultValue}" must be one of the option values",
"name": "UserInputError",
},
]
`;
@@ -1,85 +0,0 @@
import { CREATE_ENUM_FIELD_METADATA_TEST_CASES } from 'test/integration/metadata/suites/field-metadata/enum/common/create-enum-field-metadata-test-cases';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
describe('Failing create field metadata MULTI_SELECT tests suite', () => {
let createdObjectMetadataId: string;
const testCases =
CREATE_ENUM_FIELD_METADATA_TEST_CASES[FieldMetadataType.MULTI_SELECT];
if (!isDefined(testCases)) {
return;
}
const { failing: failingTestCases } = testCases;
beforeAll(async () => {
await updateFeatureFlag({
expectToFail: false,
featureFlag: FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
value: false,
});
});
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
labelSingular: 'Enum testing v1',
labelPlural: 'Enums testings v1',
nameSingular: 'enumTestingV1',
namePlural: 'enumsTestingsV1',
icon: 'IconBuildingSkyscraper',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterAll(async () => {
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
afterAll(async () => {
await updateFeatureFlag({
expectToFail: false,
featureFlag: FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
value: true,
});
});
test.each(failingTestCases)(
'Create $title',
async ({ context: { input } }) => {
const { data, errors } = await createOneFieldMetadata({
expectToFail: true,
input: {
objectMetadataId: createdObjectMetadataId,
type: FieldMetadataType.MULTI_SELECT,
name: 'testField',
label: 'Test Field',
isLabelSyncedWithName: false,
...input,
},
gqlFields: `
id
options
defaultValue
`,
});
expect(data).toBeNull();
expect(errors).toBeDefined();
expect(errors).toMatchSnapshot();
},
);
});
@@ -1,85 +0,0 @@
import { CREATE_ENUM_FIELD_METADATA_TEST_CASES } from 'test/integration/metadata/suites/field-metadata/enum/common/create-enum-field-metadata-test-cases';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { isDefined } from 'twenty-shared/utils';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
describe('Failing create field metadata RATING tests suite', () => {
let createdObjectMetadataId: string;
const testCases =
CREATE_ENUM_FIELD_METADATA_TEST_CASES[FieldMetadataType.SELECT];
if (!isDefined(testCases)) {
return;
}
const { failing: failingTestCases } = testCases;
beforeAll(async () => {
await updateFeatureFlag({
expectToFail: false,
featureFlag: FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
value: false,
});
});
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
labelSingular: 'Enum testing v1',
labelPlural: 'Enums testings v1',
nameSingular: 'enumTestingV1',
namePlural: 'enumsTestingsV1',
icon: 'IconBuildingSkyscraper',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterAll(async () => {
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
afterAll(async () => {
await updateFeatureFlag({
expectToFail: false,
featureFlag: FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
value: true,
});
});
test.each(failingTestCases)(
'Create $title',
async ({ context: { input } }) => {
const { data, errors } = await createOneFieldMetadata({
expectToFail: true,
input: {
objectMetadataId: createdObjectMetadataId,
type: FieldMetadataType.SELECT,
name: 'testField',
label: 'Test Field',
isLabelSyncedWithName: false,
...input,
},
gqlFields: `
id
options
defaultValue
`,
});
expect(data).toBeNull();
expect(errors).toBeDefined();
expect(errors).toMatchSnapshot();
},
);
});
@@ -1,124 +0,0 @@
import { UPDATE_ENUM_FIELD_METADATA_TEST_CASES } from 'test/integration/metadata/suites/field-metadata/enum/common/update-enum-field-metadata-test-cases';
import { type 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 { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { fieldMetadataEnumTypes } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util';
describe.each(fieldMetadataEnumTypes)(
'Failing update field metadata %s tests suite',
(testedFieldMetadataType) => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string;
const testCases =
UPDATE_ENUM_FIELD_METADATA_TEST_CASES[testedFieldMetadataType];
if (!isDefined(testCases)) {
return;
}
const { failing: failingTestCases } = testCases;
beforeAll(async () => {
await updateFeatureFlag({
expectToFail: false,
featureFlag: FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
value: false,
});
});
const initialOptions: CreateOneFieldFactoryInput['options'] = [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 1,
},
{
label: 'Option 2',
value: 'OPTION_2',
color: 'green',
position: 2,
},
];
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
labelSingular: 'failingUpdateEnumField',
labelPlural: 'failingUpdateEnumFields',
nameSingular: 'failingUpdateEnumField',
namePlural: 'failingUpdateEnumFields',
icon: 'IconBuildingSkyscraper',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
const {
data: { createOneField },
} = await createOneFieldMetadata({
input: {
objectMetadataId: createdObjectMetadataId,
type: testedFieldMetadataType,
name: 'testField',
label: 'Test Field',
isLabelSyncedWithName: false,
options: initialOptions,
},
gqlFields: `
id
type
`,
});
createdFieldMetadataId = createOneField.id;
});
afterAll(async () => {
await deleteOneObjectMetadata({
input: {
idToDelete: createdObjectMetadataId,
},
expectToFail: false,
});
});
afterAll(async () => {
await updateFeatureFlag({
expectToFail: false,
featureFlag: FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
value: true,
});
});
test.each(failingTestCases)(
'Update $title',
async ({ context: { input } }) => {
const { ...updatePayload } = input;
const { data, errors } = await updateOneFieldMetadata({
expectToFail: true,
input: {
idToUpdate: createdFieldMetadataId,
updatePayload,
},
gqlFields: `
id
options
`,
});
expect(data).toBeNull();
expect(errors).toBeDefined();
expect(errors).toMatchSnapshot();
},
);
},
);
@@ -1,112 +0,0 @@
import { CREATE_ENUM_FIELD_METADATA_TEST_CASES } from 'test/integration/metadata/suites/field-metadata/enum/common/create-enum-field-metadata-test-cases';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { fieldMetadataEnumTypes } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util';
describe.each(fieldMetadataEnumTypes)(
'Successful create field metadata %s tests suite',
(testedFieldMetadataType) => {
let createdObjectMetadataId: string;
const testCases =
CREATE_ENUM_FIELD_METADATA_TEST_CASES[testedFieldMetadataType];
if (!isDefined(testCases)) {
return;
}
const { successful: successfulTestCases } = testCases;
beforeAll(async () => {
await updateFeatureFlag({
expectToFail: false,
featureFlag: FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
value: false,
});
});
afterAll(async () => {
await updateFeatureFlag({
expectToFail: false,
featureFlag: FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
value: true,
});
});
beforeEach(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
labelSingular: 'successCreateEnumField',
labelPlural: 'successCreateEnumFields',
nameSingular: 'successCreateEnumField',
namePlural: 'successCreateEnumFields',
icon: 'IconBuildingSkyscraper',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
});
afterEach(async () => {
await updateOneObjectMetadata({
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: {
isActive: false,
},
},
expectToFail: false,
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
test.each(successfulTestCases)(
'Create $title',
async ({ context: { input, expectedOptions } }) => {
const { data, errors } = await createOneFieldMetadata<
typeof testedFieldMetadataType
>({
input: {
objectMetadataId: createdObjectMetadataId,
type: testedFieldMetadataType,
name: 'testField',
label: 'Test Field',
isLabelSyncedWithName: false,
...input,
},
gqlFields: `
id
options
defaultValue
type
`,
});
expect(data).not.toBeNull();
expect(data.createOneField).toBeDefined();
expect(data.createOneField.type).toEqual(testedFieldMetadataType);
const createdOptions = data.createOneField.options;
const optionsToCompare = expectedOptions ?? input.options ?? [];
expect(errors).toBeUndefined();
expect(createdOptions?.length).toBe(optionsToCompare.length);
createdOptions?.forEach((option) => expect(option.id).toBeDefined());
expect(createdOptions).toMatchObject(optionsToCompare);
if (isDefined(input.defaultValue)) {
expect(data.createOneField.defaultValue).toEqual(input.defaultValue);
}
},
);
},
);
@@ -1,204 +0,0 @@
import { UPDATE_ENUM_FIELD_METADATA_TEST_CASES } from 'test/integration/metadata/suites/field-metadata/enum/common/update-enum-field-metadata-test-cases';
import { type 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 { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import {
type FieldMetadataComplexOption,
type FieldMetadataDefaultOption,
} from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
import { fieldMetadataEnumTypes } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util';
describe.each(fieldMetadataEnumTypes)(
'Successful update field metadata %s tests suite',
(testedFieldMetadataType) => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string;
const testCases =
UPDATE_ENUM_FIELD_METADATA_TEST_CASES[testedFieldMetadataType];
if (!isDefined(testCases)) {
return;
}
const { successful: successfulTestCases } = testCases;
beforeAll(async () => {
await updateFeatureFlag({
expectToFail: false,
featureFlag: FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
value: false,
});
});
afterAll(async () => {
await updateFeatureFlag({
expectToFail: false,
featureFlag: FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
value: true,
});
});
const initialOptions: CreateOneFieldFactoryInput['options'] = [
{
label: 'Option 1',
value: 'OPTION_1',
color: 'green',
position: 1,
},
{
label: 'Option 2',
value: 'OPTION_2',
color: 'green',
position: 2,
},
];
beforeEach(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
labelSingular: 'successUpdateEnumField',
labelPlural: 'successUpdateEnumFields',
nameSingular: 'successUpdateEnumField',
namePlural: 'successUpdateEnumFields',
icon: 'IconBuildingSkyscraper',
isLabelSyncedWithName: false,
},
});
createdObjectMetadataId = data.createOneObject.id;
const {
data: { createOneField },
} = await createOneFieldMetadata({
expectToFail: false,
input: {
objectMetadataId: createdObjectMetadataId,
type: testedFieldMetadataType,
name: 'testField',
label: 'Test Field',
isLabelSyncedWithName: false,
options: initialOptions,
},
gqlFields: `
id
type
`,
});
createdFieldMetadataId = createOneField.id;
});
afterEach(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: createdObjectMetadataId },
});
});
it('Should update default value to null even if it was set before', async () => {
const isMultiSelect =
testedFieldMetadataType === FieldMetadataType.MULTI_SELECT;
const rawDefaultValue = `'${initialOptions[0].value}'`;
const expectedDefaultValue = isMultiSelect
? [rawDefaultValue]
: rawDefaultValue;
const { data: firstUpdate } = await updateOneFieldMetadata({
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: {
defaultValue: expectedDefaultValue,
},
},
gqlFields: `
id
defaultValue
`,
});
expect(firstUpdate.updateOneField.defaultValue).toEqual(
expectedDefaultValue,
);
const updatedOptions = initialOptions.slice(1);
const { data: secondUpdate, errors } = await updateOneFieldMetadata({
input: {
idToUpdate: createdFieldMetadataId,
updatePayload: {
defaultValue: null,
options: updatedOptions,
},
},
gqlFields: `
id
options
defaultValue
`,
});
expect(errors).toBeUndefined();
expect(secondUpdate.updateOneField.defaultValue).toBeNull();
expect(secondUpdate.updateOneField.options).toMatchObject(updatedOptions);
});
test.each(successfulTestCases)(
'Update $title',
async ({ context: { input, expectedOptions } }) => {
const { ...updatePayload } = input;
const { data, errors } = await updateOneFieldMetadata({
input: {
idToUpdate: createdFieldMetadataId,
updatePayload,
},
gqlFields: `
id
options
defaultValue
`,
});
expect(data.updateOneField).toBeDefined();
const updatedOptions:
| FieldMetadataComplexOption[]
| FieldMetadataDefaultOption[]
| null = data.updateOneField.options;
expect(updatedOptions).toBeDefined();
if (!isDefined(updatedOptions))
throw new Error(
'Should never occur, type invariant post test assertion',
);
expect(errors).toBeUndefined();
updatedOptions.forEach((option) => expect(option.id).toBeDefined());
const optionsToCompare = expectedOptions ?? input.options ?? [];
expect(updatedOptions.length).toBe(optionsToCompare.length);
expect(updatedOptions).toMatchObject(optionsToCompare);
if (isDefined(input.defaultValue)) {
expect(data.updateOneField.defaultValue).toEqual(input.defaultValue);
}
},
);
},
);
@@ -1,191 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Field metadata morph relation creation should fail relation MANY_TO_ONE when targetFieldLabel conflicts with an existing field on target object metadata id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "Name not available",
"userFriendlyMessage": "This name is not available as it is already used by another field.",
},
"message": "Name "collisionfieldlabel" is not available as it is already used by another field",
"name": "UserInputError",
},
]
`;
exports[`Field metadata morph relation creation should fail relation MANY_TO_ONE when targetFieldLabel contains only whitespace 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "Invalid label",
"userFriendlyMessage": "An error occurred.",
},
"message": "Invalid label: " "",
"name": "UserInputError",
},
]
`;
exports[`Field metadata morph relation creation should fail relation MANY_TO_ONE when targetFieldLabel exceeds maximum length 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "Exceeds max length",
"userFriendlyMessage": "An error occurred.",
},
"message": "Name is too long: it exceeds the 63 characters limit.",
"name": "UserInputError",
},
]
`;
exports[`Field metadata morph relation creation should fail relation MANY_TO_ONE when targetFieldLabel is empty 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "Input too short",
"userFriendlyMessage": "An error occurred.",
},
"message": "Input is too short: """,
"name": "UserInputError",
},
]
`;
exports[`Field metadata morph relation creation should fail relation MANY_TO_ONE when targetObjectMetadataId is unknown 1`] = `
[
{
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"exceptionEventId": "mocked-exception-id",
"userFriendlyMessage": "An error occurred.",
},
"message": "Target object metadata does not exist in the object metadata maps",
},
]
`;
exports[`Field metadata morph relation creation should fail relation MANY_TO_ONE when type is a wrong value 1`] = `
[
{
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"exceptionEventId": "mocked-exception-id",
"userFriendlyMessage": "An error occurred.",
},
"message": "Invalid relation type (wrong) for field owner on firstTargetRelation",
},
]
`;
exports[`Field metadata morph relation creation should fail relation MANY_TO_ONE when type is not provided 1`] = `
[
{
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"exceptionEventId": "mocked-exception-id",
"userFriendlyMessage": "An error occurred.",
},
"message": "Invalid relation type (undefined) for field owner on firstTargetRelation",
},
]
`;
exports[`Field metadata morph relation creation should fail relation ONE_TO_MANY when targetFieldLabel conflicts with an existing field on target object metadata id 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "Name not available",
"userFriendlyMessage": "This name is not available as it is already used by another field.",
},
"message": "Name "collisionfieldlabel" is not available as it is already used by another field",
"name": "UserInputError",
},
]
`;
exports[`Field metadata morph relation creation should fail relation ONE_TO_MANY when targetFieldLabel contains only whitespace 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "Invalid label",
"userFriendlyMessage": "An error occurred.",
},
"message": "Invalid label: " "",
"name": "UserInputError",
},
]
`;
exports[`Field metadata morph relation creation should fail relation ONE_TO_MANY when targetFieldLabel exceeds maximum length 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "Exceeds max length",
"userFriendlyMessage": "An error occurred.",
},
"message": "Name is too long: it exceeds the 63 characters limit.",
"name": "UserInputError",
},
]
`;
exports[`Field metadata morph relation creation should fail relation ONE_TO_MANY when targetFieldLabel is empty 1`] = `
[
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "Input too short",
"userFriendlyMessage": "An error occurred.",
},
"message": "Input is too short: """,
"name": "UserInputError",
},
]
`;
exports[`Field metadata morph relation creation should fail relation ONE_TO_MANY when targetObjectMetadataId is unknown 1`] = `
[
{
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"exceptionEventId": "mocked-exception-id",
"userFriendlyMessage": "An error occurred.",
},
"message": "Target object metadata does not exist in the object metadata maps",
},
]
`;
exports[`Field metadata morph relation creation should fail relation ONE_TO_MANY when type is a wrong value 1`] = `
[
{
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"exceptionEventId": "mocked-exception-id",
"userFriendlyMessage": "An error occurred.",
},
"message": "Invalid relation type (wrong) for field owner on firstTargetRelation",
},
]
`;
exports[`Field metadata morph relation creation should fail relation ONE_TO_MANY when type is not provided 1`] = `
[
{
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"exceptionEventId": "mocked-exception-id",
"userFriendlyMessage": "An error occurred.",
},
"message": "Invalid relation type (undefined) for field owner on firstTargetRelation",
},
]
`;
@@ -1,247 +0,0 @@
import { faker } from '@faker-js/faker/.';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { getMockCreateObjectInput } from 'test/integration/metadata/suites/object-metadata/utils/generate-mock-create-object-metadata-input';
import { type EachTestingContext } from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
type GlobalTestContext = {
objectMetadataIds: {
firstTargetObjectId: string;
secondTargetObjectId: string;
sourceObjectId: string;
};
targetFieldLabel: string;
type: FieldMetadataType;
targetFieldIcon: string;
collisionFieldLabel: string;
};
const globalTestContext: GlobalTestContext = {
objectMetadataIds: {
firstTargetObjectId: '',
secondTargetObjectId: '',
sourceObjectId: '',
},
targetFieldLabel: 'defaultTargetFieldLabel',
type: FieldMetadataType.MORPH_RELATION,
targetFieldIcon: 'IconBuildingSkyscraper',
collisionFieldLabel: 'collisionfieldlabel',
};
type TestedRelationCreationPayload = Partial<
NonNullable<CreateFieldInput['relationCreationPayload']>
>;
type CreateOneObjectMetadataItemTestingContext = EachTestingContext<
| TestedRelationCreationPayload
| ((context: GlobalTestContext) => TestedRelationCreationPayload)
>[];
describe('Field metadata morph relation creation should fail', () => {
beforeAll(async () => {
await updateFeatureFlag({
expectToFail: false,
featureFlag: FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
value: false,
});
});
const failingLabelsCreationTestsUseCase: CreateOneObjectMetadataItemTestingContext =
[
{
title: 'when targetFieldLabel is empty',
context: { targetFieldLabel: '' },
},
{
title: 'when targetFieldLabel exceeds maximum length',
context: {
targetFieldLabel: 'A'.repeat(64),
},
},
{
// Not handled gracefully should be refactored
title: 'when targetObjectMetadataId is unknown',
context: {
targetObjectMetadataId: faker.string.uuid(),
},
},
{
title: 'when targetFieldLabel contains only whitespace',
context: { targetFieldLabel: ' ' },
},
{
title:
'when targetFieldLabel conflicts with an existing field on target object metadata id',
context: ({ collisionFieldLabel }) => ({
targetFieldLabel: collisionFieldLabel,
}),
},
{
title: 'when type is not provided',
context: { type: undefined },
},
{
title: 'when type is a wrong value',
context: { type: 'wrong' as RelationType },
},
];
beforeAll(async () => {
const {
data: {
createOneObject: { id: sourceObjectId },
},
} = await createOneObjectMetadata({
input: getMockCreateObjectInput({
namePlural: 'sourceRelations',
nameSingular: 'sourceRelation',
}),
});
const {
data: {
createOneObject: { id: firstTargetObjectId },
},
} = await createOneObjectMetadata({
input: getMockCreateObjectInput({
namePlural: 'firstTargetRelations',
nameSingular: 'firstTargetRelation',
}),
});
const {
data: {
createOneObject: { id: secondTargetObjectId },
},
} = await createOneObjectMetadata({
input: getMockCreateObjectInput({
namePlural: 'secondTargetRelations',
nameSingular: 'secondTargetRelation',
}),
});
globalTestContext.objectMetadataIds = {
sourceObjectId,
firstTargetObjectId,
secondTargetObjectId,
};
const { data } = await createOneFieldMetadata({
input: {
objectMetadataId: firstTargetObjectId,
name: globalTestContext.collisionFieldLabel,
label: 'LabelThatCouldBeAnything',
isLabelSyncedWithName: false,
type: FieldMetadataType.TEXT,
},
});
expect(data).toBeDefined();
});
afterAll(async () => {
for (const objectMetadataId of Object.values(
globalTestContext.objectMetadataIds,
)) {
await deleteOneObjectMetadata({
input: {
idToDelete: objectMetadataId,
},
});
}
});
afterAll(async () => {
await updateFeatureFlag({
expectToFail: false,
featureFlag: FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
value: true,
});
});
it.each(failingLabelsCreationTestsUseCase)(
'relation ONE_TO_MANY $title',
async ({ context }) => {
const computedContext =
typeof context === 'function' ? context(globalTestContext) : context;
const { errors } = await createOneFieldMetadata({
expectToFail: true,
input: {
objectMetadataId: globalTestContext.objectMetadataIds.sourceObjectId,
name: 'owner',
label: 'owner field',
isLabelSyncedWithName: false,
type: FieldMetadataType.MORPH_RELATION,
morphRelationsCreationPayload: [
{
targetFieldLabel: 'defaultFirstTargetFieldLabel',
type: RelationType.ONE_TO_MANY,
targetObjectMetadataId:
globalTestContext.objectMetadataIds.firstTargetObjectId,
targetFieldIcon: 'IconBuildingSkyscraper',
...computedContext,
},
{
targetFieldLabel: 'defaultSecondTargetFieldLabel',
type: RelationType.ONE_TO_MANY,
targetObjectMetadataId:
globalTestContext.objectMetadataIds.secondTargetObjectId,
targetFieldIcon: 'IconBuildingSkyscraper',
...computedContext,
},
],
},
});
expect(errors).toBeDefined();
expect(errors).toMatchSnapshot();
},
);
it.each(failingLabelsCreationTestsUseCase)(
'relation MANY_TO_ONE $title',
async ({ context }) => {
const computedContext =
typeof context === 'function' ? context(globalTestContext) : context;
const { errors } = await createOneFieldMetadata({
expectToFail: true,
input: {
objectMetadataId: globalTestContext.objectMetadataIds.sourceObjectId,
name: 'owner',
label: 'owner field',
isLabelSyncedWithName: false,
type: FieldMetadataType.MORPH_RELATION,
morphRelationsCreationPayload: [
{
targetFieldLabel: 'defaultFirstTargetFieldLabel',
type: RelationType.MANY_TO_ONE,
targetObjectMetadataId:
globalTestContext.objectMetadataIds.firstTargetObjectId,
targetFieldIcon: 'IconBuildingSkyscraper',
...computedContext,
},
{
targetFieldLabel: 'defaultSecondTargetFieldLabel',
type: RelationType.MANY_TO_ONE,
targetObjectMetadataId:
globalTestContext.objectMetadataIds.secondTargetObjectId,
targetFieldIcon: 'IconBuildingSkyscraper',
...computedContext,
},
],
},
});
expect(errors).toBeDefined();
expect(errors).toMatchSnapshot();
},
);
});
@@ -1,226 +0,0 @@
import gql from 'graphql-tag';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
const createRecordQuery = gql`
mutation CreateFirstRecord($data: TestRecordObjectCreateInput!) {
createTestRecordObject(data: $data) {
id
uniqueTestField
}
}
`;
describe('create records with custom unique fields', () => {
let createdObjectMetadataId = '';
let uniqueFieldId = '';
beforeAll(async () => {
await updateFeatureFlag({
expectToFail: false,
featureFlag: FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
value: false,
});
});
afterAll(async () => {
await updateFeatureFlag({
expectToFail: false,
featureFlag: FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
value: true,
});
});
beforeEach(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'testRecordObject',
namePlural: 'testRecordObjects',
labelSingular: 'Test Record Object',
labelPlural: 'Test Record Objects',
icon: 'IconTestRecord',
},
});
createdObjectMetadataId = objectMetadataId;
const { data: createdField } = await createOneFieldMetadata({
input: {
name: 'uniqueTestField',
label: 'Unique Test Field',
type: FieldMetadataType.TEXT,
objectMetadataId: createdObjectMetadataId,
isUnique: true,
},
gqlFields: `
id
name
label
type
isUnique
`,
});
uniqueFieldId = createdField.createOneField.id;
});
afterEach(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataId },
});
});
it('should not create the second record with same value on unique field', async () => {
const firstRecordResponse = await makeGraphqlAPIRequest({
query: createRecordQuery,
variables: {
data: {
uniqueTestField: 'uniqueValue123',
},
},
});
expect(firstRecordResponse.body.errors).toBeUndefined();
expect(firstRecordResponse.body.data.createTestRecordObject).toBeDefined();
expect(
firstRecordResponse.body.data.createTestRecordObject.uniqueTestField,
).toBe('uniqueValue123');
const secondRecordResponse = await makeGraphqlAPIRequest({
query: createRecordQuery,
variables: {
data: {
uniqueTestField: 'uniqueValue123',
},
},
});
expect(secondRecordResponse.body.errors).toBeDefined();
expect(secondRecordResponse.body.errors[0].message).toContain(
'Duplicate Unique Test Field with value uniqueValue123. Please set a unique one.',
);
expect(secondRecordResponse.body.data.createTestRecordObject).toBeNull();
});
it('should create the second record with same value on updated field (previously unique)', async () => {
const firstRecordResponse = await makeGraphqlAPIRequest({
query: createRecordQuery,
variables: {
data: {
uniqueTestField: 'duplicateValue',
},
},
});
expect(firstRecordResponse.body.errors).toBeUndefined();
expect(
firstRecordResponse.body.data.createTestRecordObject.uniqueTestField,
).toBe('duplicateValue');
const { data, errors } = await updateOneFieldMetadata({
input: {
idToUpdate: uniqueFieldId,
updatePayload: { isUnique: false },
},
gqlFields: `
id
name
isUnique
`,
});
expect(errors).toBeUndefined();
expect(data.updateOneField.isUnique).toBe(false);
const secondRecordResponse = await makeGraphqlAPIRequest({
query: createRecordQuery,
variables: {
data: {
uniqueTestField: 'duplicateValue',
},
},
});
expect(secondRecordResponse.body.errors).toBeUndefined();
expect(secondRecordResponse.body.data.createTestRecordObject).toBeDefined();
expect(
secondRecordResponse.body.data.createTestRecordObject.uniqueTestField,
).toBe('duplicateValue');
});
it('should not create a unique index on field if records with same value already exist', async () => {
await updateOneFieldMetadata({
input: {
idToUpdate: uniqueFieldId,
updatePayload: { isUnique: false },
},
gqlFields: `
id
name
isUnique
`,
});
const firstRecordResponse = await makeGraphqlAPIRequest({
query: createRecordQuery,
variables: {
data: {
uniqueTestField: 'duplicatedValue',
},
},
});
expect(firstRecordResponse.body.errors).toBeUndefined();
const secondRecordResponse = await makeGraphqlAPIRequest({
query: createRecordQuery,
variables: {
data: {
uniqueTestField: 'duplicatedValue',
},
},
});
expect(secondRecordResponse.body.errors).toBeUndefined();
const { data, errors } = await updateOneFieldMetadata({
input: {
idToUpdate: uniqueFieldId,
updatePayload: { isUnique: true },
},
gqlFields: `
id
name
isUnique
`,
expectToFail: true,
});
expect(data).toBeNull();
expect(errors).toBeDefined();
expect(errors[0].message).toContain(
'Unique index creation failed because of unique constraint violation',
);
});
});
@@ -1,380 +0,0 @@
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 { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
describe('update one unique field metadata', () => {
let createdObjectId = '';
beforeAll(async () => {
await updateFeatureFlag({
expectToFail: false,
featureFlag: FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
value: false,
});
});
afterAll(async () => {
await updateFeatureFlag({
expectToFail: false,
featureFlag: FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
value: true,
});
});
beforeEach(async () => {
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'testObjectToTestUniqueField',
namePlural: 'testObjectsToTestUniqueFields',
labelSingular: 'Test Object To Test Unique Field',
labelPlural: 'Test Objects To Test Unique Fields',
icon: 'IconTest',
},
});
createdObjectId = objectMetadataId;
});
afterEach(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectId },
});
});
it('should update non unique field metadata to unique', async () => {
const { data: createdField } = await createOneFieldMetadata({
input: {
name: 'testField',
label: 'Test Field',
type: FieldMetadataType.TEXT,
objectMetadataId: createdObjectId,
isUnique: false,
},
gqlFields: `
id
name
label
type
isUnique
`,
});
const { data, errors } = await updateOneFieldMetadata({
input: {
idToUpdate: createdField.createOneField.id,
updatePayload: { isUnique: true },
},
gqlFields: `
id
name
label
type
isUnique
`,
});
expect(errors).toBeUndefined();
expect(data).not.toBeNull();
expect(data.updateOneField).toBeDefined();
expect(data.updateOneField.isUnique).toBe(true);
});
it('should update unique field metadata to non unique', async () => {
const { data: createdField } = await createOneFieldMetadata({
input: {
name: 'uniqueTestField',
label: 'Unique Test Field',
type: FieldMetadataType.TEXT,
objectMetadataId: createdObjectId,
isUnique: true,
},
gqlFields: `
id
name
label
type
isUnique
`,
});
const { data, errors } = await updateOneFieldMetadata({
input: {
idToUpdate: createdField.createOneField.id,
updatePayload: { isUnique: false },
},
gqlFields: `
id
name
label
type
isUnique
`,
});
expect(errors).toBeUndefined();
expect(data).not.toBeNull();
expect(data.updateOneField).toBeDefined();
expect(data.updateOneField.isUnique).toBe(false);
});
it('should update unique index name if field metadata name is updated', async () => {
const { data: createdField } = await createOneFieldMetadata({
input: {
name: 'uniqueTestField',
label: 'Unique Test Field',
type: FieldMetadataType.TEXT,
objectMetadataId: createdObjectId,
isUnique: true,
},
gqlFields: `
id
name
label
type
isUnique
`,
});
const { data, errors } = await updateOneFieldMetadata({
input: {
idToUpdate: createdField.createOneField.id,
updatePayload: {
name: 'renamedUniqueField',
label: 'Renamed Unique Field',
},
},
gqlFields: `
id
name
label
type
isUnique
`,
});
expect(errors).toBeUndefined();
expect(data).not.toBeNull();
expect(data.updateOneField).toBeDefined();
expect(data.updateOneField.name).toBe('renamedUniqueField');
expect(data.updateOneField.isUnique).toBe(true);
await updateOneFieldMetadata({
input: {
idToUpdate: createdField.createOneField.id,
updatePayload: {
isActive: false,
},
},
gqlFields: `
id
`,
});
const { data: deletedField, errors: deletedFieldErrors } =
await deleteOneFieldMetadata({
input: {
idToDelete: createdField.createOneField.id,
},
});
expect(deletedField).not.toBeNull();
expect(deletedFieldErrors).toBeUndefined();
});
it('should not update to unique field metadata if it has custom default value', async () => {
const { data: createdField } = await createOneFieldMetadata({
input: {
name: 'fieldWithDefault',
label: 'Field With Default',
type: FieldMetadataType.TEXT,
objectMetadataId: createdObjectId,
defaultValue: "'defaultValue'",
isUnique: false,
},
gqlFields: `
id
name
label
type
isUnique
`,
});
const { data, errors } = await updateOneFieldMetadata({
input: {
idToUpdate: createdField.createOneField.id,
updatePayload: { isUnique: true },
},
gqlFields: `
id
name
label
type
isUnique
`,
expectToFail: true,
});
expect(data).toBeNull();
expect(errors).toBeDefined();
expect(errors[0].message).toBe('Unique field cannot have a default value');
});
it('should not update with custom default value if unique field metadata', async () => {
const { data: createdField } = await createOneFieldMetadata({
input: {
name: 'uniqueTestField',
label: 'Unique Test Field',
type: FieldMetadataType.TEXT,
objectMetadataId: createdObjectId,
isUnique: true,
},
gqlFields: `
id
name
label
type
isUnique
`,
});
const { data, errors } = await updateOneFieldMetadata({
input: {
idToUpdate: createdField.createOneField.id,
updatePayload: { defaultValue: "'defaultValue'" },
},
gqlFields: `
id
name
label
type
isUnique
`,
expectToFail: true,
});
expect(data).toBeNull();
expect(errors).toBeDefined();
expect(errors[0].message).toBe('Unique field cannot have a default value');
});
it('should not update to unique field metadata if it has composite type without unique subfields', async () => {
const { data: createdField } = await createOneFieldMetadata({
input: {
name: 'fullNameField',
label: 'Full Name Field',
type: FieldMetadataType.FULL_NAME,
objectMetadataId: createdObjectId,
isUnique: false,
},
gqlFields: `
id
name
label
type
isUnique
`,
});
const { data, errors } = await updateOneFieldMetadata({
input: {
idToUpdate: createdField.createOneField.id,
updatePayload: { isUnique: true },
},
gqlFields: `
id
name
label
type
isUnique
`,
expectToFail: true,
});
expect(data).toBeNull();
expect(errors).toBeDefined();
expect(errors[0].message).toContain(
'Unique index cannot be created for field fullNameField of type FULL_NAME',
);
});
it('should update a standard field to unique field metadata if it has not standard unique index', async () => {
const { data: createdField } = await createOneFieldMetadata({
input: {
name: 'testField',
label: 'Test Field',
type: FieldMetadataType.TEXT,
objectMetadataId: createdObjectId,
isUnique: false,
isCustom: false,
},
gqlFields: `
id
name
label
type
isUnique
`,
});
const { data: addUniqueData, errors: addUniqueErrors } =
await updateOneFieldMetadata({
input: {
idToUpdate: createdField.createOneField.id,
updatePayload: { isUnique: true },
},
gqlFields: `
id
name
label
type
isUnique
`,
});
expect(addUniqueErrors).toBeUndefined();
expect(addUniqueData).not.toBeNull();
expect(addUniqueData.updateOneField).toBeDefined();
expect(addUniqueData.updateOneField.isUnique).toBe(true);
const { data: removeUniqueData, errors: removeUniqueErrors } =
await updateOneFieldMetadata({
input: {
idToUpdate: createdField.createOneField.id,
updatePayload: { isUnique: false },
},
gqlFields: `
id
name
label
type
isUnique
`,
});
expect(removeUniqueErrors).toBeUndefined();
expect(removeUniqueData).not.toBeNull();
expect(removeUniqueData.updateOneField).toBeDefined();
expect(removeUniqueData.updateOneField.isUnique).toBe(false);
});
});
@@ -1,366 +0,0 @@
import { faker } from '@faker-js/faker';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { getMockCreateObjectInput } from 'test/integration/metadata/suites/object-metadata/utils/generate-mock-create-object-metadata-input';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import {
createTestViewFilterWithRestApi,
createTestViewWithRestApi,
findViewFilterWithRestApi,
} from 'test/integration/rest/utils/view-rest-api.util';
import { type EachTestingContext } from 'twenty-shared/testing';
import {
type EnumFieldMetadataType,
FieldMetadataType,
ViewFilterOperand,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import {
type FieldMetadataComplexOption,
type FieldMetadataDefaultOption,
} from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
import { type ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type';
import { ViewType } from 'src/engine/metadata-modules/view/enums/view-type.enum';
type Option = FieldMetadataDefaultOption | FieldMetadataComplexOption;
const generateOption = (index: number): Option => ({
label: `Option ${index}`,
value: `OPTION_${index}`,
color: 'green',
position: index,
});
const generateOptions = (length: number) =>
Array.from({ length }, (_value, index) => generateOption(index));
const fakeOptionUpdate = ({ value, label, ...option }: Option) => ({
...option,
value: `${value}_UPDATED`,
label: `${label} updated`,
});
const ALL_OPTIONS = generateOptions(10);
const isEven = (_value: unknown, index: number) => index % 2 === 0;
type ViewFilterUpdate = {
value: string[];
};
type FieldMetadataOptionsAndType = {
options: Option[];
type: EnumFieldMetadataType;
};
type TestCase = EachTestingContext<{
fieldMetadata?: FieldMetadataOptionsAndType;
createViewFilter?: ViewFilterUpdate;
updateOptions: (
options: FieldMetadataDefaultOption[] | FieldMetadataComplexOption[],
) => FieldMetadataDefaultOption[] | FieldMetadataComplexOption[];
expected?: null;
}>;
const testFieldMetadataType: EnumFieldMetadataType[] = [
FieldMetadataType.SELECT,
FieldMetadataType.MULTI_SELECT,
];
describe('update-one-field-metadata-related-record', () => {
let idToDelete: string;
beforeAll(async () => {
await updateFeatureFlag({
expectToFail: false,
featureFlag: FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
value: false,
});
});
afterAll(async () => {
await updateFeatureFlag({
expectToFail: false,
featureFlag: FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
value: true,
});
});
const createObjectSelectFieldAndView = async ({
options,
type: fieldMetadataType,
}: FieldMetadataOptionsAndType) => {
const singular = faker.lorem.words();
const plural = singular + faker.lorem.word();
const {
data: { createOneObject },
} = await createOneObjectMetadata({
expectToFail: false,
input: getMockCreateObjectInput({
labelSingular: singular,
labelPlural: plural,
nameSingular: singular.split(' ').join(''),
namePlural: plural.split(' ').join(''),
isLabelSyncedWithName: false,
}),
});
idToDelete = createOneObject.id;
const {
data: { createOneField },
} = await createOneFieldMetadata<typeof fieldMetadataType>({
expectToFail: false,
input: {
objectMetadataId: createOneObject.id,
type: fieldMetadataType,
name: 'testName',
label: 'Test name',
isLabelSyncedWithName: true,
options,
},
gqlFields: `
id
options
`,
});
const createdView = await createTestViewWithRestApi({
id: faker.string.uuid(),
name: 'Test View',
objectMetadataId: createOneObject.id,
type: ViewType.TABLE,
});
return { createOneObject, createOneField, createdView };
};
afterEach(async () => {
if (isDefined(idToDelete)) {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: idToDelete,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: idToDelete },
});
}
});
describe.each(testFieldMetadataType)('%s', (fieldType) => {
const testCases: TestCase[] = [
{
title:
'should delete related view filter if all select field options got deleted',
context: {
updateOptions: () => generateOptions(3),
expected: null,
},
},
{
title: 'should update related multi selected options view filter',
context: {
updateOptions: (options) =>
options.map((option, index) =>
isEven(option, index) ? fakeOptionUpdate(option) : option,
),
},
},
{
title: 'should update related solo selected option view filter',
context: {
createViewFilter: {
value: [ALL_OPTIONS[5].value],
},
updateOptions: (options) => [fakeOptionUpdate(options[5])],
},
},
{
title:
'should handle partial deletion of selected options in view filter',
context: {
updateOptions: (options) => options.slice(4),
},
},
{
title:
'should handle reordering of options while maintaining view filter values',
context: {
createViewFilter: {
value: ALL_OPTIONS.slice(0, 2).map((option) => option.value),
},
updateOptions: (options) => [...options].reverse(),
},
},
{
title:
'should handle no changes update of options while maintaining existing view filter values',
context: {
updateOptions: (options) => options,
},
},
{
title:
'should handle adding new options while maintaining existing view filter',
context: {
fieldMetadata: {
options: ALL_OPTIONS.slice(0, 5),
type: fieldType,
},
createViewFilter: {
value: ALL_OPTIONS.slice(0, 2).map((option) => option.value),
},
updateOptions: (options) => [
...options,
...generateOptions(6).slice(5),
],
},
},
];
test.each(testCases)(
'$title',
async ({
context: {
expected,
createViewFilter = {
value: ALL_OPTIONS.map((option) => option.value),
},
fieldMetadata = { options: ALL_OPTIONS, type: fieldType },
updateOptions,
},
}) => {
const { createOneField, createdView } =
await createObjectSelectFieldAndView(fieldMetadata);
const createdViewFilter = await createTestViewFilterWithRestApi({
viewId: createdView.id,
fieldMetadataId: createOneField.id,
operand: ViewFilterOperand.IS,
value: createViewFilter.value,
});
const optionsWithIds = createOneField.options;
if (!isDefined(optionsWithIds)) {
throw new Error('optionsWithIds is not defined');
}
const updatedOptions = updateOptions(optionsWithIds);
await updateOneFieldMetadata({
input: {
idToUpdate: createOneField.id,
updatePayload: {
options: updatedOptions,
},
},
gqlFields: `
id
options
`,
});
const updatedViewFilter = await findViewFilterWithRestApi(
createdViewFilter.id,
);
if (expected !== undefined) {
expect(updatedViewFilter).toBe(expected);
return;
}
if (!isDefined(updatedViewFilter)) {
throw new Error(
'updatedViewFilter is not defined but should be at this point',
);
}
expect(updatedViewFilter.value).not.toBeNull();
if (updatedViewFilter.value === null) {
throw new Error('Invariant parsedValue should not be null');
}
expect(updatedOptions.map((option) => option.value)).toEqual(
expect.arrayContaining(updatedViewFilter.value as string[]),
);
expect({
value: updatedViewFilter.value,
operand: updatedViewFilter.operand,
viewFilterGroupId: updatedViewFilter.viewFilterGroupId,
positionInViewFilterGroup:
updatedViewFilter.positionInViewFilterGroup,
subFieldName: updatedViewFilter.subFieldName,
}).toMatchSnapshot();
},
);
// Note these test exists only because we do not validate the view filter value on creation/update
// Should be removed after https://github.com/twentyhq/core-team-issues/issues/1009 completion
const failingTestCases: EachTestingContext<{
createViewFilterValue: unknown;
}>[] = [
{
title:
'should throw error if view filter value is not a stringified JSON array',
context: {
createViewFilterValue: JSON.stringify(
'not an array stringified json',
),
},
},
];
test.each(failingTestCases)(
'$title',
async ({ context: { createViewFilterValue } }) => {
const { createOneField, createdView } =
await createObjectSelectFieldAndView({
options: ALL_OPTIONS,
type: fieldType,
});
const viewFilterId = '20202020-e3b5-4fa7-85aa-9b1950fc7bf5';
await createTestViewFilterWithRestApi({
id: viewFilterId,
viewId: createdView.id,
fieldMetadataId: createOneField.id,
operand: ViewFilterOperand.IS,
value: createViewFilterValue as unknown as ViewFilterValue,
});
const optionsWithIds = createOneField.options;
if (!isDefined(optionsWithIds)) {
throw new Error('optionsWithIds is not defined');
}
const updatePayload = {
options: optionsWithIds.map((option) => fakeOptionUpdate(option)),
};
const { errors, data } = await updateOneFieldMetadata({
input: {
idToUpdate: createOneField.id,
updatePayload,
},
gqlFields: `
id
options
`,
});
expect(data).toBeNull();
expect(errors).toBeDefined();
expect(errors).toMatchSnapshot();
},
);
});
});
@@ -1,43 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Object metadata creation should fail when labelPlural contains only whitespace 1`] = `"Input is too short: """`;
exports[`Object metadata creation should fail when labelPlural exceeds maximum length 1`] = `"Name is too long: it exceeds the 63 characters limit."`;
exports[`Object metadata creation should fail when labelPlural is empty 1`] = `"Input is too short: """`;
exports[`Object metadata creation should fail when labelSingular contains only whitespace 1`] = `"Input is too short: """`;
exports[`Object metadata creation should fail when labelSingular exceeds maximum length 1`] = `"Name is too long: it exceeds the 63 characters limit."`;
exports[`Object metadata creation should fail when labelSingular is empty 1`] = `"Input is too short: """`;
exports[`Object metadata creation should fail when labels are identical 1`] = `"The singular and plural labels cannot be the same for an object"`;
exports[`Object metadata creation should fail when labels with whitespaces result to be identical 1`] = `"The singular and plural labels cannot be the same for an object"`;
exports[`Object metadata creation should fail when name exceeds maximum length 1`] = `"Name is too long: it exceeds the 63 characters limit."`;
exports[`Object metadata creation should fail when namePlural has invalid characters 1`] = `"String "μ" is not valid: must start with lowercase letter and contain only alphanumeric letters"`;
exports[`Object metadata creation should fail when namePlural is a reserved keyword 1`] = `"The name "users" is not available"`;
exports[`Object metadata creation should fail when namePlural is an empty string 1`] = `"Input is too short: """`;
exports[`Object metadata creation should fail when namePlural is not camelCased 1`] = `"Not_Camel_Case should be in camelCase"`;
exports[`Object metadata creation should fail when nameSingular contains only one char and whitespaces 1`] = `"a a should be in camelCase"`;
exports[`Object metadata creation should fail when nameSingular contains only whitespaces 1`] = `"should be in camelCase"`;
exports[`Object metadata creation should fail when nameSingular has invalid characters 1`] = `"String "μ" is not valid: must start with lowercase letter and contain only alphanumeric letters"`;
exports[`Object metadata creation should fail when nameSingular is a reserved keyword 1`] = `"The name "user" is not available"`;
exports[`Object metadata creation should fail when nameSingular is an empty string 1`] = `"Input is too short: """`;
exports[`Object metadata creation should fail when nameSingular is not camelCased 1`] = `"Not_Camel_Case should be in camelCase"`;
exports[`Object metadata creation should fail when names are identical 1`] = `"The singular and plural names cannot be the same for an object"`;
exports[`Object metadata creation should fail when names with whitespaces result to be identical 1`] = `"fooBar should be in camelCase"`;
@@ -1,47 +0,0 @@
import { OBJECT_METADATA_LABEL_FAILING_TEST_CASES } from 'test/integration/metadata/suites/object-metadata/common/object-metadata-label-failing-tests-cases';
import { OBJECT_METADATA_NAMES_FAILING_TEST_CASES } from 'test/integration/metadata/suites/object-metadata/common/object-metadata-names-failing-tests-cases';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { getMockCreateObjectInput } from 'test/integration/metadata/suites/object-metadata/utils/generate-mock-create-object-metadata-input';
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const allTestsUseCases = [
...OBJECT_METADATA_NAMES_FAILING_TEST_CASES,
...OBJECT_METADATA_LABEL_FAILING_TEST_CASES,
];
describe('Object metadata creation should fail', () => {
beforeAll(async () => {
await updateFeatureFlag({
expectToFail: false,
featureFlag: FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
value: false,
workspaceId: SEED_APPLE_WORKSPACE_ID,
});
});
afterAll(async () => {
await updateFeatureFlag({
expectToFail: false,
featureFlag: FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
value: true,
workspaceId: SEED_APPLE_WORKSPACE_ID,
});
});
it.each(allTestsUseCases)('$title', async ({ context }) => {
const { errors } = await createOneObjectMetadata({
input: getMockCreateObjectInput(context),
expectToFail: true,
});
expect(errors.length).toBe(1);
const firstError = errors[0];
expect(firstError.extensions.code).toBe(ErrorCode.BAD_USER_INPUT);
expect(firstError.message).toMatchSnapshot();
});
});