fix: implement polymorphic relation detach when selecting 'No {Field}' (#17264)

Fixes #17253

When clicking 'No {Field}' in a polymorphic relation picker, the
relation was not being cleared. This commit implements the missing
detach logic:

- RecordDetailMorphRelationSectionDropdownManyToOne: Call onSubmit with
newValue: null when no item is selected
- MorphRelationManyToOneFieldInput: Call persistMorphManyToOne with
valueToPersist: null for detach case
- useMorphPersistManyToOne: Implement actual detach logic that sets all
morph relation ID fields to null via updateOneRecord

Also adds unit tests for the useMorphPersistManyToOne hook.
This commit is contained in:
bitloi
2026-01-21 10:45:14 -05:00
committed by GitHub
parent 26a70c23e9
commit a4f5197e45
6 changed files with 272 additions and 31 deletions
@@ -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 {
@@ -91,7 +91,10 @@ export const RecordDetailMorphRelationSectionDropdownManyToOne = ({
) => {
closeDropdown(dropdownId);
if (!selectedMorphItem?.recordId) return;
if (!isDefined(selectedMorphItem)) {
onSubmit?.({ newValue: null });
return;
}
onSubmit?.({
newValue: {
@@ -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;
}
@@ -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<FieldMorphRelationMetadata>;
const Wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>{children}</RecoilRoot>
);
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,
},
});
});
});
});
@@ -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<FieldMetadata>;
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<string, null> =
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<string, null>,
);
const recordWithAllMorphObjectIdsToNull =
buildRecordWithAllMorphObjectIdsToNull({
morphRelations: fieldDefinition.metadata.morphRelations,
fieldName,
relationType: fieldDefinition.metadata.relationType,
});
updateOneRecord?.({
objectNameSingular: objectMetadataNameSingular,
idToUpdate: recordId,
updateOneRecordInput: {
...allNullRecordInput,
...recordWithAllMorphObjectIdsToNull,
[`${computedFieldName}Id`]: valueToPersist,
},
});
@@ -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<string, null> => {
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<string, null>,
);
};