From 49a80c72d2331d8393eafc118e35db364d6f0845 Mon Sep 17 00:00:00 2001 From: Parship Chowdhury Date: Wed, 1 Jul 2026 15:32:28 +0530 Subject: [PATCH] feat: show group-by context in record show breadcrumb (#22247) Fixes [#837](https://github.com/twentyhq/core-team-issues/issues/837) On the record show page, extend breadcrumb pagination when the current view is grouped: (`rank/total in {viewName} -> {groupValue}`) Example: `Tasks / Schedule follow-up call (1/1,800 in By Status -> To do)` https://github.com/user-attachments/assets/7038d1f5-57e5-4e85-a3e6-09ac46c5b824 https://github.com/user-attachments/assets/88134fa4-e038-4520-a970-ce058a4444b0 Screenshot 2026-06-27 202807 Screenshot 2026-06-27 202851 Screenshot 2026-06-27 203423 Review in cubic --------- Signed-off-by: Parship Chowdhury Co-authored-by: bosiraphael --- .../ObjectRecordShowPageBreadcrumb.tsx | 19 +++- .../useRecordShowPageGroupByBreadcrumbInfo.ts | 95 +++++++++++++++++++ .../utils/getRecordGroupByValueLabel.ts | 41 ++++++++ ...etRecordGroupByValueLabelFromFieldValue.ts | 19 ++++ ...RecordShowPageBreadcrumbPaginationLabel.ts | 28 ++++++ 5 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowPageGroupByBreadcrumbInfo.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-show/utils/getRecordGroupByValueLabel.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-show/utils/getRecordGroupByValueLabelFromFieldValue.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-show/utils/getRecordShowPageBreadcrumbPaginationLabel.ts diff --git a/packages/twenty-front/src/modules/object-record/record-show/components/ObjectRecordShowPageBreadcrumb.tsx b/packages/twenty-front/src/modules/object-record/record-show/components/ObjectRecordShowPageBreadcrumb.tsx index 86c37856a0..1672c126b4 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/components/ObjectRecordShowPageBreadcrumb.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/components/ObjectRecordShowPageBreadcrumb.tsx @@ -6,7 +6,9 @@ import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsRecordFieldReadOnly'; import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; import { useRecordShowContainerActions } from '@/object-record/record-show/hooks/useRecordShowContainerActions'; +import { useRecordShowPageGroupByBreadcrumbInfo } from '@/object-record/record-show/hooks/useRecordShowPageGroupByBreadcrumbInfo'; import { useRecordShowPagePagination } from '@/object-record/record-show/hooks/useRecordShowPagePagination'; +import { getRecordShowPageBreadcrumbPaginationLabel } from '@/object-record/record-show/utils/getRecordShowPageBreadcrumbPaginationLabel'; import { RecordTitleCell } from '@/object-record/record-title-cell/components/RecordTitleCell'; import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType'; import { styled } from '@linaria/react'; @@ -85,8 +87,23 @@ export const ObjectRecordShowPageBreadcrumb = ({ const { navigateToIndexView, rankInView, totalCount } = useRecordShowPagePagination(objectNameSingular, objectRecordId); + const { viewName, groupValueLabel, isGroupByActive, isGroupValueLoading } = + useRecordShowPageGroupByBreadcrumbInfo({ + objectNameSingular, + objectRecordId, + }); + const { formatNumber } = useNumberFormat(); + const paginationInformation = getRecordShowPageBreadcrumbPaginationLabel({ + rank: formatNumber(rankInView + 1), + total: formatNumber(totalCount), + isGroupByActive, + viewName, + isGroupValueLoading, + groupValueLabel, + }); + if (!loading && isInitialLoad) { setIsInitialLoad(false); } @@ -139,7 +156,7 @@ export const ObjectRecordShowPageBreadcrumb = ({ - {`(${formatNumber(rankInView + 1)}/${formatNumber(totalCount)})`} + {paginationInformation} ); diff --git a/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowPageGroupByBreadcrumbInfo.ts b/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowPageGroupByBreadcrumbInfo.ts new file mode 100644 index 0000000000..d0d83b4926 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowPageGroupByBreadcrumbInfo.ts @@ -0,0 +1,95 @@ +import { PreComputedChipGeneratorsContext } from '@/object-metadata/contexts/PreComputedChipGeneratorsContext'; +import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById'; +import { getFieldMetadataItemGqlFieldName } from '@/object-metadata/utils/getFieldMetadataItemGqlFieldName'; +import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField'; +import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; +import { getRecordGroupByValueLabel } from '@/object-record/record-show/utils/getRecordGroupByValueLabel'; +import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly'; +import { useContext } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +export const useRecordShowPageGroupByBreadcrumbInfo = ({ + objectNameSingular, + objectRecordId, +}: { + objectNameSingular: string; + objectRecordId: string; +}) => { + const { currentView } = useGetCurrentViewOnly(); + + const { fieldMetadataItem: groupByFieldMetadataItem } = + useFieldMetadataItemById(currentView?.mainGroupByFieldMetadataId ?? ''); + + const groupByFieldGqlName = isDefined(groupByFieldMetadataItem) + ? getFieldMetadataItemGqlFieldName(groupByFieldMetadataItem) + : undefined; + + const { record, loading: isLoadingRecord } = useFindOneRecord({ + objectNameSingular, + objectRecordId, + recordGqlFields: isDefined(groupByFieldGqlName) + ? { [groupByFieldGqlName]: true } + : undefined, + skip: !isDefined(groupByFieldMetadataItem), + }); + + const rawGroupFieldValue = isDefined(groupByFieldGqlName) + ? record?.[groupByFieldGqlName] + : undefined; + + const isRelationGroupBy = + isDefined(groupByFieldMetadataItem) && + isManyToOneRelationField(groupByFieldMetadataItem); + + const relationRecordId = + isRelationGroupBy && + isDefined(rawGroupFieldValue) && + rawGroupFieldValue !== '' + ? String(rawGroupFieldValue) + : undefined; + + const relationObjectNameSingular = isRelationGroupBy + ? groupByFieldMetadataItem?.relation?.targetObjectMetadata.nameSingular + : undefined; + + const shouldFetchRelationRecord = + isDefined(relationObjectNameSingular) && isDefined(relationRecordId); + + const { record: relationRecord, loading: isLoadingRelationRecord } = + useFindOneRecord({ + objectNameSingular: relationObjectNameSingular ?? objectNameSingular, + objectRecordId: relationRecordId ?? '', + skip: !shouldFetchRelationRecord, + withSoftDeleted: true, + }); + + const { identifierChipGeneratorPerObject } = useContext( + PreComputedChipGeneratorsContext, + ); + + const identifierChipGenerator = isDefined(relationObjectNameSingular) + ? identifierChipGeneratorPerObject[relationObjectNameSingular] + : undefined; + + const groupValueLabel = getRecordGroupByValueLabel({ + groupByFieldMetadataItem, + rawGroupFieldValue, + relationRecordId, + relationRecord, + isLoadingRelationRecord, + identifierChipGenerator, + }); + + const isGroupByActive = isDefined(groupByFieldMetadataItem); + const isGroupValueLoading = + isGroupByActive && + (isLoadingRecord || + (isDefined(relationRecordId) && isLoadingRelationRecord)); + + return { + viewName: currentView?.name, + groupValueLabel, + isGroupByActive, + isGroupValueLoading, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-show/utils/getRecordGroupByValueLabel.ts b/packages/twenty-front/src/modules/object-record/record-show/utils/getRecordGroupByValueLabel.ts new file mode 100644 index 0000000000..bee57a2209 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-show/utils/getRecordGroupByValueLabel.ts @@ -0,0 +1,41 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField'; +import { type RecordChipData } from '@/object-record/record-field/ui/types/RecordChipData'; +import { getRecordGroupByValueLabelFromFieldValue } from '@/object-record/record-show/utils/getRecordGroupByValueLabelFromFieldValue'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { t } from '@lingui/core/macro'; +import { isDefined } from 'twenty-shared/utils'; + +export const getRecordGroupByValueLabel = ({ + groupByFieldMetadataItem, + rawGroupFieldValue, + relationRecordId, + relationRecord, + isLoadingRelationRecord, + identifierChipGenerator, +}: { + groupByFieldMetadataItem?: FieldMetadataItem; + rawGroupFieldValue: unknown; + relationRecordId?: string; + relationRecord?: ObjectRecord; + isLoadingRelationRecord: boolean; + identifierChipGenerator?: (record: ObjectRecord) => RecordChipData; +}): string | undefined => { + if (!isDefined(groupByFieldMetadataItem)) return undefined; + + if ( + isManyToOneRelationField(groupByFieldMetadataItem) && + isDefined(relationRecordId) + ) { + if (isLoadingRelationRecord) return undefined; + if (!isDefined(relationRecord)) return t`Deleted`; + if (!isDefined(identifierChipGenerator)) return undefined; + + return identifierChipGenerator(relationRecord).name; + } + + return getRecordGroupByValueLabelFromFieldValue({ + groupByFieldMetadataItem, + fieldValue: rawGroupFieldValue, + }); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-show/utils/getRecordGroupByValueLabelFromFieldValue.ts b/packages/twenty-front/src/modules/object-record/record-show/utils/getRecordGroupByValueLabelFromFieldValue.ts new file mode 100644 index 0000000000..5c92ebcf8f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-show/utils/getRecordGroupByValueLabelFromFieldValue.ts @@ -0,0 +1,19 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { t } from '@lingui/core/macro'; +import { isDefined } from 'twenty-shared/utils'; + +export const getRecordGroupByValueLabelFromFieldValue = ({ + groupByFieldMetadataItem, + fieldValue, +}: { + groupByFieldMetadataItem: FieldMetadataItem; + fieldValue: unknown; +}): string => { + if (!isDefined(fieldValue) || fieldValue === '') return t`No Value`; + + const selectedOption = groupByFieldMetadataItem.options?.find( + (option) => option.value === fieldValue, + ); + + return selectedOption?.label ?? t`No Value`; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-show/utils/getRecordShowPageBreadcrumbPaginationLabel.ts b/packages/twenty-front/src/modules/object-record/record-show/utils/getRecordShowPageBreadcrumbPaginationLabel.ts new file mode 100644 index 0000000000..6d689bce65 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-show/utils/getRecordShowPageBreadcrumbPaginationLabel.ts @@ -0,0 +1,28 @@ +import { t } from '@lingui/core/macro'; +import { isNonEmptyString } from '@sniptt/guards'; + +export const getRecordShowPageBreadcrumbPaginationLabel = ({ + rank, + total, + isGroupByActive, + viewName, + isGroupValueLoading, + groupValueLabel, +}: { + rank: string; + total: string; + isGroupByActive: boolean; + viewName?: string; + isGroupValueLoading: boolean; + groupValueLabel?: string; +}): string => { + if (!isGroupByActive || !isNonEmptyString(viewName)) { + return `(${rank}/${total})`; + } + + if (isGroupValueLoading || !isNonEmptyString(groupValueLabel)) { + return t`(${rank}/${total} in ${viewName})`; + } + + return t`(${rank}/${total} in ${viewName} → ${groupValueLabel})`; +};