[BUG] Handle optimistic cache deletion operation (#9914)

# Introduction
This PR is highly related to previous optimistic cache refactor:
https://github.com/twentyhq/twenty/pull/9881
Here we've added some logic within the
`triggerUpdateRelationsOptimisticEffect` which will now run if given
recordInput `deletedAt` field is defined.

If deletion, we will iterate over all the fields searching for
`RELATION` for which deletion might implies necessity to detach the
relation

## Known troubleshooting ( also on main )

![image](https://github.com/user-attachments/assets/10ad59cd-e87b-4f26-89df-0a028fcfa518)
We might have to refactor the `prefillRecord` to spread and
overrides`inputValue` over defaultOne as inputValue could be a partial
one for more info please a look to

# Conclusion
Any suggestions are welcomed !
fixes https://github.com/twentyhq/twenty/issues/9580

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Paul Rastoin
2025-01-30 11:12:37 +01:00
committed by GitHub
parent e7da9b6b87
commit 9635fe9222
15 changed files with 177 additions and 140 deletions

View File

@ -1,3 +1,4 @@
import { DeleteManyRecordsProps } from '@/object-record/hooks/useDeleteManyRecords';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { renderHook, waitFor } from '@testing-library/react'; import { renderHook, waitFor } from '@testing-library/react';
import { act } from 'react'; import { act } from 'react';
@ -75,13 +76,12 @@ describe('useDeleteMultipleRecordsAction', () => {
result.current.ConfirmationModal?.props?.onConfirmClick(); result.current.ConfirmationModal?.props?.onConfirmClick();
}); });
const expectedParams: DeleteManyRecordsProps = {
recordIdsToDelete: [peopleMock[0].id, peopleMock[1].id],
};
await waitFor(() => { await waitFor(() => {
expect(resetTableRowSelectionMock).toHaveBeenCalled(); expect(resetTableRowSelectionMock).toHaveBeenCalled();
expect(deleteManyRecordsMock).toHaveBeenCalledWith(expectedParams);
expect(deleteManyRecordsMock).toHaveBeenCalledWith([
peopleMock[0].id,
peopleMock[1].id,
]);
}); });
}); });
}); });

View File

@ -73,7 +73,9 @@ export const useDeleteMultipleRecordsAction: ActionHookWithObjectMetadataItem =
resetTableRowSelection(); resetTableRowSelection();
await deleteManyRecords(recordIdsToDelete); await deleteManyRecords({
recordIdsToDelete,
});
}, [deleteManyRecords, fetchAllRecordIds, resetTableRowSelection]); }, [deleteManyRecords, fetchAllRecordIds, resetTableRowSelection]);
const isRemoteObject = objectMetadataItem.isRemote; const isRemoteObject = objectMetadataItem.isRemote;

View File

@ -4,11 +4,13 @@ import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useDestroyOneRecord } from '@/object-record/hooks/useDestroyOneRecord'; import { useDestroyOneRecord } from '@/object-record/hooks/useDestroyOneRecord';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { AppPath } from '@/types/AppPath';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { useCallback, useContext, useState } from 'react'; import { useCallback, useContext, useState } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui'; import { isDefined } from 'twenty-ui';
import { useNavigateApp } from '~/hooks/useNavigateApp';
export const useDestroySingleRecordAction: ActionHookWithObjectMetadataItem = ({ export const useDestroySingleRecordAction: ActionHookWithObjectMetadataItem = ({
objectMetadataItem, objectMetadataItem,
@ -18,6 +20,8 @@ export const useDestroySingleRecordAction: ActionHookWithObjectMetadataItem = ({
const [isDestroyRecordsModalOpen, setIsDestroyRecordsModalOpen] = const [isDestroyRecordsModalOpen, setIsDestroyRecordsModalOpen] =
useState(false); useState(false);
const navigateApp = useNavigateApp();
const { resetTableRowSelection } = useRecordTable({ const { resetTableRowSelection } = useRecordTable({
recordTableId: objectMetadataItem.namePlural, recordTableId: objectMetadataItem.namePlural,
}); });
@ -34,7 +38,16 @@ export const useDestroySingleRecordAction: ActionHookWithObjectMetadataItem = ({
resetTableRowSelection(); resetTableRowSelection();
await destroyOneRecord(recordId); await destroyOneRecord(recordId);
}, [resetTableRowSelection, destroyOneRecord, recordId]); navigateApp(AppPath.RecordIndexPage, {
objectNamePlural: objectMetadataItem.namePlural,
});
}, [
resetTableRowSelection,
destroyOneRecord,
recordId,
navigateApp,
objectMetadataItem.namePlural,
]);
const isRemoteObject = objectMetadataItem.isRemote; const isRemoteObject = objectMetadataItem.isRemote;

View File

@ -92,28 +92,26 @@ export const useOpenCreateActivityDrawer = ({
const targetableObjectRelationIdName = `${targetableObjects[0].targetObjectNameSingular}Id`; const targetableObjectRelationIdName = `${targetableObjects[0].targetObjectNameSingular}Id`;
await createOneActivityTarget({ await createOneActivityTarget({
taskId: ...(activityObjectNameSingular === CoreObjectNameSingular.Task
activityObjectNameSingular === CoreObjectNameSingular.Task ? {
? activity.id taskId: activity.id,
: undefined, }
noteId: : {
activityObjectNameSingular === CoreObjectNameSingular.Note noteId: activity.id,
? activity.id }),
: undefined,
[targetableObjectRelationIdName]: targetableObjects[0].id, [targetableObjectRelationIdName]: targetableObjects[0].id,
}); });
setActivityTargetableEntityArray(targetableObjects); setActivityTargetableEntityArray(targetableObjects);
} else { } else {
await createOneActivityTarget({ await createOneActivityTarget({
taskId: ...(activityObjectNameSingular === CoreObjectNameSingular.Task
activityObjectNameSingular === CoreObjectNameSingular.Task ? {
? activity.id taskId: activity.id,
: undefined, }
noteId: : {
activityObjectNameSingular === CoreObjectNameSingular.Note noteId: activity.id,
? activity.id }),
: undefined,
}); });
setActivityTargetableEntityArray([]); setActivityTargetableEntityArray([]);

View File

@ -15,8 +15,8 @@ import { getJoinObjectNameSingular } from '@/activities/utils/getJoinObjectNameS
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateManyRecordsInCache } from '@/object-record/cache/hooks/useCreateManyRecordsInCache'; import { useCreateManyRecordsInCache } from '@/object-record/cache/hooks/useCreateManyRecordsInCache';
import { useCreateManyRecords } from '@/object-record/hooks/useCreateManyRecords'; import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords'; import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { activityTargetObjectRecordFamilyState } from '@/object-record/record-field/states/activityTargetObjectRecordFamilyState'; import { activityTargetObjectRecordFamilyState } from '@/object-record/record-field/states/activityTargetObjectRecordFamilyState';
import { objectRecordMultiSelectCheckedRecordsIdsComponentState } from '@/object-record/record-field/states/objectRecordMultiSelectCheckedRecordsIdsComponentState'; import { objectRecordMultiSelectCheckedRecordsIdsComponentState } from '@/object-record/record-field/states/objectRecordMultiSelectCheckedRecordsIdsComponentState';
import { import {
@ -54,17 +54,15 @@ export const ActivityTargetInlineCellEditMode = ({
}), }),
); );
const { createManyRecords: createManyActivityTargets } = useCreateManyRecords< const { createOneRecord: createOneActivityTarget } = useCreateOneRecord<
NoteTarget | TaskTarget NoteTarget | TaskTarget
>({ >({
objectNameSingular: getJoinObjectNameSingular(activityObjectNameSingular), objectNameSingular: getJoinObjectNameSingular(activityObjectNameSingular),
}); });
const { deleteManyRecords: deleteManyActivityTargets } = useDeleteManyRecords( const { deleteOneRecord: deleteOneActivityTarget } = useDeleteOneRecord({
{ objectNameSingular: getJoinObjectNameSingular(activityObjectNameSingular),
objectNameSingular: getJoinObjectNameSingular(activityObjectNameSingular), });
},
);
const { closeInlineCell: closeEditableField } = useInlineCell(); const { closeInlineCell: closeEditableField } = useInlineCell();
@ -168,36 +166,21 @@ export const ActivityTargetInlineCellEditMode = ({
); );
const newActivityTargetId = v4(); const newActivityTargetId = v4();
const fieldName = record.objectMetadataItem.nameSingular;
const fieldNameWithIdSuffix = getActivityTargetObjectFieldIdName({ const fieldNameWithIdSuffix = getActivityTargetObjectFieldIdName({
nameSingular: record.objectMetadataItem.nameSingular, nameSingular: record.objectMetadataItem.nameSingular,
}); });
const newActivityTargetInput = {
id: newActivityTargetId,
...(activityObjectNameSingular === CoreObjectNameSingular.Task
? { taskId: activity.id }
: { noteId: activity.id }),
[fieldNameWithIdSuffix]: recordId,
};
const newActivityTarget = prefillRecord<NoteTarget | TaskTarget>({ const newActivityTarget = prefillRecord<NoteTarget | TaskTarget>({
objectMetadataItem: objectMetadataItemActivityTarget, objectMetadataItem: objectMetadataItemActivityTarget,
input: { input: newActivityTargetInput,
id: newActivityTargetId,
taskId:
activityObjectNameSingular === CoreObjectNameSingular.Task
? activity.id
: null,
task:
activityObjectNameSingular === CoreObjectNameSingular.Task
? activity
: null,
noteId:
activityObjectNameSingular === CoreObjectNameSingular.Note
? activity.id
: null,
note:
activityObjectNameSingular === CoreObjectNameSingular.Note
? activity
: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
[fieldName]: record.record,
[fieldNameWithIdSuffix]: recordId,
},
}); });
activityTargetsAfterUpdate.push(newActivityTarget); activityTargetsAfterUpdate.push(newActivityTarget);
@ -215,7 +198,7 @@ export const ActivityTargetInlineCellEditMode = ({
}, },
}); });
} else { } else {
await createManyActivityTargets([newActivityTarget]); await createOneActivityTarget(newActivityTargetInput);
} }
set(activityTargetObjectRecordFamilyState(recordId), { set(activityTargetObjectRecordFamilyState(recordId), {
@ -252,7 +235,7 @@ export const ActivityTargetInlineCellEditMode = ({
}, },
}); });
} else { } else {
await deleteManyActivityTargets([activityTargetToDeleteId]); await deleteOneActivityTarget(activityTargetToDeleteId);
} }
set(activityTargetObjectRecordFamilyState(recordId), { set(activityTargetObjectRecordFamilyState(recordId), {
@ -263,9 +246,9 @@ export const ActivityTargetInlineCellEditMode = ({
[ [
activity, activity,
activityTargetWithTargetRecords, activityTargetWithTargetRecords,
createManyActivityTargets, createOneActivityTarget,
createManyActivityTargetsInCache, createManyActivityTargetsInCache,
deleteManyActivityTargets, deleteOneActivityTarget,
isActivityInCreateMode, isActivityInCreateMode,
objectMetadataItemActivityTarget, objectMetadataItemActivityTarget,
recordPickerInstanceId, recordPickerInstanceId,

View File

@ -27,6 +27,10 @@ export const triggerUpdateRelationsOptimisticEffect = ({
updatedSourceRecord, updatedSourceRecord,
objectMetadataItems, objectMetadataItems,
}: triggerUpdateRelationsOptimisticEffectArgs) => { }: triggerUpdateRelationsOptimisticEffectArgs) => {
const isDeletion =
isDefined(updatedSourceRecord) &&
isDefined(updatedSourceRecord['deletedAt']);
return sourceObjectMetadataItem.fields.forEach( return sourceObjectMetadataItem.fields.forEach(
(fieldMetadataItemOnSourceRecord) => { (fieldMetadataItemOnSourceRecord) => {
const notARelationField = const notARelationField =
@ -72,15 +76,15 @@ export const triggerUpdateRelationsOptimisticEffect = ({
| RecordGqlNode | RecordGqlNode
| null = updatedSourceRecord?.[fieldMetadataItemOnSourceRecord.name]; | null = updatedSourceRecord?.[fieldMetadataItemOnSourceRecord.name];
if ( const noDiff = isDeeplyEqual(
isDeeplyEqual( currentFieldValueOnSourceRecord,
currentFieldValueOnSourceRecord, updatedFieldValueOnSourceRecord,
updatedFieldValueOnSourceRecord, { strict: true },
{ strict: true }, );
) if (noDiff && !isDeletion) {
) {
return; return;
} }
const extractTargetRecordsFromRelation = ( const extractTargetRecordsFromRelation = (
value: RecordGqlConnection | RecordGqlNode | null, value: RecordGqlConnection | RecordGqlNode | null,
): RecordGqlNode[] => { ): RecordGqlNode[] => {
@ -96,11 +100,12 @@ export const triggerUpdateRelationsOptimisticEffect = ({
return [value]; return [value];
}; };
const recordToExtractDetachFrom = isDeletion
? updatedFieldValueOnSourceRecord
: currentFieldValueOnSourceRecord;
const targetRecordsToDetachFrom = extractTargetRecordsFromRelation( const targetRecordsToDetachFrom = extractTargetRecordsFromRelation(
currentFieldValueOnSourceRecord, recordToExtractDetachFrom,
);
const targetRecordsToAttachTo = extractTargetRecordsFromRelation(
updatedFieldValueOnSourceRecord,
); );
// TODO: see if we can de-hardcode this, put cascade delete in relation metadata item // TODO: see if we can de-hardcode this, put cascade delete in relation metadata item
@ -129,7 +134,11 @@ export const triggerUpdateRelationsOptimisticEffect = ({
}); });
} }
if (isDefined(updatedSourceRecord)) { if (!isDeletion && isDefined(updatedSourceRecord)) {
const targetRecordsToAttachTo = extractTargetRecordsFromRelation(
updatedFieldValueOnSourceRecord,
);
targetRecordsToAttachTo.forEach((targetRecordToAttachTo) => targetRecordsToAttachTo.forEach((targetRecordToAttachTo) =>
triggerAttachRelationOptimisticEffect({ triggerAttachRelationOptimisticEffect({
cache, cache,

View File

@ -22,7 +22,6 @@ export const useCreateFavoriteFolder = () => {
); );
await createFavoriteFolder({ await createFavoriteFolder({
workspaceMemberId: currentWorkspaceMemberId,
name, name,
position: maxPosition + 1, position: maxPosition + 1,
}); });

View File

@ -52,7 +52,9 @@ describe('useDeleteManyRecords', () => {
); );
await act(async () => { await act(async () => {
const res = await result.current.deleteManyRecords(personIds); const res = await result.current.deleteManyRecords({
recordIdsToDelete: personIds,
});
expect(res).toBeDefined(); expect(res).toBeDefined();
expect(res[0]).toHaveProperty('id'); expect(res[0]).toHaveProperty('id');
}); });

View File

@ -135,7 +135,7 @@ export const useCreateManyRecords = <
update: (cache, { data }) => { update: (cache, { data }) => {
const records = data?.[mutationResponseField]; const records = data?.[mutationResponseField];
if (!records?.length || skipPostOptmisticEffect) return; if (!isDefined(records?.length) || skipPostOptmisticEffect) return;
triggerCreateRecordsOptimisticEffect({ triggerCreateRecordsOptimisticEffect({
cache, cache,

View File

@ -24,7 +24,8 @@ type useDeleteManyRecordProps = {
refetchFindManyQuery?: boolean; refetchFindManyQuery?: boolean;
}; };
type DeleteManyRecordsOptions = { export type DeleteManyRecordsProps = {
recordIdsToDelete: string[];
skipOptimisticEffect?: boolean; skipOptimisticEffect?: boolean;
delayInMsBetweenRequests?: number; delayInMsBetweenRequests?: number;
}; };
@ -61,16 +62,18 @@ export const useDeleteManyRecords = ({
objectMetadataItem.namePlural, objectMetadataItem.namePlural,
); );
const deleteManyRecords = async ( const deleteManyRecords = async ({
idsToDelete: string[], recordIdsToDelete,
options?: DeleteManyRecordsOptions, delayInMsBetweenRequests,
) => { skipOptimisticEffect = false,
const numberOfBatches = Math.ceil(idsToDelete.length / mutationPageSize); }: DeleteManyRecordsProps) => {
const numberOfBatches = Math.ceil(
recordIdsToDelete.length / mutationPageSize,
);
const deletedRecords = []; const deletedRecords = [];
for (let batchIndex = 0; batchIndex < numberOfBatches; batchIndex++) { for (let batchIndex = 0; batchIndex < numberOfBatches; batchIndex++) {
const batchedIdsToDelete = idsToDelete.slice( const batchedIdsToDelete = recordIdsToDelete.slice(
batchIndex * mutationPageSize, batchIndex * mutationPageSize,
(batchIndex + 1) * mutationPageSize, (batchIndex + 1) * mutationPageSize,
); );
@ -81,22 +84,21 @@ export const useDeleteManyRecords = ({
.map((idToDelete) => getRecordFromCache(idToDelete, apolloClient.cache)) .map((idToDelete) => getRecordFromCache(idToDelete, apolloClient.cache))
.filter(isDefined); .filter(isDefined);
const cachedRecordsWithConnection: RecordGqlNode[] = []; if (!skipOptimisticEffect) {
const optimisticRecordsWithConnection: RecordGqlNode[] = []; const cachedRecordsNode: RecordGqlNode[] = [];
const computedOptimisticRecordsNode: RecordGqlNode[] = [];
if (!options?.skipOptimisticEffect) {
cachedRecords.forEach((cachedRecord) => { cachedRecords.forEach((cachedRecord) => {
if (!cachedRecord || !cachedRecord.id) { if (!isDefined(cachedRecord) || !isDefined(cachedRecord.id)) {
return; return;
} }
const cachedRecordWithConnection = const cachedRecordNode = getRecordNodeFromRecord<ObjectRecord>({
getRecordNodeFromRecord<ObjectRecord>({ record: cachedRecord,
record: cachedRecord, objectMetadataItem,
objectMetadataItem, objectMetadataItems,
objectMetadataItems, computeReferences: false,
computeReferences: true, });
});
const computedOptimisticRecord = { const computedOptimisticRecord = {
...cachedRecord, ...cachedRecord,
@ -104,34 +106,36 @@ export const useDeleteManyRecords = ({
...{ __typename: capitalize(objectMetadataItem.nameSingular) }, ...{ __typename: capitalize(objectMetadataItem.nameSingular) },
}; };
const optimisticRecordWithConnection = const optimisticRecordNode = getRecordNodeFromRecord<ObjectRecord>({
getRecordNodeFromRecord<ObjectRecord>({ record: computedOptimisticRecord,
record: computedOptimisticRecord, objectMetadataItem,
objectMetadataItem, objectMetadataItems,
objectMetadataItems, computeReferences: false,
computeReferences: true, });
});
if (!optimisticRecordWithConnection || !cachedRecordWithConnection) { if (
return null; !isDefined(optimisticRecordNode) ||
!isDefined(cachedRecordNode)
) {
return;
} }
cachedRecordsWithConnection.push(cachedRecordWithConnection);
optimisticRecordsWithConnection.push(optimisticRecordWithConnection);
updateRecordFromCache({ updateRecordFromCache({
objectMetadataItems, objectMetadataItems,
objectMetadataItem, objectMetadataItem,
cache: apolloClient.cache, cache: apolloClient.cache,
record: computedOptimisticRecord, record: computedOptimisticRecord,
}); });
computedOptimisticRecordsNode.push(optimisticRecordNode);
cachedRecordsNode.push(cachedRecordNode);
}); });
triggerUpdateRecordOptimisticEffectByBatch({ triggerUpdateRecordOptimisticEffectByBatch({
cache: apolloClient.cache, cache: apolloClient.cache,
objectMetadataItem, objectMetadataItem,
currentRecords: cachedRecordsWithConnection, currentRecords: cachedRecordsNode,
updatedRecords: optimisticRecordsWithConnection, updatedRecords: computedOptimisticRecordsNode,
objectMetadataItems, objectMetadataItems,
}); });
} }
@ -144,8 +148,8 @@ export const useDeleteManyRecords = ({
}, },
}) })
.catch((error: Error) => { .catch((error: Error) => {
const cachedRecordsWithConnection: RecordGqlNode[] = []; const cachedRecordsNode: RecordGqlNode[] = [];
const optimisticRecordsWithConnection: RecordGqlNode[] = []; const computedOptimisticRecordsNode: RecordGqlNode[] = [];
cachedRecords.forEach((cachedRecord) => { cachedRecords.forEach((cachedRecord) => {
if (isUndefinedOrNull(cachedRecord?.id)) { if (isUndefinedOrNull(cachedRecord?.id)) {
@ -164,7 +168,7 @@ export const useDeleteManyRecords = ({
record: cachedRecord, record: cachedRecord,
objectMetadataItem, objectMetadataItem,
objectMetadataItems, objectMetadataItems,
computeReferences: true, computeReferences: false,
}); });
const computedOptimisticRecord = { const computedOptimisticRecord = {
@ -178,27 +182,25 @@ export const useDeleteManyRecords = ({
record: computedOptimisticRecord, record: computedOptimisticRecord,
objectMetadataItem, objectMetadataItem,
objectMetadataItems, objectMetadataItems,
computeReferences: true, computeReferences: false,
}); });
if ( if (
!optimisticRecordWithConnection || !isDefined(optimisticRecordWithConnection) ||
!cachedRecordWithConnection !isDefined(cachedRecordWithConnection)
) { ) {
return; return;
} }
cachedRecordsWithConnection.push(cachedRecordWithConnection); cachedRecordsNode.push(cachedRecordWithConnection);
optimisticRecordsWithConnection.push( computedOptimisticRecordsNode.push(optimisticRecordWithConnection);
optimisticRecordWithConnection,
);
}); });
triggerUpdateRecordOptimisticEffectByBatch({ triggerUpdateRecordOptimisticEffectByBatch({
cache: apolloClient.cache, cache: apolloClient.cache,
objectMetadataItem, objectMetadataItem,
currentRecords: optimisticRecordsWithConnection, currentRecords: computedOptimisticRecordsNode,
updatedRecords: cachedRecordsWithConnection, updatedRecords: cachedRecordsNode,
objectMetadataItems, objectMetadataItems,
}); });
@ -210,8 +212,8 @@ export const useDeleteManyRecords = ({
deletedRecords.push(...deletedRecordsForThisBatch); deletedRecords.push(...deletedRecordsForThisBatch);
if (isDefined(options?.delayInMsBetweenRequests)) { if (isDefined(delayInMsBetweenRequests)) {
await sleep(options.delayInMsBetweenRequests); await sleep(delayInMsBetweenRequests);
} }
} }
await refetchAggregateQueries(); await refetchAggregateQueries();

View File

@ -12,6 +12,7 @@ import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggr
import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getDeleteOneRecordMutationResponseField } from '@/object-record/utils/getDeleteOneRecordMutationResponseField'; import { getDeleteOneRecordMutationResponseField } from '@/object-record/utils/getDeleteOneRecordMutationResponseField';
import { capitalize } from 'twenty-shared'; import { capitalize } from 'twenty-shared';
import { isDefined } from 'twenty-ui';
type useDeleteOneRecordProps = { type useDeleteOneRecordProps = {
objectNameSingular: string; objectNameSingular: string;
@ -49,11 +50,11 @@ export const useDeleteOneRecord = ({
const cachedRecord = getRecordFromCache(idToDelete, apolloClient.cache); const cachedRecord = getRecordFromCache(idToDelete, apolloClient.cache);
const cachedRecordWithConnection = getRecordNodeFromRecord<ObjectRecord>({ const cachedRecordNode = getRecordNodeFromRecord<ObjectRecord>({
record: cachedRecord, record: cachedRecord,
objectMetadataItem, objectMetadataItem,
objectMetadataItems, objectMetadataItems,
computeReferences: true, computeReferences: false,
}); });
const computedOptimisticRecord = { const computedOptimisticRecord = {
@ -62,15 +63,14 @@ export const useDeleteOneRecord = ({
...{ __typename: capitalize(objectMetadataItem.nameSingular) }, ...{ __typename: capitalize(objectMetadataItem.nameSingular) },
}; };
const optimisticRecordWithConnection = const optimisticRecordNode = getRecordNodeFromRecord<ObjectRecord>({
getRecordNodeFromRecord<ObjectRecord>({ record: computedOptimisticRecord,
record: computedOptimisticRecord, objectMetadataItem,
objectMetadataItem, objectMetadataItems,
objectMetadataItems, computeReferences: false,
computeReferences: true, });
});
if (!optimisticRecordWithConnection || !cachedRecordWithConnection) { if (!isDefined(optimisticRecordNode) || !isDefined(cachedRecordNode)) {
return null; return null;
} }
@ -84,8 +84,8 @@ export const useDeleteOneRecord = ({
triggerUpdateRecordOptimisticEffect({ triggerUpdateRecordOptimisticEffect({
cache: apolloClient.cache, cache: apolloClient.cache,
objectMetadataItem, objectMetadataItem,
currentRecord: cachedRecordWithConnection, currentRecord: cachedRecordNode,
updatedRecord: optimisticRecordWithConnection, updatedRecord: optimisticRecordNode,
objectMetadataItems, objectMetadataItems,
}); });
@ -98,12 +98,13 @@ export const useDeleteOneRecord = ({
update: (cache, { data }) => { update: (cache, { data }) => {
const record = data?.[mutationResponseField]; const record = data?.[mutationResponseField];
if (!record || !cachedRecord) return; if (!isDefined(record) || !isDefined(computedOptimisticRecord))
return;
triggerUpdateRecordOptimisticEffect({ triggerUpdateRecordOptimisticEffect({
cache, cache,
objectMetadataItem, objectMetadataItem,
currentRecord: cachedRecord, currentRecord: computedOptimisticRecord,
updatedRecord: record, updatedRecord: record,
objectMetadataItems, objectMetadataItems,
}); });
@ -123,8 +124,8 @@ export const useDeleteOneRecord = ({
triggerUpdateRecordOptimisticEffect({ triggerUpdateRecordOptimisticEffect({
cache: apolloClient.cache, cache: apolloClient.cache,
objectMetadataItem, objectMetadataItem,
currentRecord: optimisticRecordWithConnection, currentRecord: optimisticRecordNode,
updatedRecord: cachedRecordWithConnection, updatedRecord: cachedRecordNode,
objectMetadataItems, objectMetadataItems,
}); });

View File

@ -14,6 +14,7 @@ import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeO
import { getUpdateOneRecordMutationResponseField } from '@/object-record/utils/getUpdateOneRecordMutationResponseField'; import { getUpdateOneRecordMutationResponseField } from '@/object-record/utils/getUpdateOneRecordMutationResponseField';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput'; import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { capitalize } from 'twenty-shared'; import { capitalize } from 'twenty-shared';
import { isDefined } from 'twenty-ui';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
type useUpdateOneRecordProps = { type useUpdateOneRecordProps = {
@ -130,7 +131,8 @@ export const useUpdateOneRecord = <
update: (cache, { data }) => { update: (cache, { data }) => {
const record = data?.[mutationResponseField]; const record = data?.[mutationResponseField];
if (!record || !computedOptimisticRecord) return; if (!isDefined(record) || !isDefined(computedOptimisticRecord))
return;
triggerUpdateRecordOptimisticEffect({ triggerUpdateRecordOptimisticEffect({
cache, cache,

View File

@ -179,4 +179,28 @@ describe('computeOptimisticRecordFromInput', () => {
`"Should never provide relation mutation through anything else than the fieldId e.g companyId"`, `"Should never provide relation mutation through anything else than the fieldId e.g companyId"`,
); );
}); });
it('should throw an error if recordInput contains both the relationFieldId and relationField even if null', () => {
const cache = new InMemoryCache();
const personObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'person',
);
if (!personObjectMetadataItem) {
throw new Error('Person object metadata item not found');
}
expect(() =>
computeOptimisticRecordFromInput({
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem,
recordInput: {
companyId: '123',
company: null,
},
cache,
}),
).toThrowErrorMatchingInlineSnapshot(
`"Should never provide relation mutation through anything else than the fieldId e.g companyId"`,
);
});
}); });

View File

@ -91,7 +91,7 @@ export const computeOptimisticRecordFromInput = ({
continue; continue;
} }
if (isDefined(recordInputFieldValue)) { if (!isUndefined(recordInputFieldValue)) {
throw new Error( throw new Error(
'Should never provide relation mutation through anything else than the fieldId e.g companyId', 'Should never provide relation mutation through anything else than the fieldId e.g companyId',
); );

View File

@ -34,8 +34,10 @@ export const sanitizeRecordInput = ({
(field) => field.name === relationIdFieldName, (field) => field.name === relationIdFieldName,
); );
const relationIdFieldValue = recordInput[relationIdFieldName];
return relationIdFieldMetadataItem return relationIdFieldMetadataItem
? [relationIdFieldName, fieldValue?.id ?? null] ? [relationIdFieldName, relationIdFieldValue ?? null]
: undefined; : undefined;
} }