fix: morph relation persist uses wrong foreign key naming, producing invalid field parentObjectId. (#18352)

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.
This commit is contained in:
Abdullah.
2026-03-06 20:16:23 +05:00
committed by GitHub
parent 06451407ce
commit faee5ee63d
3 changed files with 129 additions and 57 deletions
@@ -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<FieldMorphRelationMetadata>
).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<string, true> = {};
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,
@@ -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],
);
@@ -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<string, string | null>;
allMorphForeignKeysNulled: Record<string, null>;
} => {
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<string, string | null> = {
...allMorphForeignKeysNulled,
[`${computedFieldName}Id`]: targetRecordId,
};
return { updateInput, allMorphForeignKeysNulled };
};