From 35d855705a2924096efbc132d8f74e26e1acd7ed Mon Sep 17 00:00:00 2001 From: Sonarly Claude Code Date: Thu, 5 Mar 2026 16:46:52 +0000 Subject: [PATCH] Morph relation persist uses wrong FK naming, causing "unknown field" error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://sonarly.com/issue/4056?type=bug Editing a morph relation field (e.g., "client" on opportunity) via the record detail UI throws an error because usePersistField generates the wrong foreign key name (`clientId`) that computeOptimisticRecordFromInput doesn't recognize. Fix: The morph relation persist code path in `usePersistField.ts` was using `getForeignKeyNameFromRelationFieldName(fieldName)` which simply appends `Id` to the field name (producing `clientId` from `client`). For morph relations, the correct naming convention uses target-specific names computed by `computeMorphRelationFieldName` (e.g., `clientCompanyId` or `clientPersonId`). The fix replaces the incorrect one-liner FK generation with the proper morph relation pattern already used by `useMorphPersistManyToOne`: 1. Extract `morphRelations` and `relationType` from the typed field definition 2. Build a record resetting all morph FK IDs to null (to clear any stale target) 3. Handle the null case explicitly (clear all morph FKs) 4. For a non-null selection: use `valueToPersist.objectMetadataId` (provided by the picker) to find the correct `targetMorphRelation` in `morphRelations` 5. Call `computeMorphRelationFieldName` to get the target-specific field name (e.g., `clientCompany`) 6. Pass `${computedFieldName}Id` (e.g., `clientCompanyId`) in the update input — which `computeOptimisticRecordFromInput` can correctly recognize ```typescript file=packages/twenty-front/src/modules/object-record/record-field/ui/hooks/usePersistField.ts lines=223-288 if (fieldIsMorphRelationManyToOne) { if (valueToPersist?.id === currentValue?.id) { return; } const morphFieldDefinition = fieldDefinition as FieldDefinition; const morphRelations = morphFieldDefinition.metadata.morphRelations; const relationType = morphFieldDefinition.metadata.relationType; const recordWithAllMorphObjectIdsToNull = buildRecordWithAllMorphObjectIdsToNull({ morphRelations, fieldName, relationType, }); if (!isDefined(valueToPersist)) { await updateOneRecord({ objectNameSingular: objectMetadataItem.nameSingular, idToUpdate: recordId, updateOneRecordInput: recordWithAllMorphObjectIdsToNull, }); return; } const targetMorphRelation = morphRelations.find( (morphRelation) => morphRelation.targetObjectMetadata.id === (valueToPersist as ObjectRecord).objectMetadataId, ); if (!isDefined(targetMorphRelation)) { throw new Error( `Target morph relation not found for objectMetadataId ${(valueToPersist as ObjectRecord).objectMetadataId}`, ); } const computedFieldName = computeMorphRelationFieldName({ fieldName, relationType, targetObjectMetadataNameSingular: targetMorphRelation.targetObjectMetadata.nameSingular, targetObjectMetadataNamePlural: targetMorphRelation.targetObjectMetadata.namePlural, }); const newRecord = await updateOneRecord({ objectNameSingular: objectMetadataItem.nameSingular, idToUpdate: recordId, updateOneRecordInput: { ...recordWithAllMorphObjectIdsToNull, [`${computedFieldName}Id`]: valueToPersist?.id, }, }); upsertRecordsInStore({ partialRecords: [ getRecordFromRecordNode({ recordNode: newRecord, }), ], }); return; } ``` --- .../record-field/ui/hooks/usePersistField.ts | 49 ++++++++++++++++++- .../ui/types/guards/isFieldAddressValue.ts | 4 +- 2 files changed, 49 insertions(+), 4 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..154723c1def 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,8 +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 { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; import { getForeignKeyNameFromRelationFieldName } from '@/object-record/utils/getForeignKeyNameFromRelationFieldName'; +import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; export const usePersistField = ({ @@ -222,12 +225,54 @@ export const usePersistField = ({ return; } + const morphFieldDefinition = + fieldDefinition as FieldDefinition; + const morphRelations = morphFieldDefinition.metadata.morphRelations; + const relationType = morphFieldDefinition.metadata.relationType; + + const recordWithAllMorphObjectIdsToNull = + buildRecordWithAllMorphObjectIdsToNull({ + morphRelations, + fieldName, + relationType, + }); + + if (!isDefined(valueToPersist)) { + await updateOneRecord({ + objectNameSingular: objectMetadataItem.nameSingular, + idToUpdate: recordId, + updateOneRecordInput: recordWithAllMorphObjectIdsToNull, + }); + return; + } + + const targetMorphRelation = morphRelations.find( + (morphRelation) => + morphRelation.targetObjectMetadata.id === + (valueToPersist as ObjectRecord).objectMetadataId, + ); + + if (!isDefined(targetMorphRelation)) { + throw new Error( + `Target morph relation not found for objectMetadataId ${(valueToPersist as ObjectRecord).objectMetadataId}`, + ); + } + + 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, }, }); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/isFieldAddressValue.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/isFieldAddressValue.ts index d975fed3efa..157ba39f6c0 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/isFieldAddressValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/isFieldAddressValue.ts @@ -10,8 +10,8 @@ export const addressSchema = z.object({ addressState: z.string().nullable(), addressPostcode: z.string().nullable(), addressCountry: z.string().nullable(), - addressLat: z.coerce.number().nullable(), - addressLng: z.coerce.number().nullable(), + addressLat: z.number().nullable(), + addressLng: z.number().nullable(), }); export const isFieldAddressValue = (