fixing Numbers formatting (#14403)

fix #13880

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Ketan Mehta
2025-09-18 02:33:33 +05:30
committed by GitHub
parent 666b99524a
commit dc26e48f6f
36 changed files with 583 additions and 96 deletions
File diff suppressed because one or more lines are too long
@@ -3899,6 +3899,7 @@ export type WorkspaceMember = {
id: Scalars['UUID'];
locale?: Maybe<Scalars['String']>;
name: FullName;
numberFormat?: Maybe<WorkspaceMemberNumberFormatEnum>;
roles?: Maybe<Array<Role>>;
timeFormat?: Maybe<WorkspaceMemberTimeFormatEnum>;
timeZone?: Maybe<Scalars['String']>;
@@ -3914,6 +3915,15 @@ export enum WorkspaceMemberDateFormatEnum {
YEAR_FIRST = 'YEAR_FIRST'
}
/** Number format for displaying numbers */
export enum WorkspaceMemberNumberFormatEnum {
APOSTROPHE_AND_DOT = 'APOSTROPHE_AND_DOT',
COMMAS_AND_DOT = 'COMMAS_AND_DOT',
DOTS_AND_COMMA = 'DOTS_AND_COMMA',
SPACES_AND_COMMA = 'SPACES_AND_COMMA',
SYSTEM = 'SYSTEM'
}
/** Time time as Military, Standard or system as default */
export enum WorkspaceMemberTimeFormatEnum {
HOUR_12 = 'HOUR_12',
@@ -9,8 +9,8 @@ import { ProgressBar } from 'twenty-ui/feedback';
import { Section } from 'twenty-ui/layout';
import { BACKGROUND_LIGHT, COLOR } from 'twenty-ui/theme';
import { SubscriptionStatus } from '~/generated/graphql';
import { formatAmount } from '~/utils/format/formatAmount';
import { formatNumber } from '~/utils/format/number';
import { formatToShortNumber } from '~/utils/format/formatToShortNumber';
import { formatNumber } from '~/utils/format/formatNumber';
// import { useListAvailableMeteredBillingPricesQuery } from '~/generated-metadata/graphql';
// import { MeteredPriceSelector } from '@/billing/components/internal/MeteredPriceSelector';
import { type CurrentWorkspace } from '@/auth/states/currentWorkspaceState';
@@ -54,7 +54,7 @@ export const SettingsBillingCreditsSection = ({
<SubscriptionInfoContainer>
<SettingsBillingLabelValueItem
label={t`Credits Used`}
value={`${formatNumber(usedCredits)}/${formatAmount(grantedCredits)}`}
value={`${formatNumber(usedCredits)}/${formatToShortNumber(grantedCredits)}`}
/>
<ProgressBar
value={progressBarValue}
@@ -14,7 +14,7 @@ import {
SubscriptionInterval,
} from '~/generated/graphql';
import { findOrThrow } from '~/utils/array/findOrThrow';
import { formatNumber } from '~/utils/format/number';
import { formatNumber } from '~/utils/format/formatNumber';
const compareByAmountAsc = (a: BillingPriceOutput, b: BillingPriceOutput) =>
a.amount - b.amount;
@@ -0,0 +1,7 @@
export enum NumberFormat {
SYSTEM = 'SYSTEM',
COMMAS_AND_DOT = 'COMMAS_AND_DOT',
SPACES_AND_COMMA = 'SPACES_AND_COMMA',
DOTS_AND_COMMA = 'DOTS_AND_COMMA',
APOSTROPHE_AND_DOT = 'APOSTROPHE_AND_DOT',
}
@@ -0,0 +1,73 @@
import { detectNumberFormat } from '../detectNumberFormat';
// Mock navigator.language
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'en-US',
});
describe('detectNumberFormat', () => {
beforeEach(() => {
// Reset to default
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'en-US',
});
});
it('should detect COMMAS_AND_DOT format for en-US locale', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'en-US',
});
expect(detectNumberFormat()).toBe('COMMAS_AND_DOT');
});
it('should detect SPACES_AND_COMMA format for fr-FR locale', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'fr-FR',
});
expect(detectNumberFormat()).toBe('SPACES_AND_COMMA');
});
it('should detect DOTS_AND_COMMA format for de-DE locale', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'de-DE',
});
expect(detectNumberFormat()).toBe('DOTS_AND_COMMA');
});
it('should detect APOSTROPHE_AND_DOT format for de-CH locale', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'de-CH',
});
expect(detectNumberFormat()).toBe('APOSTROPHE_AND_DOT');
});
it('should fallback to COMMAS_AND_DOT for unknown patterns', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'ja-JP', // Uses different separators not in our patterns
});
expect(detectNumberFormat()).toBe('COMMAS_AND_DOT');
});
it('should handle invalid locale gracefully', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: 'invalid-locale',
});
expect(detectNumberFormat()).toBe('COMMAS_AND_DOT');
});
it('should handle missing navigator.language', () => {
Object.defineProperty(navigator, 'language', {
writable: true,
value: undefined,
});
expect(detectNumberFormat()).toBe('COMMAS_AND_DOT');
});
});
@@ -0,0 +1,41 @@
import { NumberFormat } from '@/localization/constants/NumberFormat';
const SPACE_CHARS = new Set([' ', '\u00A0', '\u202F']); // space, non-breaking space, narrow no-break space
const APOSTROPHE_CHARS = new Set(["'", '\u2019']); // apostrophe, right single quotation mark
const FORMAT_PATTERNS = new Map<string, keyof typeof NumberFormat>([
[',|.', NumberFormat.COMMAS_AND_DOT],
['.|,', NumberFormat.DOTS_AND_COMMA],
['space|,', NumberFormat.SPACES_AND_COMMA],
['apostrophe|.', NumberFormat.APOSTROPHE_AND_DOT],
]);
export const detectNumberFormat = (): keyof typeof NumberFormat => {
const testNumber = 1234567.89;
let language = navigator?.language || 'en-US';
let formatter: Intl.NumberFormat;
try {
formatter = new Intl.NumberFormat(language);
} catch {
formatter = new Intl.NumberFormat('en-US');
}
const parts = formatter.formatToParts(testNumber);
const thousandSeparator =
parts.find((part) => part.type === 'group')?.value || '';
const decimalSeparator =
parts.find((part) => part.type === 'decimal')?.value || '';
let thousandCategory: string;
if (SPACE_CHARS.has(thousandSeparator)) {
thousandCategory = 'space';
} else if (APOSTROPHE_CHARS.has(thousandSeparator)) {
thousandCategory = 'apostrophe';
} else {
thousandCategory = thousandSeparator;
}
const pattern = `${thousandCategory}|${decimalSeparator}`;
return FORMAT_PATTERNS.get(pattern) || NumberFormat.COMMAS_AND_DOT;
};
@@ -0,0 +1,22 @@
import { NumberFormat } from '@/localization/constants/NumberFormat';
import { detectNumberFormat } from '@/localization/utils/detectNumberFormat';
import { WorkspaceMemberNumberFormatEnum } from '~/generated/graphql';
export const getNumberFormatFromWorkspaceNumberFormat = (
numberFormat: WorkspaceMemberNumberFormatEnum,
): NumberFormat => {
switch (numberFormat) {
case WorkspaceMemberNumberFormatEnum.SYSTEM:
return NumberFormat[detectNumberFormat()];
case WorkspaceMemberNumberFormatEnum.COMMAS_AND_DOT:
return NumberFormat.COMMAS_AND_DOT;
case WorkspaceMemberNumberFormatEnum.SPACES_AND_COMMA:
return NumberFormat.SPACES_AND_COMMA;
case WorkspaceMemberNumberFormatEnum.DOTS_AND_COMMA:
return NumberFormat.DOTS_AND_COMMA;
case WorkspaceMemberNumberFormatEnum.APOSTROPHE_AND_DOT:
return NumberFormat.APOSTROPHE_AND_DOT;
default:
return NumberFormat.COMMAS_AND_DOT;
}
};
@@ -0,0 +1,21 @@
import { NumberFormat } from '@/localization/constants/NumberFormat';
import { WorkspaceMemberNumberFormatEnum } from '~/generated/graphql';
export const getWorkspaceNumberFormatFromNumberFormat = (
numberFormat: NumberFormat,
): WorkspaceMemberNumberFormatEnum => {
switch (numberFormat) {
case NumberFormat.SYSTEM:
return WorkspaceMemberNumberFormatEnum.SYSTEM;
case NumberFormat.COMMAS_AND_DOT:
return WorkspaceMemberNumberFormatEnum.COMMAS_AND_DOT;
case NumberFormat.SPACES_AND_COMMA:
return WorkspaceMemberNumberFormatEnum.SPACES_AND_COMMA;
case NumberFormat.DOTS_AND_COMMA:
return WorkspaceMemberNumberFormatEnum.DOTS_AND_COMMA;
case NumberFormat.APOSTROPHE_AND_DOT:
return WorkspaceMemberNumberFormatEnum.APOSTROPHE_AND_DOT;
default:
return WorkspaceMemberNumberFormatEnum.COMMAS_AND_DOT;
}
};
@@ -9,7 +9,7 @@ import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ApolloError } from '@apollo/client';
import { t } from '@lingui/core/macro';
import { formatNumber } from '~/utils/format/number';
import { formatNumber } from '~/utils/format/formatNumber';
export const useBatchCreateManyRecords = <
CreatedObjectRecord extends ObjectRecord = ObjectRecord,
@@ -13,8 +13,8 @@ import isEmpty from 'lodash.isempty';
import { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { formatAmount } from '~/utils/format/formatAmount';
import { formatNumber } from '~/utils/format/number';
import { formatToShortNumber } from '~/utils/format/formatToShortNumber';
import { formatNumber } from '~/utils/format/formatNumber';
import { formatDateString } from '~/utils/string/formatDateString';
import { formatDateTimeString } from '~/utils/string/formatDateTimeString';
@@ -83,7 +83,7 @@ export const computeAggregateValueAndLabel = ({
switch (field.type) {
case FieldMetadataType.CURRENCY: {
value = Number(aggregateValue);
value = formatAmount(value / 1_000_000);
value = formatToShortNumber(value / 1_000_000);
break;
}
@@ -1,22 +1,38 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { getNumberFormatFromWorkspaceNumberFormat } from '@/localization/utils/getNumberFormatFromWorkspaceNumberFormat';
import { useNumberFieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/useNumberFieldDisplay';
import { NumberDisplay } from '@/ui/field/display/components/NumberDisplay';
import { useRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { formatAmount } from '~/utils/format/formatAmount';
import { formatNumber } from '~/utils/format/number';
import { formatToShortNumber } from '~/utils/format/formatToShortNumber';
import { formatNumber } from '~/utils/format/formatNumber';
export const NumberFieldDisplay = () => {
const { fieldValue, fieldDefinition } = useNumberFieldDisplay();
const decimals = fieldDefinition.metadata.settings?.decimals;
const [currentWorkspaceMember] = useRecoilState(currentWorkspaceMemberState);
const type = fieldDefinition.metadata.settings?.type;
const decimals = fieldDefinition.metadata.settings?.decimals;
if (!isDefined(fieldValue))
return <NumberDisplay value={null} decimals={decimals} />;
const value =
type === 'percentage'
? `${formatNumber(Number(fieldValue) * 100, decimals)}%`
: type === 'shortNumber'
? formatAmount(Number(fieldValue))
: formatNumber(Number(fieldValue), decimals);
if (!isDefined(fieldValue)) {
return <NumberDisplay value={null} />;
}
return <NumberDisplay value={value} decimals={decimals} />;
const numericValue = Number(fieldValue);
let formattedValue: string;
if (type === 'percentage') {
formattedValue = `${formatNumber(numericValue * 100, getNumberFormatFromWorkspaceNumberFormat(currentWorkspaceMember?.numberFormat!), decimals)}%`;
} else if (type === 'shortNumber') {
formattedValue = formatToShortNumber(numericValue);
} else {
formattedValue = formatNumber(
numericValue,
getNumberFormatFromWorkspaceNumberFormat(
currentWorkspaceMember?.numberFormat!,
),
decimals,
);
}
return <NumberDisplay value={formattedValue} />;
};
@@ -1,6 +1,6 @@
import { isDefined } from 'twenty-shared/utils';
import { formatAmount } from '~/utils/format/formatAmount';
import { formatNumber } from '~/utils/format/number';
import { formatNumber } from '~/utils/format/formatNumber';
import { formatToShortNumber } from '~/utils/format/formatToShortNumber';
export type GraphValueFormatOptions = {
displayType?: 'percentage' | 'number' | 'shortNumber' | 'currency' | 'custom';
@@ -31,7 +31,7 @@ export const formatGraphValue = (
return `${formatNumber(value * 100, decimals)}%`;
case 'shortNumber':
return `${prefix}${formatAmount(value)}${suffix}`;
return `${prefix}${formatToShortNumber(value)}${suffix}`;
case 'currency': {
const currencyPrefix = prefix || '$';
@@ -10,7 +10,7 @@ import { NUMBER_DATA_MODEL_SELECT_OPTIONS } from '@/settings/data-model/fields/f
import { Select } from '@/ui/input/components/Select';
import { useLingui } from '@lingui/react/macro';
import { IconDecimal, IconEye } from 'twenty-ui/display';
import { DEFAULT_DECIMAL_VALUE } from '~/utils/format/number';
import { DEFAULT_DECIMAL_VALUE } from '~/utils/format/formatNumber';
export const settingsDataModelFieldNumberFormSchema = z.object({
settings: numberFieldDefaultValueSchema,
@@ -4,7 +4,7 @@ import { DateFormat } from '@/localization/constants/DateFormat';
import { detectDateFormat } from '@/localization/utils/detectDateFormat';
import { detectTimeZone } from '@/localization/utils/detectTimeZone';
import { Select } from '@/ui/input/components/Select';
import { t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
type DateTimeSettingsDateFormatSelectProps = {
value: DateFormat;
@@ -17,6 +17,8 @@ export const DateTimeSettingsDateFormatSelect = ({
timeZone,
value,
}: DateTimeSettingsDateFormatSelectProps) => {
const { t } = useLingui();
const systemTimeZone = detectTimeZone();
const usedTimeZone = timeZone === 'system' ? systemTimeZone : timeZone;
@@ -56,11 +56,11 @@ export const DateTimeSettingsTimeFormatSelect = ({
value: TimeFormat.SYSTEM,
},
{
label: t`24h (${hour24Label})`,
label: t`24h - ${hour24Label}`,
value: TimeFormat.HOUR_24,
},
{
label: t`12h (${hour12Label})`,
label: t`12h - ${hour12Label}`,
value: TimeFormat.HOUR_12,
},
]}
@@ -0,0 +1,75 @@
import { NumberFormat } from '@/localization/constants/NumberFormat';
import { detectNumberFormat } from '@/localization/utils/detectNumberFormat';
import { Select } from '@/ui/input/components/Select';
import { useLingui } from '@lingui/react/macro';
import { formatNumber } from '~/utils/format/formatNumber';
type NumberFormatSelectProps = {
value: NumberFormat;
onChange: (nextValue: NumberFormat) => void;
};
export const NumberFormatSelect = ({
onChange,
value,
}: NumberFormatSelectProps) => {
const { t } = useLingui();
const systemNumberFormat = NumberFormat[detectNumberFormat()];
const systemNumberFormatLabel = formatNumber(1234.56, systemNumberFormat, 2);
const commasAndDotExample = formatNumber(
1234.56,
NumberFormat.COMMAS_AND_DOT,
2,
);
const spacesAndCommaExample = formatNumber(
1234.56,
NumberFormat.SPACES_AND_COMMA,
2,
);
const dotsAndCommaExample = formatNumber(
1234.56,
NumberFormat.DOTS_AND_COMMA,
2,
);
const apostropheAndDotExample = formatNumber(
1234.56,
NumberFormat.APOSTROPHE_AND_DOT,
2,
);
return (
<Select
dropdownId="number-format-select"
dropdownWidth={218}
label={t`Number format`}
dropdownWidthAuto
fullWidth
value={value}
options={[
{
label: t`System Settings - ${systemNumberFormatLabel}`,
value: NumberFormat.SYSTEM,
},
{
label: t`Commas and dot - ${commasAndDotExample}`,
value: NumberFormat.COMMAS_AND_DOT,
},
{
label: t`Spaces and comma - ${spacesAndCommaExample}`,
value: NumberFormat.SPACES_AND_COMMA,
},
{
label: t`Dots and comma - ${dotsAndCommaExample}`,
value: NumberFormat.DOTS_AND_COMMA,
},
{
label: t`Apostrophe and dot - ${apostropheAndDotExample}`,
value: NumberFormat.APOSTROPHE_AND_DOT,
},
]}
onChange={onChange}
/>
);
};
@@ -7,7 +7,7 @@ import { Modal } from '@/ui/layout/modal/components/Modal';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { Loader } from 'twenty-ui/feedback';
import { formatNumber } from '~/utils/format/number';
import { formatNumber } from '~/utils/format/formatNumber';
const StyledContent = styled(Modal.Content)`
align-items: center;
@@ -10,7 +10,7 @@ import { readFileAsync } from '@/spreadsheet-import/utils/readFilesAsync';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Trans, useLingui } from '@lingui/react/macro';
import { MainButton } from 'twenty-ui/input';
import { formatNumber } from '~/utils/format/number';
import { formatNumber } from '~/utils/format/formatNumber';
const StyledContainer = styled.div`
align-items: center;
@@ -8,8 +8,8 @@ import {
import { SETTINGS_FIELD_CURRENCY_CODES } from '@/settings/data-model/constants/SettingsFieldCurrencyCodes';
import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay';
import { isDefined } from 'twenty-shared/utils';
import { formatAmount } from '~/utils/format/formatAmount';
import { formatNumber } from '~/utils/format/number';
import { formatToShortNumber } from '~/utils/format/formatToShortNumber';
import { formatNumber } from '~/utils/format/formatNumber';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
type CurrencyDisplayProps = {
@@ -52,7 +52,7 @@ export const CurrencyDisplay = ({
)}
{amountToDisplay !== null
? !isDefined(format) || format === 'short'
? formatAmount(amountToDisplay)
? formatToShortNumber(amountToDisplay)
: formatNumber(amountToDisplay)
: null}
</EllipsisDisplay>
@@ -1,4 +1,4 @@
import { formatNumber } from '~/utils/format/number';
import { formatNumber } from '~/utils/format/formatNumber';
import { EllipsisDisplay } from './EllipsisDisplay';
@@ -15,5 +15,6 @@ export const WORKSPACE_MEMBER_QUERY_FRAGMENT = gql`
dateFormat
timeFormat
calendarStartDay
numberFormat
}
`;
@@ -1,5 +1,6 @@
import {
type WorkspaceMemberDateFormatEnum,
type WorkspaceMemberNumberFormatEnum,
type WorkspaceMemberTimeFormatEnum,
} from '~/generated/graphql';
@@ -23,6 +24,7 @@ export type WorkspaceMember = {
timeZone?: string | null;
dateFormat?: WorkspaceMemberDateFormatEnum | null;
timeFormat?: WorkspaceMemberTimeFormatEnum | null;
numberFormat?: WorkspaceMemberNumberFormatEnum | null;
calendarStartDay?: number | null;
};
@@ -0,0 +1,62 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { NumberFormat } from '@/localization/constants/NumberFormat';
import { getNumberFormatFromWorkspaceNumberFormat } from '@/localization/utils/getNumberFormatFromWorkspaceNumberFormat';
import { getWorkspaceNumberFormatFromNumberFormat } from '@/localization/utils/getWorkspaceNumberFormatFromNumberFormat';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { NumberFormatSelect } from '@/settings/experience/components/NumberFormatSelect';
import { useRecoilState } from 'recoil';
import { WorkspaceMemberNumberFormatEnum } from '~/generated/graphql';
import { logError } from '~/utils/logError';
export const NumberFormatSettings = () => {
const { updateOneRecord } = useUpdateOneRecord({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
});
const [currentWorkspaceMember, setCurrentWorkspaceMember] = useRecoilState(
currentWorkspaceMemberState,
);
const handleNumberFormatChange = async (value: NumberFormat) => {
if (!currentWorkspaceMember?.id) {
logError('User is not logged in');
return;
}
const workspaceNumberFormat =
getWorkspaceNumberFormatFromNumberFormat(value);
try {
await updateOneRecord({
idToUpdate: currentWorkspaceMember.id,
updateOneRecordInput: {
numberFormat: workspaceNumberFormat,
},
});
setCurrentWorkspaceMember({
...currentWorkspaceMember,
numberFormat: workspaceNumberFormat,
});
} catch (error) {
logError(error);
}
};
const numberFormat = currentWorkspaceMember?.numberFormat
? currentWorkspaceMember.numberFormat ===
WorkspaceMemberNumberFormatEnum.SYSTEM
? NumberFormat.SYSTEM
: getNumberFormatFromWorkspaceNumberFormat(
currentWorkspaceMember.numberFormat,
)
: NumberFormat.SYSTEM;
return (
<NumberFormatSelect
value={numberFormat}
onChange={handleNumberFormatChange}
/>
);
};
@@ -2,7 +2,6 @@ import { SettingsPageContainer } from '@/settings/components/SettingsPageContain
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useColorScheme } from '@/ui/theme/hooks/useColorScheme';
import { SettingsPath } from 'twenty-shared/types';
import { Trans, useLingui } from '@lingui/react/macro';
import { getSettingsPath } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
@@ -10,10 +9,10 @@ import { ColorSchemePicker } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { DateTimeSettings } from '~/pages/settings/profile/appearance/components/DateTimeSettings';
import { LocalePicker } from '~/pages/settings/profile/appearance/components/LocalePicker';
import { NumberFormatSettings } from '~/pages/settings/profile/appearance/components/NumberFormatSettings';
export const SettingsExperience = () => {
const { colorScheme, setColorScheme } = useColorScheme();
const { t } = useLingui();
return (
@@ -54,6 +53,13 @@ export const SettingsExperience = () => {
/>
<DateTimeSettings />
</Section>
<Section>
<H2Title
title={t`Other formats`}
description={t`Choose additional formatting preferences`}
/>
<NumberFormatSettings />
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
@@ -1,28 +0,0 @@
import { formatAmount } from '../formatAmount';
describe('amountFormat', () => {
it('formats numbers less than 1000 correctly', () => {
expect(formatAmount(500)).toBe('500');
expect(formatAmount(123.456)).toBe('123.5');
});
it('formats numbers between 1000 and 999999 correctly', () => {
expect(formatAmount(1500)).toBe('1.5k');
expect(formatAmount(789456)).toBe('789.5k');
});
it('formats numbers between 1000000 and 999999999 correctly', () => {
expect(formatAmount(2000000)).toBe('2m');
expect(formatAmount(654987654)).toBe('655m');
});
it('formats numbers greater than or equal to 1000000000 correctly', () => {
expect(formatAmount(1200000000)).toBe('1.2b');
expect(formatAmount(987654321987)).toBe('987.7b');
});
it('handles numbers with decimal places correctly', () => {
expect(formatAmount(123.456)).toBe('123.5');
expect(formatAmount(789.0123)).toBe('789');
});
});
@@ -0,0 +1,45 @@
import { NumberFormat } from '@/localization/constants/NumberFormat';
import { formatNumber } from '../formatNumber';
describe('formatNumber', () => {
describe('without format (backward compatibility)', () => {
it('should format 123 correctly', () => {
expect(formatNumber(123)).toEqual('123');
});
it('should format decimal numbers correctly', () => {
expect(formatNumber(123.92, 2)).toEqual('123.92');
});
it('should format large numbers correctly', () => {
expect(formatNumber(1234567)).toEqual('1,234,567');
});
it('should format large numbers with a decimal point correctly', () => {
expect(formatNumber(7654321.89, 2)).toEqual('7,654,321.89');
});
it('should format apply decimals correctly', () => {
expect(formatNumber(123.456, 2)).toEqual('123.46');
});
});
describe('with localized formats', () => {
it('should format with COMMAS_AND_DOT', () => {
expect(formatNumber(1234.56, NumberFormat.COMMAS_AND_DOT, 2)).toEqual(
'1,234.56',
);
});
it('should format with SPACES_AND_COMMA', () => {
expect(formatNumber(1234.56, NumberFormat.SPACES_AND_COMMA, 2)).toEqual(
'1\u202F234,56', // Uses narrow no-break space (U+202F)
);
});
it('should format with DOTS_AND_COMMA', () => {
expect(formatNumber(1234.56, NumberFormat.DOTS_AND_COMMA, 2)).toEqual(
'1.234,56',
);
});
it('should format with APOSTROPHE_AND_DOT', () => {
expect(formatNumber(1234.56, NumberFormat.APOSTROPHE_AND_DOT, 2)).toEqual(
'1\u2019234.56', // Uses right single quotation mark (U+2019)
);
});
});
});
@@ -0,0 +1,28 @@
import { formatToShortNumber } from '../formatToShortNumber';
describe('formatToShortNumber', () => {
it('formats numbers less than 1000 correctly', () => {
expect(formatToShortNumber(500)).toBe('500');
expect(formatToShortNumber(123.456)).toBe('123.5');
});
it('formats numbers between 1000 and 999999 correctly', () => {
expect(formatToShortNumber(1500)).toBe('1.5k');
expect(formatToShortNumber(789456)).toBe('789.5k');
});
it('formats numbers between 1000000 and 999999999 correctly', () => {
expect(formatToShortNumber(2000000)).toBe('2m');
expect(formatToShortNumber(654987654)).toBe('655m');
});
it('formats numbers greater than or equal to 1000000000 correctly', () => {
expect(formatToShortNumber(1200000000)).toBe('1.2b');
expect(formatToShortNumber(987654321987)).toBe('987.7b');
});
it('handles numbers with decimal places correctly', () => {
expect(formatToShortNumber(123.456)).toBe('123.5');
expect(formatToShortNumber(789.0123)).toBe('789');
});
});
@@ -1,20 +0,0 @@
import { formatNumber } from '../number';
// This tests the en-US locale by default
describe('formatNumber', () => {
it(`Should format 123 correctly`, () => {
expect(formatNumber(123)).toEqual('123');
});
it(`Should format decimal numbers correctly`, () => {
expect(formatNumber(123.92, 2)).toEqual('123.92');
});
it(`Should format large numbers correctly`, () => {
expect(formatNumber(1234567)).toEqual('1,234,567');
});
it(`Should format large numbers with a decimal point correctly`, () => {
expect(formatNumber(7654321.89, 2)).toEqual('7,654,321.89');
});
it('should format apply decimals correctly', () => {
expect(formatNumber(123.456, 2)).toEqual('123.46');
});
});
@@ -0,0 +1,57 @@
import { NumberFormat } from '@/localization/constants/NumberFormat';
import { detectNumberFormat } from '@/localization/utils/detectNumberFormat';
const DEFAULT_DECIMALS = 0;
export const DEFAULT_DECIMAL_VALUE = DEFAULT_DECIMALS;
const DEFAULT_LOCALE = 'en-US';
const FORMAT_LOCALE_MAP = {
[NumberFormat.COMMAS_AND_DOT]: 'en-US',
[NumberFormat.SPACES_AND_COMMA]: 'fr-FR',
[NumberFormat.DOTS_AND_COMMA]: 'de-DE',
[NumberFormat.APOSTROPHE_AND_DOT]: 'de-CH',
} as const;
const getLocaleForFormat = (format?: NumberFormat): string => {
if (!format) {
return DEFAULT_LOCALE;
}
if (format === NumberFormat.SYSTEM) {
const detectedFormat = NumberFormat[detectNumberFormat()];
return (
FORMAT_LOCALE_MAP[detectedFormat as keyof typeof FORMAT_LOCALE_MAP] ??
DEFAULT_LOCALE
);
}
return (
FORMAT_LOCALE_MAP[format as keyof typeof FORMAT_LOCALE_MAP] ??
DEFAULT_LOCALE
);
};
export const formatNumber = (
value: number,
decimalsOrFormat?: number | NumberFormat,
decimals?: number,
): string => {
// Parse parameters
const isLegacyCall = typeof decimalsOrFormat === 'number';
const actualDecimals = isLegacyCall
? decimalsOrFormat
: (decimals ?? DEFAULT_DECIMALS);
const format = isLegacyCall ? undefined : decimalsOrFormat;
// Create formatting options
const options: Intl.NumberFormatOptions = {
minimumFractionDigits: actualDecimals,
maximumFractionDigits: actualDecimals,
};
// Determine locale
const locale = getLocaleForFormat(format);
return new Intl.NumberFormat(locale, options).format(value);
};
@@ -1,4 +1,4 @@
export const formatAmount = (amount: number) => {
export const formatToShortNumber = (amount: number) => {
if (amount < 1000) {
return amount.toFixed(1).replace(/\.?0+$/, '');
} else if (amount < 1000000) {
@@ -1,8 +0,0 @@
export const DEFAULT_DECIMAL_VALUE = 0;
export const formatNumber = (value: number, decimals?: number): string => {
return value.toLocaleString('en-US', {
minimumFractionDigits: decimals ?? DEFAULT_DECIMAL_VALUE,
maximumFractionDigits: decimals ?? DEFAULT_DECIMAL_VALUE,
});
};
@@ -7,6 +7,7 @@ import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import {
WorkspaceMemberDateFormatEnum,
WorkspaceMemberNumberFormatEnum,
WorkspaceMemberTimeFormatEnum,
} from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@@ -58,4 +59,7 @@ export class WorkspaceMember {
@Field(() => UUIDScalarType, { nullable: true })
userWorkspaceId?: string;
@Field(() => WorkspaceMemberNumberFormatEnum, { nullable: true })
numberFormat?: WorkspaceMemberNumberFormatEnum;
}
@@ -10,6 +10,7 @@ import { type WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspac
import { type RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { fromRoleEntitiesToRoleDtos } from 'src/engine/metadata-modules/role/utils/fromRoleEntityToRoleDto.util';
import {
type WorkspaceMemberNumberFormatEnum,
type WorkspaceMemberDateFormatEnum,
type WorkspaceMemberTimeFormatEnum,
type WorkspaceMemberWorkspaceEntity,
@@ -61,6 +62,7 @@ export class WorkspaceMemberTranspiler {
timeZone,
dateFormat,
calendarStartDay,
numberFormat,
} = workspaceMemberEntity;
const avatarUrl = this.generateSignedAvatarUrl({
@@ -86,6 +88,7 @@ export class WorkspaceMemberTranspiler {
timeZone,
roles,
calendarStartDay,
numberFormat: numberFormat as WorkspaceMemberNumberFormatEnum,
} satisfies WorkspaceMember;
}
@@ -526,6 +526,7 @@ export const WORKSPACE_MEMBER_STANDARD_FIELD_IDS = {
timeFormat: '20202020-8acb-4cf8-a851-a6ed443c8d81',
searchVector: '20202020-46d0-4e7f-bc26-74c0edaeb619',
calendarStartDay: '20202020-92d0-1d7f-a126-25ededa6b142',
numberFormat: '20202020-7f40-4e7f-b126-11c0eda6b141',
} as const;
export const CUSTOM_OBJECT_STANDARD_FIELD_IDS = {
@@ -51,6 +51,19 @@ export enum WorkspaceMemberTimeFormatEnum {
HOUR_24 = 'HOUR_24',
}
export enum WorkspaceMemberNumberFormatEnum {
SYSTEM = 'SYSTEM',
COMMAS_AND_DOT = 'COMMAS_AND_DOT',
SPACES_AND_COMMA = 'SPACES_AND_COMMA',
DOTS_AND_COMMA = 'DOTS_AND_COMMA',
APOSTROPHE_AND_DOT = 'APOSTROPHE_AND_DOT',
}
registerEnumType(WorkspaceMemberNumberFormatEnum, {
name: 'WorkspaceMemberNumberFormatEnum',
description: 'Number format for displaying numbers',
});
registerEnumType(WorkspaceMemberTimeFormatEnum, {
name: 'WorkspaceMemberTimeFormatEnum',
description: 'Time time as Military, Standard or system as default',
@@ -376,4 +389,47 @@ export class WorkspaceMemberWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsSystem()
@WorkspaceFieldIndex({ indexType: IndexType.GIN })
searchVector: string;
@WorkspaceField({
standardId: WORKSPACE_MEMBER_STANDARD_FIELD_IDS.numberFormat,
type: FieldMetadataType.SELECT,
label: msg`Number format`,
description: msg`User's preferred number format`,
icon: 'IconNumbers',
options: [
{
value: WorkspaceMemberNumberFormatEnum.SYSTEM,
label: 'System',
position: 0,
color: 'turquoise',
},
{
value: WorkspaceMemberNumberFormatEnum.COMMAS_AND_DOT,
label: 'Commas and dot (1,234.56)',
position: 1,
color: 'blue',
},
{
value: WorkspaceMemberNumberFormatEnum.SPACES_AND_COMMA,
label: 'Spaces and comma (1 234,56)',
position: 2,
color: 'green',
},
{
value: WorkspaceMemberNumberFormatEnum.DOTS_AND_COMMA,
label: 'Dots and comma (1.234,56)',
position: 3,
color: 'orange',
},
{
value: WorkspaceMemberNumberFormatEnum.APOSTROPHE_AND_DOT,
label: "Apostrophe and dot (1'234.56)",
position: 4,
color: 'purple',
},
],
defaultValue: `'${WorkspaceMemberNumberFormatEnum.SYSTEM}'`,
})
@WorkspaceIsSystem()
numberFormat: string;
}