Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code 4ee15a60b3 Morph relation persist uses wrong foreign key naming, producing invalid field parentObjectId
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<FieldMorphRelationMetadata>;
  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
2026-03-03 03:39:12 +00:00
@@ -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<FieldMorphRelationMetadata>;
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 }),
],
});