From 151e3f74f81886b6e275cda9fa1616c2cef9f3a8 Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Fri, 12 Dec 2025 12:09:16 +0100 Subject: [PATCH 1/4] Fixed Apollo cache bug --- .../record-index/hooks/useRecordIndexTableFetchMore.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableFetchMore.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableFetchMore.ts index 242ee8c13f69a..8955777988aee 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableFetchMore.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableFetchMore.ts @@ -18,6 +18,7 @@ export const useRecordIndexTableFetchMore = (objectNameSingular: string) => { useLazyFindManyRecords({ ...params, recordGqlFields, + fetchPolicy: 'network-only', }); return { From aa68a6ec9c5c15b349a8e76fdc309c8d37d2c953 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Fri, 12 Dec 2025 16:32:56 +0100 Subject: [PATCH 2/4] Fix optimistic --- .../triggerDestroyRecordsOptimisticEffect.ts | 45 ++++++++++++------ .../triggerUpdateRelationsOptimisticEffect.ts | 46 +++++++++++++------ .../components/ApolloCoreProvider.tsx | 2 + .../cache/types/RecordGqlRefConnection.ts | 7 ++- .../isObjectRecordConnection.test.ts | 36 --------------- .../utils/getRecordConnectionFromRecords.ts | 4 +- .../utils/getRecordsFromRecordConnection.ts | 4 +- .../cache/utils/isObjectRecordConnection.ts | 44 +++++++++++------- .../graphql/types/RecordGqlConnection.ts | 4 +- .../types/RecordGqlConnectionEdgesRequired.ts | 7 +++ ...RecordGqlOperationFindDuplicatesResults.ts | 4 +- .../types/RecordGqlOperationFindManyResult.ts | 4 +- .../types/RecordGqlOperationSearchResult.ts | 4 +- .../hooks/__mocks__/useFetchAllRecordIds.ts | 8 ++-- .../hooks/useFindDuplicateRecords.ts | 6 +-- .../CombinedFindManyRecordsQueryResult.ts | 4 +- .../hooks/useRecordIndexTableFetchMore.ts | 2 +- .../SignInBackgroundMockCompanies.ts | 4 +- .../src/testing/mock-data/people.ts | 4 +- 19 files changed, 130 insertions(+), 109 deletions(-) delete mode 100644 packages/twenty-front/src/modules/object-record/cache/utils/__tests__/isObjectRecordConnection.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlConnectionEdgesRequired.ts 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 1d694a48ce887..be44d477bb942 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 @@ -4,6 +4,7 @@ import { triggerUpdateGroupByQueriesOptimisticEffect } from '@/apollo/optimistic import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { type RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge'; +import { isObjectRecordConnection } from '@/object-record/cache/utils/isObjectRecordConnection'; import { isObjectRecordConnectionWithRefs } from '@/object-record/cache/utils/isObjectRecordConnectionWithRefs'; import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; @@ -34,16 +35,36 @@ export const triggerDestroyRecordsOptimisticEffect = ({ rootQueryCachedResponse, { readField }, ) => { - const rootQueryCachedResponseIsNotACachedObjectRecordConnection = - !isObjectRecordConnectionWithRefs( + if ( + !isObjectRecordConnection( objectMetadataItem.nameSingular, rootQueryCachedResponse, - ); - - if (rootQueryCachedResponseIsNotACachedObjectRecordConnection) { + ) + ) { return rootQueryCachedResponse; } + const totalCount = readField( + 'totalCount', + rootQueryCachedResponse, + ); + + const newTotalCount = isDefined(totalCount) + ? Math.max(totalCount - recordsToDestroy.length, 0) + : undefined; + + if ( + !isObjectRecordConnectionWithRefs( + objectMetadataItem.nameSingular, + rootQueryCachedResponse, + ) + ) { + return { + ...rootQueryCachedResponse, + totalCount: newTotalCount, + }; + } + const rootQueryCachedObjectRecordConnection = rootQueryCachedResponse; const recordIdsToDestroy = recordsToDestroy.map(({ id }) => id); @@ -52,11 +73,6 @@ export const triggerDestroyRecordsOptimisticEffect = ({ rootQueryCachedObjectRecordConnection, ); - const totalCount = readField( - 'totalCount', - rootQueryCachedObjectRecordConnection, - ); - const nextCachedEdges = cachedEdges?.filter((cachedEdge) => { const nodeId = readField('id', cachedEdge.node); @@ -65,14 +81,15 @@ export const triggerDestroyRecordsOptimisticEffect = ({ }) || []; if (nextCachedEdges.length === cachedEdges?.length) - return rootQueryCachedObjectRecordConnection; + return { + ...rootQueryCachedObjectRecordConnection, + totalCount: newTotalCount, + }; return { ...rootQueryCachedObjectRecordConnection, edges: nextCachedEdges, - totalCount: isDefined(totalCount) - ? totalCount - recordIdsToDestroy.length - : undefined, + totalCount: newTotalCount, }; }, }, 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 0ca181b676ee6..7000676d9c146 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 @@ -7,15 +7,18 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataIte import { type FieldMetadataItemRelation } from '@/object-metadata/types/FieldMetadataItemRelation'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { getFieldMetadataItemById } from '@/object-metadata/utils/getFieldMetadataItemById'; -import { isObjectRecordConnection } from '@/object-record/cache/utils/isObjectRecordConnection'; -import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection'; +import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired'; import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode'; import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation'; import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { type ApolloCache } from '@apollo/client'; import { isArray } from '@sniptt/guards'; -import { FieldMetadataType, type ObjectPermissions } from 'twenty-shared/types'; +import { + FieldMetadataType, + RelationType, + type ObjectPermissions, +} from 'twenty-shared/types'; import { computeMorphRelationFieldName, CustomError, @@ -147,12 +150,12 @@ const triggerUpdateRelationOptimisticEffect = ({ } const currentFieldValueOnSourceRecord: - | RecordGqlConnection + | RecordGqlConnectionEdgesRequired | RecordGqlNode | null = currentSourceRecord?.[fieldMetadataItemOnSourceRecord.name]; const updatedFieldValueOnSourceRecord: - | RecordGqlConnection + | RecordGqlConnectionEdgesRequired | RecordGqlNode | null = updatedSourceRecord?.[fieldMetadataItemOnSourceRecord.name]; @@ -179,6 +182,7 @@ const triggerUpdateRelationOptimisticEffect = ({ CORE_OBJECT_NAMES_TO_DELETE_ON_TRIGGER_RELATION_DETACH.includes( targetObjectMetadata.nameSingular as CoreObjectNameSingular, ); + const gqlFieldNameOnTargetRecord = targetFieldMetadataFullObject.type === FieldMetadataType.RELATION ? targetFieldMetadataFullObject.name @@ -189,7 +193,10 @@ const triggerUpdateRelationOptimisticEffect = ({ sourceObjectMetadataItem.nameSingular, targetObjectMetadataNamePlural: sourceObjectMetadataItem.namePlural, }); - if (shouldCascadeDeleteTargetRecords) { + if ( + shouldCascadeDeleteTargetRecords && + targetRecordsToDetachFrom.length > 0 + ) { triggerDestroyRecordsOptimisticEffect({ cache, objectMetadataItem: fullTargetObjectMetadataItem, @@ -198,7 +205,10 @@ const triggerUpdateRelationOptimisticEffect = ({ upsertRecordsInStore, objectPermissionsByObjectMetadataId, }); - } else if (isDefined(currentSourceRecord)) { + } else if ( + isDefined(currentSourceRecord) && + targetRecordsToDetachFrom.length > 0 + ) { targetRecordsToDetachFrom.forEach((targetRecordToDetachFrom) => { triggerDetachRelationOptimisticEffect({ cache, @@ -306,12 +316,12 @@ const triggerUpdateMorphRelationOptimisticEffect = ({ } const currentFieldValueOnSourceRecord: - | RecordGqlConnection + | RecordGqlConnectionEdgesRequired | RecordGqlNode | null = currentSourceRecord?.[gqlFieldMorphRelation]; const updatedFieldValueOnSourceRecord: - | RecordGqlConnection + | RecordGqlConnectionEdgesRequired | RecordGqlNode | null = updatedSourceRecord?.[gqlFieldMorphRelation]; @@ -338,7 +348,10 @@ const triggerUpdateMorphRelationOptimisticEffect = ({ CORE_OBJECT_NAMES_TO_DELETE_ON_TRIGGER_RELATION_DETACH.includes( targetObjectMetadata.nameSingular as CoreObjectNameSingular, ); - if (shouldCascadeDeleteTargetRecords) { + if ( + shouldCascadeDeleteTargetRecords && + targetRecordsToDetachFrom.length > 0 + ) { triggerDestroyRecordsOptimisticEffect({ cache, objectMetadataItem: fullTargetObjectMetadataItem, @@ -347,7 +360,10 @@ const triggerUpdateMorphRelationOptimisticEffect = ({ objectPermissionsByObjectMetadataId, upsertRecordsInStore, }); - } else if (isDefined(currentSourceRecord)) { + } else if ( + isDefined(currentSourceRecord) && + targetRecordsToDetachFrom.length > 0 + ) { targetRecordsToDetachFrom.forEach((targetRecordToDetachFrom) => { triggerDetachRelationOptimisticEffect({ cache, @@ -387,7 +403,7 @@ const triggerUpdateMorphRelationOptimisticEffect = ({ }; const extractTargetRecordsFromRelation = ( - value: RecordGqlConnection | RecordGqlNode | null, + value: RecordGqlConnectionEdgesRequired | RecordGqlNode | null, relation: FieldMetadataItemRelation, ): RecordGqlNode[] => { // TODO investigate on the root cause of array injection here, should never occurs @@ -399,9 +415,9 @@ const extractTargetRecordsFromRelation = ( if (!isDefined(relation)) { throw new Error('Relation found is undefined'); } - if (isObjectRecordConnection(relation, value)) { - return value.edges.map(({ node }) => node); + if (relation.type === RelationType.ONE_TO_MANY) { + return value.edges.map(({ node }: { node: RecordGqlNode }) => node); } - return [value]; + return [value as RecordGqlNode]; }; diff --git a/packages/twenty-front/src/modules/object-metadata/components/ApolloCoreProvider.tsx b/packages/twenty-front/src/modules/object-metadata/components/ApolloCoreProvider.tsx index 0b210d2355924..f31a044abbe3d 100644 --- a/packages/twenty-front/src/modules/object-metadata/components/ApolloCoreProvider.tsx +++ b/packages/twenty-front/src/modules/object-metadata/components/ApolloCoreProvider.tsx @@ -9,6 +9,8 @@ export const ApolloCoreProvider = ({ }) => { const apolloCoreClient = useApolloFactory(); + window.__APOLLO_CLIENT__ = apolloCoreClient; + return ( {children} diff --git a/packages/twenty-front/src/modules/object-record/cache/types/RecordGqlRefConnection.ts b/packages/twenty-front/src/modules/object-record/cache/types/RecordGqlRefConnection.ts index 5a71bfcc950c9..ce8ee484db0be 100644 --- a/packages/twenty-front/src/modules/object-record/cache/types/RecordGqlRefConnection.ts +++ b/packages/twenty-front/src/modules/object-record/cache/types/RecordGqlRefConnection.ts @@ -1,6 +1,9 @@ import { type RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge'; -import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection'; +import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired'; -export type RecordGqlRefConnection = Omit & { +export type RecordGqlRefConnection = Omit< + RecordGqlConnectionEdgesRequired, + 'edges' +> & { edges: RecordGqlRefEdge[]; }; diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/isObjectRecordConnection.test.ts b/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/isObjectRecordConnection.test.ts deleted file mode 100644 index 1ad087ab89574..0000000000000 --- a/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/isObjectRecordConnection.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { isObjectRecordConnection } from '@/object-record/cache/utils/isObjectRecordConnection'; -import { RelationType } from '~/generated-metadata/graphql'; -describe('isObjectRecordConnection', () => { - const relationDefinitionMap: { [K in RelationType]: boolean } = { - [RelationType.ONE_TO_MANY]: true, - [RelationType.MANY_TO_ONE]: false, - }; - - it.each(Object.entries(relationDefinitionMap))( - '.$relation', - (relation, expected) => { - const emptyRecord = {}; - const result = isObjectRecordConnection( - { - type: relation, - } as NonNullable, - emptyRecord, - ); - - expect(result).toEqual(expected); - }, - ); - - it('should throw on unknown relation direction', () => { - const emptyRecord = {}; - expect(() => - isObjectRecordConnection( - { - direction: 'UNKNOWN_TYPE', - } as any, - emptyRecord, - ), - ).toThrowError(); - }); -}); diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordConnectionFromRecords.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordConnectionFromRecords.ts index 3527c83c80ca8..0a725a0c09ca9 100644 --- a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordConnectionFromRecords.ts +++ b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordConnectionFromRecords.ts @@ -2,7 +2,7 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataI import { getConnectionTypename } from '@/object-record/cache/utils/getConnectionTypename'; import { getEmptyPageInfo } from '@/object-record/cache/utils/getEmptyPageInfo'; import { getRecordEdgeFromRecord } from '@/object-record/cache/utils/getRecordEdgeFromRecord'; -import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection'; +import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired'; import { type RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; @@ -40,5 +40,5 @@ export const getRecordConnectionFromRecords = ({ }), ...(withPageInfo && { pageInfo: getEmptyPageInfo() }), ...(withPageInfo && { totalCount: records.length }), - } as RecordGqlConnection; + } as RecordGqlConnectionEdgesRequired; }; diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordsFromRecordConnection.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordsFromRecordConnection.ts index a035b1bcfa3e0..7d74b19c269e9 100644 --- a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordsFromRecordConnection.ts +++ b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordsFromRecordConnection.ts @@ -1,11 +1,11 @@ import { getRecordFromRecordNode } from '@/object-record/cache/utils/getRecordFromRecordNode'; -import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection'; +import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; export const getRecordsFromRecordConnection = ({ recordConnection, }: { - recordConnection: RecordGqlConnection; + recordConnection: RecordGqlConnectionEdgesRequired; }): T[] => { return recordConnection?.edges?.map((edge) => getRecordFromRecordNode({ recordNode: edge.node }), diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/isObjectRecordConnection.ts b/packages/twenty-front/src/modules/object-record/cache/utils/isObjectRecordConnection.ts index 3d9539a97372d..8100711791fc2 100644 --- a/packages/twenty-front/src/modules/object-record/cache/utils/isObjectRecordConnection.ts +++ b/packages/twenty-front/src/modules/object-record/cache/utils/isObjectRecordConnection.ts @@ -1,20 +1,32 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type StoreValue } from '@apollo/client'; +import { z } from 'zod'; + import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection'; -import { assertUnreachable } from 'twenty-shared/utils'; -import { RelationType } from '~/generated-metadata/graphql'; +import { capitalize } from 'twenty-shared/utils'; export const isObjectRecordConnection = ( - relation: NonNullable, - value: unknown, -): value is RecordGqlConnection => { - switch (relation.type) { - case RelationType.ONE_TO_MANY: { - return true; - } - case RelationType.MANY_TO_ONE: - return false; - default: { - return assertUnreachable(relation.type); - } - } + objectNameSingular: string, + storeValue: StoreValue, +): storeValue is RecordGqlConnection => { + const objectConnectionTypeName = `${capitalize( + objectNameSingular, + )}Connection`; + const objectEdgeTypeName = `${capitalize(objectNameSingular)}Edge`; + const cachedObjectConnectionSchema = z.object({ + __typename: z.literal(objectConnectionTypeName), + edges: z.array( + z + .object({ + __typename: z.literal(objectEdgeTypeName), + node: z.object({ + __ref: z.string().startsWith(`${capitalize(objectNameSingular)}:`), + }), + }) + .optional(), + ), + }); + const cachedConnectionValidation = + cachedObjectConnectionSchema.safeParse(storeValue); + + return cachedConnectionValidation.success; }; diff --git a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlConnection.ts b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlConnection.ts index 00f3bf870bc89..3e228342417f5 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlConnection.ts +++ b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlConnection.ts @@ -3,8 +3,8 @@ import { type Nullable } from 'twenty-ui/utilities'; export type RecordGqlConnection = { __typename?: string; - edges: RecordGqlEdge[]; - pageInfo: { + edges?: RecordGqlEdge[]; + pageInfo?: { __typename?: Nullable; hasNextPage?: Nullable; hasPreviousPage?: Nullable; diff --git a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlConnectionEdgesRequired.ts b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlConnectionEdgesRequired.ts new file mode 100644 index 0000000000000..35395be0e6784 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlConnectionEdgesRequired.ts @@ -0,0 +1,7 @@ +import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection'; + +export type RecordGqlConnectionEdgesRequired = Omit< + RecordGqlConnection, + 'edges' +> & + Required>; diff --git a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFindDuplicatesResults.ts b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFindDuplicatesResults.ts index 4e7564ed86a3e..9989626a04c08 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFindDuplicatesResults.ts +++ b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFindDuplicatesResults.ts @@ -1,5 +1,5 @@ -import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection'; +import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired'; export type RecordGqlOperationFindDuplicatesResult = { - [objectNamePlural: string]: RecordGqlConnection[]; + [objectNamePlural: string]: RecordGqlConnectionEdgesRequired[]; }; diff --git a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFindManyResult.ts b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFindManyResult.ts index 368301935957f..acd9732b32161 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFindManyResult.ts +++ b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFindManyResult.ts @@ -1,5 +1,5 @@ -import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection'; +import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired'; export type RecordGqlOperationFindManyResult = { - [objectNamePlural: string]: RecordGqlConnection; + [objectNamePlural: string]: RecordGqlConnectionEdgesRequired; }; diff --git a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationSearchResult.ts b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationSearchResult.ts index 8520a6377443c..8f0ffb23b2ed3 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationSearchResult.ts +++ b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationSearchResult.ts @@ -1,5 +1,5 @@ -import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection'; +import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired'; export type RecordGqlOperationSearchResult = { - [objectNamePlural: string]: RecordGqlConnection; + [objectNamePlural: string]: RecordGqlConnectionEdgesRequired; }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFetchAllRecordIds.ts b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFetchAllRecordIds.ts index d63ae538c2e8f..005a8f196b89b 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFetchAllRecordIds.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFetchAllRecordIds.ts @@ -1,4 +1,4 @@ -import { RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection'; +import { RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired'; import { gql } from '@apollo/client'; import { peopleQueryResult } from '~/testing/mock-data/people'; @@ -36,7 +36,7 @@ export const query = gql` export const mockPageSize = 2; -export const peopleMockWithIdsOnly: RecordGqlConnection = { +export const peopleMockWithIdsOnly: RecordGqlConnectionEdgesRequired = { ...peopleQueryResult.people, edges: peopleQueryResult.people.edges.map((edge) => ({ ...edge, @@ -72,7 +72,7 @@ export const variablesThirdRequest = { }; const paginateRequestResponse = ( - response: RecordGqlConnection, + response: RecordGqlConnectionEdgesRequired, start: number, end: number, hasNextPage: boolean, @@ -86,7 +86,7 @@ const paginateRequestResponse = ( startCursor: response.edges[start].cursor, endCursor: response.edges[end].cursor, hasNextPage, - } satisfies RecordGqlConnection['pageInfo'], + } satisfies RecordGqlConnectionEdgesRequired['pageInfo'], totalCount, }; }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFindDuplicateRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useFindDuplicateRecords.ts index 3b1bfe9380f8f..26fbf203fdb13 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useFindDuplicateRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useFindDuplicateRecords.ts @@ -5,7 +5,7 @@ import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { type ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection'; -import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection'; +import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired'; import { type RecordGqlOperationFindDuplicatesResult } from '@/object-record/graphql/types/RecordGqlOperationFindDuplicatesResults'; import { useFindDuplicateRecordsQuery } from '@/object-record/hooks/useFindDuplicatesRecordsQuery'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; @@ -20,7 +20,7 @@ export const useFindDuplicateRecords = ({ skip, }: ObjectMetadataItemIdentifier & { objectRecordIds: string[] | undefined; - onCompleted?: (data: RecordGqlConnection[]) => void; + onCompleted?: (data: RecordGqlConnectionEdgesRequired[]) => void; skip?: boolean; }) => { const findDuplicateQueryStateIdentifier = objectNameSingular; @@ -69,7 +69,7 @@ export const useFindDuplicateRecords = ({ const results = useMemo( () => - objectResults?.map((result: RecordGqlConnection) => { + objectResults?.map((result: RecordGqlConnectionEdgesRequired) => { return result ? (getRecordsFromRecordConnection({ recordConnection: result, diff --git a/packages/twenty-front/src/modules/object-record/multiple-objects/types/CombinedFindManyRecordsQueryResult.ts b/packages/twenty-front/src/modules/object-record/multiple-objects/types/CombinedFindManyRecordsQueryResult.ts index 1b8badae32876..5cef20ce88c21 100644 --- a/packages/twenty-front/src/modules/object-record/multiple-objects/types/CombinedFindManyRecordsQueryResult.ts +++ b/packages/twenty-front/src/modules/object-record/multiple-objects/types/CombinedFindManyRecordsQueryResult.ts @@ -1,5 +1,5 @@ -import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection'; +import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired'; export type CombinedFindManyRecordsQueryResult = { - [namePlural: string]: RecordGqlConnection; + [namePlural: string]: RecordGqlConnectionEdgesRequired; }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableFetchMore.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableFetchMore.ts index 8955777988aee..6595ef4855a2f 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableFetchMore.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableFetchMore.ts @@ -18,7 +18,7 @@ export const useRecordIndexTableFetchMore = (objectNameSingular: string) => { useLazyFindManyRecords({ ...params, recordGqlFields, - fetchPolicy: 'network-only', + // fetchPolicy: 'network-only', }); return { diff --git a/packages/twenty-front/src/modules/sign-in-background-mock/constants/SignInBackgroundMockCompanies.ts b/packages/twenty-front/src/modules/sign-in-background-mock/constants/SignInBackgroundMockCompanies.ts index 35a87a2803c9e..20a3ac52e54e9 100644 --- a/packages/twenty-front/src/modules/sign-in-background-mock/constants/SignInBackgroundMockCompanies.ts +++ b/packages/twenty-front/src/modules/sign-in-background-mock/constants/SignInBackgroundMockCompanies.ts @@ -1,5 +1,5 @@ import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection'; -import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection'; +import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired'; import { type RecordGqlEdge } from '@/object-record/graphql/types/RecordGqlEdge'; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -1377,7 +1377,7 @@ const baseMockToRecordConnection = { ) as any, pageInfo: {}, __typename: 'CompanyConnection', -} as RecordGqlConnection; +} as RecordGqlConnectionEdgesRequired; // eslint-disable-next-line @nx/workspace-max-consts-per-file export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = getRecordsFromRecordConnection( diff --git a/packages/twenty-front/src/testing/mock-data/people.ts b/packages/twenty-front/src/testing/mock-data/people.ts index 46f91898a60b9..353d537028430 100644 --- a/packages/twenty-front/src/testing/mock-data/people.ts +++ b/packages/twenty-front/src/testing/mock-data/people.ts @@ -1,5 +1,5 @@ import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection'; -import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection'; +import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { type FieldMetadataType } from 'twenty-shared/types'; @@ -1746,7 +1746,7 @@ export const peopleQueryResult = { }, ], }, -} satisfies { people: RecordGqlConnection }; +} satisfies { people: RecordGqlConnectionEdgesRequired }; export const allMockPersonRecords = getRecordsFromRecordConnection({ recordConnection: peopleQueryResult.people, From e4026545bae59357758719c9996d9f12c8be357f Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Fri, 12 Dec 2025 17:02:57 +0100 Subject: [PATCH 3/4] Fix --- .../triggerDestroyRecordsOptimisticEffect.ts | 21 +++++++++++++++++-- .../triggerUpdateRecordOptimisticEffect.ts | 21 +++++++++++++++++++ .../components/ApolloCoreProvider.tsx | 2 -- .../cache/utils/isObjectRecordConnection.ts | 12 +++++------ .../hooks/useRecordIndexTableFetchMore.ts | 1 - 5 files changed, 46 insertions(+), 11 deletions(-) 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 be44d477bb942..87e4e3c6b32e7 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 @@ -2,14 +2,17 @@ import { type ApolloCache, type StoreObject } from '@apollo/client'; import { triggerUpdateGroupByQueriesOptimisticEffect } from '@/apollo/optimistic-effect/group-by/utils/triggerUpdateGroupByQueriesOptimisticEffect'; import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect'; +import { type CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { type RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge'; import { isObjectRecordConnection } from '@/object-record/cache/utils/isObjectRecordConnection'; import { isObjectRecordConnectionWithRefs } from '@/object-record/cache/utils/isObjectRecordConnectionWithRefs'; import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode'; +import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { type ObjectPermissions } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; +import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName'; export const triggerDestroyRecordsOptimisticEffect = ({ cache, @@ -33,8 +36,13 @@ export const triggerDestroyRecordsOptimisticEffect = ({ fields: { [objectMetadataItem.namePlural]: ( rootQueryCachedResponse, - { readField }, + { readField, storeFieldName }, ) => { + const { fieldVariables: rootQueryVariables } = + parseApolloStoreFieldName( + storeFieldName, + ); + if ( !isObjectRecordConnection( objectMetadataItem.nameSingular, @@ -49,8 +57,17 @@ export const triggerDestroyRecordsOptimisticEffect = ({ rootQueryCachedResponse, ); + const recordsMatchingRootQueryFilter = recordsToDestroy.filter( + (record) => + isRecordMatchingFilter({ + record, + filter: rootQueryVariables?.filter ?? {}, + objectMetadataItem, + }), + ); + const newTotalCount = isDefined(totalCount) - ? Math.max(totalCount - recordsToDestroy.length, 0) + ? Math.max(totalCount - recordsMatchingRootQueryFilter.length, 0) : undefined; if ( 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 2ebbcad72c7b5..3bf8af17275e3 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 @@ -82,6 +82,26 @@ export const triggerUpdateRecordOptimisticEffect = ({ objectMetadataItem, }); + const currentRecordIndexInRootQueryEdges = isRecordMatchingFilter({ + record: currentRecord, + filter: rootQueryFilter ?? {}, + objectMetadataItem, + }); + + const totalCount = readField( + 'totalCount', + rootQueryConnection, + ); + + const newTotalCount = isDefined(totalCount) + ? Math.max( + totalCount + + (updatedRecordMatchesThisRootQueryFilter ? 1 : 0) + + (currentRecordIndexInRootQueryEdges ? -1 : 0), + 0, + ) + : undefined; + const updatedRecordIndexInRootQueryEdges = rootQueryCurrentEdges.findIndex( (cachedEdge) => @@ -131,6 +151,7 @@ export const triggerUpdateRecordOptimisticEffect = ({ return { ...rootQueryConnection, edges: rootQueryNextEdges, + totalCount: newTotalCount, }; }, }, diff --git a/packages/twenty-front/src/modules/object-metadata/components/ApolloCoreProvider.tsx b/packages/twenty-front/src/modules/object-metadata/components/ApolloCoreProvider.tsx index f31a044abbe3d..0b210d2355924 100644 --- a/packages/twenty-front/src/modules/object-metadata/components/ApolloCoreProvider.tsx +++ b/packages/twenty-front/src/modules/object-metadata/components/ApolloCoreProvider.tsx @@ -9,8 +9,6 @@ export const ApolloCoreProvider = ({ }) => { const apolloCoreClient = useApolloFactory(); - window.__APOLLO_CLIENT__ = apolloCoreClient; - return ( {children} diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/isObjectRecordConnection.ts b/packages/twenty-front/src/modules/object-record/cache/utils/isObjectRecordConnection.ts index 8100711791fc2..16f78e5d76e84 100644 --- a/packages/twenty-front/src/modules/object-record/cache/utils/isObjectRecordConnection.ts +++ b/packages/twenty-front/src/modules/object-record/cache/utils/isObjectRecordConnection.ts @@ -14,16 +14,16 @@ export const isObjectRecordConnection = ( const objectEdgeTypeName = `${capitalize(objectNameSingular)}Edge`; const cachedObjectConnectionSchema = z.object({ __typename: z.literal(objectConnectionTypeName), - edges: z.array( - z - .object({ + edges: z + .array( + z.object({ __typename: z.literal(objectEdgeTypeName), node: z.object({ __ref: z.string().startsWith(`${capitalize(objectNameSingular)}:`), }), - }) - .optional(), - ), + }), + ) + .optional(), }); const cachedConnectionValidation = cachedObjectConnectionSchema.safeParse(storeValue); diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableFetchMore.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableFetchMore.ts index 6595ef4855a2f..242ee8c13f69a 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableFetchMore.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableFetchMore.ts @@ -18,7 +18,6 @@ export const useRecordIndexTableFetchMore = (objectNameSingular: string) => { useLazyFindManyRecords({ ...params, recordGqlFields, - // fetchPolicy: 'network-only', }); return { From 88313aeba17a2118359cd9b78017a9bbc7a80f10 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Fri, 12 Dec 2025 17:34:44 +0100 Subject: [PATCH 4/4] Fix tests --- .../getRecordFromRecordNode.test.ts.snap | 107 ++++++++++++ .../__tests__/getRecordFromRecordNode.test.ts | 140 ++++++++++++++++ .../isObjectRecordConnection.test.ts | 132 +++++++++++++++ .../isObjectRecordConnectionWithRefs.test.ts | 152 ++++++++++++++++++ .../cache/utils/isObjectRecordConnection.ts | 4 +- .../utils/__tests__/sortMorphItems.test.ts | 136 ++++++++++++++++ 6 files changed, 668 insertions(+), 3 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/cache/utils/__tests__/__snapshots__/getRecordFromRecordNode.test.ts.snap create mode 100644 packages/twenty-front/src/modules/object-record/cache/utils/__tests__/getRecordFromRecordNode.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/cache/utils/__tests__/isObjectRecordConnection.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/cache/utils/__tests__/isObjectRecordConnectionWithRefs.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/utils/__tests__/sortMorphItems.test.ts diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/__snapshots__/getRecordFromRecordNode.test.ts.snap b/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/__snapshots__/getRecordFromRecordNode.test.ts.snap new file mode 100644 index 0000000000000..7721e0850b41f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/__snapshots__/getRecordFromRecordNode.test.ts.snap @@ -0,0 +1,107 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getRecordFromRecordNode should convert a simple record node 1`] = ` +{ + "__typename": "Person", + "email": "john@example.com", + "id": "123", + "name": "John Doe", +} +`; + +exports[`getRecordFromRecordNode should handle array values 1`] = ` +{ + "__typename": "Person", + "id": "123", + "scores": [ + 100, + 200, + 300, + ], + "tags": [ + "developer", + "designer", + ], +} +`; + +exports[`getRecordFromRecordNode should handle connection fields with edges 1`] = ` +{ + "__typename": "Company", + "id": "123", + "name": "Acme Inc", + "people": [ + { + "__typename": "Person", + "id": "456", + "name": "John Doe", + }, + { + "__typename": "Person", + "id": "789", + "name": "Jane Smith", + }, + ], +} +`; + +exports[`getRecordFromRecordNode should handle deeply nested structures 1`] = ` +{ + "__typename": "Workspace", + "id": "123", + "settings": { + "__typename": "WorkspaceSettings", + "display": { + "__typename": "DisplaySettings", + "layout": "compact", + "theme": "dark", + }, + }, +} +`; + +exports[`getRecordFromRecordNode should handle mixed nested objects and connections 1`] = ` +{ + "__typename": "Company", + "address": { + "__typename": "Address", + "city": "New York", + "country": "USA", + }, + "employees": [ + { + "__typename": "Person", + "id": "456", + "name": { + "__typename": "FullName", + "firstName": "John", + "lastName": "Doe", + }, + }, + ], + "id": "123", + "name": "Acme Inc", +} +`; + +exports[`getRecordFromRecordNode should handle nested object fields 1`] = ` +{ + "__typename": "Person", + "id": "123", + "name": { + "__typename": "FullName", + "firstName": "John", + "lastName": "Doe", + }, +} +`; + +exports[`getRecordFromRecordNode should handle null and undefined values 1`] = ` +{ + "__typename": "Person", + "email": null, + "id": "123", + "name": "John Doe", + "phone": undefined, +} +`; diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/getRecordFromRecordNode.test.ts b/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/getRecordFromRecordNode.test.ts new file mode 100644 index 0000000000000..0b327fedef57c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/getRecordFromRecordNode.test.ts @@ -0,0 +1,140 @@ +import { getRecordFromRecordNode } from '../getRecordFromRecordNode'; + +describe('getRecordFromRecordNode', () => { + it('should convert a simple record node', () => { + const recordNode = { + id: '123', + __typename: 'Person', + name: 'John Doe', + email: 'john@example.com', + }; + + const result = getRecordFromRecordNode({ recordNode }); + + expect(result).toMatchSnapshot(); + }); + + it('should handle nested object fields', () => { + const recordNode = { + id: '123', + __typename: 'Person', + name: { + __typename: 'FullName', + firstName: 'John', + lastName: 'Doe', + }, + }; + + const result = getRecordFromRecordNode({ recordNode }); + + expect(result).toMatchSnapshot(); + }); + + it('should handle connection fields with edges', () => { + const recordNode = { + id: '123', + __typename: 'Company', + name: 'Acme Inc', + people: { + edges: [ + { + node: { + id: '456', + __typename: 'Person', + name: 'John Doe', + }, + }, + { + node: { + id: '789', + __typename: 'Person', + name: 'Jane Smith', + }, + }, + ], + }, + }; + + const result = getRecordFromRecordNode({ recordNode }); + + expect(result).toMatchSnapshot(); + }); + + it('should handle null and undefined values', () => { + const recordNode = { + id: '123', + __typename: 'Person', + name: 'John Doe', + email: null, + phone: undefined, + }; + + const result = getRecordFromRecordNode({ recordNode }); + + expect(result).toMatchSnapshot(); + }); + + it('should handle array values', () => { + const recordNode = { + id: '123', + __typename: 'Person', + tags: ['developer', 'designer'], + scores: [100, 200, 300], + }; + + const result = getRecordFromRecordNode({ recordNode }); + + expect(result).toMatchSnapshot(); + }); + + it('should handle deeply nested structures', () => { + const recordNode = { + id: '123', + __typename: 'Workspace', + settings: { + __typename: 'WorkspaceSettings', + display: { + __typename: 'DisplaySettings', + theme: 'dark', + layout: 'compact', + }, + }, + }; + + const result = getRecordFromRecordNode({ recordNode }); + + expect(result).toMatchSnapshot(); + }); + + it('should handle mixed nested objects and connections', () => { + const recordNode = { + id: '123', + __typename: 'Company', + name: 'Acme Inc', + address: { + __typename: 'Address', + city: 'New York', + country: 'USA', + }, + employees: { + edges: [ + { + node: { + id: '456', + __typename: 'Person', + name: { + __typename: 'FullName', + firstName: 'John', + lastName: 'Doe', + }, + }, + }, + ], + }, + }; + + const result = getRecordFromRecordNode({ recordNode }); + + expect(result).toMatchSnapshot(); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/isObjectRecordConnection.test.ts b/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/isObjectRecordConnection.test.ts new file mode 100644 index 0000000000000..4f415608a88f6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/isObjectRecordConnection.test.ts @@ -0,0 +1,132 @@ +import { isObjectRecordConnection } from '../isObjectRecordConnection'; + +describe('isObjectRecordConnection', () => { + it('should return true for valid connection with edges', () => { + const storeValue = { + __typename: 'PersonConnection', + edges: [ + { + __typename: 'PersonEdge', + node: { + id: '123', + }, + }, + { + __typename: 'PersonEdge', + node: { + id: '456', + }, + }, + ], + }; + + const result = isObjectRecordConnection('person', storeValue); + + expect(result).toBe(true); + }); + + it('should return true for valid connection with empty edges array', () => { + const storeValue = { + __typename: 'CompanyConnection', + edges: [], + }; + + const result = isObjectRecordConnection('company', storeValue); + + expect(result).toBe(true); + }); + + it('should return true for valid connection without edges (optional)', () => { + const storeValue = { + __typename: 'PersonConnection', + }; + + const result = isObjectRecordConnection('person', storeValue); + + expect(result).toBe(true); + }); + + it('should return false for incorrect __typename', () => { + const storeValue = { + __typename: 'WrongConnection', + edges: [], + }; + + const result = isObjectRecordConnection('person', storeValue); + + expect(result).toBe(false); + }); + + it('should return false for incorrect edge __typename', () => { + const storeValue = { + __typename: 'PersonConnection', + edges: [ + { + __typename: 'WrongEdge', + node: { + id: '123', + }, + }, + ], + }; + + const result = isObjectRecordConnection('person', storeValue); + + expect(result).toBe(false); + }); + + it('should return true regardless of node content', () => { + const storeValue = { + __typename: 'PersonConnection', + edges: [ + { + __typename: 'PersonEdge', + node: { + id: '123', + name: 'John Doe', + }, + }, + ], + }; + + const result = isObjectRecordConnection('person', storeValue); + + expect(result).toBe(true); + }); + + it('should return false for null value', () => { + const result = isObjectRecordConnection('person', null); + + expect(result).toBe(false); + }); + + it('should return false for undefined value', () => { + const result = isObjectRecordConnection('person', undefined); + + expect(result).toBe(false); + }); + + it('should return false for primitive value', () => { + const result = isObjectRecordConnection('person', 'not an object'); + + expect(result).toBe(false); + }); + + it('should handle camelCase object names', () => { + const storeValue = { + __typename: 'CalendarEventConnection', + edges: [ + { + __typename: 'CalendarEventEdge', + node: { + id: '123', + }, + }, + ], + }; + + const result = isObjectRecordConnection('calendarEvent', storeValue); + + expect(result).toBe(true); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/isObjectRecordConnectionWithRefs.test.ts b/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/isObjectRecordConnectionWithRefs.test.ts new file mode 100644 index 0000000000000..7f375a147b38e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/isObjectRecordConnectionWithRefs.test.ts @@ -0,0 +1,152 @@ +import { isObjectRecordConnectionWithRefs } from '../isObjectRecordConnectionWithRefs'; + +describe('isObjectRecordConnectionWithRefs', () => { + it('should return true for valid connection with edges', () => { + const storeValue = { + __typename: 'PersonConnection', + edges: [ + { + __typename: 'PersonEdge', + node: { + __ref: 'Person:123', + }, + }, + { + __typename: 'PersonEdge', + node: { + __ref: 'Person:456', + }, + }, + ], + }; + + const result = isObjectRecordConnectionWithRefs('person', storeValue); + + expect(result).toBe(true); + }); + + it('should return true for valid connection with empty edges array', () => { + const storeValue = { + __typename: 'CompanyConnection', + edges: [], + }; + + const result = isObjectRecordConnectionWithRefs('company', storeValue); + + expect(result).toBe(true); + }); + + it('should return false for connection without edges (required)', () => { + const storeValue = { + __typename: 'PersonConnection', + }; + + const result = isObjectRecordConnectionWithRefs('person', storeValue); + + expect(result).toBe(false); + }); + + it('should return false for incorrect __typename', () => { + const storeValue = { + __typename: 'WrongConnection', + edges: [], + }; + + const result = isObjectRecordConnectionWithRefs('person', storeValue); + + expect(result).toBe(false); + }); + + it('should return false for incorrect edge __typename', () => { + const storeValue = { + __typename: 'PersonConnection', + edges: [ + { + __typename: 'WrongEdge', + node: { + __ref: 'Person:123', + }, + }, + ], + }; + + const result = isObjectRecordConnectionWithRefs('person', storeValue); + + expect(result).toBe(false); + }); + + it('should return false for incorrect __ref prefix', () => { + const storeValue = { + __typename: 'PersonConnection', + edges: [ + { + __typename: 'PersonEdge', + node: { + __ref: 'Company:123', + }, + }, + ], + }; + + const result = isObjectRecordConnectionWithRefs('person', storeValue); + + expect(result).toBe(false); + }); + + it('should return false for null value', () => { + const result = isObjectRecordConnectionWithRefs('person', null); + + expect(result).toBe(false); + }); + + it('should return false for undefined value', () => { + const result = isObjectRecordConnectionWithRefs('person', undefined); + + expect(result).toBe(false); + }); + + it('should return false for primitive value', () => { + const result = isObjectRecordConnectionWithRefs('person', 'not an object'); + + expect(result).toBe(false); + }); + + it('should handle camelCase object names', () => { + const storeValue = { + __typename: 'CalendarEventConnection', + edges: [ + { + __typename: 'CalendarEventEdge', + node: { + __ref: 'CalendarEvent:123', + }, + }, + ], + }; + + const result = isObjectRecordConnectionWithRefs( + 'calendarEvent', + storeValue, + ); + + expect(result).toBe(true); + }); + + it('should return false when node is missing __ref', () => { + const storeValue = { + __typename: 'PersonConnection', + edges: [ + { + __typename: 'PersonEdge', + node: { + id: '123', + }, + }, + ], + }; + + const result = isObjectRecordConnectionWithRefs('person', storeValue); + + expect(result).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/isObjectRecordConnection.ts b/packages/twenty-front/src/modules/object-record/cache/utils/isObjectRecordConnection.ts index 16f78e5d76e84..fa5c01d75ff86 100644 --- a/packages/twenty-front/src/modules/object-record/cache/utils/isObjectRecordConnection.ts +++ b/packages/twenty-front/src/modules/object-record/cache/utils/isObjectRecordConnection.ts @@ -18,9 +18,7 @@ export const isObjectRecordConnection = ( .array( z.object({ __typename: z.literal(objectEdgeTypeName), - node: z.object({ - __ref: z.string().startsWith(`${capitalize(objectNameSingular)}:`), - }), + node: z.object(), }), ) .optional(), diff --git a/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/utils/__tests__/sortMorphItems.test.ts b/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/utils/__tests__/sortMorphItems.test.ts new file mode 100644 index 0000000000000..38be5fab4a946 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/utils/__tests__/sortMorphItems.test.ts @@ -0,0 +1,136 @@ +import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem'; +import { type SearchRecord } from '~/generated-metadata/graphql'; +import { sortMorphItems } from '../sortMorphItems'; + +const createMorphItem = ( + recordId: string, + isSelected: boolean, +): RecordPickerPickableMorphItem => ({ + recordId, + objectMetadataId: 'object-1', + isSelected, + isMatchingSearchFilter: true, +}); + +const createSearchRecord = (recordId: string): SearchRecord => ({ + recordId, + label: `Record ${recordId}`, + objectNameSingular: 'person', + tsRank: 0, + tsRankCD: 0, +}); + +describe('sortMorphItems', () => { + it('should sort selected items before non-selected items', () => { + const morphItems: RecordPickerPickableMorphItem[] = [ + createMorphItem('1', false), + createMorphItem('2', true), + createMorphItem('3', false), + ]; + const searchRecords: SearchRecord[] = [ + createSearchRecord('1'), + createSearchRecord('2'), + createSearchRecord('3'), + ]; + + const result = sortMorphItems(morphItems, searchRecords); + + expect(result[0].recordId).toBe('2'); + expect(result[0].isSelected).toBe(true); + expect(result[1].isSelected).toBe(false); + expect(result[2].isSelected).toBe(false); + }); + + it('should sort by search record order within non-selected items', () => { + const morphItems: RecordPickerPickableMorphItem[] = [ + createMorphItem('3', false), + createMorphItem('1', false), + createMorphItem('2', false), + ]; + const searchRecords: SearchRecord[] = [ + createSearchRecord('1'), + createSearchRecord('2'), + createSearchRecord('3'), + ]; + + const result = sortMorphItems(morphItems, searchRecords); + + expect(result.map((item) => item.recordId)).toEqual(['1', '2', '3']); + }); + + it('should sort by search record order within selected items', () => { + const morphItems: RecordPickerPickableMorphItem[] = [ + createMorphItem('3', true), + createMorphItem('1', true), + createMorphItem('2', true), + ]; + const searchRecords: SearchRecord[] = [ + createSearchRecord('1'), + createSearchRecord('2'), + createSearchRecord('3'), + ]; + + const result = sortMorphItems(morphItems, searchRecords); + + expect(result.map((item) => item.recordId)).toEqual(['1', '2', '3']); + }); + + it('should handle mixed selected and non-selected items with correct ordering', () => { + const morphItems: RecordPickerPickableMorphItem[] = [ + createMorphItem('4', false), + createMorphItem('2', true), + createMorphItem('1', false), + createMorphItem('3', true), + ]; + const searchRecords: SearchRecord[] = [ + createSearchRecord('1'), + createSearchRecord('2'), + createSearchRecord('3'), + createSearchRecord('4'), + ]; + + const result = sortMorphItems(morphItems, searchRecords); + + expect(result.map((item) => item.recordId)).toEqual(['2', '3', '1', '4']); + expect(result[0].isSelected).toBe(true); + expect(result[1].isSelected).toBe(true); + expect(result[2].isSelected).toBe(false); + expect(result[3].isSelected).toBe(false); + }); + + it('should handle empty morphItems array', () => { + const morphItems: RecordPickerPickableMorphItem[] = []; + const searchRecords: SearchRecord[] = [createSearchRecord('1')]; + + const result = sortMorphItems(morphItems, searchRecords); + + expect(result).toEqual([]); + }); + + it('should handle empty searchRecords array', () => { + const morphItems: RecordPickerPickableMorphItem[] = [ + createMorphItem('1', false), + createMorphItem('2', true), + ]; + const searchRecords: SearchRecord[] = []; + + const result = sortMorphItems(morphItems, searchRecords); + + expect(result[0].recordId).toBe('2'); + expect(result[0].isSelected).toBe(true); + }); + + it('should place items not present in searchRecords before indexed items', () => { + const morphItems: RecordPickerPickableMorphItem[] = [ + createMorphItem('unknown', false), + createMorphItem('1', false), + ]; + const searchRecords: SearchRecord[] = [createSearchRecord('1')]; + + const result = sortMorphItems(morphItems, searchRecords); + + // Items not in searchRecords get rank -1, so they come before items with rank >= 0 + expect(result[0].recordId).toBe('unknown'); + expect(result[1].recordId).toBe('1'); + }); +});