fix(front): respect user number format for counts and aggregates (#21894)

## Problem

Several user-facing numbers were rendered raw (e.g. `153909`) instead of
honoring the workspace member's **Number format** preference (e.g. `153
909` with `Spaces and comma`). The formatting utilities already existed
(`formatNumber` / `useNumberFormat`) but were not applied on these
surfaces.

## Root cause

`transformAggregateRawValueIntoAggregateDisplayValue` — the shared
helper behind every table/board/chart aggregate — returned the `COUNT`
branch as a raw string and never threaded the user's locale format into
`formatNumber` for the other branches (so they silently fell back to
`COMMAS_AND_DOT`).

Its existing `numberFormat` param actually held the chart `SHORT`/`FULL`
abbreviation setting, so it is renamed to `chartNumberFormat`, and a new
`numberFormat: NumberFormat` now carries the locale separators.

## Surfaces fixed

- Record table footer aggregates, including the raw **"Count all"**
total
- Record board column / group-section aggregates
- Aggregate chart and pie-chart center metric (including their raw
`COUNT` early-returns)
- View picker `<view> · <count>` total
- Record show breadcrumb pagination `(x/y)`
- Record index header and side panel `N selected` counts

The board-column header needs no change — it now receives an
already-formatted string from the transform.

## Out of scope (intentionally left raw)

The editable `SettingsCounter` input (formatting would break parsing),
the advanced-filter pill, the `+N` overflow badge, and the AI routing
debug display.

## Testing

- New + existing unit tests pass
(`transformAggregateRawValueIntoAggregateDisplayValue`, `formatNumber`,
`useNumberFormat`), with added locale-aware coverage (`SPACES_AND_COMMA`
→ `153 909`, `DOTS_AND_COMMA` → `153.909`).
- `nx typecheck twenty-front`, oxlint and oxfmt on the diff all pass.

> Note: two i18n strings change placeholder shape (`{count} selected` →
`{0} selected`); a `lingui:extract` will refresh the catalogs (runtime
falls back to source text meanwhile).

https://claude.ai/code/session_013XNL2Xa11Bw7fsnPFQgsGX

---
_Generated by [Claude
Code](https://claude.ai/code/session_013XNL2Xa11Bw7fsnPFQgsGX)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21894?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. -->
This commit is contained in:
Félix Malfait
2026-06-20 14:28:12 +02:00
committed by GitHub
parent 2e1da86535
commit b8ea742a88
10 changed files with 131 additions and 28 deletions
@@ -4,6 +4,7 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataIte
import { transformAggregateRawValueIntoAggregateDisplayValue } from '@/object-record/record-aggregate/utils/transformAggregateRawValueIntoAggregateDisplayValue';
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { DateAggregateOperations } from '@/object-record/record-table/constants/DateAggregateOperations';
import { NumberFormat } from '@/localization/constants/NumberFormat';
import { enUS } from 'date-fns/locale';
import { findByProperty } from 'twenty-shared/utils';
import {
@@ -48,6 +49,50 @@ describe('transformAggregateRawValueIntoAggregateDisplayValue', () => {
).toBe('4');
});
it('should format large COUNT values with the default number format', () => {
expect(
transformAggregateRawValueIntoAggregateDisplayValue({
aggregateFieldMetadataItem: undefined,
aggregateOperation: AggregateOperations.COUNT,
aggregateRawValue: 153909,
dateFormat: DateFormat.DAY_FIRST,
timeFormat: TimeFormat.HOUR_24,
localeCatalog: enUS,
timeZone: 'UTC',
}),
).toBe('153,909');
});
it('should format large COUNT values respecting the SPACES_AND_COMMA number format', () => {
expect(
transformAggregateRawValueIntoAggregateDisplayValue({
aggregateFieldMetadataItem: undefined,
aggregateOperation: AggregateOperations.COUNT,
aggregateRawValue: 153909,
dateFormat: DateFormat.DAY_FIRST,
timeFormat: TimeFormat.HOUR_24,
localeCatalog: enUS,
timeZone: 'UTC',
numberFormat: NumberFormat.SPACES_AND_COMMA,
}),
).toBe('153\u202F909');
});
it('should format a COUNT value provided as a string', () => {
expect(
transformAggregateRawValueIntoAggregateDisplayValue({
aggregateFieldMetadataItem: undefined,
aggregateOperation: AggregateOperations.COUNT,
aggregateRawValue: '153909',
dateFormat: DateFormat.DAY_FIRST,
timeFormat: TimeFormat.HOUR_24,
localeCatalog: enUS,
timeZone: 'UTC',
numberFormat: NumberFormat.DOTS_AND_COMMA,
}),
).toBe('153.909');
});
it('should return "-" for nullish aggregate raw value', () => {
expect(
transformAggregateRawValueIntoAggregateDisplayValue({
@@ -123,6 +168,21 @@ describe('transformAggregateRawValueIntoAggregateDisplayValue', () => {
).toBe('100,000,000');
});
it('should return number formatted value respecting the DOTS_AND_COMMA number format', () => {
expect(
transformAggregateRawValueIntoAggregateDisplayValue({
aggregateFieldMetadataItem: mockCompanyEmployeesFieldMetadataItem,
aggregateOperation: AggregateOperations.SUM,
aggregateRawValue: 100000000,
dateFormat: DateFormat.DAY_FIRST,
timeFormat: TimeFormat.HOUR_24,
localeCatalog: enUS,
timeZone: 'UTC',
numberFormat: NumberFormat.DOTS_AND_COMMA,
}),
).toBe('100.000.000');
});
it('should return full currency formatted value with FULL number format', () => {
const mockCurrencyFieldMetadataItem = {
...mockCompanyEmployeesFieldMetadataItem,
@@ -138,7 +198,7 @@ describe('transformAggregateRawValueIntoAggregateDisplayValue', () => {
timeFormat: TimeFormat.HOUR_24,
localeCatalog: enUS,
timeZone: 'UTC',
numberFormat: ChartNumberFormat.FULL,
chartNumberFormat: ChartNumberFormat.FULL,
}),
).toBe('230,440');
});
@@ -158,7 +218,7 @@ describe('transformAggregateRawValueIntoAggregateDisplayValue', () => {
timeFormat: TimeFormat.HOUR_24,
localeCatalog: enUS,
timeZone: 'UTC',
numberFormat: ChartNumberFormat.SHORT,
chartNumberFormat: ChartNumberFormat.SHORT,
}),
).toBe('230.4k');
});
@@ -173,7 +233,7 @@ describe('transformAggregateRawValueIntoAggregateDisplayValue', () => {
timeFormat: TimeFormat.HOUR_24,
localeCatalog: enUS,
timeZone: 'UTC',
numberFormat: ChartNumberFormat.SHORT,
chartNumberFormat: ChartNumberFormat.SHORT,
}),
).toBe('100m');
});
@@ -188,7 +248,7 @@ describe('transformAggregateRawValueIntoAggregateDisplayValue', () => {
timeFormat: TimeFormat.HOUR_24,
localeCatalog: enUS,
timeZone: 'UTC',
numberFormat: ChartNumberFormat.FULL,
chartNumberFormat: ChartNumberFormat.FULL,
}),
).toBe('100,000,000');
});
@@ -1,6 +1,7 @@
import { type Locale } from 'date-fns';
import { type DateFormat } from '@/localization/constants/DateFormat';
import { type NumberFormat } from '@/localization/constants/NumberFormat';
import { type TimeFormat } from '@/localization/constants/TimeFormat';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { COUNT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/countAggregateOperationOptions';
@@ -26,6 +27,7 @@ export const transformAggregateRawValueIntoAggregateDisplayValue = ({
timeZone,
localeCatalog,
numberFormat,
chartNumberFormat,
}: {
aggregateFieldMetadataItem: Nullable<FieldMetadataItem>;
aggregateOperation: ExtendedAggregateOperations;
@@ -34,7 +36,8 @@ export const transformAggregateRawValueIntoAggregateDisplayValue = ({
timeFormat: TimeFormat;
timeZone: string;
localeCatalog: Locale;
numberFormat?: ChartNumberFormat;
numberFormat?: NumberFormat;
chartNumberFormat?: ChartNumberFormat;
}): string => {
if (!isDefined(aggregateRawValue)) {
return '-';
@@ -43,7 +46,7 @@ export const transformAggregateRawValueIntoAggregateDisplayValue = ({
aggregateOperation as AggregateOperations,
)
) {
return `${aggregateRawValue}`;
return formatNumber(Number(aggregateRawValue), { format: numberFormat });
} else if (!isDefined(aggregateFieldMetadataItem)) {
return '-';
} else if (
@@ -51,13 +54,13 @@ export const transformAggregateRawValueIntoAggregateDisplayValue = ({
aggregateOperation as AggregateOperations,
)
) {
return `${formatNumber(Number(aggregateRawValue) * 100)}%`;
return `${formatNumber(Number(aggregateRawValue) * 100, { format: numberFormat })}%`;
} else {
switch (aggregateFieldMetadataItem.type) {
case FieldMetadataType.CURRENCY: {
const amount = Number(aggregateRawValue) / 1_000_000;
return numberFormat === ChartNumberFormat.FULL
? formatNumber(amount, { decimals: 2 })
return chartNumberFormat === ChartNumberFormat.FULL
? formatNumber(amount, { decimals: 2, format: numberFormat })
: formatToShortNumber(amount);
}
@@ -65,11 +68,11 @@ export const transformAggregateRawValueIntoAggregateDisplayValue = ({
const castedValue = Number(aggregateRawValue);
const { decimals, type } = aggregateFieldMetadataItem.settings ?? {};
if (type === 'percentage') {
return `${formatNumber(castedValue * 100, { decimals })}%`;
return `${formatNumber(castedValue * 100, { decimals, format: numberFormat })}%`;
}
return numberFormat === ChartNumberFormat.SHORT
return chartNumberFormat === ChartNumberFormat.SHORT
? formatToShortNumber(castedValue)
: formatNumber(castedValue, { decimals });
: formatNumber(castedValue, { decimals, format: numberFormat });
}
case FieldMetadataType.DATE_TIME: {
@@ -3,6 +3,7 @@ import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainCo
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { RecordIndexPageHeaderIcon } from '@/object-record/record-index/components/RecordIndexPageHeaderIcon';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
@@ -39,6 +40,8 @@ export const RecordIndexPageHeader = () => {
contextStoreNumberOfSelectedRecordsComponentState,
);
const { formatNumber } = useNumberFormat();
const { objectNamePlural } = useRecordIndexContextOrThrow();
const objectMetadataItem =
@@ -52,7 +55,7 @@ export const RecordIndexPageHeader = () => {
<StyledTitle>{label}</StyledTitle>
<>{'->'}</>
<StyledSelectedRecordsCount>
{t`${contextStoreNumberOfSelectedRecords} selected`}
{t`${formatNumber(contextStoreNumberOfSelectedRecords)} selected`}
</StyledSelectedRecordsCount>
</StyledTitleWithSelectedRecords>
) : (
@@ -1,3 +1,4 @@
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { transformAggregateRawValueIntoAggregateDisplayValue } from '@/object-record/record-aggregate/utils/transformAggregateRawValueIntoAggregateDisplayValue';
import { recordIndexAggregateDisplayValueForGroupValueComponentFamilyState } from '@/object-record/record-index/states/recordIndexAggregateDisplayValueForGroupValueComponentFamilyState';
@@ -13,6 +14,7 @@ import { dateLocaleState } from '~/localization/states/dateLocaleState';
export const useSetRecordIndexAggregateDisplayValueForRecordGroupValue = () => {
const { dateFormat, timeFormat, timeZone } = useContext(UserContext);
const dateLocale = useAtomStateValue(dateLocaleState);
const { numberFormat } = useNumberFormat();
const recordIndexAggregateValueByGroupValueCallbackState =
useAtomComponentFamilyStateCallbackState(
@@ -38,6 +40,7 @@ export const useSetRecordIndexAggregateDisplayValueForRecordGroupValue = () => {
timeFormat,
timeZone,
localeCatalog: dateLocale.localeCatalog,
numberFormat,
});
store.set(
@@ -54,6 +57,7 @@ export const useSetRecordIndexAggregateDisplayValueForRecordGroupValue = () => {
store,
timeFormat,
timeZone,
numberFormat,
],
);
@@ -1,3 +1,4 @@
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { ObjectMetadataIcon } from '@/object-metadata/components/ObjectMetadataIcon';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
@@ -84,6 +85,8 @@ export const ObjectRecordShowPageBreadcrumb = ({
const { navigateToIndexView, rankInView, totalCount } =
useRecordShowPagePagination(objectNameSingular, objectRecordId);
const { formatNumber } = useNumberFormat();
if (!loading && isInitialLoad) {
setIsInitialLoad(false);
}
@@ -136,7 +139,7 @@ export const ObjectRecordShowPageBreadcrumb = ({
</FieldContext.Provider>
</StyledTitle>
<StyledPaginationInformation>
{`(${rankInView + 1}/${totalCount})`}
{`(${formatNumber(rankInView + 1)}/${formatNumber(totalCount)})`}
</StyledPaginationInformation>
</StyledEditableTitleContainer>
);
@@ -1,3 +1,4 @@
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useAggregateRecords } from '@/object-record/hooks/useAggregateRecords';
import { transformAggregateRawValueIntoAggregateDisplayValue } from '@/object-record/record-aggregate/utils/transformAggregateRawValueIntoAggregateDisplayValue';
@@ -36,6 +37,8 @@ export const useAggregateRecordsForRecordTableColumnFooter = (
const { objectMetadataItem } = useRecordTableContextOrThrow();
const { recordGroupFilter } = useRecordGroupFilter(objectMetadataItem.fields);
const { numberFormat, formatNumber } = useNumberFormat();
const currentRecordFilterGroups = useAtomComponentStateValue(
currentRecordFilterGroupsComponentState,
);
@@ -125,11 +128,15 @@ export const useAggregateRecordsForRecordTableColumnFooter = (
);
if (!isDefined(aggregateFieldMetadataItem)) {
const totalCountAggregateValue =
data?.[FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION]?.[
AggregateOperations.COUNT
];
return {
aggregateValue:
data?.[FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION]?.[
AggregateOperations.COUNT
],
aggregateValue: isDefined(totalCountAggregateValue)
? formatNumber(Number(totalCountAggregateValue))
: totalCountAggregateValue,
aggregateLabel: getAggregateOperationLabel(AggregateOperations.COUNT),
isLoading: loading,
};
@@ -155,6 +162,7 @@ export const useAggregateRecordsForRecordTableColumnFooter = (
localeCatalog: dateLocale.localeCatalog,
timeFormat,
timeZone,
numberFormat,
});
const { aggregateLabel } = getRecordAggregateDisplayLabel({
@@ -1,3 +1,4 @@
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { useAggregateRecords } from '@/object-record/hooks/useAggregateRecords';
import { transformAggregateRawValueIntoAggregateDisplayValue } from '@/object-record/record-aggregate/utils/transformAggregateRawValueIntoAggregateDisplayValue';
@@ -81,6 +82,7 @@ export const usePieChartCenterMetricData = ({
const { dateFormat, timeFormat, timeZone } = useContext(UserContext);
const dateLocale = useAtomStateValue(dateLocaleState);
const { numberFormat, formatNumber } = useNumberFormat();
const aggregateFieldMetadataItem = objectMetadataItem.readableFields.find(
findById(configuration.aggregateFieldMetadataId),
@@ -115,9 +117,14 @@ export const usePieChartCenterMetricData = ({
const centerMetricValue = useMemo(() => {
if (!isDefined(aggregateFieldMetadataItem)) {
return centerMetricData?.[FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION]?.[
AggregateOperations.COUNT
];
const totalCountValue =
centerMetricData?.[FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION]?.[
AggregateOperations.COUNT
];
return isDefined(totalCountValue)
? formatNumber(Number(totalCountValue))
: totalCountValue;
}
const aggregateRawValue =
@@ -133,6 +140,7 @@ export const usePieChartCenterMetricData = ({
localeCatalog: dateLocale.localeCatalog,
timeFormat,
timeZone,
numberFormat,
});
}, [
aggregateFieldMetadataItem,
@@ -143,6 +151,8 @@ export const usePieChartCenterMetricData = ({
dateLocale.localeCatalog,
timeFormat,
timeZone,
numberFormat,
formatNumber,
]);
return {
@@ -1,3 +1,4 @@
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { useAggregateRecords } from '@/object-record/hooks/useAggregateRecords';
import { transformAggregateRawValueIntoAggregateDisplayValue } from '@/object-record/record-aggregate/utils/transformAggregateRawValueIntoAggregateDisplayValue';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
@@ -89,6 +90,7 @@ export const useGraphWidgetAggregateQuery = ({
const { dateFormat, timeFormat, timeZone } = useContext(UserContext);
const dateLocale = useAtomStateValue(dateLocaleState);
const { numberFormat, formatNumber } = useNumberFormat();
if (isRatioQuery) {
const isRatioLoading = ratioNumeratorLoading || ratioDenominatorLoading;
@@ -130,11 +132,15 @@ export const useGraphWidgetAggregateQuery = ({
);
if (!isDefined(aggregateFieldMetadataItem)) {
const totalCountValue =
data?.[FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION]?.[
AggregateOperations.COUNT
];
return {
value:
data?.[FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION]?.[
AggregateOperations.COUNT
],
value: isDefined(totalCountValue)
? formatNumber(Number(totalCountValue))
: totalCountValue,
label: getAggregateOperationLabel(AggregateOperations.COUNT),
loading,
error,
@@ -158,7 +164,8 @@ export const useGraphWidgetAggregateQuery = ({
localeCatalog: dateLocale.localeCatalog,
timeFormat,
timeZone,
numberFormat: configuration.numberFormat ?? undefined,
numberFormat,
chartNumberFormat: configuration.numberFormat ?? undefined,
});
return {
@@ -1,5 +1,6 @@
import { SidePanelPageInfoLayout } from '@/side-panel/components/SidePanelPageInfoLayout';
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { IconPencil } from 'twenty-ui/icon';
@@ -13,6 +14,7 @@ export const SidePanelMultipleRecordsInfo = ({
sidePanelPageInstanceId,
}: SidePanelMultipleRecordsInfoProps) => {
const { theme } = useContext(ThemeContext);
const { formatNumber } = useNumberFormat();
const { totalCount } = useFindManyRecordsSelectedInContextStore({
instanceId: sidePanelPageInstanceId,
limit: 1,
@@ -25,7 +27,7 @@ export const SidePanelMultipleRecordsInfo = ({
}
iconColor={theme.font.color.tertiary}
title={t`Update records`}
label={t`${totalCount} selected`}
label={t`${formatNumber(totalCount ?? 0)} selected`}
/>
);
};
@@ -2,6 +2,7 @@ import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { StyledDropdownButtonContainer } from '@/ui/layout/dropdown/components/StyledDropdownButtonContainer';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useGetRecordIndexTotalCount } from '@/views/hooks/internal/useGetRecordIndexTotalCount';
@@ -58,6 +59,8 @@ export const ViewPickerDropdown = () => {
const { totalCount } = useGetRecordIndexTotalCount();
const { formatNumber } = useNumberFormat();
const isDropdownOpen = useAtomComponentStateValue(
isDropdownOpenComponentState,
VIEW_PICKER_DROPDOWN_ID,
@@ -94,7 +97,7 @@ export const ViewPickerDropdown = () => {
<OverflowingTextWithTooltip text={currentView?.name ?? t`All`} />
</StyledViewName>
<StyledDropdownLabelAdornments>
{isDefined(totalCount) && <>· {totalCount} </>}
{isDefined(totalCount) && <>· {formatNumber(totalCount)} </>}
<IconChevronDown size={theme.icon.size.sm} />
</StyledDropdownLabelAdornments>
</StyledDropdownButtonContainer>