diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-field-metadata-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-field-metadata-validator.service.ts index d952d1a3d8..507d4096fb 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-field-metadata-validator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-field-metadata-validator.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { msg } from '@lingui/core/macro'; import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; +import { isFieldMetadataTypeWithDefaultValue } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; @@ -191,6 +192,7 @@ export class FlatFieldMetadataValidatorService { } if ( + isFieldMetadataTypeWithDefaultValue(flatFieldMetadataToValidate.type) && flatFieldMetadataToValidate.isNullable === false && flatFieldMetadataToValidate.defaultValue === null ) { 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 index 0c8dadb85a..d5f7dcabab 100644 --- 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 @@ -142,6 +142,56 @@ describe('Field metadata relation update should succeed', () => { expect(data).toBeDefined(); expect(data.updateOneField.name).toBe('leadEmployer'); }); + + // Regression test for https://github.com/twentyhq/twenty/issues/21751: a non-nullable relation has no literal defaultValue and must still be updatable. + it('should successfully update a non-nullable relation field', async () => { + const { + data: { + createOneField: { id: nonNullableRelationFieldId, isNullable }, + }, + } = await createOneFieldMetadata({ + input: { + objectMetadataId: globalTestContext.employeeObjectId, + name: 'mandatoryEmployer', + label: 'Mandatory employer', + isLabelSyncedWithName: false, + isNullable: false, + type: FieldMetadataType.RELATION, + relationCreationPayload: { + targetFieldLabel: 'mandatoryEmployees', + type: RelationType.MANY_TO_ONE, + targetObjectMetadataId: globalTestContext.enterpriseObjectId, + targetFieldIcon: 'IconBuildingSkyscraper', + }, + }, + gqlFields: ` + id + isNullable + `, + }); + + expect(isNullable).toBe(false); + + const { data, errors } = await updateOneFieldMetadata({ + expectToFail: false, + input: { + idToUpdate: nonNullableRelationFieldId, + updatePayload: { + description: 'Updated description for a required relation', + }, + }, + gqlFields: ` + id + description + `, + }); + + expect(errors).toBeUndefined(); + expect(data).toBeDefined(); + expect(data.updateOneField.description).toBe( + 'Updated description for a required relation', + ); + }); }); describe('Field metadata self-relation update should succeed', () => { diff --git a/packages/twenty-shared/src/types/FieldMetadataDefaultValue.ts b/packages/twenty-shared/src/types/FieldMetadataDefaultValue.ts index e72bea9536..bfede7273e 100644 --- a/packages/twenty-shared/src/types/FieldMetadataDefaultValue.ts +++ b/packages/twenty-shared/src/types/FieldMetadataDefaultValue.ts @@ -1,5 +1,5 @@ import { type LinkMetadata } from '@/types/composite-types/links.composite-type'; -import { type FieldMetadataType } from '@/types/FieldMetadataType'; +import { FieldMetadataType } from '@/types/FieldMetadataType'; import { type IsExactly } from '@/types/IsExactly'; export const fieldMetadataDefaultValueFunctionName = { @@ -111,3 +111,17 @@ export type FieldMetadataDefaultValue< : T extends keyof FieldMetadataDefaultValueMapping ? FieldMetadataDefaultValueMapping[T] : never | null; + +export const FIELD_METADATA_TYPES_WITHOUT_DEFAULT_VALUE = [ + FieldMetadataType.RELATION, + FieldMetadataType.MORPH_RELATION, + FieldMetadataType.FILES, + FieldMetadataType.TS_VECTOR, +] as const satisfies FieldMetadataType[]; + +export const isFieldMetadataTypeWithDefaultValue = ( + type: FieldMetadataType, +): boolean => + !( + FIELD_METADATA_TYPES_WITHOUT_DEFAULT_VALUE as readonly FieldMetadataType[] + ).includes(type); diff --git a/packages/twenty-shared/src/types/__tests__/field-metadata-default-value-classification.type-test.ts b/packages/twenty-shared/src/types/__tests__/field-metadata-default-value-classification.type-test.ts new file mode 100644 index 0000000000..789f1a54fe --- /dev/null +++ b/packages/twenty-shared/src/types/__tests__/field-metadata-default-value-classification.type-test.ts @@ -0,0 +1,19 @@ +import { type Equal, type Expect } from '@/testing'; +import { + type FieldMetadataDefaultValueMapping, + type FIELD_METADATA_TYPES_WITHOUT_DEFAULT_VALUE, +} from '@/types/FieldMetadataDefaultValue'; +import { type FieldMetadataType } from '@/types/FieldMetadataType'; + +type ClassifiedFieldMetadataType = + | keyof FieldMetadataDefaultValueMapping + | (typeof FIELD_METADATA_TYPES_WITHOUT_DEFAULT_VALUE)[number]; + +type Unclassified = Exclude; +type ExtraClassified = Exclude; + +// oxlint-disable-next-line unused-imports/no-unused-vars +type Assertions = [ + Expect>, + Expect>, +]; diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts index cd1e3ab8a9..e855a4c4c1 100644 --- a/packages/twenty-shared/src/types/index.ts +++ b/packages/twenty-shared/src/types/index.ts @@ -92,7 +92,11 @@ export type { FieldMetadataDefaultValueForAnyType, FieldMetadataDefaultValue, } from './FieldMetadataDefaultValue'; -export { fieldMetadataDefaultValueFunctionName } from './FieldMetadataDefaultValue'; +export { + fieldMetadataDefaultValueFunctionName, + FIELD_METADATA_TYPES_WITHOUT_DEFAULT_VALUE, + isFieldMetadataTypeWithDefaultValue, +} from './FieldMetadataDefaultValue'; export type { FieldMetadataMultiItemSettings } from './FieldMetadataMultiItemSettings'; export { FieldMetadataSettingsOnClickAction } from './FieldMetadataMultiItemSettings'; export type {