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
@@ -9,7 +9,11 @@ export const generateDepthOneWithoutRelationsRecordGqlFields = ({
objectMetadataItem,
}: GenerateDepthOneWithoutRelationsRecordGqlFields) => {
return objectMetadataItem.fields
.filter((field) => field.type !== FieldMetadataType.RELATION)
.filter(
(field) =>
field.type !== FieldMetadataType.RELATION &&
field.type !== FieldMetadataType.MORPH_RELATION,
)
.reduce<Record<string, true>>((acc, field) => {
return {
...acc,
@@ -23,6 +23,11 @@ import { isFieldRelationFromManyObjects } from '@/object-record/record-field/ui/
import { isFieldRelationToOneObject } from '@/object-record/record-field/ui/types/guards/isFieldRelationToOneObject';
import { isFieldRichText } from '@/object-record/record-field/ui/types/guards/isFieldRichText';
import { isFieldRichTextV2 } from '@/object-record/record-field/ui/types/guards/isFieldRichTextV2';
import { MorphRelationManyToOneFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/MorphRelationManyToOneFieldDisplay';
import { MorphRelationOneToManyFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/MorphRelationOneToManyFieldDisplay';
import { isFieldMorphRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationManyToOne';
import { isFieldMorphRelationOneToMany } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationOneToMany';
import { isDefined } from 'twenty-shared/utils';
import { FieldContext } from '../contexts/FieldContext';
import { AddressFieldDisplay } from '../meta-types/display/components/AddressFieldDisplay';
@@ -73,6 +78,10 @@ export const FieldDisplay = () => {
<RelationToOneFieldDisplay />
) : isFieldRelationFromManyObjects(fieldDefinition) ? (
<RelationFromManyFieldDisplay />
) : isFieldMorphRelationManyToOne(fieldDefinition) ? (
<MorphRelationManyToOneFieldDisplay />
) : isFieldMorphRelationOneToMany(fieldDefinition) ? (
<MorphRelationOneToManyFieldDisplay />
) : isFieldText(fieldDefinition) ? (
<TextFieldDisplay />
) : isFieldUuid(fieldDefinition) ? (
@@ -0,0 +1,33 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { RecordChip } from '@/object-record/components/RecordChip';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { useMorphRelationToOneFieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/useMorphRelationToOneFieldDisplay';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const MorphRelationManyToOneFieldDisplay = () => {
const { morphFieldValuesWithObjectName } =
useMorphRelationToOneFieldDisplay();
const { disableChipClick, triggerEvent } = useContext(FieldContext);
if (!isDefined(morphFieldValuesWithObjectName?.value)) {
return null;
}
const isWorkspaceMemberFieldMetadataRelation =
morphFieldValuesWithObjectName.objectNameSingular ===
CoreObjectNameSingular.WorkspaceMember;
return (
<RecordChip
key={morphFieldValuesWithObjectName.value.id}
objectNameSingular={morphFieldValuesWithObjectName.objectNameSingular}
record={morphFieldValuesWithObjectName.value}
forceDisableClick={
isWorkspaceMemberFieldMetadataRelation || disableChipClick
}
triggerEvent={triggerEvent}
/>
);
};
@@ -0,0 +1,45 @@
import { RecordChip } from '@/object-record/components/RecordChip';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { useFieldFocus } from '@/object-record/record-field/ui/hooks/useFieldFocus';
import { useMorphRelationFromManyFieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/useMorphRelationFromManyFieldDisplay';
import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
import { Fragment, useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const MorphRelationOneToManyFieldDisplay = () => {
const { morphValuesWithObjectNameSingular } =
useMorphRelationFromManyFieldDisplay();
const { isFocused } = useFieldFocus();
const { disableChipClick, triggerEvent } = useContext(FieldContext);
if (!morphValuesWithObjectNameSingular) {
return null;
}
return (
<ExpandableList isChipCountDisplayed={isFocused}>
{morphValuesWithObjectNameSingular
.filter(isDefined)
.map((morphValueWithObjectNameSingular) => {
return (
<Fragment key={morphValueWithObjectNameSingular.objectNameSingular}>
{morphValueWithObjectNameSingular.value.map((record) => {
return (
<RecordChip
key={record.id}
objectNameSingular={
morphValueWithObjectNameSingular.objectNameSingular
}
record={record}
forceDisableClick={disableChipClick}
triggerEvent={triggerEvent}
/>
);
})}
</Fragment>
);
})}
</ExpandableList>
);
};
@@ -0,0 +1,61 @@
import { isNonEmptyString } from '@sniptt/guards';
import { useContext } from 'react';
import { PreComputedChipGeneratorsContext } from '@/object-metadata/contexts/PreComputedChipGeneratorsContext';
import { FIELD_EDIT_BUTTON_WIDTH } from '@/ui/field/display/constants/FieldEditButtonWidth';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { recordStoreMorphOneToManyValueWithObjectNameFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreMorphOneToManyValueWithObjectNameFamilySelector';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { FieldContext } from '../../contexts/FieldContext';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';
export const useMorphRelationFromManyFieldDisplay = () => {
const { recordId, fieldDefinition, maxWidth } = useContext(FieldContext);
const { chipGeneratorPerObjectPerField } = useContext(
PreComputedChipGeneratorsContext,
);
if (!isDefined(chipGeneratorPerObjectPerField)) {
throw new Error('Chip generator per object per field is not defined');
}
assertFieldMetadata(
FieldMetadataType.MORPH_RELATION,
isFieldMorphRelation,
fieldDefinition,
);
const button = fieldDefinition.editButtonIcon;
const morphValuesWithObjectNameSingular = useRecoilValue(
recordStoreMorphOneToManyValueWithObjectNameFamilySelector({
recordId,
morphRelations: fieldDefinition.metadata.morphRelations,
fieldName: fieldDefinition.metadata.fieldName,
}),
);
const maxWidthForField =
isDefined(button) && isDefined(maxWidth)
? maxWidth - FIELD_EDIT_BUTTON_WIDTH
: maxWidth;
if (
!isDefined(fieldDefinition.metadata.objectMetadataNameSingular) ||
!isNonEmptyString(fieldDefinition.metadata.objectMetadataNameSingular)
) {
throw new Error('Object metadata name singular is not a non-empty string');
}
return {
fieldDefinition,
morphValuesWithObjectNameSingular,
maxWidth: maxWidthForField,
recordId,
};
};
@@ -0,0 +1,78 @@
import { isNonEmptyString } from '@sniptt/guards';
import { useContext } from 'react';
import { PreComputedChipGeneratorsContext } from '@/object-metadata/contexts/PreComputedChipGeneratorsContext';
import { FIELD_EDIT_BUTTON_WIDTH } from '@/ui/field/display/constants/FieldEditButtonWidth';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { assertFieldMetadata } from '@/object-record/record-field/ui/types/guards/assertFieldMetadata';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { recordStoreMorphManyToOneValueWithObjectNameFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreMorphManyToOneValueWithObjectNameFamilySelector';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const useMorphRelationToOneFieldDisplay = () => {
const { recordId, fieldDefinition, maxWidth } = useContext(FieldContext);
const { chipGeneratorPerObjectPerField } = useContext(
PreComputedChipGeneratorsContext,
);
if (!isDefined(chipGeneratorPerObjectPerField)) {
throw new Error('Chip generator per object per field is not defined');
}
assertFieldMetadata(
FieldMetadataType.MORPH_RELATION,
isFieldMorphRelation,
fieldDefinition,
);
const button = fieldDefinition.editButtonIcon;
const fieldName = fieldDefinition.metadata.fieldName;
const morphFieldValueWithObjectName = useRecoilValue(
recordStoreMorphManyToOneValueWithObjectNameFamilySelector({
recordId,
morphRelations: fieldDefinition.metadata.morphRelations,
fieldName,
}),
);
if (!isDefined(morphFieldValueWithObjectName)) {
return {
fieldDefinition,
fieldValues: [],
maxWidth: maxWidth,
recordId,
generateRecordChipData: () => ({
recordId: '',
objectNameSingular: '',
}),
};
}
const maxWidthForField =
isDefined(button) && isDefined(maxWidth)
? maxWidth - FIELD_EDIT_BUTTON_WIDTH
: maxWidth;
if (
!isDefined(fieldDefinition.metadata.objectMetadataNameSingular) ||
!isNonEmptyString(fieldDefinition.metadata.objectMetadataNameSingular)
) {
throw new Error('Object metadata name singular is not a non-empty string');
}
return {
fieldDefinition,
morphFieldValuesWithObjectName: morphFieldValueWithObjectName,
maxWidth: maxWidthForField,
recordId,
};
};
@@ -48,7 +48,7 @@ export const useRelationToOneFieldDisplay = () => {
!isDefined(fieldDefinition.metadata.objectMetadataNameSingular) ||
!isNonEmptyString(fieldDefinition.metadata.objectMetadataNameSingular)
) {
throw new Error('Object metadata name singular is not a non-empty string');
throw new Error('Object metadata name singular is empty');
}
const fieldChipGenerator =
@@ -10,4 +10,4 @@ export const isFieldMorphRelationManyToOne = (
field: Pick<FieldDefinition<FieldMetadata>, 'type' | 'metadata'>,
): field is FieldDefinition<FieldMorphRelationMetadata> =>
isFieldMorphRelation(field) &&
field.metadata.morphRelations?.[0].type === RelationType.MANY_TO_ONE;
field.metadata.relationType === RelationType.MANY_TO_ONE;
@@ -5,6 +5,8 @@ import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/g
import { generateDepthOneWithoutRelationsRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneWithoutRelationsRecordGqlFields';
import { visibleTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { FieldMetadataType } from 'twenty-shared/types';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
export const useRecordTableRecordGqlFields = ({
objectMetadataItem,
@@ -30,11 +32,41 @@ export const useRecordTableRecordGqlFields = ({
objectMetadataItem,
});
const gqlFieldsList = Object.fromEntries(
visibleTableColumns.flatMap((column) => {
const isMorphRelation = column.type === FieldMetadataType.MORPH_RELATION;
if (!isMorphRelation) {
return [[column.metadata.fieldName, true]];
}
const fieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.id === column.fieldMetadataId,
);
if (
!isDefined(fieldMetadataItem) ||
!isDefined(fieldMetadataItem.morphRelations)
) {
throw new Error(
`Field ${column.metadata.fieldName} is missing, please refresh the page. If the problem persists, please contact support.`,
);
}
return fieldMetadataItem.morphRelations.map((morphRelation) => [
computeMorphRelationFieldName({
fieldName: fieldMetadataItem.name,
relationDirection: morphRelation.type,
nameSingular: morphRelation.targetObjectMetadata.nameSingular,
namePlural: morphRelation.targetObjectMetadata.namePlural,
}),
true,
]);
}),
);
const recordGqlFields: Record<string, any> = {
...allDepthOneWithoutRelationsRecordGqlFields,
...Object.fromEntries(
visibleTableColumns.map((column) => [column.metadata.fieldName, true]),
),
...gqlFieldsList,
noteTargets: generateDepthOneRecordGqlFields({
objectMetadataItem: noteTargetObjectMetadataItem,
}),
@@ -0,0 +1,46 @@
import { selectorFamily } from 'recoil';
import { type FieldMetadataItemRelation } from '@/object-metadata/types/FieldMetadataItemRelation';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { RelationType } from 'twenty-shared/types';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
export const recordStoreMorphManyToOneValueWithObjectNameFamilySelector =
selectorFamily({
key: 'recordStoreMorphManyToOneValueWithObjectNameFamilySelector',
get:
({
fieldName,
recordId,
morphRelations,
}: {
fieldName: string;
recordId: string;
morphRelations: FieldMetadataItemRelation[];
}) =>
({ get }) => {
const morphValuesWithObjectName = morphRelations.map(
(morphRelation) => {
const computedFieldName = computeMorphRelationFieldName({
fieldName,
relationDirection: RelationType.MANY_TO_ONE,
nameSingular: morphRelation.targetObjectMetadata.nameSingular,
namePlural: morphRelation.targetObjectMetadata.namePlural,
});
return {
objectNameSingular:
morphRelation.targetObjectMetadata.nameSingular,
value: get(recordStoreFamilyState(recordId))?.[
computedFieldName
] as ObjectRecord,
};
},
);
const morphValueFiltered = morphValuesWithObjectName.filter(
(morphValue) => isDefined(morphValue.value),
);
return morphValueFiltered.length > 0 ? morphValueFiltered[0] : null;
},
});
@@ -0,0 +1,43 @@
import { selectorFamily } from 'recoil';
import { type FieldMetadataItemRelation } from '@/object-metadata/types/FieldMetadataItemRelation';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { RelationType } from 'twenty-shared/types';
import { computeMorphRelationFieldName } from 'twenty-shared/utils';
export const recordStoreMorphOneToManyValueWithObjectNameFamilySelector =
selectorFamily({
key: 'recordStoreMorphOneToManyValueWithObjectNameFamilySelector',
get:
({
fieldName,
recordId,
morphRelations,
}: {
fieldName: string;
recordId: string;
morphRelations: FieldMetadataItemRelation[];
}) =>
({ get }) => {
const morphValuesWithObjectName = morphRelations.map(
(morphRelation) => {
const computedFieldName = computeMorphRelationFieldName({
fieldName,
relationDirection: RelationType.ONE_TO_MANY,
nameSingular: morphRelation.targetObjectMetadata.nameSingular,
namePlural: morphRelation.targetObjectMetadata.namePlural,
});
return {
objectNameSingular:
morphRelation.targetObjectMetadata.nameSingular,
value: (get(recordStoreFamilyState(recordId))?.[
computedFieldName
] || []) as ObjectRecord[],
};
},
);
return morphValuesWithObjectName;
},
});
@@ -29,7 +29,7 @@ export const RecordTableCellFieldContextGeneric = ({
const updateRecord = useContext(RecordUpdateContext);
let hasObjectReadPermissions = objectPermissions.canReadObjectRecords;
// todo @guillim : adjust this to handle morph relations permissions display
if (
isFieldRelationToOneObject(columnDefinition) ||
isFieldRelationFromManyObjects(columnDefinition)
@@ -4,7 +4,10 @@ import { assertUnreachable } from 'twenty-shared/utils';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
export type GenerateEmptyFieldValueArgs = {
fieldMetadataItem: Pick<FieldMetadataItem, 'type' | 'relation'>;
fieldMetadataItem: Pick<
FieldMetadataItem,
'type' | 'settings' | 'defaultValue'
>;
shouldComputeFunctionDefaultValue?: boolean;
};
// TODO strictly type each fieldValue following their FieldMetadataType
@@ -62,11 +65,13 @@ export const generateEmptyFieldValue = ({
return null;
}
case FieldMetadataType.BOOLEAN: {
return true;
return fieldMetadataItem?.defaultValue ?? true;
}
case FieldMetadataType.RELATION:
case FieldMetadataType.MORPH_RELATION: {
if (fieldMetadataItem.relation?.type === RelationType.MANY_TO_ONE) {
if (
fieldMetadataItem.settings?.relationType === RelationType.MANY_TO_ONE
) {
return null;
}