-
Notifications
You must be signed in to change notification settings - Fork 2k
feat(client-core): Introduce formating API #10653
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3c4c453
d39197b
3ca960e
c649781
203919c
fbd5550
9c99182
1ea70b1
9cf0834
0dd0d83
ec061d9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| import { formatLocale } from 'd3-format'; | ||
|
|
||
| import type { FormatLocaleDefinition, FormatLocaleObject } from 'd3-format'; | ||
|
|
||
| import enUS from 'd3-format/locale/en-US.json'; | ||
| import enGB from 'd3-format/locale/en-GB.json'; | ||
| import zhCN from 'd3-format/locale/zh-CN.json'; | ||
| import esES from 'd3-format/locale/es-ES.json'; | ||
| import esMX from 'd3-format/locale/es-MX.json'; | ||
| import deDE from 'd3-format/locale/de-DE.json'; | ||
| import jaJP from 'd3-format/locale/ja-JP.json'; | ||
| import frFR from 'd3-format/locale/fr-FR.json'; | ||
| import ptBR from 'd3-format/locale/pt-BR.json'; | ||
| import koKR from 'd3-format/locale/ko-KR.json'; | ||
| import itIT from 'd3-format/locale/it-IT.json'; | ||
| import nlNL from 'd3-format/locale/nl-NL.json'; | ||
| import ruRU from 'd3-format/locale/ru-RU.json'; | ||
|
|
||
| // Pre-built d3 locale definitions for the most popular locales. | ||
| // Used as a fallback when Intl is unavailable (e.g. some edge runtimes). | ||
| export const formatD3NumericLocale: Record<string, FormatLocaleDefinition> = { | ||
| 'en-US': enUS as unknown as FormatLocaleDefinition, | ||
| 'en-GB': enGB as unknown as FormatLocaleDefinition, | ||
| 'zh-CN': zhCN as unknown as FormatLocaleDefinition, | ||
| 'es-ES': esES as unknown as FormatLocaleDefinition, | ||
| 'es-MX': esMX as unknown as FormatLocaleDefinition, | ||
| 'de-DE': deDE as unknown as FormatLocaleDefinition, | ||
| 'ja-JP': jaJP as unknown as FormatLocaleDefinition, | ||
| 'fr-FR': frFR as unknown as FormatLocaleDefinition, | ||
| 'pt-BR': ptBR as unknown as FormatLocaleDefinition, | ||
| 'ko-KR': koKR as unknown as FormatLocaleDefinition, | ||
| 'it-IT': itIT as unknown as FormatLocaleDefinition, | ||
| 'nl-NL': nlNL as unknown as FormatLocaleDefinition, | ||
| 'ru-RU': ruRU as unknown as FormatLocaleDefinition, | ||
| }; | ||
|
|
||
| const currencySymbols: Record<string, string> = { | ||
| USD: '$', | ||
| EUR: '€', | ||
| GBP: '£', | ||
| JPY: '¥', | ||
| CNY: '¥', | ||
| KRW: '₩', | ||
| INR: '₹', | ||
| RUB: '₽', | ||
| }; | ||
|
|
||
| function getCurrencySymbol(locale: string | undefined, currencyCode: string): [string, string] { | ||
| try { | ||
| const cf = new Intl.NumberFormat(locale, { style: 'currency', currency: currencyCode }); | ||
| const currencyParts = cf.formatToParts(1); | ||
| const currencySymbol = currencyParts.find((p) => p.type === 'currency')?.value ?? currencyCode; | ||
| const firstMeaningfulType = currencyParts.find((p) => !['literal', 'nan'].includes(p.type))?.type; | ||
| const symbolIsPrefix = firstMeaningfulType === 'currency'; | ||
|
|
||
| return symbolIsPrefix ? [currencySymbol, ''] : ['', currencySymbol]; | ||
| } catch { | ||
| const symbol = currencySymbols[currencyCode] ?? currencyCode; | ||
| return [symbol, '']; | ||
| } | ||
| } | ||
|
|
||
| function deriveGrouping(locale: string): number[] { | ||
| // en-US → "1,234,567,890" → sizes [1,3,3,3] → [3] | ||
| // en-IN → "1,23,45,67,890" → sizes [1,2,2,2,3] → [3,2] | ||
| const sizes = new Intl.NumberFormat(locale).formatToParts(1234567890) | ||
| .filter((p) => p.type === 'integer') | ||
| .map((p) => p.value.length); | ||
|
|
||
| if (sizes.length <= 1) { | ||
| return [3]; | ||
| } | ||
|
|
||
| // d3 repeats the last array element for all remaining groups, | ||
| // so we only need the two rightmost (least-significant) group sizes. | ||
| const first = sizes[sizes.length - 1]; | ||
| const second = sizes[sizes.length - 2]; | ||
|
|
||
| return first === second ? [first] : [first, second]; | ||
| } | ||
|
|
||
| function getD3NumericLocaleFromIntl(locale: string, currencyCode = 'USD'): FormatLocaleDefinition { | ||
| const nf = new Intl.NumberFormat(locale); | ||
| const numParts = nf.formatToParts(1234567.89); | ||
| const find = (type: string) => numParts.find((p) => p.type === type)?.value ?? ''; | ||
|
|
||
| return { | ||
| decimal: find('decimal') || '.', | ||
| thousands: find('group') || ',', | ||
| grouping: deriveGrouping(locale), | ||
| currency: getCurrencySymbol(locale, currencyCode), | ||
| }; | ||
| } | ||
|
|
||
| const localeCache: Record<string, FormatLocaleObject> = Object.create(null); | ||
|
|
||
| export function getD3NumericLocale(locale: string, currencyCode = 'USD'): FormatLocaleObject { | ||
| const key = `${locale}:${currencyCode}`; | ||
| if (localeCache[key]) { | ||
| return localeCache[key]; | ||
| } | ||
|
|
||
| let definition: FormatLocaleDefinition; | ||
|
|
||
| if (formatD3NumericLocale[locale]) { | ||
| definition = { ...formatD3NumericLocale[locale], currency: getCurrencySymbol(locale, currencyCode) }; | ||
| } else { | ||
| try { | ||
| definition = getD3NumericLocaleFromIntl(locale, currencyCode); | ||
| } catch (e: unknown) { | ||
| console.warn('Failed to generate d3 local via Intl, failing back to en-US', e); | ||
|
|
||
| definition = formatD3NumericLocale['en-US']; | ||
| } | ||
| } | ||
|
|
||
| localeCache[key] = formatLocale(definition); | ||
| return localeCache[key]; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| import { format as d3Format } from 'd3-format'; | ||
| import { timeFormat } from 'd3-time-format'; | ||
| import { getD3NumericLocale } from './format-d3-numeric-locale'; | ||
|
|
||
| import type { DimensionFormat, MeasureFormat, TCubeMemberType } from './types'; | ||
|
|
||
| // Default d3-format specifiers — aligned with the named _2 formats | ||
| // (number_2, currency_2, percent_2) in named-numeric-formats.ts | ||
| const DEFAULT_NUMBER_FORMAT = ',.2f'; | ||
| const DEFAULT_CURRENCY_FORMAT = '$,.2f'; | ||
| const DEFAULT_PERCENT_FORMAT = '.2%'; | ||
|
|
||
| function detectLocale() { | ||
| try { | ||
| return new Intl.NumberFormat().resolvedOptions().locale; | ||
| } catch (e) { | ||
| console.warn('Failed to detect locale', e); | ||
|
|
||
| return 'en-US'; | ||
| } | ||
| } | ||
|
|
||
| const currentLocale = detectLocale(); | ||
|
|
||
| const DEFAULT_DATETIME_FORMAT = '%Y-%m-%d %H:%M:%S'; | ||
| const DEFAULT_DATE_FORMAT = '%Y-%m-%d'; | ||
| const DEFAULT_DATE_MONTH_FORMAT = '%Y-%m'; | ||
| const DEFAULT_DATE_QUARTER_FORMAT = '%Y-Q%q'; | ||
| const DEFAULT_DATE_YEAR_FORMAT = '%Y'; | ||
|
|
||
| function getTimeFormatByGrain(grain: string | undefined): string { | ||
| switch (grain) { | ||
| case 'day': | ||
| case 'week': | ||
| return DEFAULT_DATE_FORMAT; | ||
| case 'month': | ||
| return DEFAULT_DATE_MONTH_FORMAT; | ||
| case 'quarter': | ||
| return DEFAULT_DATE_QUARTER_FORMAT; | ||
| case 'year': | ||
| return DEFAULT_DATE_YEAR_FORMAT; | ||
| case 'second': | ||
| case 'minute': | ||
| case 'hour': | ||
| default: | ||
| return DEFAULT_DATETIME_FORMAT; | ||
| } | ||
| } | ||
|
|
||
| function parseNumber(value: any): number { | ||
| if (value === null || value === undefined) { | ||
| return 0; | ||
| } | ||
|
|
||
| return parseFloat(value); | ||
| } | ||
ovr marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| export type FormatValueMember = { | ||
| type: TCubeMemberType; | ||
| format?: DimensionFormat | MeasureFormat; | ||
| /** ISO 4217 currency code (e.g. 'USD', 'EUR'). Used when format is 'currency'. */ | ||
| currency?: string; | ||
| /** Time dimension granularity (e.g. 'day', 'month', 'year'). Used for time formatting when no explicit format is set. */ | ||
| granularity?: string; | ||
| }; | ||
|
|
||
| export type FormatValueOptions = FormatValueMember & { | ||
| /** Locale tag (e.g. 'en-US', 'de-DE', 'nl-NL'). Defaults to the runtime's locale via Intl.NumberFormat. */ | ||
| locale?: string, | ||
| /** String to return for null/undefined values. Defaults to '∅'. */ | ||
| emptyPlaceholder?: string; | ||
| }; | ||
|
|
||
| export function formatValue( | ||
| value: any, | ||
| { type, format, currency = 'USD', granularity, locale = currentLocale, emptyPlaceholder = '∅' }: FormatValueOptions | ||
| ): string { | ||
| if (value === null || value === undefined) { | ||
| return emptyPlaceholder; | ||
| } | ||
|
|
||
| if (format && typeof format === 'object') { | ||
| if (format.type === 'custom-numeric') { | ||
| return d3Format(format.value)(parseNumber(value)); | ||
| } | ||
|
|
||
| if (format.type === 'custom-time') { | ||
| const date = new Date(value); | ||
| return Number.isNaN(date.getTime()) ? 'Invalid date' : timeFormat(format.value)(date); | ||
| } | ||
|
|
||
| // { type: 'link', label: string } — return value as string | ||
| return String(value); | ||
| } | ||
|
|
||
| if (typeof format === 'string') { | ||
| switch (format) { | ||
| case 'currency': | ||
| return getD3NumericLocale(locale, currency).format(DEFAULT_CURRENCY_FORMAT)(parseNumber(value)); | ||
| case 'percent': | ||
| return getD3NumericLocale(locale).format(DEFAULT_PERCENT_FORMAT)(parseNumber(value)); | ||
| case 'number': | ||
| return getD3NumericLocale(locale).format(DEFAULT_NUMBER_FORMAT)(parseNumber(value)); | ||
| case 'imageUrl': | ||
| case 'id': | ||
| case 'link': | ||
| default: | ||
| return String(value); | ||
| } | ||
| } | ||
|
|
||
| // No explicit format — infer from type | ||
| if (type === 'time') { | ||
| const date = new Date(value); | ||
| if (Number.isNaN(date.getTime())) return 'Invalid date'; | ||
|
|
||
| return timeFormat(getTimeFormatByGrain(granularity))(date); | ||
| } | ||
|
|
||
| if (type === 'number') { | ||
| return getD3NumericLocale(locale, currency).format(DEFAULT_NUMBER_FORMAT)(parseNumber(value)); | ||
| } | ||
|
|
||
| return String(value); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -910,3 +910,5 @@ export * from './HttpTransport'; | |
| export * from './utils'; | ||
| export * from './time'; | ||
| export * from './types'; | ||
| // We don't export it for now, because size of builds for cjs/umd users will be affected | ||
| // export * from './format'; | ||
|
Comment on lines
+913
to
+914
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The export is commented out — meaning consumers can't actually use Is this intentional for this PR? If so, how are consumers expected to access this function? If this is meant to be usable in a follow-up (e.g., only from ESM builds), it might be worth adding a note in the PR description about the plan. If tree-shaking is the concern, modern bundlers (Rollup, webpack, esbuild) will eliminate unused exports from ESM builds. The CJS/UMD concern is valid, but you could consider a separate entry point (e.g.,
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Intended for now, I will prepare separate entrypoints in different PR, It require testing
Comment on lines
+913
to
+914
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The export is still commented out — consumers can't access Is this intentional for this PR? If the concern is bundle size for CJS/UMD users, a separate entry point (e.g., If this is meant to land without the export, it might be worth noting in the PR description that this is a foundational PR with a follow-up planned to wire the export.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Intended for now, I will prepare separate entrypoints in different PR, It require testing |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; | ||
|
|
||
| describe('formatValue without Intl', () => { | ||
| const originalIntl = globalThis.Intl; | ||
|
|
||
| beforeAll(() => { | ||
| vi.resetModules(); | ||
|
|
||
| // @ts-expect-error — intentionally removing Intl to simulate environments where it is unavailable | ||
| delete globalThis.Intl; | ||
| }); | ||
|
|
||
| afterAll(() => { | ||
| globalThis.Intl = originalIntl; | ||
| }); | ||
|
|
||
| it('detectLocale falls back to en-US and formatting works', async () => { | ||
| const { formatValue } = await import('../src/format'); | ||
|
|
||
| // number type uses the detected locale (should be en-US fallback) | ||
| expect(formatValue(1234.56, { type: 'number' })).toBe('1,234.56'); | ||
| }); | ||
|
|
||
| it('currency formatting falls back to en-US locale definition', async () => { | ||
| const { formatValue } = await import('../src/format'); | ||
|
|
||
| expect(formatValue(1234.56, { type: 'number', format: 'currency' })).toBe('$1,234.56'); | ||
| }); | ||
|
|
||
| it('percent formatting works without Intl', async () => { | ||
| const { formatValue } = await import('../src/format'); | ||
|
|
||
| expect(formatValue(0.1234, { type: 'number', format: 'percent' })).toBe('12.34%'); | ||
| }); | ||
|
|
||
| it('time formatting works without Intl', async () => { | ||
| const { formatValue } = await import('../src/format'); | ||
|
|
||
| expect(formatValue('2024-03-15T00:00:00.000', { type: 'time', granularity: 'day' })).toBe('2024-03-15'); | ||
| }); | ||
|
|
||
| it('null/undefined still return emptyPlaceholder', async () => { | ||
| const { formatValue } = await import('../src/format'); | ||
|
|
||
| expect(formatValue(null, { type: 'number' })).toBe('∅'); | ||
| expect(formatValue(undefined, { type: 'number' })).toBe('∅'); | ||
| }); | ||
|
|
||
| // Known locale (de-DE) — pre-built d3 definition is used, | ||
| // getCurrencySymbol falls back to the static currencySymbols map. | ||
| it('known locale (de-DE) uses pre-built locale definition', async () => { | ||
| const { formatValue } = await import('../src/format'); | ||
|
|
||
| expect(formatValue(1234.56, { type: 'number', format: 'number', locale: 'de-DE' })).toBe('1.234,56'); | ||
| expect(formatValue(1234.56, { type: 'number', format: 'currency', currency: 'EUR', locale: 'de-DE' })).toBe('€1.234,56'); | ||
| expect(formatValue(0.1234, { type: 'number', format: 'percent', locale: 'de-DE' })).toBe('12,34%'); | ||
| }); | ||
|
|
||
| // Unknown locale (sv-SE) — getD3NumericLocaleFromIntl throws, | ||
| // falls back entirely to en-US. | ||
| it('unknown locale (sv-SE) falls back to en-US', async () => { | ||
| const { formatValue } = await import('../src/format'); | ||
|
|
||
| expect(formatValue(1234.56, { type: 'number', format: 'number', locale: 'sv-SE' })).toBe('1,234.56'); | ||
| expect(formatValue(1234.56, { type: 'number', format: 'currency', currency: 'USD', locale: 'sv-SE' })).toBe('$1,234.56'); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.