first step in the show record page with a morph relation. (#15109)

Adds the field on the Show page and side panel for Morph relation
fields.

Updates
`packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/hooks/useOpenMorphRelationOneToManyFieldInput.tsx`
to use the correct `recordPickerInstanceId` when opening the picker so
the helper consistently renders (fixes [core-team-issues
#1324](https://github.com/twentyhq/core-team-issues/issues/1324)).

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Guillim
2025-10-23 17:21:49 +02:00
committed by GitHub
parent eb2fd7c383
commit 4e767799c6
25 changed files with 1019 additions and 162 deletions
@@ -36,6 +36,7 @@ export const useCreateOneRecordInCache = <T extends ObjectRecord>({
record: prefilledRecord,
depth: 1,
});
const fragment = gql`
fragment Create${capitalize(
objectMetadataItem.nameSingular,
@@ -9,6 +9,7 @@ import { RecordFieldListCellEditModePortal } from '@/object-record/record-field-
import { RecordFieldListCellHoveredPortal } from '@/object-record/record-field-list/anchored-portal/components/RecordFieldListCellHoveredPortal';
import { useFieldListFieldMetadataItems } from '@/object-record/record-field-list/hooks/useFieldListFieldMetadataItems';
import { RecordDetailDuplicatesSection } from '@/object-record/record-field-list/record-detail-section/duplicate/components/RecordDetailDuplicatesSection';
import { RecordDetailMorphRelationSection } from '@/object-record/record-field-list/record-detail-section/relation/components/RecordDetailMorphRelationSection';
import { RecordDetailRelationSection } from '@/object-record/record-field-list/record-detail-section/relation/components/RecordDetailRelationSection';
import { RecordFieldListComponentInstanceContext } from '@/object-record/record-field-list/states/contexts/RecordFieldListComponentInstanceContext';
import { recordFieldListHoverPositionComponentState } from '@/object-record/record-field-list/states/recordFieldListHoverPositionComponentState';
@@ -22,6 +23,7 @@ import { useRecordShowContainerData } from '@/object-record/record-show/hooks/us
import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId';
import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { FieldMetadataType } from 'twenty-shared/types';
type RecordFieldListProps = {
instanceId: string;
@@ -212,37 +214,50 @@ export const RecordFieldList = ({
objectNameSingular={objectNameSingular}
/>
)}
{boxedRelationFieldMetadataItems?.map((fieldMetadataItem, index) => (
<FieldContext.Provider
key={objectRecordId + fieldMetadataItem.id}
value={{
recordId: objectRecordId,
isLabelIdentifier: false,
fieldDefinition: formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: index,
objectMetadataItem,
}),
useUpdateRecord: useUpdateOneObjectRecordMutation,
isDisplayModeFixHeight: true,
isRecordFieldReadOnly: isRecordFieldReadOnly({
isRecordReadOnly,
objectPermissions: getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
objectMetadataId: objectMetadataItem.id,
{boxedRelationFieldMetadataItems
.filter(
(fieldMetadataItem) =>
fieldMetadataItem.type === FieldMetadataType.RELATION ||
fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION,
)
.map((fieldMetadataItem, index) => (
<FieldContext.Provider
key={objectRecordId + fieldMetadataItem.id}
value={{
recordId: objectRecordId,
isLabelIdentifier: false,
fieldDefinition: formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: index,
objectMetadataItem,
}),
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
},
}),
}}
>
<RecordDetailRelationSection
loading={isPrefetchLoading || recordLoading}
/>
</FieldContext.Provider>
))}
useUpdateRecord: useUpdateOneObjectRecordMutation,
isDisplayModeFixHeight: true,
isRecordFieldReadOnly: isRecordFieldReadOnly({
isRecordReadOnly,
objectPermissions:
getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
objectMetadataId: objectMetadataItem.id,
}),
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
},
}),
}}
>
{fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION ? (
<RecordDetailMorphRelationSection
loading={isPrefetchLoading || recordLoading}
/>
) : (
<RecordDetailRelationSection
loading={isPrefetchLoading || recordLoading}
/>
)}
</FieldContext.Provider>
))}
<RecordFieldListCellHoveredPortal
objectMetadataItem={objectMetadataItem}
@@ -45,7 +45,8 @@ export const useFieldListFieldMetadataItems = ({
(fieldMetadataItem.name !== 'createdAt' &&
fieldMetadataItem.name !== 'deletedAt')) &&
(showRelationSections ||
fieldMetadataItem.type !== FieldMetadataType.RELATION),
(fieldMetadataItem.type !== FieldMetadataType.RELATION &&
fieldMetadataItem.type !== FieldMetadataType.MORPH_RELATION)),
)
.sort((fieldMetadataItemA, fieldMetadataItemB) =>
fieldMetadataItemA.name.localeCompare(fieldMetadataItemB.name),
@@ -63,7 +64,8 @@ export const useFieldListFieldMetadataItems = ({
fieldMetadataItem.type !== FieldMetadataType.RICH_TEXT_V2,
),
(fieldMetadataItem) =>
fieldMetadataItem.type === FieldMetadataType.RELATION
fieldMetadataItem.type === FieldMetadataType.RELATION ||
fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION
? 'relationFieldMetadataItems'
: 'inlineFieldMetadataItems',
);
@@ -78,22 +80,35 @@ export const useFieldListFieldMetadataItems = ({
fieldMetadataItem.name === 'taskTargets'),
);
const boxedRelationFieldMetadataItems = (
relationFieldMetadataItems ?? []
).filter(
(fieldMetadataItem) =>
!(
(objectNameSingular === CoreObjectNameSingular.Note &&
fieldMetadataItem.name === 'noteTargets') ||
(objectNameSingular === CoreObjectNameSingular.Task &&
fieldMetadataItem.name === 'taskTargets')
) &&
isDefined(fieldMetadataItem.relation?.targetObjectMetadata.id) &&
getObjectPermissionsForObject(
objectPermissionsByObjectMetadataId,
fieldMetadataItem.relation?.targetObjectMetadata.id,
).canReadObjectRecords,
);
const boxedRelationFieldMetadataItems = (relationFieldMetadataItems ?? [])
.filter(
(fieldMetadataItem) =>
!(
(objectNameSingular === CoreObjectNameSingular.Note &&
fieldMetadataItem.name === 'noteTargets') ||
(objectNameSingular === CoreObjectNameSingular.Task &&
fieldMetadataItem.name === 'taskTargets')
),
)
.filter((fieldMetadataItem) => {
const canReadRelation =
isDefined(fieldMetadataItem.relation?.targetObjectMetadata.id) &&
getObjectPermissionsForObject(
objectPermissionsByObjectMetadataId,
fieldMetadataItem.relation?.targetObjectMetadata.id,
).canReadObjectRecords;
const canReadMorphRelation = fieldMetadataItem?.morphRelations?.every(
(morphRelation) =>
isDefined(morphRelation.targetObjectMetadata.id) &&
getObjectPermissionsForObject(
objectPermissionsByObjectMetadataId,
morphRelation.targetObjectMetadata.id,
).canReadObjectRecords,
);
return canReadRelation || canReadMorphRelation;
});
return {
inlineFieldMetadataItems,
@@ -0,0 +1,131 @@
import { useContext } from 'react';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { RecordDetailSectionContainer } from '@/object-record/record-field-list/record-detail-section/components/RecordDetailSectionContainer';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import {
FieldInputEventContext,
type FieldInputEvent,
} from '@/object-record/record-field/ui/contexts/FieldInputEventContext';
import { type FieldMorphRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { getRecordFieldCardRelationPickerDropdownId } from '@/object-record/record-show/utils/getRecordFieldCardRelationPickerDropdownId';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { RecordDetailMorphRelationSectionDropdown } from '@/object-record/record-field-list/record-detail-section/relation/components/RecordDetailMorphRelationSectionDropdown';
import { RecordDetailRelationRecordsList } from '@/object-record/record-field-list/record-detail-section/relation/components/RecordDetailRelationRecordsList';
import { useGetMorphRelationRelatedRecordsWithObjectNameSingular } from '@/object-record/record-field-list/record-detail-section/relation/components/hooks/useGetMorphRelationRelatedRecordsWithObjectNameSingular';
import { useMorphPersistManyToOne } from '@/object-record/record-field/ui/meta-types/input/hooks/useMorphPersistManyToOne';
import { CustomError, isDefined } from 'twenty-shared/utils';
type RecordDetailMorphRelationSectionProps = {
loading: boolean;
};
export const RecordDetailMorphRelationSection = ({
loading,
}: RecordDetailMorphRelationSectionProps) => {
const { recordId, fieldDefinition } = useContext(FieldContext);
const metadata = fieldDefinition.metadata as FieldMorphRelationMetadata;
const { objectMetadataNameSingular } = metadata;
const isMobile = useIsMobile();
const recordsWithObjectNameSingular =
useGetMorphRelationRelatedRecordsWithObjectNameSingular({
recordId,
morphRelations: metadata.morphRelations,
});
const relationRecordsCount = recordsWithObjectNameSingular.length;
const dropdownId = getRecordFieldCardRelationPickerDropdownId({
fieldDefinition,
recordId,
});
const isDropdownOpen = useRecoilComponentValue(
isDropdownOpenComponentState,
dropdownId,
);
const { objectMetadataItems } = useObjectMetadataItems();
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItemToFind) =>
objectMetadataItemToFind.nameSingular === objectMetadataNameSingular,
);
if (!objectMetadataItem) {
throw new CustomError(
'Object metadata item not found',
'OBJECT_METADATA_ITEM_NOT_FOUND',
);
}
const { persistMorphManyToOne } = useMorphPersistManyToOne({
objectMetadataNameSingular: objectMetadataItem.nameSingular,
});
const handleSubmit: FieldInputEvent = ({ newValue }) => {
if (!isDefined(newValue)) {
throw new CustomError(
'Value to persist is required',
'VALUE_TO_PERSIST_IS_REQUIRED',
);
}
const { id, objectMetadataId } = newValue as {
id: string;
objectMetadataId: string;
};
if (!isDefined(id)) {
throw new CustomError('Id is required', 'ID_IS_REQUIRED');
}
const targetObjectMetadataNameSingular = objectMetadataItems.find(
(objectMetadataItem) => objectMetadataItem.id === objectMetadataId,
)?.nameSingular;
if (!isDefined(targetObjectMetadataNameSingular)) {
throw new CustomError(
'Target object metadata name singular is required',
'TARGET_OBJECT_METADATA_NAME_SINGULAR_IS_REQUIRED',
);
}
persistMorphManyToOne({
recordId,
fieldDefinition,
valueToPersist: id,
targetObjectMetadataNameSingular,
});
};
if (loading) return null;
return (
<FieldInputEventContext.Provider
value={{
onSubmit: handleSubmit,
}}
>
<RecordDetailSectionContainer
title={fieldDefinition.label}
hideRightAdornmentOnMouseLeave={!isDropdownOpen && !isMobile}
areRecordsAvailable={relationRecordsCount > 0}
rightAdornment={
<RecordDetailMorphRelationSectionDropdown loading={loading} />
}
>
{relationRecordsCount > 0 && (
<RecordDetailRelationRecordsList
recordsWithObjectNameSingular={recordsWithObjectNameSingular}
/>
)}
</RecordDetailSectionContainer>
</FieldInputEventContext.Provider>
);
};
@@ -0,0 +1,73 @@
import { useContext } from 'react';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { isRecordReadOnly } from '@/object-record/read-only/utils/isRecordReadOnly';
import { RecordDetailMorphRelationSectionDropdownManyToOne } from '@/object-record/record-field-list/record-detail-section/relation/components/RecordDetailMorphRelationSectionDropdownManyToOne';
import { RecordDetailMorphRelationSectionDropdownOneToMany } from '@/object-record/record-field-list/record-detail-section/relation/components/RecordDetailMorphRelationSectionDropdownOneToMany';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { useIsRecordDeleted } from '@/object-record/record-field/ui/hooks/useIsRecordDeleted';
import { assertFieldMetadata } from '@/object-record/record-field/ui/types/guards/assertFieldMetadata';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
type RecordDetailMorphRelationSectionDropdownProps = {
loading: boolean;
};
export const RecordDetailMorphRelationSectionDropdown = ({
loading,
}: RecordDetailMorphRelationSectionDropdownProps) => {
const { fieldDefinition, isRecordFieldReadOnly, recordId } =
useContext(FieldContext);
assertFieldMetadata(
FieldMetadataType.MORPH_RELATION,
isFieldMorphRelation,
fieldDefinition,
);
const { relationType, morphRelations } = fieldDefinition.metadata;
const { objectMetadataItems } = useObjectMetadataItems();
const relatedObjectMetadataItems = morphRelations
.map((morphRelation) => morphRelation.targetObjectMetadata.id)
.map((objectMetadataId) =>
objectMetadataItems.find(
(objectMetadataItem) => objectMetadataItem.id === objectMetadataId,
),
)
.filter(isDefined);
const isToOneObject = relationType === RelationType.MANY_TO_ONE;
const isToManyObjects = relationType === RelationType.ONE_TO_MANY;
const isDeleted = useIsRecordDeleted({ recordId });
const isRecordReadOnlyFromRelatedRecordPerspective =
relatedObjectMetadataItems.some((relatedObjectMetadataItem) => {
return isRecordReadOnly({
objectPermissions: {
canUpdateObjectRecords: true,
objectMetadataId: relatedObjectMetadataItem.id,
},
isRecordDeleted: isDeleted,
objectMetadataItem: relatedObjectMetadataItem,
});
});
if (
loading ||
isRecordFieldReadOnly ||
isRecordReadOnlyFromRelatedRecordPerspective
) {
return null;
}
if (isToOneObject) {
return <RecordDetailMorphRelationSectionDropdownManyToOne />;
} else if (isToManyObjects) {
return <RecordDetailMorphRelationSectionDropdownOneToMany />;
} else {
return null;
}
};
@@ -0,0 +1,142 @@
import { useCallback, useContext } from 'react';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useGetMorphRelationRelatedRecordsWithObjectNameSingular } from '@/object-record/record-field-list/record-detail-section/relation/components/hooks/useGetMorphRelationRelatedRecordsWithObjectNameSingular';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts/FieldInputEventContext';
import { assertFieldMetadata } from '@/object-record/record-field/ui/types/guards/assertFieldMetadata';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { SingleRecordPicker } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPicker';
import { useSingleRecordPickerOpen } from '@/object-record/record-picker/single-record-picker/hooks/useSingleRecordPickerOpen';
import { singleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSearchFilterComponentState';
import { singleRecordPickerSelectedIdComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSelectedIdComponentState';
import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { getRecordFieldCardRelationPickerDropdownId } from '@/object-record/record-show/utils/getRecordFieldCardRelationPickerDropdownId';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { dropdownPlacementComponentState } from '@/ui/layout/dropdown/states/dropdownPlacementComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconForbid, IconPencil } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
export const RecordDetailMorphRelationSectionDropdownManyToOne = () => {
const { recordId, fieldDefinition } = useContext(FieldContext);
assertFieldMetadata(
FieldMetadataType.MORPH_RELATION,
isFieldMorphRelation,
fieldDefinition,
);
const { morphRelations } = fieldDefinition.metadata;
const { objectMetadataItems } = useObjectMetadataItems();
const relationObjectMetadataItems = morphRelations
.map((morphRelation) => morphRelation.targetObjectMetadata)
.map((relationObjectMetadataItem) =>
objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.id === relationObjectMetadataItem.id,
),
)
.filter(isDefined);
const recordsWithObjectNameSingular =
useGetMorphRelationRelatedRecordsWithObjectNameSingular({
recordId,
morphRelations,
});
const dropdownId = getRecordFieldCardRelationPickerDropdownId({
fieldDefinition,
recordId,
});
const { closeDropdown } = useCloseDropdown();
const dropdownPlacement = useRecoilComponentValue(
dropdownPlacementComponentState,
dropdownId,
);
const setSingleRecordPickerSearchFilter = useSetRecoilComponentState(
singleRecordPickerSearchFilterComponentState,
dropdownId,
);
const setSingleRecordPickerSelectedId = useSetRecoilComponentState(
singleRecordPickerSelectedIdComponentState,
dropdownId,
);
const handleCloseRelationPickerDropdown = useCallback(() => {
setSingleRecordPickerSearchFilter('');
}, [setSingleRecordPickerSearchFilter]);
const { onSubmit } = useContext(FieldInputEventContext);
const handleRelationPickerEntitySelected = (
selectedMorphItem?: RecordPickerPickableMorphItem,
) => {
closeDropdown(dropdownId);
if (!selectedMorphItem?.recordId) return;
onSubmit?.({
newValue: {
id: selectedMorphItem.recordId,
objectMetadataId: selectedMorphItem.objectMetadataId,
},
});
};
const { openSingleRecordPicker } = useSingleRecordPickerOpen();
const handleOpenRelationPickerDropdown = () => {
setSingleRecordPickerSearchFilter('');
openSingleRecordPicker(dropdownId);
if (recordsWithObjectNameSingular.length > 0) {
setSingleRecordPickerSelectedId(
recordsWithObjectNameSingular[0]?.value.id,
);
}
};
return (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="left-start"
onClose={handleCloseRelationPickerDropdown}
onOpen={handleOpenRelationPickerDropdown}
clickableComponent={
<LightIconButton
className="displayOnHover"
Icon={IconPencil}
accent="tertiary"
/>
}
dropdownComponents={
<SingleRecordPicker
focusId={dropdownId}
componentInstanceId={dropdownId}
EmptyIcon={IconForbid}
onMorphItemSelected={handleRelationPickerEntitySelected}
objectNameSingulars={relationObjectMetadataItems.map(
(relationObjectMetadataItem) =>
relationObjectMetadataItem.nameSingular,
)}
recordPickerInstanceId={dropdownId}
onCancel={() => closeDropdown(dropdownId)}
layoutDirection={
dropdownPlacement?.includes('end')
? 'search-bar-on-bottom'
: 'search-bar-on-top'
}
/>
}
/>
);
};
@@ -0,0 +1,182 @@
import { useCallback, useContext } from 'react';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useGetMorphRelationRelatedRecordsWithObjectNameSingular } from '@/object-record/record-field-list/record-detail-section/relation/components/hooks/useGetMorphRelationRelatedRecordsWithObjectNameSingular';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { useUpdateMorphRelationOneToManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useUpdateMorphRelationOneToManyFieldInput';
import { assertFieldMetadata } from '@/object-record/record-field/ui/types/guards/assertFieldMetadata';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { MultipleRecordPicker } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPicker';
import { useMultipleRecordPickerOpen } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerOpen';
import { useMultipleRecordPickerPerformSearch } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch';
import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState';
import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchFilterComponentState';
import { multipleRecordPickerSearchableObjectMetadataItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchableObjectMetadataItemsComponentState';
import { getRecordFieldCardRelationPickerDropdownId } from '@/object-record/record-show/utils/getRecordFieldCardRelationPickerDropdownId';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { dropdownPlacementComponentState } from '@/ui/layout/dropdown/states/dropdownPlacementComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { FieldMetadataType } from 'twenty-shared/types';
import { CustomError, isDefined } from 'twenty-shared/utils';
import { IconPlus } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
export const RecordDetailMorphRelationSectionDropdownOneToMany = () => {
const { recordId, fieldDefinition } = useContext(FieldContext);
assertFieldMetadata(
FieldMetadataType.MORPH_RELATION,
isFieldMorphRelation,
fieldDefinition,
);
const { morphRelations } = fieldDefinition.metadata;
const { objectMetadataItems } = useObjectMetadataItems();
const relationObjectMetadataItems = morphRelations
.map((morphRelation) => morphRelation.targetObjectMetadata)
.map((relationObjectMetadataItem) =>
objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.id === relationObjectMetadataItem.id,
),
)
.filter(isDefined);
const recordsWithObjectNameSingular =
useGetMorphRelationRelatedRecordsWithObjectNameSingular({
recordId,
morphRelations: morphRelations,
});
const dropdownId = getRecordFieldCardRelationPickerDropdownId({
fieldDefinition,
recordId,
});
const { closeDropdown } = useCloseDropdown();
const dropdownPlacement = useRecoilComponentValue(
dropdownPlacementComponentState,
dropdownId,
);
const setMultipleRecordPickerSearchFilter = useSetRecoilComponentState(
multipleRecordPickerSearchFilterComponentState,
dropdownId,
);
const setMultipleRecordPickerPickableMorphItems = useSetRecoilComponentState(
multipleRecordPickerPickableMorphItemsComponentState,
dropdownId,
);
const setMultipleRecordPickerSearchableObjectMetadataItems =
useSetRecoilComponentState(
multipleRecordPickerSearchableObjectMetadataItemsComponentState,
dropdownId,
);
const { performSearch: multipleRecordPickerPerformSearch } =
useMultipleRecordPickerPerformSearch();
const { openMultipleRecordPicker } = useMultipleRecordPickerOpen();
const handleCloseRelationPickerDropdown = useCallback(() => {
setMultipleRecordPickerSearchFilter('');
}, [setMultipleRecordPickerSearchFilter]);
const handleOpenRelationPickerDropdown = () => {
setMultipleRecordPickerSearchableObjectMetadataItems(
relationObjectMetadataItems,
);
setMultipleRecordPickerSearchFilter('');
setMultipleRecordPickerPickableMorphItems(
recordsWithObjectNameSingular.map((record) => {
const objectMetadataId = morphRelations.find(
(morphRelation) =>
morphRelation.targetObjectMetadata.nameSingular ===
record.objectNameSingular,
)?.targetObjectMetadata.id;
if (!objectMetadataId) {
throw new CustomError(
'Sorry, we could not determine the correct object type for this related record. Please contact support if the issue persists.',
);
}
return {
recordId: record.value.id,
objectMetadataId: objectMetadataId,
isSelected: true,
isMatchingSearchFilter: true,
};
}),
);
openMultipleRecordPicker(dropdownId);
multipleRecordPickerPerformSearch({
multipleRecordPickerInstanceId: dropdownId,
forceSearchFilter: '',
forceSearchableObjectMetadataItems: relationObjectMetadataItems,
forcePickableMorphItems: recordsWithObjectNameSingular.map((record) => {
const objectMetadataId = morphRelations.find(
(morphRelation) =>
morphRelation.targetObjectMetadata.nameSingular ===
record.objectNameSingular,
)?.targetObjectMetadata.id;
if (!objectMetadataId) {
throw new Error('ObjectMetadataId is required');
}
return {
recordId: record.value.id,
objectMetadataId: objectMetadataId,
isSelected: true,
isMatchingSearchFilter: true,
};
}),
});
};
const { updateMorphRelationOneToMany } =
useUpdateMorphRelationOneToManyFieldInput();
return (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="left-start"
onClose={handleCloseRelationPickerDropdown}
onOpen={handleOpenRelationPickerDropdown}
clickableComponent={
<LightIconButton
className="displayOnHover"
Icon={IconPlus}
accent="tertiary"
/>
}
dropdownComponents={
<MultipleRecordPicker
focusId={dropdownId}
componentInstanceId={dropdownId}
onChange={(morphItem) => {
updateMorphRelationOneToMany(morphItem);
}}
onSubmit={() => {
closeDropdown(dropdownId);
}}
onClickOutside={() => {
closeDropdown(dropdownId);
}}
layoutDirection={
dropdownPlacement?.includes('end')
? 'search-bar-on-bottom'
: 'search-bar-on-top'
}
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
/>
}
/>
);
};
@@ -6,12 +6,16 @@ import { RecordDetailRelationRecordsListItemEffect } from '@/object-record/recor
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
type RecordDetailRelationRecordsListProps = {
relationRecords: ObjectRecord[];
objectNameSingular: string;
value: ObjectRecord;
fieldMetadataId: string;
};
export const RecordDetailRelationRecordsList = ({
relationRecords,
}: RecordDetailRelationRecordsListProps) => {
recordsWithObjectNameSingular,
}: {
recordsWithObjectNameSingular: RecordDetailRelationRecordsListProps[];
}) => {
const [expandedItem, setExpandedItem] = useState('');
const handleItemClick = (recordId: string) =>
@@ -19,20 +23,35 @@ export const RecordDetailRelationRecordsList = ({
return (
<RecordDetailRecordsListContainer>
{relationRecords.slice(0, 5).map((relationRecord) => (
<Fragment key={relationRecord.id}>
<RecordDetailRelationRecordsListItemEffect
key={`${relationRecord.id}-effect`}
relationRecordId={relationRecord.id}
/>
<RecordDetailRelationRecordsListItem
key={relationRecord.id}
isExpanded={expandedItem === relationRecord.id}
onClick={handleItemClick}
relationRecord={relationRecord}
/>
</Fragment>
))}
{recordsWithObjectNameSingular
.slice(0, 5)
.map((recordWithObjectNameSingular) => (
<Fragment
key={`${recordWithObjectNameSingular.value.id}-${recordWithObjectNameSingular.fieldMetadataId}`}
>
<RecordDetailRelationRecordsListItemEffect
key={`${recordWithObjectNameSingular.value.id}-effect`}
relationRecordId={recordWithObjectNameSingular.value.id}
relationObjectMetadataNameSingular={
recordWithObjectNameSingular.objectNameSingular
}
/>
<RecordDetailRelationRecordsListItem
key={recordWithObjectNameSingular.value.id}
isExpanded={
expandedItem === recordWithObjectNameSingular.value.id
}
onClick={handleItemClick}
relationRecord={recordWithObjectNameSingular.value}
relationObjectMetadataNameSingular={
recordWithObjectNameSingular.objectNameSingular
}
relationFieldMetadataId={
recordWithObjectNameSingular.fieldMetadataId
}
/>
</Fragment>
))}
</RecordDetailRecordsListContainer>
);
};
@@ -4,6 +4,7 @@ import { motion } from 'framer-motion';
import { useCallback, useContext } from 'react';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { RecordChip } from '@/object-record/components/RecordChip';
@@ -30,6 +31,10 @@ import { useIsInRightDrawerOrThrow } from '@/ui/layout/right-drawer/contexts/Rig
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { createPortal } from 'react-dom';
import {
computeMorphRelationFieldName,
CustomError,
} from 'twenty-shared/utils';
import {
IconChevronDown,
IconDotsVertical,
@@ -40,7 +45,7 @@ import {
import { LightIconButton } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
import { AnimatedEaseInOut } from 'twenty-ui/utilities';
import { RelationType } from '~/generated-metadata/graphql';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
const StyledListItem = styled(RecordDetailRecordsListItemContainer)<{
isDropdownOpen?: boolean;
@@ -84,12 +89,16 @@ type RecordDetailRelationRecordsListItemProps = {
isExpanded: boolean;
onClick: (relationRecordId: string) => void;
relationRecord: ObjectRecord;
relationObjectMetadataNameSingular: string;
relationFieldMetadataId: string;
};
export const RecordDetailRelationRecordsListItem = ({
isExpanded,
onClick,
relationRecord,
relationObjectMetadataNameSingular,
relationFieldMetadataId,
}: RecordDetailRelationRecordsListItemProps) => {
const {
fieldDefinition,
@@ -100,12 +109,23 @@ export const RecordDetailRelationRecordsListItem = ({
const { onSubmit } = useContext(FieldInputEventContext);
const { openModal } = useModal();
const { isInRightDrawer } = useIsInRightDrawerOrThrow();
const {
relationFieldMetadataId,
relationObjectMetadataNameSingular,
relationType,
} = fieldDefinition.metadata as FieldRelationMetadata;
const { relationType, objectMetadataNameSingular } =
fieldDefinition.metadata as FieldRelationMetadata;
const { objectMetadataItems } = useObjectMetadataItems();
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItemToFind) =>
objectMetadataItemToFind.nameSingular === objectMetadataNameSingular,
);
if (!objectMetadataItem) {
throw new CustomError(
'Object metadata item not found',
'OBJECT_METADATA_ITEM_NOT_FOUND',
);
}
const isToOneObject = relationType === RelationType.MANY_TO_ONE;
const { objectMetadataItem: relationObjectMetadataItem } =
@@ -153,6 +173,27 @@ export const RecordDetailRelationRecordsListItem = ({
({ id }) => id === relationFieldMetadataId,
);
const relationFieldMetadataIsMorphRelation =
relationFieldMetadataItem?.type === FieldMetadataType.MORPH_RELATION;
const computedName = relationFieldMetadataItem
? computeMorphRelationFieldName({
fieldName: relationFieldMetadataItem.name,
relationType: relationFieldMetadataItem.settings.relationType,
targetObjectMetadataNameSingular: objectMetadataItem.nameSingular,
targetObjectMetadataNamePlural: objectMetadataItem.namePlural,
})
: '';
const updateOneRecordInput = relationFieldMetadataIsMorphRelation
? {
[getForeignKeyNameFromRelationFieldName(computedName)]: null,
}
: {
[getForeignKeyNameFromRelationFieldName(
relationFieldMetadataItem?.name ?? '',
)]: null,
};
const handleDetach = () => {
closeDropdown(dropdownInstanceId);
@@ -163,11 +204,7 @@ export const RecordDetailRelationRecordsListItem = ({
} else {
updateOneRelationRecord({
idToUpdate: relationRecord.id,
updateOneRecordInput: {
[getForeignKeyNameFromRelationFieldName(
relationFieldMetadataItem.name,
)]: null,
},
updateOneRecordInput,
});
}
@@ -199,8 +236,6 @@ export const RecordDetailRelationRecordsListItem = ({
[isExpanded],
);
const { isInRightDrawer } = useIsInRightDrawerOrThrow();
return (
<>
<StyledListItem isDropdownOpen={isDropdownOpen}>
@@ -1,24 +1,19 @@
import { useContext, useEffect } from 'react';
import { useEffect } from 'react';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { type FieldRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { isDefined } from 'twenty-shared/utils';
type RecordDetailRelationRecordsListItemEffectProps = {
relationRecordId: string;
relationObjectMetadataNameSingular: string;
};
export const RecordDetailRelationRecordsListItemEffect = ({
relationRecordId,
relationObjectMetadataNameSingular,
}: RecordDetailRelationRecordsListItemEffectProps) => {
const { fieldDefinition } = useContext(FieldContext);
const { relationObjectMetadataNameSingular } =
fieldDefinition.metadata as FieldRelationMetadata;
const { record } = useFindOneRecord({
objectNameSingular: relationObjectMetadataNameSingular,
objectRecordId: relationRecordId,
@@ -25,10 +25,16 @@ import { coreIndexViewIdFromObjectMetadataItemFamilySelector } from '@/views/sta
import { useLingui } from '@lingui/react/macro';
import {
AppPath,
FieldMetadataType,
ViewFilterOperand,
type RecordGqlOperationFilter,
} from 'twenty-shared/types';
import { getAppPath } from 'twenty-shared/utils';
import {
computeMorphRelationFieldName,
CustomError,
getAppPath,
isDefined,
} from 'twenty-shared/utils';
import { RelationType } from '~/generated-metadata/graphql';
type RecordDetailRelationSectionProps = {
@@ -60,6 +66,19 @@ export const RecordDetailRelationSection = ({
({ id }) => id === relationFieldMetadataId,
);
const { objectMetadataItems } = useObjectMetadataItems();
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItemToFind) =>
objectMetadataItemToFind.nameSingular === objectMetadataNameSingular,
);
if (!objectMetadataItem) {
throw new CustomError(
'Object metadata item not found',
'OBJECT_METADATA_ITEM_NOT_FOUND',
);
}
const fieldValue = useRecoilValue<
({ id: string } & Record<string, any>) | ObjectRecord[] | null
>(recordStoreFamilySelector({ recordId, fieldName }));
@@ -108,9 +127,29 @@ export const RecordDetailRelationSection = ({
filterQueryParams,
);
const persistField = usePersistField({
objectMetadataItemId: objectMetadataItem?.id ?? '',
});
const relationFieldMetadataIsMorphRelation =
relationFieldMetadataItem?.type === FieldMetadataType.MORPH_RELATION;
const computedName = isDefined(relationFieldMetadataItem)
? computeMorphRelationFieldName({
fieldName: relationFieldMetadataItem.name,
relationType: relationFieldMetadataItem.settings.relationType,
targetObjectMetadataNameSingular: objectMetadataItem.nameSingular,
targetObjectMetadataNamePlural: objectMetadataItem.namePlural,
})
: undefined;
const gqlFieldName = relationFieldMetadataIsMorphRelation
? computedName
: relationFieldMetadataItem?.name;
const filtersForAggregate = isToManyObjects
? ({
[`${relationFieldMetadataItem?.name}Id`]: {
[`${gqlFieldName}Id`]: {
in: [recordId],
},
} satisfies RecordGqlOperationFilter)
@@ -127,19 +166,6 @@ export const RecordDetailRelationSection = ({
},
});
// TODO: refactor this when we have refactored columnDefinitions and field definitions because
// we should be able to get the objectMetadataItem from a context way more easily
const { objectMetadataItems } = useObjectMetadataItems();
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItemToFind) =>
objectMetadataItemToFind.nameSingular === objectMetadataNameSingular,
);
const persistField = usePersistField({
objectMetadataItemId: objectMetadataItem?.id ?? '',
});
const handleSubmit: FieldInputEvent = ({ newValue }) => {
persistField({
recordId: recordId,
@@ -178,7 +204,15 @@ export const RecordDetailRelationSection = ({
}
>
{relationRecords.length > 0 && (
<RecordDetailRelationRecordsList relationRecords={relationRecords} />
<RecordDetailRelationRecordsList
recordsWithObjectNameSingular={relationRecords.map(
(relationRecord) => ({
value: relationRecord,
objectNameSingular: relationObjectMetadataNameSingular,
fieldMetadataId: relationFieldMetadataId,
}),
)}
/>
)}
</RecordDetailSectionContainer>
</FieldInputEventContext.Provider>
@@ -2,8 +2,11 @@ import { useCallback, useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { getFieldMetadataItemById } from '@/object-metadata/utils/getFieldMetadataItemById';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { useAddNewRecordAndOpenRightDrawer } from '@/object-record/record-field/ui/meta-types/input/hooks/useAddNewRecordAndOpenRightDrawer';
import { useUpdateRelationOneToManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useUpdateRelationOneToManyFieldInput';
import { type FieldRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { MultipleRecordPicker } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPicker';
import { useMultipleRecordPickerOpen } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerOpen';
@@ -19,18 +22,32 @@ import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { dropdownPlacementComponentState } from '@/ui/layout/dropdown/states/dropdownPlacementComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { CustomError } from 'twenty-shared/utils';
import { IconPlus } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { useUpdateRelationOneToManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useUpdateRelationOneToManyFieldInput';
export const RecordDetailRelationSectionDropdownToMany = () => {
const { recordId, fieldDefinition } = useContext(FieldContext);
const { fieldMetadataId } = fieldDefinition;
const {
fieldName,
relationFieldMetadataId,
relationObjectMetadataNameSingular,
} = fieldDefinition.metadata as FieldRelationMetadata;
const { objectMetadataItems } = useObjectMetadataItems();
const { fieldMetadataItem, objectMetadataItem } = getFieldMetadataItemById({
fieldMetadataId,
objectMetadataItems,
});
if (!fieldMetadataItem || !objectMetadataItem) {
throw new CustomError(
'Field metadata item or object metadata item not found',
'FIELD_METADATA_ITEM_OR_OBJECT_METADATA_ITEM_NOT_FOUND',
);
}
const { objectMetadataItem: relationObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular: relationObjectMetadataNameSingular,
@@ -39,6 +56,12 @@ export const RecordDetailRelationSectionDropdownToMany = () => {
const relationFieldMetadataItem = relationObjectMetadataItem.fields.find(
({ id }) => id === relationFieldMetadataId,
);
if (!relationFieldMetadataItem) {
throw new CustomError(
'Relation field metadata item not found',
'RELATION_FIELD_METADATA_ITEM_NOT_FOUND',
);
}
const fieldValue = useRecoilValue<
({ id: string } & Record<string, any>) | ObjectRecord[] | null
@@ -87,6 +110,8 @@ export const RecordDetailRelationSectionDropdownToMany = () => {
const { createNewRecordAndOpenRightDrawer } =
useAddNewRecordAndOpenRightDrawer({
fieldMetadataItem,
objectMetadataItem,
relationObjectMetadataNameSingular,
relationObjectMetadataItem,
relationFieldMetadataItem,
@@ -6,7 +6,6 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts/FieldInputEventContext';
import { useAddNewRecordAndOpenRightDrawer } from '@/object-record/record-field/ui/meta-types/input/hooks/useAddNewRecordAndOpenRightDrawer';
import { type FieldRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { SingleRecordPicker } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPicker';
import { useSingleRecordPickerOpen } from '@/object-record/record-picker/single-record-picker/hooks/useSingleRecordPickerOpen';
import { singleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSearchFilterComponentState';
@@ -20,16 +19,42 @@ import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { dropdownPlacementComponentState } from '@/ui/layout/dropdown/states/dropdownPlacementComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { getFieldMetadataItemById } from '@/object-metadata/utils/getFieldMetadataItemById';
import { assertFieldMetadata } from '@/object-record/record-field/ui/types/guards/assertFieldMetadata';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { FieldMetadataType } from 'twenty-shared/types';
import { CustomError } from 'twenty-shared/utils';
import { IconForbid, IconPencil } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
export const RecordDetailRelationSectionDropdownToOne = () => {
const { recordId, fieldDefinition } = useContext(FieldContext);
assertFieldMetadata(
FieldMetadataType.RELATION,
isFieldRelation,
fieldDefinition,
);
const { fieldMetadataId } = fieldDefinition;
const {
fieldName,
relationFieldMetadataId,
relationObjectMetadataNameSingular,
} = fieldDefinition.metadata as FieldRelationMetadata;
} = fieldDefinition.metadata;
const { objectMetadataItems } = useObjectMetadataItems();
const { fieldMetadataItem, objectMetadataItem } = getFieldMetadataItemById({
fieldMetadataId,
objectMetadataItems,
});
if (!fieldMetadataItem || !objectMetadataItem) {
throw new CustomError(
'Field metadata item or object metadata item not found',
'FIELD_METADATA_ITEM_OR_OBJECT_METADATA_ITEM_NOT_FOUND',
);
}
const { objectMetadataItem: relationObjectMetadataItem } =
useObjectMetadataItem({
@@ -40,6 +65,13 @@ export const RecordDetailRelationSectionDropdownToOne = () => {
({ id }) => id === relationFieldMetadataId,
);
if (!relationFieldMetadataItem) {
throw new CustomError(
'Relation field metadata item not found',
'RELATION_FIELD_METADATA_ITEM_NOT_FOUND',
);
}
const fieldValue = useRecoilValue<
({ id: string } & Record<string, any>) | ObjectRecord[] | null
>(recordStoreFamilySelector({ recordId, fieldName }));
@@ -89,6 +121,8 @@ export const RecordDetailRelationSectionDropdownToOne = () => {
const { createNewRecordAndOpenRightDrawer } =
useAddNewRecordAndOpenRightDrawer({
fieldMetadataItem,
objectMetadataItem,
relationObjectMetadataNameSingular,
relationObjectMetadataItem,
relationFieldMetadataItem,
@@ -0,0 +1,61 @@
import { type FieldMetadataItemRelation } from '@/object-metadata/types/FieldMetadataItemRelation';
import { recordStoreMorphOneToManyValueWithObjectNameFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreMorphOneToManyValueWithObjectNameFamilySelector';
import { useRecoilValue } from 'recoil';
import { CustomError, isNonEmptyArray } from 'twenty-shared/utils';
export const useGetMorphRelationRelatedRecordsWithObjectNameSingular = ({
recordId,
morphRelations,
}: {
recordId: string;
morphRelations: FieldMetadataItemRelation[];
}) => {
const morphRelationObjectNameSingularWithValues = useRecoilValue(
recordStoreMorphOneToManyValueWithObjectNameFamilySelector({
recordId,
morphRelations,
}),
);
if (morphRelations.length < 1) {
throw new CustomError(
'Cannot load relation records: no morph relations were provided to this component.',
'MORPH_RELATIONS_REQUIRED',
);
}
const morphRelationObjectNameSingularWithValuesArray =
morphRelationObjectNameSingularWithValues
.map((recordWithObjectNameSingular) => ({
...recordWithObjectNameSingular,
value: Array.isArray(recordWithObjectNameSingular.value)
? recordWithObjectNameSingular.value
: recordWithObjectNameSingular.value
? [recordWithObjectNameSingular.value]
: [],
}))
.filter((recordWithObjectNameSingular) =>
isNonEmptyArray(recordWithObjectNameSingular.value),
);
const recordsWithObjectNameSingular =
morphRelationObjectNameSingularWithValuesArray.flatMap(
(recordWithObjectNameSingular) => {
const fieldMetadataId = morphRelations.find(
(morphRelation) =>
morphRelation.targetObjectMetadata.nameSingular ===
recordWithObjectNameSingular.objectNameSingular,
)?.targetFieldMetadata.id;
if (!fieldMetadataId) {
return [];
}
return recordWithObjectNameSingular.value.map((value) => ({
objectNameSingular: recordWithObjectNameSingular.objectNameSingular,
value: value,
fieldMetadataId,
}));
},
);
return recordsWithObjectNameSingular;
};
@@ -190,10 +190,28 @@ export const usePersistField = ({
.getValue();
if (fieldIsRelationManyToOne) {
if (
fieldIsRelationManyToOne &&
valueToPersist?.id === currentValue?.id
) {
if (valueToPersist?.id === currentValue?.id) {
return;
}
const newRecord = await updateOneRecord?.({
idToUpdate: recordId,
updateOneRecordInput: {
[getForeignKeyNameFromRelationFieldName(fieldName)]:
valueToPersist?.id ?? null,
},
});
upsertRecordsInStore([
getRecordFromRecordNode({
recordNode: newRecord,
}),
]);
return;
}
if (fieldIsMorphRelationManyToOne) {
if (valueToPersist?.id === currentValue?.id) {
return;
}
@@ -3,6 +3,7 @@ import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldCont
import { useFieldFocus } from '@/object-record/record-field/ui/hooks/useFieldFocus';
import { useMorphRelationFromManyFieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/useMorphRelationFromManyFieldDisplay';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
import { Fragment, useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
@@ -24,19 +25,21 @@ export const MorphRelationOneToManyFieldDisplay = () => {
.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}
/>
);
})}
{morphValueWithObjectNameSingular.value.map(
(record: ObjectRecord) => {
return (
<RecordChip
key={record.id}
objectNameSingular={
morphValueWithObjectNameSingular.objectNameSingular
}
record={record}
forceDisableClick={disableChipClick}
triggerEvent={triggerEvent}
/>
);
},
)}
</Fragment>
);
})}
@@ -2,6 +2,8 @@ import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts
import { useRelationField } from '../../hooks/useRelationField';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { getFieldMetadataItemById } from '@/object-metadata/utils/getFieldMetadataItemById';
import { useAddNewRecordAndOpenRightDrawer } from '@/object-record/record-field/ui/meta-types/input/hooks/useAddNewRecordAndOpenRightDrawer';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
import { recordFieldInputLayoutDirectionComponentState } from '@/object-record/record-field/ui/states/recordFieldInputLayoutDirectionComponentState';
@@ -15,13 +17,24 @@ import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/ho
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { CustomError, isDefined } from 'twenty-shared/utils';
import { IconForbid } from 'twenty-ui/display';
export const RelationManyToOneFieldInput = () => {
const { t } = useLingui();
const { fieldDefinition, recordId } = useRelationField<ObjectRecord>();
const { objectMetadataItems } = useObjectMetadataItems();
const { fieldMetadataItem, objectMetadataItem } = getFieldMetadataItemById({
fieldMetadataId: fieldDefinition.fieldMetadataId,
objectMetadataItems,
});
if (!fieldMetadataItem || !objectMetadataItem) {
throw new CustomError(
'Field metadata item or object metadata item not found',
'FIELD_METADATA_ITEM_OR_OBJECT_METADATA_ITEM_NOT_FOUND',
);
}
const { onSubmit, onCancel } = useContext(FieldInputEventContext);
const instanceId = useAvailableComponentInstanceIdOrThrow(
@@ -46,9 +59,17 @@ export const RelationManyToOneFieldInput = () => {
const relationFieldMetadataItem = relationObjectMetadataItem.fields.find(
({ id }) => id === fieldDefinition.metadata.relationFieldMetadataId,
);
if (!relationFieldMetadataItem) {
throw new CustomError(
'Relation field metadata item not found',
'RELATION_FIELD_METADATA_ITEM_NOT_FOUND',
);
}
const { createNewRecordAndOpenRightDrawer } =
useAddNewRecordAndOpenRightDrawer({
fieldMetadataItem,
objectMetadataItem,
relationObjectMetadataNameSingular:
fieldDefinition.metadata.relationObjectMetadataNameSingular,
relationObjectMetadataItem,
@@ -5,7 +5,9 @@ import { useUpdateActivityTargetFromCell } from '@/activities/inline-cell/hooks/
import { type NoteTarget } from '@/activities/types/NoteTarget';
import { type TaskTarget } from '@/activities/types/TaskTarget';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getFieldMetadataItemById } from '@/object-metadata/utils/getFieldMetadataItemById';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts/FieldInputEventContext';
import { useRelationField } from '@/object-record/record-field/ui/meta-types/hooks/useRelationField';
@@ -22,7 +24,7 @@ import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/com
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { CustomError, isDefined } from 'twenty-shared/utils';
export const RelationOneToManyFieldInput = () => {
const { fieldDefinition, recordId } = useContext(FieldContext);
@@ -34,6 +36,17 @@ export const RelationOneToManyFieldInput = () => {
const { updateRelation } = useUpdateRelationOneToManyFieldInput();
const fieldName = fieldDefinition.metadata.fieldName;
const { objectMetadataItems } = useObjectMetadataItems();
const { fieldMetadataItem, objectMetadataItem } = getFieldMetadataItemById({
fieldMetadataId: fieldDefinition.fieldMetadataId,
objectMetadataItems,
});
if (!fieldMetadataItem || !objectMetadataItem) {
throw new CustomError(
'Field metadata item or object metadata item not found',
'FIELD_METADATA_ITEM_OR_OBJECT_METADATA_ITEM_NOT_FOUND',
);
}
const objectMetadataNameSingular =
fieldDefinition.metadata.objectMetadataNameSingular;
@@ -73,9 +86,17 @@ export const RelationOneToManyFieldInput = () => {
const relationFieldMetadataItem = relationObjectMetadataItem.fields.find(
({ id }) => id === relationFieldDefinition.metadata.relationFieldMetadataId,
);
if (!relationFieldMetadataItem) {
throw new CustomError(
'Relation field metadata item not found',
'RELATION_FIELD_METADATA_ITEM_NOT_FOUND',
);
}
const { createNewRecordAndOpenRightDrawer } =
useAddNewRecordAndOpenRightDrawer({
fieldMetadataItem,
objectMetadataItem,
relationObjectMetadataNameSingular:
relationFieldDefinition.metadata.relationObjectMetadataNameSingular,
relationObjectMetadataItem,
@@ -81,7 +81,7 @@ const RelationManyToOneFieldInputWithContext = ({
<FieldContext.Provider
value={{
fieldDefinition: {
fieldMetadataId: 'relation',
fieldMetadataId: 'e82262eb-7f58-4167-a23c-fc51ec584d1b',
label: 'Relation',
type: FieldMetadataType.RELATION,
iconName: 'IconLink',
@@ -90,9 +90,9 @@ const RelationManyToOneFieldInputWithContext = ({
relationObjectMetadataNamePlural: 'companies',
relationObjectMetadataNameSingular:
CoreObjectNameSingular.Company,
relationObjectMetadataId: '20202020-8c37-4163-ba06-1dada334ce3e',
relationObjectMetadataId: '4a45f524-b8cb-40e8-8450-28e402b442cf',
objectMetadataNameSingular: 'person',
relationFieldMetadataId: '20202020-8c37-4163-ba06-1dada334ce3e',
relationFieldMetadataId: '3c211c59-02a1-4904-ad0f-5bb30b736461',
},
},
recordId: recordId,
@@ -43,7 +43,7 @@ const RelationOneToManyFieldInputWithContext = () => {
const fieldDefinition = useMemo(
() => ({
fieldMetadataId: 'relation',
fieldMetadataId: 'e82262eb-7f58-4167-a23c-fc51ec584d1b',
label: 'People',
type: FieldMetadataType.RELATION,
iconName: 'IconLink',
@@ -53,7 +53,7 @@ const RelationOneToManyFieldInputWithContext = () => {
relationObjectMetadataNamePlural: 'companies',
relationObjectMetadataNameSingular: CoreObjectNameSingular.Company,
objectMetadataNameSingular: 'person',
relationFieldMetadataId: '20202020-8c37-4163-ba06-1dada334ce3e',
relationFieldMetadataId: '3c211c59-02a1-4904-ad0f-5bb30b736461',
},
}),
[],
@@ -8,26 +8,31 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataIte
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useUpdateOneRecordV2 } from '@/object-record/hooks/useUpdateOneRecordV2';
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
import { viewableRecordNameSingularState } from '@/object-record/record-right-drawer/states/viewableRecordNameSingularState';
import { getOperationName } from '@apollo/client/utilities';
import { isDefined } from 'twenty-shared/utils';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
type RecordDetailRelationSectionProps = {
type useAddNewRecordAndOpenRightDrawerProps = {
fieldMetadataItem: FieldMetadataItem;
objectMetadataItem: ObjectMetadataItem;
relationObjectMetadataNameSingular: string;
relationObjectMetadataItem: ObjectMetadataItem;
relationFieldMetadataItem?: FieldMetadataItem;
relationFieldMetadataItem: FieldMetadataItem;
recordId: string;
};
export const useAddNewRecordAndOpenRightDrawer = ({
fieldMetadataItem,
objectMetadataItem,
relationObjectMetadataNameSingular,
relationObjectMetadataItem,
relationFieldMetadataItem,
recordId,
}: RecordDetailRelationSectionProps) => {
}: useAddNewRecordAndOpenRightDrawerProps) => {
const setViewableRecordId = useSetRecoilState(viewableRecordIdState);
const setViewableRecordNameSingular = useSetRecoilState(
viewableRecordNameSingularState,
@@ -37,11 +42,7 @@ export const useAddNewRecordAndOpenRightDrawer = ({
objectNameSingular: relationObjectMetadataNameSingular,
});
const { updateOneRecord } = useUpdateOneRecord({
objectNameSingular:
relationFieldMetadataItem?.relation?.targetObjectMetadata.nameSingular ??
'workspaceMember',
});
const { updateOneRecord } = useUpdateOneRecordV2();
const { openRecordInCommandMenu } = useOpenRecordInCommandMenu();
@@ -49,14 +50,14 @@ export const useAddNewRecordAndOpenRightDrawer = ({
if (
relationObjectMetadataNameSingular === 'workspaceMember' ||
!isDefined(
relationFieldMetadataItem?.relation?.targetObjectMetadata.nameSingular,
)
!isDefined(objectMetadataItem.nameSingular)
) {
return {
createNewRecordAndOpenRightDrawer: undefined,
};
}
const relationFieldMetadataItemRelationType =
relationFieldMetadataItem.settings?.relationType;
return {
createNewRecordAndOpenRightDrawer: async (searchInput?: string) => {
@@ -84,24 +85,30 @@ export const useAddNewRecordAndOpenRightDrawer = ({
}
: { id: newRecordId, name: searchInput ?? '' };
if (
relationFieldMetadataItem?.relation?.type === RelationType.MANY_TO_ONE
) {
createRecordPayload[
`${relationFieldMetadataItem?.relation?.sourceFieldMetadata.name}Id`
] = recordId;
if (relationFieldMetadataItemRelationType === RelationType.MANY_TO_ONE) {
const gqlField =
relationFieldMetadataItem.type === FieldMetadataType.RELATION
? relationFieldMetadataItem.name
: computeMorphRelationFieldName({
fieldName: relationFieldMetadataItem.name,
relationType: relationFieldMetadataItemRelationType,
targetObjectMetadataNameSingular:
objectMetadataItem.nameSingular,
targetObjectMetadataNamePlural: objectMetadataItem.namePlural,
});
createRecordPayload[`${gqlField}Id`] = recordId;
}
await createOneRecord(createRecordPayload);
if (
relationFieldMetadataItem?.relation?.type === RelationType.ONE_TO_MANY
) {
if (relationFieldMetadataItemRelationType === RelationType.ONE_TO_MANY) {
await updateOneRecord({
objectNameSingular:
objectMetadataItem.nameSingular ?? 'workspaceMember',
idToUpdate: recordId,
updateOneRecordInput: {
[`${relationFieldMetadataItem?.relation?.targetFieldMetadata.name}Id`]:
newRecordId,
[`${fieldMetadataItem.name}Id`]: newRecordId,
},
});
}
@@ -111,7 +111,7 @@ export const useMorphPersistManyToOne = ({
return;
},
[updateOneRecord, objectMetadataItems, objectMetadataNameSingular],
[objectMetadataItems, updateOneRecord, objectMetadataNameSingular],
);
return { persistMorphManyToOne };
@@ -14,6 +14,7 @@ import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFi
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useRecoilCallback } from 'recoil';
import { type ObjectRecord } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const useOpenMorphRelationOneToManyFieldInput = () => {
@@ -83,12 +84,14 @@ export const useOpenMorphRelationOneToManyFieldInput = () => {
return [];
}
return recordWithObjectNameSingular.value.map((recordValue) => ({
objectMetadataId: objectMetadataItem.id,
recordId: recordValue.id,
isSelected: true,
isMatchingSearchFilter: true,
}));
return recordWithObjectNameSingular.value.map(
(recordValue: ObjectRecord) => ({
objectMetadataId: objectMetadataItem.id,
recordId: recordValue.id,
isSelected: true,
isMatchingSearchFilter: true,
}),
);
},
);
@@ -32,7 +32,7 @@ export const recordStoreMorphOneToManyValueWithObjectNameFamilySelector =
objectNameSingular:
morphRelation.targetObjectMetadata.nameSingular,
value: (get(recordStoreFamilyState(recordId))?.[fieldName] ||
[]) as ObjectRecord[],
[]) as ObjectRecord[] | ObjectRecord,
};
},
);
@@ -4,7 +4,7 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataIte
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { generateEmptyFieldValue } from '@/object-record/utils/generateEmptyFieldValue';
import { isDefined } from 'twenty-shared/utils';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
import { FieldMetadataType, RelationType } from '~/generated/graphql';
type PrefillRecordArgs = {
@@ -35,6 +35,28 @@ export const prefillRecord = <T extends ObjectRecord>({
[fieldMetadataItem.settings?.joinColumnName, joinColumnValue],
];
}
if (
fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION &&
fieldMetadataItem.settings?.relationType === RelationType.MANY_TO_ONE
) {
const gqlFields = fieldMetadataItem.morphRelations?.map(
(morphRelation) => {
return computeMorphRelationFieldName({
fieldName: fieldMetadataItem.name,
relationType: morphRelation.type,
targetObjectMetadataNameSingular:
morphRelation.targetObjectMetadata.nameSingular,
targetObjectMetadataNamePlural:
morphRelation.targetObjectMetadata.namePlural,
});
},
);
return gqlFields?.flatMap((gqlField) => [
[gqlField, fieldValue],
[`${gqlField}Id`, input[`${gqlField}Id`] ?? null],
]);
}
return [[fieldMetadataItem.name, fieldValue]];
})