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 1d694a48ce..87e4e3c6b3 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,13 +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, @@ -32,16 +36,50 @@ export const triggerDestroyRecordsOptimisticEffect = ({ fields: { [objectMetadataItem.namePlural]: ( rootQueryCachedResponse, - { readField }, + { readField, storeFieldName }, ) => { - const rootQueryCachedResponseIsNotACachedObjectRecordConnection = + const { fieldVariables: rootQueryVariables } = + parseApolloStoreFieldName( + storeFieldName, + ); + + if ( + !isObjectRecordConnection( + objectMetadataItem.nameSingular, + rootQueryCachedResponse, + ) + ) { + return rootQueryCachedResponse; + } + + const totalCount = readField( + 'totalCount', + rootQueryCachedResponse, + ); + + const recordsMatchingRootQueryFilter = recordsToDestroy.filter( + (record) => + isRecordMatchingFilter({ + record, + filter: rootQueryVariables?.filter ?? {}, + objectMetadataItem, + }), + ); + + const newTotalCount = isDefined(totalCount) + ? Math.max(totalCount - recordsMatchingRootQueryFilter.length, 0) + : undefined; + + if ( !isObjectRecordConnectionWithRefs( objectMetadataItem.nameSingular, rootQueryCachedResponse, - ); - - if (rootQueryCachedResponseIsNotACachedObjectRecordConnection) { - return rootQueryCachedResponse; + ) + ) { + return { + ...rootQueryCachedResponse, + totalCount: newTotalCount, + }; } const rootQueryCachedObjectRecordConnection = rootQueryCachedResponse; @@ -52,11 +90,6 @@ export const triggerDestroyRecordsOptimisticEffect = ({ rootQueryCachedObjectRecordConnection, ); - const totalCount = readField( - 'totalCount', - rootQueryCachedObjectRecordConnection, - ); - const nextCachedEdges = cachedEdges?.filter((cachedEdge) => { const nodeId = readField('id', cachedEdge.node); @@ -65,14 +98,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/triggerUpdateRecordOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts index 2ebbcad72c..3bf8af1727 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/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect.ts index 0ca181b676..7000676d9c 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-record/cache/types/RecordGqlRefConnection.ts b/packages/twenty-front/src/modules/object-record/cache/types/RecordGqlRefConnection.ts index 5a71bfcc95..ce8ee484db 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__/__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 0000000000..7721e0850b --- /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 0000000000..0b327fedef --- /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 index 1ad087ab89..4f415608a8 100644 --- 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 @@ -1,36 +1,132 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { isObjectRecordConnection } from '@/object-record/cache/utils/isObjectRecordConnection'; -import { RelationType } from '~/generated-metadata/graphql'; +import { isObjectRecordConnection } from '../isObjectRecordConnection'; + 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( + it('should return true for valid connection with edges', () => { + const storeValue = { + __typename: 'PersonConnection', + edges: [ { - type: relation, - } as NonNullable, - emptyRecord, - ); - - expect(result).toEqual(expected); - }, - ); - - it('should throw on unknown relation direction', () => { - const emptyRecord = {}; - expect(() => - isObjectRecordConnection( + __typename: 'PersonEdge', + node: { + id: '123', + }, + }, { - direction: 'UNKNOWN_TYPE', - } as any, - emptyRecord, - ), - ).toThrowError(); + __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 0000000000..7f375a147b --- /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/getRecordConnectionFromRecords.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordConnectionFromRecords.ts index 3527c83c80..0a725a0c09 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 a035b1bcfa..7d74b19c26 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 3d9539a973..fa5c01d75f 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,30 @@ -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(), + }), + ) + .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 00f3bf870b..3e22834241 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 0000000000..35395be0e6 --- /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 4e7564ed86..9989626a04 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 3683019359..acd9732b32 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 8520a63774..8f0ffb23b2 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 d63ae538c2..005a8f196b 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 3b1bfe9380..26fbf203fd 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 1b8badae32..5cef20ce88 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-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 0000000000..38be5fab4a --- /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'); + }); +}); 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 35a87a2803..20a3ac52e5 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 46f91898a6..353d537028 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,