From 1834b38d04befc3809047b5a260e626565f2106d Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Wed, 9 Apr 2025 14:41:11 +0200 Subject: [PATCH] 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 --- .../RecordDetailRelationSection.tsx | 224 +++------------- .../RecordDetailRelationSectionDropdown.tsx | 249 ++++++++++++++++++ 2 files changed, 280 insertions(+), 193 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationSectionDropdown.tsx diff --git a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationSection.tsx b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationSection.tsx index cf4e50c1b..28d551c15 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationSection.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationSection.tsx @@ -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 && ( - - ) - ); - }; + 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 ( @@ -258,61 +145,12 @@ export const RecordDetailRelationSection = ({ hideRightAdornmentOnMouseLeave={!isDropdownOpen && !isMobile} areRecordsAvailable={relationRecords.length > 0} rightAdornment={ - !isFieldReadOnly && ( - - - } - dropdownHotkeyScope={{ scope: dropdownId }} - dropdownComponents={ - isToOneObject ? ( - - ) : ( - { - closeDropdown(); - createNewRecordAndOpenRightDrawer?.(); - }} - onChange={updateRelation} - onSubmit={closeDropdown} - onClickOutside={closeDropdown} - layoutDirection={ - dropdownPlacement?.includes('end') - ? 'search-bar-on-bottom' - : 'search-bar-on-top' - } - /> - ) - } - /> - - ) + } /> - {showContent()} + {relationRecords.length > 0 && ( + + )} ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationSectionDropdown.tsx b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationSectionDropdown.tsx new file mode 100644 index 000000000..f2f6511c7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationSectionDropdown.tsx @@ -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) | 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 ( + + + } + dropdownHotkeyScope={{ scope: dropdownId }} + dropdownComponents={ + isToOneObject ? ( + + ) : ( + { + closeDropdown(); + createNewRecordAndOpenRightDrawer?.(); + }} + onChange={updateRelation} + onSubmit={closeDropdown} + onClickOutside={closeDropdown} + layoutDirection={ + dropdownPlacement?.includes('end') + ? 'search-bar-on-bottom' + : 'search-bar-on-top' + } + /> + ) + } + /> + + ); +};