From ff3f3d46613f5f18ff42a0e03ac99b9f61b5797d Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Ronssin <65334819+jbronssin@users.noreply.github.com> Date: Tue, 15 Jul 2025 10:32:52 +0200 Subject: [PATCH] fix(api): Allow deactivation of relation fields (#13202) _(AI generated)_ ### Summary This PR fixes a validation bug in the GraphQL API that prevented relation fields from being programmatically deactivated. The validation was incorrectly triggering a "name cannot be changed" error even when the update payload did not include a name, making it impossible to disable the field. Issue #13200 ### Problem - The [updateOneField] mutation failed when trying to set `isActive: false` on a `RELATION` field. - The root cause was a validation check in [FieldMetadataValidationService] that compared the incoming `name` with the existing one. If the input `name` was `undefined`, the check `undefined !== existingName` would incorrectly fail. - This created a catch-22 where a field could not be deleted (because it had to be deactivated first) and could not be deactivated (due to this validation error). ### Solution - The validation logic in [field-metadata-validation.service.ts] has been updated to only check for a name change if a new name is **explicitly provided** in the input (`isDefined(fieldMetadataInput.name)`). - This change correctly enforces the rule that relation field names cannot be changed, while allowing other properties like `isActive` to be updated without issue. ### How to Test 1. Create a custom field of type `RELATION`. 2. Using the GraphQL API, call the [updateOneField] mutation with the field's ID and the payload `{ "isActive": false }`. 3. Verify that the mutation succeeds and the field is now inactive. 4. Call the [deleteOneField] mutation to delete the field. 5. Verify that the deletion is successful. ### Additional Changes _to be deleted if not necessary_ - Added a new integration test [successful-field-metadata-relation-update.integration-spec.ts] to cover this specific use case and prevent future regressions. The existing test for failing updates remains untouched and continues to pass. --- .../field-metadata-validation.service.ts | 1 + ...tadata-relation-update.integration-spec.ts | 95 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/successful-field-metadata-relation-update.integration-spec.ts 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 95f475c13..6745c52cc 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 @@ -195,6 +195,7 @@ export class FieldMetadataValidationService { if ( isRelationField && isDefined(existingFieldMetadata) && + isDefined(fieldMetadataInput.name) && fieldMetadataInput.name !== existingFieldMetadata.name ) { throw new FieldMetadataException( diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/successful-field-metadata-relation-update.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/successful-field-metadata-relation-update.integration-spec.ts new file mode 100644 index 000000000..cf128b0fc --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/successful-field-metadata-relation-update.integration-spec.ts @@ -0,0 +1,95 @@ +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 { FieldMetadataType } from 'twenty-shared/types'; + +import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; + +const globalTestContext = { + employeeObjectId: '', + enterpriseObjectId: '', + employerFieldMetadataId: '', +}; + +describe('Field metadata relation update should succeed', () => { + 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('when isActive is updated to false', async () => { + const { data, errors } = await updateOneFieldMetadata({ + expectToFail: false, + input: { + idToUpdate: globalTestContext.employerFieldMetadataId, + updatePayload: { + isActive: false, + }, + }, + gqlFields: ` + id + isActive + `, + }); + + expect(errors).toBeUndefined(); + expect(data).toBeDefined(); + expect(data.updateOneField.isActive).toBe(false); + }); +});