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:
+5
-1
@@ -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,
|
||||
|
||||
+9
@@ -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) ? (
|
||||
|
||||
+33
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+45
@@ -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>
|
||||
);
|
||||
};
|
||||
+61
@@ -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,
|
||||
};
|
||||
};
|
||||
+78
@@ -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,
|
||||
};
|
||||
};
|
||||
+1
-1
@@ -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 =
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+35
-3
@@ -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,
|
||||
}),
|
||||
|
||||
+46
@@ -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;
|
||||
},
|
||||
});
|
||||
+43
@@ -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;
|
||||
},
|
||||
});
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user