Skip to content

Commit 3ca960e

Browse files
committed
feat(client-core): Derive d3 locale from Intl and support per-call locale option
Replace hardcoded en-US locale with Intl.NumberFormat-derived locale that resolves decimal/thousands separators and currency symbol position from the runtime. Add locale option to FormatValueOptions, cached per locale:currency pair. Extract FormatValueMember type.
1 parent d39197b commit 3ca960e

2 files changed

Lines changed: 50 additions & 23 deletions

File tree

packages/cubejs-client-core/src/format.ts

Lines changed: 41 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { format as d3Format, formatLocale, FormatLocaleDefinition } from 'd3-format';
1+
import {format as d3Format, formatLocale, FormatLocaleDefinition, FormatLocaleObject} from 'd3-format';
22
import { timeFormat } from 'd3-time-format';
33

44
import type { DimensionFormat, MeasureFormat, TCubeMemberType } from './types';
@@ -7,25 +7,39 @@ const DEFAULT_NUMBER_FORMAT = ',.2~f';
77
const DEFAULT_CURRENCY_FORMAT = '$,.2~f';
88
const DEFAULT_PERCENT_FORMAT = '.2~%';
99

10-
// d3-format en-US defaults — serves as the base for all locales
11-
const DEFAULT_LOCALE: FormatLocaleDefinition = {
12-
decimal: '.',
13-
thousands: ',',
14-
grouping: [3],
15-
currency: ['$', ''],
16-
};
10+
function getD3LocaleFromIntl(locale?: string, currencyCode = 'USD'): FormatLocaleDefinition {
11+
const nf = new Intl.NumberFormat(locale);
12+
const numParts = nf.formatToParts(1234567.89);
13+
const find = (type: string) => numParts.find((p) => p.type === type)?.value ?? '';
14+
15+
const cf = new Intl.NumberFormat(locale, { style: 'currency', currency: currencyCode });
16+
const currencyParts = cf.formatToParts(1);
17+
const currencySymbol = currencyParts.find((p) => p.type === 'currency')?.value ?? currencyCode;
18+
const firstMeaningfulType = currencyParts.find((p) => !['literal', 'nan'].includes(p.type))?.type;
19+
const symbolIsPrefix = firstMeaningfulType === 'currency';
20+
21+
return {
22+
decimal: find('decimal') || '.',
23+
thousands: find('group') || ',',
24+
grouping: [3],
25+
currency: symbolIsPrefix ? [currencySymbol, ''] : ['', currencySymbol],
26+
};
27+
}
1728

18-
function getCurrencySymbol(code: string): string {
19-
return new Intl.NumberFormat('en-US', { style: 'currency', currency: code })
20-
.formatToParts(0)
21-
.find((part) => part.type === 'currency')?.value || code;
29+
const localeCache: Record<string, FormatLocaleObject> = Object.create(null);
30+
31+
function getCurrentD3Locale(locale: string, currencyCode = 'USD'): FormatLocaleObject {
32+
const key = `${locale}:${currencyCode}`;
33+
if (localeCache[key]) {
34+
return localeCache[key];
35+
}
36+
37+
localeCache[key] = formatLocale(getD3LocaleFromIntl(locale, currencyCode));
38+
return localeCache[key];
2239
}
2340

24-
function createLocale(currencyCode: string) {
25-
return formatLocale({
26-
...DEFAULT_LOCALE,
27-
currency: [getCurrencySymbol(currencyCode), ''],
28-
});
41+
function getCurrentLocale(): string {
42+
return new Intl.NumberFormat().resolvedOptions().locale;
2943
}
3044

3145
const DEFAULT_DATETIME_FORMAT = '%Y-%m-%d %H:%M:%S';
@@ -61,19 +75,23 @@ function parseNumber(value: any): number {
6175
return parseFloat(value);
6276
}
6377

64-
export type FormatValueOptions = {
78+
export type FormatValueMember = {
6579
type: TCubeMemberType;
6680
format?: DimensionFormat | MeasureFormat;
6781
/** ISO 4217 currency code (e.g. 'USD', 'EUR'). Used when format is 'currency'. */
6882
currency?: string;
6983
/** Time dimension granularity (e.g. 'day', 'month', 'year'). Used for time formatting when no explicit format is set. */
7084
granularity?: string;
85+
};
86+
87+
export type FormatValueOptions = FormatValueMember & {
88+
locale?: string,
7189
emptyPlaceholder?: string;
7290
};
7391

7492
export function formatValue(
7593
value: any,
76-
{ type, format, currency = 'USD', granularity, emptyPlaceholder = '∅' }: FormatValueOptions
94+
{ type, format, currency = 'USD', granularity, locale = getCurrentLocale(), emptyPlaceholder = '∅' }: FormatValueOptions
7795
): string {
7896
if (value === null || value === undefined) {
7997
return emptyPlaceholder;
@@ -95,11 +113,11 @@ export function formatValue(
95113
if (typeof format === 'string') {
96114
switch (format) {
97115
case 'currency':
98-
return createLocale(currency).format(DEFAULT_CURRENCY_FORMAT)(parseNumber(value));
116+
return getCurrentD3Locale(locale, currency).format(DEFAULT_CURRENCY_FORMAT)(parseNumber(value));
99117
case 'percent':
100-
return d3Format(DEFAULT_PERCENT_FORMAT)(parseNumber(value));
118+
return getCurrentD3Locale(locale, currency).format(DEFAULT_PERCENT_FORMAT)(parseNumber(value));
101119
case 'number':
102-
return d3Format(DEFAULT_NUMBER_FORMAT)(parseNumber(value));
120+
return getCurrentD3Locale(locale, currency).format(DEFAULT_NUMBER_FORMAT)(parseNumber(value));
103121
case 'imageUrl':
104122
case 'id':
105123
case 'link':
@@ -115,7 +133,7 @@ export function formatValue(
115133
}
116134

117135
if (type === 'number') {
118-
return createLocale(currency).format(DEFAULT_NUMBER_FORMAT)(parseNumber(value));
136+
return getCurrentD3Locale(locale, currency).format(DEFAULT_NUMBER_FORMAT)(parseNumber(value));
119137
}
120138

121139
return String(value);

packages/cubejs-client-core/test/format.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ describe('formatValue', () => {
6666
expect(formatValue('2024-03-15T14:30:45.000', { type: 'time' })).toBe('2024-03-15 14:30:45');
6767
});
6868

69+
it('format with nl-NL locale', () => {
70+
const locale = 'nl-NL';
71+
expect(formatValue(1234.56, { type: 'number', format: 'currency', currency: 'EUR', locale })).toBe('€1.234,56');
72+
expect(formatValue(0, { type: 'number', format: 'currency', currency: 'EUR', locale })).toBe('€0');
73+
expect(formatValue(1234.56, { type: 'number', format: 'currency', currency: 'USD', locale })).toBe('US$1.234,56');
74+
expect(formatValue(1234.56, { type: 'number', format: 'number', locale })).toBe('1.234,56');
75+
expect(formatValue(1234.56, { type: 'number', locale })).toBe('1.234,56');
76+
});
77+
6978
it('default fallback', () => {
7079
expect(formatValue('hello', { type: 'string' })).toBe('hello');
7180
expect(formatValue(42, { type: 'number' })).toBe('42');

0 commit comments

Comments
 (0)