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.
This commit is contained in:
Jean-Baptiste Ronssin
2025-07-15 10:32:52 +02:00
committed by GitHub
parent 0cb8533e50
commit ff3f3d4661
2 changed files with 96 additions and 0 deletions

View File

@ -195,6 +195,7 @@ export class FieldMetadataValidationService {
if (
isRelationField &&
isDefined(existingFieldMetadata) &&
isDefined(fieldMetadataInput.name) &&
fieldMetadataInput.name !== existingFieldMetadata.name
) {
throw new FieldMetadataException(

View File

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