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 <charles@twenty.com>
This commit is contained in:
Guillim
2025-10-10 19:11:00 +02:00
committed by GitHub
parent 00eef8ba33
commit 448091e81f
9 changed files with 814 additions and 198 deletions
@@ -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<unknown>;
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<unknown>;
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<unknown>;
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];
};
@@ -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;
@@ -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,
}));
});
@@ -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);
});
});
@@ -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,
});
});
@@ -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<ObjectMetadataItem, 'fields'>;
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);
})
);
};
@@ -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<FieldMorphRelationMetadata, 'morphRelations'>;
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,
),
};
};
@@ -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 = <T extends ObjectRecord>({
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;
@@ -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;