From 448091e81ff3e2c969d66081eb7b17f1740703c1 Mon Sep 17 00:00:00 2001 From: Guillim Date: Fri, 10 Oct 2025 19:11:00 +0200 Subject: [PATCH] Optimistic rendering of morph relations (#14997) The optimistic rendering of a morph relation Is broken. We intend to fix it here. - mapping of gql fields Fixes https://github.com/twentyhq/core-team-issues/issues/1322 --------- Co-authored-by: Charles Bochet --- .../triggerUpdateRelationsOptimisticEffect.ts | 453 +++++++++++++----- .../utils/mapFieldMetadataToGraphQLQuery.ts | 2 +- .../utils/mapObjectMetadataToGraphQLQuery.ts | 4 +- .../getFieldMetadataFromGqlField.test.ts | 177 +++++++ ...omputePossibleMorphGqlFieldForFieldName.ts | 20 + .../utils/getFieldMetadataFromGqlField.ts | 35 ++ ...tFieldMetadataMorphRelationFromGqlField.ts | 74 +++ .../cache/utils/getRecordNodeFromRecord.ts | 10 +- .../utils/computeOptimisticRecordFromInput.ts | 237 ++++++--- 9 files changed, 814 insertions(+), 198 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/cache/utils/__tests__/getFieldMetadataFromGqlField.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/cache/utils/computePossibleMorphGqlFieldForFieldName.ts create mode 100644 packages/twenty-front/src/modules/object-record/cache/utils/getFieldMetadataFromGqlField.ts create mode 100644 packages/twenty-front/src/modules/object-record/cache/utils/getFieldMetadataMorphRelationFromGqlField.ts 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 86728fe675..1d8959c633 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 @@ -3,17 +3,26 @@ import { triggerDestroyRecordsOptimisticEffect } from '@/apollo/optimistic-effec import { triggerDetachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect'; import { CORE_OBJECT_NAMES_TO_DELETE_ON_TRIGGER_RELATION_DETACH } from '@/apollo/types/coreObjectNamesToDeleteOnRelationDetach'; import { type CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +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 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 ApolloCache } from '@apollo/client'; import { isArray } from '@sniptt/guards'; -import { isDefined } from 'twenty-shared/utils'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { + computeMorphRelationFieldName, + CustomError, + isDefined, +} from 'twenty-shared/utils'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; -type triggerUpdateRelationsOptimisticEffectArgs = { +type TriggerUpdateRelationsOptimisticEffectArgs = { cache: ApolloCache; sourceObjectMetadataItem: ObjectMetadataItem; currentSourceRecord: RecordGqlNode | null; @@ -26,129 +35,329 @@ export const triggerUpdateRelationsOptimisticEffect = ({ currentSourceRecord, updatedSourceRecord, objectMetadataItems, -}: triggerUpdateRelationsOptimisticEffectArgs) => { +}: TriggerUpdateRelationsOptimisticEffectArgs) => { const isDeletion = isDefined(updatedSourceRecord) && isDefined(updatedSourceRecord['deletedAt']); - return sourceObjectMetadataItem.fields.forEach( - (fieldMetadataItemOnSourceRecord) => { - const notARelationField = - fieldMetadataItemOnSourceRecord.type !== FieldMetadataType.RELATION; + sourceObjectMetadataItem.fields.forEach((fieldMetadataItemOnSourceRecord) => { + if ( + !isFieldMorphRelation(fieldMetadataItemOnSourceRecord) && + !isFieldRelation(fieldMetadataItemOnSourceRecord) + ) { + return; + } - if (notARelationField) { - return; - } + if (isFieldRelation(fieldMetadataItemOnSourceRecord)) { + triggerUpdateRelationOptimisticEffect({ + fieldMetadataItemOnSourceRecord, + updatedSourceRecord, + currentSourceRecord, + objectMetadataItems, + sourceObjectMetadataItem, + cache, + isDeletion, + }); + } - const fieldDoesNotExist = - isDefined(updatedSourceRecord) && - !(fieldMetadataItemOnSourceRecord.name in updatedSourceRecord); - - if (fieldDoesNotExist) { - return; - } - - const relation = fieldMetadataItemOnSourceRecord.relation; - - if (!relation) { - return; - } - - const { targetObjectMetadata, targetFieldMetadata } = relation; - - const fullTargetObjectMetadataItem = objectMetadataItems.find( - ({ nameSingular }) => - nameSingular === targetObjectMetadata.nameSingular, - ); - - if (!fullTargetObjectMetadataItem) { - return; - } - - const currentFieldValueOnSourceRecord: - | RecordGqlConnection - | RecordGqlNode - | null = currentSourceRecord?.[fieldMetadataItemOnSourceRecord.name]; - - const updatedFieldValueOnSourceRecord: - | RecordGqlConnection - | RecordGqlNode - | null = updatedSourceRecord?.[fieldMetadataItemOnSourceRecord.name]; - - const noDiff = isDeeplyEqual( - currentFieldValueOnSourceRecord, - updatedFieldValueOnSourceRecord, - { strict: true }, - ); - if (noDiff && !isDeletion) { - 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 []; - } - - if (isObjectRecordConnection(relation, value)) { - return value.edges.map(({ node }) => node); - } - - return [value]; - }; - - const recordToExtractDetachFrom = isDeletion - ? updatedFieldValueOnSourceRecord - : currentFieldValueOnSourceRecord; - const targetRecordsToDetachFrom = extractTargetRecordsFromRelation( - recordToExtractDetachFrom, - ); - - // 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({ - cache, - objectMetadataItem: fullTargetObjectMetadataItem, - recordsToDestroy: targetRecordsToDetachFrom, - objectMetadataItems, - }); - } else if (isDefined(currentSourceRecord)) { - targetRecordsToDetachFrom.forEach((targetRecordToDetachFrom) => { - triggerDetachRelationOptimisticEffect({ - cache, - sourceObjectNameSingular: sourceObjectMetadataItem.nameSingular, - sourceRecordId: currentSourceRecord.id, - fieldNameOnTargetRecord: targetFieldMetadata.name, - targetObjectNameSingular: targetObjectMetadata.nameSingular, - targetRecordId: targetRecordToDetachFrom.id, - }); - }); - } - - if (!isDeletion && isDefined(updatedSourceRecord)) { - const targetRecordsToAttachTo = extractTargetRecordsFromRelation( - updatedFieldValueOnSourceRecord, - ); - - targetRecordsToAttachTo.forEach((targetRecordToAttachTo) => - triggerAttachRelationOptimisticEffect({ - cache, - sourceObjectNameSingular: sourceObjectMetadataItem.nameSingular, - sourceRecordId: updatedSourceRecord.id, - fieldNameOnTargetRecord: targetFieldMetadata.name, - targetObjectNameSingular: targetObjectMetadata.nameSingular, - targetRecordId: targetRecordToAttachTo.id, - }), - ); - } - }, - ); + if (isFieldMorphRelation(fieldMetadataItemOnSourceRecord)) { + triggerUpdateMorphRelationOptimisticEffect({ + fieldMetadataItemOnSourceRecord, + updatedSourceRecord, + currentSourceRecord, + objectMetadataItems, + sourceObjectMetadataItem, + cache, + isDeletion, + }); + } + }); +}; + +const triggerUpdateRelationOptimisticEffect = ({ + fieldMetadataItemOnSourceRecord, + updatedSourceRecord, + currentSourceRecord, + objectMetadataItems, + sourceObjectMetadataItem, + cache, + isDeletion, +}: { + fieldMetadataItemOnSourceRecord: FieldMetadataItem; + updatedSourceRecord: RecordGqlNode | null; + currentSourceRecord: RecordGqlNode | null; + objectMetadataItems: ObjectMetadataItem[]; + sourceObjectMetadataItem: ObjectMetadataItem; + cache: ApolloCache; + isDeletion: boolean; +}) => { + const fieldDoesNotExist = + isDefined(updatedSourceRecord) && + !(fieldMetadataItemOnSourceRecord.name in updatedSourceRecord); + + if (fieldDoesNotExist) { + return; + } + + const relation = fieldMetadataItemOnSourceRecord.relation; + if (!relation) { + return; + } + + const { targetObjectMetadata, targetFieldMetadata } = relation; + + const { fieldMetadataItem: targetFieldMetadataFullObject } = + getFieldMetadataItemById({ + fieldMetadataId: targetFieldMetadata.id, + objectMetadataItems, + }); + if (!targetFieldMetadataFullObject) { + throw new CustomError( + 'Target field metadata full object not found', + 'TARGET_FIELD_METADATA_FULL_OBJECT_NOT_FOUND', + ); + } + + const fullTargetObjectMetadataItem = objectMetadataItems.find( + ({ nameSingular }) => nameSingular === targetObjectMetadata.nameSingular, + ); + + if (!fullTargetObjectMetadataItem) { + return; + } + + const currentFieldValueOnSourceRecord: + | RecordGqlConnection + | RecordGqlNode + | null = currentSourceRecord?.[fieldMetadataItemOnSourceRecord.name]; + + const updatedFieldValueOnSourceRecord: + | RecordGqlConnection + | RecordGqlNode + | null = updatedSourceRecord?.[fieldMetadataItemOnSourceRecord.name]; + + const noDiff = isDeeplyEqual( + currentFieldValueOnSourceRecord, + updatedFieldValueOnSourceRecord, + { strict: true }, + ); + if (noDiff && !isDeletion) { + return; + } + + const recordToExtractDetachFrom = isDeletion + ? updatedFieldValueOnSourceRecord + : currentFieldValueOnSourceRecord; + const targetRecordsToDetachFrom = extractTargetRecordsFromRelation( + recordToExtractDetachFrom, + relation, + ); + + // 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 gqlFieldNameOnTargetRecord = + targetFieldMetadataFullObject.type === FieldMetadataType.RELATION + ? targetFieldMetadataFullObject.name + : computeMorphRelationFieldName({ + fieldName: targetFieldMetadataFullObject.name, + relationType: targetFieldMetadataFullObject.settings?.relationType, + targetObjectMetadataNameSingular: + sourceObjectMetadataItem.nameSingular, + targetObjectMetadataNamePlural: sourceObjectMetadataItem.namePlural, + }); + if (shouldCascadeDeleteTargetRecords) { + triggerDestroyRecordsOptimisticEffect({ + cache, + objectMetadataItem: fullTargetObjectMetadataItem, + recordsToDestroy: targetRecordsToDetachFrom, + objectMetadataItems, + }); + } else if (isDefined(currentSourceRecord)) { + targetRecordsToDetachFrom.forEach((targetRecordToDetachFrom) => { + triggerDetachRelationOptimisticEffect({ + cache, + sourceObjectNameSingular: sourceObjectMetadataItem.nameSingular, + sourceRecordId: currentSourceRecord.id, + fieldNameOnTargetRecord: gqlFieldNameOnTargetRecord, + targetObjectNameSingular: targetObjectMetadata.nameSingular, + targetRecordId: targetRecordToDetachFrom.id, + }); + }); + } + + if (!isDeletion && isDefined(updatedSourceRecord)) { + const targetRecordsToAttachTo = extractTargetRecordsFromRelation( + updatedFieldValueOnSourceRecord, + relation, + ); + + targetRecordsToAttachTo.forEach((targetRecordToAttachTo) => + triggerAttachRelationOptimisticEffect({ + cache, + sourceObjectNameSingular: sourceObjectMetadataItem.nameSingular, + sourceRecordId: updatedSourceRecord.id, + fieldNameOnTargetRecord: gqlFieldNameOnTargetRecord, + targetObjectNameSingular: targetObjectMetadata.nameSingular, + targetRecordId: targetRecordToAttachTo.id, + }), + ); + } +}; + +const triggerUpdateMorphRelationOptimisticEffect = ({ + fieldMetadataItemOnSourceRecord, + updatedSourceRecord, + currentSourceRecord, + objectMetadataItems, + sourceObjectMetadataItem, + cache, + isDeletion, +}: { + fieldMetadataItemOnSourceRecord: FieldMetadataItem; + updatedSourceRecord: RecordGqlNode | null; + currentSourceRecord: RecordGqlNode | null; + objectMetadataItems: ObjectMetadataItem[]; + sourceObjectMetadataItem: ObjectMetadataItem; + cache: ApolloCache; + isDeletion: boolean; +}) => { + const morphRelations = fieldMetadataItemOnSourceRecord.morphRelations; + if (!morphRelations) { + return; + } + + morphRelations.forEach((morphRelation) => { + const gqlFieldMorphRelation = computeMorphRelationFieldName({ + fieldName: fieldMetadataItemOnSourceRecord.name, + relationType: morphRelation.type, + targetObjectMetadataNameSingular: + morphRelation.targetObjectMetadata.nameSingular, + targetObjectMetadataNamePlural: + morphRelation.targetObjectMetadata.namePlural, + }); + + const { fieldMetadataItem: targetFieldMetadataFullObject } = + getFieldMetadataItemById({ + fieldMetadataId: morphRelation.targetFieldMetadata.id, + objectMetadataItems, + }); + if (!targetFieldMetadataFullObject) { + throw new CustomError( + 'Target field metadata full object not found', + 'TARGET_FIELD_METADATA_FULL_OBJECT_NOT_FOUND', + ); + } + + const fieldDoesNotExist = + isDefined(updatedSourceRecord) && + !(gqlFieldMorphRelation in updatedSourceRecord); + + if (fieldDoesNotExist) { + return; + } + + const { targetObjectMetadata, targetFieldMetadata } = morphRelation; + + const fullTargetObjectMetadataItem = objectMetadataItems.find( + ({ nameSingular }) => nameSingular === targetObjectMetadata.nameSingular, + ); + + if (!fullTargetObjectMetadataItem) { + return; + } + + const currentFieldValueOnSourceRecord: + | RecordGqlConnection + | RecordGqlNode + | null = currentSourceRecord?.[gqlFieldMorphRelation]; + + const updatedFieldValueOnSourceRecord: + | RecordGqlConnection + | RecordGqlNode + | null = updatedSourceRecord?.[gqlFieldMorphRelation]; + + const noDiff = isDeeplyEqual( + currentFieldValueOnSourceRecord, + updatedFieldValueOnSourceRecord, + { strict: true }, + ); + if (noDiff && !isDeletion) { + return; + } + + const recordToExtractDetachFrom = isDeletion + ? updatedFieldValueOnSourceRecord + : currentFieldValueOnSourceRecord; + const targetRecordsToDetachFrom = extractTargetRecordsFromRelation( + recordToExtractDetachFrom, + morphRelation, + ); + + // 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({ + cache, + objectMetadataItem: fullTargetObjectMetadataItem, + recordsToDestroy: targetRecordsToDetachFrom, + objectMetadataItems, + }); + } else if (isDefined(currentSourceRecord)) { + targetRecordsToDetachFrom.forEach((targetRecordToDetachFrom) => { + triggerDetachRelationOptimisticEffect({ + cache, + sourceObjectNameSingular: sourceObjectMetadataItem.nameSingular, + sourceRecordId: currentSourceRecord.id, + fieldNameOnTargetRecord: targetFieldMetadata.name, + targetObjectNameSingular: targetObjectMetadata.nameSingular, + targetRecordId: targetRecordToDetachFrom.id, + }); + }); + } + + if (!isDeletion && isDefined(updatedSourceRecord)) { + const targetRecordsToAttachTo = extractTargetRecordsFromRelation( + updatedFieldValueOnSourceRecord, + morphRelation, + ); + + targetRecordsToAttachTo.forEach((targetRecordToAttachTo) => + triggerAttachRelationOptimisticEffect({ + cache, + sourceObjectNameSingular: sourceObjectMetadataItem.nameSingular, + sourceRecordId: updatedSourceRecord.id, + fieldNameOnTargetRecord: targetFieldMetadata.name, + targetObjectNameSingular: targetObjectMetadata.nameSingular, + targetRecordId: targetRecordToAttachTo.id, + }), + ); + } + }); +}; + +const extractTargetRecordsFromRelation = ( + value: RecordGqlConnection | RecordGqlNode | null, + relation: FieldMetadataItemRelation, +): 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 []; + } + if (!isDefined(relation)) { + throw new Error('Relation found is undefined'); + } + if (isObjectRecordConnection(relation, value)) { + return value.edges.map(({ node }) => node); + } + + return [value]; }; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts b/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts index abbe91b3b9..4342e12e12 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts @@ -107,7 +107,7 @@ export const mapFieldMetadataToGraphQLQuery = ({ } if (fieldMetadata.settings?.relationType === RelationType.MANY_TO_ONE) { - if (gqlField === fieldMetadata.settings?.joinColumnName) { + if (gqlField === `${relationFieldName}Id`) { gqlMorphField += `${gqlField} `; continue; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/mapObjectMetadataToGraphQLQuery.ts b/packages/twenty-front/src/modules/object-metadata/utils/mapObjectMetadataToGraphQLQuery.ts index fd7bf14d69..88a8772ce0 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/mapObjectMetadataToGraphQLQuery.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/mapObjectMetadataToGraphQLQuery.ts @@ -71,14 +71,14 @@ export const mapObjectMetadataToGraphQLQuery = ({ } return fieldMetadata.morphRelations.map((morphRelation) => ({ - gqlField: computeMorphRelationFieldName({ + gqlField: `${computeMorphRelationFieldName({ fieldName: fieldMetadata.name, relationType: morphRelation.type, targetObjectMetadataNameSingular: morphRelation.targetObjectMetadata.nameSingular, targetObjectMetadataNamePlural: morphRelation.targetObjectMetadata.namePlural, - }), + })}Id`, fieldMetadata: fieldMetadata, })); }); diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/getFieldMetadataFromGqlField.test.ts b/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/getFieldMetadataFromGqlField.test.ts new file mode 100644 index 0000000000..ba65d2288d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/getFieldMetadataFromGqlField.test.ts @@ -0,0 +1,177 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { RelationType } from 'twenty-shared/types'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { getFieldMetadataFromGqlField } from '../getFieldMetadataFromGqlField'; + +const objectMetadataItemGenerator = (relationType: RelationType) => { + return { + nameSingular: 'opportunity', + namePlural: 'opportunities', + fields: [ + { + name: 'owner', + type: FieldMetadataType.MORPH_RELATION, + morphRelations: [ + { + type: relationType, + targetObjectMetadata: { + nameSingular: 'company', + namePlural: 'companies', + }, + targetFieldMetadata: { + name: 'ownedBy', + type: FieldMetadataType.RELATION, + }, + sourceFieldMetadata: { + name: 'owner', + type: FieldMetadataType.MORPH_RELATION, + }, + sourceObjectMetadata: { + nameSingular: 'opportunity', + namePlural: 'opportunities', + }, + }, + { + type: relationType, + targetObjectMetadata: { + nameSingular: 'person', + namePlural: 'people', + }, + targetFieldMetadata: { + name: 'ownedBy', + type: FieldMetadataType.RELATION, + }, + sourceFieldMetadata: { + name: 'owner', + type: FieldMetadataType.MORPH_RELATION, + }, + sourceObjectMetadata: { + nameSingular: 'opportunity', + namePlural: 'opportunities', + }, + }, + ], + } as unknown as FieldMetadataItem, + ], + }; +}; + +describe('getFieldMetadataFromGqlField', () => { + it('should find "name"', () => { + const objectMetadataItem = { + fields: [ + { + name: 'name', + type: FieldMetadataType.FULL_NAME, + } as unknown as FieldMetadataItem, + ], + }; + + const result = getFieldMetadataFromGqlField({ + objectMetadataItem, + gqlField: 'name', + }); + + expect(result).toBeDefined(); + expect(result?.name).toBe('name'); + expect(result?.type).toBe(FieldMetadataType.FULL_NAME); + }); + + it('should find company by join column name of a relation field', () => { + const objectMetadataItem = { + fields: [ + { + name: 'company', + type: FieldMetadataType.RELATION, + settings: { + joinColumnName: 'companyId', + }, + } as FieldMetadataItem, + ], + }; + + const result = getFieldMetadataFromGqlField({ + objectMetadataItem, + gqlField: 'companyId', + }); + + expect(result).toBeDefined(); + expect(result?.name).toBe('company'); + expect(result?.type).toBe(FieldMetadataType.RELATION); + expect(result?.settings?.joinColumnName).toBe('companyId'); + }); + + it('should return undefined for non-existent field', () => { + const objectMetadataItem = { + fields: [ + { + name: 'name', + type: FieldMetadataType.TEXT, + } as unknown as FieldMetadataItem, + ], + }; + + const result = getFieldMetadataFromGqlField({ + objectMetadataItem, + gqlField: 'nonExistentField', + }); + + expect(result).toBeUndefined(); + }); + + it('should find owner by direct morph relation ONE_TO_MANY field name', () => { + const objectMetadataItem = objectMetadataItemGenerator( + RelationType.ONE_TO_MANY, + ); + const result = getFieldMetadataFromGqlField({ + objectMetadataItem, + gqlField: 'owner', + }); + + expect(result).toBeDefined(); + expect(result?.name).toBe('owner'); + expect(result?.type).toBe(FieldMetadataType.MORPH_RELATION); + }); + + it('should find ownerCompanies by computed morph relation ONE_TO_MANY field name', () => { + const objectMetadataItem = objectMetadataItemGenerator( + RelationType.ONE_TO_MANY, + ); + const result = getFieldMetadataFromGqlField({ + objectMetadataItem, + gqlField: 'ownerCompanies', + }); + + expect(result).toBeDefined(); + expect(result?.name).toBe('owner'); + expect(result?.type).toBe(FieldMetadataType.MORPH_RELATION); + }); + + it('should find ownerPerson by computed morph relation MANY_TO_ONE field name', () => { + const objectMetadataItem = objectMetadataItemGenerator( + RelationType.MANY_TO_ONE, + ); + const result = getFieldMetadataFromGqlField({ + objectMetadataItem, + gqlField: 'ownerPerson', + }); + + expect(result).toBeDefined(); + expect(result?.name).toBe('owner'); + expect(result?.type).toBe(FieldMetadataType.MORPH_RELATION); + }); + + it('should find ownerPersonId by computed morph relation join column name MANY_TO_ONE field name', () => { + const objectMetadataItem = objectMetadataItemGenerator( + RelationType.MANY_TO_ONE, + ); + const result = getFieldMetadataFromGqlField({ + objectMetadataItem, + gqlField: 'ownerPersonId', + }); + + expect(result).toBeDefined(); + expect(result?.name).toBe('owner'); + expect(result?.type).toBe(FieldMetadataType.MORPH_RELATION); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/computePossibleMorphGqlFieldForFieldName.ts b/packages/twenty-front/src/modules/object-record/cache/utils/computePossibleMorphGqlFieldForFieldName.ts new file mode 100644 index 0000000000..48940e4a4d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/utils/computePossibleMorphGqlFieldForFieldName.ts @@ -0,0 +1,20 @@ +import { type FieldMorphRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata'; +import { computeMorphRelationFieldName } from 'twenty-shared/utils'; +export const computePossibleMorphGqlFieldForFieldName = ({ + fieldMetadata, +}: { + fieldMetadata: Pick< + FieldMorphRelationMetadata, + 'morphRelations' | 'fieldName' + >; +}) => + fieldMetadata.morphRelations.map((morphRelation) => { + return computeMorphRelationFieldName({ + fieldName: fieldMetadata.fieldName, + relationType: morphRelation.type, + targetObjectMetadataNameSingular: + morphRelation.targetObjectMetadata.nameSingular, + targetObjectMetadataNamePlural: + morphRelation.targetObjectMetadata.namePlural, + }); + }); diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getFieldMetadataFromGqlField.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getFieldMetadataFromGqlField.ts new file mode 100644 index 0000000000..36d1432ce4 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/utils/getFieldMetadataFromGqlField.ts @@ -0,0 +1,35 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { computePossibleMorphGqlFieldForFieldName } from '@/object-record/cache/utils/computePossibleMorphGqlFieldForFieldName'; +import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation'; + +export const getFieldMetadataFromGqlField = ({ + objectMetadataItem, + gqlField, +}: { + objectMetadataItem: Pick; + gqlField: string; +}) => { + return ( + objectMetadataItem.fields.find((field) => field.name === gqlField) ?? + objectMetadataItem.fields.find( + (field) => field.settings?.joinColumnName === gqlField, + ) ?? + objectMetadataItem.fields.filter(isFieldMorphRelation).find((field) => { + const morphRelations = field.morphRelations; + if (!morphRelations) return false; + const possibleMorphRelationsNames = + computePossibleMorphGqlFieldForFieldName({ + fieldMetadata: { + morphRelations: field.morphRelations ?? [], + fieldName: field.name, + }, + }); + return possibleMorphRelationsNames + .flatMap((possibleMorphRelationName) => [ + possibleMorphRelationName, + `${possibleMorphRelationName}Id`, + ]) + .includes(gqlField); + }) + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getFieldMetadataMorphRelationFromGqlField.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getFieldMetadataMorphRelationFromGqlField.ts new file mode 100644 index 0000000000..580c4af21e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/utils/getFieldMetadataMorphRelationFromGqlField.ts @@ -0,0 +1,74 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getFieldMetadataItemById } from '@/object-metadata/utils/getFieldMetadataItemById'; + +import { type FieldMorphRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata'; +import { computeMorphRelationFieldName } from 'twenty-shared/utils'; + +export const getFieldMetadataMorphRelationFromGqlField = ({ + objectMetadataItems, + fieldMetadata, + gqlField, +}: { + objectMetadataItems: ObjectMetadataItem[]; + fieldMetadata: Pick; + gqlField: string; +}) => { + const possibleMorphRelationGqlFields = fieldMetadata.morphRelations.map( + (morphRelation) => { + const targetObjectMetadata = objectMetadataItems.find( + (objectMetadataItem) => + objectMetadataItem.id === morphRelation.targetObjectMetadata.id, + ); + if (!targetObjectMetadata) { + return { + gqlField: undefined, + morphRelation: undefined, + }; + } + const computedName = computeMorphRelationFieldName({ + fieldName: morphRelation.sourceFieldMetadata.name, + relationType: morphRelation.type, + targetObjectMetadataNameSingular: targetObjectMetadata.nameSingular, + targetObjectMetadataNamePlural: targetObjectMetadata.namePlural, + }); + + return { + gqlField: computedName, + morphRelation, + }; + }, + ); + + const gqlFieldMorphRelation = possibleMorphRelationGqlFields.find( + (possibeMorphRelationGqlField) => + possibeMorphRelationGqlField.gqlField === gqlField, + ); + + if (!gqlFieldMorphRelation || !gqlFieldMorphRelation.morphRelation) { + return undefined; + } + + return { + morphRelation: gqlFieldMorphRelation.morphRelation, + sourceFieldMetadata: getFieldMetadataItemById({ + fieldMetadataId: + gqlFieldMorphRelation.morphRelation.sourceFieldMetadata.id, + objectMetadataItems, + }), + targetFieldMetadata: getFieldMetadataItemById({ + fieldMetadataId: + gqlFieldMorphRelation.morphRelation?.targetFieldMetadata.id, + objectMetadataItems, + }), + targetObjectMetadata: objectMetadataItems.find( + (objectMetadataItem) => + objectMetadataItem.id === + gqlFieldMorphRelation.morphRelation?.targetObjectMetadata.id, + ), + sourceObjectMetadata: objectMetadataItems.find( + (objectMetadataItem) => + objectMetadataItem.id === + gqlFieldMorphRelation.morphRelation?.sourceObjectMetadata.id, + ), + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordNodeFromRecord.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordNodeFromRecord.ts index d98911091c..0a3a9969ac 100644 --- a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordNodeFromRecord.ts +++ b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordNodeFromRecord.ts @@ -1,6 +1,7 @@ import { isNull, isUndefined } from '@sniptt/guards'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getFieldMetadataFromGqlField } from '@/object-record/cache/utils/getFieldMetadataFromGqlField'; import { getNodeTypename } from '@/object-record/cache/utils/getNodeTypename'; import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename'; import { getRecordConnectionFromRecords } from '@/object-record/cache/utils/getRecordConnectionFromRecords'; @@ -48,11 +49,10 @@ export const getRecordNodeFromRecord = ({ return undefined; } - const field = - objectMetadataItem.fields.find((field) => field.name === fieldName) ?? - objectMetadataItem.fields.find( - (field) => field.settings?.joinColumnName === fieldName, - ); + const field = getFieldMetadataFromGqlField({ + objectMetadataItem, + gqlField: fieldName, + }); if (isUndefined(field)) { return undefined; diff --git a/packages/twenty-front/src/modules/object-record/utils/computeOptimisticRecordFromInput.ts b/packages/twenty-front/src/modules/object-record/utils/computeOptimisticRecordFromInput.ts index 51345ef4ac..3bf593ba5f 100644 --- a/packages/twenty-front/src/modules/object-record/utils/computeOptimisticRecordFromInput.ts +++ b/packages/twenty-front/src/modules/object-record/utils/computeOptimisticRecordFromInput.ts @@ -2,6 +2,8 @@ import { isNull, isUndefined } from '@sniptt/guards'; import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getFieldMetadataFromGqlField } from '@/object-record/cache/utils/getFieldMetadataFromGqlField'; +import { getFieldMetadataMorphRelationFromGqlField } from '@/object-record/cache/utils/getFieldMetadataMorphRelationFromGqlField'; import { getRecordFromCache, type GetRecordFromCacheArgs, @@ -51,16 +53,9 @@ export const computeOptimisticRecordFromInput = ({ objectMetadataItem.fields.find((field) => { if (!isFieldMorphRelation(field)) return false; - return field.morphRelations?.some((morphRelation) => { - const computedFieldName = computeMorphRelationFieldName({ - fieldName: field.name, - relationType: morphRelation.type, - targetObjectMetadataNameSingular: - morphRelation.targetObjectMetadata.nameSingular, - targetObjectMetadataNamePlural: - morphRelation.targetObjectMetadata.namePlural, - }); - return computedFieldName === recordKey.replace('Id', ''); + return getFieldMetadataFromGqlField({ + objectMetadataItem, + gqlField: recordKey, }); }); @@ -120,7 +115,8 @@ export const computeOptimisticRecordFromInput = ({ } const isRelationField = isFieldRelation(fieldMetadataItem); - if (!isRelationField) { + const isMorphRelationField = isFieldMorphRelation(fieldMetadataItem); + if (!isRelationField && !isMorphRelationField) { if (!isDefined(recordInputFieldValue)) { continue; } @@ -133,78 +129,183 @@ export const computeOptimisticRecordFromInput = ({ continue; } - if (fieldMetadataItem.relation?.type === RelationType.ONE_TO_MANY) { - continue; - } + if (isRelationField) { + if (fieldMetadataItem.relation?.type === RelationType.ONE_TO_MANY) { + continue; + } - const isManyToOneRelation = - fieldMetadataItem.relation?.type === RelationType.MANY_TO_ONE; - if (!isManyToOneRelation) { - continue; - } + const isManyToOneRelation = + fieldMetadataItem.relation?.type === RelationType.MANY_TO_ONE; + if (!isManyToOneRelation) { + continue; + } - if (!isUndefined(recordInputFieldValue)) { - throw new Error( - `Should never provide relation mutation through anything else than the fieldId e.g companyId and not company, encountered: ${fieldMetadataItem.name}`, + if (!isUndefined(recordInputFieldValue)) { + throw new Error( + `Should never provide relation mutation through anything else than the fieldId e.g companyId and not company, encountered: ${fieldMetadataItem.name}`, + ); + } + + const relationFieldIdName = getForeignKeyNameFromRelationFieldName( + fieldMetadataItem.name, ); - } - const relationFieldIdName = getForeignKeyNameFromRelationFieldName( - fieldMetadataItem.name, - ); + const recordInputFieldIdValue: string | null | undefined = + recordInput[relationFieldIdName]; - const recordInputFieldIdValue: string | null | undefined = - recordInput[relationFieldIdName]; + if (isUndefined(recordInputFieldIdValue)) { + continue; + } - if (isUndefined(recordInputFieldIdValue)) { - continue; - } - - const relationIdFieldMetadataItem = objectMetadataItem.fields.find( - (field) => field.name === relationFieldIdName, - ); - - if ( - !isDefined(relationIdFieldMetadataItem) && - !isDefined(fieldMetadataItem.settings?.joinColumnName) - ) { - throw new Error( - 'Should never occur, encountered unknown relationId within relations definitions', + const relationIdFieldMetadataItem = objectMetadataItem.fields.find( + (field) => field.name === relationFieldIdName, ); - } - if (isNull(recordInputFieldIdValue)) { - optimisticRecord[relationFieldIdName] = null; - optimisticRecord[fieldMetadataItem.name] = null; - continue; - } + if ( + !isDefined(relationIdFieldMetadataItem) && + !isDefined(fieldMetadataItem.settings?.joinColumnName) + ) { + throw new Error( + 'Should never occur, encountered unknown relationId within relations definitions', + ); + } - const targetNameSingular = - fieldMetadataItem.relation?.targetObjectMetadata.nameSingular; - const targetObjectMetataDataItem = objectMetadataItems.find( - ({ nameSingular }) => nameSingular === targetNameSingular, - ); - if (!isDefined(targetObjectMetataDataItem)) { - throw new Error( - 'Should never occur, encountered invalid relation definition', + if (isNull(recordInputFieldIdValue)) { + optimisticRecord[relationFieldIdName] = null; + optimisticRecord[fieldMetadataItem.name] = null; + continue; + } + + const targetNameSingular = + fieldMetadataItem.relation?.targetObjectMetadata.nameSingular; + const targetObjectMetataDataItem = objectMetadataItems.find( + ({ nameSingular }) => nameSingular === targetNameSingular, ); + if (!isDefined(targetObjectMetataDataItem)) { + throw new Error( + 'Should never occur, encountered invalid relation definition', + ); + } + + const cachedRecord = getRecordFromCache({ + cache, + objectMetadataItem: targetObjectMetataDataItem, + objectMetadataItems, + recordId: recordInputFieldIdValue as string, + objectPermissionsByObjectMetadataId, + }); + + optimisticRecord[relationFieldIdName] = recordInputFieldIdValue; + + if (!isDefined(cachedRecord) || Object.keys(cachedRecord).length <= 0) { + continue; + } + + optimisticRecord[fieldMetadataItem.name] = cachedRecord; } - const cachedRecord = getRecordFromCache({ - cache, - objectMetadataItem: targetObjectMetataDataItem, - objectMetadataItems, - recordId: recordInputFieldIdValue as string, - objectPermissionsByObjectMetadataId, - }); + if (isMorphRelationField) { + const relationType = fieldMetadataItem.settings?.relationType; + if (relationType === RelationType.ONE_TO_MANY) { + continue; + } - optimisticRecord[relationFieldIdName] = recordInputFieldIdValue; + const isManyToOneRelation = relationType === RelationType.MANY_TO_ONE; + if (!isManyToOneRelation) { + continue; + } - if (!isDefined(cachedRecord) || Object.keys(cachedRecord).length <= 0) { - continue; + if (!isUndefined(recordInputFieldValue)) { + throw new Error( + `Should never provide relation mutation through anything else than the fieldId e.g companyId and not company, encountered: ${fieldMetadataItem.name}`, + ); + } + + const relationFieldIdNames = fieldMetadataItem.morphRelations?.map( + (morphRelation) => { + return computeMorphRelationFieldName({ + fieldName: fieldMetadataItem.name, + relationType, + targetObjectMetadataNameSingular: + morphRelation.targetObjectMetadata.nameSingular, + targetObjectMetadataNamePlural: + morphRelation.targetObjectMetadata.namePlural, + }); + }, + ); + + const relationFieldName = relationFieldIdNames?.find( + (relationFieldIdName) => recordInput[`${relationFieldIdName}Id`], + ); + + const relationFieldIdName = `${relationFieldName}Id`; + + if (isUndefined(relationFieldName)) { + continue; + } + + const recordInputFieldIdValue: string | null | undefined = + recordInput[relationFieldIdName]; + + if (isUndefined(recordInputFieldIdValue)) { + continue; + } + + const relationIdFieldMetadataItem = getFieldMetadataFromGqlField({ + objectMetadataItem, + gqlField: relationFieldName, + }); + + if ( + !isDefined(relationIdFieldMetadataItem) && + !isDefined(fieldMetadataItem.settings?.joinColumnName) + ) { + throw new Error( + 'Should never occur, encountered unknown relationId within relations definitions', + ); + } + + if (isNull(recordInputFieldIdValue)) { + optimisticRecord[relationFieldName] = null; + optimisticRecord[fieldMetadataItem.name] = null; + continue; + } + + if (!isDefined(fieldMetadataItem.morphRelations)) { + throw new Error( + 'Should never occur, encountered invalid relation definition', + ); + } + + const fieldMetadataMorphRelation = + getFieldMetadataMorphRelationFromGqlField({ + objectMetadataItems, + fieldMetadata: { morphRelations: fieldMetadataItem.morphRelations }, + gqlField: relationFieldName, + }); + + if (!isDefined(fieldMetadataMorphRelation?.targetObjectMetadata)) { + throw new Error( + 'Should never occur, encountered invalid relation definition', + ); + } + + const cachedRecord = getRecordFromCache({ + cache, + objectMetadataItem: fieldMetadataMorphRelation.targetObjectMetadata, + objectMetadataItems, + recordId: recordInputFieldIdValue as string, + objectPermissionsByObjectMetadataId, + }); + + optimisticRecord[relationFieldIdName] = recordInputFieldIdValue; + + if (!isDefined(cachedRecord) || Object.keys(cachedRecord).length <= 0) { + continue; + } + + optimisticRecord[relationFieldName] = cachedRecord; } - - optimisticRecord[fieldMetadataItem.name] = cachedRecord; } return optimisticRecord;