From 4ee15a60b328bc0263e69d177d2eff03d9f52217 Mon Sep 17 00:00:00 2001 From: Sonarly Claude Code Date: Tue, 3 Mar 2026 03:39:12 +0000 Subject: [PATCH] Morph relation persist uses wrong foreign key naming, producing invalid field parentObjectId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://sonarly.com/issue/8116?type=bug When editing a morph relation field on a task record via the Field Widget, `usePersistField.ts` constructs the wrong foreign key name (`parentObjectId` instead of e.g. `parentObjectCompanyId`), causing the optimistic cache validation to throw. Fix: The morph relation branch in `usePersistField.ts` (lines 220–243, introduced by regression commit `4e767799c6`) used `getForeignKeyNameFromRelationFieldName(fieldName)` to construct the update key, producing `parentObjectId` instead of the required target-specific key like `parentObjectCompanyId`. This caused `computeOptimisticRecordFromInput` to throw because `parentObjectId` doesn't match any known field pattern for morph relations. The fix replaces the broken morph relation branch with the correct approach that mirrors `useMorphPersistManyToOne`: 1. **Extracts `morphRelations` and `relationType`** from the field definition metadata (cast as `FieldMorphRelationMetadata`). 2. **Builds the null-out record** using `buildRecordWithAllMorphObjectIdsToNull` — this correctly zeroes out all morph target IDs (e.g., both `parentObjectCompanyId` and `parentObjectPersonId`) before setting the new one. 3. **For null values**: sends the all-null record to clear the relation. 4. **For non-null values**: finds the matching `morphRelation` by comparing `valueToPersist.__typename` (e.g., `"Company"`) against each `targetObjectMetadata.nameSingular` (e.g., `"company"`), then uses `computeMorphRelationFieldName()` to compute the correct key (e.g., `parentObjectCompany`), and sends `{ ...allNull, parentObjectCompanyId: valueToPersist.id }`. ```typescript file=packages/twenty-front/src/modules/object-record/record-field/ui/hooks/usePersistField.ts lines=222-292 if (fieldIsMorphRelationManyToOne) { if (valueToPersist?.id === currentValue?.id) { return; } const morphFieldDefinition = fieldDefinition as FieldDefinition; const { morphRelations, relationType } = morphFieldDefinition.metadata; const recordWithAllMorphObjectIdsToNull = buildRecordWithAllMorphObjectIdsToNull({ morphRelations, fieldName, relationType, }); if (!valueToPersist) { // null out all morph IDs to clear the relation const newRecord = await updateOneRecord({ ... }); upsertRecordsInStore({ ... }); return; } const targetMorphRelation = morphRelations.find( (morphRelation) => morphRelation.targetObjectMetadata.nameSingular.toLowerCase() === valueToPersist.__typename?.toLowerCase(), ); const computedFieldName = computeMorphRelationFieldName({ fieldName, relationType, targetObjectMetadataNameSingular: targetMorphRelation.targetObjectMetadata.nameSingular, targetObjectMetadataNamePlural: targetMorphRelation.targetObjectMetadata.namePlural, }); // Produces e.g. "parentObjectCompanyId" ✓ instead of "parentObjectId" ✗ const newRecord = await updateOneRecord({ updateOneRecordInput: { ...recordWithAllMorphObjectIdsToNull, [`${computedFieldName}Id`]: valueToPersist.id, }, }); ... } ``` Two additional imports were added: - `computeMorphRelationFieldName` from `twenty-shared/utils` - `buildRecordWithAllMorphObjectIdsToNull` from the local utils path --- .../record-field/ui/hooks/usePersistField.ts | 59 +++++++++++++++++-- 1 file changed, 54 insertions(+), 5 deletions(-) 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 474a2831b04..ac84c515500 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 @@ -53,9 +53,11 @@ import { isFieldRichTextValue } from '@/object-record/record-field/ui/types/guar import { isFieldRichTextV2Value } from '@/object-record/record-field/ui/types/guards/isFieldRichTextValueV2'; import { isFieldText } from '@/object-record/record-field/ui/types/guards/isFieldText'; import { isFieldTextValue } from '@/object-record/record-field/ui/types/guards/isFieldTextValue'; +import { buildRecordWithAllMorphObjectIdsToNull } from '@/object-record/record-field/ui/meta-types/input/utils/buildRecordWithAllMorphObjectIdsToNull'; import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; import { getForeignKeyNameFromRelationFieldName } from '@/object-record/utils/getForeignKeyNameFromRelationFieldName'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; +import { computeMorphRelationFieldName } from 'twenty-shared/utils'; export const usePersistField = ({ objectMetadataItemId, @@ -222,20 +224,67 @@ export const usePersistField = ({ return; } + const morphFieldDefinition = + fieldDefinition as FieldDefinition; + const { morphRelations, relationType } = + morphFieldDefinition.metadata; + + const recordWithAllMorphObjectIdsToNull = + buildRecordWithAllMorphObjectIdsToNull({ + morphRelations, + fieldName, + relationType, + }); + + if (!valueToPersist) { + const newRecord = await updateOneRecord({ + objectNameSingular: objectMetadataItem.nameSingular, + idToUpdate: recordId, + updateOneRecordInput: recordWithAllMorphObjectIdsToNull, + }); + + upsertRecordsInStore({ + partialRecords: [ + getRecordFromRecordNode({ recordNode: newRecord }), + ], + }); + + return; + } + + const targetMorphRelation = morphRelations.find( + (morphRelation) => + morphRelation.targetObjectMetadata.nameSingular.toLowerCase() === + valueToPersist.__typename?.toLowerCase(), + ); + + if (!targetMorphRelation) { + throw new Error( + `Could not find morph relation target for __typename: ${valueToPersist.__typename}`, + ); + } + + const computedFieldName = computeMorphRelationFieldName({ + fieldName, + relationType, + targetObjectMetadataNameSingular: + targetMorphRelation.targetObjectMetadata.nameSingular, + targetObjectMetadataNamePlural: + targetMorphRelation.targetObjectMetadata.namePlural, + }); + const newRecord = await updateOneRecord({ objectNameSingular: objectMetadataItem.nameSingular, idToUpdate: recordId, updateOneRecordInput: { - [getForeignKeyNameFromRelationFieldName(fieldName)]: - valueToPersist?.id ?? null, + ...recordWithAllMorphObjectIdsToNull, + [`${computedFieldName}Id`]: valueToPersist.id, }, }); upsertRecordsInStore({ partialRecords: [ - getRecordFromRecordNode({ - recordNode: newRecord, - }), + getRecordFromRecordNode({ recordNode: newRecord }), ], });