From 8d1329953c150e9318bc8872dc922e1ed843dc71 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Fri, 19 Dec 2025 15:22:54 +0100 Subject: [PATCH] Refactor upsertRecordsInStore to accept an object with partialRecords (#16707) Fixes https://github.com/twentyhq/twenty/issues/16624 Original issue: - while persisting a field (calling useUpdateOne), the response from the backend is missing the taskTargets many to many (same for note). As we optimistically update the cache, we lose the "Relations" in the UI - I'm changing the behavior of useUpsertInRecordStore to accept recordGqlFields to only update the fields we want in the record store (this way, we are not losing the targets information in our case) --- .../components/CalendarEventDetailsEffect.tsx | 2 +- .../components/EventCardCalendarEvent.tsx | 2 +- .../message/components/EventCardMessage.tsx | 2 +- .../triggerAttachRelationOptimisticEffect.ts | 28 +- .../triggerCreateRecordsOptimisticEffect.ts | 2 +- .../triggerDestroyRecordsOptimisticEffect.ts | 2 +- .../triggerDetachRelationOptimisticEffect.ts | 4 +- .../triggerUpdateRecordOptimisticEffect.ts | 2 +- ...ggerUpdateRecordOptimisticEffectByBatch.ts | 2 +- .../triggerUpdateRelationsOptimisticEffect.ts | 6 +- .../useOpenMergeRecordsPageInCommandMenu.tsx | 2 +- .../CommandMenuCalendarEventPage.tsx | 2 +- .../hooks/useEmailThreadInCommandMenu.ts | 2 +- .../__tests__/filterRecordOnGqlFields.test.ts | 331 ++++++++++++++++++ .../cache/utils/deleteRecordFromCache.ts | 6 +- .../cache/utils/filterRecordOnGqlFields.ts | 56 +++ .../hooks/useUpdateOneRecordV2.ts | 2 +- .../hooks/useTriggerRecordBoardFetchMore.ts | 2 +- .../useTriggerRecordBoardInitialQuery.ts | 2 +- .../RecordIndexCalendarDataLoaderEffect.tsx | 2 +- .../hooks/useUpdateDroppedRecordOnBoard.ts | 16 +- ...ordDetailRelationRecordsListItemEffect.tsx | 2 +- .../record-field/ui/hooks/usePersistField.ts | 27 +- .../hooks/usePerformMergePreview.ts | 2 +- .../useUpsertRecordsInStore.test.tsx | 154 ++++++++ .../hooks/useUpsertRecordsInStore.ts | 43 ++- .../RecordTableNoRecordGroupAddNew.tsx | 2 +- .../hooks/useCreateNewIndexRecord.ts | 2 +- .../hooks/useTriggerFetchPages.ts | 2 +- .../useTriggerInitialRecordTableDataLoad.ts | 2 +- .../hooks/useActivateWorkflowVersion.ts | 14 +- .../hooks/useDeactivateWorkflowVersion.ts | 14 +- .../hooks/useDeleteOneWorkflowVersion.ts | 24 +- .../decorators/RecordStoreDecorator.tsx | 2 +- .../testing/jest/JestRecordStoreSetter.tsx | 2 +- 35 files changed, 680 insertions(+), 87 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/cache/utils/__tests__/filterRecordOnGqlFields.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/cache/utils/filterRecordOnGqlFields.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-store/hooks/__tests__/useUpsertRecordsInStore.test.tsx diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventDetailsEffect.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventDetailsEffect.tsx index f702bbcef0..ac97d12dd6 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventDetailsEffect.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventDetailsEffect.tsx @@ -16,7 +16,7 @@ export const CalendarEventDetailsEffect = ({ return; } - upsertRecordsInStore([record]); + upsertRecordsInStore({ partialRecords: [record] }); }, [record, upsertRecordsInStore]); return <>; diff --git a/packages/twenty-front/src/modules/activities/timeline-activities/rows/calendar/components/EventCardCalendarEvent.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/calendar/components/EventCardCalendarEvent.tsx index b4686f012a..715549a278 100644 --- a/packages/twenty-front/src/modules/activities/timeline-activities/rows/calendar/components/EventCardCalendarEvent.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/rows/calendar/components/EventCardCalendarEvent.tsx @@ -119,7 +119,7 @@ export const EventCardCalendarEvent = ({ }, }, onCompleted: (data) => { - upsertRecordsInStore([data]); + upsertRecordsInStore({ partialRecords: [data] }); }, }); diff --git a/packages/twenty-front/src/modules/activities/timeline-activities/rows/message/components/EventCardMessage.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/message/components/EventCardMessage.tsx index 79b7fc5a54..49fb50cfdc 100644 --- a/packages/twenty-front/src/modules/activities/timeline-activities/rows/message/components/EventCardMessage.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/rows/message/components/EventCardMessage.tsx @@ -81,7 +81,7 @@ export const EventCardMessage = ({ }, }, onCompleted: (data) => { - upsertRecordsInStore([data]); + upsertRecordsInStore({ partialRecords: [data] }); }, }); diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect.ts index 4e5eedc342..033894ebee 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect.ts @@ -34,7 +34,7 @@ export const triggerAttachRelationOptimisticEffect = ({ string, ObjectPermissions & { objectMetadataId: string } >; - upsertRecordsInStore: (records: ObjectRecord[]) => void; + upsertRecordsInStore: (props: { partialRecords: ObjectRecord[] }) => void; }) => { const sourceRecordTypeName = capitalize(sourceObjectNameSingular); const targetRecordTypeName = capitalize( @@ -84,18 +84,20 @@ export const triggerAttachRelationOptimisticEffect = ({ }, ]; - upsertRecordsInStore([ - getRecordFromRecordNode({ - recordNode: { - id: targetRecordId, - [fieldNameOnTargetRecord]: { - ...targetRecordFieldValue, - edges: nextEdges, + upsertRecordsInStore({ + partialRecords: [ + getRecordFromRecordNode({ + recordNode: { + id: targetRecordId, + [fieldNameOnTargetRecord]: { + ...targetRecordFieldValue, + edges: nextEdges, + }, + __typename: targetRecordTypeName, }, - __typename: targetRecordTypeName, - }, - }), - ]); + }), + ], + }); return { ...targetRecordFieldValue, @@ -121,5 +123,5 @@ export const triggerAttachRelationOptimisticEffect = ({ return; } - upsertRecordsInStore([newCachedRecord]); + upsertRecordsInStore({ partialRecords: [newCachedRecord] }); }; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts index a5b611f8a7..2f49f0298c 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts @@ -35,7 +35,7 @@ type TriggerCreateRecordsOptimisticEffectArgs = { string, ObjectPermissions & { objectMetadataId: string } >; - upsertRecordsInStore: (records: ObjectRecord[]) => void; + upsertRecordsInStore: (props: { partialRecords: ObjectRecord[] }) => void; }; export const triggerCreateRecordsOptimisticEffect = ({ diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDestroyRecordsOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDestroyRecordsOptimisticEffect.ts index 87e4e3c6b3..1b5ccd8ac7 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDestroyRecordsOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDestroyRecordsOptimisticEffect.ts @@ -26,7 +26,7 @@ export const triggerDestroyRecordsOptimisticEffect = ({ objectMetadataItem: ObjectMetadataItem; recordsToDestroy: RecordGqlNode[]; objectMetadataItems: ObjectMetadataItem[]; - upsertRecordsInStore: (records: ObjectRecord[]) => void; + upsertRecordsInStore: (props: { partialRecords: ObjectRecord[] }) => void; objectPermissionsByObjectMetadataId: Record< string, ObjectPermissions & { objectMetadataId: string } diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect.ts index ca63bf4c67..93bed483ea 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect.ts @@ -32,7 +32,7 @@ export const triggerDetachRelationOptimisticEffect = ({ string, ObjectPermissions & { objectMetadataId: string } >; - upsertRecordsInStore: (records: ObjectRecord[]) => void; + upsertRecordsInStore: (props: { partialRecords: ObjectRecord[] }) => void; }) => { const targetRecordTypeName = capitalize( targetObjectMetadataItem.nameSingular, @@ -89,5 +89,5 @@ export const triggerDetachRelationOptimisticEffect = ({ return; } - upsertRecordsInStore([newCachedRecord]); + upsertRecordsInStore({ partialRecords: [newCachedRecord] }); }; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts index 3bf8af1727..31e76309d0 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts @@ -34,7 +34,7 @@ export const triggerUpdateRecordOptimisticEffect = ({ string, ObjectPermissions & { objectMetadataId: string } >; - upsertRecordsInStore: (records: ObjectRecord[]) => void; + upsertRecordsInStore: (props: { partialRecords: ObjectRecord[] }) => void; }) => { triggerUpdateRelationsOptimisticEffect({ cache, diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch.ts index 73658136b0..f21b8bd708 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch.ts @@ -35,7 +35,7 @@ export const triggerUpdateRecordOptimisticEffectByBatch = ({ string, ObjectPermissions & { objectMetadataId: string } >; - upsertRecordsInStore: (records: ObjectRecord[]) => void; + upsertRecordsInStore: (props: { partialRecords: ObjectRecord[] }) => void; }) => { for (const [index, currentRecord] of currentRecords.entries()) { triggerUpdateRelationsOptimisticEffect({ diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect.ts index 7000676d9c..9f5a367b68 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect.ts @@ -36,7 +36,7 @@ type TriggerUpdateRelationsOptimisticEffectArgs = { string, ObjectPermissions & { objectMetadataId: string } >; - upsertRecordsInStore: (records: ObjectRecord[]) => void; + upsertRecordsInStore: (props: { partialRecords: ObjectRecord[] }) => void; }; export const triggerUpdateRelationsOptimisticEffect = ({ @@ -108,7 +108,7 @@ const triggerUpdateRelationOptimisticEffect = ({ sourceObjectMetadataItem: ObjectMetadataItem; cache: ApolloCache; isDeletion: boolean; - upsertRecordsInStore: (records: ObjectRecord[]) => void; + upsertRecordsInStore: (props: { partialRecords: ObjectRecord[] }) => void; objectPermissionsByObjectMetadataId: Record< string, ObjectPermissions & { objectMetadataId: string } @@ -264,7 +264,7 @@ const triggerUpdateMorphRelationOptimisticEffect = ({ sourceObjectMetadataItem: ObjectMetadataItem; cache: ApolloCache; isDeletion: boolean; - upsertRecordsInStore: (records: ObjectRecord[]) => void; + upsertRecordsInStore: (props: { partialRecords: ObjectRecord[] }) => void; objectPermissionsByObjectMetadataId: Record< string, ObjectPermissions & { objectMetadataId: string } diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useOpenMergeRecordsPageInCommandMenu.tsx b/packages/twenty-front/src/modules/command-menu/hooks/useOpenMergeRecordsPageInCommandMenu.tsx index 199a42556b..009e385f33 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useOpenMergeRecordsPageInCommandMenu.tsx +++ b/packages/twenty-front/src/modules/command-menu/hooks/useOpenMergeRecordsPageInCommandMenu.tsx @@ -57,7 +57,7 @@ export const useOpenMergeRecordsPageInCommandMenu = ({ objectRecordIds, }); const { records } = await findManyRecordsLazy(); - upsertRecordsInStore(records ?? []); + upsertRecordsInStore({ partialRecords: records ?? [] }); navigateCommandMenu({ page: CommandMenuPages.MergeRecords, diff --git a/packages/twenty-front/src/modules/command-menu/pages/calendar-event/components/CommandMenuCalendarEventPage.tsx b/packages/twenty-front/src/modules/command-menu/pages/calendar-event/components/CommandMenuCalendarEventPage.tsx index 6284c39905..84ebe1fd51 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/calendar-event/components/CommandMenuCalendarEventPage.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/calendar-event/components/CommandMenuCalendarEventPage.tsx @@ -20,7 +20,7 @@ export const CommandMenuCalendarEventPage = () => { recordGqlFields: FIND_ONE_CALENDAR_EVENT_OPERATION_SIGNATURE.fields, // TODO: this is not executed on sub-sequent runs, make sure that it is intended onCompleted: (record) => { - upsertRecordsInStore([record]); + upsertRecordsInStore({ partialRecords: [record] }); }, }); diff --git a/packages/twenty-front/src/modules/command-menu/pages/message-thread/hooks/useEmailThreadInCommandMenu.ts b/packages/twenty-front/src/modules/command-menu/pages/message-thread/hooks/useEmailThreadInCommandMenu.ts index 806255016d..e2bb0d9bab 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/message-thread/hooks/useEmailThreadInCommandMenu.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/message-thread/hooks/useEmailThreadInCommandMenu.ts @@ -36,7 +36,7 @@ export const useEmailThreadInCommandMenu = () => { id: true, }, onCompleted: (record) => { - upsertRecordsInStore([record]); + upsertRecordsInStore({ partialRecords: [record] }); }, }); diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/filterRecordOnGqlFields.test.ts b/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/filterRecordOnGqlFields.test.ts new file mode 100644 index 0000000000..f57b430db6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/filterRecordOnGqlFields.test.ts @@ -0,0 +1,331 @@ +import { filterRecordOnGqlFields } from '../filterRecordOnGqlFields'; + +describe('filterRecordOnGqlFields', () => { + it('should filter fields based on recordGqlFields with true values', () => { + const record = { + id: '1', + __typename: 'Person', + name: 'John', + email: 'john@example.com', + phone: '123-456-7890', + }; + + const recordGqlFields = { + id: true, + name: true, + }; + + const result = filterRecordOnGqlFields({ record, recordGqlFields }); + + expect(result).toEqual({ + id: '1', + name: 'John', + }); + }); + + it('should exclude fields with false values', () => { + const record = { + id: '1', + __typename: 'Person', + name: 'John', + email: 'john@example.com', + }; + + const recordGqlFields = { + id: true, + name: false, + email: true, + }; + + const result = filterRecordOnGqlFields({ record, recordGqlFields }); + + expect(result).toEqual({ + id: '1', + email: 'john@example.com', + }); + }); + + it('should exclude fields with undefined values', () => { + const record = { + id: '1', + __typename: 'Person', + name: 'John', + email: 'john@example.com', + }; + + const recordGqlFields = { + id: true, + name: undefined, + email: true, + }; + + const result = filterRecordOnGqlFields({ record, recordGqlFields }); + + expect(result).toEqual({ + id: '1', + email: 'john@example.com', + }); + }); + + it('should handle composite fields with RecordGqlFields', () => { + const record = { + id: '1', + __typename: 'Person', + fullName: { + firstName: 'John', + lastName: 'Doe', + }, + email: 'john@example.com', + }; + + const recordGqlFields = { + id: true, + fullName: { + firstName: true, + lastName: true, + }, + }; + + const result = filterRecordOnGqlFields({ record, recordGqlFields }); + + expect(result).toEqual({ + id: '1', + fullName: { + firstName: 'John', + lastName: 'Doe', + }, + }); + }); + + it('should handle arrays with nested RecordGqlFields', () => { + const record = { + id: '1', + __typename: 'Company', + name: 'Acme Inc', + employees: [ + { id: '2', __typename: 'Person', name: 'John', email: 'john@test.com' }, + { id: '3', __typename: 'Person', name: 'Jane', email: 'jane@test.com' }, + ], + }; + + const recordGqlFields = { + id: true, + name: true, + employees: { + id: true, + name: true, + }, + }; + + const result = filterRecordOnGqlFields({ record, recordGqlFields }); + + expect(result).toEqual({ + id: '1', + name: 'Acme Inc', + employees: [ + { id: '2', name: 'John' }, + { id: '3', name: 'Jane' }, + ], + }); + }); + + it('should handle null values in nested objects', () => { + const record = { + id: '1', + __typename: 'Person', + company: null, + }; + + const recordGqlFields = { + id: true, + company: { + id: true, + name: true, + }, + }; + + const result = filterRecordOnGqlFields({ record, recordGqlFields }); + + expect(result).toEqual({ + id: '1', + company: null, + }); + }); + + it('should handle undefined values in nested objects', () => { + const record = { + id: '1', + __typename: 'Person', + company: undefined, + }; + + const recordGqlFields = { + id: true, + company: { + id: true, + name: true, + }, + }; + + const result = filterRecordOnGqlFields({ record, recordGqlFields }); + + expect(result).toEqual({ + id: '1', + company: undefined, + }); + }); + + it('should handle deeply nested structures', () => { + const record = { + id: '1', + __typename: 'Person', + company: { + id: '2', + taskTargets: [ + { + id: '3', + task: { + id: '4', + title: 'Task 1', + assignee: { + id: '6', + name: 'Assignee 1', + }, + }, + targetCompany: { + id: '5', + name: 'Company 1', + }, + }, + ], + }, + }; + + const recordGqlFields = { + id: true, + company: { + id: true, + taskTargets: { + id: true, + task: { + id: true, + title: true, + }, + targetCompany: { + id: true, + name: true, + }, + }, + }, + }; + + const result = filterRecordOnGqlFields({ record, recordGqlFields }); + + expect(result).toEqual({ + id: '1', + company: { + id: '2', + taskTargets: [ + { + id: '3', + task: { + id: '4', + title: 'Task 1', + }, + targetCompany: { + id: '5', + name: 'Company 1', + }, + }, + ], + }, + }); + }); + + it('should handle arrays with primitive values when gqlField is true', () => { + const record = { + id: '1', + __typename: 'Person', + tags: ['tag1', 'tag2', 'tag3'], + }; + + const recordGqlFields = { + id: true, + tags: true, + }; + + const result = filterRecordOnGqlFields({ record, recordGqlFields }); + + expect(result).toEqual({ + id: '1', + tags: ['tag1', 'tag2', 'tag3'], + }); + }); + + it('should handle mixed arrays with null values', () => { + const record = { + id: '1', + __typename: 'Company', + employees: [ + { id: '2', __typename: 'Person', name: 'John' }, + null, + { id: '3', __typename: 'Person', name: 'Jane' }, + ], + }; + + const recordGqlFields = { + id: true, + employees: { + id: true, + name: true, + }, + }; + + const result = filterRecordOnGqlFields({ record, recordGqlFields }); + + expect(result).toEqual({ + id: '1', + employees: [{ id: '2', name: 'John' }, null, { id: '3', name: 'Jane' }], + }); + }); + + it('should return empty object when no fields match', () => { + const record = { + id: '1', + __typename: 'Person', + name: 'John', + }; + + const recordGqlFields = { + email: true, + phone: true, + }; + + const result = filterRecordOnGqlFields({ record, recordGqlFields }); + + expect(result).toEqual({}); + }); + + it('should not include fields that are not in recordGqlFields', () => { + const record = { + id: '1', + __typename: 'Person', + name: 'John', + email: 'john@example.com', + phone: '123-456-7890', + address: 'NYC', + }; + + const recordGqlFields = { + id: true, + email: true, + }; + + const result = filterRecordOnGqlFields({ record, recordGqlFields }); + + expect(result).toEqual({ + id: '1', + email: 'john@example.com', + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/deleteRecordFromCache.ts b/packages/twenty-front/src/modules/object-record/cache/utils/deleteRecordFromCache.ts index 66afcc448d..00b8f71ce3 100644 --- a/packages/twenty-front/src/modules/object-record/cache/utils/deleteRecordFromCache.ts +++ b/packages/twenty-front/src/modules/object-record/cache/utils/deleteRecordFromCache.ts @@ -22,7 +22,11 @@ export const deleteRecordFromCache = ({ string, ObjectPermissions & { objectMetadataId: string } >; - upsertRecordsInStore: (records: ObjectRecord[]) => void; + upsertRecordsInStore: ({ + partialRecords, + }: { + partialRecords: ObjectRecord[]; + }) => void; }) => { triggerDestroyRecordsOptimisticEffect({ cache, diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/filterRecordOnGqlFields.ts b/packages/twenty-front/src/modules/object-record/cache/utils/filterRecordOnGqlFields.ts new file mode 100644 index 0000000000..66f4950595 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/utils/filterRecordOnGqlFields.ts @@ -0,0 +1,56 @@ +import { type RecordGqlFields } from '@/object-record/graphql/record-gql-fields/types/RecordGqlFields'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { isNonEmptyArray } from '@apollo/client/utilities'; +import { isObject } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; +export const filterRecordOnGqlFields = ({ + record, + recordGqlFields, +}: { + record: ObjectRecord; + recordGqlFields: RecordGqlFields; +}): Partial => { + return Object.fromEntries( + Object.entries(record) + .map(([key, value]) => { + const gqlFieldValue = recordGqlFields[key]; + + if (!isDefined(gqlFieldValue) || gqlFieldValue === false) { + return undefined; + } + + if (gqlFieldValue === true) { + return [key, value]; + } + + if (isNonEmptyArray(value)) { + return [ + key, + value.map((item) => { + if (!isDefined(item) || typeof item !== 'object') { + return item; + } + + return filterRecordOnGqlFields({ + record: item as ObjectRecord, + recordGqlFields: gqlFieldValue, + }); + }), + ]; + } + + if (isObject(value)) { + return [ + key, + filterRecordOnGqlFields({ + record: value as ObjectRecord, + recordGqlFields: gqlFieldValue, + }), + ]; + } + + return [key, value]; + }) + .filter(isDefined), + ) as Partial; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecordV2.ts b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecordV2.ts index e13ffbbaea..db790e6b57 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecordV2.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecordV2.ts @@ -175,7 +175,7 @@ export const useUpdateOneRecordV2 = () => { const recordToUpsert = getRecordFromRecordNode({ recordNode: record, }); - upsertRecordsInStore([recordToUpsert]); + upsertRecordsInStore({ partialRecords: [recordToUpsert] }); triggerUpdateRecordOptimisticEffect({ cache, diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useTriggerRecordBoardFetchMore.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useTriggerRecordBoardFetchMore.ts index fbd8bbcc51..7ddf6a03d6 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useTriggerRecordBoardFetchMore.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useTriggerRecordBoardFetchMore.ts @@ -186,7 +186,7 @@ export const useTriggerRecordBoardFetchMore = () => { newRecordIds, ); - upsertRecordsInStore(newRecords); + upsertRecordsInStore({ partialRecords: newRecords }); if (newRecords.length < RECORD_BOARD_QUERY_PAGE_SIZE) { set( diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useTriggerRecordBoardInitialQuery.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useTriggerRecordBoardInitialQuery.ts index 6a58df0e54..069bacd0ad 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useTriggerRecordBoardInitialQuery.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useTriggerRecordBoardInitialQuery.ts @@ -139,7 +139,7 @@ export const useTriggerRecordBoardInitialQuery = () => { continue; } - upsertRecordsInStore(records); + upsertRecordsInStore({ partialRecords: records }); setRecordIdsForColumn(recordGroupDefinition.id, records); diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordIndexCalendarDataLoaderEffect.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordIndexCalendarDataLoaderEffect.tsx index 2b5eb07dcf..66f6d01dc7 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordIndexCalendarDataLoaderEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordIndexCalendarDataLoaderEffect.tsx @@ -39,7 +39,7 @@ export const RecordIndexCalendarDataLoaderEffect = () => { ); useEffect(() => { - upsertRecordsInStore(records); + upsertRecordsInStore({ partialRecords: records }); const recordIds = records.map((record) => record.id); setRecordCalendarRecordIds(recordIds); }, [records, setRecordCalendarRecordIds, upsertRecordsInStore]); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/hooks/useUpdateDroppedRecordOnBoard.ts b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useUpdateDroppedRecordOnBoard.ts index c8a2f3002f..29e9e9132d 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/hooks/useUpdateDroppedRecordOnBoard.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useUpdateDroppedRecordOnBoard.ts @@ -139,13 +139,15 @@ export const useUpdateDroppedRecordOnBoard = () => { newTargetRecordGroupWithIds.map((record) => record.id), ); - upsertRecordsInStore([ - { - ...initialRecord, - [selectFieldMetadataItem.name]: targetRecordGroupValue, - position: newPosition, - }, - ]); + upsertRecordsInStore({ + partialRecords: [ + { + ...initialRecord, + [selectFieldMetadataItem.name]: targetRecordGroupValue, + position: newPosition, + }, + ], + }); updateOneRecord({ idToUpdate: initialRecord.id, diff --git a/packages/twenty-front/src/modules/object-record/record-field-list/record-detail-section/relation/components/RecordDetailRelationRecordsListItemEffect.tsx b/packages/twenty-front/src/modules/object-record/record-field-list/record-detail-section/relation/components/RecordDetailRelationRecordsListItemEffect.tsx index 54e1ef830d..15076de6c4 100644 --- a/packages/twenty-front/src/modules/object-record/record-field-list/record-detail-section/relation/components/RecordDetailRelationRecordsListItemEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field-list/record-detail-section/relation/components/RecordDetailRelationRecordsListItemEffect.tsx @@ -23,7 +23,7 @@ export const RecordDetailRelationRecordsListItemEffect = ({ useEffect(() => { if (isDefined(record)) { - upsertRecordsInStore([record]); + upsertRecordsInStore({ partialRecords: [record] }); } }, [record, upsertRecordsInStore]); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/usePersistField.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/usePersistField.ts index 509328cdb8..4d9c612f64 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/usePersistField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/usePersistField.ts @@ -202,11 +202,16 @@ export const usePersistField = ({ }, }); - upsertRecordsInStore([ - getRecordFromRecordNode({ - recordNode: newRecord, - }), - ]); + upsertRecordsInStore({ + partialRecords: [ + getRecordFromRecordNode({ + recordNode: newRecord, + }), + ], + recordGqlFields: { + [getForeignKeyNameFromRelationFieldName(fieldName)]: true, + }, + }); return; } @@ -223,11 +228,13 @@ export const usePersistField = ({ }, }); - upsertRecordsInStore([ - getRecordFromRecordNode({ - recordNode: newRecord, - }), - ]); + upsertRecordsInStore({ + partialRecords: [ + getRecordFromRecordNode({ + recordNode: newRecord, + }), + ], + }); return; } diff --git a/packages/twenty-front/src/modules/object-record/record-merge/hooks/usePerformMergePreview.ts b/packages/twenty-front/src/modules/object-record/record-merge/hooks/usePerformMergePreview.ts index 6f41bd6f5e..83524e204c 100644 --- a/packages/twenty-front/src/modules/object-record/record-merge/hooks/usePerformMergePreview.ts +++ b/packages/twenty-front/src/modules/object-record/record-merge/hooks/usePerformMergePreview.ts @@ -59,7 +59,7 @@ export const usePerformMergePreview = ({ }); setMergePreviewRecord(transformPreviewRecord); - upsertRecordsInStore([transformPreviewRecord]); + upsertRecordsInStore({ partialRecords: [transformPreviewRecord] }); } catch { setMergePreviewRecord(null); } finally { diff --git a/packages/twenty-front/src/modules/object-record/record-store/hooks/__tests__/useUpsertRecordsInStore.test.tsx b/packages/twenty-front/src/modules/object-record/record-store/hooks/__tests__/useUpsertRecordsInStore.test.tsx new file mode 100644 index 0000000000..75fc77d212 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-store/hooks/__tests__/useUpsertRecordsInStore.test.tsx @@ -0,0 +1,154 @@ +import { act, renderHook } from '@testing-library/react'; +import { type ReactNode } from 'react'; +import { RecoilRoot, useRecoilValue } from 'recoil'; + +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { useUpsertRecordsInStore } from '../useUpsertRecordsInStore'; + +const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe('useUpsertRecordsInStore', () => { + it('should insert a new record when no current record exists', () => { + const recordId = 'test-record-1'; + + const { result } = renderHook( + () => { + const record = useRecoilValue(recordStoreFamilyState(recordId)); + const { upsertRecordsInStore } = useUpsertRecordsInStore(); + + return { record, upsertRecordsInStore }; + }, + { wrapper: Wrapper }, + ); + + expect(result.current.record).toBeNull(); + + act(() => { + result.current.upsertRecordsInStore({ + partialRecords: [ + { + id: recordId, + __typename: 'Person', + name: 'John Doe', + email: 'john@example.com', + }, + ], + }); + }); + + expect(result.current.record).toEqual({ + id: recordId, + __typename: 'Person', + name: 'John Doe', + email: 'john@example.com', + }); + }); + + it('should merge filtered partial record with existing record', () => { + const recordId = 'test-record-2'; + + const { result } = renderHook( + () => { + const record = useRecoilValue(recordStoreFamilyState(recordId)); + const { upsertRecordsInStore } = useUpsertRecordsInStore(); + + return { record, upsertRecordsInStore }; + }, + { wrapper: Wrapper }, + ); + + act(() => { + result.current.upsertRecordsInStore({ + partialRecords: [ + { + id: recordId, + __typename: 'Person', + name: 'John Doe', + email: 'john@example.com', + }, + ], + }); + }); + + expect(result.current.record).toEqual({ + id: recordId, + __typename: 'Person', + name: 'John Doe', + email: 'john@example.com', + }); + + act(() => { + result.current.upsertRecordsInStore({ + partialRecords: [ + { + id: recordId, + __typename: 'Person', + name: 'Jane Doe', + phone: '123-456-7890', + }, + ], + recordGqlFields: { + id: true, + name: true, + }, + }); + }); + + expect(result.current.record).toEqual({ + id: recordId, + __typename: 'Person', + name: 'Jane Doe', + email: 'john@example.com', + }); + }); + + it('should not update when filtered values are deeply equal', () => { + const recordId = 'test-record-3'; + + const { result } = renderHook( + () => { + const record = useRecoilValue(recordStoreFamilyState(recordId)); + const { upsertRecordsInStore } = useUpsertRecordsInStore(); + + return { record, upsertRecordsInStore }; + }, + { wrapper: Wrapper }, + ); + + act(() => { + result.current.upsertRecordsInStore({ + partialRecords: [ + { + id: recordId, + __typename: 'Person', + name: 'John Doe', + email: 'john@example.com', + }, + ], + }); + }); + + const recordAfterFirstUpsert = result.current.record; + + act(() => { + result.current.upsertRecordsInStore({ + partialRecords: [ + { + id: recordId, + __typename: 'Person', + name: 'John Doe', + phone: '123-456-7890', + }, + ], + recordGqlFields: { + id: true, + name: true, + }, + }); + }); + + expect(result.current.record).toBe(recordAfterFirstUpsert); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-store/hooks/useUpsertRecordsInStore.ts b/packages/twenty-front/src/modules/object-record/record-store/hooks/useUpsertRecordsInStore.ts index 8a53f1dfb9..245b2fccc5 100644 --- a/packages/twenty-front/src/modules/object-record/record-store/hooks/useUpsertRecordsInStore.ts +++ b/packages/twenty-front/src/modules/object-record/record-store/hooks/useUpsertRecordsInStore.ts @@ -1,22 +1,53 @@ import { useRecoilCallback } from 'recoil'; +import { filterRecordOnGqlFields } from '@/object-record/cache/utils/filterRecordOnGqlFields'; +import { type RecordGqlFields } from '@/object-record/graphql/record-gql-fields/types/RecordGqlFields'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { isDefined } from 'twenty-shared/utils'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; +type UpsertRecordsInStoreProps = { + partialRecords: ObjectRecord[]; + recordGqlFields?: RecordGqlFields; +}; + export const useUpsertRecordsInStore = () => { const upsertRecordsInStore = useRecoilCallback( ({ set, snapshot }) => - (records: ObjectRecord[]) => { - for (const record of records) { + ({ partialRecords, recordGqlFields }: UpsertRecordsInStoreProps) => { + for (const partialRecord of partialRecords) { const currentRecord = snapshot - .getLoadable(recordStoreFamilyState(record.id)) + .getLoadable(recordStoreFamilyState(partialRecord.id)) .getValue(); - if (!isDeeplyEqual(currentRecord, record)) { - set(recordStoreFamilyState(record.id), { + const filteredPartialRecord = isDefined(recordGqlFields) + ? filterRecordOnGqlFields({ + record: partialRecord, + recordGqlFields, + }) + : partialRecord; + + if (!isDefined(currentRecord)) { + set(recordStoreFamilyState(partialRecord.id), { + id: partialRecord.id, + __typename: partialRecord.__typename, + ...filteredPartialRecord, + }); + continue; + } + + const filteredCurrentRecord = isDefined(recordGqlFields) + ? filterRecordOnGqlFields({ + record: currentRecord, + recordGqlFields, + }) + : currentRecord; + + if (!isDeeplyEqual(filteredCurrentRecord, filteredPartialRecord)) { + set(recordStoreFamilyState(partialRecord.id), { ...currentRecord, - ...record, + ...filteredPartialRecord, }); } } diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupAddNew.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupAddNew.tsx index 10d72e612e..c5cbfd3770 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupAddNew.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupAddNew.tsx @@ -42,7 +42,7 @@ export const RecordTableNoRecordGroupAddNew = () => { position: 'last', }); - upsertRecordsInStore([createdRecord]); + upsertRecordsInStore({ partialRecords: [createdRecord] }); if (isDefined(totalNumberOfRecordsToVirtualize)) { loadRecordsToVirtualRows({ diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewIndexRecord.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewIndexRecord.ts index 30b02580ef..7f5fd828ed 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewIndexRecord.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewIndexRecord.ts @@ -139,7 +139,7 @@ export const useCreateNewIndexRecord = ({ } } - upsertRecordsInStore([createdRecord]); + upsertRecordsInStore({ partialRecords: [createdRecord] }); return createdRecord; }, diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useTriggerFetchPages.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useTriggerFetchPages.ts index a3bfa9533e..a553ab10df 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useTriggerFetchPages.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useTriggerFetchPages.ts @@ -172,7 +172,7 @@ export const useTriggerFetchPages = () => { startingRealIndex: startingRealIndexInThisUIPage, }); - upsertRecordsInStore(recordsSlice); + upsertRecordsInStore({ partialRecords: recordsSlice }); } } diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useTriggerInitialRecordTableDataLoad.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useTriggerInitialRecordTableDataLoad.ts index f012b595b0..9c3940fd2b 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useTriggerInitialRecordTableDataLoad.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useTriggerInitialRecordTableDataLoad.ts @@ -170,7 +170,7 @@ export const useTriggerInitialRecordTableDataLoad = () => { set(totalNumberOfRecordsToVirtualizeCallbackState, totalCount); if (isDefined(records)) { - upsertRecordsInStore(records); + upsertRecordsInStore({ partialRecords: records }); loadRecordsToVirtualRows({ records, diff --git a/packages/twenty-front/src/modules/workflow/hooks/useActivateWorkflowVersion.ts b/packages/twenty-front/src/modules/workflow/hooks/useActivateWorkflowVersion.ts index 17c1a8557c..d92ef6972d 100644 --- a/packages/twenty-front/src/modules/workflow/hooks/useActivateWorkflowVersion.ts +++ b/packages/twenty-front/src/modules/workflow/hooks/useActivateWorkflowVersion.ts @@ -148,12 +148,14 @@ export const useActivateWorkflowVersion = () => { statuses: () => Array.from(newStatuses), }, }); - upsertRecordsInStore([ - { - ...cachedWorkflow, - statuses: Array.from(newStatuses) as WorkflowStatus[], - }, - ]); + upsertRecordsInStore({ + partialRecords: [ + { + ...cachedWorkflow, + statuses: Array.from(newStatuses) as WorkflowStatus[], + }, + ], + }); } }, }); diff --git a/packages/twenty-front/src/modules/workflow/hooks/useDeactivateWorkflowVersion.ts b/packages/twenty-front/src/modules/workflow/hooks/useDeactivateWorkflowVersion.ts index 32ba0326f7..2c29721ea3 100644 --- a/packages/twenty-front/src/modules/workflow/hooks/useDeactivateWorkflowVersion.ts +++ b/packages/twenty-front/src/modules/workflow/hooks/useDeactivateWorkflowVersion.ts @@ -111,12 +111,14 @@ export const useDeactivateWorkflowVersion = () => { statuses: () => Array.from(newStatuses), }, }); - upsertRecordsInStore([ - { - ...cachedWorkflow, - statuses: Array.from(newStatuses) as WorkflowStatus[], - }, - ]); + upsertRecordsInStore({ + partialRecords: [ + { + ...cachedWorkflow, + statuses: Array.from(newStatuses) as WorkflowStatus[], + }, + ], + }); } }, }); diff --git a/packages/twenty-front/src/modules/workflow/hooks/useDeleteOneWorkflowVersion.ts b/packages/twenty-front/src/modules/workflow/hooks/useDeleteOneWorkflowVersion.ts index c7066a1576..efd14b97b9 100644 --- a/packages/twenty-front/src/modules/workflow/hooks/useDeleteOneWorkflowVersion.ts +++ b/packages/twenty-front/src/modules/workflow/hooks/useDeleteOneWorkflowVersion.ts @@ -66,17 +66,19 @@ export const useDeleteOneWorkflowVersion = () => { }, }); - upsertRecordsInStore([ - { - ...cachedWorkflow, - statuses: - cachedWorkflow.statuses?.filter((status) => status !== 'DRAFT') ?? - [], - versions: cachedWorkflow.versions.filter( - (version) => version.id !== workflowVersionId, - ), - }, - ]); + upsertRecordsInStore({ + partialRecords: [ + { + ...cachedWorkflow, + statuses: + cachedWorkflow.statuses?.filter((status) => status !== 'DRAFT') ?? + [], + versions: cachedWorkflow.versions.filter( + (version) => version.id !== workflowVersionId, + ), + }, + ], + }); }, [ apolloCoreClient.cache, diff --git a/packages/twenty-front/src/testing/decorators/RecordStoreDecorator.tsx b/packages/twenty-front/src/testing/decorators/RecordStoreDecorator.tsx index 39be92a6b4..d60421485c 100644 --- a/packages/twenty-front/src/testing/decorators/RecordStoreDecorator.tsx +++ b/packages/twenty-front/src/testing/decorators/RecordStoreDecorator.tsx @@ -9,7 +9,7 @@ export const RecordStoreDecorator: Decorator = (Story, context) => { const { upsertRecordsInStore } = useUpsertRecordsInStore(); useEffect(() => { - upsertRecordsInStore(records); + upsertRecordsInStore({ partialRecords: records }); }); return ; diff --git a/packages/twenty-front/src/testing/jest/JestRecordStoreSetter.tsx b/packages/twenty-front/src/testing/jest/JestRecordStoreSetter.tsx index 076582405d..c90f955990 100644 --- a/packages/twenty-front/src/testing/jest/JestRecordStoreSetter.tsx +++ b/packages/twenty-front/src/testing/jest/JestRecordStoreSetter.tsx @@ -13,7 +13,7 @@ export const JestRecordStoreSetter = ({ const { upsertRecordsInStore } = useUpsertRecordsInStore(); useEffect(() => { - upsertRecordsInStore(records); + upsertRecordsInStore({ partialRecords: records }); }); return <>{children};