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};