From bb73cf7a8f6370af77ff94a0d0063bcad763bfba Mon Sep 17 00:00:00 2001 From: richardfn Date: Thu, 27 Nov 2025 17:30:00 -0300 Subject: [PATCH 01/43] feat(currency): implement dynamic currency handling --- .../dynamic-currency-testing-guide.md | 647 ++++++++++++++++++ .../src/shared-controls/sharedControls.tsx | 3 + .../src/currency-format/CurrencyFormatter.ts | 116 +++- .../src/currency-format/utils.ts | 124 +++- .../src/query/types/Datasource.ts | 1 + .../src/query/types/QueryResponse.ts | 6 + .../currency-format/CurrencyFormatter.test.ts | 52 ++ .../analyzeCurrencyInData.test.ts | 97 +++ .../hasMixedCurrencies.test.ts | 51 ++ .../currency-format/normalizeCurrency.test.ts | 68 ++ .../src/transformProps.js | 12 +- .../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 | 54 +- .../src/Pie/transformProps.ts | 13 +- .../src/Sunburst/transformProps.ts | 11 +- .../src/Timeseries/transformProps.ts | 30 +- .../src/Treemap/transformProps.ts | 12 +- .../test/BigNumber/transformProps.test.ts | 58 ++ .../test/Timeseries/transformProps.test.ts | 130 ++++ .../src/PivotTableChart.tsx | 123 +++- .../src/plugin/transformProps.ts | 12 +- .../src/react-pivottable/TableRenderers.jsx | 8 +- .../src/react-pivottable/utilities.js | 130 ++++ .../plugin-chart-pivot-table/src/types.ts | 1 + .../test/plugin/transformProps.test.ts | 189 +++++ .../plugin-chart-table/src/TableChart.tsx | 2 +- .../plugin-chart-table/src/transformProps.ts | 8 +- .../plugins/plugin-chart-table/src/types.ts | 1 + .../src/utils/formatValue.ts | 11 +- .../test/utils/formatValue.test.ts | 155 +++++ .../Datasource/DatasourceModal/index.tsx | 1 + .../DatasourceEditor/DatasourceEditor.jsx | 178 +++-- .../tests/DatasourceEditorCurrency.test.tsx | 87 +++ .../tests/DatasourceEditorRTL.test.tsx | 44 +- .../ColumnConfigControl.tsx | 2 + .../ColumnConfigControl/constants.tsx | 2 +- .../CurrencyControl/CurrencyControl.tsx | 69 +- .../src/features/datasets/types.ts | 2 + superset/charts/schemas.py | 9 + superset/cli/examples.py | 3 + superset/common/query_actions.py | 77 +++ superset/connectors/sqla/models.py | 5 + superset/datasets/schemas.py | 2 + superset/examples/currency_test.py | 221 ++++++ superset/examples/data_loading.py | 2 + ...787190b3d89_add_currency_column_support.py | 61 ++ .../unit_tests/connectors/sqla/models_test.py | 39 ++ tests/unit_tests/datasets/schema_tests.py | 44 ++ 54 files changed, 2920 insertions(+), 129 deletions(-) create mode 100644 docs/docs/contributing/dynamic-currency-testing-guide.md 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/examples/currency_test.py create mode 100644 superset/migrations/versions/2025-11-18_14-00_9787190b3d89_add_currency_column_support.py diff --git a/docs/docs/contributing/dynamic-currency-testing-guide.md b/docs/docs/contributing/dynamic-currency-testing-guide.md new file mode 100644 index 000000000000..66e40930a4aa --- /dev/null +++ b/docs/docs/contributing/dynamic-currency-testing-guide.md @@ -0,0 +1,647 @@ +# Dynamic Currency Handling - Comprehensive Testing Guide + +## Table of Contents +1. [Prerequisites](#prerequisites) +2. [Dataset Setup](#dataset-setup) +3. [Test Data Overview](#test-data-overview) +4. [Chart Test Cases](#chart-test-cases) + - [Table Chart](#1-table-chart) + - [Pivot Table](#2-pivot-table) + - [Big Number](#3-big-number) + - [Big Number with Trendline](#4-big-number-with-trendline) + - [Time-series Chart](#5-time-series-chart) + - [Mixed Time-series](#6-mixed-time-series) + - [Pie Chart](#7-pie-chart) + - [Gauge Chart](#8-gauge-chart) + - [Funnel Chart](#9-funnel-chart) + - [Treemap](#10-treemap) + - [Heatmap](#11-heatmap) + - [Sunburst](#12-sunburst) +5. [Edge Case Tests](#edge-case-tests) +6. [Backwards Compatibility Tests](#backwards-compatibility-tests) + +--- + +## Prerequisites + +1. **Superset Running**: Ensure Superset is running at `http://localhost:8088` +2. **Database Connection**: Examples database connected (`postgresql://examples:examples@db:5432/examples`) +3. **Test Table Created**: `currency_test_full` table exists (see SQL below) + +### Create Test Table SQL + +```sql +CREATE TABLE IF NOT EXISTS currency_test_full ( + id SERIAL PRIMARY KEY, + transaction_date DATE, + country VARCHAR(50), + region VARCHAR(50), + currency_code VARCHAR(10), + revenue DECIMAL(15, 2), + profit DECIMAL(15, 2), + product_name VARCHAR(100), + product_category VARCHAR(50) +); + +INSERT INTO currency_test_full (transaction_date, country, region, currency_code, revenue, profit, product_name, product_category) VALUES +-- USD transactions (USA) - various case formats +('2024-01-15', 'USA', 'North America', 'USD', 64999.50, 14999.90, 'Premium Laptop', 'Electronics'), +('2024-01-15', 'USA', 'North America', 'usd', 179998.00, 44999.50, 'Enterprise Server', 'Electronics'), +('2024-01-20', 'USA', 'North America', 'USD', 74995.00, 17498.75, 'Office Suite 100-pack', 'Software'), +('2024-02-01', 'USA', 'North America', 'USD', 89996.00, 20999.15, 'Cloud Service Annual', 'Services'), +('2024-02-15', 'USA', 'North America', 'usd', 124997.50, 29999.40, 'Data Center Equipment', 'Electronics'), +('2024-03-01', 'USA', 'North America', 'USD', 34999.75, 8749.94, 'Security Software', 'Software'), +-- EUR transactions (Germany, France) +('2024-01-16', 'Germany', 'Europe', 'EUR', 47999.60, 11999.90, 'Industrial Sensor Kit', 'Electronics'), +('2024-01-25', 'Germany', 'Europe', 'eur', 89998.80, 22499.70, 'Manufacturing Robot', 'Electronics'), +('2024-02-10', 'Germany', 'Europe', 'EUR', 29999.85, 7499.96, 'ERP License Pack', 'Software'), +('2024-02-20', 'France', 'Europe', 'EUR', 54998.75, 13749.69, 'Telecom Equipment', 'Electronics'), +('2024-03-05', 'France', 'Europe', 'eur', 18999.90, 4749.98, 'Analytics Platform', 'Software'), +-- GBP transactions (UK) +('2024-01-18', 'UK', 'Europe', 'GBP', 34999.65, 8749.91, 'Financial Terminal', 'Electronics'), +('2024-02-05', 'UK', 'Europe', 'Gbp', 67998.70, 16999.68, 'Trading Platform', 'Software'), +('2024-02-28', 'UK', 'Europe', 'GBP', 44999.55, 11249.89, 'Compliance System', 'Services'), +-- JPY transactions (Japan) +('2024-01-22', 'Japan', 'Asia', 'JPY', 3999975.00, 999993.75, 'Precision Equipment', 'Electronics'), +('2024-02-12', 'Japan', 'Asia', 'JPY', 7499952.00, 1874988.00, 'Semiconductor Tools', 'Electronics'), +('2024-03-10', 'Japan', 'Asia', 'JPY', 1999988.00, 499997.00, 'Quality Control System', 'Software'), +-- AUD transactions (Australia) +('2024-01-28', 'Australia', 'Asia Pacific', 'AUD', 52999.45, 13249.86, 'Mining Software', 'Software'), +('2024-02-18', 'Australia', 'Asia Pacific', 'AUD', 84998.30, 21249.58, 'Resource Management', 'Services'), +('2024-03-15', 'Australia', 'Asia Pacific', 'AUD', 39999.60, 9999.90, 'Environmental Monitor', 'Electronics'), +-- CAD transactions (Canada) - mixed case +('2024-01-30', 'Canada', 'North America', 'CAD', 47999.55, 11999.89, 'Healthcare System', 'Software'), +('2024-02-22', 'Canada', 'North America', 'Cad', 72998.40, 18249.60, 'Medical Equipment', 'Electronics'), +('2024-03-08', 'Canada', 'North America', 'CAD', 28999.70, 7249.93, 'Pharma Analytics', 'Services'), +-- Edge cases +('2024-02-14', 'Unknown', 'Other', NULL, 15999.95, 3999.99, 'Generic Product', 'Other'), +('2024-02-25', 'Unknown', 'Other', '', 12999.85, 3249.96, 'Unclassified Item', 'Other'), +-- Small values for edge case testing +('2024-03-01', 'USA', 'North America', 'USD', 0.50, 0.10, 'Micro Transaction', 'Services'), +('2024-03-01', 'Germany', 'Europe', 'EUR', 0.75, 0.15, 'Small Fee', 'Services'), +('2024-03-01', 'UK', 'Europe', 'GBP', 0.99, 0.20, 'Minimal Charge', 'Services'); +``` + +--- + +## Dataset Setup + +### Step 1: Create the Dataset + +1. Navigate to **Data → Datasets** +2. Click **+ Dataset** +3. Configure: + - **Database**: `examples` + - **Schema**: `public` + - **Table**: `currency_test_full` +4. Click **Create Dataset and Create Chart** + +### Step 2: Configure Currency Code Column + +1. Navigate to **Data → Datasets** +2. Find and click on `currency_test_full` to edit +3. Scroll down to **"Default Column Settings"** section +4. In the **"Currency code column"** dropdown: + - Select `currency_code` +5. Click **Save** + +### Step 3: Verify Configuration Persisted + +1. Refresh the page +2. Go back to edit the dataset +3. Verify **"Currency code column"** still shows `currency_code` + +--- + +## Test Data Overview + +The `currency_test_full` table contains: + +| Data Aspect | Details | +|-------------|---------| +| **Currencies** | USD, EUR, GBP, JPY, AUD, CAD | +| **Case Variations** | `USD`, `usd`, `EUR`, `eur`, `Cad`, `Gbp` (tests normalization) | +| **Regions** | North America, Europe, Asia, Asia Pacific, Other | +| **Countries** | USA, Canada, Germany, France, UK, Japan, Australia, Unknown | +| **Time Range** | 2024-01-15 to 2024-03-15 | +| **Edge Cases** | NULL currency codes, empty strings, small numbers (< 1) | + +### Quick Verification Query +```sql +SELECT currency_code, COUNT(*) as rows, ROUND(SUM(revenue)::numeric, 2) as total_revenue +FROM currency_test_full +GROUP BY currency_code +ORDER BY currency_code; +``` + +--- + +## Chart Test Cases + +### 1. Table Chart + +#### Test 1.1: Per-Row Currency Symbols (No Filter) + +**Setup:** +1. Click **+ Chart** → Select `currency_test_full` → Choose **Table** +2. In **Query** tab: + - **Columns**: Add `country`, `product_name`, `revenue`, `currency_code` +3. In **Customize** tab: + - Expand **Customize columns** + - Click on `revenue` column + - **Currency**: Select **"Auto-detect from dataset"** + - **Position**: Select **"Prefix"** +4. Click **Update Chart** + +**Expected Result:** +- Each row displays its own currency symbol: + - USA rows: `$64,999.50` + - Germany rows: `€47,999.60` + - UK rows: `£34,999.65` + - Japan rows: `¥3,999,975` + +--- + +#### Test 1.2: Single Currency Filter (USA Only) + +**Setup:** +1. Continue from Test 1.1 +2. In **Query** tab → **Filters**: + - Add filter: `country = USA` +3. Click **Update Chart** + +**Expected Result:** +- All rows show `$` symbol (single currency detected) +- Values: `$64,999.50`, `$179,998.00`, `$74,995.00`, etc. + +--- + +#### Test 1.3: Mixed Currency Region (Europe) + +**Setup:** +1. Change filter to: `region = Europe` +2. Click **Update Chart** + +**Expected Result:** +- Rows show their respective currency: + - Germany/France: `€` (EUR) + - UK: `£` (GBP) + +--- + +### 2. Pivot Table + +#### Test 2.1: Basic Pivot with AUTO Mode + +**Setup:** +1. Click **+ Chart** → Select `currency_test_full` → Choose **Pivot Table** +2. In **Query** tab: + - **Rows**: `region` + - **Columns**: `product_category` + - **Metrics**: `SUM(revenue)` +3. In **Customize** tab: + - **Currency**: Select **"Auto-detect from dataset"** + - **Position**: **"Prefix"** +4. Click **Update Chart** + +**Expected Result (No Filter):** +- Individual cells may show symbols if they represent single currency +- **Grand totals show neutral formatting** (no symbol) because they aggregate mixed currencies + +--- + +#### Test 2.2: Pivot with Single Currency Filter + +**Setup:** +1. Add filter: `country = USA` +2. Click **Update Chart** + +**Expected Result:** +- All cells show `$` symbol +- Totals also show `$` symbol (single currency) + +--- + +#### Test 2.3: Pivot Cell-Level Detection + +**Setup:** +1. Remove filters +2. Change **Rows** to: `country` +3. Click **Update Chart** + +**Expected Result:** +- USA row cells: `$` +- Germany row cells: `€` +- UK row cells: `£` +- Grand total row: Neutral formatting (mixed currencies) + +--- + +### 3. Big Number + +#### Test 3.1: Single Currency Detection + +**Setup:** +1. Click **+ Chart** → Select `currency_test_full` → Choose **Big Number** +2. In **Query** tab: + - **Metric**: `SUM(revenue)` + - **Filters**: `country = USA` +3. In **Customize** tab: + - **Currency**: Select **"Auto-detect from dataset"** + - **Position**: **"Prefix"** +4. Click **Update Chart** + +**Expected Result:** +- Big number displays with `$` symbol +- Example: `$1,237,472.75` + +--- + +#### Test 3.2: Mixed Currency (Neutral Formatting) + +**Setup:** +1. Change filter to: `region = Europe` +2. Click **Update Chart** + +**Expected Result:** +- Big number displays **without** currency symbol (neutral formatting) +- Example: `1,070,487.04` (no € or £ because data contains both) + +--- + +#### Test 3.3: No Filter (All Data) + +**Setup:** +1. Remove all filters +2. Click **Update Chart** + +**Expected Result:** +- Big number displays **without** currency symbol +- Total revenue shown in neutral format + +--- + +### 4. Big Number with Trendline + +#### Test 4.1: Single Currency with Trend + +**Setup:** +1. Click **+ Chart** → Select `currency_test_full` → Choose **Big Number with Trendline** +2. In **Query** tab: + - **Metric**: `SUM(revenue)` + - **Temporal Column**: `transaction_date` + - **Filters**: `country = USA` +3. In **Customize** tab: + - **Currency**: Select **"Auto-detect from dataset"** + - **Position**: **"Prefix"** +4. Click **Update Chart** + +**Expected Result:** +- Big number shows `$` symbol +- Trendline tooltip values also show `$` symbol + +--- + +#### Test 4.2: Mixed Currency Trend + +**Setup:** +1. Remove filter (show all data) +2. Click **Update Chart** + +**Expected Result:** +- Big number and trendline use neutral formatting (no symbol) + +--- + +### 5. Time-series Chart + +#### Test 5.1: Single Currency Time-series + +**Setup:** +1. Click **+ Chart** → Select `currency_test_full` → Choose **Time-series Chart** +2. In **Query** tab: + - **Time Column**: `transaction_date` + - **Time Grain**: `Month` + - **Metrics**: `SUM(revenue)` + - **Filters**: `country = USA` +3. In **Customize** tab: + - **Y Axis** section: + - **Currency**: Select **"Auto-detect from dataset"** + - **Position**: **"Prefix"** +4. Click **Update Chart** + +**Expected Result:** +- Y-axis labels show `$` symbol: `$100K`, `$200K`, etc. +- Tooltip values show `$` symbol + +--- + +#### Test 5.2: Multi-Series with Mixed Currency + +**Setup:** +1. Remove filter +2. Add **Dimensions**: `region` +3. Click **Update Chart** + +**Expected Result:** +- Y-axis shows **neutral formatting** (no symbol) +- Each series represents different regions with different currencies + +--- + +### 6. Mixed Time-series + +#### Test 6.1: Dual Axis with Different Currency Contexts + +**Setup:** +1. Click **+ Chart** → Select `currency_test_full` → Choose **Mixed Chart** +2. In **Query** tab: + - **Time Column**: `transaction_date` + - **Metrics (Left Y-Axis)**: `SUM(revenue)` + - **Metrics (Right Y-Axis)**: `SUM(profit)` + - **Filters**: `country = USA` +3. In **Customize** tab: + - **Y Axis** → **Currency**: **"Auto-detect from dataset"** + - **Y Axis 2** → **Currency**: **"Auto-detect from dataset"** +4. Click **Update Chart** + +**Expected Result:** +- Both axes show `$` symbol (single currency in filtered data) + +--- + +### 7. Pie Chart + +#### Test 7.1: Pie with Single Currency + +**Setup:** +1. Click **+ Chart** → Select `currency_test_full` → Choose **Pie Chart** +2. In **Query** tab: + - **Dimensions**: `product_category` + - **Metric**: `SUM(revenue)` + - **Filters**: `country = USA` +3. In **Customize** tab: + - **Number format** → **Currency**: **"Auto-detect from dataset"** +4. Click **Update Chart** + +**Expected Result:** +- Slice labels show `$` symbol +- Tooltip shows `$` formatted values + +--- + +#### Test 7.2: Pie with Mixed Currency + +**Setup:** +1. Remove filter +2. Change **Dimensions** to: `country` +3. Click **Update Chart** + +**Expected Result:** +- Labels show **neutral formatting** (no symbol) +- Tooltip shows neutral formatted values + +--- + +### 8. Gauge Chart + +#### Test 8.1: Gauge with Single Currency + +**Setup:** +1. Click **+ Chart** → Select `currency_test_full` → Choose **Gauge Chart** +2. In **Query** tab: + - **Metric**: `SUM(revenue)` + - **Filters**: `country = USA` +3. In **Customize** tab: + - **Number format** → **Currency**: **"Auto-detect from dataset"** + - **Min**: `0` + - **Max**: `2000000` +4. Click **Update Chart** + +**Expected Result:** +- Gauge value shows `$` symbol +- Axis labels show `$` formatted values + +--- + +### 9. Funnel Chart + +#### Test 9.1: Funnel with Single Currency + +**Setup:** +1. Click **+ Chart** → Select `currency_test_full` → Choose **Funnel Chart** +2. In **Query** tab: + - **Dimensions**: `product_category` + - **Metric**: `SUM(revenue)` + - **Filters**: `country = USA` +3. In **Customize** tab: + - **Number format** → **Currency**: **"Auto-detect from dataset"** +4. Click **Update Chart** + +**Expected Result:** +- Funnel segment labels show `$` symbol +- Tooltip shows `$` formatted values + +--- + +### 10. Treemap + +#### Test 10.1: Treemap with Single Currency + +**Setup:** +1. Click **+ Chart** → Select `currency_test_full` → Choose **Treemap** +2. In **Query** tab: + - **Dimensions**: `region`, `country` + - **Metric**: `SUM(revenue)` + - **Filters**: `currency_code = USD` (include case variations) +3. In **Customize** tab: + - **Number format** → **Currency**: **"Auto-detect from dataset"** +4. Click **Update Chart** + +**Expected Result:** +- Tree node labels show `$` symbol +- Tooltip shows `$` formatted values + +--- + +### 11. Heatmap + +#### Test 11.1: Heatmap with Single Currency + +**Setup:** +1. Click **+ Chart** → Select `currency_test_full` → Choose **Heatmap** +2. In **Query** tab: + - **X Axis**: `product_category` + - **Y Axis**: `region` + - **Metric**: `SUM(revenue)` + - **Filters**: `country = USA` +3. In **Customize** tab: + - **Number format** → **Currency**: **"Auto-detect from dataset"** +4. Click **Update Chart** + +**Expected Result:** +- Cell values show `$` symbol +- Legend shows `$` formatted range +- Tooltip shows `$` formatted values + +--- + +### 12. Sunburst + +#### Test 12.1: Sunburst with Single Currency + +**Setup:** +1. Click **+ Chart** → Select `currency_test_full` → Choose **Sunburst** +2. In **Query** tab: + - **Hierarchy**: `region`, `country`, `product_category` + - **Primary Metric**: `SUM(revenue)` + - **Filters**: `country = USA` +3. In **Customize** tab: + - **Number format** → **Currency**: **"Auto-detect from dataset"** +4. Click **Update Chart** + +**Expected Result:** +- Center total shows `$` symbol +- Segment tooltips show `$` formatted values + +--- + +## Edge Case Tests + +### Test E1: Currency Code Normalization + +**Setup:** +1. Any chart with AUTO mode +2. Filter: `country IN ('USA', 'Canada')` + +**Expected Result:** +- Data includes `USD`, `usd`, `CAD`, `Cad` +- USA data normalizes to `USD` → shows `$` +- Canada data normalizes to `CAD` → shows `CA$` or neutral if mixed with USD + +--- + +### Test E2: NULL Currency Values + +**Setup:** +1. Table chart with AUTO mode +2. Filter: `country = Unknown` + +**Expected Result:** +- Rows with NULL `currency_code` show **neutral formatting** +- Chart does not break or error + +--- + +### Test E3: Empty String Currency + +**Setup:** +1. Table chart with AUTO mode +2. Include rows where `currency_code = ''` + +**Expected Result:** +- Empty string treated as missing → neutral formatting + +--- + +### Test E4: Small Numbers (< 1) + +**Setup:** +1. Table chart with AUTO mode +2. Filter: `product_category = Services` + +**Expected Result:** +- Small values like `0.50`, `0.75`, `0.99` display correctly +- Currency symbol still applies: `$0.50` + +--- + +## Backwards Compatibility Tests + +### Test B1: Static Currency Override + +**Setup:** +1. Any chart +2. Set Currency to **"USD ($)"** (static, not Auto-detect) +3. Filter: `country = Germany` (EUR data) + +**Expected Result:** +- Values show `$` symbol regardless of actual data currency +- This maintains existing behavior for users who don't want auto-detection + +--- + +### Test B2: No Currency Code Column Configured + +**Setup:** +1. Edit the dataset +2. Clear the "Currency code column" selection +3. Save +4. Return to any chart with AUTO mode selected + +**Expected Result:** +- Chart shows **neutral formatting** (no symbol) +- No errors occur + +--- + +### Test B3: Existing Dashboard Unchanged + +**Setup:** +1. Open any existing dashboard created before this feature +2. Charts that don't have AUTO mode selected + +**Expected Result:** +- All existing charts render exactly as before +- No automatic behavior changes + +--- + +## Test Summary Checklist + +| # | Test | Chart Type | Scenario | Expected | +|---|------|------------|----------|----------| +| 1.1 | Table - Per Row | Table | No filter | Per-row symbols | +| 1.2 | Table - Single | Table | USA filter | All `$` | +| 1.3 | Table - Mixed | Table | Europe filter | `€` and `£` | +| 2.1 | Pivot - Basic | Pivot | No filter | Neutral totals | +| 2.2 | Pivot - Single | Pivot | USA filter | All `$` | +| 2.3 | Pivot - Cell | Pivot | By country | Per-row symbols | +| 3.1 | BigNum - Single | Big Number | USA filter | `$` | +| 3.2 | BigNum - Mixed | Big Number | Europe filter | Neutral | +| 4.1 | BigTrend - Single | Big Number + Trend | USA filter | `$` | +| 5.1 | Time - Single | Time-series | USA filter | `$` on Y-axis | +| 5.2 | Time - Multi | Time-series | By region | Neutral | +| 6.1 | Mixed - Dual | Mixed Chart | USA filter | Both axes `$` | +| 7.1 | Pie - Single | Pie | USA filter | `$` labels | +| 7.2 | Pie - Mixed | Pie | By country | Neutral | +| 8.1 | Gauge - Single | Gauge | USA filter | `$` | +| 9.1 | Funnel - Single | Funnel | USA filter | `$` | +| 10.1 | Tree - Single | Treemap | USD filter | `$` | +| 11.1 | Heat - Single | Heatmap | USA filter | `$` | +| 12.1 | Sun - Single | Sunburst | USA filter | `$` | +| E1 | Normalization | Any | Mixed case | Normalized | +| E2 | NULL handling | Table | Unknown country | Neutral | +| E3 | Empty string | Table | Empty currency | Neutral | +| E4 | Small numbers | Table | Services | `$0.50` | +| B1 | Static override | Any | Static USD, EUR data | `$` | +| B2 | No column | Any | No currency col | Neutral | +| B3 | Existing | Dashboard | Legacy charts | Unchanged | + +--- + +## Troubleshooting + +### Chart shows neutral formatting when single currency expected +1. Verify dataset has "Currency code column" configured +2. Check filter is correctly limiting to single currency +3. Run SQL to verify data: `SELECT DISTINCT currency_code FROM currency_test_full WHERE [your_filter]` + +### Currency symbol not appearing +1. Ensure "Auto-detect from dataset" is selected (not blank) +2. Verify position (Prefix/Suffix) is set +3. Check that the currency code column contains valid ISO codes or symbols + +### Mixed results within same currency +1. Check for case variations: `USD` vs `usd` +2. Normalization should handle this - verify with: `SELECT DISTINCT UPPER(currency_code) FROM ...` 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 1ac7fe9effcc..eebc27da57b4 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 from dataset' 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-core/src/currency-format/CurrencyFormatter.ts b/superset-frontend/packages/superset-ui-core/src/currency-format/CurrencyFormatter.ts index 50be87adf691..57fb5d2bbb1f 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 @@ -30,7 +30,11 @@ interface CurrencyFormatterConfig { } interface CurrencyFormatter { - (value: number | null | undefined): string; + ( + value: number | null | undefined, + rowData?: Record, + currencyColumn?: string, + ): string; } export const getCurrencySymbol = (currency: Partial) => @@ -41,6 +45,73 @@ export const getCurrencySymbol = (currency: Partial) => .formatToParts(1) .find(x => x.type === 'currency')?.value; +/** + * Normalize currency codes and symbols to standardized ISO 4217 codes. + * Handles lowercase codes, common symbols, and whitespace. + * + * @param value - Currency code or symbol to normalize + * @returns Normalized ISO 4217 currency code, or null if invalid + */ +export function normalizeCurrency( + value: string | null | undefined, +): string | null { + if (!value) return null; + + const str = value.toString().trim(); + if (!str) return null; + + const upper = str.toUpperCase(); + + // Already ISO code (3 uppercase letters) + if (/^[A-Z]{3}$/.test(upper)) return upper; + + // Common symbol mappings (use original case-sensitive value) + const symbolMap: Record = { + $: 'USD', + '€': 'EUR', + '£': 'GBP', + '¥': 'JPY', + '₹': 'INR', + }; + + // Full currency name mappings (use uppercase) + const nameMap: Record = { + DOLLAR: 'USD', + DOLLARS: 'USD', + EURO: 'EUR', + EUROS: 'EUR', + POUND: 'GBP', + POUNDS: 'GBP', + YEN: 'JPY', + RUPEE: 'INR', + RUPEES: 'INR', + }; + + return symbolMap[str] || nameMap[upper] || null; +} + +/** + * Determine if an array of currency values contains mixed currencies. + * Normalizes currencies before comparison to handle different formats. + * + * @param currencies - Array of currency codes/symbols to check + * @returns True if multiple distinct currencies are present, false otherwise + */ +export function hasMixedCurrencies( + currencies: (string | null | undefined)[], +): boolean { + // Filter out null/undefined and normalize + const normalized = currencies + .map(c => normalizeCurrency(c)) + .filter((c): c is string => c !== null); + + if (normalized.length === 0) return false; + + // Check if all normalized currencies are the same + const first = normalized[0]; + return !normalized.every(c => c === first); +} + class CurrencyFormatter extends ExtensibleFunction { d3Format: string; @@ -49,7 +120,10 @@ class CurrencyFormatter extends ExtensibleFunction { currency: Currency; constructor(config: CurrencyFormatterConfig) { - super((value: number) => this.format(value)); + super( + (value: number, rowData?: Record, currencyColumn?: string) => + this.format(value, rowData, currencyColumn), + ); this.d3Format = config.d3Format || NumberFormats.SMART_NUMBER; this.currency = config.currency; this.locale = config.locale || 'en-US'; @@ -63,14 +137,48 @@ class CurrencyFormatter extends ExtensibleFunction { return this.d3Format.replace(/\$|%/g, ''); } - format(value: number) { + format( + value: number, + rowData?: Record, + currencyColumn?: string, + ): string { const formattedValue = getNumberFormatter(this.getNormalizedD3Format())( value, ); - if (!this.hasValidCurrency()) { + + // Check if AUTO mode is enabled + const isAutoMode = this.currency?.symbol === 'AUTO'; + + if (!this.hasValidCurrency() && !isAutoMode) { + return formattedValue as string; + } + + // AUTO mode: Use row context if available + 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} ${formattedValue}`; + } + return `${formattedValue} ${symbol}`; + } + } catch (error) { + // If currency code is invalid, fall through to neutral format + } + } + } + + // No row context or invalid currency - return neutral format return formattedValue as string; } + // Static mode: Use configured currency if (this.currency.symbolPosition === 'prefix') { return `${getCurrencySymbol(this.currency)} ${formattedValue}`; } 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 14228ea77fa3..60067da52864 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,87 @@ import { QueryFormMetric, ValueFormatter, } from '@superset-ui/core'; +import { normalizeCurrency, hasMixedCurrencies } from './CurrencyFormatter'; + +/** + * Analyze data to detect if it contains single or mixed currencies. + * Used by AUTO mode to determine whether to show currency symbols or neutral formatting. + * + * @param data - Array of data records from chart query results + * @param currencyColumn - Name of the column containing currency codes/symbols + * @returns Normalized ISO 4217 currency code if single currency detected, null otherwise + * + * @example + * // Single currency detected + * analyzeCurrencyInData([{curr: 'USD'}, {curr: 'usd'}], 'curr') // 'USD' + * + * @example + * // Mixed currencies detected + * analyzeCurrencyInData([{curr: 'USD'}, {curr: 'EUR'}], 'curr') // null + */ +export const analyzeCurrencyInData = ( + data: Record[], + currencyColumn: string | undefined, +): string | null => { + if (!currencyColumn || !data || data.length === 0) { + return null; + } + + // Extract all currency values from the data + const currencies = data + .map(row => row[currencyColumn]) + .filter(val => val !== null && val !== undefined); + + if (currencies.length === 0) { + return null; + } + + // Use existing hasMixedCurrencies utility + if (hasMixedCurrencies(currencies)) { + return null; // Mixed currencies - use neutral format + } + + // Single currency detected - normalize and return it + return normalizeCurrency(currencies[0]); +}; export const buildCustomFormatters = ( metrics: QueryFormMetric | QueryFormMetric[] | undefined, savedCurrencyFormats: Record, savedColumnFormats: Record, d3Format: string | undefined, - currencyFormat: Currency | undefined, + currencyFormat: Currency | undefined | null, + data?: Record[], + currencyCodeColumn?: string, ) => { const metricsArray = ensureIsArray(metrics); + + // Detect currency for AUTO mode + let resolvedCurrency = currencyFormat; + if (currencyFormat?.symbol === 'AUTO' && data && currencyCodeColumn) { + const detectedCurrency = analyzeCurrencyInData(data, currencyCodeColumn); + if (detectedCurrency) { + // Single currency: use it with same position as configured + resolvedCurrency = { + symbol: detectedCurrency, + symbolPosition: currencyFormat.symbolPosition, + }; + } else { + // Mixed currencies: use neutral format (explicitly set to null to prevent fallback) + resolvedCurrency = null; + } + } + return metricsArray.reduce((acc, metric) => { if (isSavedMetric(metric)) { const actualD3Format = d3Format ?? savedColumnFormats[metric]; - const actualCurrencyFormat = currencyFormat?.symbol - ? currencyFormat - : savedCurrencyFormats[metric]; + // null means "explicitly no currency" (from AUTO mixed detection), don't fall back + const actualCurrencyFormat = + resolvedCurrency === null + ? undefined + : resolvedCurrency?.symbol + ? resolvedCurrency + : savedCurrencyFormats[metric]; return actualCurrencyFormat?.symbol ? { ...acc, @@ -76,14 +142,54 @@ export const getValueFormatter = ( d3Format: string | undefined, currencyFormat: Currency | undefined, key?: string, + data?: Record[], + currencyCodeColumn?: string, + detectedCurrency?: string | null, ) => { + // Detect currency for AUTO mode + let resolvedCurrency: Currency | undefined | null = currencyFormat; + if (currencyFormat?.symbol === 'AUTO') { + // Priority 1: Use backend-detected currency if available + if (detectedCurrency !== undefined) { + if (detectedCurrency) { + // Single currency detected by backend + resolvedCurrency = { + symbol: detectedCurrency, + symbolPosition: currencyFormat.symbolPosition, + }; + } else { + // Mixed currencies (null from backend) - use neutral format + resolvedCurrency = null; + } + } + // Priority 2: Fallback to frontend data analysis (for Table charts with currency column in data) + else if (data && currencyCodeColumn) { + const frontendDetectedCurrency = analyzeCurrencyInData( + data, + currencyCodeColumn, + ); + if (frontendDetectedCurrency) { + resolvedCurrency = { + symbol: frontendDetectedCurrency, + symbolPosition: currencyFormat.symbolPosition, + }; + } else { + resolvedCurrency = null; + } + } + // Priority 3: No detection possible - use neutral format + else { + resolvedCurrency = null; + } + } + const customFormatter = getCustomFormatter( buildCustomFormatters( metrics, savedCurrencyFormats, savedColumnFormats, d3Format, - currencyFormat, + resolvedCurrency, ), metrics, key, @@ -92,8 +198,12 @@ export const getValueFormatter = ( if (customFormatter) { return customFormatter; } - if (currencyFormat?.symbol) { - return new CurrencyFormatter({ currency: currencyFormat, d3Format }); + // null means neutral format (don't use currency), undefined means try currency if available + if (resolvedCurrency === null) { + return getNumberFormatter(d3Format); + } + if (resolvedCurrency?.symbol) { + return new CurrencyFormatter({ currency: resolvedCurrency, 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 c5ce93c1e916..47902cf07ae3 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 a4523969f3cb..e355f6a31623 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 @@ -73,6 +73,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 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 b731b1ba498a..34b935a155c6 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,55 @@ test('CurrencyFormatter:format', () => { }); expect(currencyFormatterWithCurrencyD3(VALUE)).toEqual('56,100,057.0 PLN'); }); + +test('CurrencyFormatter formats with AUTO mode and row context', () => { + const formatter = new CurrencyFormatter({ + d3Format: ',.2f', + currency: { symbol: 'AUTO', symbolPosition: 'prefix' }, + }); + + const row = { amount: 1000, currency: 'EUR' }; + const result = formatter.format(1000, row, 'currency'); + + expect(result).toContain('€'); + expect(result).toContain('1,000.00'); +}); + +test('CurrencyFormatter with AUTO mode returns neutral format without context', () => { + const formatter = new CurrencyFormatter({ + d3Format: ',.2f', + currency: { symbol: 'AUTO', symbolPosition: 'prefix' }, + }); + + const result = formatter.format(1000); + expect(result).toBe('1,000.00'); // No currency symbol +}); + +test('CurrencyFormatter with AUTO mode normalizes currency codes', () => { + const formatter = new CurrencyFormatter({ + d3Format: ',.2f', + currency: { symbol: 'AUTO', symbolPosition: 'prefix' }, + }); + + // Test with lowercase code + const row1 = { amount: 500, currency: 'usd' }; + expect(formatter.format(500, row1, 'currency')).toContain('$'); + + // Test with symbol + const row2 = { amount: 750, currency: '€' }; + expect(formatter.format(750, row2, 'currency')).toContain('€'); +}); + +test('CurrencyFormatter with static mode ignores row context', () => { + const formatter = new CurrencyFormatter({ + d3Format: ',.2f', + currency: { symbol: 'USD', symbolPosition: 'prefix' }, + }); + + // Even with EUR in row, should show $ because static mode + const row = { amount: 1000, currency: 'EUR' }; + const result = formatter.format(1000, row, 'currency'); + + expect(result).toContain('$'); + expect(result).not.toContain('€'); +}); 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 000000000000..6a448ca4b06e --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/test/currency-format/analyzeCurrencyInData.test.ts @@ -0,0 +1,97 @@ +/** + * 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'; + +describe('analyzeCurrencyInData', () => { + test('returns null when currencyColumn is undefined', () => { + const data = [{ currency: 'USD', value: 100 }]; + expect(analyzeCurrencyInData(data, undefined)).toBeNull(); + }); + + test('returns null when data is empty', () => { + expect(analyzeCurrencyInData([], 'currency_code')).toBeNull(); + }); + + test('returns normalized currency code for single currency', () => { + const data = [ + { currency_code: 'USD', value: 100 }, + { currency_code: 'usd', value: 200 }, + { currency_code: 'USD', value: 300 }, + ]; + expect(analyzeCurrencyInData(data, 'currency_code')).toBe('USD'); + }); + + test('returns normalized currency code for single currency symbol', () => { + const data = [ + { currency_code: '€', value: 100 }, + { currency_code: '€', value: 200 }, + { currency_code: '€', value: 300 }, + ]; + expect(analyzeCurrencyInData(data, 'currency_code')).toBe('EUR'); + }); + + test('returns null for mixed currencies', () => { + const data = [ + { currency_code: 'USD', value: 100 }, + { currency_code: 'EUR', value: 200 }, + { currency_code: 'GBP', value: 300 }, + ]; + expect(analyzeCurrencyInData(data, 'currency_code')).toBeNull(); + }); + + test('returns null for mixed currency symbols', () => { + const data = [ + { currency_code: '$', value: 100 }, + { currency_code: '€', value: 200 }, + { currency_code: '£', value: 300 }, + ]; + expect(analyzeCurrencyInData(data, 'currency_code')).toBeNull(); + }); + + test('ignores null and undefined values', () => { + const data = [ + { currency_code: 'USD', value: 100 }, + { currency_code: null, value: 200 }, + { currency_code: undefined, value: 300 }, + { currency_code: 'USD', value: 400 }, + ]; + expect(analyzeCurrencyInData(data, 'currency_code')).toBe('USD'); + }); + + test('returns null when all currency values are null or undefined', () => { + const data = [ + { currency_code: null, value: 100 }, + { currency_code: undefined, value: 200 }, + ]; + expect(analyzeCurrencyInData(data, 'currency_code')).toBeNull(); + }); + + test('handles single row with currency', () => { + const data = [{ currency_code: 'JPY', value: 100 }]; + expect(analyzeCurrencyInData(data, 'currency_code')).toBe('JPY'); + }); + + test('returns null for invalid currency values', () => { + const data = [ + { currency_code: 'INVALID', value: 100 }, + { currency_code: 'INVALID', value: 200 }, + ]; + expect(analyzeCurrencyInData(data, 'currency_code')).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 000000000000..178cb23f0e5d --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/test/currency-format/hasMixedCurrencies.test.ts @@ -0,0 +1,51 @@ +/** + * 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 different currencies', () => { + expect(hasMixedCurrencies(['USD', 'EUR'])).toBe(true); + expect(hasMixedCurrencies(['USD', 'EUR', 'GBP'])).toBe(true); + expect(hasMixedCurrencies(['EUR', 'JPY'])).toBe(true); +}); + +test('hasMixedCurrencies normalizes before comparing', () => { + // These are all USD after normalization + expect(hasMixedCurrencies(['USD', 'usd', '$'])).toBe(false); + // These are all EUR + expect(hasMixedCurrencies(['EUR', 'eur', '€'])).toBe(false); + // These are all GBP + expect(hasMixedCurrencies(['GBP', 'gbp', '£'])).toBe(false); +}); + +test('hasMixedCurrencies returns false for single currency', () => { + expect(hasMixedCurrencies(['USD'])).toBe(false); + expect(hasMixedCurrencies(['EUR', 'EUR', 'EUR'])).toBe(false); + expect(hasMixedCurrencies(['GBP', 'GBP'])).toBe(false); +}); + +test('hasMixedCurrencies handles empty array', () => { + expect(hasMixedCurrencies([])).toBe(false); +}); + +test('hasMixedCurrencies ignores null values', () => { + expect(hasMixedCurrencies(['USD', null, 'USD'])).toBe(false); + expect(hasMixedCurrencies([null, null])).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 000000000000..b9cf8057216a --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/test/currency-format/normalizeCurrency.test.ts @@ -0,0 +1,68 @@ +/** + * 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 converts lowercase ISO codes to uppercase', () => { + expect(normalizeCurrency('usd')).toBe('USD'); + expect(normalizeCurrency('eur')).toBe('EUR'); + expect(normalizeCurrency('gbp')).toBe('GBP'); + expect(normalizeCurrency('jpy')).toBe('JPY'); +}); + +test('normalizeCurrency maps common currency symbols', () => { + expect(normalizeCurrency('$')).toBe('USD'); + expect(normalizeCurrency('€')).toBe('EUR'); + expect(normalizeCurrency('£')).toBe('GBP'); + expect(normalizeCurrency('¥')).toBe('JPY'); + expect(normalizeCurrency('₹')).toBe('INR'); +}); + +test('normalizeCurrency returns ISO codes unchanged', () => { + expect(normalizeCurrency('USD')).toBe('USD'); + expect(normalizeCurrency('EUR')).toBe('EUR'); + expect(normalizeCurrency('GBP')).toBe('GBP'); + expect(normalizeCurrency('JPY')).toBe('JPY'); +}); + +test('normalizeCurrency handles null and undefined', () => { + expect(normalizeCurrency(null)).toBe(null); + expect(normalizeCurrency(undefined)).toBe(null); + expect(normalizeCurrency('')).toBe(null); +}); + +test('normalizeCurrency handles whitespace', () => { + expect(normalizeCurrency(' USD ')).toBe('USD'); + expect(normalizeCurrency(' usd ')).toBe('USD'); +}); + +test('normalizeCurrency maps full currency names', () => { + expect(normalizeCurrency('EURO')).toBe('EUR'); + expect(normalizeCurrency('euro')).toBe('EUR'); + expect(normalizeCurrency('EUROS')).toBe('EUR'); + expect(normalizeCurrency('euros')).toBe('EUR'); + expect(normalizeCurrency('DOLLAR')).toBe('USD'); + expect(normalizeCurrency('dollar')).toBe('USD'); + expect(normalizeCurrency('DOLLARS')).toBe('USD'); + expect(normalizeCurrency('POUND')).toBe('GBP'); + expect(normalizeCurrency('POUNDS')).toBe('GBP'); + expect(normalizeCurrency('YEN')).toBe('JPY'); + expect(normalizeCurrency('RUPEE')).toBe('INR'); + expect(normalizeCurrency('RUPEES')).toBe('INR'); +}); 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 4069e2942891..64824b1e3c6d 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 = queriesData[0].data; const formatter = getValueFormatter( metric, @@ -55,12 +60,15 @@ export default function transformProps(chartProps) { columnFormats, yAxisFormat, currencyFormat, + undefined, + data, + currencyCodeColumn, ); 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 b374678d05b8..4c3776c90873 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 @@ -81,7 +81,11 @@ export default function transformProps(chartProps: ChartProps) { height, formData, queriesData, - datasource: { currencyFormats = {}, columnFormats = {} }, + datasource: { + currencyFormats = {}, + columnFormats = {}, + currencyCodeColumn, + }, } = chartProps; const { boldText, @@ -99,7 +103,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 || []; @@ -161,6 +166,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 6181ca23f64b..ef9501fe9af7 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 20795a77482f..2de7662f287c 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 @@ -81,7 +81,11 @@ export default function transformProps( hooks, inContextMenu, theme, - datasource: { currencyFormats = {}, columnFormats = {} }, + datasource: { + currencyFormats = {}, + columnFormats = {}, + currencyCodeColumn, + }, } = chartProps; const { colorPicker, @@ -111,6 +115,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; @@ -240,6 +245,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/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/types.ts index d2bec7d68e69..c37aac32a8d7 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 7245f6702445..fd3cce40c08b 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 29dcafc45b82..a8c62f0344ff 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 4e82f5008f1b..c45c843f065f 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/transformProps.ts @@ -106,8 +106,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 = @@ -130,6 +139,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 b25245fd9b86..5c00830b20a8 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts @@ -19,10 +19,12 @@ /* eslint-disable camelcase */ import { invert } from 'lodash'; import { + analyzeCurrencyInData, AnnotationLayer, AxisType, buildCustomFormatters, CategoricalColorNamespace, + Currency, CurrencyFormatter, ensureIsArray, getCustomFormatter, @@ -138,6 +140,7 @@ export default function transformProps( verboseMap = {}, currencyFormats = {}, columnFormats = {}, + currencyCodeColumn, } = datasource; const { label_map: labelMap } = queriesData[0] as TimeseriesChartDataResponseResult; @@ -279,20 +282,55 @@ export default function transformProps( xAxisType, }); const series: SeriesOption[] = []; + + // Resolve currency for AUTO mode (primary axis) + let resolvedCurrency: Currency | undefined | null = currencyFormat; + if (currencyFormat?.symbol === 'AUTO' && data1 && currencyCodeColumn) { + const detectedCurrency = analyzeCurrencyInData(data1, currencyCodeColumn); + if (detectedCurrency) { + resolvedCurrency = { + symbol: detectedCurrency, + symbolPosition: currencyFormat.symbolPosition, + }; + } else { + // Mixed currencies: explicitly use null to prevent fallback to metric-level settings + resolvedCurrency = null; + } + } + + // Resolve currency for AUTO mode (secondary axis) + let resolvedCurrencySecondary: Currency | undefined | null = + currencyFormatSecondary; + if ( + currencyFormatSecondary?.symbol === 'AUTO' && + data2 && + currencyCodeColumn + ) { + const detectedCurrency = analyzeCurrencyInData(data2, currencyCodeColumn); + if (detectedCurrency) { + resolvedCurrencySecondary = { + symbol: detectedCurrency, + symbolPosition: currencyFormatSecondary.symbolPosition, + }; + } else { + resolvedCurrencySecondary = null; + } + } + 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 +338,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 e6fee12248e5..14faf5a9c9e6 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 22da9e02ce73..a29410c536c1 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 0a0e08e761c4..c43ccd3703d6 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -21,8 +21,10 @@ import { invert } from 'lodash'; import { AnnotationLayer, AxisType, + analyzeCurrencyInData, buildCustomFormatters, CategoricalColorNamespace, + Currency, CurrencyFormatter, ensureIsArray, tooltipHtml, @@ -134,6 +136,7 @@ export default function transformProps( verboseMap = {}, columnFormats = {}, currencyFormats = {}, + currencyCodeColumn, } = datasource; const [queryData] = queriesData; const { data = [], label_map = {} } = @@ -275,15 +278,36 @@ 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 + let resolvedCurrency: Currency | undefined | null = currencyFormat; + if (currencyFormat?.symbol === 'AUTO' && data && currencyCodeColumn) { + const detectedCurrency = analyzeCurrencyInData(data, currencyCodeColumn); + if (detectedCurrency) { + resolvedCurrency = { + symbol: detectedCurrency, + symbolPosition: currencyFormat.symbolPosition, + }; + } else { + // Mixed currencies: explicitly use null to prevent fallback to metric-level settings + resolvedCurrency = null; + } + } + + 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 5ed0c565c3ca..e4fe3053ee28 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 93ecf5df3d95..6e1d3995c0b1 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 @@ -370,3 +370,61 @@ describe('BigNumberWithTrendline - Aggregation Tests', () => { expect(transformed.bigNumber).toStrictEqual(10); }); }); + +describe('BigNumberWithTrendline - AUTO Mode Currency', () => { + it('should detect single currency in AUTO mode', () => { + 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('$'); + }); + + it('should use neutral formatting for mixed currencies in AUTO mode', () => { + 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('€'); + }); + + it('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 42ecb1803d38..93f5d3646bdd 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 @@ -723,3 +723,133 @@ describe('legend sorting', () => { ]); }); }); + +describe('EchartsTimeseries AUTO Mode Currency', () => { + it('should detect single currency and resolve AUTO mode', () => { + const chartProps = new ChartProps({ + ...chartPropsConfig, + formData: { + ...formData, + 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, + ); + // Y-axis formatter should contain $ for USD + const yAxisFormatter = (transformed.echartOptions.yAxis as any)?.axisLabel + ?.formatter; + if (yAxisFormatter) { + expect(yAxisFormatter(1000)).toContain('$'); + } + }); + + it('should use neutral formatting for mixed currencies in AUTO mode', () => { + const chartProps = new ChartProps({ + ...chartPropsConfig, + formData: { + ...formData, + 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 yAxisFormatter = (transformed.echartOptions.yAxis as any)?.axisLabel + ?.formatter; + if (yAxisFormatter) { + const formatted = yAxisFormatter(1000); + expect(formatted).not.toContain('$'); + expect(formatted).not.toContain('€'); + } + }); + + it('should preserve static currency format regardless of data', () => { + const chartProps = new ChartProps({ + ...chartPropsConfig, + formData: { + ...formData, + 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 yAxisFormatter = (transformed.echartOptions.yAxis as any)?.axisLabel + ?.formatter; + if (yAxisFormatter) { + expect(yAxisFormatter(1000)).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 a08f96a8ad93..5c6063618bbc 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx @@ -21,15 +21,18 @@ import { MinusSquareOutlined, PlusSquareOutlined } from '@ant-design/icons'; import { AdhocMetric, BinaryQueryObjectFilterClause, + Currency, CurrencyFormatter, DataRecordValue, FeatureFlag, getColumnLabel, getNumberFormatter, getSelectedText, + hasMixedCurrencies, isAdhocColumn, isFeatureEnabled, isPhysicalColumn, + normalizeCurrency, NumberFormatter, t, } from '@superset-ui/core'; @@ -101,6 +104,79 @@ const StyledMinusSquareOutlined = styled(MinusSquareOutlined)` stroke-width: 16px; `; +/** + * Interface for aggregator objects that support currency tracking. + */ +interface CurrencyTrackingAggregator { + getCurrencies?: () => string[]; +} + +/** + * Base formatter type - can be NumberFormatter or CurrencyFormatter + */ +type BaseFormatter = NumberFormatter | CurrencyFormatter; + +/** + * Creates a currency-aware formatter that wraps a base formatter. + * When AUTO mode is enabled and aggregator has currency tracking, + * it will show currency symbol for single-currency cells and + * neutral format for mixed-currency cells. + * + * @param baseFormatter - The base number formatter to use + * @param currencyConfig - Currency configuration (may have symbol='AUTO') + * @param d3Format - The d3 format string for number formatting + * @returns A formatter function that accepts (value, aggregator?) + */ +const createCurrencyAwareFormatter = ( + baseFormatter: BaseFormatter, + currencyConfig: Currency | undefined, + d3Format: 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') { + // No currency tracking available, use neutral format + return baseFormatter(value); + } + + const currencies = aggregator.getCurrencies(); + + if (!currencies || currencies.length === 0) { + // No currencies tracked, use neutral format + return baseFormatter(value); + } + + // Check if single or mixed currencies + if (hasMixedCurrencies(currencies)) { + // Mixed currencies: use neutral format (no symbol) + return getNumberFormatter(d3Format)(value); + } + + // Single currency: create formatter with detected currency + const detectedCurrency = normalizeCurrency(currencies[0]); + if (detectedCurrency && currencyConfig) { + const cellFormatter = new CurrencyFormatter({ + currency: { + symbol: detectedCurrency, + symbolPosition: currencyConfig.symbolPosition, + }, + d3Format, + }); + return cellFormatter(value); + } + + // Fallback to neutral format + return getNumberFormatter(d3Format)(value); + }; +}; + const aggregatorsFactory = (formatter: NumberFormatter) => ({ Count: aggregatorTemplates.count(formatter), 'Count Unique Values': aggregatorTemplates.countUnique(formatter), @@ -171,6 +247,7 @@ export default function PivotTableChart(props: PivotTableProps) { rowSubTotals, valueFormat, currencyFormat, + currencyCodeColumn, emitCrossFilters, setDataMask, selectedFilters, @@ -186,9 +263,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 +275,13 @@ export default function PivotTableChart(props: PivotTableProps) { : getNumberFormatter(valueFormat), [valueFormat, currencyFormat], ); + + // Currency-aware formatter for AUTO mode support + const defaultFormatter = useMemo( + () => + createCurrencyAwareFormatter(baseFormatter, currencyFormat, valueFormat), + [baseFormatter, currencyFormat, valueFormat], + ); const customFormatsArray = useMemo( () => Array.from( @@ -216,15 +302,26 @@ 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, + ), + ]; + }), ), } : undefined, @@ -249,12 +346,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 9d5339a4dd0c..ec7091c30525 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,7 +79,12 @@ export default function transformProps(chartProps: ChartProps) { rawFormData, hooks: { setDataMask = () => {}, onContextMenu }, filterState, - datasource: { verboseMap = {}, columnFormats = {}, currencyFormats = {} }, + datasource: { + verboseMap = {}, + columnFormats = {}, + currencyFormats = {}, + currencyCodeColumn, + }, emitCrossFilters, theme, } = chartProps; @@ -148,6 +153,10 @@ export default function transformProps(chartProps: ChartProps) { theme, ); + // Pass currencyFormat with AUTO symbol intact for per-cell detection. + // Per-cell logic in PivotTableChart will handle AUTO mode based on + // currencies tracked during aggregation. + return { width, height, @@ -169,6 +178,7 @@ export default function transformProps(chartProps: ChartProps) { rowSubTotals, valueFormat, currencyFormat, + currencyCodeColumn, 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 60ea1e68f6be..a58c0c3800ec 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 @@ -748,7 +748,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)} ); }); @@ -765,7 +765,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)} ); } @@ -829,7 +829,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)} ); }); @@ -846,7 +846,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 2c75e5fff2db..97ba596d2c03 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 @@ -190,6 +190,11 @@ const fmtNonString = formatter => x => typeof x === 'string' ? x : formatter(x); const baseAggregatorTemplates = { + /** + * Count aggregator - counts number of records. + * Note: Count doesn't track currencies as it's not a monetary value. + * @param {Function} formatter - Number formatter function + */ count(formatter = usFmtInt) { return () => function () { @@ -206,19 +211,37 @@ const baseAggregatorTemplates = { }; }, + /** + * Uniques aggregator - tracks unique values. + * Tracks currencies for per-cell currency detection. + * @param {Function} fn - Function to apply to unique values + * @param {Function} formatter - Number formatter function + */ uniques(fn, formatter = usFmtInt) { return function ([attr]) { return function () { return { uniq: [], + currencies: [], push(record) { if (!Array.from(this.uniq).includes(record[attr])) { this.uniq.push(record[attr]); } + // Track currency if present in record + if (record.__currencyColumn && record[record.__currencyColumn]) { + this.currencies.push(record[record.__currencyColumn]); + } }, value() { return fn(this.uniq); }, + /** + * Get tracked currencies for this aggregation cell. + * @returns {Array} Array of currency codes/symbols seen in this cell + */ + getCurrencies() { + return this.currencies; + }, format: fmtNonString(formatter), numInputs: typeof attr !== 'undefined' ? 0 : 1, }; @@ -226,21 +249,38 @@ const baseAggregatorTemplates = { }; }, + /** + * Sum aggregator - sums numeric values. + * Tracks currencies for per-cell currency detection. + * @param {Function} formatter - Number formatter function + */ sum(formatter = usFmt) { return function ([attr]) { return function () { return { sum: 0, + currencies: [], push(record) { if (Number.isNaN(Number(record[attr]))) { this.sum = record[attr]; } else { this.sum += parseFloat(record[attr]); } + // Track currency if present in record + if (record.__currencyColumn && record[record.__currencyColumn]) { + this.currencies.push(record[record.__currencyColumn]); + } }, value() { return this.sum; }, + /** + * Get tracked currencies for this aggregation cell. + * @returns {Array} Array of currency codes/symbols seen in this cell + */ + getCurrencies() { + return this.currencies; + }, format: fmtNonString(formatter), numInputs: typeof attr !== 'undefined' ? 0 : 1, }; @@ -248,11 +288,18 @@ const baseAggregatorTemplates = { }; }, + /** + * Extremes aggregator - finds min/max/first/last values. + * Tracks currencies for per-cell currency detection. + * @param {string} mode - 'min', 'max', 'first', or 'last' + * @param {Function} formatter - Number formatter function + */ extremes(mode, formatter = usFmt) { return function ([attr]) { return function (data) { return { val: null, + currencies: [], sorter: getSort( typeof data !== 'undefined' ? data.sorters : null, attr, @@ -285,10 +332,21 @@ const baseAggregatorTemplates = { ) { this.val = x; } + // Track currency if present in record + if (record.__currencyColumn && record[record.__currencyColumn]) { + this.currencies.push(record[record.__currencyColumn]); + } }, value() { return this.val; }, + /** + * Get tracked currencies for this aggregation cell. + * @returns {Array} Array of currency codes/symbols seen in this cell + */ + getCurrencies() { + return this.currencies; + }, format(x) { if (typeof x === 'number') { return formatter(x); @@ -301,12 +359,19 @@ const baseAggregatorTemplates = { }; }, + /** + * Quantile aggregator - calculates quantile values (median, etc.). + * Tracks currencies for per-cell currency detection. + * @param {number} q - Quantile value (0-1) + * @param {Function} formatter - Number formatter function + */ quantile(q, formatter = usFmt) { return function ([attr]) { return function () { return { vals: [], strMap: {}, + currencies: [], push(record) { const val = record[attr]; const x = Number(val); @@ -316,6 +381,10 @@ const baseAggregatorTemplates = { } else { this.vals.push(x); } + // Track currency if present in record + if (record.__currencyColumn && record[record.__currencyColumn]) { + this.currencies.push(record[record.__currencyColumn]); + } }, value() { if ( @@ -339,6 +408,13 @@ const baseAggregatorTemplates = { const i = (this.vals.length - 1) * q; return (this.vals[Math.floor(i)] + this.vals[Math.ceil(i)]) / 2.0; }, + /** + * Get tracked currencies for this aggregation cell. + * @returns {Array} Array of currency codes/symbols seen in this cell + */ + getCurrencies() { + return this.currencies; + }, format: fmtNonString(formatter), numInputs: typeof attr !== 'undefined' ? 0 : 1, }; @@ -346,6 +422,13 @@ const baseAggregatorTemplates = { }; }, + /** + * Running statistics aggregator - calculates mean/variance/stdev. + * Tracks currencies for per-cell currency detection. + * @param {string} mode - 'mean', 'var', or 'stdev' + * @param {number} ddof - Delta degrees of freedom + * @param {Function} formatter - Number formatter function + */ runningStat(mode = 'mean', ddof = 1, formatter = usFmt) { return function ([attr]) { return function () { @@ -354,11 +437,16 @@ const baseAggregatorTemplates = { m: 0.0, s: 0.0, strValue: null, + currencies: [], push(record) { const x = Number(record[attr]); if (Number.isNaN(x)) { this.strValue = typeof record[attr] === 'string' ? record[attr] : this.strValue; + // Still track currency even for non-numeric values + if (record.__currencyColumn && record[record.__currencyColumn]) { + this.currencies.push(record[record.__currencyColumn]); + } return; } this.n += 1.0; @@ -368,6 +456,10 @@ const baseAggregatorTemplates = { const mNew = this.m + (x - this.m) / this.n; this.s += (x - this.m) * (x - mNew); this.m = mNew; + // Track currency if present in record + if (record.__currencyColumn && record[record.__currencyColumn]) { + this.currencies.push(record[record.__currencyColumn]); + } }, value() { if (this.strValue) { @@ -392,6 +484,13 @@ const baseAggregatorTemplates = { throw new Error('unknown mode for runningStat'); } }, + /** + * Get tracked currencies for this aggregation cell. + * @returns {Array} Array of currency codes/symbols seen in this cell + */ + getCurrencies() { + return this.currencies; + }, format: fmtNonString(formatter), numInputs: typeof attr !== 'undefined' ? 0 : 1, }; @@ -399,12 +498,18 @@ const baseAggregatorTemplates = { }; }, + /** + * Sum over sum aggregator - calculates ratio of two sums. + * Tracks currencies for per-cell currency detection. + * @param {Function} formatter - Number formatter function + */ sumOverSum(formatter = usFmt) { return function ([num, denom]) { return function () { return { sumNum: 0, sumDenom: 0, + currencies: [], push(record) { if (!Number.isNaN(Number(record[num]))) { this.sumNum += parseFloat(record[num]); @@ -412,10 +517,21 @@ const baseAggregatorTemplates = { if (!Number.isNaN(Number(record[denom]))) { this.sumDenom += parseFloat(record[denom]); } + // Track currency if present in record + if (record.__currencyColumn && record[record.__currencyColumn]) { + this.currencies.push(record[record.__currencyColumn]); + } }, value() { return this.sumNum / this.sumDenom; }, + /** + * Get tracked currencies for this aggregation cell. + * @returns {Array} Array of currency codes/symbols seen in this cell + */ + getCurrencies() { + return this.currencies; + }, format: formatter, numInputs: typeof num !== 'undefined' && typeof denom !== 'undefined' ? 0 : 2, @@ -424,6 +540,13 @@ const baseAggregatorTemplates = { }; }, + /** + * Fraction of aggregator - wraps another aggregator to show as fraction. + * Delegates currency tracking to inner aggregator. + * @param {Function} wrapped - The aggregator to wrap + * @param {string} type - 'total', 'row', or 'col' + * @param {Function} formatter - Number formatter function + */ fractionOf(wrapped, type = 'total', formatter = usFmtPct) { return (...x) => function (data, rowKey, colKey) { @@ -447,6 +570,13 @@ const baseAggregatorTemplates = { return this.inner.value() / acc; }, + /** + * Delegate currency tracking to inner aggregator. + * @returns {Array} Array of currency codes/symbols seen in this cell + */ + 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 83c4e76b8617..47221922d34f 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,7 @@ interface PivotTableCustomizeProps { rowSubTotals: boolean; valueFormat: string; currencyFormat: Currency; + currencyCodeColumn?: string; 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 9327dcffe805..7a6fa52f7de7 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 fae285f496fa..baa0c474d788 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx @@ -880,7 +880,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 ef634e01b86b..9c8f2e37bd22 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts @@ -200,7 +200,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_, @@ -292,6 +297,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 2708e02e4449..721c80dfbf2b 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 7f067cd19531..212ae9a9a07c 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/test/utils/formatValue.test.ts b/superset-frontend/plugins/plugin-chart-table/test/utils/formatValue.test.ts new file mode 100644 index 000000000000..7a86328718af --- /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 currency symbol + const rowData2 = { revenue: 750, currency_code: '£' }; + 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 96e271f9e59b..e19dfad6b37d 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, 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 30d5a824281e..433bd7a50d9b 100644 --- a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.jsx +++ b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.jsx @@ -31,6 +31,7 @@ import { getClientErrorObject, getExtensionsRegistry, } from '@superset-ui/core'; +import { GenericDataType } from '@apache-superset/core/api/core'; import { css, styled, @@ -58,6 +59,7 @@ import { EditableTitle, FormLabel, Icons, + InfoTooltip, Loading, Row, Select, @@ -156,6 +158,47 @@ 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 DefaultColumnSettingsFields = styled.div` + ${({ theme }) => css` + display: flex; + flex-direction: column; + gap: ${theme.sizeUnit * 3}px; + `} +`; + +const FieldWrapper = styled.div` + ${({ theme }) => css` + display: flex; + flex-direction: column; + gap: ${theme.sizeUnit}px; + `} +`; + +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; @@ -233,7 +276,6 @@ function ColumnCollectionTable({ 'advanced_data_type', 'type', 'is_dttm', - 'main_dttm_col', 'filterable', 'groupby', ] @@ -241,7 +283,6 @@ function ColumnCollectionTable({ 'column_name', 'type', 'is_dttm', - 'main_dttm_col', 'filterable', 'groupby', ] @@ -253,7 +294,6 @@ function ColumnCollectionTable({ 'advanced_data_type', 'type', 'is_dttm', - 'main_dttm_col', 'filterable', 'groupby', ] @@ -261,7 +301,6 @@ function ColumnCollectionTable({ 'column_name', 'type', 'is_dttm', - 'main_dttm_col', 'filterable', 'groupby', ] @@ -402,7 +441,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'), } : { @@ -410,7 +448,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'), } } @@ -444,27 +481,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 => ( @@ -496,27 +512,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, @@ -1005,6 +1000,85 @@ class DatasourceEditor extends PureComponent { return metrics.sort(({ id: a }, { id: b }) => b - a); } + renderDefaultColumnSettings() { + const { datasource, databaseColumns, calculatedColumns } = this.state; + 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 ( @@ -1786,6 +1860,10 @@ class DatasourceEditor extends PureComponent { ), children: ( + {this.renderDefaultColumnSettings()} + + {t('Column Settings')} +