feat(page-layout): add number format option to aggregate chart widget (#21521)

## Context
Closes #21522
Large values in the dashboard **Number** widget are always abbreviated
(e.g. `1300090` → `1.3m`) with no way to display the full number.
Following a discussion with the core team who were interested in this
feature
(https://discordapp.com/channels/1130383047699738754/1509604545381142649)
, this adds a **Format** option in the **Style** section of the Number
(aggregate chart) widget, letting users choose between **Short**
(abbreviated, current behavior) and **Full** (complete number with
thousand separators).

Only the displayed value of the Number widget is affected — axes, labels
and tooltips of other chart types are intentionally left untouched.

## What's inside

**Server**
- New `ChartNumberFormat` GraphQL enum (`SHORT` / `FULL`), following the
`AxisNameDisplay` pattern
- The existing — and previously unused — `format` field on
`AggregateChartConfigurationDTO` is now typed with this enum and
validated with `@IsEnum`
- The dashboard AI tool schema (`widget.schema.ts`) accepts the new
`format` option
- Regenerated GraphQL types and the `twenty-client-sdk` metadata client
to reflect the enum

**Front**
- New **Format** setting in the Style section of the Number widget
settings, with a Short/Full selection dropdown (same pattern as the Axis
name setting)
- `transformAggregateRawValueIntoAggregateDisplayValue` takes an
optional `numberFormat`:
- `FULL` → full number via `formatNumber` (currency values keep up to 2
decimals)
  - `SHORT` → abbreviated via `formatToShortNumber`
- not set → behavior unchanged (currency short, number full), so
existing widgets and the record table/board footers render exactly as
before

## Screenshots

| Full UI Look | 

<img width="1917" height="955" alt="Twenty_Showcas_FullShort"
src="https://github.com/user-attachments/assets/05d05779-395d-4e1a-8ff0-964f6fbef182"
/>

| Menu UI Look |
<img width="291" height="308" alt="Screenshot_2"
src="https://github.com/user-attachments/assets/82b5a1de-32fe-46ec-a9b8-add11ab4c6cd"
/>
 

## Tests

- Extended `transformAggregateRawValueIntoAggregateDisplayValue` unit
tests with SHORT/FULL cases for currency and number fields
- Updated the page-layout-widget creation/update integration tests and
snapshots to use `ChartNumberFormat.SHORT`


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21521?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. -->

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Alexandre Ribeiro
2026-06-13 17:32:41 -04:00
committed by GitHub
parent f869ce87b1
commit fefb6cdb94
27 changed files with 970 additions and 667 deletions
@@ -6,7 +6,10 @@ import { AggregateOperations } from '@/object-record/record-table/constants/Aggr
import { DateAggregateOperations } from '@/object-record/record-table/constants/DateAggregateOperations';
import { enUS } from 'date-fns/locale';
import { findByProperty } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import {
ChartNumberFormat,
FieldMetadataType,
} from '~/generated-metadata/graphql';
import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
describe('transformAggregateRawValueIntoAggregateDisplayValue', () => {
@@ -120,6 +123,76 @@ describe('transformAggregateRawValueIntoAggregateDisplayValue', () => {
).toBe('100,000,000');
});
it('should return full currency formatted value with FULL number format', () => {
const mockCurrencyFieldMetadataItem = {
...mockCompanyEmployeesFieldMetadataItem,
type: FieldMetadataType.CURRENCY,
} as FieldMetadataItem;
expect(
transformAggregateRawValueIntoAggregateDisplayValue({
aggregateFieldMetadataItem: mockCurrencyFieldMetadataItem,
aggregateOperation: AggregateOperations.SUM,
aggregateRawValue: 230440000000,
dateFormat: DateFormat.DAY_FIRST,
timeFormat: TimeFormat.HOUR_24,
localeCatalog: enUS,
timeZone: 'UTC',
numberFormat: ChartNumberFormat.FULL,
}),
).toBe('230,440');
});
it('should return short currency formatted value with SHORT number format', () => {
const mockCurrencyFieldMetadataItem = {
...mockCompanyEmployeesFieldMetadataItem,
type: FieldMetadataType.CURRENCY,
} as FieldMetadataItem;
expect(
transformAggregateRawValueIntoAggregateDisplayValue({
aggregateFieldMetadataItem: mockCurrencyFieldMetadataItem,
aggregateOperation: AggregateOperations.SUM,
aggregateRawValue: 230440000000,
dateFormat: DateFormat.DAY_FIRST,
timeFormat: TimeFormat.HOUR_24,
localeCatalog: enUS,
timeZone: 'UTC',
numberFormat: ChartNumberFormat.SHORT,
}),
).toBe('230.4k');
});
it('should return short number formatted value with SHORT number format', () => {
expect(
transformAggregateRawValueIntoAggregateDisplayValue({
aggregateFieldMetadataItem: mockCompanyEmployeesFieldMetadataItem,
aggregateOperation: AggregateOperations.SUM,
aggregateRawValue: 100000000,
dateFormat: DateFormat.DAY_FIRST,
timeFormat: TimeFormat.HOUR_24,
localeCatalog: enUS,
timeZone: 'UTC',
numberFormat: ChartNumberFormat.SHORT,
}),
).toBe('100m');
});
it('should return full number formatted value with FULL number format', () => {
expect(
transformAggregateRawValueIntoAggregateDisplayValue({
aggregateFieldMetadataItem: mockCompanyEmployeesFieldMetadataItem,
aggregateOperation: AggregateOperations.SUM,
aggregateRawValue: 100000000,
dateFormat: DateFormat.DAY_FIRST,
timeFormat: TimeFormat.HOUR_24,
localeCatalog: enUS,
timeZone: 'UTC',
numberFormat: ChartNumberFormat.FULL,
}),
).toBe('100,000,000');
});
it('should return correct DATE formatted value', () => {
const mockDateFieldMetadataItem = {
...mockCompanyEmployeesFieldMetadataItem,
@@ -7,7 +7,10 @@ import { type ExtendedAggregateOperations } from '@/object-record/record-table/t
import { FieldMetadataType, type Nullable } from 'twenty-shared/types';
import { formatToShortNumber, isDefined } from 'twenty-shared/utils';
import { type AggregateOperations } from '~/generated-metadata/graphql';
import {
type AggregateOperations,
ChartNumberFormat,
} from '~/generated-metadata/graphql';
import { formatNumber } from '~/utils/format/formatNumber';
import { formatDateString } from '~/utils/string/formatDateString';
import { formatDateTimeString } from '~/utils/string/formatDateTimeString';
@@ -20,6 +23,7 @@ export const transformAggregateRawValueIntoAggregateDisplayValue = ({
timeFormat,
timeZone,
localeCatalog,
numberFormat,
}: {
aggregateFieldMetadataItem: Nullable<FieldMetadataItem>;
aggregateOperation: ExtendedAggregateOperations;
@@ -28,6 +32,7 @@ export const transformAggregateRawValueIntoAggregateDisplayValue = ({
timeFormat: TimeFormat;
timeZone: string;
localeCatalog: Locale;
numberFormat?: ChartNumberFormat;
}): string => {
if (!isDefined(aggregateRawValue)) {
return '-';
@@ -48,14 +53,20 @@ export const transformAggregateRawValueIntoAggregateDisplayValue = ({
} else {
switch (aggregateFieldMetadataItem.type) {
case FieldMetadataType.CURRENCY: {
return formatToShortNumber(Number(aggregateRawValue) / 1_000_000);
const amount = Number(aggregateRawValue) / 1_000_000;
return numberFormat === ChartNumberFormat.FULL
? formatNumber(amount, { decimals: 2 })
: formatToShortNumber(amount);
}
case FieldMetadataType.NUMBER: {
const castedValue = Number(aggregateRawValue);
const { decimals, type } = aggregateFieldMetadataItem.settings ?? {};
return type === 'percentage'
? `${formatNumber(castedValue * 100, { decimals })}%`
if (type === 'percentage') {
return `${formatNumber(castedValue * 100, { decimals })}%`;
}
return numberFormat === ChartNumberFormat.SHORT
? formatToShortNumber(castedValue)
: formatNumber(castedValue, { decimals });
}
@@ -121,7 +121,7 @@ export const PAGE_LAYOUT_WIDGET_FRAGMENT = gql`
aggregateOperation
label
displayDataLabel
format
numberFormat
description
filter
prefix
@@ -158,6 +158,7 @@ export const useGraphWidgetAggregateQuery = ({
localeCatalog: dateLocale.localeCatalog,
timeFormat,
timeZone,
numberFormat: configuration.numberFormat ?? undefined,
});
return {
@@ -0,0 +1,89 @@
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
import { useUpdateCurrentWidgetConfig } from '@/side-panel/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode';
import { getChartNumberFormatLabel } from '@/side-panel/pages/page-layout/utils/getChartNumberFormatLabel';
import { isWidgetConfigurationOfType } from '@/side-panel/pages/page-layout/utils/isWidgetConfigurationOfType';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { MenuItemSelect } from 'twenty-ui-deprecated/navigation';
import { ChartNumberFormat } from '~/generated-metadata/graphql';
export const ChartNumberFormatSelectionDropdownContent = () => {
const { pageLayoutId } = usePageLayoutIdFromContextStore();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
const configuration = widgetInEditMode?.configuration;
if (
!isWidgetConfigurationOfType(configuration, 'AggregateChartConfiguration')
) {
throw new Error('Invalid configuration type');
}
const currentNumberFormat = configuration.numberFormat;
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
);
const selectedItemId = useAtomComponentStateValue(
selectedItemIdComponentState,
dropdownId,
);
const numberFormatOptions: ChartNumberFormat[] = [
ChartNumberFormat.SHORT,
ChartNumberFormat.FULL,
];
const { updateCurrentWidgetConfig } =
useUpdateCurrentWidgetConfig(pageLayoutId);
const { closeDropdown } = useCloseDropdown();
const handleSelectNumberFormatOption = (
numberFormatOption: ChartNumberFormat,
) => {
updateCurrentWidgetConfig({
configToUpdate: {
numberFormat: numberFormatOption,
},
});
closeDropdown();
};
return (
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={numberFormatOptions}
>
{numberFormatOptions.map((option) => (
<SelectableListItem
key={option}
itemId={option}
onEnter={() => {
handleSelectNumberFormatOption(option);
}}
>
<MenuItemSelect
text={getChartNumberFormatLabel(option)}
selected={currentNumberFormat === option}
focused={selectedItemId === option}
onClick={() => {
handleSelectNumberFormatOption(option);
}}
/>
</SelectableListItem>
))}
</SelectableList>
</DropdownMenuItemsContainer>
);
};
@@ -2,6 +2,7 @@ import { CHART_SETTINGS_HEADINGS } from '@/side-panel/pages/page-layout/constant
import { CHART_DATA_SOURCE_SETTING } from '@/side-panel/pages/page-layout/constants/settings/ChartDataSourceSetting';
import { DATA_DISPLAY_AGGREGATE_SETTING } from '@/side-panel/pages/page-layout/constants/settings/DataDisplayAggregateSetting';
import { FILTER_SETTING } from '@/side-panel/pages/page-layout/constants/settings/FilterSetting';
import { FORMAT_SETTING } from '@/side-panel/pages/page-layout/constants/settings/FormatSetting';
import { PREFIX_SETTING } from '@/side-panel/pages/page-layout/constants/settings/PrefixSetting';
import { SUFFIX_SETTING } from '@/side-panel/pages/page-layout/constants/settings/SuffixSetting';
import { type ChartSettingsGroup } from '@/side-panel/pages/page-layout/types/ChartSettingsGroup';
@@ -17,6 +18,6 @@ export const AGGREGATE_CHART_SETTINGS: ChartSettingsGroup[] = [
},
{
heading: CHART_SETTINGS_HEADINGS.STYLE,
items: [PREFIX_SETTING, SUFFIX_SETTING],
items: [FORMAT_SETTING, PREFIX_SETTING, SUFFIX_SETTING],
},
];
@@ -25,6 +25,7 @@ export const CHART_CONFIGURATION_SETTING_LABELS = {
DATE_GRANULARITY_Y: msg`Date granularity`,
DATE_GRANULARITY: msg`Date granularity`,
SHOW_LEGEND: msg`Legend`,
FORMAT: msg`Format`,
PREFIX: msg`Prefix`,
SUFFIX: msg`Suffix`,
CUMULATIVE: msg`Cumulative`,
@@ -0,0 +1,13 @@
import { ChartNumberFormatSelectionDropdownContent } from '@/side-panel/pages/page-layout/components/dropdown-content/ChartNumberFormatSelectionDropdownContent';
import { CHART_CONFIGURATION_SETTING_LABELS } from '@/side-panel/pages/page-layout/constants/settings/ChartConfigurationSettingLabels';
import { CHART_CONFIGURATION_SETTING_IDS } from '@/side-panel/pages/page-layout/types/ChartConfigurationSettingIds';
import { type ChartSettingsItem } from '@/side-panel/pages/page-layout/types/ChartSettingsGroup';
import { Icon123 } from 'twenty-ui-deprecated/display';
export const FORMAT_SETTING: ChartSettingsItem = {
isBoolean: false,
Icon: Icon123,
label: CHART_CONFIGURATION_SETTING_LABELS.FORMAT,
id: CHART_CONFIGURATION_SETTING_IDS.FORMAT,
DropdownContent: ChartNumberFormatSelectionDropdownContent,
};
@@ -5,6 +5,7 @@ import { CHART_CONFIGURATION_SETTING_IDS } from '@/side-panel/pages/page-layout/
import { findChartGroupByFieldMetadataItem } from '@/side-panel/pages/page-layout/utils/findChartGroupByFieldMetadataItem';
import { getChartAxisNameDisplayOptions } from '@/side-panel/pages/page-layout/utils/getChartAxisNameDisplayOptions';
import { getChartFilterRulesCount } from '@/side-panel/pages/page-layout/utils/getChartFilterRulesCount';
import { getChartNumberFormatLabel } from '@/side-panel/pages/page-layout/utils/getChartNumberFormatLabel';
import { getDateGranularityLabel } from '@/side-panel/pages/page-layout/utils/getDateGranularityLabel';
import { getFieldLabelWithSubField } from '@/side-panel/pages/page-layout/utils/getFieldLabelWithSubField';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
@@ -298,6 +299,11 @@ export const useChartSettingsValues = ({
return isBarOrLineChart || isPieChart
? (configuration.displayLegend ?? true)
: true;
case CHART_CONFIGURATION_SETTING_IDS.FORMAT:
return configuration.__typename === 'AggregateChartConfiguration' &&
isDefined(configuration.numberFormat)
? getChartNumberFormatLabel(configuration.numberFormat)
: undefined;
case CHART_CONFIGURATION_SETTING_IDS.PREFIX:
return configuration.__typename === 'AggregateChartConfiguration'
? (configuration.prefix ?? '')
@@ -24,6 +24,7 @@ export enum CHART_CONFIGURATION_SETTING_IDS {
DATE_GRANULARITY_Y = 'DATE_GRANULARITY_Y',
DATE_GRANULARITY = 'DATE_GRANULARITY',
SHOW_LEGEND = 'SHOW_LEGEND',
FORMAT = 'FORMAT',
PREFIX = 'PREFIX',
SUFFIX = 'SUFFIX',
CUMULATIVE = 'CUMULATIVE',
@@ -0,0 +1,16 @@
import { t } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { ChartNumberFormat } from '~/generated-metadata/graphql';
export const getChartNumberFormatLabel = (
chartNumberFormat: ChartNumberFormat,
) => {
switch (chartNumberFormat) {
case ChartNumberFormat.SHORT:
return t`Short`;
case ChartNumberFormat.FULL:
return t`Full`;
default:
assertUnreachable(chartNumberFormat);
}
};