Overwrite standard translations (#11134)

Manage overwriting translations for standard fields and standard objects
properties
This commit is contained in:
Félix Malfait
2025-03-25 22:17:29 +01:00
committed by GitHub
parent 7a7003d859
commit 6ec06be18d
13 changed files with 1809 additions and 122 deletions

View File

@ -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
/>

View File

@ -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>

View File

@ -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

View File

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

View File

@ -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,

View File

@ -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] ?? '');

View File

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

View File

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

View File

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

View File

@ -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');
});
});

View File

@ -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(

View File

@ -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,

View File

@ -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] ?? '');