Morph-front-display-table (#13979)

## Add Morph Relation Field Display Support for Tables (one to many &
many to one)

**Features:**
- Display morph relation fields (one-to-many, many-to-one) in table
cells
- Multi-field record retrieval for improved performance
- Enhanced table cell rendering with morph relation support

**Components Added:**
- `MorphRelationManyToOneFieldDisplay` - Shows many-to-one morph
relations
- `MorphRelationOneToManyFieldDisplay` - Shows one-to-many morph
relations


TODO : 
handle notes and tasks case if we choose these kind of objects as
targets/source for a morph relation

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Guillim
2025-08-25 16:12:49 +02:00
committed by GitHub
parent dff00e813c
commit 3d1842dcb3
75 changed files with 1656 additions and 1098 deletions
@@ -229,3 +229,5 @@ idealCustomerProfile
);
});
});
// todo @guillim add a test for the morph relation
@@ -1,5 +1,4 @@
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
import { isUndefined } from '@sniptt/guards';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
@@ -7,7 +6,11 @@ import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObject
import { type RecordGqlFields } from '@/object-record/graphql/types/RecordGqlFields';
import { isNonCompositeField } from '@/object-record/object-filter-dropdown/utils/isNonCompositeField';
import { type ObjectPermissions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
computeMorphRelationFieldJoinColumnName,
computeMorphRelationFieldName,
isDefined,
} from 'twenty-shared/utils';
import { type FieldMetadataItem } from '../types/FieldMetadataItem';
type MapFieldMetadataToGraphQLQueryArgs = {
@@ -15,7 +18,7 @@ type MapFieldMetadataToGraphQLQueryArgs = {
gqlField: string;
fieldMetadata: Pick<
FieldMetadataItem,
'name' | 'type' | 'relation' | 'settings'
'name' | 'type' | 'relation' | 'morphRelations' | 'settings'
>;
relationRecordGqlFields?: RecordGqlFields;
computeReferences?: boolean;
@@ -41,6 +44,90 @@ export const mapFieldMetadataToGraphQLQuery = ({
return gqlField;
}
if (
fieldType === FieldMetadataType.MORPH_RELATION &&
(fieldMetadata.settings?.relationType === RelationType.ONE_TO_MANY ||
fieldMetadata.settings?.relationType === RelationType.MANY_TO_ONE)
) {
let gqlMorphField = '';
for (const morphRelation of fieldMetadata.morphRelations ?? []) {
const relationFieldName = computeMorphRelationFieldName({
fieldName: fieldMetadata.name,
relationDirection: fieldMetadata.settings?.relationType,
nameSingular: morphRelation.targetObjectMetadata.nameSingular,
namePlural: morphRelation.targetObjectMetadata.namePlural,
});
const relationMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.id === morphRelation.targetObjectMetadata.id,
);
if (!isDefined(relationMetadataItem)) {
continue;
}
if (
isDefined(objectPermissionsByObjectMetadataId) &&
isDefined(relationMetadataItem.id)
) {
if (!isDefined(morphRelation.targetObjectMetadata.id)) {
throw new Error(
`Target object metadata id not found with field metadata ${fieldMetadata.name}`,
);
}
const objectPermission = getObjectPermissionsForObject(
objectPermissionsByObjectMetadataId,
morphRelation.targetObjectMetadata.id,
);
if (!objectPermission.canReadObjectRecords) {
continue;
}
}
if (fieldMetadata.settings?.relationType === RelationType.ONE_TO_MANY) {
gqlMorphField += `${relationFieldName}
{
edges {
node ${mapObjectMetadataToGraphQLQuery({
objectMetadataItems,
objectMetadataItem: relationMetadataItem,
recordGqlFields: relationRecordGqlFields,
computeReferences,
isRootLevel: false,
objectPermissionsByObjectMetadataId,
})}
}
}`;
}
if (fieldMetadata.settings?.relationType === RelationType.MANY_TO_ONE) {
const joinColumnName = computeMorphRelationFieldJoinColumnName({
name: fieldMetadata.name,
targetObjectMetadataNameSingular:
morphRelation.targetObjectMetadata.nameSingular,
});
if (gqlField === joinColumnName) {
gqlMorphField += `${gqlField}
`;
continue;
}
gqlMorphField += `${relationFieldName}
${mapObjectMetadataToGraphQLQuery({
objectMetadataItems,
objectMetadataItem: relationMetadataItem,
recordGqlFields: relationRecordGqlFields,
computeReferences,
isRootLevel: false,
objectPermissionsByObjectMetadataId,
})}`;
}
}
return `${gqlMorphField}`;
}
if (
fieldType === FieldMetadataType.RELATION &&
fieldMetadata.relation?.type === RelationType.MANY_TO_ONE
@@ -51,7 +138,7 @@ export const mapFieldMetadataToGraphQLQuery = ({
fieldMetadata.relation?.targetObjectMetadata.id,
);
if (isUndefined(relationMetadataItem)) {
if (!isDefined(relationMetadataItem)) {
return '';
}
@@ -100,7 +187,7 @@ ${mapObjectMetadataToGraphQLQuery({
fieldMetadata.relation?.targetObjectMetadata.id,
);
if (isUndefined(relationMetadataItem)) {
if (!isDefined(relationMetadataItem)) {
return '';
}
@@ -5,7 +5,7 @@ import { shouldFieldBeQueried } from '@/object-metadata/utils/shouldFieldBeQueri
import { type RecordGqlFields } from '@/object-record/graphql/types/RecordGqlFields';
import { isRecordGqlFieldsNode } from '@/object-record/graphql/utils/isRecordGraphlFieldsNode';
import { FieldMetadataType, type ObjectPermissions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
type MapObjectMetadataToGraphQLQueryArgs = {
objectMetadataItems: ObjectMetadataItem[];
@@ -46,22 +46,75 @@ export const mapObjectMetadataToGraphQLQuery = ({
const manyToOneRelationFields = objectMetadataItem?.readableFields
.filter((field) => field.isActive)
.filter((field) => field.type === FieldMetadataType.RELATION)
.filter(
(field) =>
field.type === FieldMetadataType.RELATION ||
field.type === FieldMetadataType.MORPH_RELATION,
)
.filter((field) => isDefined(field.settings?.joinColumnName));
const manyToOneRelationGqlFieldWithFieldMetadata =
manyToOneRelationFields.map((field) => ({
gqlField: field.settings?.joinColumnName,
fieldMetadata: field,
manyToOneRelationFields.flatMap((field) => {
const isMorphRelation = field.type === FieldMetadataType.MORPH_RELATION;
if (!isMorphRelation) {
return {
gqlField: field.settings?.joinColumnName,
fieldMetadata: field,
};
}
if (!isDefined(field.morphRelations)) {
throw new Error(
`Field ${field.name} is missing, please refresh the page. If the problem persists, please contact support.`,
);
}
return field.morphRelations.map((morphRelation) => ({
gqlField: computeMorphRelationFieldName({
fieldName: field.name,
relationDirection: morphRelation.type,
nameSingular: morphRelation.targetObjectMetadata.nameSingular,
namePlural: morphRelation.targetObjectMetadata.namePlural,
}),
fieldMetadata: field,
}));
});
const readableFields = objectMetadataItem.readableFields.filter(
(fieldMetadata) => fieldMetadata.isActive,
);
const activeReadableFields = readableFields.flatMap((fieldMetadata) => {
const isMorphRelation =
fieldMetadata.type === FieldMetadataType.MORPH_RELATION;
if (!isMorphRelation) {
return [
{
gqlField: fieldMetadata.name,
fieldMetadata,
},
];
}
if (!isDefined(fieldMetadata.morphRelations)) {
throw new Error(
`Field ${fieldMetadata.name} is missing, please refresh the page. If the problem persists, please contact support.`,
);
}
return fieldMetadata.morphRelations.map((morphRelation) => ({
gqlField: computeMorphRelationFieldName({
fieldName: fieldMetadata.name,
relationDirection: morphRelation.type,
nameSingular: morphRelation.targetObjectMetadata.nameSingular,
namePlural: morphRelation.targetObjectMetadata.namePlural,
}),
fieldMetadata,
}));
});
const gqlFieldWithFieldMetadataThatCouldBeQueried = [
...objectMetadataItem.readableFields
.filter((fieldMetadata) => fieldMetadata.isActive)
.map((fieldMetadata) => ({
gqlField: fieldMetadata.name,
fieldMetadata,
})),
...activeReadableFields,
...manyToOneRelationGqlFieldWithFieldMetadata,
].sort((gqlFieldWithFieldMetadataA, gqlFieldWithFieldMetadataB) =>
gqlFieldWithFieldMetadataA.gqlField.localeCompare(
@@ -1,8 +1,8 @@
import { type RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { isDefined } from 'twenty-shared/utils';
import { type FieldMetadataItem } from '../types/FieldMetadataItem';
@@ -18,16 +18,21 @@ export const shouldFieldBeQueried = ({
recordGqlFields?: RecordGqlOperationGqlRecordFields;
}): any => {
const isJoinColumn: boolean =
isFieldRelation(fieldMetadata) &&
(isFieldRelation(fieldMetadata) || isFieldMorphRelation(fieldMetadata)) &&
fieldMetadata.settings.joinColumnName === gqlField;
if (
isUndefinedOrNull(recordGqlFields) &&
(fieldMetadata.type !== FieldMetadataType.RELATION || isJoinColumn)
!isFieldRelation(fieldMetadata) &&
!isFieldMorphRelation(fieldMetadata)
) {
return true;
}
if (isUndefinedOrNull(recordGqlFields) && isJoinColumn) {
return true;
}
if (
isDefined(recordGqlFields) &&
isDefined(recordGqlFields[gqlField]) &&