diff --git a/packages/cubejs-client-core/src/format-d3-numeric-locale.ts b/packages/cubejs-client-core/src/format-d3-numeric-locale.ts index 7517e4d09e1da..8168ecff00269 100644 --- a/packages/cubejs-client-core/src/format-d3-numeric-locale.ts +++ b/packages/cubejs-client-core/src/format-d3-numeric-locale.ts @@ -2,36 +2,22 @@ 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 = { - '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, +export const formatD3NumericLocale: Record> = { + 'en-US': { decimal: '.', thousands: ',', grouping: [3] }, + 'en-GB': { decimal: '.', thousands: ',', grouping: [3] }, + 'zh-CN': { decimal: '.', thousands: ',', grouping: [3] }, + 'es-ES': { decimal: ',', thousands: '.', grouping: [3] }, + 'es-MX': { decimal: '.', thousands: ',', grouping: [3] }, + 'de-DE': { decimal: ',', thousands: '.', grouping: [3] }, + 'ja-JP': { decimal: '.', thousands: ',', grouping: [3] }, + 'fr-FR': { decimal: ',', thousands: '\u00a0', grouping: [3], percent: '\u202f%' }, + 'pt-BR': { decimal: ',', thousands: '.', grouping: [3] }, + 'ko-KR': { decimal: '.', thousands: ',', grouping: [3] }, + 'it-IT': { decimal: ',', thousands: '.', grouping: [3] }, + 'nl-NL': { decimal: ',', thousands: '.', grouping: [3] }, + 'ru-RU': { decimal: ',', thousands: '\u00a0', grouping: [3] }, }; const currencySymbols: Record = { @@ -45,7 +31,7 @@ const currencySymbols: Record = { RUB: '₽', }; -function getCurrencySymbol(locale: string | undefined, currencyCode: string): [string, string] { +function getCurrencyOverride(locale: string | undefined, currencyCode: string): [string, string] { try { const cf = new Intl.NumberFormat(locale, { style: 'currency', currency: currencyCode }); const currencyParts = cf.formatToParts(1); @@ -88,7 +74,7 @@ function getD3NumericLocaleFromIntl(locale: string, currencyCode = 'USD'): Forma decimal: find('decimal') || '.', thousands: find('group') || ',', grouping: deriveGrouping(locale), - currency: getCurrencySymbol(locale, currencyCode), + currency: getCurrencyOverride(locale, currencyCode), }; } @@ -103,14 +89,17 @@ export function getD3NumericLocale(locale: string, currencyCode = 'USD'): Format let definition: FormatLocaleDefinition; if (formatD3NumericLocale[locale]) { - definition = { ...formatD3NumericLocale[locale], currency: getCurrencySymbol(locale, currencyCode) }; + definition = { ...formatD3NumericLocale[locale], currency: getCurrencyOverride(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']; + definition = { + ...formatD3NumericLocale['en-US'], + currency: getCurrencyOverride(locale, currencyCode) + }; } } diff --git a/packages/cubejs-client-core/src/format.ts b/packages/cubejs-client-core/src/format.ts index 975e266543316..3f4b930af0706 100644 --- a/packages/cubejs-client-core/src/format.ts +++ b/packages/cubejs-client-core/src/format.ts @@ -26,27 +26,57 @@ 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_WEEK_FORMAT = '%Y-%m-%d W%V'; +const DEFAULT_DATE_MONTH_FORMAT = '%Y %b'; 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 getFormatByGrain(grain?: string): string { + // Grains that should show date and time (sub-day granularities) + const dateTimeGrains = ['second', 'minute', 'hour']; + + // Grains that should show date only (day and above granularities) + const dateOnlyGrains = ['day', 'week', 'month', 'quarter', 'year']; + + if (grain === 'day') { + return DEFAULT_DATE_FORMAT; + } + + if (grain === 'week') { + return DEFAULT_DATE_WEEK_FORMAT; + } + + if (grain === 'month') { + return DEFAULT_DATE_MONTH_FORMAT; + } + + if (grain === 'quarter') { + return DEFAULT_DATE_QUARTER_FORMAT; + } + + if (grain === 'year') { + return DEFAULT_DATE_YEAR_FORMAT; + } + + if (!grain || dateTimeGrains.includes(grain)) { + return DEFAULT_DATETIME_FORMAT; + } + + if (dateOnlyGrains.includes(grain)) { + return DEFAULT_DATE_FORMAT; } + + // Fallback to datetime for unknown grains + return DEFAULT_DATETIME_FORMAT; +} + +export function formatDateByGranularity(value: Date | string | number, granularity?: string): string { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + return 'Invalid date'; + } + + return timeFormat(getFormatByGrain(granularity))(date); } function parseNumber(value: any): number { @@ -73,78 +103,125 @@ export type FormatValueOptions = FormatValueMember & { emptyPlaceholder?: string; }; -export function formatValue( - value: any, - { type, format, currency = 'USD', granularity, locale = currentLocale, emptyPlaceholder = '∅' }: FormatValueOptions -): string { - if (value === null || value === undefined) { - return emptyPlaceholder; +export type GetFormatOptions = { + locale?: string; +}; + +export type GetFormatResult = { + formatString: string | null; + formatFunc: (value: any) => string; +}; + +function formatBoolean(value: any): string { + if (typeof value === 'boolean') { + return value.toString(); } - if (type === 'boolean') { - if (typeof value === 'boolean') { - return value.toString(); - } + if (typeof value === 'number') { + return Boolean(value).toString(); + } - if (typeof value === 'number') { - return Boolean(value).toString(); - } + // Some SQL drivers return booleans as '0'/'1' or 'true'/'false' strings, It's incorrect behaivour in Cube, + // but let's format it as boolean for backward compatibility. + if (value === '0' || value === 'false') { + return 'false'; + } - // Some SQL drivers return booleans as '0'/'1' or 'true'/'false' strings, It's incorrect behaivour in Cube, - // but let's format it as boolean for backward compatibility. - if (value === '0' || value === 'false') { - return 'false'; - } + if (value === '1' || value === 'true') { + return 'true'; + } - if (value === '1' || value === 'true') { - return 'true'; - } + return String(value); +} + +export function getFormat( + member: FormatValueMember, + { locale = currentLocale }: GetFormatOptions = {} +): GetFormatResult { + const { type, format, currency = 'USD', granularity } = member; - return String(value); + if (type === 'boolean') { + return { formatString: null, formatFunc: formatBoolean }; } if (format && typeof format === 'object') { if (format.type === 'custom-numeric') { - return d3Format(format.value)(parseNumber(value)); + return { + formatString: format.value, + formatFunc: (value) => 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); + return { + formatString: format.value, + formatFunc: (value) => { + 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); + return { formatString: null, formatFunc: (value) => String(value) }; } if (typeof format === 'string') { switch (format) { case 'currency': - return getD3NumericLocale(locale, currency).format(DEFAULT_CURRENCY_FORMAT)(parseNumber(value)); + return { + formatString: DEFAULT_CURRENCY_FORMAT, + formatFunc: (value) => getD3NumericLocale(locale, currency).format(DEFAULT_CURRENCY_FORMAT)(parseNumber(value)), + }; case 'percent': - return getD3NumericLocale(locale).format(DEFAULT_PERCENT_FORMAT)(parseNumber(value)); + return { + formatString: DEFAULT_PERCENT_FORMAT, + formatFunc: (value) => getD3NumericLocale(locale).format(DEFAULT_PERCENT_FORMAT)(parseNumber(value)), + }; case 'number': - return getD3NumericLocale(locale).format(DEFAULT_NUMBER_FORMAT)(parseNumber(value)); + return { + formatString: DEFAULT_NUMBER_FORMAT, + formatFunc: (value) => getD3NumericLocale(locale).format(DEFAULT_NUMBER_FORMAT)(parseNumber(value)), + }; case 'id': - return d3Format(DEFAULT_ID_FORMAT)(parseNumber(value)); + return { + formatString: DEFAULT_ID_FORMAT, + formatFunc: (value) => d3Format(DEFAULT_ID_FORMAT)(parseNumber(value)), + }; case 'imageUrl': case 'link': default: - return String(value); + return { formatString: null, formatFunc: (value) => 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); + return { + formatString: getFormatByGrain(granularity), + formatFunc: (value) => formatDateByGranularity(value, granularity), + }; } if (type === 'number') { - return getD3NumericLocale(locale, currency).format(DEFAULT_NUMBER_FORMAT)(parseNumber(value)); + return { + formatString: DEFAULT_NUMBER_FORMAT, + formatFunc: (value) => getD3NumericLocale(locale, currency).format(DEFAULT_NUMBER_FORMAT)(parseNumber(value)), + }; } - return String(value); + return { formatString: null, formatFunc: (value) => String(value) }; +} + +export function formatValue( + value: any, + options: FormatValueOptions +): string { + const { emptyPlaceholder = '∅' } = options; + + if (value === null || value === undefined) { + return emptyPlaceholder; + } + + return getFormat(options, { locale: options.locale }).formatFunc(value); } diff --git a/packages/cubejs-client-core/test/format.test.ts b/packages/cubejs-client-core/test/format.test.ts index df1cc6ead53f9..889a301632011 100644 --- a/packages/cubejs-client-core/test/format.test.ts +++ b/packages/cubejs-client-core/test/format.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { formatValue } from '../src/format'; +import { formatValue, formatDateByGranularity, getFormat } from '../src/format'; describe('formatValue', () => { it('format null', () => { @@ -64,11 +64,12 @@ describe('formatValue', () => { it('type-based fallback: time with grain', () => { expect(formatValue('2024-03-15T00:00:00.000', { type: 'time', granularity: 'day' })).toBe('2024-03-15'); - expect(formatValue('2024-03-01T00:00:00.000', { type: 'time', granularity: 'month' })).toBe('2024-03'); + expect(formatValue('2024-03-01T00:00:00.000', { type: 'time', granularity: 'month' })).toBe('2024 Mar'); expect(formatValue('2024-01-01T00:00:00.000', { type: 'time', granularity: 'year' })).toBe('2024'); - expect(formatValue('2024-03-11T00:00:00.000', { type: 'time', granularity: 'week' })).toBe('2024-03-11'); + expect(formatValue('2024-03-11T00:00:00.000', { type: 'time', granularity: 'week' })).toBe('2024-03-11 W11'); expect(formatValue('2024-03-01T00:00:00.000', { type: 'time', granularity: 'quarter' })).toBe('2024-Q1'); expect(formatValue('2024-03-15T14:00:00.000', { type: 'time', granularity: 'hour' })).toBe('2024-03-15 14:00:00'); + expect(formatValue('2024-03-15T14:30:00.000', { type: 'time', granularity: 'minute' })).toBe('2024-03-15 14:30:00'); expect(formatValue('2024-03-15T14:30:45.000', { type: 'time' })).toBe('2024-03-15 14:30:45'); }); @@ -116,3 +117,90 @@ describe('formatValue', () => { expect(formatValue('0', { type: 'boolean' })).toBe('false'); }); }); + +describe('formatDateByGranularity', () => { + it('formats each predefined granularity', () => { + const iso = '2024-03-15T14:30:45.000'; + expect(formatDateByGranularity(iso, 'second')).toBe('2024-03-15 14:30:45'); + expect(formatDateByGranularity(iso, 'minute')).toBe('2024-03-15 14:30:45'); + expect(formatDateByGranularity(iso, 'hour')).toBe('2024-03-15 14:30:45'); + expect(formatDateByGranularity(iso, 'day')).toBe('2024-03-15'); + expect(formatDateByGranularity(iso, 'week')).toBe('2024-03-15 W11'); + expect(formatDateByGranularity(iso, 'month')).toBe('2024 Mar'); + expect(formatDateByGranularity(iso, 'quarter')).toBe('2024-Q1'); + expect(formatDateByGranularity(iso, 'year')).toBe('2024'); + }); + + it('accepts Date, ISO string, and epoch-number inputs', () => { + const date = new Date('2024-03-15T00:00:00.000'); + expect(formatDateByGranularity(date, 'day')).toBe('2024-03-15'); + expect(formatDateByGranularity(date.getTime(), 'day')).toBe('2024-03-15'); + expect(formatDateByGranularity('2024-03-15T00:00:00.000', 'day')).toBe('2024-03-15'); + }); + + it('falls back to second-grain format for missing or unknown granularity', () => { + expect(formatDateByGranularity('2024-03-15T14:30:45.000')).toBe('2024-03-15 14:30:45'); + expect(formatDateByGranularity('2024-03-15T14:30:45.000', 'decade' as any)).toBe('2024-03-15 14:30:45'); + }); + + it('returns "Invalid date" on bad input', () => { + expect(formatDateByGranularity('not-a-date', 'day')).toBe('Invalid date'); + }); +}); + +describe('getFormat', () => { + it('time dimension: returns d3 format string per granularity', () => { + expect(getFormat({ type: 'time', granularity: 'day' }).formatString).toBe('%Y-%m-%d'); + expect(getFormat({ type: 'time', granularity: 'month' }).formatString).toBe('%Y %b'); + expect(getFormat({ type: 'time', granularity: 'year' }).formatString).toBe('%Y'); + expect(getFormat({ type: 'time', granularity: 'hour' }).formatString).toBe('%Y-%m-%d %H:%M:%S'); + expect(getFormat({ type: 'time' }).formatString).toBe('%Y-%m-%d %H:%M:%S'); + }); + + it('time dimension: formatFunc delegates to formatDateByGranularity', () => { + const { formatFunc } = getFormat({ type: 'time', granularity: 'month' }); + expect(formatFunc('2024-03-01T00:00:00.000')).toBe('2024 Mar'); + }); + + it('number with currency format', () => { + const { formatString, formatFunc } = getFormat({ type: 'number', format: 'currency' }); + expect(formatString).toBe('$,.2f'); + expect(formatFunc(1234.56)).toBe('$1,234.56'); + expect(formatFunc('1234.56')).toBe('$1,234.56'); + }); + + it('number with percent format', () => { + const { formatString, formatFunc } = getFormat({ type: 'number', format: 'percent' }); + expect(formatString).toBe('.2%'); + expect(formatFunc(0.1234)).toBe('12.34%'); + }); + + it('number with no explicit format falls back to default number format', () => { + const { formatString, formatFunc } = getFormat({ type: 'number' }); + expect(formatString).toBe(',.2f'); + expect(formatFunc(1234.56)).toBe('1,234.56'); + }); + + it('custom-numeric format exposes the spec as formatString', () => { + const { formatString, formatFunc } = getFormat({ type: 'number', format: { type: 'custom-numeric', value: '.2s' } }); + expect(formatString).toBe('.2s'); + expect(formatFunc(1500)).toBe('1.5k'); + }); + + it('custom-time format exposes the spec as formatString', () => { + const { formatString, formatFunc } = getFormat({ type: 'time', format: { type: 'custom-time', value: '%Y-%m-%d' } }); + expect(formatString).toBe('%Y-%m-%d'); + expect(formatFunc('2024-03-15T10:30:00.000')).toBe('2024-03-15'); + }); + + it('string fallback returns identity formatFunc', () => { + const { formatString, formatFunc } = getFormat({ type: 'string' }); + expect(formatString).toBeNull(); + expect(formatFunc('hello')).toBe('hello'); + }); + + it('locale option is honored by formatFunc', () => { + const { formatFunc } = getFormat({ type: 'number', format: 'currency', currency: 'EUR' }, { locale: 'nl-NL' }); + expect(formatFunc(1234.56)).toBe('€1.234,56'); + }); +});