diff --git a/packages/twenty-front/src/modules/object-record/record-field-list/record-detail-section/relation/components/RecordDetailMorphRelationSection.tsx b/packages/twenty-front/src/modules/object-record/record-field-list/record-detail-section/relation/components/RecordDetailMorphRelationSection.tsx index a5ec5d2fcd..7b1fa15390 100644 --- a/packages/twenty-front/src/modules/object-record/record-field-list/record-detail-section/relation/components/RecordDetailMorphRelationSection.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field-list/record-detail-section/relation/components/RecordDetailMorphRelationSection.tsx @@ -74,10 +74,12 @@ export const RecordDetailMorphRelationSection = ({ const handleSubmit: FieldInputEvent = ({ newValue }) => { if (!isDefined(newValue)) { - throw new CustomError( - 'Value to persist is required', - 'VALUE_TO_PERSIST_IS_REQUIRED', - ); + persistMorphManyToOne({ + recordId, + fieldDefinition, + valueToPersist: null, + }); + return; } const { id, objectMetadataId } = newValue as { diff --git a/packages/twenty-front/src/modules/object-record/record-field-list/record-detail-section/relation/components/RecordDetailMorphRelationSectionDropdownManyToOne.tsx b/packages/twenty-front/src/modules/object-record/record-field-list/record-detail-section/relation/components/RecordDetailMorphRelationSectionDropdownManyToOne.tsx index 9ef0a2f944..68778c7f0f 100644 --- a/packages/twenty-front/src/modules/object-record/record-field-list/record-detail-section/relation/components/RecordDetailMorphRelationSectionDropdownManyToOne.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field-list/record-detail-section/relation/components/RecordDetailMorphRelationSectionDropdownManyToOne.tsx @@ -91,7 +91,10 @@ export const RecordDetailMorphRelationSectionDropdownManyToOne = ({ ) => { closeDropdown(dropdownId); - if (!selectedMorphItem?.recordId) return; + if (!isDefined(selectedMorphItem)) { + onSubmit?.({ newValue: null }); + return; + } onSubmit?.({ newValue: { diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MorphRelationManyToOneFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MorphRelationManyToOneFieldInput.tsx index c5b6dbb485..f3184da7b2 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MorphRelationManyToOneFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MorphRelationManyToOneFieldInput.tsx @@ -36,7 +36,12 @@ export const MorphRelationManyToOneFieldInput = () => { selectedMorphItem: RecordPickerPickableMorphItem | null | undefined, ) => { if (!isDefined(selectedMorphItem)) { - // Handle detach + await persistMorphManyToOne({ + recordId: recordId, + fieldDefinition, + valueToPersist: null, + }); + onCancel?.(); return; } diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/hooks/__tests__/useMorphPersistManyToOne.test.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/hooks/__tests__/useMorphPersistManyToOne.test.tsx new file mode 100644 index 0000000000..febf26ca47 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/hooks/__tests__/useMorphPersistManyToOne.test.tsx @@ -0,0 +1,198 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { type ReactNode } from 'react'; +import { RecoilRoot } from 'recoil'; + +import { useMorphPersistManyToOne } from '@/object-record/record-field/ui/meta-types/input/hooks/useMorphPersistManyToOne'; +import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition'; +import { type FieldMorphRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata'; +import { FieldMetadataType, RelationType } from 'twenty-shared/types'; + +const mockUpdateOneRecord = jest.fn(); + +jest.mock('@/object-record/hooks/useUpdateOneRecordV2', () => ({ + useUpdateOneRecordV2: () => ({ + updateOneRecord: mockUpdateOneRecord, + }), +})); + +jest.mock('@/object-metadata/hooks/useObjectMetadataItems', () => ({ + useObjectMetadataItems: () => ({ + objectMetadataItems: [ + { + id: 'company-metadata-id', + nameSingular: 'company', + namePlural: 'companies', + }, + { + id: 'person-metadata-id', + nameSingular: 'person', + namePlural: 'people', + }, + ], + }), +})); + +const mockMorphFieldDefinition = { + fieldMetadataId: 'morph-field-id', + label: 'Polymorphic Owner', + iconName: 'IconUser', + type: FieldMetadataType.MORPH_RELATION, + metadata: { + fieldName: 'polymorphicOwner', + relationType: RelationType.MANY_TO_ONE, + objectMetadataNameSingular: 'task', + morphRelations: [ + { + type: RelationType.MANY_TO_ONE, + sourceFieldMetadata: { + id: 'source-field-1', + name: 'polymorphicOwner', + }, + targetFieldMetadata: { + id: 'target-field-1', + name: 'tasks', + isCustom: false, + }, + sourceObjectMetadata: { + id: 'task-metadata-id', + nameSingular: 'task', + namePlural: 'tasks', + }, + targetObjectMetadata: { + id: 'company-metadata-id', + nameSingular: 'company', + namePlural: 'companies', + }, + }, + { + type: RelationType.MANY_TO_ONE, + sourceFieldMetadata: { + id: 'source-field-2', + name: 'polymorphicOwner', + }, + targetFieldMetadata: { + id: 'target-field-2', + name: 'tasks', + isCustom: false, + }, + sourceObjectMetadata: { + id: 'task-metadata-id', + nameSingular: 'task', + namePlural: 'tasks', + }, + targetObjectMetadata: { + id: 'person-metadata-id', + nameSingular: 'person', + namePlural: 'people', + }, + }, + ], + }, +} as FieldDefinition; + +const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe('useMorphPersistManyToOne', () => { + beforeEach(() => { + mockUpdateOneRecord.mockClear(); + }); + + it('should set all morph relation fields to null when detaching (valueToPersist is null)', async () => { + const { result } = renderHook( + () => + useMorphPersistManyToOne({ + objectMetadataNameSingular: 'task', + }), + { + wrapper: Wrapper, + }, + ); + + await act(async () => { + await result.current.persistMorphManyToOne({ + recordId: 'test-record-id', + fieldDefinition: mockMorphFieldDefinition, + valueToPersist: null, + }); + }); + + await waitFor(() => { + expect(mockUpdateOneRecord).toHaveBeenCalledTimes(1); + expect(mockUpdateOneRecord).toHaveBeenCalledWith({ + objectNameSingular: 'task', + idToUpdate: 'test-record-id', + updateOneRecordInput: { + polymorphicOwnerCompanyId: null, + polymorphicOwnerPersonId: null, + }, + }); + }); + }); + + it('should set all morph relation fields to null when detaching (valueToPersist is undefined)', async () => { + const { result } = renderHook( + () => + useMorphPersistManyToOne({ + objectMetadataNameSingular: 'task', + }), + { + wrapper: Wrapper, + }, + ); + + await act(async () => { + await result.current.persistMorphManyToOne({ + recordId: 'test-record-id', + fieldDefinition: mockMorphFieldDefinition, + valueToPersist: undefined, + }); + }); + + await waitFor(() => { + expect(mockUpdateOneRecord).toHaveBeenCalledTimes(1); + expect(mockUpdateOneRecord).toHaveBeenCalledWith({ + objectNameSingular: 'task', + idToUpdate: 'test-record-id', + updateOneRecordInput: { + polymorphicOwnerCompanyId: null, + polymorphicOwnerPersonId: null, + }, + }); + }); + }); + + it('should update the specific relation field when selecting a record', async () => { + const { result } = renderHook( + () => + useMorphPersistManyToOne({ + objectMetadataNameSingular: 'task', + }), + { + wrapper: Wrapper, + }, + ); + + await act(async () => { + await result.current.persistMorphManyToOne({ + recordId: 'test-record-id', + fieldDefinition: mockMorphFieldDefinition, + valueToPersist: 'selected-company-id', + targetObjectMetadataNameSingular: 'company', + }); + }); + + await waitFor(() => { + expect(mockUpdateOneRecord).toHaveBeenCalledTimes(1); + expect(mockUpdateOneRecord).toHaveBeenCalledWith({ + objectNameSingular: 'task', + idToUpdate: 'test-record-id', + updateOneRecordInput: { + polymorphicOwnerCompanyId: 'selected-company-id', + polymorphicOwnerPersonId: null, + }, + }); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/hooks/useMorphPersistManyToOne.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/hooks/useMorphPersistManyToOne.ts index ff9f83aa3a..7f012160f5 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/hooks/useMorphPersistManyToOne.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/hooks/useMorphPersistManyToOne.ts @@ -9,6 +9,7 @@ import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadat import { useUpdateOneRecordV2 } from '@/object-record/hooks/useUpdateOneRecordV2'; import { assertFieldMetadata } from '@/object-record/record-field/ui/types/guards/assertFieldMetadata'; import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation'; +import { buildRecordWithAllMorphObjectIdsToNull } from '@/object-record/record-field/ui/meta-types/input/utils/buildRecordWithAllMorphObjectIdsToNull'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { FieldMetadataType } from 'twenty-shared/types'; import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils'; @@ -35,13 +36,33 @@ export const useMorphPersistManyToOne = ({ recordId: string; fieldDefinition: FieldDefinition; valueToPersist: string | null | undefined; - targetObjectMetadataNameSingular: string; + targetObjectMetadataNameSingular?: string; }) => { assertFieldMetadata( FieldMetadataType.MORPH_RELATION, isFieldMorphRelation, fieldDefinition, ); + + const fieldName = fieldDefinition.metadata.fieldName; + + if (!isDefined(valueToPersist)) { + const recordWithAllMorphObjectIdsToNull = + buildRecordWithAllMorphObjectIdsToNull({ + morphRelations: fieldDefinition.metadata.morphRelations, + fieldName, + relationType: fieldDefinition.metadata.relationType, + }); + + updateOneRecord?.({ + objectNameSingular: objectMetadataNameSingular, + idToUpdate: recordId, + updateOneRecordInput: recordWithAllMorphObjectIdsToNull, + }); + + return; + } + const targetObjectMetadataItem = objectMetadataItems.find( (objectMetadataItem) => objectMetadataItem.nameSingular === @@ -52,13 +73,6 @@ export const useMorphPersistManyToOne = ({ throw new Error('Object metadata item not found'); } - const fieldName = fieldDefinition.metadata.fieldName; - - if (!isDefined(valueToPersist)) { - // Handle detach - return; - } - const computedFieldName = computeMorphRelationFieldName({ fieldName, relationType: fieldDefinition.metadata.relationType, @@ -83,28 +97,18 @@ export const useMorphPersistManyToOne = ({ return; } - const allNullRecordInput: Record = - fieldDefinition.metadata.morphRelations.reduce( - (acc, morphRelation) => { - const computedFieldName = computeMorphRelationFieldName({ - fieldName, - relationType: fieldDefinition.metadata.relationType, - targetObjectMetadataNameSingular: - morphRelation.targetObjectMetadata.nameSingular, - targetObjectMetadataNamePlural: - morphRelation.targetObjectMetadata.namePlural, - }); - acc[`${computedFieldName}Id`] = null; - return acc; - }, - {} as Record, - ); + const recordWithAllMorphObjectIdsToNull = + buildRecordWithAllMorphObjectIdsToNull({ + morphRelations: fieldDefinition.metadata.morphRelations, + fieldName, + relationType: fieldDefinition.metadata.relationType, + }); updateOneRecord?.({ objectNameSingular: objectMetadataNameSingular, idToUpdate: recordId, updateOneRecordInput: { - ...allNullRecordInput, + ...recordWithAllMorphObjectIdsToNull, [`${computedFieldName}Id`]: valueToPersist, }, }); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/utils/buildRecordWithAllMorphObjectIdsToNull.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/utils/buildRecordWithAllMorphObjectIdsToNull.ts new file mode 100644 index 0000000000..7cb590e40e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/utils/buildRecordWithAllMorphObjectIdsToNull.ts @@ -0,0 +1,29 @@ +import { type FieldMetadataItemRelation } from '@/object-metadata/types/FieldMetadataItemRelation'; +import { type RelationType } from 'twenty-shared/types'; +import { computeMorphRelationFieldName } from 'twenty-shared/utils'; + +export const buildRecordWithAllMorphObjectIdsToNull = ({ + morphRelations, + fieldName, + relationType, +}: { + morphRelations: FieldMetadataItemRelation[]; + fieldName: string; + relationType: RelationType; +}): Record => { + return morphRelations.reduce( + (acc, morphRelation) => { + const computedFieldName = computeMorphRelationFieldName({ + fieldName, + relationType, + targetObjectMetadataNameSingular: + morphRelation.targetObjectMetadata.nameSingular, + targetObjectMetadataNamePlural: + morphRelation.targetObjectMetadata.namePlural, + }); + acc[`${computedFieldName}Id`] = null; + return acc; + }, + {} as Record, + ); +};