[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
@@ -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;
}
}