Overwrite standard translations (#11134)
Manage overwriting translations for standard fields and standard objects properties
This commit is contained in:
@ -1,6 +1,8 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
import { IsJSON, IsOptional, IsString } from 'class-validator';
|
||||
import { GraphQLJSON } from 'graphql-type-json';
|
||||
import { APP_LOCALES } from 'twenty-shared/translations';
|
||||
|
||||
@ObjectType('ObjectStandardOverrides')
|
||||
export class ObjectStandardOverridesDTO {
|
||||
@ -23,4 +25,20 @@ export class ObjectStandardOverridesDTO {
|
||||
@IsOptional()
|
||||
@Field(() => String, { nullable: true })
|
||||
icon?: string | null;
|
||||
|
||||
@IsJSON()
|
||||
@IsOptional()
|
||||
@Field(() => GraphQLJSON, {
|
||||
nullable: true,
|
||||
})
|
||||
translations?: Partial<
|
||||
Record<
|
||||
keyof typeof APP_LOCALES,
|
||||
{
|
||||
labelSingular?: string | null;
|
||||
labelPlural?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
>
|
||||
> | null;
|
||||
}
|
||||
|
||||
@ -0,0 +1,753 @@
|
||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { i18n } from '@lingui/core';
|
||||
import { UpdateOneInputType } from '@ptc-org/nestjs-query-graphql';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
|
||||
import { BeforeUpdateOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-update-one-object.hook';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
|
||||
jest.mock('@lingui/core', () => ({
|
||||
i18n: {
|
||||
_: jest.fn().mockImplementation((messageId) => `translated:${messageId}`),
|
||||
},
|
||||
}));
|
||||
|
||||
type UpdateObjectPayloadForTest = Omit<
|
||||
UpdateObjectPayload,
|
||||
'id' | 'workspaceId'
|
||||
>;
|
||||
|
||||
describe('BeforeUpdateOneObject', () => {
|
||||
let hook: BeforeUpdateOneObject<UpdateObjectPayload>;
|
||||
let objectMetadataService: ObjectMetadataService;
|
||||
let fieldMetadataRepository: Repository<FieldMetadataEntity>;
|
||||
|
||||
const mockWorkspaceId = 'workspace-id';
|
||||
const mockObjectId = 'object-id';
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
BeforeUpdateOneObject,
|
||||
{
|
||||
provide: ObjectMetadataService,
|
||||
useValue: {
|
||||
findOneWithinWorkspace: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(FieldMetadataEntity, 'metadata'),
|
||||
useValue: {
|
||||
findBy: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
hook = module.get<BeforeUpdateOneObject<UpdateObjectPayload>>(
|
||||
BeforeUpdateOneObject,
|
||||
);
|
||||
objectMetadataService = module.get<ObjectMetadataService>(
|
||||
ObjectMetadataService,
|
||||
);
|
||||
fieldMetadataRepository = module.get<Repository<FieldMetadataEntity>>(
|
||||
getRepositoryToken(FieldMetadataEntity, 'metadata'),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException if workspaceId is not provided', async () => {
|
||||
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
isActive: true,
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
hook.run(instance as UpdateOneInputType<UpdateObjectPayload>, {
|
||||
workspaceId: '',
|
||||
locale: undefined,
|
||||
}),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException if object does not exist', async () => {
|
||||
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
isActive: true,
|
||||
},
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
|
||||
.mockResolvedValue(null as unknown as ObjectMetadataEntity);
|
||||
|
||||
await expect(
|
||||
hook.run(instance as UpdateOneInputType<UpdateObjectPayload>, {
|
||||
workspaceId: mockWorkspaceId,
|
||||
locale: undefined,
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should not affect custom objects', async () => {
|
||||
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
nameSingular: 'newName',
|
||||
isActive: true,
|
||||
},
|
||||
};
|
||||
|
||||
const mockObject: Partial<ObjectMetadataEntity> = {
|
||||
id: mockObjectId,
|
||||
isCustom: true,
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
|
||||
.mockResolvedValue(mockObject as ObjectMetadataEntity);
|
||||
|
||||
jest.spyOn(fieldMetadataRepository, 'findBy').mockResolvedValue([]);
|
||||
|
||||
const result = await hook.run(
|
||||
instance as UpdateOneInputType<UpdateObjectPayload>,
|
||||
{
|
||||
workspaceId: mockWorkspaceId,
|
||||
locale: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual(instance);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when trying to update non-updatable fields on standard objects', async () => {
|
||||
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
nameSingular: 'newName',
|
||||
},
|
||||
};
|
||||
|
||||
const mockObject: Partial<ObjectMetadataEntity> = {
|
||||
id: mockObjectId,
|
||||
isCustom: false,
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
|
||||
.mockResolvedValue(mockObject as ObjectMetadataEntity);
|
||||
|
||||
await expect(
|
||||
hook.run(instance as UpdateOneInputType<UpdateObjectPayload>, {
|
||||
workspaceId: mockWorkspaceId,
|
||||
locale: undefined,
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when trying to update labels when they are synced with name', async () => {
|
||||
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
labelSingular: 'New Label',
|
||||
},
|
||||
};
|
||||
|
||||
const mockObject: Partial<ObjectMetadataEntity> = {
|
||||
id: mockObjectId,
|
||||
isCustom: false,
|
||||
isLabelSyncedWithName: true,
|
||||
labelSingular: 'Old Label',
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
|
||||
.mockResolvedValue(mockObject as ObjectMetadataEntity);
|
||||
|
||||
await expect(
|
||||
hook.run(instance as UpdateOneInputType<UpdateObjectPayload>, {
|
||||
workspaceId: mockWorkspaceId,
|
||||
locale: undefined,
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should handle isActive updates for standard objects', async () => {
|
||||
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
isActive: false,
|
||||
},
|
||||
};
|
||||
|
||||
const mockObject: Partial<ObjectMetadataEntity> = {
|
||||
id: mockObjectId,
|
||||
isCustom: false,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
|
||||
.mockResolvedValue(mockObject as ObjectMetadataEntity);
|
||||
|
||||
const result = await hook.run(
|
||||
instance as UpdateOneInputType<UpdateObjectPayload>,
|
||||
{
|
||||
workspaceId: mockWorkspaceId,
|
||||
locale: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const expectedResult = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
isActive: false,
|
||||
standardOverrides: {},
|
||||
},
|
||||
};
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it('should handle isLabelSyncedWithName updates for standard objects', async () => {
|
||||
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
isLabelSyncedWithName: true,
|
||||
},
|
||||
};
|
||||
|
||||
const mockObject: Partial<ObjectMetadataEntity> = {
|
||||
id: mockObjectId,
|
||||
isCustom: false,
|
||||
isLabelSyncedWithName: false,
|
||||
standardOverrides: {
|
||||
labelSingular: 'Custom Label',
|
||||
labelPlural: 'Custom Labels',
|
||||
},
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
|
||||
.mockResolvedValue(mockObject as ObjectMetadataEntity);
|
||||
|
||||
const result = await hook.run(
|
||||
instance as UpdateOneInputType<UpdateObjectPayload>,
|
||||
{
|
||||
workspaceId: mockWorkspaceId,
|
||||
locale: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const expectedResult = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
isLabelSyncedWithName: true,
|
||||
standardOverrides: {
|
||||
labelSingular: null,
|
||||
labelPlural: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it('should handle labelSingular override when not synced with name', async () => {
|
||||
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
labelSingular: 'New Label',
|
||||
},
|
||||
};
|
||||
|
||||
const mockObject: Partial<ObjectMetadataEntity> = {
|
||||
id: mockObjectId,
|
||||
isCustom: false,
|
||||
isLabelSyncedWithName: false,
|
||||
labelSingular: 'Default Label',
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
|
||||
.mockResolvedValue(mockObject as ObjectMetadataEntity);
|
||||
|
||||
const result = await hook.run(
|
||||
instance as UpdateOneInputType<UpdateObjectPayload>,
|
||||
{
|
||||
workspaceId: mockWorkspaceId,
|
||||
locale: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const expectedResult = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
standardOverrides: {
|
||||
labelSingular: 'New Label',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it('should handle labelPlural override when not synced with name', async () => {
|
||||
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
labelPlural: 'New Labels',
|
||||
},
|
||||
};
|
||||
|
||||
const mockObject: Partial<ObjectMetadataEntity> = {
|
||||
id: mockObjectId,
|
||||
isCustom: false,
|
||||
isLabelSyncedWithName: false,
|
||||
labelPlural: 'Default Labels',
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
|
||||
.mockResolvedValue(mockObject as ObjectMetadataEntity);
|
||||
|
||||
const result = await hook.run(
|
||||
instance as UpdateOneInputType<UpdateObjectPayload>,
|
||||
{
|
||||
workspaceId: mockWorkspaceId,
|
||||
locale: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const expectedResult = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
standardOverrides: {
|
||||
labelPlural: 'New Labels',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it('should reset labelSingular override when it matches the original value', async () => {
|
||||
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
labelSingular: 'Default Label',
|
||||
},
|
||||
};
|
||||
|
||||
const mockObject: Partial<ObjectMetadataEntity> = {
|
||||
id: mockObjectId,
|
||||
isCustom: false,
|
||||
isLabelSyncedWithName: false,
|
||||
labelSingular: 'Default Label',
|
||||
standardOverrides: {
|
||||
labelSingular: 'Custom Label',
|
||||
},
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
|
||||
.mockResolvedValue(mockObject as ObjectMetadataEntity);
|
||||
|
||||
const result = await hook.run(
|
||||
instance as UpdateOneInputType<UpdateObjectPayload>,
|
||||
{
|
||||
workspaceId: mockWorkspaceId,
|
||||
locale: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const expectedResult = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
standardOverrides: {
|
||||
labelSingular: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it('should handle description override', async () => {
|
||||
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
description: 'New Description',
|
||||
},
|
||||
};
|
||||
|
||||
const mockObject: Partial<ObjectMetadataEntity> = {
|
||||
id: mockObjectId,
|
||||
isCustom: false,
|
||||
description: 'Default Description',
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
|
||||
.mockResolvedValue(mockObject as ObjectMetadataEntity);
|
||||
|
||||
const result = await hook.run(
|
||||
instance as UpdateOneInputType<UpdateObjectPayload>,
|
||||
{
|
||||
workspaceId: mockWorkspaceId,
|
||||
locale: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const expectedResult = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
standardOverrides: {
|
||||
description: 'New Description',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it('should handle icon override', async () => {
|
||||
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
icon: 'IconStar',
|
||||
},
|
||||
};
|
||||
|
||||
const mockObject: Partial<ObjectMetadataEntity> = {
|
||||
id: mockObjectId,
|
||||
isCustom: false,
|
||||
icon: 'IconCircle',
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
|
||||
.mockResolvedValue(mockObject as ObjectMetadataEntity);
|
||||
|
||||
const result = await hook.run(
|
||||
instance as UpdateOneInputType<UpdateObjectPayload>,
|
||||
{
|
||||
workspaceId: mockWorkspaceId,
|
||||
locale: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const expectedResult = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
standardOverrides: {
|
||||
icon: 'IconStar',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it('should handle locale-specific translations', async () => {
|
||||
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
labelSingular: 'Étiquette',
|
||||
labelPlural: 'Étiquettes',
|
||||
description: 'Description en français',
|
||||
},
|
||||
};
|
||||
|
||||
const mockObject: Partial<ObjectMetadataEntity> = {
|
||||
id: mockObjectId,
|
||||
isCustom: false,
|
||||
isLabelSyncedWithName: false,
|
||||
labelSingular: 'Label',
|
||||
labelPlural: 'Labels',
|
||||
description: 'Description',
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
|
||||
.mockResolvedValue(mockObject as ObjectMetadataEntity);
|
||||
|
||||
const result = await hook.run(
|
||||
instance as UpdateOneInputType<UpdateObjectPayload>,
|
||||
{
|
||||
workspaceId: mockWorkspaceId,
|
||||
locale: 'fr-FR',
|
||||
},
|
||||
);
|
||||
|
||||
const expectedResult = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
standardOverrides: {
|
||||
translations: {
|
||||
'fr-FR': {
|
||||
labelSingular: 'Étiquette',
|
||||
labelPlural: 'Étiquettes',
|
||||
description: 'Description en français',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it('should reset locale-specific translations when they match translated defaults', async () => {
|
||||
const translatedLabel = 'translated:msg-label';
|
||||
|
||||
(i18n._ as jest.Mock).mockImplementation(() => translatedLabel);
|
||||
|
||||
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
labelSingular: translatedLabel,
|
||||
},
|
||||
};
|
||||
|
||||
const mockObject: Partial<ObjectMetadataEntity> = {
|
||||
id: mockObjectId,
|
||||
isCustom: false,
|
||||
isLabelSyncedWithName: false,
|
||||
labelSingular: 'Label',
|
||||
standardOverrides: {
|
||||
translations: {
|
||||
'fr-FR': {
|
||||
labelSingular: 'Ancienne Étiquette',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
|
||||
.mockResolvedValue(mockObject as ObjectMetadataEntity);
|
||||
|
||||
const result = await hook.run(
|
||||
instance as UpdateOneInputType<UpdateObjectPayload>,
|
||||
{
|
||||
workspaceId: mockWorkspaceId,
|
||||
locale: 'fr-FR',
|
||||
},
|
||||
);
|
||||
|
||||
const expectedResult = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
standardOverrides: {
|
||||
translations: {
|
||||
'fr-FR': {
|
||||
labelSingular: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it('should handle multiple updates together', async () => {
|
||||
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
isActive: false,
|
||||
isLabelSyncedWithName: false,
|
||||
labelSingular: 'New Label',
|
||||
labelPlural: 'New Labels',
|
||||
icon: 'IconStar',
|
||||
description: 'New Description',
|
||||
},
|
||||
};
|
||||
|
||||
const mockObject: Partial<ObjectMetadataEntity> = {
|
||||
id: mockObjectId,
|
||||
isCustom: false,
|
||||
isActive: true,
|
||||
isLabelSyncedWithName: false,
|
||||
labelSingular: 'Default Label',
|
||||
labelPlural: 'Default Labels',
|
||||
icon: 'IconCircle',
|
||||
description: 'Default Description',
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
|
||||
.mockResolvedValue(mockObject as ObjectMetadataEntity);
|
||||
|
||||
const result = await hook.run(
|
||||
instance as UpdateOneInputType<UpdateObjectPayload>,
|
||||
{
|
||||
workspaceId: mockWorkspaceId,
|
||||
locale: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const expectedResult = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
isActive: false,
|
||||
isLabelSyncedWithName: false,
|
||||
standardOverrides: {
|
||||
labelSingular: 'New Label',
|
||||
labelPlural: 'New Labels',
|
||||
icon: 'IconStar',
|
||||
description: 'New Description',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it('should validate label identifier field correctly for custom objects', async () => {
|
||||
const labelIdentifierFieldId = 'label-field-id';
|
||||
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
labelIdentifierFieldMetadataId: labelIdentifierFieldId,
|
||||
},
|
||||
};
|
||||
|
||||
const mockObject: Partial<ObjectMetadataEntity> = {
|
||||
id: mockObjectId,
|
||||
isCustom: true,
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
|
||||
.mockResolvedValue(mockObject as ObjectMetadataEntity);
|
||||
|
||||
jest
|
||||
.spyOn(fieldMetadataRepository, 'findBy')
|
||||
.mockImplementation(async (criteria) => {
|
||||
expect(criteria).toHaveProperty('workspaceId');
|
||||
expect(criteria).toHaveProperty('objectMetadataId');
|
||||
expect(criteria).toHaveProperty('id');
|
||||
|
||||
return [{ id: labelIdentifierFieldId } as FieldMetadataEntity];
|
||||
});
|
||||
|
||||
const result = await hook.run(
|
||||
instance as UpdateOneInputType<UpdateObjectPayload>,
|
||||
{
|
||||
workspaceId: mockWorkspaceId,
|
||||
locale: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual(instance);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException if label identifier field does not exist', async () => {
|
||||
const labelIdentifierFieldId = 'nonexistent-field-id';
|
||||
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
labelIdentifierFieldMetadataId: labelIdentifierFieldId,
|
||||
},
|
||||
};
|
||||
|
||||
const mockObject: Partial<ObjectMetadataEntity> = {
|
||||
id: mockObjectId,
|
||||
isCustom: true,
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
|
||||
.mockResolvedValue(mockObject as ObjectMetadataEntity);
|
||||
|
||||
jest.spyOn(fieldMetadataRepository, 'findBy').mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
hook.run(instance as UpdateOneInputType<UpdateObjectPayload>, {
|
||||
workspaceId: mockWorkspaceId,
|
||||
locale: undefined,
|
||||
}),
|
||||
).rejects.toThrow('This label identifier does not exist');
|
||||
});
|
||||
|
||||
it('should validate image identifier field correctly for custom objects', async () => {
|
||||
const imageIdentifierFieldId = 'image-field-id';
|
||||
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
imageIdentifierFieldMetadataId: imageIdentifierFieldId,
|
||||
},
|
||||
};
|
||||
|
||||
const mockObject: Partial<ObjectMetadataEntity> = {
|
||||
id: mockObjectId,
|
||||
isCustom: true,
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
|
||||
.mockResolvedValue(mockObject as ObjectMetadataEntity);
|
||||
|
||||
jest
|
||||
.spyOn(fieldMetadataRepository, 'findBy')
|
||||
.mockImplementation(async (criteria) => {
|
||||
expect(criteria).toHaveProperty('workspaceId');
|
||||
expect(criteria).toHaveProperty('objectMetadataId');
|
||||
expect(criteria).toHaveProperty('id');
|
||||
|
||||
return [{ id: imageIdentifierFieldId } as FieldMetadataEntity];
|
||||
});
|
||||
|
||||
const result = await hook.run(
|
||||
instance as UpdateOneInputType<UpdateObjectPayload>,
|
||||
{
|
||||
workspaceId: mockWorkspaceId,
|
||||
locale: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual(instance);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException if image identifier field does not exist', async () => {
|
||||
const imageIdentifierFieldId = 'nonexistent-field-id';
|
||||
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
|
||||
id: mockObjectId,
|
||||
update: {
|
||||
imageIdentifierFieldMetadataId: imageIdentifierFieldId,
|
||||
},
|
||||
};
|
||||
|
||||
const mockObject: Partial<ObjectMetadataEntity> = {
|
||||
id: mockObjectId,
|
||||
isCustom: true,
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
|
||||
.mockResolvedValue(mockObject as ObjectMetadataEntity);
|
||||
|
||||
jest.spyOn(fieldMetadataRepository, 'findBy').mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
hook.run(instance as UpdateOneInputType<UpdateObjectPayload>, {
|
||||
workspaceId: mockWorkspaceId,
|
||||
locale: undefined,
|
||||
}),
|
||||
).rejects.toThrow('This image identifier does not exist');
|
||||
});
|
||||
});
|
||||
@ -5,13 +5,16 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { i18n } from '@lingui/core';
|
||||
import {
|
||||
BeforeUpdateOneHook,
|
||||
UpdateOneInputType,
|
||||
} from '@ptc-org/nestjs-query-graphql';
|
||||
import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Equal, In, Repository } from 'typeorm';
|
||||
|
||||
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { ObjectStandardOverridesDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-standard-overrides.dto';
|
||||
import { UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
|
||||
@ -36,7 +39,13 @@ export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
|
||||
// TODO: this logic could be moved to a policy guard
|
||||
async run(
|
||||
instance: UpdateOneInputType<T>,
|
||||
workspaceId: string,
|
||||
{
|
||||
workspaceId,
|
||||
locale,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
locale: keyof typeof APP_LOCALES | undefined;
|
||||
},
|
||||
): Promise<UpdateOneInputType<T>> {
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException();
|
||||
@ -45,7 +54,7 @@ export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
|
||||
const objectMetadata = await this.getObjectMetadata(instance, workspaceId);
|
||||
|
||||
if (!objectMetadata.isCustom) {
|
||||
return this.handleStandardObjectUpdate(instance, objectMetadata);
|
||||
return this.handleStandardObjectUpdate(instance, objectMetadata, locale);
|
||||
}
|
||||
|
||||
await this.validateIdentifierFields(instance, workspaceId);
|
||||
@ -74,6 +83,7 @@ export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
|
||||
private handleStandardObjectUpdate(
|
||||
instance: UpdateOneInputType<T>,
|
||||
objectMetadata: ObjectMetadataEntity,
|
||||
locale: keyof typeof APP_LOCALES | undefined,
|
||||
): UpdateOneInputType<T> {
|
||||
const update: StandardObjectUpdate = {};
|
||||
const updatableFields = ['isActive', 'isLabelSyncedWithName'];
|
||||
@ -84,14 +94,11 @@ export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
|
||||
'description',
|
||||
];
|
||||
|
||||
// Check if any field is not allowed
|
||||
const nonUpdatableFields = Object.keys(instance.update).filter(
|
||||
(key) =>
|
||||
!updatableFields.includes(key) && !overridableFields.includes(key),
|
||||
);
|
||||
|
||||
const hasNonUpdatableFields = nonUpdatableFields.length > 0;
|
||||
|
||||
const isUpdatingLabelsWhenSynced =
|
||||
(instance.update.labelSingular || instance.update.labelPlural) &&
|
||||
objectMetadata.isLabelSyncedWithName &&
|
||||
@ -105,7 +112,7 @@ export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasNonUpdatableFields) {
|
||||
if (nonUpdatableFields.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Only isActive, isLabelSyncedWithName, labelSingular, labelPlural, icon and description fields can be updated for standard objects. Disallowed fields: ${nonUpdatableFields.join(', ')}`,
|
||||
);
|
||||
@ -118,7 +125,7 @@ export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
|
||||
|
||||
this.handleActiveField(instance, update);
|
||||
this.handleLabelSyncedWithNameField(instance, update);
|
||||
this.handleStandardOverrides(instance, objectMetadata, update);
|
||||
this.handleStandardOverrides(instance, objectMetadata, update, locale);
|
||||
|
||||
return {
|
||||
id: instance.id,
|
||||
@ -161,6 +168,7 @@ export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
|
||||
instance: UpdateOneInputType<T>,
|
||||
objectMetadata: ObjectMetadataEntity,
|
||||
update: StandardObjectUpdate,
|
||||
locale: keyof typeof APP_LOCALES | undefined,
|
||||
): void {
|
||||
const hasStandardOverrides =
|
||||
isDefined(instance.update.description) ||
|
||||
@ -174,29 +182,156 @@ export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
|
||||
|
||||
update.standardOverrides = update.standardOverrides || {};
|
||||
|
||||
this.handleDescriptionOverride(instance, objectMetadata, update);
|
||||
this.handleIconOverride(instance, objectMetadata, update);
|
||||
this.handleLabelOverrides(instance, objectMetadata, update);
|
||||
this.handleDescriptionOverride(instance, objectMetadata, update, locale);
|
||||
this.handleLabelOverrides(instance, objectMetadata, update, locale);
|
||||
}
|
||||
|
||||
private resetOverrideIfMatchesOriginal({
|
||||
update,
|
||||
overrideKey,
|
||||
newValue,
|
||||
originalValue,
|
||||
locale,
|
||||
}: {
|
||||
update: StandardObjectUpdate;
|
||||
overrideKey: 'labelSingular' | 'labelPlural' | 'description' | 'icon';
|
||||
newValue: string;
|
||||
originalValue: string;
|
||||
locale?: keyof typeof APP_LOCALES | undefined;
|
||||
}): boolean {
|
||||
if (locale && locale !== SOURCE_LOCALE) {
|
||||
const wasOverrideReset = this.resetLocalizedOverride(
|
||||
update,
|
||||
overrideKey,
|
||||
newValue,
|
||||
originalValue,
|
||||
locale,
|
||||
);
|
||||
|
||||
return wasOverrideReset;
|
||||
}
|
||||
|
||||
const wasOverrideReset = this.resetDefaultOverride(
|
||||
update,
|
||||
overrideKey,
|
||||
newValue,
|
||||
originalValue,
|
||||
);
|
||||
|
||||
return wasOverrideReset;
|
||||
}
|
||||
|
||||
private resetLocalizedOverride(
|
||||
update: StandardObjectUpdate,
|
||||
overrideKey: 'labelSingular' | 'labelPlural' | 'description' | 'icon',
|
||||
newValue: string,
|
||||
originalValue: string,
|
||||
locale: keyof typeof APP_LOCALES,
|
||||
): boolean {
|
||||
const messageId = generateMessageId(originalValue ?? '');
|
||||
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];
|
||||
|
||||
(localeTranslations as Record<string, any>)[overrideKey] = null;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private resetDefaultOverride(
|
||||
update: StandardObjectUpdate,
|
||||
overrideKey: 'labelSingular' | 'labelPlural' | 'description' | 'icon',
|
||||
newValue: string,
|
||||
originalValue: string,
|
||||
): boolean {
|
||||
if (newValue !== originalValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
update.standardOverrides = update.standardOverrides || {};
|
||||
update.standardOverrides[overrideKey] = null;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private setOverrideValue(
|
||||
update: StandardObjectUpdate,
|
||||
overrideKey: 'labelSingular' | 'labelPlural' | 'description' | 'icon',
|
||||
value: string,
|
||||
locale?: keyof typeof APP_LOCALES,
|
||||
): void {
|
||||
update.standardOverrides = update.standardOverrides || {};
|
||||
|
||||
const shouldSetLocalizedOverride =
|
||||
locale && locale !== SOURCE_LOCALE && overrideKey !== 'icon';
|
||||
|
||||
if (!shouldSetLocalizedOverride) {
|
||||
update.standardOverrides[overrideKey] = value;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.setLocalizedOverrideValue(update, overrideKey, value, locale);
|
||||
}
|
||||
|
||||
private setLocalizedOverrideValue(
|
||||
update: StandardObjectUpdate,
|
||||
overrideKey: 'labelSingular' | 'labelPlural' | 'description' | 'icon',
|
||||
value: string,
|
||||
locale: keyof typeof APP_LOCALES,
|
||||
): void {
|
||||
update.standardOverrides = update.standardOverrides || {};
|
||||
update.standardOverrides.translations =
|
||||
update.standardOverrides.translations || {};
|
||||
update.standardOverrides.translations[locale] =
|
||||
update.standardOverrides.translations[locale] || {};
|
||||
|
||||
const localeTranslations = update.standardOverrides.translations[locale];
|
||||
|
||||
(localeTranslations as Record<string, any>)[overrideKey] = value;
|
||||
}
|
||||
|
||||
private handleDescriptionOverride(
|
||||
instance: UpdateOneInputType<T>,
|
||||
objectMetadata: ObjectMetadataEntity,
|
||||
update: StandardObjectUpdate,
|
||||
locale?: keyof typeof APP_LOCALES,
|
||||
): void {
|
||||
if (!isDefined(instance.update.description)) {
|
||||
return;
|
||||
}
|
||||
|
||||
update.standardOverrides = update.standardOverrides || {};
|
||||
|
||||
if (instance.update.description === objectMetadata.description) {
|
||||
update.standardOverrides.description = null;
|
||||
|
||||
if (
|
||||
this.resetOverrideIfMatchesOriginal({
|
||||
update,
|
||||
overrideKey: 'description',
|
||||
newValue: instance.update.description,
|
||||
originalValue: objectMetadata.description,
|
||||
locale,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
update.standardOverrides.description = instance.update.description;
|
||||
this.setOverrideValue(
|
||||
update,
|
||||
'description',
|
||||
instance.update.description,
|
||||
locale,
|
||||
);
|
||||
}
|
||||
|
||||
private handleIconOverride(
|
||||
@ -208,21 +343,26 @@ export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
|
||||
return;
|
||||
}
|
||||
|
||||
update.standardOverrides = update.standardOverrides || {};
|
||||
|
||||
if (instance.update.icon === objectMetadata.icon) {
|
||||
update.standardOverrides.icon = null;
|
||||
|
||||
if (
|
||||
this.resetOverrideIfMatchesOriginal({
|
||||
update,
|
||||
overrideKey: 'icon',
|
||||
newValue: instance.update.icon,
|
||||
originalValue: objectMetadata.icon,
|
||||
locale: undefined,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
update.standardOverrides.icon = instance.update.icon;
|
||||
this.setOverrideValue(update, 'icon', instance.update.icon);
|
||||
}
|
||||
|
||||
private handleLabelOverrides(
|
||||
instance: UpdateOneInputType<T>,
|
||||
objectMetadata: ObjectMetadataEntity,
|
||||
update: StandardObjectUpdate,
|
||||
locale?: keyof typeof APP_LOCALES,
|
||||
): void {
|
||||
// Skip label updates if labels are synced with name or will be synced
|
||||
if (
|
||||
@ -232,48 +372,68 @@ export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleLabelSingularOverride(instance, objectMetadata, update);
|
||||
this.handleLabelPluralOverride(instance, objectMetadata, update);
|
||||
this.handleLabelSingularOverride(instance, objectMetadata, update, locale);
|
||||
this.handleLabelPluralOverride(instance, objectMetadata, update, locale);
|
||||
}
|
||||
|
||||
private handleLabelSingularOverride(
|
||||
instance: UpdateOneInputType<T>,
|
||||
objectMetadata: ObjectMetadataEntity,
|
||||
update: StandardObjectUpdate,
|
||||
locale?: keyof typeof APP_LOCALES,
|
||||
): void {
|
||||
if (!isDefined(instance.update.labelSingular)) {
|
||||
return;
|
||||
}
|
||||
|
||||
update.standardOverrides = update.standardOverrides || {};
|
||||
|
||||
if (instance.update.labelSingular === objectMetadata.labelSingular) {
|
||||
update.standardOverrides.labelSingular = null;
|
||||
|
||||
if (
|
||||
this.resetOverrideIfMatchesOriginal({
|
||||
update,
|
||||
overrideKey: 'labelSingular',
|
||||
newValue: instance.update.labelSingular,
|
||||
originalValue: objectMetadata.labelSingular,
|
||||
locale,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
update.standardOverrides.labelSingular = instance.update.labelSingular;
|
||||
this.setOverrideValue(
|
||||
update,
|
||||
'labelSingular',
|
||||
instance.update.labelSingular,
|
||||
locale,
|
||||
);
|
||||
}
|
||||
|
||||
private handleLabelPluralOverride(
|
||||
instance: UpdateOneInputType<T>,
|
||||
objectMetadata: ObjectMetadataEntity,
|
||||
update: StandardObjectUpdate,
|
||||
locale?: keyof typeof APP_LOCALES,
|
||||
): void {
|
||||
if (!isDefined(instance.update.labelPlural)) {
|
||||
return;
|
||||
}
|
||||
|
||||
update.standardOverrides = update.standardOverrides || {};
|
||||
|
||||
if (instance.update.labelPlural === objectMetadata.labelPlural) {
|
||||
update.standardOverrides.labelPlural = null;
|
||||
|
||||
if (
|
||||
this.resetOverrideIfMatchesOriginal({
|
||||
update,
|
||||
overrideKey: 'labelPlural',
|
||||
newValue: instance.update.labelPlural,
|
||||
originalValue: objectMetadata.labelPlural,
|
||||
locale,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
update.standardOverrides.labelPlural = instance.update.labelPlural;
|
||||
this.setOverrideValue(
|
||||
update,
|
||||
'labelPlural',
|
||||
instance.update.labelPlural,
|
||||
locale,
|
||||
);
|
||||
}
|
||||
|
||||
private async validateIdentifierFields(
|
||||
|
||||
@ -106,12 +106,13 @@ export class ObjectMetadataResolver {
|
||||
async updateOneObject(
|
||||
@Args('input') input: UpdateOneObjectInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
@Context() context: I18nContext,
|
||||
) {
|
||||
try {
|
||||
const updatedInput = (await this.beforeUpdateOneObject.run(
|
||||
input,
|
||||
const updatedInput = (await this.beforeUpdateOneObject.run(input, {
|
||||
workspaceId,
|
||||
)) as UpdateOneObjectInput;
|
||||
locale: context.req.headers['x-locale'],
|
||||
})) as UpdateOneObjectInput;
|
||||
|
||||
return await this.objectMetadataService.updateOneObject(
|
||||
updatedInput,
|
||||
|
||||
@ -5,8 +5,8 @@ import { i18n } from '@lingui/core';
|
||||
import { Query, QueryOptions } from '@ptc-org/nestjs-query-core';
|
||||
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
||||
import { isDefined } from 'class-validator';
|
||||
import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import { FindManyOptions, FindOneOptions, In, Not, Repository } from 'typeorm';
|
||||
import { APP_LOCALES } from 'twenty-shared/translations';
|
||||
|
||||
import { ObjectMetadataStandardIdToIdMap } from 'src/engine/metadata-modules/object-metadata/interfaces/object-metadata-standard-id-to-id-map';
|
||||
|
||||
@ -561,15 +561,22 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
return objectMetadata[labelKey];
|
||||
}
|
||||
|
||||
if (
|
||||
objectMetadata.standardOverrides &&
|
||||
isDefined(objectMetadata.standardOverrides[labelKey])
|
||||
) {
|
||||
return objectMetadata.standardOverrides[labelKey] as string;
|
||||
if (!locale || locale === SOURCE_LOCALE) {
|
||||
if (
|
||||
objectMetadata.standardOverrides &&
|
||||
isDefined(objectMetadata.standardOverrides[labelKey])
|
||||
) {
|
||||
return objectMetadata.standardOverrides[labelKey] as string;
|
||||
}
|
||||
|
||||
return objectMetadata[labelKey];
|
||||
}
|
||||
|
||||
if (!locale) {
|
||||
return objectMetadata[labelKey];
|
||||
const translationValue =
|
||||
objectMetadata.standardOverrides?.translations?.[locale]?.[labelKey];
|
||||
|
||||
if (isDefined(translationValue)) {
|
||||
return translationValue;
|
||||
}
|
||||
|
||||
const messageId = generateMessageId(objectMetadata[labelKey] ?? '');
|
||||
|
||||
Reference in New Issue
Block a user