diff --git a/web/src/components/ui/charts/BusterChartJS/helpers/datalabelHelper.ts b/web/src/components/ui/charts/BusterChartJS/helpers/datalabelHelper.ts index 6dccdb66e..f341bbda0 100644 --- a/web/src/components/ui/charts/BusterChartJS/helpers/datalabelHelper.ts +++ b/web/src/components/ui/charts/BusterChartJS/helpers/datalabelHelper.ts @@ -1,62 +1,7 @@ -import { formatLabel } from '@/lib/columnFormatter'; import type { Context } from 'chartjs-plugin-datalabels'; -import { formatChartLabelDelimiter } from '../../commonHelpers'; -import { extractFieldsFromChain, appendToKeyValueChain } from '../../chartHooks'; -import { - type BusterChartProps, - type ColumnLabelFormat -} from '@/api/asset_interfaces/metric/charts'; import { determineFontColorContrast } from '@/lib/colors'; export const dataLabelFontColorContrast = (context: Context) => { const color = context.dataset.backgroundColor as string; return determineFontColorContrast(color); }; - -export const formatChartLabel = ( - label: string, - columnLabelFormats: NonNullable, - hasMultipleMeasures: boolean, - hasCategoryAxis: boolean -): string => { - if (hasCategoryAxis && !hasMultipleMeasures) { - const fields = extractFieldsFromChain(label); - const lastField = fields.at(0)!; - const newLabel = appendToKeyValueChain(lastField); - return formatChartLabelDelimiter(newLabel, columnLabelFormats); - } - - if (!hasMultipleMeasures) { - const fields = extractFieldsFromChain(label); - const lastField = fields.at(-1)!; - return formatChartLabelDelimiter(lastField.value || lastField.key, columnLabelFormats); - } - - return formatChartLabelDelimiter(label, columnLabelFormats); -}; - -export const formatBarAndLineDataLabel = ( - value: number, - context: Context, - usePercentage: boolean | undefined, - columnLabelFormat: ColumnLabelFormat -) => { - if (!usePercentage) { - return formatLabel(value, columnLabelFormat); - } - - const shownDatasets = context.chart.data.datasets.filter( - (dataset) => !dataset.hidden && !dataset.isTrendline - ); - const hasMultipleDatasets = shownDatasets.length > 1; - const total: number = hasMultipleDatasets - ? context.chart.$totalizer.stackTotals[context.dataIndex] - : context.chart.$totalizer.seriesTotals[context.datasetIndex]; - const percentage = ((value as number) / total) * 100; - - return formatLabel(percentage, { - ...columnLabelFormat, - style: 'percent', - columnType: 'number' - }); -}; diff --git a/web/src/components/ui/charts/BusterChartJS/helpers/formatBarAndLineDataLabel.test.ts b/web/src/components/ui/charts/BusterChartJS/helpers/formatBarAndLineDataLabel.test.ts new file mode 100644 index 000000000..251846450 --- /dev/null +++ b/web/src/components/ui/charts/BusterChartJS/helpers/formatBarAndLineDataLabel.test.ts @@ -0,0 +1,36 @@ +import { formatBarAndLineDataLabel } from './formatBarAndLineDataLabel'; +import { ColumnLabelFormat, IColumnLabelFormat } from '@/api/asset_interfaces/metric'; +import { Context } from 'chartjs-plugin-datalabels'; + +describe('formatBarAndLineDataLabel', () => { + it('formats a single value without percentage', () => { + const value = 1234.56; + const columnLabelFormat: ColumnLabelFormat = { + style: 'number', + columnType: 'number', + numberSeparatorStyle: ',', + minimumFractionDigits: 0, + maximumFractionDigits: 2 + }; + + const mockContext = { + chart: { + data: { + datasets: [] + }, + $totalizer: { + stackTotals: [], + seriesTotals: [] + } + }, + active: false, + dataIndex: 0, + dataset: {}, + datasetIndex: 0 + } as unknown as Context; + + const result = formatBarAndLineDataLabel(value, mockContext, false, columnLabelFormat); + + expect(result).toBe('1,234.56'); + }); +}); diff --git a/web/src/components/ui/charts/BusterChartJS/helpers/formatBarAndLineDataLabel.ts b/web/src/components/ui/charts/BusterChartJS/helpers/formatBarAndLineDataLabel.ts new file mode 100644 index 000000000..a226d135c --- /dev/null +++ b/web/src/components/ui/charts/BusterChartJS/helpers/formatBarAndLineDataLabel.ts @@ -0,0 +1,29 @@ +import { ColumnLabelFormat } from '@/api/asset_interfaces/metric'; +import { formatLabel } from '@/lib/columnFormatter'; +import type { Context } from 'chartjs-plugin-datalabels'; + +export const formatBarAndLineDataLabel = ( + value: number, + context: Context, + usePercentage: boolean | undefined, + columnLabelFormat: ColumnLabelFormat +) => { + if (!usePercentage) { + return formatLabel(value, columnLabelFormat); + } + + const shownDatasets = context.chart.data.datasets.filter( + (dataset) => !dataset.hidden && !dataset.isTrendline + ); + const hasMultipleDatasets = shownDatasets.length > 1; + const total: number = hasMultipleDatasets + ? context.chart.$totalizer.stackTotals[context.dataIndex] + : context.chart.$totalizer.seriesTotals[context.datasetIndex]; + const percentage = ((value as number) / total) * 100; + + return formatLabel(percentage, { + ...columnLabelFormat, + style: 'percent', + columnType: 'number' + }); +}; diff --git a/web/src/components/ui/charts/BusterChartJS/helpers/formatChartLabel.test.ts b/web/src/components/ui/charts/BusterChartJS/helpers/formatChartLabel.test.ts new file mode 100644 index 000000000..6576fadf0 --- /dev/null +++ b/web/src/components/ui/charts/BusterChartJS/helpers/formatChartLabel.test.ts @@ -0,0 +1,76 @@ +import { formatChartLabel } from './formatChartLabel'; +import type { IColumnLabelFormat } from '@/api/asset_interfaces/metric/charts'; + +describe('formatChartLabel', () => { + it('should format label correctly when hasCategoryAxis is true and hasMultipleMeasures is false', () => { + const columnLabelFormats = { + month: { + style: 'date', + compactNumbers: false, + columnType: 'date', + displayName: '', + numberSeparatorStyle: ',', + minimumFractionDigits: 0, + maximumFractionDigits: 2, + currency: 'USD', + convertNumberTo: null, + dateFormat: 'MMM YYYY', + useRelativeTime: false, + isUTC: false, + multiplier: 1, + prefix: '', + suffix: '', + replaceMissingDataWith: null, + makeLabelHumanReadable: true + }, + recent_total: { + style: 'currency', + compactNumbers: false, + columnType: 'number', + displayName: '', + numberSeparatorStyle: ',', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + currency: 'USD', + convertNumberTo: null, + dateFormat: 'auto', + useRelativeTime: false, + isUTC: false, + multiplier: 1, + prefix: '', + suffix: '', + replaceMissingDataWith: 0, + makeLabelHumanReadable: true + }, + long_term_avg: { + style: 'currency', + compactNumbers: false, + columnType: 'number', + displayName: '', + numberSeparatorStyle: ',', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + currency: 'USD', + convertNumberTo: null, + dateFormat: 'auto', + useRelativeTime: false, + isUTC: false, + multiplier: 1, + prefix: '', + suffix: '', + replaceMissingDataWith: 0, + makeLabelHumanReadable: true + } + } satisfies Record; + + // Act + let result = formatChartLabel('recent_total__🔑__', columnLabelFormats, true, false); + expect(result).toBe('Recent Total'); + + result = formatChartLabel('recent_total__🔑__', columnLabelFormats, false, false); + expect(result).toBe('Recent Total'); + + result = formatChartLabel('recent_total__🔑__', columnLabelFormats, false, true); + expect(result).toBe('Recent Total'); + }); +}); diff --git a/web/src/components/ui/charts/BusterChartJS/helpers/formatChartLabel.ts b/web/src/components/ui/charts/BusterChartJS/helpers/formatChartLabel.ts new file mode 100644 index 000000000..49339f36c --- /dev/null +++ b/web/src/components/ui/charts/BusterChartJS/helpers/formatChartLabel.ts @@ -0,0 +1,25 @@ +import type { BusterChartProps } from '@/api/asset_interfaces'; +import { extractFieldsFromChain, appendToKeyValueChain } from '../../chartHooks'; +import { formatChartLabelDelimiter } from '../../commonHelpers'; + +export const formatChartLabel = ( + label: string, + columnLabelFormats: NonNullable, + hasMultipleMeasures: boolean, + hasCategoryAxis: boolean +): string => { + if (hasCategoryAxis && !hasMultipleMeasures) { + const fields = extractFieldsFromChain(label); + const lastField = fields.at(0)!; + const newLabel = appendToKeyValueChain(lastField); + return formatChartLabelDelimiter(newLabel, columnLabelFormats); + } + + if (!hasMultipleMeasures) { + const fields = extractFieldsFromChain(label); + const lastField = fields.at(-1)!; + return formatChartLabelDelimiter(lastField.value || lastField.key, columnLabelFormats); + } + + return formatChartLabelDelimiter(label, columnLabelFormats); +}; diff --git a/web/src/components/ui/charts/BusterChartJS/helpers/index.ts b/web/src/components/ui/charts/BusterChartJS/helpers/index.ts index a69a5b593..55311b610 100644 --- a/web/src/components/ui/charts/BusterChartJS/helpers/index.ts +++ b/web/src/components/ui/charts/BusterChartJS/helpers/index.ts @@ -1 +1,3 @@ export * from './datalabelHelper'; +export * from './formatChartLabel'; +export * from './formatBarAndLineDataLabel'; diff --git a/web/src/components/ui/charts/commonHelpers/labelHelpers.test.ts b/web/src/components/ui/charts/commonHelpers/labelHelpers.test.ts new file mode 100644 index 000000000..5a23ed723 --- /dev/null +++ b/web/src/components/ui/charts/commonHelpers/labelHelpers.test.ts @@ -0,0 +1,103 @@ +import { formatChartValueDelimiter } from './labelHelpers'; +import type { ColumnLabelFormat } from '@/api/asset_interfaces/metric/charts'; + +describe('formatChartValueDelimiter', () => { + it('should format a numeric value using the column format', () => { + const rawValue = 1234.56; + const columnNameDelimiter = 'value__🔑__'; + const columnLabelFormats: Record = { + value: { + style: 'number', + columnType: 'number', + minimumFractionDigits: 1, + maximumFractionDigits: 1 + } + }; + + const result = formatChartValueDelimiter(rawValue, columnNameDelimiter, columnLabelFormats); + expect(result).toBe('1,234.6'); + }); + + it('If the key is not found, it will fallback to the default format', () => { + const rawValue = 1234.56; + const columnNameDelimiter = 'THIS_IS_INVALID_KEY'; + const columnLabelFormats: Record = { + value: { + style: 'number', + columnType: 'number', + minimumFractionDigits: 1, + maximumFractionDigits: 1 + } + }; + + const result = formatChartValueDelimiter(rawValue, columnNameDelimiter, columnLabelFormats); + expect(result).toBe('1234.56'); + }); + + it('should return a valid dollar format', () => { + const rawValue = 3363690.3966666665; + const columnNameDelimiter = 'long_term_avg__🔑__'; + const columnLabelFormats: Record = { + month: { + style: 'date', + compactNumbers: false, + columnType: 'date', + displayName: '', + numberSeparatorStyle: ',', + minimumFractionDigits: 0, + maximumFractionDigits: 2, + currency: 'USD', + convertNumberTo: null, + dateFormat: 'MMM YYYY', + useRelativeTime: false, + isUTC: false, + multiplier: 1, + prefix: '', + suffix: '', + replaceMissingDataWith: null, + makeLabelHumanReadable: true + }, + recent_total: { + style: 'currency', + compactNumbers: false, + columnType: 'number', + displayName: '', + numberSeparatorStyle: ',', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + currency: 'USD', + convertNumberTo: null, + dateFormat: 'auto', + useRelativeTime: false, + isUTC: false, + multiplier: 1, + prefix: '', + suffix: '', + replaceMissingDataWith: 0, + makeLabelHumanReadable: true + }, + long_term_avg: { + style: 'currency', + compactNumbers: false, + columnType: 'number', + displayName: '', + numberSeparatorStyle: ',', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + currency: 'USD', + convertNumberTo: null, + dateFormat: 'auto', + useRelativeTime: false, + isUTC: false, + multiplier: 1, + prefix: '', + suffix: '', + replaceMissingDataWith: 0, + makeLabelHumanReadable: true + } + }; + + const result = formatChartValueDelimiter(rawValue, columnNameDelimiter, columnLabelFormats); + expect(result).toBe('$3,363,690.40'); + }); +}); diff --git a/web/src/layouts/ChatLayout/ChatLayoutContext/helpers.ts b/web/src/layouts/ChatLayout/ChatLayoutContext/helpers.ts index 2178a3682..f33f50a6a 100644 --- a/web/src/layouts/ChatLayout/ChatLayoutContext/helpers.ts +++ b/web/src/layouts/ChatLayout/ChatLayoutContext/helpers.ts @@ -22,7 +22,7 @@ const chatRouteRecord: Record }), metric: (chatId, assetId) => createBusterRoute({ - route: BusterRoutes.APP_CHAT_ID_METRIC_ID, + route: BusterRoutes.APP_CHAT_ID_METRIC_ID_CHART, chatId, metricId: assetId }), diff --git a/web/src/lib/columnFormatter.test.ts b/web/src/lib/columnFormatter.test.ts index 6310d75f9..8ed6dc8b0 100644 --- a/web/src/lib/columnFormatter.test.ts +++ b/web/src/lib/columnFormatter.test.ts @@ -1,3 +1,4 @@ +import { ColumnLabelFormat } from '@/api/asset_interfaces/metric'; import { formatLabel } from './columnFormatter'; describe('formatLabel', () => { @@ -133,6 +134,32 @@ describe('formatLabel', () => { }) ).toBe(''); }); + + it('should handle complex dollar formatting', () => { + const rawValue = 3363690.3966666665; + const config = { + style: 'currency', + compactNumbers: false, + columnType: 'number', + displayName: '', + numberSeparatorStyle: ',', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + currency: 'USD', + convertNumberTo: null, + dateFormat: 'auto', + useRelativeTime: false, + isUTC: false, + multiplier: 1, + prefix: '', + suffix: '', + replaceMissingDataWith: 0, + makeLabelHumanReadable: true + } satisfies ColumnLabelFormat; + + const result = formatLabel(rawValue, config); + expect(result).toBe('$3,363,690.40'); + }); }); describe('string formatting', () => { diff --git a/web/src/lib/numbers.test.ts b/web/src/lib/numbers.test.ts new file mode 100644 index 000000000..4cfe50bb6 --- /dev/null +++ b/web/src/lib/numbers.test.ts @@ -0,0 +1,60 @@ +import { formatNumber } from './numbers'; + +describe('formatNumber', () => { + test('handles null and undefined values', () => { + expect(formatNumber(null)).toBe(''); + expect(formatNumber(undefined)).toBe(''); + }); + + test('formats basic numbers', () => { + expect(formatNumber(1234.5678)).toBe('1,234.568'); + expect(formatNumber(1000)).toBe('1,000'); + expect(formatNumber(0)).toBe('0'); + }); + + test('respects decimal places settings', () => { + expect(formatNumber(1234.5678, { minDecimals: 3 })).toBe('1,234.568'); + expect(formatNumber(1234.5, { minDecimals: 3 })).toBe('1,234.500'); + expect(formatNumber(1234.5678, { maximumDecimals: 1 })).toBe('1,234.6'); + }); + + test('handles compact notation', () => { + expect(formatNumber(1_000_000, { compact: true })).toBe('1M'); + expect(formatNumber(1_500_000, { compact: true })).toBe('1.5M'); + expect(formatNumber(1_000, { compact: true })).toBe('1K'); + }); + + test('handles different locales', () => { + expect(formatNumber(1234.56, { locale: 'de-DE' })).toBe('1.234,56'); + expect(formatNumber(1234.56, { locale: 'fr-FR' })).toBe('1 234,56'); + }); + + test('handles currency formatting', () => { + expect(formatNumber(1234.56, { currency: 'USD' })).toBe('$1,234.56'); + expect(formatNumber(1234.56, { currency: 'EUR' })).toBe('€1,234.56'); + }); + + test('handles string inputs', () => { + expect(formatNumber('1234.56')).toBe('1,234.56'); + expect(formatNumber('1000')).toBe('1,000'); + }); + + test('handles non-numeric strings', () => { + expect(formatNumber('not a number')).toBe('not a number'); + expect(formatNumber('')).toBe(''); + }); + + test('handles grouping options', () => { + expect(formatNumber(1234567, { useGrouping: false })).toBe('1234567'); + expect(formatNumber(1234567, { useGrouping: true })).toBe('1,234,567'); + }); + + test('handles compact dollar', () => { + const roundedNumber = 3363690.4; + const currency = 'USD'; + const compactNumbers = false; + expect(formatNumber(roundedNumber, { currency, compact: compactNumbers })).toBe( + '$3,363,690.40' + ); + }); +}); diff --git a/web/src/lib/numbers.ts b/web/src/lib/numbers.ts index bd0b86482..f940a5bcd 100644 --- a/web/src/lib/numbers.ts +++ b/web/src/lib/numbers.ts @@ -60,12 +60,13 @@ export const formatNumber = ( ); const formatter = new Intl.NumberFormat(locale, { - ...options, - minimumFractionDigits: options?.minDecimals || options?.minimumFractionDigits || 0, + minimumFractionDigits: options?.minDecimals, maximumFractionDigits: maxFractionDigits, - notation: options?.compact ? 'compact' : undefined, + notation: options?.compact ? 'compact' : 'standard', compactDisplay: 'short', - style: options?.currency ? 'currency' : undefined + style: options?.currency ? 'currency' : 'decimal', + currency: options?.currency, + useGrouping: options?.useGrouping !== false }); return formatter.format(Number(value));