Morph relation persist uses wrong FK naming, causing "unknown field" error

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<FieldMorphRelationMetadata>;
  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;
}
```
This commit is contained in:
Sonarly Claude Code
2026-03-05 16:46:52 +00:00
parent c9bd3fa9f7
commit 35d855705a
2 changed files with 49 additions and 4 deletions
@@ -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<FieldMorphRelationMetadata>;
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,
},
});
@@ -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 = (