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:
+6
-4
@@ -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 {
|
||||
|
||||
+4
-1
@@ -91,7 +91,10 @@ export const RecordDetailMorphRelationSectionDropdownManyToOne = ({
|
||||
) => {
|
||||
closeDropdown(dropdownId);
|
||||
|
||||
if (!selectedMorphItem?.recordId) return;
|
||||
if (!isDefined(selectedMorphItem)) {
|
||||
onSubmit?.({ newValue: null });
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit?.({
|
||||
newValue: {
|
||||
|
||||
+6
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+198
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+29
-25
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
+29
@@ -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>,
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user