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

<img width="1427" height="173" alt="Screenshot 2026-06-27 202807"
src="https://github.com/user-attachments/assets/842d911e-b4bc-4443-afcd-4c67ed007ae0"
/>
<img width="1426" height="183" alt="Screenshot 2026-06-27 202851"
src="https://github.com/user-attachments/assets/f8d9ee31-b1cb-46fa-9e7c-8876d4b099ff"
/>
<img width="1427" height="178" alt="Screenshot 2026-06-27 203423"
src="https://github.com/user-attachments/assets/196ed694-0edb-4b52-934c-0938d3fd4da2"
/>




<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22247?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Signed-off-by: Parship Chowdhury <parshipchowdhury@gmail.com>
Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
This commit is contained in:
Parship Chowdhury
2026-07-01 15:32:28 +05:30
committed by GitHub
parent 4fef02394f
commit 49a80c72d2
5 changed files with 201 additions and 1 deletions
@@ -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 = ({
</FieldContext.Provider>
</StyledTitle>
<StyledPaginationInformation>
{`(${formatNumber(rankInView + 1)}/${formatNumber(totalCount)})`}
{paginationInformation}
</StyledPaginationInformation>
</StyledEditableTitleContainer>
);
@@ -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,
};
};
@@ -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,
});
};
@@ -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`;
};
@@ -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})`;
};