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 dc76f4f293ec..078f011d11e0 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 @@ -1,5 +1,3 @@ -import { ApolloCache } from '@apollo/client'; - import { triggerAttachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect'; import { triggerDestroyRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDestroyRecordsOptimisticEffect'; import { triggerDetachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect'; @@ -10,23 +8,26 @@ import { isObjectRecordConnection } from '@/object-record/cache/utils/isObjectRe import { RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection'; import { RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { ApolloCache } from '@apollo/client'; +import { isArray } from '@sniptt/guards'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; import { isDefined } from '~/utils/isDefined'; +type triggerUpdateRelationsOptimisticEffectArgs = { + cache: ApolloCache; + sourceObjectMetadataItem: ObjectMetadataItem; + currentSourceRecord: ObjectRecord | null; + updatedSourceRecord: ObjectRecord | null; + objectMetadataItems: ObjectMetadataItem[]; +}; export const triggerUpdateRelationsOptimisticEffect = ({ cache, sourceObjectMetadataItem, currentSourceRecord, updatedSourceRecord, objectMetadataItems, -}: { - cache: ApolloCache; - sourceObjectMetadataItem: ObjectMetadataItem; - currentSourceRecord: ObjectRecord | null; - updatedSourceRecord: ObjectRecord | null; - objectMetadataItems: ObjectMetadataItem[]; -}) => { +}: triggerUpdateRelationsOptimisticEffectArgs) => { return sourceObjectMetadataItem.fields.forEach( (fieldMetadataItemOnSourceRecord) => { const notARelationField = @@ -81,71 +82,55 @@ export const triggerUpdateRelationsOptimisticEffect = ({ ) { return; } + const extractTargetRecordsFromRelation = ( + value: RecordGqlConnection | RecordGqlNode | null, + ): RecordGqlNode[] => { + // TODO investigate on the root cause of array injection here, should never occurs + // Cache might be corrupted somewhere due to ObjectRecord and RecordGqlNode inclusion + if (!isDefined(value) || isArray(value)) { + return []; + } - // TODO: replace this by a relation type check, if it's one to many, - // it's an object record connection (we can still check it though as a safeguard) - const currentFieldValueOnSourceRecordIsARecordConnection = - isObjectRecordConnection( - targetObjectMetadata.nameSingular, - currentFieldValueOnSourceRecord, - ); + if (isObjectRecordConnection(relationDefinition, value)) { + return value.edges.map(({ node }) => node); + } - const targetRecordsToDetachFrom = - currentFieldValueOnSourceRecordIsARecordConnection - ? currentFieldValueOnSourceRecord.edges.map( - ({ node }) => node as RecordGqlNode, - ) - : [currentFieldValueOnSourceRecord].filter(isDefined); + return [value]; + }; + const targetRecordsToDetachFrom = extractTargetRecordsFromRelation( + currentFieldValueOnSourceRecord, + ); + const targetRecordsToAttachTo = extractTargetRecordsFromRelation( + updatedFieldValueOnSourceRecord, + ); - const updatedFieldValueOnSourceRecordIsARecordConnection = - isObjectRecordConnection( - targetObjectMetadata.nameSingular, - updatedFieldValueOnSourceRecord, + // TODO: see if we can de-hardcode this, put cascade delete in relation metadata item + // Instead of hardcoding it here + const shouldCascadeDeleteTargetRecords = + CORE_OBJECT_NAMES_TO_DELETE_ON_TRIGGER_RELATION_DETACH.includes( + targetObjectMetadata.nameSingular as CoreObjectNameSingular, ); - - const targetRecordsToAttachTo = - updatedFieldValueOnSourceRecordIsARecordConnection - ? updatedFieldValueOnSourceRecord.edges.map( - ({ node }) => node as RecordGqlNode, - ) - : [updatedFieldValueOnSourceRecord].filter(isDefined); - - const shouldDetachSourceFromAllTargets = - isDefined(currentSourceRecord) && targetRecordsToDetachFrom.length > 0; - - if (shouldDetachSourceFromAllTargets) { - // TODO: see if we can de-hardcode this, put cascade delete in relation metadata item - // Instead of hardcoding it here - const shouldCascadeDeleteTargetRecords = - CORE_OBJECT_NAMES_TO_DELETE_ON_TRIGGER_RELATION_DETACH.includes( - targetObjectMetadata.nameSingular as CoreObjectNameSingular, - ); - - if (shouldCascadeDeleteTargetRecords) { - triggerDestroyRecordsOptimisticEffect({ + if (shouldCascadeDeleteTargetRecords) { + triggerDestroyRecordsOptimisticEffect({ + cache, + objectMetadataItem: fullTargetObjectMetadataItem, + recordsToDestroy: targetRecordsToDetachFrom, + objectMetadataItems, + }); + } else if (isDefined(currentSourceRecord)) { + targetRecordsToDetachFrom.forEach((targetRecordToDetachFrom) => { + triggerDetachRelationOptimisticEffect({ cache, - objectMetadataItem: fullTargetObjectMetadataItem, - recordsToDestroy: targetRecordsToDetachFrom, - objectMetadataItems, - }); - } else { - targetRecordsToDetachFrom.forEach((targetRecordToDetachFrom) => { - triggerDetachRelationOptimisticEffect({ - cache, - sourceObjectNameSingular: sourceObjectMetadataItem.nameSingular, - sourceRecordId: currentSourceRecord.id, - fieldNameOnTargetRecord: targetFieldMetadata.name, - targetObjectNameSingular: targetObjectMetadata.nameSingular, - targetRecordId: targetRecordToDetachFrom.id, - }); + sourceObjectNameSingular: sourceObjectMetadataItem.nameSingular, + sourceRecordId: currentSourceRecord.id, + fieldNameOnTargetRecord: targetFieldMetadata.name, + targetObjectNameSingular: targetObjectMetadata.nameSingular, + targetRecordId: targetRecordToDetachFrom.id, }); - } + }); } - const shouldAttachSourceToAllTargets = - isDefined(updatedSourceRecord) && targetRecordsToAttachTo.length > 0; - - if (shouldAttachSourceToAllTargets) { + if (isDefined(updatedSourceRecord)) { targetRecordsToAttachTo.forEach((targetRecordToAttachTo) => triggerAttachRelationOptimisticEffect({ cache, 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 bbe8b38db7bb..ae44beddd066 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,27 +1,38 @@ -import { peopleQueryResult } from '~/testing/mock-data/people'; - +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { isObjectRecordConnection } from '@/object-record/cache/utils/isObjectRecordConnection'; - +import { RelationDefinitionType } from '~/generated-metadata/graphql'; describe('isObjectRecordConnection', () => { - it('should work with query result', () => { - const validQueryResult = peopleQueryResult.people; - - const isValidQueryResult = isObjectRecordConnection( - 'person', - validQueryResult, - ); - - expect(isValidQueryResult).toEqual(true); - }); + const relationDefinitionMap: { [K in RelationDefinitionType]: boolean } = { + [RelationDefinitionType.MANY_TO_MANY]: true, + [RelationDefinitionType.ONE_TO_MANY]: true, + [RelationDefinitionType.MANY_TO_ONE]: false, + [RelationDefinitionType.ONE_TO_ONE]: false, + }; - it('should fail with invalid result', () => { - const invalidResult = { test: 123 }; + it.each(Object.entries(relationDefinitionMap))( + '.$relation', + (relation, expected) => { + const emptyRecord = {}; + const result = isObjectRecordConnection( + { + direction: relation, + } as NonNullable, + emptyRecord, + ); - const isValidQueryResult = isObjectRecordConnection( - 'person', - invalidResult, - ); + expect(result).toEqual(expected); + }, + ); - expect(isValidQueryResult).toEqual(false); + 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/isObjectRecordConnection.ts b/packages/twenty-front/src/modules/object-record/cache/utils/isObjectRecordConnection.ts index fbeba7ba61c2..51e2109bcacf 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,30 +1,23 @@ -import { z } from 'zod'; - +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection'; -import { capitalize } from 'twenty-shared'; +import { assertUnreachable } from '@/workflow/utils/assertUnreachable'; +import { RelationDefinitionType } from '~/generated-metadata/graphql'; export const isObjectRecordConnection = ( - objectNameSingular: string, + relationDefinition: NonNullable, value: unknown, ): value is RecordGqlConnection => { - const objectConnectionTypeName = `${capitalize( - objectNameSingular, - )}Connection`; - const objectEdgeTypeName = `${capitalize(objectNameSingular)}Edge`; - - const objectConnectionSchema = z.object({ - __typename: z.literal(objectConnectionTypeName).optional(), - edges: z.array( - z.object({ - __typename: z.literal(objectEdgeTypeName).optional(), - node: z.object({ - id: z.string().uuid(), - }), - }), - ), - }); - - const connectionValidation = objectConnectionSchema.safeParse(value); - - return connectionValidation.success; + switch (relationDefinition.direction) { + case RelationDefinitionType.MANY_TO_MANY: + case RelationDefinitionType.ONE_TO_MANY: { + return true; + } + case RelationDefinitionType.MANY_TO_ONE: + case RelationDefinitionType.ONE_TO_ONE: { + return false; + } + default: { + return assertUnreachable(relationDefinition.direction); + } + } };