From faee5ee63d5bceea3e52aa329c47250245e0a330 Mon Sep 17 00:00:00 2001 From: "Abdullah." <125115953+mabdullahabaid@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:16:23 +0500 Subject: [PATCH] fix: morph relation persist uses wrong foreign key naming, producing invalid field parentObjectId. (#18352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solves [Sonarly Issue 8116](https://sonarly.com/issue/8116). ### Problem Editing a morph relation field (e.g. "Parent Object" on Task) via the field widget was broken in two ways: 1. **Setting a value** sent the wrong foreign key name (`parentObjectId` instead of target-specific keys like `parentObjectCompanyId`), causing the relation to not save. 2. **Detaching** never sent a request at all — the early return check `valueToPersist?.id === currentValue?.id` evaluated to `undefined === undefined` when the morph field wasn't loaded in the store, silently skipping the update. The record detail section worked fine because it uses a separate hook (`useMorphPersistManyToOne`). ### Fix Added proper morph relation handling in `usePersistField` so all persistence goes through this single hook consistently: - Compute the correct FK name using `computeMorphRelationFieldName` (e.g. `parentObjectCompanyId`) instead of deriving it from the field name directly. - Null all morph FK columns before setting the target one, ensuring only one FK is non-null at a time (consistent with `useMorphPersistManyToOne`). - Fix the early return to only skip when **setting** a value that matches the current one — detach always proceeds. - Derive `currentRelationId` via a type guard instead of an `as` cast. --- .../record-field/ui/hooks/usePersistField.ts | 49 +++++++++--- .../input/hooks/useMorphPersistManyToOne.ts | 76 +++++++------------ .../utils/buildMorphRelationUpdateInput.ts | 61 +++++++++++++++ 3 files changed, 129 insertions(+), 57 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/utils/buildMorphRelationUpdateInput.ts diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/usePersistField.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/usePersistField.ts index 474a2831b0..29d3130e24 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/usePersistField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/usePersistField.ts @@ -28,18 +28,20 @@ import { isFieldSelectValue } from '@/object-record/record-field/ui/types/guards import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; import { getRecordFromRecordNode } from '@/object-record/cache/utils/getRecordFromRecordNode'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { buildMorphRelationUpdateInput } from '@/object-record/record-field/ui/meta-types/input/utils/buildMorphRelationUpdateInput'; import { isFieldArray } from '@/object-record/record-field/ui/types/guards/isFieldArray'; import { isFieldArrayValue } from '@/object-record/record-field/ui/types/guards/isFieldArrayValue'; -import { isFieldFiles } from '@/object-record/record-field/ui/types/guards/isFieldFiles'; -import { isFieldFilesValue } from '@/object-record/record-field/ui/types/guards/isFieldFilesValue'; import { isFieldBoolean } from '@/object-record/record-field/ui/types/guards/isFieldBoolean'; import { isFieldBooleanValue } from '@/object-record/record-field/ui/types/guards/isFieldBooleanValue'; import { isFieldCurrency } from '@/object-record/record-field/ui/types/guards/isFieldCurrency'; import { isFieldCurrencyValue } from '@/object-record/record-field/ui/types/guards/isFieldCurrencyValue'; import { isFieldDateTime } from '@/object-record/record-field/ui/types/guards/isFieldDateTime'; import { isFieldDateTimeValue } from '@/object-record/record-field/ui/types/guards/isFieldDateTimeValue'; +import { isFieldFiles } from '@/object-record/record-field/ui/types/guards/isFieldFiles'; +import { isFieldFilesValue } from '@/object-record/record-field/ui/types/guards/isFieldFilesValue'; import { isFieldMorphRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationManyToOne'; import { isFieldNumber } from '@/object-record/record-field/ui/types/guards/isFieldNumber'; import { isFieldNumberValue } from '@/object-record/record-field/ui/types/guards/isFieldNumberValue'; @@ -55,6 +57,7 @@ import { isFieldText } from '@/object-record/record-field/ui/types/guards/isFiel import { isFieldTextValue } from '@/object-record/record-field/ui/types/guards/isFieldTextValue'; import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; import { getForeignKeyNameFromRelationFieldName } from '@/object-record/utils/getForeignKeyNameFromRelationFieldName'; +import { isDefined } from 'twenty-shared/utils'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; export const usePersistField = ({ @@ -66,6 +69,8 @@ export const usePersistField = ({ objectId: objectMetadataItemId, }); + const { objectMetadataItems } = useObjectMetadataItems(); + const { updateOneRecord } = useUpdateOneRecord(); const store = useStore(); @@ -218,25 +223,48 @@ export const usePersistField = ({ } if (fieldIsMorphRelationManyToOne) { - if (valueToPersist?.id === currentValue?.id) { + if ( + isDefined(valueToPersist) && + valueToPersist.id === currentValue?.id + ) { return; } + const morphMetadata = ( + fieldDefinition as FieldDefinition + ).metadata; + + const { updateInput, allMorphForeignKeysNulled } = + buildMorphRelationUpdateInput({ + morphRelations: morphMetadata.morphRelations, + fieldName, + relationType: morphMetadata.relationType, + objectMetadataItems, + targetRecordId: valueToPersist?.id, + targetObjectMetadataId: valueToPersist?.objectMetadataId, + }); + const newRecord = await updateOneRecord({ objectNameSingular: objectMetadataItem.nameSingular, idToUpdate: recordId, - updateOneRecordInput: { - [getForeignKeyNameFromRelationFieldName(fieldName)]: - valueToPersist?.id ?? null, - }, + updateOneRecordInput: updateInput, }); + const morphForeignKeyGqlFields: Record = {}; + + for (const key of Object.keys(allMorphForeignKeysNulled)) { + morphForeignKeyGqlFields[key] = true; + } + upsertRecordsInStore({ partialRecords: [ - getRecordFromRecordNode({ - recordNode: newRecord, - }), + { + ...getRecordFromRecordNode({ recordNode: newRecord }), + ...allMorphForeignKeysNulled, + ...updateInput, + }, ], + recordGqlFields: morphForeignKeyGqlFields, }); return; @@ -270,6 +298,7 @@ export const usePersistField = ({ }, [ objectMetadataItem?.nameSingular, + objectMetadataItems, store, updateOneRecord, upsertRecordsInStore, 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 ba5edb3f5d..fb46abdba2 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 @@ -8,12 +8,12 @@ import { recordStoreFamilySelector } from '@/object-record/record-store/states/s import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { buildMorphRelationUpdateInput } from '@/object-record/record-field/ui/meta-types/input/utils/buildMorphRelationUpdateInput'; 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 { isFieldRelationManyToOneValue } from '@/object-record/record-field/ui/types/guards/isFieldRelationManyToOneValue'; import { FieldMetadataType } from 'twenty-shared/types'; -import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils'; +import { isDefined } from 'twenty-shared/utils'; export type MorphPersistManyToOneProps = { objectMetadataNameSingular: string; @@ -47,70 +47,52 @@ export const useMorphPersistManyToOne = ({ const fieldName = fieldDefinition.metadata.fieldName; - if (!isDefined(valueToPersist)) { - const recordWithAllMorphObjectIdsToNull = - buildRecordWithAllMorphObjectIdsToNull({ - morphRelations: fieldDefinition.metadata.morphRelations, - fieldName, - relationType: fieldDefinition.metadata.relationType, - }); + const targetObjectMetadataItem = isDefined( + targetObjectMetadataNameSingular, + ) + ? objectMetadataItems.find( + (objectMetadataItem) => + objectMetadataItem.nameSingular === + targetObjectMetadataNameSingular, + ) + : undefined; - updateOneRecord?.({ - objectNameSingular: objectMetadataNameSingular, - idToUpdate: recordId, - updateOneRecordInput: recordWithAllMorphObjectIdsToNull, - }); - - return; - } - - const targetObjectMetadataItem = objectMetadataItems.find( - (objectMetadataItem) => - objectMetadataItem.nameSingular === targetObjectMetadataNameSingular, - ); - - if (!isDefined(targetObjectMetadataItem)) { + if ( + isDefined(targetObjectMetadataNameSingular) && + !isDefined(targetObjectMetadataItem) + ) { throw new Error('Object metadata item not found'); } - const computedFieldName = computeMorphRelationFieldName({ - fieldName, - relationType: fieldDefinition.metadata.relationType, - targetObjectMetadataNameSingular: targetObjectMetadataItem.nameSingular, - targetObjectMetadataNamePlural: targetObjectMetadataItem.namePlural, - }); - - const currentValue: unknown = store.get( + const currentValue = store.get( recordStoreFamilySelector.selectorFamily({ recordId, - fieldName: computedFieldName, + fieldName, }), ); if ( + isFieldRelationManyToOneValue(currentValue) && isDefined(currentValue) && - (currentValue as ObjectRecord).id === valueToPersist + currentValue.id === valueToPersist ) { return; } - const recordWithAllMorphObjectIdsToNull = - buildRecordWithAllMorphObjectIdsToNull({ - morphRelations: fieldDefinition.metadata.morphRelations, - fieldName, - relationType: fieldDefinition.metadata.relationType, - }); + const { updateInput } = buildMorphRelationUpdateInput({ + morphRelations: fieldDefinition.metadata.morphRelations, + fieldName, + relationType: fieldDefinition.metadata.relationType, + objectMetadataItems, + targetRecordId: valueToPersist ?? undefined, + targetObjectMetadataId: targetObjectMetadataItem?.id, + }); updateOneRecord({ objectNameSingular: objectMetadataNameSingular, idToUpdate: recordId, - updateOneRecordInput: { - ...recordWithAllMorphObjectIdsToNull, - [`${computedFieldName}Id`]: valueToPersist, - }, + updateOneRecordInput: updateInput, }); - - return; }, [objectMetadataItems, objectMetadataNameSingular, store, updateOneRecord], ); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/utils/buildMorphRelationUpdateInput.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/utils/buildMorphRelationUpdateInput.ts new file mode 100644 index 0000000000..88c7d51a6b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/utils/buildMorphRelationUpdateInput.ts @@ -0,0 +1,61 @@ +import { type FieldMetadataItemRelation } from '@/object-metadata/types/FieldMetadataItemRelation'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { buildRecordWithAllMorphObjectIdsToNull } from '@/object-record/record-field/ui/meta-types/input/utils/buildRecordWithAllMorphObjectIdsToNull'; +import { type RelationType } from 'twenty-shared/types'; +import { computeMorphRelationFieldName } from 'twenty-shared/utils'; + +export const buildMorphRelationUpdateInput = ({ + morphRelations, + fieldName, + relationType, + objectMetadataItems, + targetRecordId, + targetObjectMetadataId, +}: { + morphRelations: FieldMetadataItemRelation[]; + fieldName: string; + relationType: RelationType; + objectMetadataItems: ObjectMetadataItem[]; + targetRecordId?: string; + targetObjectMetadataId?: string; +}): { + updateInput: Record; + allMorphForeignKeysNulled: Record; +} => { + const allMorphForeignKeysNulled = buildRecordWithAllMorphObjectIdsToNull({ + morphRelations, + fieldName, + relationType, + }); + + if (!targetRecordId || !targetObjectMetadataId) { + return { + updateInput: { ...allMorphForeignKeysNulled }, + allMorphForeignKeysNulled, + }; + } + + const targetObjectMetadataItem = objectMetadataItems.find( + (objectMetadataItem) => objectMetadataItem.id === targetObjectMetadataId, + ); + + if (!targetObjectMetadataItem) { + throw new Error( + `Target object metadata item not found for id ${targetObjectMetadataId}`, + ); + } + + const computedFieldName = computeMorphRelationFieldName({ + fieldName, + relationType, + targetObjectMetadataNameSingular: targetObjectMetadataItem.nameSingular, + targetObjectMetadataNamePlural: targetObjectMetadataItem.namePlural, + }); + + const updateInput: Record = { + ...allMorphForeignKeysNulled, + [`${computedFieldName}Id`]: targetRecordId, + }; + + return { updateInput, allMorphForeignKeysNulled }; +};