[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:
+3
-17
@@ -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,
|
||||
],
|
||||
|
||||
+18
-76
@@ -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,
|
||||
|
||||
-684
@@ -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);
|
||||
});
|
||||
});
|
||||
-448
@@ -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);
|
||||
}
|
||||
}
|
||||
-357
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
-390
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
-28
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
-1148
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user