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:
committed by
GitHub
parent
4fef02394f
commit
49a80c72d2
+18
-1
@@ -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>
|
||||
);
|
||||
|
||||
+95
@@ -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,
|
||||
};
|
||||
};
|
||||
+41
@@ -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,
|
||||
});
|
||||
};
|
||||
+19
@@ -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`;
|
||||
};
|
||||
+28
@@ -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})`;
|
||||
};
|
||||
Reference in New Issue
Block a user