Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
bb73cf7
feat(currency): implement dynamic currency handling
richardfogaca Nov 27, 2025
5cfa7f4
Merge branch 'master' into feat-dynamic-currency
richardfogaca Nov 27, 2025
af340ba
Merge branch 'master' into feat-dynamic-currency
richardfogaca Nov 27, 2025
7f61867
fix(dynamic-currency): use backend detected_currency as fallback for …
richardfogaca Nov 28, 2025
abb0955
refactor: rename currency_test dataset to international_sales
richardfogaca Nov 28, 2025
dca153e
fix(datasets): include currency_code_column in API response for edit …
richardfogaca Dec 1, 2025
fae949c
fix(datasets): send null instead of undefined when clearing currency_…
richardfogaca Dec 1, 2025
666ee4a
fix(currency): inject currency_code_column into query for cell-level …
richardfogaca Dec 1, 2025
00b8834
fix(dynamic-currency): limit currency column injection to charts with…
richardfogaca Dec 1, 2025
e5336b6
refactor(currency): extract currency detection to shared utility for …
richardfogaca Dec 1, 2025
a78594a
test(currency): add unit tests for detect_currency utility
richardfogaca Dec 1, 2025
58b3569
fix(currency): show AUTO option only when dataset has currency_code_c…
richardfogaca Dec 3, 2025
3d8badb
refactor(currency): code clean up and update tests
richardfogaca Dec 3, 2025
19ee4e9
ci(currency): fix mypy issues
richardfogaca Dec 3, 2025
57029e0
chore: rebase with master branch
richardfogaca Dec 3, 2025
3fb5b22
style: rename 'Auto-detect from dataset' to 'Auto-detect'
richardfogaca Dec 3, 2025
bc9b3c9
test(currency): restore the original tests in CurrencyFormatter.test.ts
richardfogaca Dec 3, 2025
9e01d32
Merge branch 'master' into feat-dynamic-currency
richardfogaca Dec 3, 2025
086afa7
refactor(currency): code clean up
richardfogaca Dec 4, 2025
54dac48
refactor(currency): several improvements to code quality and clean up
richardfogaca Dec 4, 2025
ecb3651
refactor(currency): Removed all stale is_currency_code references
richardfogaca Dec 4, 2025
afde8a1
refactor(currency): ensure consistent detected_currency API response
richardfogaca Dec 4, 2025
a9d6995
refactor(currency): detectedCurrency property type should be string |…
richardfogaca Dec 4, 2025
81b308c
ci(currency): fix linter issues
richardfogaca Dec 4, 2025
fd6fa38
Merge branch 'master' into feat-dynamic-currency
richardfogaca Dec 4, 2025
3f0b5a5
ci(currency): fix linter issues
richardfogaca Dec 4, 2025
0554970
test(currency): fix failing test
richardfogaca Dec 4, 2025
60059ff
refactor(currency): apply bito bot suggestions
richardfogaca Dec 4, 2025
58a21dc
refactor(currency): improvements to dynamic currency logic
richardfogaca Dec 5, 2025
9bd4b7f
refactor(currency): improvements to dynamic currency logic
richardfogaca Dec 5, 2025
ed98a32
fix(currency): resolve CI issues - mypy types, ruff imports, and test…
richardfogaca Dec 5, 2025
2cabd2b
ci: trigger actions
richardfogaca Dec 5, 2025
c5a8ff1
fix(currency): add currency_code_column to dashboard schema and updat…
richardfogaca Dec 5, 2025
e63c34f
ci: trigger pipeline
richardfogaca Dec 5, 2025
f0165e5
fix(dataset): skip AUTO currency validation when clearing currency_co…
richardfogaca Dec 5, 2025
b5706ec
Merge branch 'master' into feat-dynamic-currency
richardfogaca Dec 8, 2025
5b992bb
test: add coverage for dynamic currency detection feature
richardfogaca Dec 8, 2025
ab03cf9
fix(tests): resolve merge conflicts in DatasourceEditor tests
richardfogaca Jan 6, 2026
33ce2b2
fix(currency): replace any types, extract constants, improve code org…
richardfogaca Jan 6, 2026
3c0e6fb
fix(tests): replace any types with proper typing in Timeseries curren…
richardfogaca Jan 6, 2026
a06886d
fix(migrations): resolve multiple head revisions by rebasing currency…
richardfogaca Jan 6, 2026
832bd59
fix(prettier): apply prettier formatting
richardfogaca Jan 6, 2026
8abf362
fix(currency): address PR feedback - explicit catch returns, flat tes…
richardfogaca Jan 9, 2026
c189974
fix(currency): explicit symbolPosition handling for prefix/suffix wit…
richardfogaca Jan 9, 2026
d0ddc8f
fix(DatasourceEditor): use Flex component instead of styled component…
richardfogaca Jan 12, 2026
b941688
chore: rebase with master branch
richardfogaca Jan 12, 2026
594238e
fix(datasets): add currency_code_column to ImportV1DatasetSchema for …
richardfogaca Jan 13, 2026
776ab40
fix(currency): show auto currency on table charts
richardfogaca Jan 13, 2026
fbd2663
chore: rebase master branch
richardfogaca Jan 13, 2026
dabc514
fix(currency): restrict currency column injection to pivot_table_v2 only
richardfogaca Jan 14, 2026
2a9bafe
fix(currency): remove unused currency detection from legacy viz.py path
richardfogaca Jan 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,9 @@ const currency_format: SharedControlConfig<'CurrencyControl'> = {
type: 'CurrencyControl',
label: t('Currency format'),
renderTrigger: true,
description: t(
"Format metrics or columns with currency symbols as prefixes or suffixes. Choose a symbol manually or use 'Auto-detect' to apply the correct symbol based on the dataset's currency code column. When multiple currencies are present, formatting falls back to neutral numbers.",
),
};

const x_axis_time_format: SharedControlConfig<
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export interface Dataset {
currency_formats?: Record<string, Currency>;
verbose_map: Record<string, string>;
main_dttm_col: string;
currency_code_column?: string;
// eg. ['["ds", true]', 'ds [asc]']
order_by_choices?: [string, string][] | null;
time_grain_sqla?: [string, string][];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

export const AUTO_CURRENCY_SYMBOL = 'AUTO';

export const ISO_4217_REGEX = /^[A-Z]{3}$/;
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
import { ExtensibleFunction } from '../models';
import { getNumberFormatter, NumberFormats } from '../number-format';
import { Currency } from '../query';
import { RowData, RowDataValue } from './types';
import { AUTO_CURRENCY_SYMBOL, ISO_4217_REGEX } from './CurrencyFormats';

/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */

Expand All @@ -30,7 +32,11 @@ interface CurrencyFormatterConfig {
}

interface CurrencyFormatter {
(value: number | null | undefined): string;
(
value: number | null | undefined,
rowData?: RowData,
currencyColumn?: string,
): string;
}

export const getCurrencySymbol = (currency: Partial<Currency>) =>
Expand All @@ -41,6 +47,32 @@ export const getCurrencySymbol = (currency: Partial<Currency>) =>
.formatToParts(1)
.find(x => x.type === 'currency')?.value;

export function normalizeCurrency(value: RowDataValue): string | null {
if (value === null || value === undefined) return null;
if (typeof value !== 'string') return null;

const normalized = value.trim().toUpperCase();

return ISO_4217_REGEX.test(normalized) ? normalized : null;
}

export function hasMixedCurrencies(currencies: RowDataValue[]): boolean {
let first: string | null = null;

for (const c of currencies) {
const normalized = normalizeCurrency(c);
if (normalized === null) continue;

if (first === null) {
first = normalized;
} else if (normalized !== first) {
return true;
}
}

return false;
}

class CurrencyFormatter extends ExtensibleFunction {
d3Format: string;

Expand All @@ -49,7 +81,9 @@ class CurrencyFormatter extends ExtensibleFunction {
currency: Currency;

constructor(config: CurrencyFormatterConfig) {
super((value: number) => this.format(value));
super((value: number, rowData?: RowData, currencyColumn?: string) =>
this.format(value, rowData, currencyColumn),
);
this.d3Format = config.d3Format || NumberFormats.SMART_NUMBER;
this.currency = config.currency;
this.locale = config.locale || 'en-US';
Expand All @@ -67,19 +101,59 @@ class CurrencyFormatter extends ExtensibleFunction {
return value.replace(/%/g, '');
}

format(value: number) {
format(value: number, rowData?: RowData, currencyColumn?: string): string {
const formattedValue = getNumberFormatter(this.getNormalizedD3Format())(
value,
);
if (!this.hasValidCurrency()) {

const isAutoMode = this.currency?.symbol === AUTO_CURRENCY_SYMBOL;

if (!this.hasValidCurrency() && !isAutoMode) {
return formattedValue as string;
}

// Remove % signs from formatted value for currency display
const normalizedValue = this.normalizeForCurrency(formattedValue);
if (this.currency.symbolPosition === 'prefix') {
return `${getCurrencySymbol(this.currency)} ${normalizedValue}`;

if (isAutoMode) {
if (rowData && currencyColumn && rowData[currencyColumn]) {
const rawCurrency = rowData[currencyColumn];
const normalizedCurrency = normalizeCurrency(rawCurrency);

if (normalizedCurrency) {
try {
const symbol = getCurrencySymbol({ symbol: normalizedCurrency });
if (symbol) {
if (this.currency.symbolPosition === 'prefix') {
return `${symbol} ${normalizedValue}`;
} else if (this.currency.symbolPosition === 'suffix') {
return `${normalizedValue} ${symbol}`;
}
// Unknown symbolPosition - default to suffix
return `${normalizedValue} ${symbol}`;
}
} catch {
// Invalid currency code - return value without currency symbol
return formattedValue;
}
}
}
return formattedValue;
}

try {
const symbol = getCurrencySymbol(this.currency);
if (this.currency.symbolPosition === 'prefix') {
return `${symbol} ${normalizedValue}`;
} else if (this.currency.symbolPosition === 'suffix') {
return `${normalizedValue} ${symbol}`;
}
// Unknown symbolPosition - default to suffix
return `${normalizedValue} ${symbol}`;
} catch {
// Invalid currency code - return value without currency symbol
return formattedValue;
}
return `${normalizedValue} ${getCurrencySymbol(this.currency)}`;
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/*
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
Expand All @@ -18,5 +18,11 @@
*/

export { default as CurrencyFormatter } from './CurrencyFormatter';
export * from './CurrencyFormatter';
export {
getCurrencySymbol,
normalizeCurrency,
hasMixedCurrencies,
} from './CurrencyFormatter';
export { AUTO_CURRENCY_SYMBOL, ISO_4217_REGEX } from './CurrencyFormats';
export * from './types';
export * from './utils';
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

export type RowDataValue =
| string
| number
| boolean
| Date
| bigint
| null
| undefined;

export type RowData = Record<string, RowDataValue>;
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,104 @@ import {
QueryFormMetric,
ValueFormatter,
} from '@superset-ui/core';
import { normalizeCurrency, hasMixedCurrencies } from './CurrencyFormatter';
import { RowData, RowDataValue } from './types';
import { AUTO_CURRENCY_SYMBOL } from './CurrencyFormats';

export const analyzeCurrencyInData = (
data: RowData[],
currencyColumn: string | undefined,
): string | null => {
if (!currencyColumn || !data || data.length === 0) {
return null;
}

const currencies: RowDataValue[] = data
.map(row => row[currencyColumn])
.filter(val => val !== null && val !== undefined);

if (currencies.length === 0) {
return null;
}

if (hasMixedCurrencies(currencies)) {
return null;
}

return normalizeCurrency(currencies[0]);
};

export const resolveAutoCurrency = (
currencyFormat: Currency | undefined,
backendDetected: string | null | undefined,
data?: RowData[],
currencyCodeColumn?: string,
): Currency | undefined | null => {
if (currencyFormat?.symbol !== AUTO_CURRENCY_SYMBOL) return currencyFormat;

const detectedCurrency =
backendDetected ??
(data && currencyCodeColumn
? analyzeCurrencyInData(data, currencyCodeColumn)
: null);

if (detectedCurrency) {
return {
symbol: detectedCurrency,
symbolPosition: currencyFormat.symbolPosition,
};
}
return null; // Mixed currencies
};

const getEffectiveCurrencyFormat = (
resolvedCurrencyFormat: Currency | undefined | null,
savedFormat: Currency | undefined,
): Currency | undefined => {
if (resolvedCurrencyFormat === null) {
return undefined;
}
if (resolvedCurrencyFormat?.symbol) {
return resolvedCurrencyFormat;
}
return savedFormat;
};

export const buildCustomFormatters = (
metrics: QueryFormMetric | QueryFormMetric[] | undefined,
savedCurrencyFormats: Record<string, Currency>,
savedColumnFormats: Record<string, string>,
d3Format: string | undefined,
currencyFormat: Currency | undefined,
currencyFormat: Currency | undefined | null,
data?: RowData[],
currencyCodeColumn?: string,
) => {
const metricsArray = ensureIsArray(metrics);

let resolvedCurrencyFormat = currencyFormat;
if (
currencyFormat?.symbol === AUTO_CURRENCY_SYMBOL &&
data &&
currencyCodeColumn
) {
const detectedCurrency = analyzeCurrencyInData(data, currencyCodeColumn);
if (detectedCurrency) {
resolvedCurrencyFormat = {
symbol: detectedCurrency,
symbolPosition: currencyFormat.symbolPosition,
};
} else {
resolvedCurrencyFormat = null;
}
}

return metricsArray.reduce((acc, metric) => {
if (isSavedMetric(metric)) {
const actualD3Format = d3Format ?? savedColumnFormats[metric];
const actualCurrencyFormat = currencyFormat?.symbol
? currencyFormat
: savedCurrencyFormats[metric];
const actualCurrencyFormat = getEffectiveCurrencyFormat(
resolvedCurrencyFormat,
savedCurrencyFormats[metric],
);
return actualCurrencyFormat?.symbol
? {
...acc,
Expand Down Expand Up @@ -76,14 +159,40 @@ export const getValueFormatter = (
d3Format: string | undefined,
currencyFormat: Currency | undefined,
key?: string,
data?: RowData[],
currencyCodeColumn?: string,
detectedCurrency?: string | null,
) => {
let resolvedCurrencyFormat: Currency | undefined | null = currencyFormat;
if (currencyFormat?.symbol === AUTO_CURRENCY_SYMBOL) {
// Use backend-detected currency, or fallback to frontend analysis
if (detectedCurrency !== undefined) {
resolvedCurrencyFormat = detectedCurrency
? {
symbol: detectedCurrency,
symbolPosition: currencyFormat.symbolPosition,
}
: null;
} else if (data && currencyCodeColumn) {
const frontendDetected = analyzeCurrencyInData(data, currencyCodeColumn);
resolvedCurrencyFormat = frontendDetected
? {
symbol: frontendDetected,
symbolPosition: currencyFormat.symbolPosition,
}
: null;
} else {
resolvedCurrencyFormat = null;
}
}

const customFormatter = getCustomFormatter(
buildCustomFormatters(
metrics,
savedCurrencyFormats,
savedColumnFormats,
d3Format,
currencyFormat,
resolvedCurrencyFormat,
),
metrics,
key,
Expand All @@ -92,8 +201,14 @@ export const getValueFormatter = (
if (customFormatter) {
return customFormatter;
}
if (currencyFormat?.symbol) {
return new CurrencyFormatter({ currency: currencyFormat, d3Format });
if (resolvedCurrencyFormat === null) {
return getNumberFormatter(d3Format);
}
if (resolvedCurrencyFormat?.symbol) {
return new CurrencyFormatter({
currency: resolvedCurrencyFormat,
d3Format,
});
}
return getNumberFormatter(d3Format);
};
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export interface Datasource {
verboseMap?: {
[key: string]: string;
};
currencyCodeColumn?: string;
}

export const DEFAULT_METRICS: Metric[] = [
Expand Down
Loading
Loading