mirror of
https://github.com/apache/superset.git
synced 2026-04-17 15:15:20 +00:00
feat: Dynamic currency (#36416)
This commit is contained in:
committed by
GitHub
parent
896947c787
commit
f4474b2e3e
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -68,6 +68,8 @@ interface PivotTableCustomizeProps {
|
||||
rowSubTotals: boolean;
|
||||
valueFormat: string;
|
||||
currencyFormat: Currency;
|
||||
currencyCodeColumn?: string;
|
||||
detectedCurrency?: string | null;
|
||||
setDataMask: SetDataMaskHook;
|
||||
emitCrossFilters?: boolean;
|
||||
selectedFilters?: SelectedFiltersType;
|
||||
|
||||
Reference in New Issue
Block a user