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:
+39
-10
@@ -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,
|
||||
|
||||
+29
-47
@@ -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],
|
||||
);
|
||||
|
||||
+61
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user