feat: Dynamic currency (#36416)

This commit is contained in:
Richard Fogaca Nienkotter
2026-01-17 02:58:41 -03:00
committed by GitHub
parent 896947c787
commit f4474b2e3e
72 changed files with 3068 additions and 173 deletions

View File

@@ -22,15 +22,18 @@ import { t } from '@apache-superset/core';
import {
AdhocMetric,
BinaryQueryObjectFilterClause,
Currency,
CurrencyFormatter,
DataRecordValue,
FeatureFlag,
getColumnLabel,
getNumberFormatter,
getSelectedText,
hasMixedCurrencies,
isAdhocColumn,
isFeatureEnabled,
isPhysicalColumn,
normalizeCurrency,
NumberFormatter,
} from '@superset-ui/core';
import { styled, useTheme } from '@apache-superset/core/ui';
@@ -101,6 +104,71 @@ const StyledMinusSquareOutlined = styled(MinusSquareOutlined)`
stroke-width: 16px;
`;
/** Aggregator with currency tracking support */
interface CurrencyTrackingAggregator {
getCurrencies?: () => string[];
}
type BaseFormatter = NumberFormatter | CurrencyFormatter;
/** Create formatter that handles AUTO mode with per-cell currency detection */
const createCurrencyAwareFormatter = (
baseFormatter: BaseFormatter,
currencyConfig: Currency | undefined,
d3Format: string,
fallbackCurrency?: string,
): ((value: number, aggregator?: CurrencyTrackingAggregator) => string) => {
const isAutoMode = currencyConfig?.symbol === 'AUTO';
return (value: number, aggregator?: CurrencyTrackingAggregator): string => {
// If not AUTO mode, use base formatter directly
if (!isAutoMode) {
return baseFormatter(value);
}
// AUTO mode: check aggregator for currency tracking
if (aggregator && typeof aggregator.getCurrencies === 'function') {
const currencies = aggregator.getCurrencies();
if (currencies && currencies.length > 0) {
if (hasMixedCurrencies(currencies)) {
return getNumberFormatter(d3Format)(value);
}
const detectedCurrency = normalizeCurrency(currencies[0]);
if (detectedCurrency && currencyConfig) {
const cellFormatter = new CurrencyFormatter({
currency: {
symbol: detectedCurrency,
symbolPosition: currencyConfig.symbolPosition,
},
d3Format,
});
return cellFormatter(value);
}
}
}
// Fallback: use detected_currency from API response if available
if (fallbackCurrency && currencyConfig) {
const normalizedFallback = normalizeCurrency(fallbackCurrency);
if (normalizedFallback) {
const fallbackFormatter = new CurrencyFormatter({
currency: {
symbol: normalizedFallback,
symbolPosition: currencyConfig.symbolPosition,
},
d3Format,
});
return fallbackFormatter(value);
}
}
// Final fallback to neutral format
return getNumberFormatter(d3Format)(value);
};
};
const aggregatorsFactory = (formatter: NumberFormatter) => ({
Count: aggregatorTemplates.count(formatter),
'Count Unique Values': aggregatorTemplates.countUnique(formatter),
@@ -171,6 +239,8 @@ export default function PivotTableChart(props: PivotTableProps) {
rowSubTotals,
valueFormat,
currencyFormat,
currencyCodeColumn,
detectedCurrency,
emitCrossFilters,
setDataMask,
selectedFilters,
@@ -186,9 +256,11 @@ export default function PivotTableChart(props: PivotTableProps) {
} = props;
const theme = useTheme();
const defaultFormatter = useMemo(
// Base formatter without currency-awareness (for non-AUTO mode or as fallback)
const baseFormatter = useMemo(
() =>
currencyFormat?.symbol
currencyFormat?.symbol && currencyFormat.symbol !== 'AUTO'
? new CurrencyFormatter({
currency: currencyFormat,
d3Format: valueFormat,
@@ -196,6 +268,18 @@ export default function PivotTableChart(props: PivotTableProps) {
: getNumberFormatter(valueFormat),
[valueFormat, currencyFormat],
);
// Currency-aware formatter for AUTO mode support
const defaultFormatter = useMemo(
() =>
createCurrencyAwareFormatter(
baseFormatter,
currencyFormat,
valueFormat,
detectedCurrency ?? undefined,
),
[baseFormatter, currencyFormat, valueFormat, detectedCurrency],
);
const customFormatsArray = useMemo(
() =>
Array.from(
@@ -216,19 +300,31 @@ export default function PivotTableChart(props: PivotTableProps) {
hasCustomMetricFormatters
? {
[METRIC_KEY]: Object.fromEntries(
customFormatsArray.map(([metric, d3Format, currency]) => [
metric,
currency
? new CurrencyFormatter({
currency,
d3Format,
})
: getNumberFormatter(d3Format),
]),
customFormatsArray.map(([metric, d3Format, currency]) => {
// Create base formatter
const metricBaseFormatter =
currency && (currency as Currency).symbol !== 'AUTO'
? new CurrencyFormatter({
currency: currency as Currency,
d3Format: d3Format as string,
})
: getNumberFormatter(d3Format as string);
// Wrap with currency-aware formatter for AUTO mode support
return [
metric,
createCurrencyAwareFormatter(
metricBaseFormatter,
currency as Currency | undefined,
d3Format as string,
detectedCurrency ?? undefined,
),
];
}),
),
}
: undefined,
[customFormatsArray, hasCustomMetricFormatters],
[customFormatsArray, hasCustomMetricFormatters, detectedCurrency],
);
const metricNames = useMemo(
@@ -249,12 +345,14 @@ export default function PivotTableChart(props: PivotTableProps) {
...record,
[METRIC_KEY]: name,
value: record[name],
// Mark currency column for per-cell currency detection in aggregators
__currencyColumn: currencyCodeColumn,
}))
.filter(record => record.value !== null),
],
[],
),
[data, metricNames],
[data, metricNames, currencyCodeColumn],
);
const groupbyRows = useMemo(
() => groupbyRowsRaw.map(getColumnLabel),

View File

@@ -79,11 +79,21 @@ export default function transformProps(chartProps: ChartProps<QueryFormData>) {
rawFormData,
hooks: { setDataMask = () => {}, onContextMenu },
filterState,
datasource: { verboseMap = {}, columnFormats = {}, currencyFormats = {} },
datasource: {
verboseMap = {},
columnFormats = {},
currencyFormats = {},
currencyCodeColumn,
},
emitCrossFilters,
theme,
} = chartProps;
const { data, colnames, coltypes } = queriesData[0];
const {
data,
colnames,
coltypes,
detected_currency: detectedCurrency,
} = queriesData[0];
const {
groupbyRows,
groupbyColumns,
@@ -148,6 +158,8 @@ export default function transformProps(chartProps: ChartProps<QueryFormData>) {
theme,
);
// AUTO symbol passed through - PivotTableChart handles per-cell currency detection
return {
width,
height,
@@ -169,6 +181,8 @@ export default function transformProps(chartProps: ChartProps<QueryFormData>) {
rowSubTotals,
valueFormat,
currencyFormat,
currencyCodeColumn,
detectedCurrency,
emitCrossFilters,
setDataMask,
selectedFilters,

View File

@@ -749,7 +749,7 @@ export class TableRenderer extends Component {
onContextMenu={e => this.props.onContextMenu(e, colKey, rowKey)}
style={style}
>
{displayCell(agg.format(aggValue), allowRenderHtml)}
{displayCell(agg.format(aggValue, agg), allowRenderHtml)}
</td>
);
});
@@ -766,7 +766,7 @@ export class TableRenderer extends Component {
onClick={rowTotalCallbacks[flatRowKey]}
onContextMenu={e => this.props.onContextMenu(e, undefined, rowKey)}
>
{displayCell(agg.format(aggValue), allowRenderHtml)}
{displayCell(agg.format(aggValue, agg), allowRenderHtml)}
</td>
);
}
@@ -830,7 +830,7 @@ export class TableRenderer extends Component {
onContextMenu={e => this.props.onContextMenu(e, colKey, undefined)}
style={{ padding: '5px' }}
>
{displayCell(agg.format(aggValue), this.props.allowRenderHtml)}
{displayCell(agg.format(aggValue, agg), this.props.allowRenderHtml)}
</td>
);
});
@@ -847,7 +847,7 @@ export class TableRenderer extends Component {
onClick={grandTotalCallback}
onContextMenu={e => this.props.onContextMenu(e, undefined, undefined)}
>
{displayCell(agg.format(aggValue), this.props.allowRenderHtml)}
{displayCell(agg.format(aggValue, agg), this.props.allowRenderHtml)}
</td>
);
}

View File

@@ -186,9 +186,13 @@ const usFmtPct = numberFormat({
suffix: '%',
});
const fmtNonString = formatter => x =>
typeof x === 'string' ? x : formatter(x);
const fmtNonString = formatter => (x, aggregator) =>
typeof x === 'string' ? x : formatter(x, aggregator);
/*
* Aggregators track currencies via push() and expose them via getCurrencies()
* for per-cell currency detection in AUTO mode.
*/
const baseAggregatorTemplates = {
count(formatter = usFmtInt) {
return () =>
@@ -211,14 +215,21 @@ const baseAggregatorTemplates = {
return function () {
return {
uniq: [],
currencySet: new Set(),
push(record) {
if (!Array.from(this.uniq).includes(record[attr])) {
this.uniq.push(record[attr]);
}
if (record.__currencyColumn && record[record.__currencyColumn]) {
this.currencySet.add(record[record.__currencyColumn]);
}
},
value() {
return fn(this.uniq);
},
getCurrencies() {
return Array.from(this.currencySet);
},
format: fmtNonString(formatter),
numInputs: typeof attr !== 'undefined' ? 0 : 1,
};
@@ -231,16 +242,23 @@ const baseAggregatorTemplates = {
return function () {
return {
sum: 0,
currencySet: new Set(),
push(record) {
if (Number.isNaN(Number(record[attr]))) {
this.sum = record[attr];
} else {
this.sum += parseFloat(record[attr]);
}
if (record.__currencyColumn && record[record.__currencyColumn]) {
this.currencySet.add(record[record.__currencyColumn]);
}
},
value() {
return this.sum;
},
getCurrencies() {
return Array.from(this.currencySet);
},
format: fmtNonString(formatter),
numInputs: typeof attr !== 'undefined' ? 0 : 1,
};
@@ -253,6 +271,7 @@ const baseAggregatorTemplates = {
return function (data) {
return {
val: null,
currencySet: new Set(),
sorter: getSort(
typeof data !== 'undefined' ? data.sorters : null,
attr,
@@ -285,10 +304,16 @@ const baseAggregatorTemplates = {
) {
this.val = x;
}
if (record.__currencyColumn && record[record.__currencyColumn]) {
this.currencySet.add(record[record.__currencyColumn]);
}
},
value() {
return this.val;
},
getCurrencies() {
return Array.from(this.currencySet);
},
format(x) {
if (typeof x === 'number') {
return formatter(x);
@@ -307,6 +332,7 @@ const baseAggregatorTemplates = {
return {
vals: [],
strMap: {},
currencySet: new Set(),
push(record) {
const val = record[attr];
const x = Number(val);
@@ -316,6 +342,9 @@ const baseAggregatorTemplates = {
} else {
this.vals.push(x);
}
if (record.__currencyColumn && record[record.__currencyColumn]) {
this.currencySet.add(record[record.__currencyColumn]);
}
},
value() {
if (
@@ -339,6 +368,9 @@ const baseAggregatorTemplates = {
const i = (this.vals.length - 1) * q;
return (this.vals[Math.floor(i)] + this.vals[Math.ceil(i)]) / 2.0;
},
getCurrencies() {
return Array.from(this.currencySet);
},
format: fmtNonString(formatter),
numInputs: typeof attr !== 'undefined' ? 0 : 1,
};
@@ -354,11 +386,15 @@ const baseAggregatorTemplates = {
m: 0.0,
s: 0.0,
strValue: null,
currencySet: new Set(),
push(record) {
const x = Number(record[attr]);
if (Number.isNaN(x)) {
this.strValue =
typeof record[attr] === 'string' ? record[attr] : this.strValue;
if (record.__currencyColumn && record[record.__currencyColumn]) {
this.currencySet.add(record[record.__currencyColumn]);
}
return;
}
this.n += 1.0;
@@ -368,6 +404,9 @@ const baseAggregatorTemplates = {
const mNew = this.m + (x - this.m) / this.n;
this.s += (x - this.m) * (x - mNew);
this.m = mNew;
if (record.__currencyColumn && record[record.__currencyColumn]) {
this.currencySet.add(record[record.__currencyColumn]);
}
},
value() {
if (this.strValue) {
@@ -392,6 +431,9 @@ const baseAggregatorTemplates = {
throw new Error('unknown mode for runningStat');
}
},
getCurrencies() {
return Array.from(this.currencySet);
},
format: fmtNonString(formatter),
numInputs: typeof attr !== 'undefined' ? 0 : 1,
};
@@ -405,6 +447,7 @@ const baseAggregatorTemplates = {
return {
sumNum: 0,
sumDenom: 0,
currencySet: new Set(),
push(record) {
if (!Number.isNaN(Number(record[num]))) {
this.sumNum += parseFloat(record[num]);
@@ -412,10 +455,16 @@ const baseAggregatorTemplates = {
if (!Number.isNaN(Number(record[denom]))) {
this.sumDenom += parseFloat(record[denom]);
}
if (record.__currencyColumn && record[record.__currencyColumn]) {
this.currencySet.add(record[record.__currencyColumn]);
}
},
value() {
return this.sumNum / this.sumDenom;
},
getCurrencies() {
return Array.from(this.currencySet);
},
format: formatter,
numInputs:
typeof num !== 'undefined' && typeof denom !== 'undefined' ? 0 : 2,
@@ -447,6 +496,9 @@ const baseAggregatorTemplates = {
return this.inner.value() / acc;
},
getCurrencies() {
return this.inner.getCurrencies ? this.inner.getCurrencies() : [];
},
numInputs: wrapped(...Array.from(x || []))().numInputs,
};
};

View File

@@ -68,6 +68,8 @@ interface PivotTableCustomizeProps {
rowSubTotals: boolean;
valueFormat: string;
currencyFormat: Currency;
currencyCodeColumn?: string;
detectedCurrency?: string | null;
setDataMask: SetDataMaskHook;
emitCrossFilters?: boolean;
selectedFilters?: SelectedFiltersType;