Fix show page relation record count (#11459)

This PR fixes the incorrect relation count on a show page relation
section title, when there are more than 60 records.

An aggregate COUNT query has been used to rely on the backend.

A new component RecordDetailRelationSectionDropdown has been created to
abstract a chunk of the parent RecordDetailRelationSection component.

Fixes https://github.com/twentyhq/twenty/issues/11032
This commit is contained in:
Lucas Bordeau
2025-04-09 14:41:11 +02:00
committed by GitHub
parent bd3ec6d5e3
commit 1834b38d04
2 changed files with 280 additions and 193 deletions

View File

@ -1,65 +1,46 @@
import styled from '@emotion/styled';
import { useCallback, useContext } from 'react';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { RecordGqlOperationFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { useAggregateRecords } from '@/object-record/hooks/useAggregateRecords';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { useIsFieldValueReadOnly } from '@/object-record/record-field/hooks/useIsFieldValueReadOnly';
import { useIsRecordReadOnly } from '@/object-record/record-field/hooks/useIsRecordReadOnly';
import { usePersistField } from '@/object-record/record-field/hooks/usePersistField';
import { useAddNewRecordAndOpenRightDrawer } from '@/object-record/record-field/meta-types/input/hooks/useAddNewRecordAndOpenRightDrawer';
import { useUpdateRelationFromManyFieldInput } from '@/object-record/record-field/meta-types/input/hooks/useUpdateRelationFromManyFieldInput';
import { FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { MultipleRecordPicker } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPicker';
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 { SingleRecordPicker } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPicker';
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 { SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
import { RecordDetailRelationRecordsList } from '@/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsList';
import { RecordDetailRelationSectionDropdown } from '@/object-record/record-show/record-detail-section/components/RecordDetailRelationSectionDropdown';
import { RecordDetailSection } from '@/object-record/record-show/record-detail-section/components/RecordDetailSection';
import { RecordDetailSectionHeader } from '@/object-record/record-show/record-detail-section/components/RecordDetailSectionHeader';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { prefetchIndexViewIdFromObjectMetadataItemFamilySelector } from '@/prefetch/states/selector/prefetchIndexViewIdFromObjectMetadataItemFamilySelector';
import { AppPath } from '@/types/AppPath';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { useLingui } from '@lingui/react/macro';
import { RelationDefinitionType } from '~/generated-metadata/graphql';
import { getAppPath } from '~/utils/navigation/getAppPath';
import { IconForbid, IconPencil, IconPlus } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
type RecordDetailRelationSectionProps = {
loading: boolean;
};
const StyledAddDropdown = styled(Dropdown)`
margin-left: auto;
`;
export const RecordDetailRelationSection = ({
loading,
}: RecordDetailRelationSectionProps) => {
const { t } = useLingui();
const { recordId, fieldDefinition } = useContext(FieldContext);
const {
fieldName,
relationFieldMetadataId,
relationObjectMetadataNameSingular,
relationType,
} = fieldDefinition.metadata as FieldRelationMetadata;
const record = useRecoilValue(recordStoreFamilyState(recordId));
const isMobile = useIsMobile();
const { objectMetadataItem: relationObjectMetadataItem } =
@ -86,69 +67,7 @@ export const RecordDetailRelationSection = ({
const dropdownId = `record-field-card-relation-picker-${fieldDefinition.fieldMetadataId}-${recordId}`;
const { closeDropdown, isDropdownOpen, dropdownPlacement } =
useDropdown(dropdownId);
const setMultipleRecordPickerSearchFilter = useSetRecoilComponentStateV2(
multipleRecordPickerSearchFilterComponentState,
dropdownId,
);
const setMultipleRecordPickerPickableMorphItems =
useSetRecoilComponentStateV2(
multipleRecordPickerPickableMorphItemsComponentState,
dropdownId,
);
const setMultipleRecordPickerSearchableObjectMetadataItems =
useSetRecoilComponentStateV2(
multipleRecordPickerSearchableObjectMetadataItemsComponentState,
dropdownId,
);
const { performSearch: multipleRecordPickerPerformSearch } =
useMultipleRecordPickerPerformSearch();
const setSingleRecordPickerSearchFilter = useSetRecoilComponentStateV2(
singleRecordPickerSearchFilterComponentState,
dropdownId,
);
const setSingleRecordPickerSelectedId = useSetRecoilComponentStateV2(
singleRecordPickerSelectedIdComponentState,
dropdownId,
);
const handleCloseRelationPickerDropdown = useCallback(() => {
setMultipleRecordPickerSearchFilter('');
}, [setMultipleRecordPickerSearchFilter]);
const persistField = usePersistField();
const { updateOneRecord: updateOneRelationRecord } = useUpdateOneRecord({
objectNameSingular: relationObjectMetadataNameSingular,
});
const handleRelationPickerEntitySelected = (
selectedRelationEntity?: SingleRecordPickerRecord,
) => {
closeDropdown();
if (!selectedRelationEntity?.id || !relationFieldMetadataItem?.name) return;
if (isToOneObject) {
persistField(selectedRelationEntity.record);
return;
}
updateOneRelationRecord({
idToUpdate: selectedRelationEntity.id,
updateOneRecordInput: {
[relationFieldMetadataItem.name]: record,
},
});
};
const { updateRelation } = useUpdateRelationFromManyFieldInput();
const { isDropdownOpen } = useDropdown(dropdownId);
const indexViewId = useRecoilValue(
prefetchIndexViewIdFromObjectMetadataItemFamilySelector({
@ -175,20 +94,24 @@ export const RecordDetailRelationSection = ({
filterQueryParams,
);
const showContent = () => {
return (
relationRecords.length > 0 && (
<RecordDetailRelationRecordsList relationRecords={relationRecords} />
)
);
};
const filtersForAggregate = isToManyObjects
? ({
[`${relationFieldMetadataItem?.name}Id`]: {
in: [recordId],
},
} satisfies RecordGqlOperationFilter)
: {};
const { createNewRecordAndOpenRightDrawer } =
useAddNewRecordAndOpenRightDrawer({
relationObjectMetadataNameSingular,
relationObjectMetadataItem,
relationFieldMetadataItem,
recordId,
const { data: relationAggregateResult, loading: aggregateLoading } =
useAggregateRecords<{
id: { COUNT: number };
}>({
objectNameSingular: relationObjectMetadataItem.nameSingular,
filter: filtersForAggregate,
skip: !isToManyObjects,
recordGqlFieldsAggregate: {
id: [AGGREGATE_OPERATIONS.count],
},
});
const isRecordReadOnly = useIsRecordReadOnly({
@ -200,45 +123,9 @@ export const RecordDetailRelationSection = ({
isRecordReadOnly,
});
if (loading) return null;
if (loading || aggregateLoading || isFieldReadOnly) return null;
const relationRecordsCount = relationRecords.length;
const handleOpenRelationPickerDropdown = () => {
if (isToOneObject) {
setSingleRecordPickerSearchFilter('');
if (relationRecords.length > 0) {
setSingleRecordPickerSelectedId(relationRecords[0].id);
}
}
if (isToManyObjects) {
setMultipleRecordPickerSearchableObjectMetadataItems([
relationObjectMetadataItem,
]);
setMultipleRecordPickerSearchFilter('');
setMultipleRecordPickerPickableMorphItems(
relationRecords.map((record) => ({
recordId: record.id,
objectMetadataId: relationObjectMetadataItem.id,
isSelected: true,
isMatchingSearchFilter: true,
})),
);
multipleRecordPickerPerformSearch({
multipleRecordPickerInstanceId: dropdownId,
forceSearchFilter: '',
forceSearchableObjectMetadataItems: [relationObjectMetadataItem],
forcePickableMorphItems: relationRecords.map((record) => ({
recordId: record.id,
objectMetadataId: relationObjectMetadataItem.id,
isSelected: true,
isMatchingSearchFilter: true,
})),
});
}
};
const relationRecordsCount = relationAggregateResult?.id?.COUNT ?? 0;
return (
<RecordDetailSection>
@ -258,61 +145,12 @@ export const RecordDetailRelationSection = ({
hideRightAdornmentOnMouseLeave={!isDropdownOpen && !isMobile}
areRecordsAvailable={relationRecords.length > 0}
rightAdornment={
!isFieldReadOnly && (
<DropdownScope dropdownScopeId={dropdownId}>
<StyledAddDropdown
dropdownId={dropdownId}
dropdownPlacement="left-start"
onClose={handleCloseRelationPickerDropdown}
onOpen={handleOpenRelationPickerDropdown}
clickableComponent={
<LightIconButton
className="displayOnHover"
Icon={isToOneObject ? IconPencil : IconPlus}
accent="tertiary"
/>
}
dropdownHotkeyScope={{ scope: dropdownId }}
dropdownComponents={
isToOneObject ? (
<SingleRecordPicker
componentInstanceId={dropdownId}
EmptyIcon={IconForbid}
onRecordSelected={handleRelationPickerEntitySelected}
objectNameSingular={relationObjectMetadataNameSingular}
recordPickerInstanceId={dropdownId}
onCreate={createNewRecordAndOpenRightDrawer}
onCancel={closeDropdown}
layoutDirection={
dropdownPlacement?.includes('end')
? 'search-bar-on-bottom'
: 'search-bar-on-top'
}
/>
) : (
<MultipleRecordPicker
componentInstanceId={dropdownId}
onCreate={() => {
closeDropdown();
createNewRecordAndOpenRightDrawer?.();
}}
onChange={updateRelation}
onSubmit={closeDropdown}
onClickOutside={closeDropdown}
layoutDirection={
dropdownPlacement?.includes('end')
? 'search-bar-on-bottom'
: 'search-bar-on-top'
}
/>
)
}
/>
</DropdownScope>
)
<RecordDetailRelationSectionDropdown loading={loading} />
}
/>
{showContent()}
{relationRecords.length > 0 && (
<RecordDetailRelationRecordsList relationRecords={relationRecords} />
)}
</RecordDetailSection>
);
};

View File

@ -0,0 +1,249 @@
import styled from '@emotion/styled';
import { useCallback, useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { useIsFieldValueReadOnly } from '@/object-record/record-field/hooks/useIsFieldValueReadOnly';
import { useIsRecordReadOnly } from '@/object-record/record-field/hooks/useIsRecordReadOnly';
import { usePersistField } from '@/object-record/record-field/hooks/usePersistField';
import { useAddNewRecordAndOpenRightDrawer } from '@/object-record/record-field/meta-types/input/hooks/useAddNewRecordAndOpenRightDrawer';
import { useUpdateRelationFromManyFieldInput } from '@/object-record/record-field/meta-types/input/hooks/useUpdateRelationFromManyFieldInput';
import { FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { MultipleRecordPicker } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPicker';
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 { SingleRecordPicker } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPicker';
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 { SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { IconForbid, IconPencil, IconPlus } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { RelationDefinitionType } from '~/generated-metadata/graphql';
type RecordDetailRelationSectionDropdownProps = {
loading: boolean;
};
const StyledAddDropdown = styled(Dropdown)`
margin-left: auto;
`;
export const RecordDetailRelationSectionDropdown = ({
loading,
}: RecordDetailRelationSectionDropdownProps) => {
const { recordId, fieldDefinition } = useContext(FieldContext);
const {
fieldName,
relationFieldMetadataId,
relationObjectMetadataNameSingular,
relationType,
} = fieldDefinition.metadata as FieldRelationMetadata;
const record = useRecoilValue(recordStoreFamilyState(recordId));
const { objectMetadataItem: relationObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular: relationObjectMetadataNameSingular,
});
const relationFieldMetadataItem = relationObjectMetadataItem.fields.find(
({ id }) => id === relationFieldMetadataId,
);
const fieldValue = useRecoilValue<
({ id: string } & Record<string, any>) | ObjectRecord[] | null
>(recordStoreFamilySelector({ recordId, fieldName }));
// TODO: use new relation type
const isToOneObject = relationType === RelationDefinitionType.MANY_TO_ONE;
const isToManyObjects = relationType === RelationDefinitionType.ONE_TO_MANY;
const relationRecords: ObjectRecord[] =
fieldValue && isToOneObject
? [fieldValue as ObjectRecord]
: ((fieldValue as ObjectRecord[]) ?? []);
const dropdownId = `record-field-card-relation-picker-${fieldDefinition.fieldMetadataId}-${recordId}`;
const { closeDropdown, dropdownPlacement } = useDropdown(dropdownId);
const setMultipleRecordPickerSearchFilter = useSetRecoilComponentStateV2(
multipleRecordPickerSearchFilterComponentState,
dropdownId,
);
const setMultipleRecordPickerPickableMorphItems =
useSetRecoilComponentStateV2(
multipleRecordPickerPickableMorphItemsComponentState,
dropdownId,
);
const setMultipleRecordPickerSearchableObjectMetadataItems =
useSetRecoilComponentStateV2(
multipleRecordPickerSearchableObjectMetadataItemsComponentState,
dropdownId,
);
const { performSearch: multipleRecordPickerPerformSearch } =
useMultipleRecordPickerPerformSearch();
const setSingleRecordPickerSearchFilter = useSetRecoilComponentStateV2(
singleRecordPickerSearchFilterComponentState,
dropdownId,
);
const setSingleRecordPickerSelectedId = useSetRecoilComponentStateV2(
singleRecordPickerSelectedIdComponentState,
dropdownId,
);
const handleCloseRelationPickerDropdown = useCallback(() => {
setMultipleRecordPickerSearchFilter('');
}, [setMultipleRecordPickerSearchFilter]);
const persistField = usePersistField();
const { updateOneRecord: updateOneRelationRecord } = useUpdateOneRecord({
objectNameSingular: relationObjectMetadataNameSingular,
});
const handleRelationPickerEntitySelected = (
selectedRelationEntity?: SingleRecordPickerRecord,
) => {
closeDropdown();
if (!selectedRelationEntity?.id || !relationFieldMetadataItem?.name) return;
if (isToOneObject) {
persistField(selectedRelationEntity.record);
return;
}
updateOneRelationRecord({
idToUpdate: selectedRelationEntity.id,
updateOneRecordInput: {
[relationFieldMetadataItem.name]: record,
},
});
};
const { updateRelation } = useUpdateRelationFromManyFieldInput();
const { createNewRecordAndOpenRightDrawer } =
useAddNewRecordAndOpenRightDrawer({
relationObjectMetadataNameSingular,
relationObjectMetadataItem,
relationFieldMetadataItem,
recordId,
});
const isRecordReadOnly = useIsRecordReadOnly({
recordId,
});
const isFieldReadOnly = useIsFieldValueReadOnly({
fieldDefinition,
isRecordReadOnly,
});
if (loading || isFieldReadOnly) return null;
const handleOpenRelationPickerDropdown = () => {
if (isToOneObject) {
setSingleRecordPickerSearchFilter('');
if (relationRecords.length > 0) {
setSingleRecordPickerSelectedId(relationRecords[0].id);
}
}
if (isToManyObjects) {
setMultipleRecordPickerSearchableObjectMetadataItems([
relationObjectMetadataItem,
]);
setMultipleRecordPickerSearchFilter('');
setMultipleRecordPickerPickableMorphItems(
relationRecords.map((record) => ({
recordId: record.id,
objectMetadataId: relationObjectMetadataItem.id,
isSelected: true,
isMatchingSearchFilter: true,
})),
);
multipleRecordPickerPerformSearch({
multipleRecordPickerInstanceId: dropdownId,
forceSearchFilter: '',
forceSearchableObjectMetadataItems: [relationObjectMetadataItem],
forcePickableMorphItems: relationRecords.map((record) => ({
recordId: record.id,
objectMetadataId: relationObjectMetadataItem.id,
isSelected: true,
isMatchingSearchFilter: true,
})),
});
}
};
return (
<DropdownScope dropdownScopeId={dropdownId}>
<StyledAddDropdown
dropdownId={dropdownId}
dropdownPlacement="left-start"
onClose={handleCloseRelationPickerDropdown}
onOpen={handleOpenRelationPickerDropdown}
clickableComponent={
<LightIconButton
className="displayOnHover"
Icon={isToOneObject ? IconPencil : IconPlus}
accent="tertiary"
/>
}
dropdownHotkeyScope={{ scope: dropdownId }}
dropdownComponents={
isToOneObject ? (
<SingleRecordPicker
componentInstanceId={dropdownId}
EmptyIcon={IconForbid}
onRecordSelected={handleRelationPickerEntitySelected}
objectNameSingular={relationObjectMetadataNameSingular}
recordPickerInstanceId={dropdownId}
onCreate={createNewRecordAndOpenRightDrawer}
onCancel={closeDropdown}
layoutDirection={
dropdownPlacement?.includes('end')
? 'search-bar-on-bottom'
: 'search-bar-on-top'
}
/>
) : (
<MultipleRecordPicker
componentInstanceId={dropdownId}
onCreate={() => {
closeDropdown();
createNewRecordAndOpenRightDrawer?.();
}}
onChange={updateRelation}
onSubmit={closeDropdown}
onClickOutside={closeDropdown}
layoutDirection={
dropdownPlacement?.includes('end')
? 'search-bar-on-bottom'
: 'search-bar-on-top'
}
/>
)
}
/>
</DropdownScope>
);
};