diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldIconLabelForm.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldIconLabelForm.tsx index 8086cbc2a..99cff8f30 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldIconLabelForm.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldIconLabelForm.tsx @@ -13,6 +13,7 @@ import { IconPicker } from '@/ui/input/components/IconPicker'; import { TextInput } from '@/ui/input/components/TextInput'; import { useTheme } from '@emotion/react'; import { useLingui } from '@lingui/react/macro'; +import { FieldMetadataType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { AppTooltip, @@ -74,11 +75,11 @@ const StyledAdvancedSettingsContainer = styled.div` type SettingsDataModelFieldIconLabelFormProps = { fieldMetadataItem?: FieldMetadataItem; maxLength?: number; - canToggleSyncLabelWithName?: boolean; + isCreationMode?: boolean; }; export const SettingsDataModelFieldIconLabelForm = ({ - canToggleSyncLabelWithName = true, + isCreationMode = false, fieldMetadataItem, maxLength, }: SettingsDataModelFieldIconLabelFormProps) => { @@ -92,6 +93,8 @@ export const SettingsDataModelFieldIconLabelForm = ({ const theme = useTheme(); + const label = watch('label'); + const { t } = useLingui(); const labelTextInputId = `${fieldMetadataItem?.id}-label`; @@ -102,7 +105,6 @@ export const SettingsDataModelFieldIconLabelForm = ({ (isDefined(fieldMetadataItem) ? fieldMetadataItem.isLabelSyncedWithName : true); - const label = watch('label'); const apiNameTooltipText = isLabelSyncedWithName ? 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 ( <> @@ -124,7 +147,7 @@ export const SettingsDataModelFieldIconLabelForm = ({ defaultValue={fieldMetadataItem?.icon ?? 'IconUsers'} render={({ field: { onChange, value } }) => ( onChange(iconKey)} variant="primary" /> @@ -139,19 +162,19 @@ export const SettingsDataModelFieldIconLabelForm = ({ instanceId={labelTextInputId} placeholder={t`Employees`} value={value} + disabled={!isLabelEditEnabled} onChange={(value) => { onChange(value); trigger('label'); - if (isLabelSyncedWithName === true) { + if ( + isCreationMode || + (isLabelSyncedWithName === true && + fieldMetadataItem?.isCustom === true) + ) { fillNameFromLabel(value); } }} error={getErrorMessageFromError(errors.label?.message)} - disabled={ - isLabelSyncedWithName === true && - fieldMetadataItem && - !fieldMetadataItem?.isCustom - } maxLength={maxLength} fullWidth /> @@ -176,10 +199,7 @@ export const SettingsDataModelFieldIconLabelForm = ({ placeholder={t`employees`} value={value} onChange={onChange} - disabled={ - (isLabelSyncedWithName ?? false) || - !fieldMetadataItem?.isCustom - } + disabled={!isNameEditEnabled} fullWidth maxLength={DATABASE_IDENTIFIER_MAXIMUM_LENGTH} RightIcon={() => @@ -228,8 +248,20 @@ export const SettingsDataModelFieldIconLabelForm = ({ advancedMode onChange={(value) => { onChange(value); - if (value === true) { + if (!isDefined(fieldMetadataItem)) { + return; + } + + if (value === false) { + return; + } + + if ( + fieldMetadataItem.isCustom === true && + !isRelation + ) { fillNameFromLabel(label); + return; } }} /> diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx index f5e61c5be..a6438fb4b 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx @@ -215,10 +215,7 @@ export const SettingsObjectFieldEdit = () => { { diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldConfigure.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldConfigure.tsx index 5b31e6dc4..9748f0a2a 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldConfigure.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldConfigure.tsx @@ -221,9 +221,7 @@ export const SettingsObjectNewFieldConfigure = () => { />
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/hooks/__tests__/before-update-one-field.hook.spec.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/hooks/__tests__/before-update-one-field.hook.spec.ts index 3486ae424..602e8dacf 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/hooks/__tests__/before-update-one-field.hook.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/hooks/__tests__/before-update-one-field.hook.spec.ts @@ -141,33 +141,6 @@ describe('BeforeUpdateOneField', () => { ).rejects.toThrow(ValidationError); }); - it('should throw ValidationError when trying to update label when it is synced with name', async () => { - const instance: UpdateOneInputType = { - id: mockFieldId, - update: { - label: 'New Label', - }, - }; - - const mockField: Partial = { - id: mockFieldId, - isCustom: false, - isLabelSyncedWithName: true, - label: 'Old Label', - }; - - jest - .spyOn(fieldMetadataService, 'findOneWithinWorkspace') - .mockResolvedValue(mockField as FieldMetadataEntity); - - await expect( - hook.run(instance as UpdateOneInputType, { - workspaceId: mockWorkspaceId, - locale: undefined, - }), - ).rejects.toThrow(ValidationError); - }); - it('should handle isActive updates for standard fields', async () => { const instance: UpdateOneInputType = { id: mockFieldId, diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook.ts index 31b9278cc..f25374350 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook.ts @@ -89,18 +89,6 @@ export class BeforeUpdateOneField !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) { throw new ValidationError( `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 update: StandardFieldUpdate, locale?: keyof typeof APP_LOCALES, ): void { - if ( - fieldMetadata.isLabelSyncedWithName || - update.isLabelSyncedWithName === true - ) { - return; - } - if (!isDefined(instance.update.label)) { return; } diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service.ts index 3a11328ed..95f475c13 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service.ts @@ -9,10 +9,10 @@ import { Max, Min, ValidationError, - isDefined, validateOrReject, } from 'class-validator'; 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 { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; @@ -187,5 +187,20 @@ export class FieldMetadataValidationService { 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, + ); + } } } diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/resolve-overridable-string.spec.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/resolve-overridable-string.spec.ts new file mode 100644 index 000000000..f84ee1661 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/resolve-overridable-string.spec.ts @@ -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; +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'); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/resolve-overridable-string.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/resolve-overridable-string.util.ts index 5803aa6a2..45f026a98 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/resolve-overridable-string.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/resolve-overridable-string.util.ts @@ -1,6 +1,7 @@ import { i18n } from '@lingui/core'; -import { isDefined } from 'class-validator'; -import { APP_LOCALES } from 'twenty-shared/translations'; +import { isNonEmptyString } from '@sniptt/guards'; +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 { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; @@ -17,12 +18,28 @@ export const resolveOverridableString = ( return fieldMetadata[labelKey] ?? ''; } - const translationValue = - // @ts-expect-error legacy noImplicitAny - fieldMetadata.standardOverrides?.translations?.[locale]?.[labelKey]; + if (labelKey === 'icon' && isDefined(fieldMetadata.standardOverrides?.icon)) { + return fieldMetadata.standardOverrides.icon; + } - if (isDefined(translationValue)) { - return translationValue; + if ( + 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] ?? ''); diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-field-metadata-relation-creation.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/failing-field-metadata-relation-creation.integration-spec.ts.snap similarity index 100% rename from packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-field-metadata-relation-creation.integration-spec.ts.snap rename to packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/failing-field-metadata-relation-creation.integration-spec.ts.snap diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/failing-field-metadata-relation-update.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/failing-field-metadata-relation-update.integration-spec.ts.snap new file mode 100644 index 000000000..762d785e7 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/failing-field-metadata-relation-update.integration-spec.ts.snap @@ -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", + }, +] +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/failing-field-metadata-relation-creation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/failing-field-metadata-relation-creation.integration-spec.ts similarity index 100% rename from packages/twenty-server/test/integration/metadata/suites/object-metadata/failing-field-metadata-relation-creation.integration-spec.ts rename to packages/twenty-server/test/integration/metadata/suites/field-metadata/failing-field-metadata-relation-creation.integration-spec.ts diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/failing-field-metadata-relation-update.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/failing-field-metadata-relation-update.integration-spec.ts new file mode 100644 index 000000000..239766607 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/failing-field-metadata-relation-update.integration-spec.ts @@ -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(); + }, + ); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata.integration-spec.ts index ed5ee282c..34ae3b08d 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata.integration-spec.ts @@ -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", - }, -] -`); - }); - }); });