Overwrite standard translations (#11134)
Manage overwriting translations for standard fields and standard objects properties
This commit is contained in:
@ -105,7 +105,6 @@ export const SettingsDataModelFieldIconLabelForm = ({
|
||||
|
||||
const fillNameFromLabel = (label: string) => {
|
||||
isDefined(label) &&
|
||||
fieldMetadataItem?.isCustom &&
|
||||
setValue('name', computeMetadataNameFromLabel(label), {
|
||||
shouldDirty: true,
|
||||
});
|
||||
@ -141,7 +140,11 @@ export const SettingsDataModelFieldIconLabelForm = ({
|
||||
}
|
||||
}}
|
||||
error={getErrorMessageFromError(errors.label?.message)}
|
||||
disabled={isLabelSyncedWithName === true}
|
||||
disabled={
|
||||
isLabelSyncedWithName === true &&
|
||||
fieldMetadataItem &&
|
||||
!fieldMetadataItem?.isCustom
|
||||
}
|
||||
maxLength={maxLength}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
@ -100,6 +100,7 @@ export const SettingsDataModelObjectAboutForm = ({
|
||||
setValue('labelPlural', labelPluralFromSingularLabel, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
fillNamePluralFromLabelPlural(labelPluralFromSingularLabel);
|
||||
};
|
||||
|
||||
const fillNameSingularFromLabelSingular = (
|
||||
@ -161,7 +162,11 @@ export const SettingsDataModelObjectAboutForm = ({
|
||||
}
|
||||
}}
|
||||
onBlur={() => onNewDirtyField?.()}
|
||||
disabled={!objectMetadataItem?.isCustom && isLabelSyncedWithName}
|
||||
disabled={
|
||||
objectMetadataItem &&
|
||||
!objectMetadataItem?.isCustom &&
|
||||
isLabelSyncedWithName
|
||||
}
|
||||
fullWidth
|
||||
maxLength={OBJECT_NAME_MAXIMUM_LENGTH}
|
||||
/>
|
||||
@ -187,7 +192,11 @@ export const SettingsDataModelObjectAboutForm = ({
|
||||
}
|
||||
}}
|
||||
onBlur={() => onNewDirtyField?.()}
|
||||
disabled={!objectMetadataItem?.isCustom && isLabelSyncedWithName}
|
||||
disabled={
|
||||
objectMetadataItem &&
|
||||
!objectMetadataItem?.isCustom &&
|
||||
isLabelSyncedWithName
|
||||
}
|
||||
fullWidth
|
||||
maxLength={OBJECT_NAME_MAXIMUM_LENGTH}
|
||||
/>
|
||||
@ -293,37 +302,40 @@ export const SettingsDataModelObjectAboutForm = ({
|
||||
</AdvancedSettingsWrapper>
|
||||
),
|
||||
)}
|
||||
<AdvancedSettingsWrapper>
|
||||
<Controller
|
||||
name="isLabelSyncedWithName"
|
||||
control={control}
|
||||
defaultValue={objectMetadataItem?.isLabelSyncedWithName}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Card rounded>
|
||||
<SettingsOptionCardContentToggle
|
||||
Icon={IconRefresh}
|
||||
title={t`Synchronize Objects Labels and API Names`}
|
||||
description={t`Should changing an object's label also change the API?`}
|
||||
checked={value ?? true}
|
||||
advancedMode
|
||||
onChange={(value) => {
|
||||
onChange(value);
|
||||
onNewDirtyField?.();
|
||||
{(!objectMetadataItem || objectMetadataItem?.isCustom) && (
|
||||
<AdvancedSettingsWrapper>
|
||||
<Controller
|
||||
name="isLabelSyncedWithName"
|
||||
control={control}
|
||||
defaultValue={objectMetadataItem?.isLabelSyncedWithName}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Card rounded>
|
||||
<SettingsOptionCardContentToggle
|
||||
Icon={IconRefresh}
|
||||
title={t`Synchronize Objects Labels and API Names`}
|
||||
description={t`Should changing an object's label also change the API?`}
|
||||
checked={value ?? true}
|
||||
advancedMode
|
||||
onChange={(value) => {
|
||||
onChange(value);
|
||||
onNewDirtyField?.();
|
||||
|
||||
if (
|
||||
value === true &&
|
||||
isDefined(objectMetadataItem) &&
|
||||
objectMetadataItem.isCustom
|
||||
) {
|
||||
fillNamePluralFromLabelPlural(labelPlural);
|
||||
fillNameSingularFromLabelSingular(labelSingular);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
/>
|
||||
</AdvancedSettingsWrapper>
|
||||
if (
|
||||
value === true &&
|
||||
((isDefined(objectMetadataItem) &&
|
||||
objectMetadataItem.isCustom) ||
|
||||
!isDefined(objectMetadataItem))
|
||||
) {
|
||||
fillNamePluralFromLabelPlural(labelPlural);
|
||||
fillNameSingularFromLabelSingular(labelSingular);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
/>
|
||||
</AdvancedSettingsWrapper>
|
||||
)}
|
||||
</StyledAdvancedSettingsSectionInputWrapper>
|
||||
</StyledAdvancedSettingsContainer>
|
||||
</StyledAdvancedSettingsOuterContainer>
|
||||
|
||||
@ -9,10 +9,10 @@ import { Select } from '@/ui/input/components/Select';
|
||||
|
||||
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItem';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
|
||||
import { logError } from '~/utils/logError';
|
||||
import { APP_LOCALES } from 'twenty-shared/translations';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
|
||||
import { logError } from '~/utils/logError';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
@ -62,7 +62,7 @@ export const LocalePicker = () => {
|
||||
await refreshObjectMetadataItems();
|
||||
};
|
||||
|
||||
const localeOptions: Array<{
|
||||
const unsortedLocaleOptions: Array<{
|
||||
label: string;
|
||||
value: (typeof APP_LOCALES)[keyof typeof APP_LOCALES];
|
||||
}> = [
|
||||
@ -187,13 +187,18 @@ export const LocalePicker = () => {
|
||||
value: APP_LOCALES['vi-VN'],
|
||||
},
|
||||
];
|
||||
|
||||
if (isDebugMode) {
|
||||
localeOptions.push({
|
||||
unsortedLocaleOptions.push({
|
||||
label: t`Pseudo-English`,
|
||||
value: APP_LOCALES['pseudo-en'],
|
||||
});
|
||||
}
|
||||
|
||||
const localeOptions = [...unsortedLocaleOptions].sort((a, b) =>
|
||||
a.label.localeCompare(b.label),
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<Select
|
||||
|
||||
@ -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('StandardOverrides')
|
||||
export class FieldStandardOverridesDTO {
|
||||
@ -18,4 +20,19 @@ export class FieldStandardOverridesDTO {
|
||||
@IsOptional()
|
||||
@Field(() => String, { nullable: true })
|
||||
icon?: string | null;
|
||||
|
||||
@IsJSON()
|
||||
@IsOptional()
|
||||
@Field(() => GraphQLJSON, {
|
||||
nullable: true,
|
||||
})
|
||||
translations?: Partial<
|
||||
Record<
|
||||
keyof typeof APP_LOCALES,
|
||||
{
|
||||
label?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
>
|
||||
> | null;
|
||||
}
|
||||
|
||||
@ -108,12 +108,13 @@ export class FieldMetadataResolver {
|
||||
async updateOneField(
|
||||
@Args('input') input: UpdateOneFieldMetadataInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
@Context() context: I18nContext,
|
||||
) {
|
||||
try {
|
||||
const updatedInput = (await this.beforeUpdateOneField.run(
|
||||
input,
|
||||
const updatedInput = (await this.beforeUpdateOneField.run(input, {
|
||||
workspaceId,
|
||||
)) as UpdateOneFieldMetadataInput;
|
||||
locale: context.req.headers['x-locale'],
|
||||
})) as UpdateOneFieldMetadataInput;
|
||||
|
||||
return await this.fieldMetadataService.updateOne(updatedInput.id, {
|
||||
...updatedInput.update,
|
||||
|
||||
@ -4,7 +4,7 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
||||
import isEmpty from 'lodash.isempty';
|
||||
import { APP_LOCALES } from 'twenty-shared/translations';
|
||||
import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { DataSource, FindOneOptions, In, Repository } from 'typeorm';
|
||||
@ -618,15 +618,22 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
||||
return fieldMetadata[labelKey] ?? '';
|
||||
}
|
||||
|
||||
if (!locale) {
|
||||
if (!locale || locale === SOURCE_LOCALE) {
|
||||
if (
|
||||
fieldMetadata.standardOverrides &&
|
||||
isDefined(fieldMetadata.standardOverrides[labelKey])
|
||||
) {
|
||||
return fieldMetadata.standardOverrides[labelKey] as string;
|
||||
}
|
||||
|
||||
return fieldMetadata[labelKey] ?? '';
|
||||
}
|
||||
|
||||
if (
|
||||
fieldMetadata.standardOverrides &&
|
||||
isDefined(fieldMetadata.standardOverrides[labelKey])
|
||||
) {
|
||||
return fieldMetadata.standardOverrides[labelKey] as string;
|
||||
const translationValue =
|
||||
fieldMetadata.standardOverrides?.translations?.[locale]?.[labelKey];
|
||||
|
||||
if (isDefined(translationValue)) {
|
||||
return translationValue;
|
||||
}
|
||||
|
||||
const messageId = generateMessageId(fieldMetadata[labelKey] ?? '');
|
||||
|
||||
@ -0,0 +1,554 @@
|
||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { i18n } from '@lingui/core';
|
||||
import { UpdateOneInputType } from '@ptc-org/nestjs-query-graphql';
|
||||
|
||||
import { UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service';
|
||||
import { BeforeUpdateOneField } from 'src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook';
|
||||
|
||||
jest.mock('@lingui/core', () => ({
|
||||
i18n: {
|
||||
_: jest.fn().mockImplementation((messageId) => `translated:${messageId}`),
|
||||
},
|
||||
}));
|
||||
|
||||
// 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;
|
||||
|
||||
const mockWorkspaceId = 'workspace-id';
|
||||
const mockFieldId = 'field-id';
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
BeforeUpdateOneField,
|
||||
{
|
||||
provide: FieldMetadataService,
|
||||
useValue: {
|
||||
findOneWithinWorkspace: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
hook =
|
||||
module.get<BeforeUpdateOneField<UpdateFieldInput>>(BeforeUpdateOneField);
|
||||
fieldMetadataService =
|
||||
module.get<FieldMetadataService>(FieldMetadataService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException 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(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException 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(BadRequestException);
|
||||
});
|
||||
|
||||
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: Partial<FieldMetadataEntity> = {
|
||||
id: mockFieldId,
|
||||
isCustom: true,
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(fieldMetadataService, 'findOneWithinWorkspace')
|
||||
.mockResolvedValue(mockField as FieldMetadataEntity);
|
||||
|
||||
const result = await hook.run(
|
||||
instance as UpdateOneInputType<UpdateFieldInput>,
|
||||
{
|
||||
workspaceId: mockWorkspaceId,
|
||||
locale: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual(instance);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException 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: Partial<FieldMetadataEntity> = {
|
||||
id: mockFieldId,
|
||||
isCustom: false,
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(fieldMetadataService, 'findOneWithinWorkspace')
|
||||
.mockResolvedValue(mockField as FieldMetadataEntity);
|
||||
|
||||
await expect(
|
||||
hook.run(instance as UpdateOneInputType<UpdateFieldInput>, {
|
||||
workspaceId: mockWorkspaceId,
|
||||
locale: undefined,
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when trying to update label when it is synced with name', async () => {
|
||||
const instance: UpdateOneInputType<UpdateFieldInputForTest> = {
|
||||
id: mockFieldId,
|
||||
update: {
|
||||
label: 'New Label',
|
||||
},
|
||||
};
|
||||
|
||||
const mockField: Partial<FieldMetadataEntity> = {
|
||||
id: mockFieldId,
|
||||
isCustom: false,
|
||||
isLabelSyncedWithName: true,
|
||||
label: 'Old Label',
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(fieldMetadataService, 'findOneWithinWorkspace')
|
||||
.mockResolvedValue(mockField as FieldMetadataEntity);
|
||||
|
||||
await expect(
|
||||
hook.run(instance as UpdateOneInputType<UpdateFieldInput>, {
|
||||
workspaceId: mockWorkspaceId,
|
||||
locale: undefined,
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should handle isActive updates for standard fields', async () => {
|
||||
const instance: UpdateOneInputType<UpdateFieldInputForTest> = {
|
||||
id: mockFieldId,
|
||||
update: {
|
||||
isActive: false,
|
||||
},
|
||||
};
|
||||
|
||||
const mockField: Partial<FieldMetadataEntity> = {
|
||||
id: mockFieldId,
|
||||
isCustom: false,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
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,
|
||||
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: Partial<FieldMetadataEntity> = {
|
||||
id: mockFieldId,
|
||||
isCustom: false,
|
||||
isLabelSyncedWithName: false,
|
||||
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: {
|
||||
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: Partial<FieldMetadataEntity> = {
|
||||
id: mockFieldId,
|
||||
isCustom: false,
|
||||
isLabelSyncedWithName: false,
|
||||
label: 'Default 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: '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 = 'translated:msg-label';
|
||||
|
||||
(i18n._ as jest.Mock).mockImplementation(() => translatedLabel);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -4,12 +4,15 @@ import {
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
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 { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
|
||||
import { FieldStandardOverridesDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto';
|
||||
import { UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
@ -27,7 +30,13 @@ export class BeforeUpdateOneField<T extends UpdateFieldInput>
|
||||
|
||||
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();
|
||||
@ -36,7 +45,7 @@ export class BeforeUpdateOneField<T extends UpdateFieldInput>
|
||||
const fieldMetadata = await this.getFieldMetadata(instance, workspaceId);
|
||||
|
||||
if (!fieldMetadata.isCustom) {
|
||||
return this.handleStandardFieldUpdate(instance, fieldMetadata);
|
||||
return this.handleStandardFieldUpdate(instance, fieldMetadata, locale);
|
||||
}
|
||||
|
||||
return instance;
|
||||
@ -63,12 +72,13 @@ export class BeforeUpdateOneField<T extends UpdateFieldInput>
|
||||
private handleStandardFieldUpdate(
|
||||
instance: UpdateOneInputType<T>,
|
||||
fieldMetadata: FieldMetadataEntity,
|
||||
locale?: keyof typeof APP_LOCALES,
|
||||
): UpdateOneInputType<T> {
|
||||
const update: StandardFieldUpdate = {};
|
||||
const updatableFields = ['isActive', 'isLabelSyncedWithName'];
|
||||
const overridableFields = ['label', 'icon', 'description'];
|
||||
|
||||
const hasNonUpdatableFields = Object.keys(instance.update).some(
|
||||
const nonUpdatableFields = Object.keys(instance.update).filter(
|
||||
(key) =>
|
||||
!updatableFields.includes(key) && !overridableFields.includes(key),
|
||||
);
|
||||
@ -85,9 +95,9 @@ export class BeforeUpdateOneField<T extends UpdateFieldInput>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasNonUpdatableFields) {
|
||||
if (nonUpdatableFields.length > 0) {
|
||||
throw new BadRequestException(
|
||||
'Only isActive, isLabelSyncedWithName, label, icon and description fields can be updated for standard fields',
|
||||
`Only isActive, isLabelSyncedWithName, label, icon and description fields can be updated for standard fields. Invalid fields: ${nonUpdatableFields.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
@ -98,7 +108,7 @@ export class BeforeUpdateOneField<T extends UpdateFieldInput>
|
||||
|
||||
this.handleActiveField(instance, update);
|
||||
this.handleLabelSyncedWithNameField(instance, update);
|
||||
this.handleStandardOverrides(instance, fieldMetadata, update);
|
||||
this.handleStandardOverrides(instance, fieldMetadata, update, locale);
|
||||
|
||||
return {
|
||||
id: instance.id,
|
||||
@ -139,6 +149,7 @@ export class BeforeUpdateOneField<T extends UpdateFieldInput>
|
||||
instance: UpdateOneInputType<T>,
|
||||
fieldMetadata: FieldMetadataEntity,
|
||||
update: StandardFieldUpdate,
|
||||
locale?: keyof typeof APP_LOCALES,
|
||||
): void {
|
||||
const hasStandardOverrides =
|
||||
isDefined(instance.update.description) ||
|
||||
@ -151,29 +162,158 @@ export class BeforeUpdateOneField<T extends UpdateFieldInput>
|
||||
|
||||
update.standardOverrides = update.standardOverrides || {};
|
||||
|
||||
this.handleDescriptionOverride(instance, fieldMetadata, update);
|
||||
this.handleDescriptionOverride(instance, fieldMetadata, update, locale);
|
||||
this.handleIconOverride(instance, fieldMetadata, update);
|
||||
this.handleLabelOverride(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;
|
||||
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,
|
||||
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: StandardFieldUpdate,
|
||||
overrideKey: 'label' | '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: 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];
|
||||
|
||||
(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;
|
||||
}
|
||||
|
||||
update.standardOverrides = update.standardOverrides || {};
|
||||
|
||||
if (instance.update.description === fieldMetadata.description) {
|
||||
update.standardOverrides.description = null;
|
||||
|
||||
if (
|
||||
this.resetOverrideIfMatchesOriginal({
|
||||
update,
|
||||
overrideKey: 'description',
|
||||
newValue: instance.update.description,
|
||||
originalValue: fieldMetadata.description,
|
||||
locale,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
update.standardOverrides.description = instance.update.description;
|
||||
this.setOverrideValue(
|
||||
update,
|
||||
'description',
|
||||
instance.update.description,
|
||||
locale,
|
||||
);
|
||||
}
|
||||
|
||||
private handleIconOverride(
|
||||
@ -185,21 +325,26 @@ export class BeforeUpdateOneField<T extends UpdateFieldInput>
|
||||
return;
|
||||
}
|
||||
|
||||
update.standardOverrides = update.standardOverrides || {};
|
||||
|
||||
if (instance.update.icon === fieldMetadata.icon) {
|
||||
update.standardOverrides.icon = null;
|
||||
|
||||
if (
|
||||
this.resetOverrideIfMatchesOriginal({
|
||||
update,
|
||||
overrideKey: 'icon',
|
||||
newValue: instance.update.icon,
|
||||
originalValue: fieldMetadata.icon,
|
||||
locale: undefined,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
update.standardOverrides.icon = instance.update.icon;
|
||||
this.setOverrideValue(update, 'icon', instance.update.icon);
|
||||
}
|
||||
|
||||
private handleLabelOverride(
|
||||
instance: UpdateOneInputType<T>,
|
||||
fieldMetadata: FieldMetadataEntity,
|
||||
update: StandardFieldUpdate,
|
||||
locale?: keyof typeof APP_LOCALES,
|
||||
): void {
|
||||
if (
|
||||
fieldMetadata.isLabelSyncedWithName ||
|
||||
@ -212,14 +357,18 @@ export class BeforeUpdateOneField<T extends UpdateFieldInput>
|
||||
return;
|
||||
}
|
||||
|
||||
update.standardOverrides = update.standardOverrides || {};
|
||||
|
||||
if (instance.update.label === fieldMetadata.label) {
|
||||
update.standardOverrides.label = null;
|
||||
|
||||
if (
|
||||
this.resetOverrideIfMatchesOriginal({
|
||||
update,
|
||||
overrideKey: 'label',
|
||||
newValue: instance.update.label,
|
||||
originalValue: fieldMetadata.label,
|
||||
locale,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
update.standardOverrides.label = instance.update.label;
|
||||
this.setOverrideValue(update, 'label', instance.update.label, locale);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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