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) => {
|
const fillNameFromLabel = (label: string) => {
|
||||||
isDefined(label) &&
|
isDefined(label) &&
|
||||||
fieldMetadataItem?.isCustom &&
|
|
||||||
setValue('name', computeMetadataNameFromLabel(label), {
|
setValue('name', computeMetadataNameFromLabel(label), {
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
});
|
});
|
||||||
@ -141,7 +140,11 @@ export const SettingsDataModelFieldIconLabelForm = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
error={getErrorMessageFromError(errors.label?.message)}
|
error={getErrorMessageFromError(errors.label?.message)}
|
||||||
disabled={isLabelSyncedWithName === true}
|
disabled={
|
||||||
|
isLabelSyncedWithName === true &&
|
||||||
|
fieldMetadataItem &&
|
||||||
|
!fieldMetadataItem?.isCustom
|
||||||
|
}
|
||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -100,6 +100,7 @@ export const SettingsDataModelObjectAboutForm = ({
|
|||||||
setValue('labelPlural', labelPluralFromSingularLabel, {
|
setValue('labelPlural', labelPluralFromSingularLabel, {
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
});
|
});
|
||||||
|
fillNamePluralFromLabelPlural(labelPluralFromSingularLabel);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fillNameSingularFromLabelSingular = (
|
const fillNameSingularFromLabelSingular = (
|
||||||
@ -161,7 +162,11 @@ export const SettingsDataModelObjectAboutForm = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onBlur={() => onNewDirtyField?.()}
|
onBlur={() => onNewDirtyField?.()}
|
||||||
disabled={!objectMetadataItem?.isCustom && isLabelSyncedWithName}
|
disabled={
|
||||||
|
objectMetadataItem &&
|
||||||
|
!objectMetadataItem?.isCustom &&
|
||||||
|
isLabelSyncedWithName
|
||||||
|
}
|
||||||
fullWidth
|
fullWidth
|
||||||
maxLength={OBJECT_NAME_MAXIMUM_LENGTH}
|
maxLength={OBJECT_NAME_MAXIMUM_LENGTH}
|
||||||
/>
|
/>
|
||||||
@ -187,7 +192,11 @@ export const SettingsDataModelObjectAboutForm = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onBlur={() => onNewDirtyField?.()}
|
onBlur={() => onNewDirtyField?.()}
|
||||||
disabled={!objectMetadataItem?.isCustom && isLabelSyncedWithName}
|
disabled={
|
||||||
|
objectMetadataItem &&
|
||||||
|
!objectMetadataItem?.isCustom &&
|
||||||
|
isLabelSyncedWithName
|
||||||
|
}
|
||||||
fullWidth
|
fullWidth
|
||||||
maxLength={OBJECT_NAME_MAXIMUM_LENGTH}
|
maxLength={OBJECT_NAME_MAXIMUM_LENGTH}
|
||||||
/>
|
/>
|
||||||
@ -293,37 +302,40 @@ export const SettingsDataModelObjectAboutForm = ({
|
|||||||
</AdvancedSettingsWrapper>
|
</AdvancedSettingsWrapper>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
<AdvancedSettingsWrapper>
|
{(!objectMetadataItem || objectMetadataItem?.isCustom) && (
|
||||||
<Controller
|
<AdvancedSettingsWrapper>
|
||||||
name="isLabelSyncedWithName"
|
<Controller
|
||||||
control={control}
|
name="isLabelSyncedWithName"
|
||||||
defaultValue={objectMetadataItem?.isLabelSyncedWithName}
|
control={control}
|
||||||
render={({ field: { onChange, value } }) => (
|
defaultValue={objectMetadataItem?.isLabelSyncedWithName}
|
||||||
<Card rounded>
|
render={({ field: { onChange, value } }) => (
|
||||||
<SettingsOptionCardContentToggle
|
<Card rounded>
|
||||||
Icon={IconRefresh}
|
<SettingsOptionCardContentToggle
|
||||||
title={t`Synchronize Objects Labels and API Names`}
|
Icon={IconRefresh}
|
||||||
description={t`Should changing an object's label also change the API?`}
|
title={t`Synchronize Objects Labels and API Names`}
|
||||||
checked={value ?? true}
|
description={t`Should changing an object's label also change the API?`}
|
||||||
advancedMode
|
checked={value ?? true}
|
||||||
onChange={(value) => {
|
advancedMode
|
||||||
onChange(value);
|
onChange={(value) => {
|
||||||
onNewDirtyField?.();
|
onChange(value);
|
||||||
|
onNewDirtyField?.();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
value === true &&
|
value === true &&
|
||||||
isDefined(objectMetadataItem) &&
|
((isDefined(objectMetadataItem) &&
|
||||||
objectMetadataItem.isCustom
|
objectMetadataItem.isCustom) ||
|
||||||
) {
|
!isDefined(objectMetadataItem))
|
||||||
fillNamePluralFromLabelPlural(labelPlural);
|
) {
|
||||||
fillNameSingularFromLabelSingular(labelSingular);
|
fillNamePluralFromLabelPlural(labelPlural);
|
||||||
}
|
fillNameSingularFromLabelSingular(labelSingular);
|
||||||
}}
|
}
|
||||||
/>
|
}}
|
||||||
</Card>
|
/>
|
||||||
)}
|
</Card>
|
||||||
/>
|
)}
|
||||||
</AdvancedSettingsWrapper>
|
/>
|
||||||
|
</AdvancedSettingsWrapper>
|
||||||
|
)}
|
||||||
</StyledAdvancedSettingsSectionInputWrapper>
|
</StyledAdvancedSettingsSectionInputWrapper>
|
||||||
</StyledAdvancedSettingsContainer>
|
</StyledAdvancedSettingsContainer>
|
||||||
</StyledAdvancedSettingsOuterContainer>
|
</StyledAdvancedSettingsOuterContainer>
|
||||||
|
|||||||
@ -9,10 +9,10 @@ import { Select } from '@/ui/input/components/Select';
|
|||||||
|
|
||||||
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItem';
|
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItem';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
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 { APP_LOCALES } from 'twenty-shared/translations';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
|
||||||
|
import { logError } from '~/utils/logError';
|
||||||
|
|
||||||
const StyledContainer = styled.div`
|
const StyledContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -62,7 +62,7 @@ export const LocalePicker = () => {
|
|||||||
await refreshObjectMetadataItems();
|
await refreshObjectMetadataItems();
|
||||||
};
|
};
|
||||||
|
|
||||||
const localeOptions: Array<{
|
const unsortedLocaleOptions: Array<{
|
||||||
label: string;
|
label: string;
|
||||||
value: (typeof APP_LOCALES)[keyof typeof APP_LOCALES];
|
value: (typeof APP_LOCALES)[keyof typeof APP_LOCALES];
|
||||||
}> = [
|
}> = [
|
||||||
@ -187,13 +187,18 @@ export const LocalePicker = () => {
|
|||||||
value: APP_LOCALES['vi-VN'],
|
value: APP_LOCALES['vi-VN'],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isDebugMode) {
|
if (isDebugMode) {
|
||||||
localeOptions.push({
|
unsortedLocaleOptions.push({
|
||||||
label: t`Pseudo-English`,
|
label: t`Pseudo-English`,
|
||||||
value: APP_LOCALES['pseudo-en'],
|
value: APP_LOCALES['pseudo-en'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const localeOptions = [...unsortedLocaleOptions].sort((a, b) =>
|
||||||
|
a.label.localeCompare(b.label),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledContainer>
|
<StyledContainer>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { Field, ObjectType } from '@nestjs/graphql';
|
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')
|
@ObjectType('StandardOverrides')
|
||||||
export class FieldStandardOverridesDTO {
|
export class FieldStandardOverridesDTO {
|
||||||
@ -18,4 +20,19 @@ export class FieldStandardOverridesDTO {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Field(() => String, { nullable: true })
|
@Field(() => String, { nullable: true })
|
||||||
icon?: string | null;
|
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(
|
async updateOneField(
|
||||||
@Args('input') input: UpdateOneFieldMetadataInput,
|
@Args('input') input: UpdateOneFieldMetadataInput,
|
||||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||||
|
@Context() context: I18nContext,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const updatedInput = (await this.beforeUpdateOneField.run(
|
const updatedInput = (await this.beforeUpdateOneField.run(input, {
|
||||||
input,
|
|
||||||
workspaceId,
|
workspaceId,
|
||||||
)) as UpdateOneFieldMetadataInput;
|
locale: context.req.headers['x-locale'],
|
||||||
|
})) as UpdateOneFieldMetadataInput;
|
||||||
|
|
||||||
return await this.fieldMetadataService.updateOne(updatedInput.id, {
|
return await this.fieldMetadataService.updateOne(updatedInput.id, {
|
||||||
...updatedInput.update,
|
...updatedInput.update,
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { i18n } from '@lingui/core';
|
import { i18n } from '@lingui/core';
|
||||||
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
||||||
import isEmpty from 'lodash.isempty';
|
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 { FieldMetadataType } from 'twenty-shared/types';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
import { DataSource, FindOneOptions, In, Repository } from 'typeorm';
|
import { DataSource, FindOneOptions, In, Repository } from 'typeorm';
|
||||||
@ -618,15 +618,22 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
|||||||
return fieldMetadata[labelKey] ?? '';
|
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] ?? '';
|
return fieldMetadata[labelKey] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const translationValue =
|
||||||
fieldMetadata.standardOverrides &&
|
fieldMetadata.standardOverrides?.translations?.[locale]?.[labelKey];
|
||||||
isDefined(fieldMetadata.standardOverrides[labelKey])
|
|
||||||
) {
|
if (isDefined(translationValue)) {
|
||||||
return fieldMetadata.standardOverrides[labelKey] as string;
|
return translationValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageId = generateMessageId(fieldMetadata[labelKey] ?? '');
|
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,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
import { i18n } from '@lingui/core';
|
||||||
import {
|
import {
|
||||||
BeforeUpdateOneHook,
|
BeforeUpdateOneHook,
|
||||||
UpdateOneInputType,
|
UpdateOneInputType,
|
||||||
} from '@ptc-org/nestjs-query-graphql';
|
} from '@ptc-org/nestjs-query-graphql';
|
||||||
|
import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
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 { 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 { 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 { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||||
@ -27,7 +30,13 @@ export class BeforeUpdateOneField<T extends UpdateFieldInput>
|
|||||||
|
|
||||||
async run(
|
async run(
|
||||||
instance: UpdateOneInputType<T>,
|
instance: UpdateOneInputType<T>,
|
||||||
workspaceId: string,
|
{
|
||||||
|
workspaceId,
|
||||||
|
locale,
|
||||||
|
}: {
|
||||||
|
workspaceId: string;
|
||||||
|
locale: keyof typeof APP_LOCALES | undefined;
|
||||||
|
},
|
||||||
): Promise<UpdateOneInputType<T>> {
|
): Promise<UpdateOneInputType<T>> {
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
@ -36,7 +45,7 @@ export class BeforeUpdateOneField<T extends UpdateFieldInput>
|
|||||||
const fieldMetadata = await this.getFieldMetadata(instance, workspaceId);
|
const fieldMetadata = await this.getFieldMetadata(instance, workspaceId);
|
||||||
|
|
||||||
if (!fieldMetadata.isCustom) {
|
if (!fieldMetadata.isCustom) {
|
||||||
return this.handleStandardFieldUpdate(instance, fieldMetadata);
|
return this.handleStandardFieldUpdate(instance, fieldMetadata, locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
return instance;
|
return instance;
|
||||||
@ -63,12 +72,13 @@ export class BeforeUpdateOneField<T extends UpdateFieldInput>
|
|||||||
private handleStandardFieldUpdate(
|
private handleStandardFieldUpdate(
|
||||||
instance: UpdateOneInputType<T>,
|
instance: UpdateOneInputType<T>,
|
||||||
fieldMetadata: FieldMetadataEntity,
|
fieldMetadata: FieldMetadataEntity,
|
||||||
|
locale?: keyof typeof APP_LOCALES,
|
||||||
): UpdateOneInputType<T> {
|
): UpdateOneInputType<T> {
|
||||||
const update: StandardFieldUpdate = {};
|
const update: StandardFieldUpdate = {};
|
||||||
const updatableFields = ['isActive', 'isLabelSyncedWithName'];
|
const updatableFields = ['isActive', 'isLabelSyncedWithName'];
|
||||||
const overridableFields = ['label', 'icon', 'description'];
|
const overridableFields = ['label', 'icon', 'description'];
|
||||||
|
|
||||||
const hasNonUpdatableFields = Object.keys(instance.update).some(
|
const nonUpdatableFields = Object.keys(instance.update).filter(
|
||||||
(key) =>
|
(key) =>
|
||||||
!updatableFields.includes(key) && !overridableFields.includes(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(
|
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.handleActiveField(instance, update);
|
||||||
this.handleLabelSyncedWithNameField(instance, update);
|
this.handleLabelSyncedWithNameField(instance, update);
|
||||||
this.handleStandardOverrides(instance, fieldMetadata, update);
|
this.handleStandardOverrides(instance, fieldMetadata, update, locale);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: instance.id,
|
id: instance.id,
|
||||||
@ -139,6 +149,7 @@ export class BeforeUpdateOneField<T extends UpdateFieldInput>
|
|||||||
instance: UpdateOneInputType<T>,
|
instance: UpdateOneInputType<T>,
|
||||||
fieldMetadata: FieldMetadataEntity,
|
fieldMetadata: FieldMetadataEntity,
|
||||||
update: StandardFieldUpdate,
|
update: StandardFieldUpdate,
|
||||||
|
locale?: keyof typeof APP_LOCALES,
|
||||||
): void {
|
): void {
|
||||||
const hasStandardOverrides =
|
const hasStandardOverrides =
|
||||||
isDefined(instance.update.description) ||
|
isDefined(instance.update.description) ||
|
||||||
@ -151,29 +162,158 @@ export class BeforeUpdateOneField<T extends UpdateFieldInput>
|
|||||||
|
|
||||||
update.standardOverrides = update.standardOverrides || {};
|
update.standardOverrides = update.standardOverrides || {};
|
||||||
|
|
||||||
this.handleDescriptionOverride(instance, fieldMetadata, update);
|
this.handleDescriptionOverride(instance, fieldMetadata, update, locale);
|
||||||
this.handleIconOverride(instance, fieldMetadata, update);
|
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(
|
private handleDescriptionOverride(
|
||||||
instance: UpdateOneInputType<T>,
|
instance: UpdateOneInputType<T>,
|
||||||
fieldMetadata: FieldMetadataEntity,
|
fieldMetadata: FieldMetadataEntity,
|
||||||
update: StandardFieldUpdate,
|
update: StandardFieldUpdate,
|
||||||
|
locale?: keyof typeof APP_LOCALES,
|
||||||
): void {
|
): void {
|
||||||
if (!isDefined(instance.update.description)) {
|
if (!isDefined(instance.update.description)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
update.standardOverrides = update.standardOverrides || {};
|
if (
|
||||||
|
this.resetOverrideIfMatchesOriginal({
|
||||||
if (instance.update.description === fieldMetadata.description) {
|
update,
|
||||||
update.standardOverrides.description = null;
|
overrideKey: 'description',
|
||||||
|
newValue: instance.update.description,
|
||||||
|
originalValue: fieldMetadata.description,
|
||||||
|
locale,
|
||||||
|
})
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
update.standardOverrides.description = instance.update.description;
|
this.setOverrideValue(
|
||||||
|
update,
|
||||||
|
'description',
|
||||||
|
instance.update.description,
|
||||||
|
locale,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleIconOverride(
|
private handleIconOverride(
|
||||||
@ -185,21 +325,26 @@ export class BeforeUpdateOneField<T extends UpdateFieldInput>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
update.standardOverrides = update.standardOverrides || {};
|
if (
|
||||||
|
this.resetOverrideIfMatchesOriginal({
|
||||||
if (instance.update.icon === fieldMetadata.icon) {
|
update,
|
||||||
update.standardOverrides.icon = null;
|
overrideKey: 'icon',
|
||||||
|
newValue: instance.update.icon,
|
||||||
|
originalValue: fieldMetadata.icon,
|
||||||
|
locale: undefined,
|
||||||
|
})
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
update.standardOverrides.icon = instance.update.icon;
|
this.setOverrideValue(update, 'icon', instance.update.icon);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleLabelOverride(
|
private handleLabelOverride(
|
||||||
instance: UpdateOneInputType<T>,
|
instance: UpdateOneInputType<T>,
|
||||||
fieldMetadata: FieldMetadataEntity,
|
fieldMetadata: FieldMetadataEntity,
|
||||||
update: StandardFieldUpdate,
|
update: StandardFieldUpdate,
|
||||||
|
locale?: keyof typeof APP_LOCALES,
|
||||||
): void {
|
): void {
|
||||||
if (
|
if (
|
||||||
fieldMetadata.isLabelSyncedWithName ||
|
fieldMetadata.isLabelSyncedWithName ||
|
||||||
@ -212,14 +357,18 @@ export class BeforeUpdateOneField<T extends UpdateFieldInput>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
update.standardOverrides = update.standardOverrides || {};
|
if (
|
||||||
|
this.resetOverrideIfMatchesOriginal({
|
||||||
if (instance.update.label === fieldMetadata.label) {
|
update,
|
||||||
update.standardOverrides.label = null;
|
overrideKey: 'label',
|
||||||
|
newValue: instance.update.label,
|
||||||
|
originalValue: fieldMetadata.label,
|
||||||
|
locale,
|
||||||
|
})
|
||||||
|
) {
|
||||||
return;
|
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 { 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')
|
@ObjectType('ObjectStandardOverrides')
|
||||||
export class ObjectStandardOverridesDTO {
|
export class ObjectStandardOverridesDTO {
|
||||||
@ -23,4 +25,20 @@ export class ObjectStandardOverridesDTO {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Field(() => String, { nullable: true })
|
@Field(() => String, { nullable: true })
|
||||||
icon?: string | null;
|
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';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { i18n } from '@lingui/core';
|
||||||
import {
|
import {
|
||||||
BeforeUpdateOneHook,
|
BeforeUpdateOneHook,
|
||||||
UpdateOneInputType,
|
UpdateOneInputType,
|
||||||
} from '@ptc-org/nestjs-query-graphql';
|
} from '@ptc-org/nestjs-query-graphql';
|
||||||
|
import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
import { Equal, In, Repository } from 'typeorm';
|
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 { 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 { 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';
|
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
|
// TODO: this logic could be moved to a policy guard
|
||||||
async run(
|
async run(
|
||||||
instance: UpdateOneInputType<T>,
|
instance: UpdateOneInputType<T>,
|
||||||
workspaceId: string,
|
{
|
||||||
|
workspaceId,
|
||||||
|
locale,
|
||||||
|
}: {
|
||||||
|
workspaceId: string;
|
||||||
|
locale: keyof typeof APP_LOCALES | undefined;
|
||||||
|
},
|
||||||
): Promise<UpdateOneInputType<T>> {
|
): Promise<UpdateOneInputType<T>> {
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
@ -45,7 +54,7 @@ export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
|
|||||||
const objectMetadata = await this.getObjectMetadata(instance, workspaceId);
|
const objectMetadata = await this.getObjectMetadata(instance, workspaceId);
|
||||||
|
|
||||||
if (!objectMetadata.isCustom) {
|
if (!objectMetadata.isCustom) {
|
||||||
return this.handleStandardObjectUpdate(instance, objectMetadata);
|
return this.handleStandardObjectUpdate(instance, objectMetadata, locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.validateIdentifierFields(instance, workspaceId);
|
await this.validateIdentifierFields(instance, workspaceId);
|
||||||
@ -74,6 +83,7 @@ export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
|
|||||||
private handleStandardObjectUpdate(
|
private handleStandardObjectUpdate(
|
||||||
instance: UpdateOneInputType<T>,
|
instance: UpdateOneInputType<T>,
|
||||||
objectMetadata: ObjectMetadataEntity,
|
objectMetadata: ObjectMetadataEntity,
|
||||||
|
locale: keyof typeof APP_LOCALES | undefined,
|
||||||
): UpdateOneInputType<T> {
|
): UpdateOneInputType<T> {
|
||||||
const update: StandardObjectUpdate = {};
|
const update: StandardObjectUpdate = {};
|
||||||
const updatableFields = ['isActive', 'isLabelSyncedWithName'];
|
const updatableFields = ['isActive', 'isLabelSyncedWithName'];
|
||||||
@ -84,14 +94,11 @@ export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
|
|||||||
'description',
|
'description',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Check if any field is not allowed
|
|
||||||
const nonUpdatableFields = Object.keys(instance.update).filter(
|
const nonUpdatableFields = Object.keys(instance.update).filter(
|
||||||
(key) =>
|
(key) =>
|
||||||
!updatableFields.includes(key) && !overridableFields.includes(key),
|
!updatableFields.includes(key) && !overridableFields.includes(key),
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasNonUpdatableFields = nonUpdatableFields.length > 0;
|
|
||||||
|
|
||||||
const isUpdatingLabelsWhenSynced =
|
const isUpdatingLabelsWhenSynced =
|
||||||
(instance.update.labelSingular || instance.update.labelPlural) &&
|
(instance.update.labelSingular || instance.update.labelPlural) &&
|
||||||
objectMetadata.isLabelSyncedWithName &&
|
objectMetadata.isLabelSyncedWithName &&
|
||||||
@ -105,7 +112,7 @@ export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasNonUpdatableFields) {
|
if (nonUpdatableFields.length > 0) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Only isActive, isLabelSyncedWithName, labelSingular, labelPlural, icon and description fields can be updated for standard objects. Disallowed fields: ${nonUpdatableFields.join(', ')}`,
|
`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.handleActiveField(instance, update);
|
||||||
this.handleLabelSyncedWithNameField(instance, update);
|
this.handleLabelSyncedWithNameField(instance, update);
|
||||||
this.handleStandardOverrides(instance, objectMetadata, update);
|
this.handleStandardOverrides(instance, objectMetadata, update, locale);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: instance.id,
|
id: instance.id,
|
||||||
@ -161,6 +168,7 @@ export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
|
|||||||
instance: UpdateOneInputType<T>,
|
instance: UpdateOneInputType<T>,
|
||||||
objectMetadata: ObjectMetadataEntity,
|
objectMetadata: ObjectMetadataEntity,
|
||||||
update: StandardObjectUpdate,
|
update: StandardObjectUpdate,
|
||||||
|
locale: keyof typeof APP_LOCALES | undefined,
|
||||||
): void {
|
): void {
|
||||||
const hasStandardOverrides =
|
const hasStandardOverrides =
|
||||||
isDefined(instance.update.description) ||
|
isDefined(instance.update.description) ||
|
||||||
@ -174,29 +182,156 @@ export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
|
|||||||
|
|
||||||
update.standardOverrides = update.standardOverrides || {};
|
update.standardOverrides = update.standardOverrides || {};
|
||||||
|
|
||||||
this.handleDescriptionOverride(instance, objectMetadata, update);
|
|
||||||
this.handleIconOverride(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(
|
private handleDescriptionOverride(
|
||||||
instance: UpdateOneInputType<T>,
|
instance: UpdateOneInputType<T>,
|
||||||
objectMetadata: ObjectMetadataEntity,
|
objectMetadata: ObjectMetadataEntity,
|
||||||
update: StandardObjectUpdate,
|
update: StandardObjectUpdate,
|
||||||
|
locale?: keyof typeof APP_LOCALES,
|
||||||
): void {
|
): void {
|
||||||
if (!isDefined(instance.update.description)) {
|
if (!isDefined(instance.update.description)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
update.standardOverrides = update.standardOverrides || {};
|
if (
|
||||||
|
this.resetOverrideIfMatchesOriginal({
|
||||||
if (instance.update.description === objectMetadata.description) {
|
update,
|
||||||
update.standardOverrides.description = null;
|
overrideKey: 'description',
|
||||||
|
newValue: instance.update.description,
|
||||||
|
originalValue: objectMetadata.description,
|
||||||
|
locale,
|
||||||
|
})
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
update.standardOverrides.description = instance.update.description;
|
this.setOverrideValue(
|
||||||
|
update,
|
||||||
|
'description',
|
||||||
|
instance.update.description,
|
||||||
|
locale,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleIconOverride(
|
private handleIconOverride(
|
||||||
@ -208,21 +343,26 @@ export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
update.standardOverrides = update.standardOverrides || {};
|
if (
|
||||||
|
this.resetOverrideIfMatchesOriginal({
|
||||||
if (instance.update.icon === objectMetadata.icon) {
|
update,
|
||||||
update.standardOverrides.icon = null;
|
overrideKey: 'icon',
|
||||||
|
newValue: instance.update.icon,
|
||||||
|
originalValue: objectMetadata.icon,
|
||||||
|
locale: undefined,
|
||||||
|
})
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
update.standardOverrides.icon = instance.update.icon;
|
this.setOverrideValue(update, 'icon', instance.update.icon);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleLabelOverrides(
|
private handleLabelOverrides(
|
||||||
instance: UpdateOneInputType<T>,
|
instance: UpdateOneInputType<T>,
|
||||||
objectMetadata: ObjectMetadataEntity,
|
objectMetadata: ObjectMetadataEntity,
|
||||||
update: StandardObjectUpdate,
|
update: StandardObjectUpdate,
|
||||||
|
locale?: keyof typeof APP_LOCALES,
|
||||||
): void {
|
): void {
|
||||||
// Skip label updates if labels are synced with name or will be synced
|
// Skip label updates if labels are synced with name or will be synced
|
||||||
if (
|
if (
|
||||||
@ -232,48 +372,68 @@ export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.handleLabelSingularOverride(instance, objectMetadata, update);
|
this.handleLabelSingularOverride(instance, objectMetadata, update, locale);
|
||||||
this.handleLabelPluralOverride(instance, objectMetadata, update);
|
this.handleLabelPluralOverride(instance, objectMetadata, update, locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleLabelSingularOverride(
|
private handleLabelSingularOverride(
|
||||||
instance: UpdateOneInputType<T>,
|
instance: UpdateOneInputType<T>,
|
||||||
objectMetadata: ObjectMetadataEntity,
|
objectMetadata: ObjectMetadataEntity,
|
||||||
update: StandardObjectUpdate,
|
update: StandardObjectUpdate,
|
||||||
|
locale?: keyof typeof APP_LOCALES,
|
||||||
): void {
|
): void {
|
||||||
if (!isDefined(instance.update.labelSingular)) {
|
if (!isDefined(instance.update.labelSingular)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
update.standardOverrides = update.standardOverrides || {};
|
if (
|
||||||
|
this.resetOverrideIfMatchesOriginal({
|
||||||
if (instance.update.labelSingular === objectMetadata.labelSingular) {
|
update,
|
||||||
update.standardOverrides.labelSingular = null;
|
overrideKey: 'labelSingular',
|
||||||
|
newValue: instance.update.labelSingular,
|
||||||
|
originalValue: objectMetadata.labelSingular,
|
||||||
|
locale,
|
||||||
|
})
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
update.standardOverrides.labelSingular = instance.update.labelSingular;
|
this.setOverrideValue(
|
||||||
|
update,
|
||||||
|
'labelSingular',
|
||||||
|
instance.update.labelSingular,
|
||||||
|
locale,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleLabelPluralOverride(
|
private handleLabelPluralOverride(
|
||||||
instance: UpdateOneInputType<T>,
|
instance: UpdateOneInputType<T>,
|
||||||
objectMetadata: ObjectMetadataEntity,
|
objectMetadata: ObjectMetadataEntity,
|
||||||
update: StandardObjectUpdate,
|
update: StandardObjectUpdate,
|
||||||
|
locale?: keyof typeof APP_LOCALES,
|
||||||
): void {
|
): void {
|
||||||
if (!isDefined(instance.update.labelPlural)) {
|
if (!isDefined(instance.update.labelPlural)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
update.standardOverrides = update.standardOverrides || {};
|
if (
|
||||||
|
this.resetOverrideIfMatchesOriginal({
|
||||||
if (instance.update.labelPlural === objectMetadata.labelPlural) {
|
update,
|
||||||
update.standardOverrides.labelPlural = null;
|
overrideKey: 'labelPlural',
|
||||||
|
newValue: instance.update.labelPlural,
|
||||||
|
originalValue: objectMetadata.labelPlural,
|
||||||
|
locale,
|
||||||
|
})
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
update.standardOverrides.labelPlural = instance.update.labelPlural;
|
this.setOverrideValue(
|
||||||
|
update,
|
||||||
|
'labelPlural',
|
||||||
|
instance.update.labelPlural,
|
||||||
|
locale,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async validateIdentifierFields(
|
private async validateIdentifierFields(
|
||||||
|
|||||||
@ -106,12 +106,13 @@ export class ObjectMetadataResolver {
|
|||||||
async updateOneObject(
|
async updateOneObject(
|
||||||
@Args('input') input: UpdateOneObjectInput,
|
@Args('input') input: UpdateOneObjectInput,
|
||||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||||
|
@Context() context: I18nContext,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const updatedInput = (await this.beforeUpdateOneObject.run(
|
const updatedInput = (await this.beforeUpdateOneObject.run(input, {
|
||||||
input,
|
|
||||||
workspaceId,
|
workspaceId,
|
||||||
)) as UpdateOneObjectInput;
|
locale: context.req.headers['x-locale'],
|
||||||
|
})) as UpdateOneObjectInput;
|
||||||
|
|
||||||
return await this.objectMetadataService.updateOneObject(
|
return await this.objectMetadataService.updateOneObject(
|
||||||
updatedInput,
|
updatedInput,
|
||||||
|
|||||||
@ -5,8 +5,8 @@ import { i18n } from '@lingui/core';
|
|||||||
import { Query, QueryOptions } from '@ptc-org/nestjs-query-core';
|
import { Query, QueryOptions } from '@ptc-org/nestjs-query-core';
|
||||||
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
||||||
import { isDefined } from 'class-validator';
|
import { isDefined } from 'class-validator';
|
||||||
|
import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||||
import { FindManyOptions, FindOneOptions, In, Not, Repository } from 'typeorm';
|
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';
|
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];
|
return objectMetadata[labelKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!locale || locale === SOURCE_LOCALE) {
|
||||||
objectMetadata.standardOverrides &&
|
if (
|
||||||
isDefined(objectMetadata.standardOverrides[labelKey])
|
objectMetadata.standardOverrides &&
|
||||||
) {
|
isDefined(objectMetadata.standardOverrides[labelKey])
|
||||||
return objectMetadata.standardOverrides[labelKey] as string;
|
) {
|
||||||
|
return objectMetadata.standardOverrides[labelKey] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return objectMetadata[labelKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!locale) {
|
const translationValue =
|
||||||
return objectMetadata[labelKey];
|
objectMetadata.standardOverrides?.translations?.[locale]?.[labelKey];
|
||||||
|
|
||||||
|
if (isDefined(translationValue)) {
|
||||||
|
return translationValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageId = generateMessageId(objectMetadata[labelKey] ?? '');
|
const messageId = generateMessageId(objectMetadata[labelKey] ?? '');
|
||||||
|
|||||||
Reference in New Issue
Block a user