Prevent relation update from settings (#13099)
## Expected behavior Described behavior regarding: (update | create) x (custom | standard) x (icon, label, name, isSynced) **Custom:** - Field RELATION create: name, label, isSynced, icon should be editable - Field RELATION update: name should not, icon label, isSynced should - For other fields, icon, label, name, isSynced should be editable at field creation | update To simplify: Field RELATION name should not be editable at update **Standards** - Field: create does not makes sense - Field: name should not, icon label, isSynced should (this will end up in overrides) To simplify, no Field RELATION edge case, name should not be editable at update **Note:** the FE logic is quite different as the UI is hiding some details behind the syncWithLabel. See my comments and TODO there ## What I've tested: (update | create) x (custom | standard) x (icon, label, name, isSynced, description)
This commit is contained in:
@ -13,6 +13,7 @@ import { IconPicker } from '@/ui/input/components/IconPicker';
|
|||||||
import { TextInput } from '@/ui/input/components/TextInput';
|
import { TextInput } from '@/ui/input/components/TextInput';
|
||||||
import { useTheme } from '@emotion/react';
|
import { useTheme } from '@emotion/react';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { FieldMetadataType } from 'twenty-shared/types';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
import {
|
import {
|
||||||
AppTooltip,
|
AppTooltip,
|
||||||
@ -74,11 +75,11 @@ const StyledAdvancedSettingsContainer = styled.div`
|
|||||||
type SettingsDataModelFieldIconLabelFormProps = {
|
type SettingsDataModelFieldIconLabelFormProps = {
|
||||||
fieldMetadataItem?: FieldMetadataItem;
|
fieldMetadataItem?: FieldMetadataItem;
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
canToggleSyncLabelWithName?: boolean;
|
isCreationMode?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SettingsDataModelFieldIconLabelForm = ({
|
export const SettingsDataModelFieldIconLabelForm = ({
|
||||||
canToggleSyncLabelWithName = true,
|
isCreationMode = false,
|
||||||
fieldMetadataItem,
|
fieldMetadataItem,
|
||||||
maxLength,
|
maxLength,
|
||||||
}: SettingsDataModelFieldIconLabelFormProps) => {
|
}: SettingsDataModelFieldIconLabelFormProps) => {
|
||||||
@ -92,6 +93,8 @@ export const SettingsDataModelFieldIconLabelForm = ({
|
|||||||
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const label = watch('label');
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
const labelTextInputId = `${fieldMetadataItem?.id}-label`;
|
const labelTextInputId = `${fieldMetadataItem?.id}-label`;
|
||||||
@ -102,7 +105,6 @@ export const SettingsDataModelFieldIconLabelForm = ({
|
|||||||
(isDefined(fieldMetadataItem)
|
(isDefined(fieldMetadataItem)
|
||||||
? fieldMetadataItem.isLabelSyncedWithName
|
? fieldMetadataItem.isLabelSyncedWithName
|
||||||
: true);
|
: true);
|
||||||
const label = watch('label');
|
|
||||||
|
|
||||||
const apiNameTooltipText = isLabelSyncedWithName
|
const apiNameTooltipText = isLabelSyncedWithName
|
||||||
? t`Deactivate "Synchronize Objects Labels and API Names" to set a custom API name`
|
? t`Deactivate "Synchronize Objects Labels and API Names" to set a custom API name`
|
||||||
@ -115,6 +117,27 @@ export const SettingsDataModelFieldIconLabelForm = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isRelation =
|
||||||
|
fieldMetadataItem?.type === FieldMetadataType.RELATION ||
|
||||||
|
fieldMetadataItem?.type === FieldMetadataType.MORPH_RELATION;
|
||||||
|
|
||||||
|
const isCustomButNotRelationField =
|
||||||
|
fieldMetadataItem?.isCustom === true && !isRelation;
|
||||||
|
|
||||||
|
// TODO: remove the custom RELATION edge case, this will result in canToggleSyncLabelWithName = isCustom
|
||||||
|
const canToggleSyncLabelWithName =
|
||||||
|
!isCreationMode && isCustomButNotRelationField;
|
||||||
|
|
||||||
|
// TODO: remove custom RELATION edge case, this will result in isNameEditEnabled = isCustom
|
||||||
|
const isNameEditEnabled =
|
||||||
|
isLabelSyncedWithName === false && isCustomButNotRelationField;
|
||||||
|
|
||||||
|
// TODO: remove custom RELATION edge case, this will result in isLabelEditEnabled = true
|
||||||
|
const isLabelEditEnabled =
|
||||||
|
isCreationMode ||
|
||||||
|
(!isCreationMode &&
|
||||||
|
(fieldMetadataItem?.isCustom === false || isCustomButNotRelationField));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StyledInputsContainer>
|
<StyledInputsContainer>
|
||||||
@ -124,7 +147,7 @@ export const SettingsDataModelFieldIconLabelForm = ({
|
|||||||
defaultValue={fieldMetadataItem?.icon ?? 'IconUsers'}
|
defaultValue={fieldMetadataItem?.icon ?? 'IconUsers'}
|
||||||
render={({ field: { onChange, value } }) => (
|
render={({ field: { onChange, value } }) => (
|
||||||
<IconPicker
|
<IconPicker
|
||||||
selectedIconKey={value ?? ''}
|
selectedIconKey={value ?? 'IconUsers'}
|
||||||
onChange={({ iconKey }) => onChange(iconKey)}
|
onChange={({ iconKey }) => onChange(iconKey)}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
/>
|
/>
|
||||||
@ -139,19 +162,19 @@ export const SettingsDataModelFieldIconLabelForm = ({
|
|||||||
instanceId={labelTextInputId}
|
instanceId={labelTextInputId}
|
||||||
placeholder={t`Employees`}
|
placeholder={t`Employees`}
|
||||||
value={value}
|
value={value}
|
||||||
|
disabled={!isLabelEditEnabled}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
onChange(value);
|
onChange(value);
|
||||||
trigger('label');
|
trigger('label');
|
||||||
if (isLabelSyncedWithName === true) {
|
if (
|
||||||
|
isCreationMode ||
|
||||||
|
(isLabelSyncedWithName === true &&
|
||||||
|
fieldMetadataItem?.isCustom === true)
|
||||||
|
) {
|
||||||
fillNameFromLabel(value);
|
fillNameFromLabel(value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
error={getErrorMessageFromError(errors.label?.message)}
|
error={getErrorMessageFromError(errors.label?.message)}
|
||||||
disabled={
|
|
||||||
isLabelSyncedWithName === true &&
|
|
||||||
fieldMetadataItem &&
|
|
||||||
!fieldMetadataItem?.isCustom
|
|
||||||
}
|
|
||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
@ -176,10 +199,7 @@ export const SettingsDataModelFieldIconLabelForm = ({
|
|||||||
placeholder={t`employees`}
|
placeholder={t`employees`}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={
|
disabled={!isNameEditEnabled}
|
||||||
(isLabelSyncedWithName ?? false) ||
|
|
||||||
!fieldMetadataItem?.isCustom
|
|
||||||
}
|
|
||||||
fullWidth
|
fullWidth
|
||||||
maxLength={DATABASE_IDENTIFIER_MAXIMUM_LENGTH}
|
maxLength={DATABASE_IDENTIFIER_MAXIMUM_LENGTH}
|
||||||
RightIcon={() =>
|
RightIcon={() =>
|
||||||
@ -228,8 +248,20 @@ export const SettingsDataModelFieldIconLabelForm = ({
|
|||||||
advancedMode
|
advancedMode
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
onChange(value);
|
onChange(value);
|
||||||
if (value === true) {
|
if (!isDefined(fieldMetadataItem)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
fieldMetadataItem.isCustom === true &&
|
||||||
|
!isRelation
|
||||||
|
) {
|
||||||
fillNameFromLabel(label);
|
fillNameFromLabel(label);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -215,10 +215,7 @@ export const SettingsObjectFieldEdit = () => {
|
|||||||
<SettingsDataModelFieldIconLabelForm
|
<SettingsDataModelFieldIconLabelForm
|
||||||
fieldMetadataItem={fieldMetadataItem}
|
fieldMetadataItem={fieldMetadataItem}
|
||||||
maxLength={FIELD_NAME_MAXIMUM_LENGTH}
|
maxLength={FIELD_NAME_MAXIMUM_LENGTH}
|
||||||
canToggleSyncLabelWithName={
|
isCreationMode={false}
|
||||||
fieldMetadataItem.type !== FieldMetadataType.RELATION &&
|
|
||||||
fieldMetadataItem.isCustom === true
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
{
|
{
|
||||||
|
|||||||
@ -221,9 +221,7 @@ export const SettingsObjectNewFieldConfigure = () => {
|
|||||||
/>
|
/>
|
||||||
<SettingsDataModelFieldIconLabelForm
|
<SettingsDataModelFieldIconLabelForm
|
||||||
maxLength={FIELD_NAME_MAXIMUM_LENGTH}
|
maxLength={FIELD_NAME_MAXIMUM_LENGTH}
|
||||||
canToggleSyncLabelWithName={
|
isCreationMode={true}
|
||||||
fieldType !== FieldMetadataType.RELATION
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
<Section>
|
<Section>
|
||||||
|
|||||||
@ -141,33 +141,6 @@ describe('BeforeUpdateOneField', () => {
|
|||||||
).rejects.toThrow(ValidationError);
|
).rejects.toThrow(ValidationError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw ValidationError 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(ValidationError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle isActive updates for standard fields', async () => {
|
it('should handle isActive updates for standard fields', async () => {
|
||||||
const instance: UpdateOneInputType<UpdateFieldInputForTest> = {
|
const instance: UpdateOneInputType<UpdateFieldInputForTest> = {
|
||||||
id: mockFieldId,
|
id: mockFieldId,
|
||||||
|
|||||||
@ -89,18 +89,6 @@ export class BeforeUpdateOneField<T extends UpdateFieldInput>
|
|||||||
!updatableFields.includes(key) && !overridableFields.includes(key),
|
!updatableFields.includes(key) && !overridableFields.includes(key),
|
||||||
);
|
);
|
||||||
|
|
||||||
const isUpdatingLabelWhenSynced =
|
|
||||||
instance.update.label &&
|
|
||||||
fieldMetadata.isLabelSyncedWithName &&
|
|
||||||
instance.update.isLabelSyncedWithName !== false &&
|
|
||||||
instance.update.label !== fieldMetadata.label;
|
|
||||||
|
|
||||||
if (isUpdatingLabelWhenSynced) {
|
|
||||||
throw new ValidationError(
|
|
||||||
'Cannot update label when it is synced with name',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nonUpdatableFields.length > 0) {
|
if (nonUpdatableFields.length > 0) {
|
||||||
throw new ValidationError(
|
throw new ValidationError(
|
||||||
`Only isActive, isLabelSyncedWithName, label, icon, description and defaultValue fields can be updated for standard fields. Invalid fields: ${nonUpdatableFields.join(', ')}`,
|
`Only isActive, isLabelSyncedWithName, label, icon, description and defaultValue fields can be updated for standard fields. Invalid fields: ${nonUpdatableFields.join(', ')}`,
|
||||||
@ -390,13 +378,6 @@ export class BeforeUpdateOneField<T extends UpdateFieldInput>
|
|||||||
update: StandardFieldUpdate,
|
update: StandardFieldUpdate,
|
||||||
locale?: keyof typeof APP_LOCALES,
|
locale?: keyof typeof APP_LOCALES,
|
||||||
): void {
|
): void {
|
||||||
if (
|
|
||||||
fieldMetadata.isLabelSyncedWithName ||
|
|
||||||
update.isLabelSyncedWithName === true
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isDefined(instance.update.label)) {
|
if (!isDefined(instance.update.label)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,10 +9,10 @@ import {
|
|||||||
Max,
|
Max,
|
||||||
Min,
|
Min,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
isDefined,
|
|
||||||
validateOrReject,
|
validateOrReject,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { FieldMetadataType } from 'twenty-shared/types';
|
import { FieldMetadataType } from 'twenty-shared/types';
|
||||||
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
|
||||||
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
|
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
|
||||||
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
|
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
|
||||||
@ -187,5 +187,20 @@ export class FieldMetadataValidationService {
|
|||||||
settings: fieldMetadataInput.settings,
|
settings: fieldMetadataInput.settings,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isRelationField =
|
||||||
|
fieldMetadataType === FieldMetadataType.RELATION ||
|
||||||
|
fieldMetadataType === FieldMetadataType.MORPH_RELATION;
|
||||||
|
|
||||||
|
if (
|
||||||
|
isRelationField &&
|
||||||
|
isDefined(existingFieldMetadata) &&
|
||||||
|
fieldMetadataInput.name !== existingFieldMetadata.name
|
||||||
|
) {
|
||||||
|
throw new FieldMetadataException(
|
||||||
|
'Name cannot be changed for relation fields',
|
||||||
|
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,389 @@
|
|||||||
|
import { i18n } from '@lingui/core';
|
||||||
|
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||||
|
|
||||||
|
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
|
||||||
|
import { resolveOverridableString } from 'src/engine/metadata-modules/field-metadata/utils/resolve-overridable-string.util';
|
||||||
|
|
||||||
|
jest.mock('@lingui/core');
|
||||||
|
jest.mock('src/engine/core-modules/i18n/utils/generateMessageId');
|
||||||
|
|
||||||
|
const mockI18n = i18n as jest.Mocked<typeof i18n>;
|
||||||
|
const mockGenerateMessageId = generateMessageId as jest.MockedFunction<
|
||||||
|
typeof generateMessageId
|
||||||
|
>;
|
||||||
|
|
||||||
|
describe('resolveOverridableString', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Custom fields', () => {
|
||||||
|
it('should return the field value for custom label field', () => {
|
||||||
|
const fieldMetadata = {
|
||||||
|
label: 'Custom Label',
|
||||||
|
description: 'Custom Description',
|
||||||
|
icon: 'custom-icon',
|
||||||
|
isCustom: true,
|
||||||
|
standardOverrides: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = resolveOverridableString(fieldMetadata, 'label', 'fr-FR');
|
||||||
|
|
||||||
|
expect(result).toBe('Custom Label');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the field value for custom description field', () => {
|
||||||
|
const fieldMetadata = {
|
||||||
|
label: 'Custom Label',
|
||||||
|
description: 'Custom Description',
|
||||||
|
icon: 'custom-icon',
|
||||||
|
isCustom: true,
|
||||||
|
standardOverrides: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = resolveOverridableString(
|
||||||
|
fieldMetadata,
|
||||||
|
'description',
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe('Custom Description');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the field value for custom icon field', () => {
|
||||||
|
const fieldMetadata = {
|
||||||
|
label: 'Custom Label',
|
||||||
|
description: 'Custom Description',
|
||||||
|
icon: 'custom-icon',
|
||||||
|
isCustom: true,
|
||||||
|
standardOverrides: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = resolveOverridableString(
|
||||||
|
fieldMetadata,
|
||||||
|
'icon',
|
||||||
|
SOURCE_LOCALE,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe('custom-icon');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Standard fields - Icon overrides', () => {
|
||||||
|
it('should return override icon when available for standard field', () => {
|
||||||
|
const fieldMetadata = {
|
||||||
|
label: 'Standard Label',
|
||||||
|
description: 'Standard Description',
|
||||||
|
icon: 'default-icon',
|
||||||
|
isCustom: false,
|
||||||
|
standardOverrides: {
|
||||||
|
icon: 'override-icon',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = resolveOverridableString(fieldMetadata, 'icon', 'fr-FR');
|
||||||
|
|
||||||
|
expect(result).toBe('override-icon');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Standard fields - Translation overrides', () => {
|
||||||
|
it('should return translation override when available for non-icon fields', () => {
|
||||||
|
const fieldMetadata = {
|
||||||
|
label: 'Standard Label',
|
||||||
|
description: 'Standard Description',
|
||||||
|
icon: 'default-icon',
|
||||||
|
isCustom: false,
|
||||||
|
standardOverrides: {
|
||||||
|
translations: {
|
||||||
|
'fr-FR': {
|
||||||
|
label: 'Libellé traduit',
|
||||||
|
description: 'Description traduite',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(resolveOverridableString(fieldMetadata, 'label', 'fr-FR')).toBe(
|
||||||
|
'Libellé traduit',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
resolveOverridableString(fieldMetadata, 'description', 'fr-FR'),
|
||||||
|
).toBe('Description traduite');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback when translation override is not available for the locale', () => {
|
||||||
|
const fieldMetadata = {
|
||||||
|
label: 'Standard Label',
|
||||||
|
description: 'Standard Description',
|
||||||
|
icon: 'default-icon',
|
||||||
|
isCustom: false,
|
||||||
|
standardOverrides: {
|
||||||
|
translations: {
|
||||||
|
'es-ES': {
|
||||||
|
label: 'Etiqueta en español',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGenerateMessageId.mockReturnValue('generated-message-id');
|
||||||
|
mockI18n._.mockReturnValue('generated-message-id');
|
||||||
|
|
||||||
|
const result = resolveOverridableString(fieldMetadata, 'label', 'fr-FR');
|
||||||
|
|
||||||
|
expect(result).toBe('Standard Label');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback when translation override is not available for the labelKey', () => {
|
||||||
|
const fieldMetadata = {
|
||||||
|
label: 'Standard Label',
|
||||||
|
description: 'Standard Description',
|
||||||
|
icon: 'default-icon',
|
||||||
|
isCustom: false,
|
||||||
|
standardOverrides: {
|
||||||
|
translations: {
|
||||||
|
'fr-FR': {
|
||||||
|
label: 'Libellé traduit',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGenerateMessageId.mockReturnValue('generated-message-id');
|
||||||
|
mockI18n._.mockReturnValue('generated-message-id');
|
||||||
|
|
||||||
|
const result = resolveOverridableString(
|
||||||
|
fieldMetadata,
|
||||||
|
'description',
|
||||||
|
'fr-FR',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe('Standard Description');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not use translation overrides when locale is undefined', () => {
|
||||||
|
const fieldMetadata = {
|
||||||
|
label: 'Standard Label',
|
||||||
|
description: 'Standard Description',
|
||||||
|
icon: 'default-icon',
|
||||||
|
isCustom: false,
|
||||||
|
standardOverrides: {
|
||||||
|
translations: {
|
||||||
|
'fr-FR': {
|
||||||
|
label: 'Libellé traduit',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGenerateMessageId.mockReturnValue('generated-message-id');
|
||||||
|
mockI18n._.mockReturnValue('generated-message-id');
|
||||||
|
|
||||||
|
const result = resolveOverridableString(
|
||||||
|
fieldMetadata,
|
||||||
|
'label',
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe('Standard Label');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Standard fields - SOURCE_LOCALE overrides', () => {
|
||||||
|
it('should return direct override for SOURCE_LOCALE when available', () => {
|
||||||
|
const fieldMetadata = {
|
||||||
|
label: 'Standard Label',
|
||||||
|
description: 'Standard Description',
|
||||||
|
icon: 'default-icon',
|
||||||
|
isCustom: false,
|
||||||
|
standardOverrides: {
|
||||||
|
label: 'Overridden Label',
|
||||||
|
description: 'Overridden Description',
|
||||||
|
icon: 'overridden-icon',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveOverridableString(fieldMetadata, 'label', SOURCE_LOCALE),
|
||||||
|
).toBe('Overridden Label');
|
||||||
|
expect(
|
||||||
|
resolveOverridableString(fieldMetadata, 'description', SOURCE_LOCALE),
|
||||||
|
).toBe('Overridden Description');
|
||||||
|
expect(
|
||||||
|
resolveOverridableString(fieldMetadata, 'icon', SOURCE_LOCALE),
|
||||||
|
).toBe('overridden-icon');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not use direct override for non-SOURCE_LOCALE', () => {
|
||||||
|
const fieldMetadata = {
|
||||||
|
label: 'Standard Label',
|
||||||
|
description: 'Standard Description',
|
||||||
|
icon: 'default-icon',
|
||||||
|
isCustom: false,
|
||||||
|
standardOverrides: {
|
||||||
|
label: 'Overridden Label',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGenerateMessageId.mockReturnValue('generated-message-id');
|
||||||
|
mockI18n._.mockReturnValue('generated-message-id');
|
||||||
|
|
||||||
|
const result = resolveOverridableString(fieldMetadata, 'label', 'fr-FR');
|
||||||
|
|
||||||
|
expect(result).toBe('Standard Label');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not use empty string override for SOURCE_LOCALE', () => {
|
||||||
|
const fieldMetadata = {
|
||||||
|
label: 'Standard Label',
|
||||||
|
description: 'Standard Description',
|
||||||
|
icon: 'default-icon',
|
||||||
|
isCustom: false,
|
||||||
|
standardOverrides: {
|
||||||
|
label: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGenerateMessageId.mockReturnValue('generated-message-id');
|
||||||
|
mockI18n._.mockReturnValue('generated-message-id');
|
||||||
|
|
||||||
|
const result = resolveOverridableString(
|
||||||
|
fieldMetadata,
|
||||||
|
'label',
|
||||||
|
SOURCE_LOCALE,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe('Standard Label');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not use undefined override for SOURCE_LOCALE', () => {
|
||||||
|
const fieldMetadata = {
|
||||||
|
label: 'Standard Label',
|
||||||
|
description: 'Standard Description',
|
||||||
|
icon: 'default-icon',
|
||||||
|
isCustom: false,
|
||||||
|
standardOverrides: {
|
||||||
|
label: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGenerateMessageId.mockReturnValue('generated-message-id');
|
||||||
|
mockI18n._.mockReturnValue('generated-message-id');
|
||||||
|
|
||||||
|
const result = resolveOverridableString(
|
||||||
|
fieldMetadata,
|
||||||
|
'label',
|
||||||
|
SOURCE_LOCALE,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe('Standard Label');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Standard fields - Auto translation fallback', () => {
|
||||||
|
it('should return translated message when translation is available', () => {
|
||||||
|
const fieldMetadata = {
|
||||||
|
label: 'Standard Label',
|
||||||
|
description: 'Standard Description',
|
||||||
|
icon: 'default-icon',
|
||||||
|
isCustom: false,
|
||||||
|
standardOverrides: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGenerateMessageId.mockReturnValue('standard.label.message.id');
|
||||||
|
mockI18n._.mockReturnValue('Libellé traduit automatiquement');
|
||||||
|
|
||||||
|
const result = resolveOverridableString(fieldMetadata, 'label', 'fr-FR');
|
||||||
|
|
||||||
|
expect(mockGenerateMessageId).toHaveBeenCalledWith('Standard Label');
|
||||||
|
expect(mockI18n._).toHaveBeenCalledWith('standard.label.message.id');
|
||||||
|
expect(result).toBe('Libellé traduit automatiquement');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return original field value when no translation is found', () => {
|
||||||
|
const fieldMetadata = {
|
||||||
|
label: 'Standard Label',
|
||||||
|
description: 'Standard Description',
|
||||||
|
icon: 'default-icon',
|
||||||
|
isCustom: false,
|
||||||
|
standardOverrides: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const messageId = 'standard.label.message.id';
|
||||||
|
|
||||||
|
mockGenerateMessageId.mockReturnValue(messageId);
|
||||||
|
mockI18n._.mockReturnValue(messageId);
|
||||||
|
|
||||||
|
const result = resolveOverridableString(fieldMetadata, 'label', 'fr-FR');
|
||||||
|
|
||||||
|
expect(result).toBe('Standard Label');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Priority order - Standard fields', () => {
|
||||||
|
it('should prioritize translation override over SOURCE_LOCALE override for non-SOURCE_LOCALE', () => {
|
||||||
|
const fieldMetadata = {
|
||||||
|
label: 'Standard Label',
|
||||||
|
description: 'Standard Description',
|
||||||
|
icon: 'default-icon',
|
||||||
|
isCustom: false,
|
||||||
|
standardOverrides: {
|
||||||
|
label: 'Source Override',
|
||||||
|
translations: {
|
||||||
|
'fr-FR': {
|
||||||
|
label: 'Translation Override',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = resolveOverridableString(fieldMetadata, 'label', 'fr-FR');
|
||||||
|
|
||||||
|
expect(result).toBe('Translation Override');
|
||||||
|
expect(mockGenerateMessageId).not.toHaveBeenCalled();
|
||||||
|
expect(mockI18n._).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prioritize SOURCE_LOCALE override over auto translation for SOURCE_LOCALE', () => {
|
||||||
|
const fieldMetadata = {
|
||||||
|
label: 'Standard Label',
|
||||||
|
description: 'Standard Description',
|
||||||
|
icon: 'default-icon',
|
||||||
|
isCustom: false,
|
||||||
|
standardOverrides: {
|
||||||
|
label: 'Source Override',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = resolveOverridableString(
|
||||||
|
fieldMetadata,
|
||||||
|
'label',
|
||||||
|
SOURCE_LOCALE,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe('Source Override');
|
||||||
|
expect(mockGenerateMessageId).not.toHaveBeenCalled();
|
||||||
|
expect(mockI18n._).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use auto translation when no overrides are available', () => {
|
||||||
|
const fieldMetadata = {
|
||||||
|
label: 'Standard Label',
|
||||||
|
description: 'Standard Description',
|
||||||
|
icon: 'default-icon',
|
||||||
|
isCustom: false,
|
||||||
|
standardOverrides: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGenerateMessageId.mockReturnValue('auto.translation.id');
|
||||||
|
mockI18n._.mockReturnValue('Auto Translated Label');
|
||||||
|
|
||||||
|
const result = resolveOverridableString(fieldMetadata, 'label', 'de-DE');
|
||||||
|
|
||||||
|
expect(result).toBe('Auto Translated Label');
|
||||||
|
expect(mockGenerateMessageId).toHaveBeenCalledWith('Standard Label');
|
||||||
|
expect(mockI18n._).toHaveBeenCalledWith('auto.translation.id');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { i18n } from '@lingui/core';
|
import { i18n } from '@lingui/core';
|
||||||
import { isDefined } from 'class-validator';
|
import { isNonEmptyString } from '@sniptt/guards';
|
||||||
import { APP_LOCALES } from 'twenty-shared/translations';
|
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 { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
|
||||||
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
|
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
|
||||||
@ -17,12 +18,28 @@ export const resolveOverridableString = (
|
|||||||
return fieldMetadata[labelKey] ?? '';
|
return fieldMetadata[labelKey] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const translationValue =
|
if (labelKey === 'icon' && isDefined(fieldMetadata.standardOverrides?.icon)) {
|
||||||
// @ts-expect-error legacy noImplicitAny
|
return fieldMetadata.standardOverrides.icon;
|
||||||
fieldMetadata.standardOverrides?.translations?.[locale]?.[labelKey];
|
}
|
||||||
|
|
||||||
if (isDefined(translationValue)) {
|
if (
|
||||||
return translationValue;
|
isDefined(fieldMetadata.standardOverrides?.translations) &&
|
||||||
|
isDefined(locale) &&
|
||||||
|
labelKey !== 'icon'
|
||||||
|
) {
|
||||||
|
const translationValue =
|
||||||
|
fieldMetadata.standardOverrides.translations[locale]?.[labelKey];
|
||||||
|
|
||||||
|
if (isDefined(translationValue)) {
|
||||||
|
return translationValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
locale === SOURCE_LOCALE &&
|
||||||
|
isNonEmptyString(fieldMetadata.standardOverrides?.[labelKey])
|
||||||
|
) {
|
||||||
|
return fieldMetadata.standardOverrides[labelKey] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageId = generateMessageId(fieldMetadata[labelKey] ?? '');
|
const messageId = generateMessageId(fieldMetadata[labelKey] ?? '');
|
||||||
|
|||||||
@ -0,0 +1,27 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`Field metadata relation update should fail relation when name is changed 1`] = `
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"extensions": {
|
||||||
|
"code": "BAD_USER_INPUT",
|
||||||
|
"userFriendlyMessage": "An error occurred.",
|
||||||
|
},
|
||||||
|
"message": "Name cannot be changed for relation fields",
|
||||||
|
"name": "UserInputError",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Field metadata relation update should fail relation when name is not in camel case 1`] = `
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"extensions": {
|
||||||
|
"code": "BAD_USER_INPUT",
|
||||||
|
"userFriendlyMessage": "An error occurred.",
|
||||||
|
},
|
||||||
|
"message": "New Name should be in camelCase",
|
||||||
|
"name": "UserInputError",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`;
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
|
||||||
|
import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util';
|
||||||
|
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
|
||||||
|
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
|
||||||
|
import { getMockCreateObjectInput } from 'test/integration/metadata/suites/object-metadata/utils/generate-mock-create-object-metadata-input';
|
||||||
|
import { EachTestingContext } from 'twenty-shared/testing';
|
||||||
|
import { FieldMetadataType } from 'twenty-shared/types';
|
||||||
|
|
||||||
|
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
|
||||||
|
|
||||||
|
type UpdateOneFieldMetadataTestingContext = EachTestingContext<{
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const globalTestContext = {
|
||||||
|
employeeObjectId: '',
|
||||||
|
enterpriseObjectId: '',
|
||||||
|
employerFieldMetadataId: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Field metadata relation update should fail', () => {
|
||||||
|
const failingRelationUpdateTestsUseCase: UpdateOneFieldMetadataTestingContext[] =
|
||||||
|
[
|
||||||
|
{
|
||||||
|
title: 'when name is not in camel case',
|
||||||
|
context: { name: 'New Name' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'when name is changed',
|
||||||
|
context: { name: 'newName' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const {
|
||||||
|
data: {
|
||||||
|
createOneObject: { id: employeeObjectId },
|
||||||
|
},
|
||||||
|
} = await createOneObjectMetadata({
|
||||||
|
input: getMockCreateObjectInput({
|
||||||
|
namePlural: 'employees',
|
||||||
|
nameSingular: 'employee',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: {
|
||||||
|
createOneObject: { id: enterpriseObjectId },
|
||||||
|
},
|
||||||
|
} = await createOneObjectMetadata({
|
||||||
|
input: getMockCreateObjectInput({
|
||||||
|
namePlural: 'enterprises',
|
||||||
|
nameSingular: 'enterprise',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data } = await createOneFieldMetadata({
|
||||||
|
input: {
|
||||||
|
objectMetadataId: employeeObjectId,
|
||||||
|
name: 'employer',
|
||||||
|
label: 'Employer',
|
||||||
|
isLabelSyncedWithName: false,
|
||||||
|
type: FieldMetadataType.RELATION,
|
||||||
|
relationCreationPayload: {
|
||||||
|
targetFieldLabel: 'employees',
|
||||||
|
type: RelationType.MANY_TO_ONE,
|
||||||
|
targetObjectMetadataId: enterpriseObjectId,
|
||||||
|
targetFieldIcon: 'IconBuildingSkyscraper',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
globalTestContext.employeeObjectId = employeeObjectId;
|
||||||
|
globalTestContext.enterpriseObjectId = enterpriseObjectId;
|
||||||
|
globalTestContext.employerFieldMetadataId = data.createOneField.id;
|
||||||
|
|
||||||
|
expect(data).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const objectMetadataId of [
|
||||||
|
globalTestContext.employeeObjectId,
|
||||||
|
globalTestContext.enterpriseObjectId,
|
||||||
|
]) {
|
||||||
|
await deleteOneObjectMetadata({
|
||||||
|
input: {
|
||||||
|
idToDelete: objectMetadataId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(failingRelationUpdateTestsUseCase)(
|
||||||
|
'relation $title',
|
||||||
|
async ({ context }) => {
|
||||||
|
const { errors } = await updateOneFieldMetadata({
|
||||||
|
expectToFail: true,
|
||||||
|
input: {
|
||||||
|
idToUpdate: globalTestContext.employerFieldMetadataId,
|
||||||
|
updatePayload: {
|
||||||
|
name: context.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(errors).toBeDefined();
|
||||||
|
expect(errors).toMatchSnapshot();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -116,78 +116,4 @@ describe('updateOne', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('FieldMetadataService Enum Default Value Validation', () => {
|
|
||||||
let createdObjectMetadataId: string;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const { data: listingObjectMetadata } = await createOneObjectMetadata({
|
|
||||||
input: {
|
|
||||||
labelSingular: LISTING_NAME_SINGULAR,
|
|
||||||
labelPlural: LISTING_NAME_PLURAL,
|
|
||||||
nameSingular: LISTING_NAME_SINGULAR,
|
|
||||||
namePlural: LISTING_NAME_PLURAL,
|
|
||||||
icon: 'IconBuildingSkyscraper',
|
|
||||||
isLabelSyncedWithName: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
createdObjectMetadataId = listingObjectMetadata.createOneObject.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await deleteOneObjectMetadata({
|
|
||||||
input: { idToDelete: createdObjectMetadataId },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw an error if the default value is not in the options', async () => {
|
|
||||||
const { data: createdFieldMetadata } = await createOneFieldMetadata({
|
|
||||||
input: {
|
|
||||||
objectMetadataId: createdObjectMetadataId,
|
|
||||||
type: FieldMetadataType.SELECT,
|
|
||||||
name: 'testName',
|
|
||||||
label: 'Test name',
|
|
||||||
isLabelSyncedWithName: true,
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
label: 'Option 1',
|
|
||||||
value: 'OPTION_1',
|
|
||||||
color: 'green',
|
|
||||||
position: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { errors } = await updateOneFieldMetadata({
|
|
||||||
input: {
|
|
||||||
idToUpdate: createdFieldMetadata.createOneField.id,
|
|
||||||
updatePayload: {
|
|
||||||
defaultValue: "'OPTION_2'",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
gqlFields: `
|
|
||||||
id
|
|
||||||
name
|
|
||||||
label
|
|
||||||
isLabelSyncedWithName
|
|
||||||
`,
|
|
||||||
expectToFail: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(errors).toMatchInlineSnapshot(`
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"extensions": {
|
|
||||||
"code": "BAD_USER_INPUT",
|
|
||||||
"userFriendlyMessage": "Default value "'OPTION_2'" must be one of the option values",
|
|
||||||
},
|
|
||||||
"message": "Default value "'OPTION_2'" must be one of the option values",
|
|
||||||
"name": "UserInputError",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user