From f4474b2e3e97b0dafbfdcb9fa5ca5850c432efcc Mon Sep 17 00:00:00 2001 From: Richard Fogaca Nienkotter <63572350+richardfogaca@users.noreply.github.com> Date: Sat, 17 Jan 2026 02:58:41 -0300 Subject: [PATCH] feat: Dynamic currency (#36416) --- .../src/shared-controls/sharedControls.tsx | 3 + .../superset-ui-chart-controls/src/types.ts | 1 + .../src/currency-format/CurrencyFormats.ts | 22 ++ .../src/currency-format/CurrencyFormatter.ts | 88 ++++- .../src/currency-format/index.ts | 10 +- .../src/currency-format/types.ts | 29 ++ .../src/currency-format/utils.ts | 129 ++++++- .../src/query/types/Datasource.ts | 1 + .../src/query/types/QueryResponse.ts | 6 + .../currency-format/CurrencyFormatter.test.ts | 31 ++ .../analyzeCurrencyInData.test.ts | 33 ++ .../hasMixedCurrencies.test.ts | 32 ++ .../currency-format/normalizeCurrency.test.ts | 34 ++ .../src/transformProps.js | 13 +- .../transformProps.ts | 13 +- .../BigNumberTotal/transformProps.ts | 16 +- .../BigNumberWithTrendline/transformProps.ts | 11 +- .../src/BigNumber/types.ts | 2 +- .../src/Funnel/transformProps.ts | 11 +- .../src/Gauge/transformProps.ts | 6 + .../src/Heatmap/transformProps.ts | 17 +- .../src/MixedTimeseries/transformProps.ts | 36 +- .../src/Pie/transformProps.ts | 13 +- .../src/Sunburst/transformProps.ts | 11 +- .../src/Timeseries/transformProps.ts | 29 +- .../src/Treemap/transformProps.ts | 12 +- .../test/BigNumber/transformProps.test.ts | 56 +++ .../test/Timeseries/transformProps.test.ts | 132 +++++++ .../src/PivotTableChart.tsx | 124 ++++++- .../src/plugin/transformProps.ts | 18 +- .../src/react-pivottable/TableRenderers.jsx | 8 +- .../src/react-pivottable/utilities.js | 56 ++- .../plugin-chart-pivot-table/src/types.ts | 2 + .../test/plugin/transformProps.test.ts | 189 ++++++++++ .../plugin-chart-table/src/TableChart.tsx | 2 +- .../plugin-chart-table/src/transformProps.ts | 35 +- .../plugins/plugin-chart-table/src/types.ts | 1 + .../src/utils/formatValue.ts | 11 +- .../src/utils/isEqualColumns.ts | 3 + .../test/utils/formatValue.test.ts | 155 ++++++++ .../Datasource/DatasourceModal/index.tsx | 1 + .../DatasourceEditor/DatasourceEditor.jsx | 183 ++++++---- .../tests/DatasourceEditor.test.tsx | 44 ++- .../tests/DatasourceEditorCurrency.test.tsx | 67 ++++ .../ColumnConfigControl.tsx | 2 + .../ColumnConfigControl/constants.tsx | 2 +- .../CurrencyControl/CurrencyControl.test.tsx | 38 ++ .../CurrencyControl/CurrencyControl.tsx | 82 ++++- .../src/features/datasets/types.ts | 1 + superset/charts/schemas.py | 9 + superset/cli/examples.py | 3 + superset/common/query_actions.py | 65 ++++ superset/common/query_context_factory.py | 34 ++ superset/connectors/sqla/models.py | 6 +- superset/dashboards/schemas.py | 1 + superset/datasets/api.py | 2 + superset/datasets/schemas.py | 2 + superset/examples/data_loading.py | 2 + superset/examples/international_sales.py | 238 +++++++++++++ ...787190b3d89_add_currency_column_support.py | 48 +++ superset/superset_typing.py | 2 + superset/utils/currency.py | 160 +++++++++ superset/views/core.py | 21 +- superset/viz.py | 1 + .../datasets/commands_tests.py | 3 + .../fixtures/importexport.py | 1 + .../common/test_query_actions_currency.py | 290 +++++++++++++++ .../common/test_query_context_factory.py | 106 ++++++ .../unit_tests/connectors/sqla/models_test.py | 64 ++++ .../datasets/commands/export_test.py | 1 + tests/unit_tests/datasets/schema_tests.py | 29 ++ tests/unit_tests/utils/currency_test.py | 332 ++++++++++++++++++ 72 files changed, 3068 insertions(+), 173 deletions(-) create mode 100644 superset-frontend/packages/superset-ui-core/src/currency-format/CurrencyFormats.ts create mode 100644 superset-frontend/packages/superset-ui-core/src/currency-format/types.ts create mode 100644 superset-frontend/packages/superset-ui-core/test/currency-format/analyzeCurrencyInData.test.ts create mode 100644 superset-frontend/packages/superset-ui-core/test/currency-format/hasMixedCurrencies.test.ts create mode 100644 superset-frontend/packages/superset-ui-core/test/currency-format/normalizeCurrency.test.ts create mode 100644 superset-frontend/plugins/plugin-chart-table/test/utils/formatValue.test.ts create mode 100644 superset-frontend/src/explore/components/controls/CurrencyControl/CurrencyControl.test.tsx create mode 100644 superset/examples/international_sales.py create mode 100644 superset/migrations/versions/2025-11-18_14-00_9787190b3d89_add_currency_column_support.py create mode 100644 superset/utils/currency.py create mode 100644 tests/unit_tests/common/test_query_actions_currency.py create mode 100644 tests/unit_tests/utils/currency_test.py diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/sharedControls.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/sharedControls.tsx index 0dc760c3cb5..0b9953b95a0 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/sharedControls.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/sharedControls.tsx @@ -325,6 +325,9 @@ const currency_format: SharedControlConfig<'CurrencyControl'> = { type: 'CurrencyControl', label: t('Currency format'), renderTrigger: true, + description: t( + "Format metrics or columns with currency symbols as prefixes or suffixes. Choose a symbol manually or use 'Auto-detect' to apply the correct symbol based on the dataset's currency code column. When multiple currencies are present, formatting falls back to neutral numbers.", + ), }; const x_axis_time_format: SharedControlConfig< diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts index 115eb0b65b6..ffb32bf9d28 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts @@ -72,6 +72,7 @@ export interface Dataset { currency_formats?: Record; verbose_map: Record; main_dttm_col: string; + currency_code_column?: string; // eg. ['["ds", true]', 'ds [asc]'] order_by_choices?: [string, string][] | null; time_grain_sqla?: [string, string][]; diff --git a/superset-frontend/packages/superset-ui-core/src/currency-format/CurrencyFormats.ts b/superset-frontend/packages/superset-ui-core/src/currency-format/CurrencyFormats.ts new file mode 100644 index 00000000000..17f257112ad --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/currency-format/CurrencyFormats.ts @@ -0,0 +1,22 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const AUTO_CURRENCY_SYMBOL = 'AUTO'; + +export const ISO_4217_REGEX = /^[A-Z]{3}$/; diff --git a/superset-frontend/packages/superset-ui-core/src/currency-format/CurrencyFormatter.ts b/superset-frontend/packages/superset-ui-core/src/currency-format/CurrencyFormatter.ts index 9dd503cea6b..a7997879c59 100644 --- a/superset-frontend/packages/superset-ui-core/src/currency-format/CurrencyFormatter.ts +++ b/superset-frontend/packages/superset-ui-core/src/currency-format/CurrencyFormatter.ts @@ -20,6 +20,8 @@ import { ExtensibleFunction } from '../models'; import { getNumberFormatter, NumberFormats } from '../number-format'; import { Currency } from '../query'; +import { RowData, RowDataValue } from './types'; +import { AUTO_CURRENCY_SYMBOL, ISO_4217_REGEX } from './CurrencyFormats'; /* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ @@ -30,7 +32,11 @@ interface CurrencyFormatterConfig { } interface CurrencyFormatter { - (value: number | null | undefined): string; + ( + value: number | null | undefined, + rowData?: RowData, + currencyColumn?: string, + ): string; } export const getCurrencySymbol = (currency: Partial) => @@ -41,6 +47,32 @@ export const getCurrencySymbol = (currency: Partial) => .formatToParts(1) .find(x => x.type === 'currency')?.value; +export function normalizeCurrency(value: RowDataValue): string | null { + if (value === null || value === undefined) return null; + if (typeof value !== 'string') return null; + + const normalized = value.trim().toUpperCase(); + + return ISO_4217_REGEX.test(normalized) ? normalized : null; +} + +export function hasMixedCurrencies(currencies: RowDataValue[]): boolean { + let first: string | null = null; + + for (const c of currencies) { + const normalized = normalizeCurrency(c); + if (normalized === null) continue; + + if (first === null) { + first = normalized; + } else if (normalized !== first) { + return true; + } + } + + return false; +} + class CurrencyFormatter extends ExtensibleFunction { d3Format: string; @@ -49,7 +81,9 @@ class CurrencyFormatter extends ExtensibleFunction { currency: Currency; constructor(config: CurrencyFormatterConfig) { - super((value: number) => this.format(value)); + super((value: number, rowData?: RowData, currencyColumn?: string) => + this.format(value, rowData, currencyColumn), + ); this.d3Format = config.d3Format || NumberFormats.SMART_NUMBER; this.currency = config.currency; this.locale = config.locale || 'en-US'; @@ -67,19 +101,59 @@ class CurrencyFormatter extends ExtensibleFunction { return value.replace(/%/g, ''); } - format(value: number) { + format(value: number, rowData?: RowData, currencyColumn?: string): string { const formattedValue = getNumberFormatter(this.getNormalizedD3Format())( value, ); - if (!this.hasValidCurrency()) { + + const isAutoMode = this.currency?.symbol === AUTO_CURRENCY_SYMBOL; + + if (!this.hasValidCurrency() && !isAutoMode) { return formattedValue as string; } + // Remove % signs from formatted value for currency display const normalizedValue = this.normalizeForCurrency(formattedValue); - if (this.currency.symbolPosition === 'prefix') { - return `${getCurrencySymbol(this.currency)} ${normalizedValue}`; + + if (isAutoMode) { + if (rowData && currencyColumn && rowData[currencyColumn]) { + const rawCurrency = rowData[currencyColumn]; + const normalizedCurrency = normalizeCurrency(rawCurrency); + + if (normalizedCurrency) { + try { + const symbol = getCurrencySymbol({ symbol: normalizedCurrency }); + if (symbol) { + if (this.currency.symbolPosition === 'prefix') { + return `${symbol} ${normalizedValue}`; + } else if (this.currency.symbolPosition === 'suffix') { + return `${normalizedValue} ${symbol}`; + } + // Unknown symbolPosition - default to suffix + return `${normalizedValue} ${symbol}`; + } + } catch { + // Invalid currency code - return value without currency symbol + return formattedValue; + } + } + } + return formattedValue; + } + + try { + const symbol = getCurrencySymbol(this.currency); + if (this.currency.symbolPosition === 'prefix') { + return `${symbol} ${normalizedValue}`; + } else if (this.currency.symbolPosition === 'suffix') { + return `${normalizedValue} ${symbol}`; + } + // Unknown symbolPosition - default to suffix + return `${normalizedValue} ${symbol}`; + } catch { + // Invalid currency code - return value without currency symbol + return formattedValue; } - return `${normalizedValue} ${getCurrencySymbol(this.currency)}`; } } diff --git a/superset-frontend/packages/superset-ui-core/src/currency-format/index.ts b/superset-frontend/packages/superset-ui-core/src/currency-format/index.ts index 45fa851e88c..8f696f40cb9 100644 --- a/superset-frontend/packages/superset-ui-core/src/currency-format/index.ts +++ b/superset-frontend/packages/superset-ui-core/src/currency-format/index.ts @@ -1,4 +1,4 @@ -/* +/** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information @@ -18,5 +18,11 @@ */ export { default as CurrencyFormatter } from './CurrencyFormatter'; -export * from './CurrencyFormatter'; +export { + getCurrencySymbol, + normalizeCurrency, + hasMixedCurrencies, +} from './CurrencyFormatter'; +export { AUTO_CURRENCY_SYMBOL, ISO_4217_REGEX } from './CurrencyFormats'; +export * from './types'; export * from './utils'; diff --git a/superset-frontend/packages/superset-ui-core/src/currency-format/types.ts b/superset-frontend/packages/superset-ui-core/src/currency-format/types.ts new file mode 100644 index 00000000000..882fca6f42a --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/currency-format/types.ts @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export type RowDataValue = + | string + | number + | boolean + | Date + | bigint + | null + | undefined; + +export type RowData = Record; diff --git a/superset-frontend/packages/superset-ui-core/src/currency-format/utils.ts b/superset-frontend/packages/superset-ui-core/src/currency-format/utils.ts index 14228ea77fa..53a704ab97f 100644 --- a/superset-frontend/packages/superset-ui-core/src/currency-format/utils.ts +++ b/superset-frontend/packages/superset-ui-core/src/currency-format/utils.ts @@ -25,21 +25,104 @@ import { QueryFormMetric, ValueFormatter, } from '@superset-ui/core'; +import { normalizeCurrency, hasMixedCurrencies } from './CurrencyFormatter'; +import { RowData, RowDataValue } from './types'; +import { AUTO_CURRENCY_SYMBOL } from './CurrencyFormats'; + +export const analyzeCurrencyInData = ( + data: RowData[], + currencyColumn: string | undefined, +): string | null => { + if (!currencyColumn || !data || data.length === 0) { + return null; + } + + const currencies: RowDataValue[] = data + .map(row => row[currencyColumn]) + .filter(val => val !== null && val !== undefined); + + if (currencies.length === 0) { + return null; + } + + if (hasMixedCurrencies(currencies)) { + return null; + } + + return normalizeCurrency(currencies[0]); +}; + +export const resolveAutoCurrency = ( + currencyFormat: Currency | undefined, + backendDetected: string | null | undefined, + data?: RowData[], + currencyCodeColumn?: string, +): Currency | undefined | null => { + if (currencyFormat?.symbol !== AUTO_CURRENCY_SYMBOL) return currencyFormat; + + const detectedCurrency = + backendDetected ?? + (data && currencyCodeColumn + ? analyzeCurrencyInData(data, currencyCodeColumn) + : null); + + if (detectedCurrency) { + return { + symbol: detectedCurrency, + symbolPosition: currencyFormat.symbolPosition, + }; + } + return null; // Mixed currencies +}; + +const getEffectiveCurrencyFormat = ( + resolvedCurrencyFormat: Currency | undefined | null, + savedFormat: Currency | undefined, +): Currency | undefined => { + if (resolvedCurrencyFormat === null) { + return undefined; + } + if (resolvedCurrencyFormat?.symbol) { + return resolvedCurrencyFormat; + } + return savedFormat; +}; export const buildCustomFormatters = ( metrics: QueryFormMetric | QueryFormMetric[] | undefined, savedCurrencyFormats: Record, savedColumnFormats: Record, d3Format: string | undefined, - currencyFormat: Currency | undefined, + currencyFormat: Currency | undefined | null, + data?: RowData[], + currencyCodeColumn?: string, ) => { const metricsArray = ensureIsArray(metrics); + + let resolvedCurrencyFormat = currencyFormat; + if ( + currencyFormat?.symbol === AUTO_CURRENCY_SYMBOL && + data && + currencyCodeColumn + ) { + const detectedCurrency = analyzeCurrencyInData(data, currencyCodeColumn); + if (detectedCurrency) { + resolvedCurrencyFormat = { + symbol: detectedCurrency, + symbolPosition: currencyFormat.symbolPosition, + }; + } else { + resolvedCurrencyFormat = null; + } + } + return metricsArray.reduce((acc, metric) => { if (isSavedMetric(metric)) { const actualD3Format = d3Format ?? savedColumnFormats[metric]; - const actualCurrencyFormat = currencyFormat?.symbol - ? currencyFormat - : savedCurrencyFormats[metric]; + const actualCurrencyFormat = getEffectiveCurrencyFormat( + resolvedCurrencyFormat, + savedCurrencyFormats[metric], + ); return actualCurrencyFormat?.symbol ? { ...acc, @@ -76,14 +159,40 @@ export const getValueFormatter = ( d3Format: string | undefined, currencyFormat: Currency | undefined, key?: string, + data?: RowData[], + currencyCodeColumn?: string, + detectedCurrency?: string | null, ) => { + let resolvedCurrencyFormat: Currency | undefined | null = currencyFormat; + if (currencyFormat?.symbol === AUTO_CURRENCY_SYMBOL) { + // Use backend-detected currency, or fallback to frontend analysis + if (detectedCurrency !== undefined) { + resolvedCurrencyFormat = detectedCurrency + ? { + symbol: detectedCurrency, + symbolPosition: currencyFormat.symbolPosition, + } + : null; + } else if (data && currencyCodeColumn) { + const frontendDetected = analyzeCurrencyInData(data, currencyCodeColumn); + resolvedCurrencyFormat = frontendDetected + ? { + symbol: frontendDetected, + symbolPosition: currencyFormat.symbolPosition, + } + : null; + } else { + resolvedCurrencyFormat = null; + } + } + const customFormatter = getCustomFormatter( buildCustomFormatters( metrics, savedCurrencyFormats, savedColumnFormats, d3Format, - currencyFormat, + resolvedCurrencyFormat, ), metrics, key, @@ -92,8 +201,14 @@ export const getValueFormatter = ( if (customFormatter) { return customFormatter; } - if (currencyFormat?.symbol) { - return new CurrencyFormatter({ currency: currencyFormat, d3Format }); + if (resolvedCurrencyFormat === null) { + return getNumberFormatter(d3Format); + } + if (resolvedCurrencyFormat?.symbol) { + return new CurrencyFormatter({ + currency: resolvedCurrencyFormat, + d3Format, + }); } return getNumberFormatter(d3Format); }; diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts index c5ce93c1e91..47902cf07ae 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts @@ -53,6 +53,7 @@ export interface Datasource { verboseMap?: { [key: string]: string; }; + currencyCodeColumn?: string; } export const DEFAULT_METRICS: Metric[] = [ diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/QueryResponse.ts b/superset-frontend/packages/superset-ui-core/src/query/types/QueryResponse.ts index 71aef8a75c5..a1786da7389 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/types/QueryResponse.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/types/QueryResponse.ts @@ -77,6 +77,12 @@ export interface ChartDataResponseResult { // TODO(hainenber): define proper type for below attributes rejected_filters?: any[]; applied_filters?: any[]; + /** + * Detected ISO 4217 currency code when AUTO mode is used. + * Returns the currency code if all filtered data contains a single currency, + * or null if multiple currencies are present. + */ + detected_currency?: string | null; } export interface TimeseriesChartDataResponseResult extends ChartDataResponseResult { diff --git a/superset-frontend/packages/superset-ui-core/test/currency-format/CurrencyFormatter.test.ts b/superset-frontend/packages/superset-ui-core/test/currency-format/CurrencyFormatter.test.ts index 8f158865769..979c4c1d612 100644 --- a/superset-frontend/packages/superset-ui-core/test/currency-format/CurrencyFormatter.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/currency-format/CurrencyFormatter.test.ts @@ -156,3 +156,34 @@ test('CurrencyFormatter:format', () => { }); expect(currencyFormatterWithCurrencyD3(VALUE)).toEqual('56,100,057.0 PLN'); }); + +test('CurrencyFormatter AUTO mode uses row context', () => { + const formatter = new CurrencyFormatter({ + currency: { symbol: 'AUTO', symbolPosition: 'prefix' }, + d3Format: ',.2f', + }); + + const row = { currency: 'EUR' }; + expect(formatter.format(1000, row, 'currency')).toContain('€'); + expect(formatter.format(1000)).toBe('1,000.00'); +}); + +test('CurrencyFormatter static mode ignores row context', () => { + const formatter = new CurrencyFormatter({ + currency: { symbol: 'USD', symbolPosition: 'prefix' }, + d3Format: ',.2f', + }); + + const row = { currency: 'EUR' }; + expect(formatter.format(1000, row, 'currency')).toContain('$'); +}); + +test('CurrencyFormatter gracefully handles invalid currency code', () => { + const formatter = new CurrencyFormatter({ + currency: { symbol: 'INVALID_CODE', symbolPosition: 'prefix' }, + d3Format: ',.2f', + }); + + // Should not throw, should return formatted value without currency symbol + expect(formatter.format(1000)).toBe('1,000.00'); +}); diff --git a/superset-frontend/packages/superset-ui-core/test/currency-format/analyzeCurrencyInData.test.ts b/superset-frontend/packages/superset-ui-core/test/currency-format/analyzeCurrencyInData.test.ts new file mode 100644 index 00000000000..ababbf44470 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/test/currency-format/analyzeCurrencyInData.test.ts @@ -0,0 +1,33 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { analyzeCurrencyInData } from '../../src/currency-format/utils'; + +test('analyzeCurrencyInData returns currency code for single currency', () => { + const data = [ + { currency_code: 'USD', value: 100 }, + { currency_code: 'usd', value: 200 }, + ]; + expect(analyzeCurrencyInData(data, 'currency_code')).toBe('USD'); +}); + +test('analyzeCurrencyInData returns null for mixed or invalid data', () => { + expect(analyzeCurrencyInData([], 'currency_code')).toBeNull(); + expect(analyzeCurrencyInData([{ c: 'USD' }], undefined)).toBeNull(); + expect(analyzeCurrencyInData([{ c: 'USD' }, { c: 'EUR' }], 'c')).toBeNull(); +}); diff --git a/superset-frontend/packages/superset-ui-core/test/currency-format/hasMixedCurrencies.test.ts b/superset-frontend/packages/superset-ui-core/test/currency-format/hasMixedCurrencies.test.ts new file mode 100644 index 00000000000..1cdd9eda163 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/test/currency-format/hasMixedCurrencies.test.ts @@ -0,0 +1,32 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { hasMixedCurrencies } from '../../src/currency-format/CurrencyFormatter'; + +test('hasMixedCurrencies detects mixed vs single currency', () => { + expect(hasMixedCurrencies(['USD', 'EUR'])).toBe(true); + expect(hasMixedCurrencies(['USD', 'usd'])).toBe(false); + expect(hasMixedCurrencies(['USD'])).toBe(false); + expect(hasMixedCurrencies([])).toBe(false); +}); + +test('hasMixedCurrencies ignores null values', () => { + expect(hasMixedCurrencies(['USD', null, 'USD'])).toBe(false); + expect(hasMixedCurrencies(['USD', null, 'EUR'])).toBe(true); +}); diff --git a/superset-frontend/packages/superset-ui-core/test/currency-format/normalizeCurrency.test.ts b/superset-frontend/packages/superset-ui-core/test/currency-format/normalizeCurrency.test.ts new file mode 100644 index 00000000000..9048a9bb045 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/test/currency-format/normalizeCurrency.test.ts @@ -0,0 +1,34 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { normalizeCurrency } from '../../src/currency-format/CurrencyFormatter'; + +test('normalizeCurrency normalizes valid ISO 4217 codes', () => { + expect(normalizeCurrency('USD')).toBe('USD'); + expect(normalizeCurrency('usd')).toBe('USD'); + expect(normalizeCurrency(' eur ')).toBe('EUR'); +}); + +test('normalizeCurrency returns null for invalid input', () => { + expect(normalizeCurrency(null)).toBe(null); + expect(normalizeCurrency('')).toBe(null); + expect(normalizeCurrency('$')).toBe(null); + expect(normalizeCurrency('DOLLAR')).toBe(null); + expect(normalizeCurrency('USDD')).toBe(null); +}); diff --git a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/transformProps.js b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/transformProps.js index 4069e294289..92be0f8b01c 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/transformProps.js +++ b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/transformProps.js @@ -47,7 +47,12 @@ export default function transformProps(chartProps) { currencyFormat, } = formData; const { r, g, b } = colorPicker; - const { currencyFormats = {}, columnFormats = {} } = datasource; + const { + currencyFormats = {}, + columnFormats = {}, + currencyCodeColumn, + } = datasource; + const { data, detected_currency: detectedCurrency } = queriesData[0]; const formatter = getValueFormatter( metric, @@ -55,12 +60,16 @@ export default function transformProps(chartProps) { columnFormats, yAxisFormat, currencyFormat, + undefined, // key - not needed for single-metric charts + data, + currencyCodeColumn, + detectedCurrency, ); return { countryFieldtype, entity, - data: queriesData[0].data, + data, width, height, maxBubbleSize: parseInt(maxBubbleSize, 10), diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/transformProps.ts index 647b6b1aeda..b610d5b35de 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/transformProps.ts @@ -82,7 +82,11 @@ export default function transformProps(chartProps: ChartProps) { height, formData, queriesData, - datasource: { currencyFormats = {}, columnFormats = {} }, + datasource: { + currencyFormats = {}, + columnFormats = {}, + currencyCodeColumn, + }, } = chartProps; const { boldText, @@ -100,7 +104,8 @@ export default function transformProps(chartProps: ChartProps) { subtitleFontSize, columnConfig = {}, } = formData; - const { data: dataA = [] } = queriesData[0]; + const { data: dataA = [], detected_currency: detectedCurrency } = + queriesData[0] || {}; const data = dataA; const metricName = metric ? getMetricLabel(metric) : ''; const metrics = chartProps.datasource?.metrics || []; @@ -162,6 +167,10 @@ export default function transformProps(chartProps: ChartProps) { columnFormats, metricEntry?.d3format || yAxisFormat, currencyFormat, + undefined, + data, + currencyCodeColumn, + detectedCurrency, ); const compTitles = { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/transformProps.ts index 6181ca23f64..ef9501fe9af 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/transformProps.ts @@ -42,7 +42,11 @@ export default function transformProps( formData, rawFormData, hooks, - datasource: { currencyFormats = {}, columnFormats = {} }, + datasource: { + currencyFormats = {}, + columnFormats = {}, + currencyCodeColumn, + }, theme, } = chartProps; const { @@ -60,7 +64,11 @@ export default function transformProps( subheaderFontSize, } = formData; const refs: Refs = {}; - const { data = [], coltypes = [] } = queriesData[0] || {}; + const { + data = [], + coltypes = [], + detected_currency: detectedCurrency, + } = queriesData[0] || {}; const granularity = extractTimegrain(rawFormData as QueryFormData); const metrics = chartProps.datasource?.metrics || []; const originalLabel = getOriginalLabel(metric, metrics); @@ -92,6 +100,10 @@ export default function transformProps( columnFormats, metricEntry?.d3format || yAxisFormat, currencyFormat, + undefined, + data, + currencyCodeColumn, + detectedCurrency, ); const headerFormatter = diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts index c9f18207d96..7b3337fdb11 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts @@ -83,7 +83,11 @@ export default function transformProps( hooks, inContextMenu, theme, - datasource: { currencyFormats = {}, columnFormats = {} }, + datasource: { + currencyFormats = {}, + columnFormats = {}, + currencyCodeColumn, + }, } = chartProps; const { colorPicker, @@ -117,6 +121,7 @@ export default function transformProps( coltypes = [], from_dttm: fromDatetime, to_dttm: toDatetime, + detected_currency: detectedCurrency, } = queriesData[0]; const aggregatedQueryData = queriesData.length > 1 ? queriesData[1] : null; @@ -259,6 +264,10 @@ export default function transformProps( columnFormats, metricEntry?.d3format || yAxisFormat, currencyFormat, + undefined, + data, + currencyCodeColumn, + detectedCurrency, ); const xAxisFormatter = getXAxisFormatter(timeFormat); const yAxisFormatter = diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/types.ts index 394c7b1c18c..e1f115c28b8 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/types.ts @@ -31,7 +31,7 @@ import { ColorFormatters } from '@superset-ui/chart-controls'; import { BaseChartProps, Refs } from '../types'; export interface BigNumberDatum { - [key: string]: number | null; + [key: string]: number | string | null; } export type BigNumberTotalFormData = QueryFormData & { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/transformProps.ts index 7245f670244..fd3cce40c08 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/transformProps.ts @@ -99,6 +99,7 @@ export default function transformProps( datasource, } = chartProps; const data: DataRecord[] = queriesData[0].data || []; + const detectedCurrency = queriesData[0]?.detected_currency; const coltypeMapping = getColtypesMapping(queriesData[0]); const { colorScheme, @@ -127,7 +128,11 @@ export default function transformProps( ...DEFAULT_FUNNEL_FORM_DATA, ...formData, }; - const { currencyFormats = {}, columnFormats = {} } = datasource; + const { + currencyFormats = {}, + columnFormats = {}, + currencyCodeColumn, + } = datasource; const refs: Refs = {}; const metricLabel = getMetricLabel(metric); const groupbyLabels = groupby.map(getColumnLabel); @@ -154,6 +159,10 @@ export default function transformProps( columnFormats, numberFormat, currencyFormat, + undefined, + data, + currencyCodeColumn, + detectedCurrency, ); const transformedData: { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/transformProps.ts index 79075b35dbb..cd4f2610f7d 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/transformProps.ts @@ -112,6 +112,7 @@ export default function transformProps( verboseMap = {}, currencyFormats = {}, columnFormats = {}, + currencyCodeColumn, } = datasource; const { groupby, @@ -139,6 +140,7 @@ export default function transformProps( }: EchartsGaugeFormData = { ...DEFAULT_GAUGE_FORM_DATA, ...formData }; const refs: Refs = {}; const data = (queriesData[0]?.data || []) as DataRecord[]; + const detectedCurrency = queriesData[0]?.detected_currency; const coltypeMapping = getColtypesMapping(queriesData[0]); const numberFormatter = getValueFormatter( metric, @@ -146,6 +148,10 @@ export default function transformProps( columnFormats, numberFormat, currencyFormat, + undefined, + data, + currencyCodeColumn, + detectedCurrency, ); const colorFn = CategoricalColorNamespace.getScale(colorScheme as string); const axisLineWidth = calculateAxisLineWidth(data, fontSize, overlap); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/transformProps.ts index 211b1170c63..118082015cd 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/transformProps.ts @@ -201,8 +201,17 @@ export default function transformProps( const xAxisLabel = getColumnLabel(xAxis); // groupby is overridden to be a single value const yAxisLabel = getColumnLabel(groupby as unknown as QueryFormColumn); - const { data, colnames, coltypes } = queriesData[0]; - const { columnFormats = {}, currencyFormats = {} } = datasource; + const { + data, + colnames, + coltypes, + detected_currency: detectedCurrency, + } = queriesData[0]; + const { + columnFormats = {}, + currencyFormats = {}, + currencyCodeColumn, + } = datasource; const colorColumn = normalized ? 'rank' : metricLabel; const colors = getSequentialSchemeRegistry().get(linearColorScheme)?.colors; const getAxisFormatter = @@ -225,6 +234,10 @@ export default function transformProps( columnFormats, yAxisFormat, currencyFormat, + undefined, + data, + currencyCodeColumn, + detectedCurrency, ); let [min, max] = (valueBounds || []).map(parseAxisBound); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts index b25245fd9b8..ebc13fcb396 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts @@ -36,6 +36,7 @@ import { isTimeseriesAnnotationLayer, QueryFormData, QueryFormMetric, + resolveAutoCurrency, TimeseriesChartDataResponseResult, TimeseriesDataRecord, tooltipHtml, @@ -138,10 +139,11 @@ export default function transformProps( verboseMap = {}, currencyFormats = {}, columnFormats = {}, + currencyCodeColumn, } = datasource; - const { label_map: labelMap } = + const { label_map: labelMap, detected_currency: backendDetectedCurrency } = queriesData[0] as TimeseriesChartDataResponseResult; - const { label_map: labelMapB } = + const { label_map: labelMapB, detected_currency: backendDetectedCurrencyB } = queriesData[1] as TimeseriesChartDataResponseResult; const data1 = (queriesData[0].data || []) as TimeseriesDataRecord[]; const data2 = (queriesData[1].data || []) as TimeseriesDataRecord[]; @@ -279,20 +281,34 @@ export default function transformProps( xAxisType, }); const series: SeriesOption[] = []; + + const resolvedCurrency = resolveAutoCurrency( + currencyFormat, + backendDetectedCurrency, + data1, + currencyCodeColumn, + ); + const resolvedCurrencySecondary = resolveAutoCurrency( + currencyFormatSecondary, + backendDetectedCurrencyB, + data2, + currencyCodeColumn, + ); + const formatter = contributionMode ? getNumberFormatter(',.0%') - : currencyFormat?.symbol + : resolvedCurrency?.symbol ? new CurrencyFormatter({ d3Format: yAxisFormat, - currency: currencyFormat, + currency: resolvedCurrency, }) : getNumberFormatter(yAxisFormat); const formatterSecondary = contributionMode ? getNumberFormatter(',.0%') - : currencyFormatSecondary?.symbol + : resolvedCurrencySecondary?.symbol ? new CurrencyFormatter({ d3Format: yAxisFormatSecondary, - currency: currencyFormatSecondary, + currency: resolvedCurrencySecondary, }) : getNumberFormatter(yAxisFormatSecondary); const customFormatters = buildCustomFormatters( @@ -300,14 +316,18 @@ export default function transformProps( currencyFormats, columnFormats, yAxisFormat, - currencyFormat, + resolvedCurrency, + data1, + currencyCodeColumn, ); const customFormattersSecondary = buildCustomFormatters( [...ensureIsArray(metrics), ...ensureIsArray(metricsB)], currencyFormats, columnFormats, yAxisFormatSecondary, - currencyFormatSecondary, + resolvedCurrencySecondary, + data2, + currencyCodeColumn, ); const primarySeries = new Set(); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts index fa2972383a1..d33110f6ab3 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts @@ -135,8 +135,13 @@ export default function transformProps( emitCrossFilters, datasource, } = chartProps; - const { columnFormats = {}, currencyFormats = {} } = datasource; - const { data: rawData = [] } = queriesData[0]; + const { + columnFormats = {}, + currencyFormats = {}, + currencyCodeColumn, + } = datasource; + const { data: rawData = [], detected_currency: detectedCurrency } = + queriesData[0]; const coltypeMapping = getColtypesMapping(queriesData[0]); const { @@ -181,6 +186,10 @@ export default function transformProps( columnFormats, numberFormat, currencyFormat, + undefined, + rawData, + currencyCodeColumn, + detectedCurrency, ); let data = rawData; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/transformProps.ts index 6c5614e042a..f1861ab558e 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/transformProps.ts @@ -170,7 +170,7 @@ export default function transformProps( emitCrossFilters, datasource, } = chartProps; - const { data = [] } = queriesData[0]; + const { data = [], detected_currency: detectedCurrency } = queriesData[0]; const coltypeMapping = getColtypesMapping(queriesData[0]); const { groupby = [], @@ -192,6 +192,7 @@ export default function transformProps( currencyFormats = {}, columnFormats = {}, verboseMap = {}, + currencyCodeColumn, } = datasource; const refs: Refs = {}; const primaryValueFormatter = getValueFormatter( @@ -200,6 +201,10 @@ export default function transformProps( columnFormats, numberFormat, currencyFormat, + undefined, + data, + currencyCodeColumn, + detectedCurrency, ); const secondaryValueFormatter = secondaryMetric ? getValueFormatter( @@ -208,6 +213,10 @@ export default function transformProps( columnFormats, numberFormat, currencyFormat, + undefined, + data, + currencyCodeColumn, + detectedCurrency, ) : undefined; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts index d1675454fee..fe37eea0276 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -37,6 +37,7 @@ import { isIntervalAnnotationLayer, isPhysicalColumn, isTimeseriesAnnotationLayer, + resolveAutoCurrency, TimeseriesChartDataResponseResult, NumberFormats, } from '@superset-ui/core'; @@ -134,10 +135,14 @@ export default function transformProps( verboseMap = {}, columnFormats = {}, currencyFormats = {}, + currencyCodeColumn, } = datasource; const [queryData] = queriesData; - const { data = [], label_map = {} } = - queryData as TimeseriesChartDataResponseResult; + const { + data = [], + label_map = {}, + detected_currency: backendDetectedCurrency, + } = queryData as TimeseriesChartDataResponseResult; const dataTypes = getColtypesMapping(queryData); const annotationData = getAnnotationData(chartProps); @@ -275,15 +280,29 @@ export default function transformProps( const percentFormatter = forcePercentFormatter ? getPercentFormatter(yAxisFormat) : getPercentFormatter(NumberFormats.PERCENT_2_POINT); - const defaultFormatter = currencyFormat?.symbol - ? new CurrencyFormatter({ d3Format: yAxisFormat, currency: currencyFormat }) + + // Resolve currency for AUTO mode (backend detection takes precedence) + const resolvedCurrency = resolveAutoCurrency( + currencyFormat, + backendDetectedCurrency, + data, + currencyCodeColumn, + ); + + const defaultFormatter = resolvedCurrency?.symbol + ? new CurrencyFormatter({ + d3Format: yAxisFormat, + currency: resolvedCurrency, + }) : getNumberFormatter(yAxisFormat); const customFormatters = buildCustomFormatters( metrics, currencyFormats, columnFormats, yAxisFormat, - currencyFormat, + resolvedCurrency, + data, + currencyCodeColumn, ); const array = ensureIsArray(chartProps.rawFormData?.time_compare); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/transformProps.ts index 5ed0c565c3c..e4fe3053ee2 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/transformProps.ts @@ -119,8 +119,12 @@ export default function transformProps( emitCrossFilters, datasource, } = chartProps; - const { data = [] } = queriesData[0]; - const { columnFormats = {}, currencyFormats = {} } = datasource; + const { data = [], detected_currency: detectedCurrency } = queriesData[0]; + const { + columnFormats = {}, + currencyFormats = {}, + currencyCodeColumn, + } = datasource; const { setDataMask = () => {}, onContextMenu } = hooks; const coltypeMapping = getColtypesMapping(queriesData[0]); const BORDER_COLOR = theme.colorBgBase; @@ -150,6 +154,10 @@ export default function transformProps( columnFormats, numberFormat, currencyFormat, + undefined, + data, + currencyCodeColumn, + detectedCurrency, ); const formatter = (params: TreemapSeriesCallbackDataParams) => diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts index fc0b979e20e..cd1b00100eb 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts @@ -492,3 +492,59 @@ describe('BigNumberWithTrendline - Aggregation Tests', () => { expect(transformed.bigNumber).toStrictEqual(10); }); }); + +test('BigNumberWithTrendline AUTO mode should detect single currency', () => { + const props = generateProps( + [ + { __timestamp: 1607558400000, value: 1000, currency_code: 'USD' }, + { __timestamp: 1607558500000, value: 2000, currency_code: 'USD' }, + ], + { + yAxisFormat: ',.2f', + currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' }, + }, + ); + props.datasource.currencyCodeColumn = 'currency_code'; + + const transformed = transformProps(props); + // The headerFormatter should include $ for USD + expect(transformed.headerFormatter(1000)).toContain('$'); +}); + +test('BigNumberWithTrendline AUTO mode should use neutral formatting for mixed currencies', () => { + const props = generateProps( + [ + { __timestamp: 1607558400000, value: 1000, currency_code: 'USD' }, + { __timestamp: 1607558500000, value: 2000, currency_code: 'EUR' }, + ], + { + yAxisFormat: ',.2f', + currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' }, + }, + ); + props.datasource.currencyCodeColumn = 'currency_code'; + + const transformed = transformProps(props); + // With mixed currencies, should not show currency symbol + const formatted = transformed.headerFormatter(1000); + expect(formatted).not.toContain('$'); + expect(formatted).not.toContain('€'); +}); + +test('BigNumberWithTrendline should preserve static currency format', () => { + const props = generateProps( + [ + { __timestamp: 1607558400000, value: 1000, currency_code: 'USD' }, + { __timestamp: 1607558500000, value: 2000, currency_code: 'EUR' }, + ], + { + yAxisFormat: ',.2f', + currencyFormat: { symbol: 'GBP', symbolPosition: 'prefix' }, + }, + ); + props.datasource.currencyCodeColumn = 'currency_code'; + + const transformed = transformProps(props); + // Static mode should always show £ + expect(transformed.headerFormatter(1000)).toContain('£'); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts index 42ecb1803d3..b3ecf9a7ab1 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts @@ -31,6 +31,20 @@ import { supersetTheme } from '@apache-superset/core/ui'; import { EchartsTimeseriesChartProps } from '../../src/types'; import transformProps from '../../src/Timeseries/transformProps'; +type YAxisFormatter = (value: number, index: number) => string; + +function getYAxisFormatter( + transformed: ReturnType, +): YAxisFormatter { + const yAxis = transformed.echartOptions.yAxis as { + axisLabel?: { formatter?: YAxisFormatter }; + }; + expect(yAxis).toBeDefined(); + expect(yAxis.axisLabel).toBeDefined(); + expect(yAxis.axisLabel?.formatter).toBeDefined(); + return yAxis.axisLabel!.formatter!; +} + const formData: SqlaFormData = { colorScheme: 'bnbColors', datasource: '3__table', @@ -723,3 +737,121 @@ describe('legend sorting', () => { ]); }); }); + +test('EchartsTimeseries AUTO mode should detect single currency and format with $ for USD', () => { + const chartProps = new ChartProps({ + ...chartPropsConfig, + formData: { + ...formData, + metrics: ['sum__num'], + currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' }, + }, + datasource: { + currencyCodeColumn: 'currency_code', + columnFormats: {}, + currencyFormats: {}, + verboseMap: {}, + }, + queriesData: [ + { + data: [ + { + 'San Francisco': 1000, + __timestamp: 599616000000, + currency_code: 'USD', + }, + { + 'San Francisco': 2000, + __timestamp: 599916000000, + currency_code: 'USD', + }, + ], + }, + ], + }); + + const transformed = transformProps(chartProps as EchartsTimeseriesChartProps); + + const formatter = getYAxisFormatter(transformed); + expect(formatter(1000, 0)).toContain('$'); +}); + +test('EchartsTimeseries AUTO mode should use neutral formatting for mixed currencies', () => { + const chartProps = new ChartProps({ + ...chartPropsConfig, + formData: { + ...formData, + metrics: ['sum__num'], + currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' }, + }, + datasource: { + currencyCodeColumn: 'currency_code', + columnFormats: {}, + currencyFormats: {}, + verboseMap: {}, + }, + queriesData: [ + { + data: [ + { + 'San Francisco': 1000, + __timestamp: 599616000000, + currency_code: 'USD', + }, + { + 'San Francisco': 2000, + __timestamp: 599916000000, + currency_code: 'EUR', + }, + ], + }, + ], + }); + + const transformed = transformProps(chartProps as EchartsTimeseriesChartProps); + + // With mixed currencies, Y-axis should use neutral formatting + const formatter = getYAxisFormatter(transformed); + const formatted = formatter(1000, 0); + expect(formatted).not.toContain('$'); + expect(formatted).not.toContain('€'); +}); + +test('EchartsTimeseries should preserve static currency format with £ for GBP', () => { + const chartProps = new ChartProps({ + ...chartPropsConfig, + formData: { + ...formData, + metrics: ['sum__num'], + currencyFormat: { symbol: 'GBP', symbolPosition: 'prefix' }, + }, + datasource: { + currencyCodeColumn: 'currency_code', + columnFormats: {}, + currencyFormats: {}, + verboseMap: {}, + }, + queriesData: [ + { + data: [ + { + 'San Francisco': 1000, + __timestamp: 599616000000, + currency_code: 'USD', + }, + { + 'San Francisco': 2000, + __timestamp: 599916000000, + currency_code: 'EUR', + }, + ], + }, + ], + }); + + const transformed = transformProps(chartProps as EchartsTimeseriesChartProps); + + // Static mode should always show £ + const formatter = getYAxisFormatter(transformed); + expect(formatter(1000, 0)).toContain('£'); +}); diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx b/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx index 09e62069fc4..1c2f5cbc05e 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx @@ -22,15 +22,18 @@ import { t } from '@apache-superset/core'; import { AdhocMetric, BinaryQueryObjectFilterClause, + Currency, CurrencyFormatter, DataRecordValue, FeatureFlag, getColumnLabel, getNumberFormatter, getSelectedText, + hasMixedCurrencies, isAdhocColumn, isFeatureEnabled, isPhysicalColumn, + normalizeCurrency, NumberFormatter, } from '@superset-ui/core'; import { styled, useTheme } from '@apache-superset/core/ui'; @@ -101,6 +104,71 @@ const StyledMinusSquareOutlined = styled(MinusSquareOutlined)` stroke-width: 16px; `; +/** Aggregator with currency tracking support */ +interface CurrencyTrackingAggregator { + getCurrencies?: () => string[]; +} + +type BaseFormatter = NumberFormatter | CurrencyFormatter; + +/** Create formatter that handles AUTO mode with per-cell currency detection */ +const createCurrencyAwareFormatter = ( + baseFormatter: BaseFormatter, + currencyConfig: Currency | undefined, + d3Format: string, + fallbackCurrency?: string, +): ((value: number, aggregator?: CurrencyTrackingAggregator) => string) => { + const isAutoMode = currencyConfig?.symbol === 'AUTO'; + + return (value: number, aggregator?: CurrencyTrackingAggregator): string => { + // If not AUTO mode, use base formatter directly + if (!isAutoMode) { + return baseFormatter(value); + } + + // AUTO mode: check aggregator for currency tracking + if (aggregator && typeof aggregator.getCurrencies === 'function') { + const currencies = aggregator.getCurrencies(); + + if (currencies && currencies.length > 0) { + if (hasMixedCurrencies(currencies)) { + return getNumberFormatter(d3Format)(value); + } + + const detectedCurrency = normalizeCurrency(currencies[0]); + if (detectedCurrency && currencyConfig) { + const cellFormatter = new CurrencyFormatter({ + currency: { + symbol: detectedCurrency, + symbolPosition: currencyConfig.symbolPosition, + }, + d3Format, + }); + return cellFormatter(value); + } + } + } + + // Fallback: use detected_currency from API response if available + if (fallbackCurrency && currencyConfig) { + const normalizedFallback = normalizeCurrency(fallbackCurrency); + if (normalizedFallback) { + const fallbackFormatter = new CurrencyFormatter({ + currency: { + symbol: normalizedFallback, + symbolPosition: currencyConfig.symbolPosition, + }, + d3Format, + }); + return fallbackFormatter(value); + } + } + + // Final fallback to neutral format + return getNumberFormatter(d3Format)(value); + }; +}; + const aggregatorsFactory = (formatter: NumberFormatter) => ({ Count: aggregatorTemplates.count(formatter), 'Count Unique Values': aggregatorTemplates.countUnique(formatter), @@ -171,6 +239,8 @@ export default function PivotTableChart(props: PivotTableProps) { rowSubTotals, valueFormat, currencyFormat, + currencyCodeColumn, + detectedCurrency, emitCrossFilters, setDataMask, selectedFilters, @@ -186,9 +256,11 @@ export default function PivotTableChart(props: PivotTableProps) { } = props; const theme = useTheme(); - const defaultFormatter = useMemo( + + // Base formatter without currency-awareness (for non-AUTO mode or as fallback) + const baseFormatter = useMemo( () => - currencyFormat?.symbol + currencyFormat?.symbol && currencyFormat.symbol !== 'AUTO' ? new CurrencyFormatter({ currency: currencyFormat, d3Format: valueFormat, @@ -196,6 +268,18 @@ export default function PivotTableChart(props: PivotTableProps) { : getNumberFormatter(valueFormat), [valueFormat, currencyFormat], ); + + // Currency-aware formatter for AUTO mode support + const defaultFormatter = useMemo( + () => + createCurrencyAwareFormatter( + baseFormatter, + currencyFormat, + valueFormat, + detectedCurrency ?? undefined, + ), + [baseFormatter, currencyFormat, valueFormat, detectedCurrency], + ); const customFormatsArray = useMemo( () => Array.from( @@ -216,19 +300,31 @@ export default function PivotTableChart(props: PivotTableProps) { hasCustomMetricFormatters ? { [METRIC_KEY]: Object.fromEntries( - customFormatsArray.map(([metric, d3Format, currency]) => [ - metric, - currency - ? new CurrencyFormatter({ - currency, - d3Format, - }) - : getNumberFormatter(d3Format), - ]), + customFormatsArray.map(([metric, d3Format, currency]) => { + // Create base formatter + const metricBaseFormatter = + currency && (currency as Currency).symbol !== 'AUTO' + ? new CurrencyFormatter({ + currency: currency as Currency, + d3Format: d3Format as string, + }) + : getNumberFormatter(d3Format as string); + + // Wrap with currency-aware formatter for AUTO mode support + return [ + metric, + createCurrencyAwareFormatter( + metricBaseFormatter, + currency as Currency | undefined, + d3Format as string, + detectedCurrency ?? undefined, + ), + ]; + }), ), } : undefined, - [customFormatsArray, hasCustomMetricFormatters], + [customFormatsArray, hasCustomMetricFormatters, detectedCurrency], ); const metricNames = useMemo( @@ -249,12 +345,14 @@ export default function PivotTableChart(props: PivotTableProps) { ...record, [METRIC_KEY]: name, value: record[name], + // Mark currency column for per-cell currency detection in aggregators + __currencyColumn: currencyCodeColumn, })) .filter(record => record.value !== null), ], [], ), - [data, metricNames], + [data, metricNames, currencyCodeColumn], ); const groupbyRows = useMemo( () => groupbyRowsRaw.map(getColumnLabel), diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/transformProps.ts b/superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/transformProps.ts index 9d5339a4dd0..847247a2141 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/transformProps.ts @@ -79,11 +79,21 @@ export default function transformProps(chartProps: ChartProps) { rawFormData, hooks: { setDataMask = () => {}, onContextMenu }, filterState, - datasource: { verboseMap = {}, columnFormats = {}, currencyFormats = {} }, + datasource: { + verboseMap = {}, + columnFormats = {}, + currencyFormats = {}, + currencyCodeColumn, + }, emitCrossFilters, theme, } = chartProps; - const { data, colnames, coltypes } = queriesData[0]; + const { + data, + colnames, + coltypes, + detected_currency: detectedCurrency, + } = queriesData[0]; const { groupbyRows, groupbyColumns, @@ -148,6 +158,8 @@ export default function transformProps(chartProps: ChartProps) { theme, ); + // AUTO symbol passed through - PivotTableChart handles per-cell currency detection + return { width, height, @@ -169,6 +181,8 @@ export default function transformProps(chartProps: ChartProps) { rowSubTotals, valueFormat, currencyFormat, + currencyCodeColumn, + detectedCurrency, emitCrossFilters, setDataMask, selectedFilters, diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.jsx b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.jsx index 13a983ecc3c..5fefac2904c 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.jsx +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.jsx @@ -749,7 +749,7 @@ export class TableRenderer extends Component { onContextMenu={e => this.props.onContextMenu(e, colKey, rowKey)} style={style} > - {displayCell(agg.format(aggValue), allowRenderHtml)} + {displayCell(agg.format(aggValue, agg), allowRenderHtml)} ); }); @@ -766,7 +766,7 @@ export class TableRenderer extends Component { onClick={rowTotalCallbacks[flatRowKey]} onContextMenu={e => this.props.onContextMenu(e, undefined, rowKey)} > - {displayCell(agg.format(aggValue), allowRenderHtml)} + {displayCell(agg.format(aggValue, agg), allowRenderHtml)} ); } @@ -830,7 +830,7 @@ export class TableRenderer extends Component { onContextMenu={e => this.props.onContextMenu(e, colKey, undefined)} style={{ padding: '5px' }} > - {displayCell(agg.format(aggValue), this.props.allowRenderHtml)} + {displayCell(agg.format(aggValue, agg), this.props.allowRenderHtml)} ); }); @@ -847,7 +847,7 @@ export class TableRenderer extends Component { onClick={grandTotalCallback} onContextMenu={e => this.props.onContextMenu(e, undefined, undefined)} > - {displayCell(agg.format(aggValue), this.props.allowRenderHtml)} + {displayCell(agg.format(aggValue, agg), this.props.allowRenderHtml)} ); } diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/utilities.js b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/utilities.js index 17f33a7ea86..100f2defa0e 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/utilities.js +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/utilities.js @@ -186,9 +186,13 @@ const usFmtPct = numberFormat({ suffix: '%', }); -const fmtNonString = formatter => x => - typeof x === 'string' ? x : formatter(x); +const fmtNonString = formatter => (x, aggregator) => + typeof x === 'string' ? x : formatter(x, aggregator); +/* + * Aggregators track currencies via push() and expose them via getCurrencies() + * for per-cell currency detection in AUTO mode. + */ const baseAggregatorTemplates = { count(formatter = usFmtInt) { return () => @@ -211,14 +215,21 @@ const baseAggregatorTemplates = { return function () { return { uniq: [], + currencySet: new Set(), push(record) { if (!Array.from(this.uniq).includes(record[attr])) { this.uniq.push(record[attr]); } + if (record.__currencyColumn && record[record.__currencyColumn]) { + this.currencySet.add(record[record.__currencyColumn]); + } }, value() { return fn(this.uniq); }, + getCurrencies() { + return Array.from(this.currencySet); + }, format: fmtNonString(formatter), numInputs: typeof attr !== 'undefined' ? 0 : 1, }; @@ -231,16 +242,23 @@ const baseAggregatorTemplates = { return function () { return { sum: 0, + currencySet: new Set(), push(record) { if (Number.isNaN(Number(record[attr]))) { this.sum = record[attr]; } else { this.sum += parseFloat(record[attr]); } + if (record.__currencyColumn && record[record.__currencyColumn]) { + this.currencySet.add(record[record.__currencyColumn]); + } }, value() { return this.sum; }, + getCurrencies() { + return Array.from(this.currencySet); + }, format: fmtNonString(formatter), numInputs: typeof attr !== 'undefined' ? 0 : 1, }; @@ -253,6 +271,7 @@ const baseAggregatorTemplates = { return function (data) { return { val: null, + currencySet: new Set(), sorter: getSort( typeof data !== 'undefined' ? data.sorters : null, attr, @@ -285,10 +304,16 @@ const baseAggregatorTemplates = { ) { this.val = x; } + if (record.__currencyColumn && record[record.__currencyColumn]) { + this.currencySet.add(record[record.__currencyColumn]); + } }, value() { return this.val; }, + getCurrencies() { + return Array.from(this.currencySet); + }, format(x) { if (typeof x === 'number') { return formatter(x); @@ -307,6 +332,7 @@ const baseAggregatorTemplates = { return { vals: [], strMap: {}, + currencySet: new Set(), push(record) { const val = record[attr]; const x = Number(val); @@ -316,6 +342,9 @@ const baseAggregatorTemplates = { } else { this.vals.push(x); } + if (record.__currencyColumn && record[record.__currencyColumn]) { + this.currencySet.add(record[record.__currencyColumn]); + } }, value() { if ( @@ -339,6 +368,9 @@ const baseAggregatorTemplates = { const i = (this.vals.length - 1) * q; return (this.vals[Math.floor(i)] + this.vals[Math.ceil(i)]) / 2.0; }, + getCurrencies() { + return Array.from(this.currencySet); + }, format: fmtNonString(formatter), numInputs: typeof attr !== 'undefined' ? 0 : 1, }; @@ -354,11 +386,15 @@ const baseAggregatorTemplates = { m: 0.0, s: 0.0, strValue: null, + currencySet: new Set(), push(record) { const x = Number(record[attr]); if (Number.isNaN(x)) { this.strValue = typeof record[attr] === 'string' ? record[attr] : this.strValue; + if (record.__currencyColumn && record[record.__currencyColumn]) { + this.currencySet.add(record[record.__currencyColumn]); + } return; } this.n += 1.0; @@ -368,6 +404,9 @@ const baseAggregatorTemplates = { const mNew = this.m + (x - this.m) / this.n; this.s += (x - this.m) * (x - mNew); this.m = mNew; + if (record.__currencyColumn && record[record.__currencyColumn]) { + this.currencySet.add(record[record.__currencyColumn]); + } }, value() { if (this.strValue) { @@ -392,6 +431,9 @@ const baseAggregatorTemplates = { throw new Error('unknown mode for runningStat'); } }, + getCurrencies() { + return Array.from(this.currencySet); + }, format: fmtNonString(formatter), numInputs: typeof attr !== 'undefined' ? 0 : 1, }; @@ -405,6 +447,7 @@ const baseAggregatorTemplates = { return { sumNum: 0, sumDenom: 0, + currencySet: new Set(), push(record) { if (!Number.isNaN(Number(record[num]))) { this.sumNum += parseFloat(record[num]); @@ -412,10 +455,16 @@ const baseAggregatorTemplates = { if (!Number.isNaN(Number(record[denom]))) { this.sumDenom += parseFloat(record[denom]); } + if (record.__currencyColumn && record[record.__currencyColumn]) { + this.currencySet.add(record[record.__currencyColumn]); + } }, value() { return this.sumNum / this.sumDenom; }, + getCurrencies() { + return Array.from(this.currencySet); + }, format: formatter, numInputs: typeof num !== 'undefined' && typeof denom !== 'undefined' ? 0 : 2, @@ -447,6 +496,9 @@ const baseAggregatorTemplates = { return this.inner.value() / acc; }, + getCurrencies() { + return this.inner.getCurrencies ? this.inner.getCurrencies() : []; + }, numInputs: wrapped(...Array.from(x || []))().numInputs, }; }; diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/types.ts b/superset-frontend/plugins/plugin-chart-pivot-table/src/types.ts index 83c4e76b861..d154031397b 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/types.ts +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/types.ts @@ -68,6 +68,8 @@ interface PivotTableCustomizeProps { rowSubTotals: boolean; valueFormat: string; currencyFormat: Currency; + currencyCodeColumn?: string; + detectedCurrency?: string | null; setDataMask: SetDataMaskHook; emitCrossFilters?: boolean; selectedFilters?: SelectedFiltersType; diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/test/plugin/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-pivot-table/test/plugin/transformProps.test.ts index 9327dcffe80..7a6fa52f7de 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/test/plugin/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-pivot-table/test/plugin/transformProps.test.ts @@ -96,4 +96,193 @@ describe('PivotTableChart transformProps', () => { currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' }, }); }); + + describe('Per-cell currency detection (AUTO mode passes through)', () => { + it('should pass AUTO mode through for per-cell detection (single currency data)', () => { + const autoFormData = { + ...formData, + currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' }, + }; + const autoChartProps = new ChartProps({ + formData: autoFormData, + width: 800, + height: 600, + queriesData: [ + { + data: [ + { country: 'USA', currency: 'USD', revenue: 100 }, + { country: 'Canada', currency: 'USD', revenue: 200 }, + { country: 'Mexico', currency: 'usd', revenue: 150 }, + ], + colnames: ['country', 'currency', 'revenue'], + coltypes: [1, 1, 0], + }, + ], + hooks: { setDataMask }, + filterState: { selectedFilters: {} }, + datasource: { + verboseMap: {}, + columnFormats: {}, + currencyCodeColumn: 'currency', + }, + theme: supersetTheme, + }); + + const result = transformProps(autoChartProps); + // AUTO mode should be preserved for per-cell detection in PivotTableChart + expect(result.currencyFormat).toEqual({ + symbol: 'AUTO', + symbolPosition: 'prefix', + }); + // currencyCodeColumn should be passed through for per-cell detection + expect(result.currencyCodeColumn).toBe('currency'); + }); + + it('should pass AUTO mode through for per-cell detection (mixed currency data)', () => { + const autoFormData = { + ...formData, + currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' }, + }; + const autoChartProps = new ChartProps({ + formData: autoFormData, + width: 800, + height: 600, + queriesData: [ + { + data: [ + { country: 'USA', currency: 'USD', revenue: 100 }, + { country: 'UK', currency: 'GBP', revenue: 200 }, + { country: 'France', currency: 'EUR', revenue: 150 }, + ], + colnames: ['country', 'currency', 'revenue'], + coltypes: [1, 1, 0], + }, + ], + hooks: { setDataMask }, + filterState: { selectedFilters: {} }, + datasource: { + verboseMap: {}, + columnFormats: {}, + currencyCodeColumn: 'currency', + }, + theme: supersetTheme, + }); + + const result = transformProps(autoChartProps); + // AUTO mode should be preserved - per-cell detection happens in PivotTableChart + expect(result.currencyFormat).toEqual({ + symbol: 'AUTO', + symbolPosition: 'prefix', + }); + expect(result.currencyCodeColumn).toBe('currency'); + }); + + it('should pass AUTO mode through when no currency column is defined', () => { + const autoFormData = { + ...formData, + currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' }, + }; + const autoChartProps = new ChartProps({ + formData: autoFormData, + width: 800, + height: 600, + queriesData: [ + { + data: [ + { country: 'USA', revenue: 100 }, + { country: 'UK', revenue: 200 }, + ], + colnames: ['country', 'revenue'], + coltypes: [1, 0], + }, + ], + hooks: { setDataMask }, + filterState: { selectedFilters: {} }, + datasource: { + verboseMap: {}, + columnFormats: {}, + // No currencyCodeColumn defined + }, + theme: supersetTheme, + }); + + const result = transformProps(autoChartProps); + expect(result.currencyFormat).toEqual({ + symbol: 'AUTO', + symbolPosition: 'prefix', + }); + // currencyCodeColumn should be undefined when not configured + expect(result.currencyCodeColumn).toBeUndefined(); + }); + + it('should handle empty data gracefully in AUTO mode', () => { + const autoFormData = { + ...formData, + currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' }, + }; + const autoChartProps = new ChartProps({ + formData: autoFormData, + width: 800, + height: 600, + queriesData: [ + { + data: [], + colnames: ['country', 'currency', 'revenue'], + coltypes: [1, 1, 0], + }, + ], + hooks: { setDataMask }, + filterState: { selectedFilters: {} }, + datasource: { + verboseMap: {}, + columnFormats: {}, + currencyCodeColumn: 'currency', + }, + theme: supersetTheme, + }); + + const result = transformProps(autoChartProps); + expect(result.currencyFormat).toEqual({ + symbol: 'AUTO', + symbolPosition: 'prefix', + }); + expect(result.currencyCodeColumn).toBe('currency'); + }); + + it('should preserve static currency format when not using AUTO mode', () => { + const staticFormData = { + ...formData, + currencyFormat: { symbol: 'EUR', symbolPosition: 'suffix' }, + }; + const staticChartProps = new ChartProps({ + formData: staticFormData, + width: 800, + height: 600, + queriesData: [ + { + data: [ + { country: 'USA', currency: 'USD', revenue: 100 }, + { country: 'UK', currency: 'GBP', revenue: 200 }, + ], + colnames: ['country', 'currency', 'revenue'], + coltypes: [1, 1, 0], + }, + ], + hooks: { setDataMask }, + filterState: { selectedFilters: {} }, + datasource: { + verboseMap: {}, + columnFormats: {}, + currencyCodeColumn: 'currency', + }, + theme: supersetTheme, + }); + + const result = transformProps(staticChartProps); + expect(result.currencyFormat).toEqual({ + symbol: 'EUR', + symbolPosition: 'suffix', + }); + }); + }); }); diff --git a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx index a4c20bb8dfd..90d894d9a91 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx @@ -892,7 +892,7 @@ export default function TableChart( columnKey: key, accessor: ((datum: D) => datum[key]) as never, Cell: ({ value, row }: { value: DataRecordValue; row: Row }) => { - const [isHtml, text] = formatColumnValue(column, value); + const [isHtml, text] = formatColumnValue(column, value, row.original); const html = isHtml && allowRenderHtml ? { __html: text } : undefined; let backgroundColor; diff --git a/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts b/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts index 0e80b9eb817..949d7410cc5 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts @@ -29,6 +29,7 @@ import { getNumberFormatter, getTimeFormatter, getTimeFormatterForGranularity, + normalizeCurrency, NumberFormats, QueryMode, SMART_DATE_ID, @@ -200,7 +201,12 @@ const processColumns = memoizeOne(function processColumns( props: TableChartProps, ) { const { - datasource: { columnFormats, currencyFormats, verboseMap }, + datasource: { + columnFormats, + currencyFormats, + verboseMap, + currencyCodeColumn, + }, rawFormData: { table_timestamp_format: tableTimestampFormat, metrics: metrics_, @@ -210,7 +216,12 @@ const processColumns = memoizeOne(function processColumns( queriesData, } = props; const granularity = extractTimegrain(props.rawFormData); - const { data: records, colnames, coltypes } = queriesData[0] || {}; + const { + data: records, + colnames, + coltypes, + detected_currency: detectedCurrency, + } = queriesData[0] || {}; // convert `metrics` and `percentMetrics` to the key names in `data.records` const metrics = (metrics_ ?? []).map(getMetricLabel); const rawPercentMetrics = (percentMetrics_ ?? []).map(getMetricLabel); @@ -276,10 +287,25 @@ const processColumns = memoizeOne(function processColumns( // percent metrics have a default format formatter = getNumberFormatter(numberFormat || PERCENT_3_POINT); } else if (isMetric || (isNumber && (numberFormat || currency))) { - formatter = currency?.symbol + // Resolve AUTO currency when currency column isn't in query results + let resolvedCurrency = currency; + if ( + currency?.symbol === 'AUTO' && + detectedCurrency && + (!currencyCodeColumn || !colnames?.includes(currencyCodeColumn)) + ) { + const normalizedCurrency = normalizeCurrency(detectedCurrency); + if (normalizedCurrency) { + resolvedCurrency = { + ...currency, + symbol: normalizedCurrency, + }; + } + } + formatter = resolvedCurrency?.symbol ? new CurrencyFormatter({ d3Format: numberFormat, - currency, + currency: resolvedCurrency, }) : getNumberFormatter(numberFormat); } @@ -292,6 +318,7 @@ const processColumns = memoizeOne(function processColumns( isPercentMetric, formatter, config, + currencyCodeColumn, }; }); return [metrics, percentMetrics, columns] as [ diff --git a/superset-frontend/plugins/plugin-chart-table/src/types.ts b/superset-frontend/plugins/plugin-chart-table/src/types.ts index 2708e02e444..721c80dfbf2 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/types.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/types.ts @@ -72,6 +72,7 @@ export interface DataColumnMeta { isNumeric?: boolean; config?: TableColumnConfig; isChildColumn?: boolean; + currencyCodeColumn?: string; } export interface TableChartData { diff --git a/superset-frontend/plugins/plugin-chart-table/src/utils/formatValue.ts b/superset-frontend/plugins/plugin-chart-table/src/utils/formatValue.ts index 7f067cd1953..3769abe5514 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/utils/formatValue.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/utils/formatValue.ts @@ -33,6 +33,8 @@ import DateWithFormatter from './DateWithFormatter'; function formatValue( formatter: DataColumnMeta['formatter'], value: DataRecordValue, + rowData?: Record, + currencyColumn?: string, ): [boolean, string] { // render undefined as empty string if (value === undefined) { @@ -48,6 +50,10 @@ function formatValue( return [false, 'N/A']; } if (formatter) { + // If formatter is a CurrencyFormatter, pass row context for AUTO mode + if (formatter instanceof CurrencyFormatter) { + return [false, formatter(value as number, rowData, currencyColumn)]; + } return [false, formatter(value as number)]; } if (typeof value === 'string') { @@ -59,8 +65,9 @@ function formatValue( export function formatColumnValue( column: DataColumnMeta, value: DataRecordValue, + rowData?: Record, ) { - const { dataType, formatter, config = {} } = column; + const { dataType, formatter, config = {}, currencyCodeColumn } = column; const isNumber = dataType === GenericDataType.Numeric; const smallNumberFormatter = config.d3SmallNumberFormat === undefined @@ -76,5 +83,7 @@ export function formatColumnValue( ? smallNumberFormatter : formatter, value, + rowData, + currencyCodeColumn, ); } diff --git a/superset-frontend/plugins/plugin-chart-table/src/utils/isEqualColumns.ts b/superset-frontend/plugins/plugin-chart-table/src/utils/isEqualColumns.ts index 28731c73c27..0643eaab68b 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/utils/isEqualColumns.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/utils/isEqualColumns.ts @@ -28,6 +28,7 @@ export default function isEqualColumns( return ( a.datasource.columnFormats === b.datasource.columnFormats && a.datasource.currencyFormats === b.datasource.currencyFormats && + a.datasource.currencyCodeColumn === b.datasource.currencyCodeColumn && a.datasource.verboseMap === b.datasource.verboseMap && a.formData.tableTimestampFormat === b.formData.tableTimestampFormat && a.formData.timeGrainSqla === b.formData.timeGrainSqla && @@ -36,6 +37,8 @@ export default function isEqualColumns( isEqualArray(a.formData.metrics, b.formData.metrics) && isEqualArray(a.queriesData?.[0]?.colnames, b.queriesData?.[0]?.colnames) && isEqualArray(a.queriesData?.[0]?.coltypes, b.queriesData?.[0]?.coltypes) && + a.queriesData?.[0]?.detected_currency === + b.queriesData?.[0]?.detected_currency && JSON.stringify(a.formData.extraFilters || null) === JSON.stringify(b.formData.extraFilters || null) && JSON.stringify(a.formData.extraFormData || null) === diff --git a/superset-frontend/plugins/plugin-chart-table/test/utils/formatValue.test.ts b/superset-frontend/plugins/plugin-chart-table/test/utils/formatValue.test.ts new file mode 100644 index 00000000000..74430924142 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-table/test/utils/formatValue.test.ts @@ -0,0 +1,155 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { CurrencyFormatter, getNumberFormatter } from '@superset-ui/core'; +import { GenericDataType } from '@apache-superset/core/api/core'; +import { formatColumnValue } from '../../src/utils/formatValue'; +import { DataColumnMeta } from '../../src/types'; + +test('formatColumnValue with CurrencyFormatter AUTO mode uses row context', () => { + const formatter = new CurrencyFormatter({ + d3Format: ',.2f', + currency: { symbol: 'AUTO', symbolPosition: 'prefix' }, + }); + + const column: DataColumnMeta = { + key: 'revenue', + label: 'Revenue', + dataType: GenericDataType.Numeric, + formatter, + isNumeric: true, + currencyCodeColumn: 'currency_code', + }; + + const rowData = { revenue: 1000, currency_code: 'EUR' }; + const [isHtml, result] = formatColumnValue(column, 1000, rowData); + + expect(isHtml).toBe(false); + expect(result).toContain('€'); + expect(result).toContain('1,000.00'); +}); + +test('formatColumnValue with CurrencyFormatter AUTO mode returns neutral format without row context', () => { + const formatter = new CurrencyFormatter({ + d3Format: ',.2f', + currency: { symbol: 'AUTO', symbolPosition: 'prefix' }, + }); + + const column: DataColumnMeta = { + key: 'revenue', + label: 'Revenue', + dataType: GenericDataType.Numeric, + formatter, + isNumeric: true, + currencyCodeColumn: 'currency_code', + }; + + // No row data provided + const [isHtml, result] = formatColumnValue(column, 1000); + + expect(isHtml).toBe(false); + expect(result).toBe('1,000.00'); + expect(result).not.toContain('$'); + expect(result).not.toContain('€'); +}); + +test('formatColumnValue with static CurrencyFormatter ignores row context', () => { + const formatter = new CurrencyFormatter({ + d3Format: ',.2f', + currency: { symbol: 'USD', symbolPosition: 'prefix' }, + }); + + const column: DataColumnMeta = { + key: 'revenue', + label: 'Revenue', + dataType: GenericDataType.Numeric, + formatter, + isNumeric: true, + }; + + // Row has EUR but static mode should show $ + const rowData = { revenue: 1000, currency_code: 'EUR' }; + const [isHtml, result] = formatColumnValue(column, 1000, rowData); + + expect(isHtml).toBe(false); + expect(result).toContain('$'); + expect(result).not.toContain('€'); +}); + +test('formatColumnValue with AUTO mode normalizes currency codes', () => { + const formatter = new CurrencyFormatter({ + d3Format: ',.2f', + currency: { symbol: 'AUTO', symbolPosition: 'prefix' }, + }); + + const column: DataColumnMeta = { + key: 'revenue', + label: 'Revenue', + dataType: GenericDataType.Numeric, + formatter, + isNumeric: true, + currencyCodeColumn: 'currency_code', + }; + + // Test lowercase currency code + const rowData1 = { revenue: 500, currency_code: 'usd' }; + const [, result1] = formatColumnValue(column, 500, rowData1); + expect(result1).toContain('$'); + + // Test uppercase currency code (GBP -> £) + const rowData2 = { revenue: 750, currency_code: 'GBP' }; + const [, result2] = formatColumnValue(column, 750, rowData2); + expect(result2).toContain('£'); +}); + +test('formatColumnValue handles null values', () => { + const column: DataColumnMeta = { + key: 'revenue', + label: 'Revenue', + dataType: GenericDataType.Numeric, + formatter: getNumberFormatter(',.2f'), + isNumeric: true, + }; + + const [, nullResult] = formatColumnValue(column, null); + expect(nullResult).toBe('N/A'); +}); + +test('formatColumnValue with small number format and currency', () => { + const formatter = new CurrencyFormatter({ + d3Format: ',.2f', + currency: { symbol: 'EUR', symbolPosition: 'prefix' }, + }); + + const column: DataColumnMeta = { + key: 'revenue', + label: 'Revenue', + dataType: GenericDataType.Numeric, + formatter, + isNumeric: true, + config: { + d3SmallNumberFormat: ',.4f', + currencyFormat: { symbol: 'EUR', symbolPosition: 'prefix' }, + }, + }; + + // Small number should use small number format + const [, result] = formatColumnValue(column, 0.5); + expect(result).toContain('€'); + expect(result).toContain('0.5000'); +}); diff --git a/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx b/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx index 413ddbd577d..56379a40e29 100644 --- a/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx +++ b/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx @@ -119,6 +119,7 @@ const DatasourceModal: FunctionComponent = ({ datasource.schema, description: datasource.description, main_dttm_col: datasource.main_dttm_col, + currency_code_column: datasource.currency_code_column ?? null, normalize_columns: datasource.normalize_columns, always_filter_main_dttm: datasource.always_filter_main_dttm, offset: datasource.offset, diff --git a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.jsx b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.jsx index 1412b4cedf6..fc220317599 100644 --- a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.jsx +++ b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.jsx @@ -30,6 +30,7 @@ import { getClientErrorObject, getExtensionsRegistry, } from '@superset-ui/core'; +import { GenericDataType } from '@apache-superset/core/api/core'; import { css, styled, @@ -56,8 +57,10 @@ import { Col, Divider, EditableTitle, + Flex, FormLabel, Icons, + InfoTooltip, Loading, Row, Select, @@ -157,6 +160,31 @@ const StyledTableTabWrapper = styled.div` } `; +const DefaultColumnSettingsContainer = styled.div` + ${({ theme }) => css` + margin-bottom: ${theme.sizeUnit * 4}px; + `} +`; + +const DefaultColumnSettingsTitle = styled.h4` + ${({ theme }) => css` + margin: 0 0 ${theme.sizeUnit * 2}px 0; + font-size: ${theme.fontSizeLG}px; + font-weight: ${theme.fontWeightMedium}; + color: ${theme.colorText}; + `} +`; + +const FieldLabelWithTooltip = styled.div` + ${({ theme }) => css` + display: flex; + align-items: center; + gap: ${theme.sizeUnit}px; + font-size: ${theme.fontSizeSM}px; + color: ${theme.colorTextLabel}; + `} +`; + const StyledButtonWrapper = styled.span` ${({ theme }) => ` margin-top: ${theme.sizeUnit * 3}px; @@ -234,18 +262,10 @@ function ColumnCollectionTable({ 'advanced_data_type', 'type', 'is_dttm', - 'main_dttm_col', - 'filterable', - 'groupby', - ] - : [ - 'column_name', - 'type', - 'is_dttm', - 'main_dttm_col', 'filterable', 'groupby', ] + : ['column_name', 'type', 'is_dttm', 'filterable', 'groupby'] } sortColumns={ isFeatureEnabled(FeatureFlag.EnableAdvancedDataTypes) @@ -254,18 +274,10 @@ function ColumnCollectionTable({ 'advanced_data_type', 'type', 'is_dttm', - 'main_dttm_col', - 'filterable', - 'groupby', - ] - : [ - 'column_name', - 'type', - 'is_dttm', - 'main_dttm_col', 'filterable', 'groupby', ] + : ['column_name', 'type', 'is_dttm', 'filterable', 'groupby'] } allowDeletes allowAddItem={allowAddItem} @@ -403,7 +415,6 @@ function ColumnCollectionTable({ type: t('Data type'), groupby: t('Is dimension'), is_dttm: t('Is temporal'), - main_dttm_col: t('Default datetime'), filterable: t('Is filterable'), } : { @@ -411,7 +422,6 @@ function ColumnCollectionTable({ type: t('Data type'), groupby: t('Is dimension'), is_dttm: t('Is temporal'), - main_dttm_col: t('Default datetime'), filterable: t('Is filterable'), } } @@ -445,27 +455,6 @@ function ColumnCollectionTable({ {v} ), - main_dttm_col: (value, _onItemChange, _label, record) => { - const checked = datasource.main_dttm_col === record.column_name; - const disabled = !record?.is_dttm; - return ( - - onDatasourceChange({ - ...datasource, - main_dttm_col: record.column_name, - }) - } - /> - ); - }, type: d => (d ? : null), advanced_data_type: d => ( @@ -497,27 +486,6 @@ function ColumnCollectionTable({ {v} ), - main_dttm_col: (value, _onItemChange, _label, record) => { - const checked = datasource.main_dttm_col === record.column_name; - const disabled = !record?.is_dttm; - return ( - - onDatasourceChange({ - ...datasource, - main_dttm_col: record.column_name, - }) - } - /> - ); - }, type: d => (d ? : null), is_dttm: checkboxGenerator, filterable: checkboxGenerator, @@ -1083,11 +1051,12 @@ class DatasourceEditor extends PureComponent { ), ); - // validate currency code + // validate currency code (skip 'AUTO' - it's a placeholder for auto-detection) try { this.state.datasource.metrics?.forEach( metric => metric.currency?.symbol && + metric.currency.symbol !== 'AUTO' && new Intl.NumberFormat('en-US', { style: 'currency', currency: metric.currency.symbol, @@ -1108,6 +1077,86 @@ class DatasourceEditor extends PureComponent { return metrics.sort(({ id: a }, { id: b }) => b - a); } + renderDefaultColumnSettings() { + const { datasource, databaseColumns, calculatedColumns } = this.state; + const { theme } = this.props; + const allColumns = [...databaseColumns, ...calculatedColumns]; + + // Get datetime-compatible columns for the default datetime dropdown + const datetimeColumns = allColumns + .filter(col => col.is_dttm) + .map(col => ({ + value: col.column_name, + label: col.verbose_name || col.column_name, + })); + + // Get string-type columns for the currency code dropdown + const stringColumns = allColumns + .filter(col => col.type_generic === GenericDataType.String) + .map(col => ({ + value: col.column_name, + label: col.verbose_name || col.column_name, + })); + + return ( + + + {t('Default Column Settings')} + + + + + {t('Default datetime column')} + + + + this.onDatasourceChange({ + ...datasource, + currency_code_column: value, + }) + } + placeholder={t('Select currency code column')} + allowClear + data-test="currency-code-column-select" + /> + + + + ); + } + renderSettingsFieldset() { const { datasource } = this.state; return ( @@ -1889,6 +1938,10 @@ class DatasourceEditor extends PureComponent { ), children: ( + {this.renderDefaultColumnSettings()} + + {t('Column Settings')} +