feat: Implement currencies formatter for saved metrics (#24517)

This commit is contained in:
Kamil Gabryjelski 2023-06-28 20:51:40 +02:00 committed by GitHub
parent e402c94a9f
commit 83ff4cd86a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
61 changed files with 906 additions and 75 deletions

View File

@ -61705,12 +61705,14 @@
"regenerator-runtime": "^0.13.7",
"xss": "^1.0.10"
},
"devDependencies": {
"@testing-library/react": "^11.2.0"
},
"peerDependencies": {
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@testing-library/dom": "^7.29.4",
"@testing-library/jest-dom": "^5.11.6",
"@testing-library/react": "^11.2.0",
"@testing-library/react-hooks": "^5.0.3",
"@testing-library/user-event": "^12.7.0",
"@types/classnames": "*",
"@types/react": "*",
"react": "^16.13.1",
@ -77467,7 +77469,6 @@
"version": "file:plugins/plugin-chart-table",
"requires": {
"@react-icons/all-files": "^4.1.0",
"@testing-library/react": "^11.2.0",
"@types/d3-array": "^2.9.0",
"@types/enzyme": "^3.10.5",
"@types/react-table": "^7.0.29",

View File

@ -21,6 +21,7 @@ import { Dataset } from './types';
export const TestDataset: Dataset = {
column_formats: {},
currency_formats: {},
columns: [
{
advanced_data_type: undefined,
@ -123,6 +124,7 @@ export const TestDataset: Dataset = {
certification_details: null,
certified_by: null,
d3format: null,
currency: null,
description: null,
expression: 'COUNT(*)',
id: 7,

View File

@ -21,6 +21,7 @@ import React, { ReactElement, ReactNode, ReactText } from 'react';
import type {
AdhocColumn,
Column,
Currency,
DatasourceType,
JsonObject,
JsonValue,
@ -68,6 +69,7 @@ export interface Dataset {
columns: ColumnMeta[];
metrics: Metric[];
column_formats: Record<string, string>;
currency_formats: Record<string, Currency>;
verbose_map: Record<string, string>;
main_dttm_col: string;
// eg. ['["ds", true]', 'ds [asc]']

View File

@ -43,6 +43,7 @@ describe('columnChoices()', () => {
],
verbose_map: {},
column_formats: { fiz: 'NUMERIC', about: 'STRING', foo: 'DATE' },
currency_formats: {},
datasource_name: 'my_datasource',
description: 'this is my datasource',
}),

View File

@ -40,6 +40,7 @@ describe('defineSavedMetrics', () => {
columns: [],
verbose_map: {},
column_formats: {},
currency_formats: {},
datasource_name: 'my_datasource',
description: 'this is my datasource',
};

View File

@ -0,0 +1,79 @@
/**
* 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 { ExtensibleFunction } from '../models';
import { getNumberFormatter, NumberFormats } from '../number-format';
import { Currency } from '../query';
interface CurrencyFormatterConfig {
d3Format?: string;
currency: Currency;
locale?: string;
}
interface CurrencyFormatter {
(value: number | null | undefined): string;
}
export const getCurrencySymbol = (currency: Currency) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.symbol,
})
.formatToParts(1)
.find(x => x.type === 'currency')?.value;
class CurrencyFormatter extends ExtensibleFunction {
d3Format: string;
locale: string;
currency: Currency;
constructor(config: CurrencyFormatterConfig) {
super((value: number) => this.format(value));
this.d3Format = config.d3Format || NumberFormats.SMART_NUMBER;
this.currency = config.currency;
this.locale = config.locale || 'en-US';
}
hasValidCurrency() {
return Boolean(this.currency?.symbol);
}
getNormalizedD3Format() {
return this.d3Format.replace(/\$|%/g, '');
}
format(value: number) {
const formattedValue = getNumberFormatter(this.getNormalizedD3Format())(
value,
);
if (!this.hasValidCurrency()) {
return formattedValue as string;
}
if (this.currency.symbolPosition === 'prefix') {
return `${getCurrencySymbol(this.currency)} ${formattedValue}`;
}
return `${formattedValue} ${getCurrencySymbol(this.currency)}`;
}
}
export default CurrencyFormatter;

View File

@ -0,0 +1,21 @@
/*
* 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 { default as CurrencyFormatter } from './CurrencyFormatter';
export * from './CurrencyFormatter';

View File

@ -36,3 +36,4 @@ export * from './components';
export * from './math-expression';
export * from './ui-overrides';
export * from './hooks';
export * from './currency-format';

View File

@ -27,6 +27,11 @@ export enum DatasourceType {
SavedQuery = 'saved_query',
}
export interface Currency {
symbol: string;
symbolPosition: string;
}
/**
* Datasource metadata.
*/
@ -41,6 +46,9 @@ export interface Datasource {
columnFormats?: {
[key: string]: string;
};
currencyFormats?: {
[key: string]: Currency;
};
verboseMap?: {
[key: string]: string;
};

View File

@ -65,6 +65,7 @@ export interface Metric {
certification_details?: Maybe<string>;
certified_by?: Maybe<string>;
d3format?: Maybe<string>;
currency?: Maybe<string>;
description?: Maybe<string>;
is_certified?: boolean;
verbose_name?: string;

View File

@ -16,6 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import { NumberFormatter } from '../number-format';
import { CurrencyFormatter } from '../currency-format';
export * from '../query/types';
export type Maybe<T> = T | null;
@ -23,3 +26,5 @@ export type Maybe<T> = T | null;
export type Optional<T> = T | undefined;
export type ValueOf<T> = T[keyof T];
export type ValueFormatter = NumberFormatter | CurrencyFormatter;

View File

@ -0,0 +1,158 @@
/*
* 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,
getCurrencySymbol,
NumberFormats,
} from '@superset-ui/core';
it('getCurrencySymbol', () => {
expect(
getCurrencySymbol({ symbol: 'PLN', symbolPosition: 'prefix' }),
).toEqual('PLN');
expect(
getCurrencySymbol({ symbol: 'USD', symbolPosition: 'prefix' }),
).toEqual('$');
expect(() =>
getCurrencySymbol({ symbol: 'INVALID_CODE', symbolPosition: 'prefix' }),
).toThrow(RangeError);
});
it('CurrencyFormatter object fields', () => {
const defaultCurrencyFormatter = new CurrencyFormatter({
currency: { symbol: 'USD', symbolPosition: 'prefix' },
});
expect(defaultCurrencyFormatter.d3Format).toEqual(NumberFormats.SMART_NUMBER);
expect(defaultCurrencyFormatter.locale).toEqual('en-US');
expect(defaultCurrencyFormatter.currency).toEqual({
symbol: 'USD',
symbolPosition: 'prefix',
});
const currencyFormatter = new CurrencyFormatter({
currency: { symbol: 'PLN', symbolPosition: 'suffix' },
locale: 'pl-PL',
d3Format: ',.1f',
});
expect(currencyFormatter.d3Format).toEqual(',.1f');
expect(currencyFormatter.locale).toEqual('pl-PL');
expect(currencyFormatter.currency).toEqual({
symbol: 'PLN',
symbolPosition: 'suffix',
});
});
it('CurrencyFormatter:hasValidCurrency', () => {
const currencyFormatter = new CurrencyFormatter({
currency: { symbol: 'USD', symbolPosition: 'prefix' },
});
expect(currencyFormatter.hasValidCurrency()).toBe(true);
const currencyFormatterWithoutPosition = new CurrencyFormatter({
// @ts-ignore
currency: { symbol: 'USD' },
});
expect(currencyFormatterWithoutPosition.hasValidCurrency()).toBe(true);
const currencyFormatterWithoutSymbol = new CurrencyFormatter({
// @ts-ignore
currency: { symbolPosition: 'prefix' },
});
expect(currencyFormatterWithoutSymbol.hasValidCurrency()).toBe(false);
// @ts-ignore
const currencyFormatterWithoutCurrency = new CurrencyFormatter({});
expect(currencyFormatterWithoutCurrency.hasValidCurrency()).toBe(false);
});
it('CurrencyFormatter:getNormalizedD3Format', () => {
const currencyFormatter = new CurrencyFormatter({
currency: { symbol: 'USD', symbolPosition: 'prefix' },
});
expect(currencyFormatter.getNormalizedD3Format()).toEqual(
currencyFormatter.d3Format,
);
const currencyFormatter2 = new CurrencyFormatter({
currency: { symbol: 'USD', symbolPosition: 'prefix' },
d3Format: ',.1f',
});
expect(currencyFormatter2.getNormalizedD3Format()).toEqual(
currencyFormatter2.d3Format,
);
const currencyFormatter3 = new CurrencyFormatter({
currency: { symbol: 'USD', symbolPosition: 'prefix' },
d3Format: '$,.1f',
});
expect(currencyFormatter3.getNormalizedD3Format()).toEqual(',.1f');
const currencyFormatter4 = new CurrencyFormatter({
currency: { symbol: 'USD', symbolPosition: 'prefix' },
d3Format: ',.1%',
});
expect(currencyFormatter4.getNormalizedD3Format()).toEqual(',.1');
});
it('CurrencyFormatter:format', () => {
const VALUE = 56100057;
const currencyFormatterWithPrefix = new CurrencyFormatter({
currency: { symbol: 'USD', symbolPosition: 'prefix' },
});
expect(currencyFormatterWithPrefix(VALUE)).toEqual(
currencyFormatterWithPrefix.format(VALUE),
);
expect(currencyFormatterWithPrefix(VALUE)).toEqual('$ 56.1M');
const currencyFormatterWithSuffix = new CurrencyFormatter({
currency: { symbol: 'USD', symbolPosition: 'suffix' },
});
expect(currencyFormatterWithSuffix(VALUE)).toEqual('56.1M $');
const currencyFormatterWithoutPosition = new CurrencyFormatter({
// @ts-ignore
currency: { symbol: 'USD' },
});
expect(currencyFormatterWithoutPosition(VALUE)).toEqual('56.1M $');
// @ts-ignore
const currencyFormatterWithoutCurrency = new CurrencyFormatter({});
expect(currencyFormatterWithoutCurrency(VALUE)).toEqual('56.1M');
const currencyFormatterWithCustomD3 = new CurrencyFormatter({
currency: { symbol: 'USD', symbolPosition: 'prefix' },
d3Format: ',.1f',
});
expect(currencyFormatterWithCustomD3(VALUE)).toEqual('$ 56,100,057.0');
const currencyFormatterWithPercentD3 = new CurrencyFormatter({
currency: { symbol: 'USD', symbolPosition: 'prefix' },
d3Format: ',.1f%',
});
expect(currencyFormatterWithPercentD3(VALUE)).toEqual('$ 56,100,057.0');
const currencyFormatterWithCurrencyD3 = new CurrencyFormatter({
currency: { symbol: 'PLN', symbolPosition: 'suffix' },
d3Format: '$,.1f',
});
expect(currencyFormatterWithCurrencyD3(VALUE)).toEqual('56,100,057.0 PLN');
});

View File

@ -19,9 +19,9 @@
import {
ColorFormatters,
getColorFormatters,
Metric,
} from '@superset-ui/chart-controls';
import {
getNumberFormatter,
GenericDataType,
getMetricLabel,
extractTimegrain,
@ -30,12 +30,20 @@ import {
import { BigNumberTotalChartProps, BigNumberVizProps } from '../types';
import { getDateFormatter, parseMetricValue } from '../utils';
import { Refs } from '../../types';
import { getValueFormatter } from '../../utils/valueFormatter';
export default function transformProps(
chartProps: BigNumberTotalChartProps,
): BigNumberVizProps {
const { width, height, queriesData, formData, rawFormData, hooks } =
chartProps;
const {
width,
height,
queriesData,
formData,
rawFormData,
hooks,
datasource: { currencyFormats = {}, columnFormats = {} },
} = chartProps;
const {
headerFontSize,
metric = 'value',
@ -54,7 +62,7 @@ export default function transformProps(
const bigNumber =
data.length === 0 ? null : parseMetricValue(data[0][metricName]);
let metricEntry;
let metricEntry: Metric | undefined;
if (chartProps.datasource?.metrics) {
metricEntry = chartProps.datasource.metrics.find(
metricItem => metricItem.metric_name === metric,
@ -67,12 +75,19 @@ export default function transformProps(
metricEntry?.d3format,
);
const numberFormatter = getValueFormatter(
metric,
currencyFormats,
columnFormats,
yAxisFormat,
);
const headerFormatter =
coltypes[0] === GenericDataType.TEMPORAL ||
coltypes[0] === GenericDataType.STRING ||
forceTimestampFormatting
? formatTime
: getNumberFormatter(yAxisFormat ?? metricEntry?.d3format ?? undefined);
: numberFormatter;
const { onContextMenu } = hooks;

View File

@ -24,9 +24,10 @@ import {
getMetricLabel,
t,
smartDateVerboseFormatter,
NumberFormatter,
TimeFormatter,
getXAxisLabel,
Metric,
ValueFormatter,
} from '@superset-ui/core';
import { EChartsCoreOption, graphic } from 'echarts';
import {
@ -38,11 +39,12 @@ import {
import { getDateFormatter, parseMetricValue } from '../utils';
import { getDefaultTooltip } from '../../utils/tooltip';
import { Refs } from '../../types';
import { getValueFormatter } from '../../utils/valueFormatter';
const defaultNumberFormatter = getNumberFormatter();
export function renderTooltipFactory(
formatDate: TimeFormatter = smartDateVerboseFormatter,
formatValue: NumberFormatter | TimeFormatter = defaultNumberFormatter,
formatValue: ValueFormatter | TimeFormatter = defaultNumberFormatter,
) {
return function renderTooltip(params: { data: TimeSeriesDatum }[]) {
return `
@ -73,6 +75,7 @@ export default function transformProps(
theme,
hooks,
inContextMenu,
datasource: { currencyFormats = {}, columnFormats = {} },
} = chartProps;
const {
colorPicker,
@ -159,7 +162,7 @@ export default function transformProps(
className = 'negative';
}
let metricEntry;
let metricEntry: Metric | undefined;
if (chartProps.datasource?.metrics) {
metricEntry = chartProps.datasource.metrics.find(
metricEntry => metricEntry.metric_name === metric,
@ -172,12 +175,19 @@ export default function transformProps(
metricEntry?.d3format,
);
const numberFormatter = getValueFormatter(
metric,
currencyFormats,
columnFormats,
yAxisFormat,
);
const headerFormatter =
metricColtype === GenericDataType.TEMPORAL ||
metricColtype === GenericDataType.STRING ||
forceTimestampFormatting
? formatTime
: getNumberFormatter(yAxisFormat ?? metricEntry?.d3format ?? undefined);
: numberFormatter;
if (trendLineData && timeRangeFixed && fromDatetime) {
const toDatetimeOrToday = toDatetime ?? Date.now();

View File

@ -22,10 +22,10 @@ import {
ChartDataResponseResult,
ContextMenuFilters,
DataRecordValue,
NumberFormatter,
QueryFormData,
QueryFormMetric,
TimeFormatter,
ValueFormatter,
} from '@superset-ui/core';
import { ColorFormatters } from '@superset-ui/chart-controls';
import { BaseChartProps, Refs } from '../types';
@ -73,7 +73,7 @@ export type BigNumberVizProps = {
height: number;
bigNumber?: DataRecordValue;
bigNumberFallback?: TimeSeriesDatum;
headerFormatter: NumberFormatter | TimeFormatter;
headerFormatter: ValueFormatter | TimeFormatter;
formatTime?: TimeFormatter;
headerFontSize: number;
kickerFontSize?: number;

View File

@ -22,7 +22,7 @@ import {
getMetricLabel,
getNumberFormatter,
NumberFormats,
NumberFormatter,
ValueFormatter,
getColumnLabel,
} from '@superset-ui/core';
import { CallbackDataParams } from 'echarts/types/src/util/types';
@ -45,6 +45,7 @@ import { defaultGrid } from '../defaults';
import { OpacityEnum, DEFAULT_LEGEND_FORM_DATA } from '../constants';
import { getDefaultTooltip } from '../utils/tooltip';
import { Refs } from '../types';
import { getValueFormatter } from '../utils/valueFormatter';
const percentFormatter = getNumberFormatter(NumberFormats.PERCENT_2_POINT);
@ -56,7 +57,7 @@ export function formatFunnelLabel({
}: {
params: Pick<CallbackDataParams, 'name' | 'value' | 'percent'>;
labelType: EchartsFunnelLabelTypeType;
numberFormatter: NumberFormatter;
numberFormatter: ValueFormatter;
sanitizeName?: boolean;
}): string {
const { name: rawName = '', value, percent } = params;
@ -94,6 +95,7 @@ export default function transformProps(
theme,
inContextMenu,
emitCrossFilters,
datasource,
} = chartProps;
const data: DataRecord[] = queriesData[0].data || [];
const coltypeMapping = getColtypesMapping(queriesData[0]);
@ -118,6 +120,7 @@ export default function transformProps(
...DEFAULT_FUNNEL_FORM_DATA,
...formData,
};
const { currencyFormats = {}, columnFormats = {} } = datasource;
const refs: Refs = {};
const metricLabel = getMetricLabel(metric);
const groupbyLabels = groupby.map(getColumnLabel);
@ -139,7 +142,12 @@ export default function transformProps(
const { setDataMask = () => {}, onContextMenu } = hooks;
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
const numberFormatter = getNumberFormatter(numberFormat);
const numberFormatter = getValueFormatter(
metric,
currencyFormats,
columnFormats,
numberFormat,
);
const transformedData: FunnelSeriesOption[] = data.map(datum => {
const name = extractGroupbyLabel({

View File

@ -21,7 +21,6 @@ import {
CategoricalColorNamespace,
CategoricalColorScale,
DataRecord,
getNumberFormatter,
getMetricLabel,
getColumnLabel,
} from '@superset-ui/core';
@ -47,6 +46,7 @@ import { OpacityEnum } from '../constants';
import { getDefaultTooltip } from '../utils/tooltip';
import { Refs } from '../types';
import { getColtypesMapping } from '../utils/series';
import { getValueFormatter } from '../utils/valueFormatter';
const setIntervalBoundsAndColors = (
intervals: string,
@ -105,7 +105,11 @@ export default function transformProps(
} = chartProps;
const gaugeSeriesOptions = defaultGaugeSeriesOption(theme);
const { verboseMap = {} } = datasource;
const {
verboseMap = {},
currencyFormats = {},
columnFormats = {},
} = datasource;
const {
groupby,
metric,
@ -132,7 +136,12 @@ export default function transformProps(
const refs: Refs = {};
const data = (queriesData[0]?.data || []) as DataRecord[];
const coltypeMapping = getColtypesMapping(queriesData[0]);
const numberFormatter = getNumberFormatter(numberFormat);
const numberFormatter = getValueFormatter(
metric,
currencyFormats,
columnFormats,
numberFormat,
);
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
const axisLineWidth = calculateAxisLineWidth(data, fontSize, overlap);
const groupbyLabels = groupby.map(getColumnLabel);

View File

@ -23,8 +23,8 @@ import {
getNumberFormatter,
getTimeFormatter,
NumberFormats,
NumberFormatter,
t,
ValueFormatter,
} from '@superset-ui/core';
import { CallbackDataParams } from 'echarts/types/src/util/types';
import { EChartsCoreOption, PieSeriesOption } from 'echarts';
@ -47,6 +47,7 @@ import { defaultGrid } from '../defaults';
import { convertInteger } from '../utils/convertInteger';
import { getDefaultTooltip } from '../utils/tooltip';
import { Refs } from '../types';
import { getValueFormatter } from '../utils/valueFormatter';
const percentFormatter = getNumberFormatter(NumberFormats.PERCENT_2_POINT);
@ -58,7 +59,7 @@ export function formatPieLabel({
}: {
params: Pick<CallbackDataParams, 'name' | 'value' | 'percent'>;
labelType: EchartsPieLabelType;
numberFormatter: NumberFormatter;
numberFormatter: ValueFormatter;
sanitizeName?: boolean;
}): string {
const { name: rawName = '', value, percent } = params;
@ -145,7 +146,9 @@ export default function transformProps(
theme,
inContextMenu,
emitCrossFilters,
datasource,
} = chartProps;
const { columnFormats = {}, currencyFormats = {} } = datasource;
const { data = [] } = queriesData[0];
const coltypeMapping = getColtypesMapping(queriesData[0]);
@ -203,7 +206,13 @@ export default function transformProps(
const { setDataMask = () => {}, onContextMenu } = hooks;
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
const numberFormatter = getNumberFormatter(numberFormat);
const numberFormatter = getValueFormatter(
metric,
currencyFormats,
columnFormats,
numberFormat,
);
let totalValue = 0;
const transformedData: PieSeriesOption[] = data.map(datum => {

View File

@ -22,6 +22,7 @@ import {
AnnotationLayer,
AxisType,
CategoricalColorNamespace,
CurrencyFormatter,
ensureIsArray,
GenericDataType,
getMetricLabel,
@ -32,9 +33,13 @@ import {
isFormulaAnnotationLayer,
isIntervalAnnotationLayer,
isPhysicalColumn,
isSavedMetric,
isTimeseriesAnnotationLayer,
NumberFormats,
QueryFormMetric,
t,
TimeseriesChartDataResponseResult,
ValueFormatter,
} from '@superset-ui/core';
import {
extractExtraMetrics,
@ -92,6 +97,36 @@ import {
TIMEGRAIN_TO_TIMESTAMP,
} from '../constants';
import { getDefaultTooltip } from '../utils/tooltip';
import {
buildCustomFormatters,
getCustomFormatter,
} from '../utils/valueFormatter';
const getYAxisFormatter = (
metrics: QueryFormMetric[],
forcePercentFormatter: boolean,
customFormatters: Record<string, ValueFormatter>,
yAxisFormat: string = NumberFormats.SMART_NUMBER,
) => {
if (forcePercentFormatter) {
return getNumberFormatter(',.0%');
}
const metricsArray = ensureIsArray(metrics);
if (
metricsArray.every(isSavedMetric) &&
metricsArray
.map(metric => customFormatters[metric])
.every(
(formatter, _, formatters) =>
formatter instanceof CurrencyFormatter &&
(formatter as CurrencyFormatter)?.currency?.symbol ===
(formatters[0] as CurrencyFormatter)?.currency?.symbol,
)
) {
return customFormatters[metricsArray[0]];
}
return getNumberFormatter(yAxisFormat);
};
export default function transformProps(
chartProps: EchartsTimeseriesChartProps,
@ -109,7 +144,11 @@ export default function transformProps(
inContextMenu,
emitCrossFilters,
} = chartProps;
const { verboseMap = {} } = datasource;
const {
verboseMap = {},
columnFormats = {},
currencyFormats = {},
} = datasource;
const [queryData] = queriesData;
const { data = [], label_map = {} } =
queryData as TimeseriesChartDataResponseResult;
@ -232,8 +271,15 @@ export default function transformProps(
const xAxisType = getAxisType(xAxisDataType);
const series: SeriesOption[] = [];
const formatter = getNumberFormatter(
contributionMode || isAreaExpand ? ',.0%' : yAxisFormat,
const forcePercentFormatter = Boolean(contributionMode || isAreaExpand);
const percentFormatter = getNumberFormatter(',.0%');
const defaultFormatter = getNumberFormatter(yAxisFormat);
const customFormatters = buildCustomFormatters(
metrics,
currencyFormats,
columnFormats,
yAxisFormat,
);
const array = ensureIsArray(chartProps.rawFormData?.time_compare);
@ -262,7 +308,13 @@ export default function transformProps(
seriesType,
legendState,
stack,
formatter,
formatter: forcePercentFormatter
? percentFormatter
: getCustomFormatter(
customFormatters,
metrics,
labelMap[seriesName]?.[0],
) ?? defaultFormatter,
showValue,
onlyTotal,
totalStackedValues: sortedTotalValues,
@ -440,7 +492,14 @@ export default function transformProps(
max,
minorTick: { show: true },
minorSplitLine: { show: minorSplitLine },
axisLabel: { formatter },
axisLabel: {
formatter: getYAxisFormatter(
metrics,
forcePercentFormatter,
customFormatters,
yAxisFormat,
),
},
scale: truncateYAxis,
name: yAxisTitle,
nameGap: convertInteger(yAxisTitleMargin),
@ -485,10 +544,17 @@ export default function transformProps(
if (value.observation === 0 && stack) {
return;
}
// if there are no dimensions, key is a verbose name of a metric,
// otherwise it is a comma separated string where the first part is metric name
const formatterKey =
groupby.length === 0 ? inverted[key] : labelMap[key]?.[0];
const content = formatForecastTooltipSeries({
...value,
seriesName: key,
formatter,
formatter: forcePercentFormatter
? percentFormatter
: getCustomFormatter(customFormatters, metrics, formatterKey) ??
defaultFormatter,
});
if (!legendState || legendState[key]) {
rows.push(`<span style="font-weight: 700">${content}</span>`);

View File

@ -28,13 +28,13 @@ import {
IntervalAnnotationLayer,
isTimeseriesAnnotationResult,
LegendState,
NumberFormatter,
smartDateDetailedFormatter,
smartDateFormatter,
SupersetTheme,
TimeFormatter,
TimeseriesAnnotationLayer,
TimeseriesDataRecord,
ValueFormatter,
} from '@superset-ui/core';
import { SeriesOption } from 'echarts';
import {
@ -158,7 +158,7 @@ export function transformSeries(
showValue?: boolean;
onlyTotal?: boolean;
legendState?: LegendState;
formatter?: NumberFormatter;
formatter?: ValueFormatter;
totalStackedValues?: number[];
showValueIndexes?: number[];
thresholdValues?: number[];

View File

@ -23,7 +23,7 @@ import {
getNumberFormatter,
getTimeFormatter,
NumberFormats,
NumberFormatter,
ValueFormatter,
} from '@superset-ui/core';
import { TreemapSeriesNodeItemOption } from 'echarts/types/src/chart/treemap/TreemapSeries';
import { EChartsCoreOption, TreemapSeriesOption } from 'echarts';
@ -48,6 +48,7 @@ import { OpacityEnum } from '../constants';
import { getDefaultTooltip } from '../utils/tooltip';
import { Refs } from '../types';
import { treeBuilder, TreeNode } from '../utils/treeBuilder';
import { getValueFormatter } from '../utils/valueFormatter';
export function formatLabel({
params,
@ -56,7 +57,7 @@ export function formatLabel({
}: {
params: TreemapSeriesCallbackDataParams;
labelType: EchartsTreemapLabelType;
numberFormatter: NumberFormatter;
numberFormatter: ValueFormatter;
}): string {
const { name = '', value } = params;
const formattedValue = numberFormatter(value as number);
@ -78,7 +79,7 @@ export function formatTooltip({
numberFormatter,
}: {
params: TreemapSeriesCallbackDataParams;
numberFormatter: NumberFormatter;
numberFormatter: ValueFormatter;
}): string {
const { value, treePathInfo = [] } = params;
const formattedValue = numberFormatter(value as number);
@ -118,8 +119,10 @@ export default function transformProps(
theme,
inContextMenu,
emitCrossFilters,
datasource,
} = chartProps;
const { data = [] } = queriesData[0];
const { columnFormats = {}, currencyFormats = {} } = datasource;
const { setDataMask = () => {}, onContextMenu } = hooks;
const coltypeMapping = getColtypesMapping(queriesData[0]);
@ -141,7 +144,13 @@ export default function transformProps(
};
const refs: Refs = {};
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
const numberFormatter = getNumberFormatter(numberFormat);
const numberFormatter = getValueFormatter(
metric,
currencyFormats,
columnFormats,
numberFormat,
);
const formatter = (params: TreemapSeriesCallbackDataParams) =>
formatLabel({
params,

View File

@ -17,7 +17,7 @@
* under the License.
*/
import { isNumber } from 'lodash';
import { DataRecord, DTTM_ALIAS, NumberFormatter } from '@superset-ui/core';
import { DataRecord, DTTM_ALIAS, ValueFormatter } from '@superset-ui/core';
import { OptionName } from 'echarts/types/src/util/types';
import { TooltipMarker } from 'echarts/types/src/util/format';
import {
@ -91,7 +91,7 @@ export const formatForecastTooltipSeries = ({
}: ForecastValue & {
seriesName: string;
marker: TooltipMarker;
formatter: NumberFormatter;
formatter: ValueFormatter;
}): string => {
let row = `${marker}${sanitizeHtml(seriesName)}: `;
let isObservation = false;

View File

@ -31,6 +31,7 @@ import {
SupersetTheme,
normalizeTimestamp,
LegendState,
ValueFormatter,
} from '@superset-ui/core';
import { SortSeriesType } from '@superset-ui/chart-controls';
import { format, LegendComponentOption, SeriesOption } from 'echarts';
@ -345,7 +346,7 @@ export function formatSeriesName(
timeFormatter,
coltype,
}: {
numberFormatter?: NumberFormatter;
numberFormatter?: ValueFormatter;
timeFormatter?: TimeFormatter;
coltype?: GenericDataType;
} = {},

View File

@ -0,0 +1,63 @@
import {
Currency,
CurrencyFormatter,
ensureIsArray,
getNumberFormatter,
isSavedMetric,
QueryFormMetric,
ValueFormatter,
} from '@superset-ui/core';
export const buildCustomFormatters = (
metrics: QueryFormMetric | QueryFormMetric[] | undefined,
currencyFormats: Record<string, Currency>,
columnFormats: Record<string, string>,
d3Format: string | undefined,
) => {
const metricsArray = ensureIsArray(metrics);
return metricsArray.reduce((acc, metric) => {
const actualD3Format = isSavedMetric(metric)
? columnFormats[metric] ?? d3Format
: d3Format;
if (isSavedMetric(metric)) {
return currencyFormats[metric]
? {
...acc,
[metric]: new CurrencyFormatter({
d3Format: actualD3Format,
currency: currencyFormats[metric],
}),
}
: {
...acc,
[metric]: getNumberFormatter(actualD3Format),
};
}
return acc;
}, {});
};
export const getCustomFormatter = (
customFormatters: Record<string, ValueFormatter>,
metrics: QueryFormMetric | QueryFormMetric[] | undefined,
key?: string,
) => {
const metricsArray = ensureIsArray(metrics);
if (metricsArray.length === 1 && isSavedMetric(metricsArray[0])) {
return customFormatters[metricsArray[0]];
}
return key ? customFormatters[key] : undefined;
};
export const getValueFormatter = (
metrics: QueryFormMetric | QueryFormMetric[] | undefined,
currencyFormats: Record<string, Currency>,
columnFormats: Record<string, string>,
d3Format: string | undefined,
key?: string,
) =>
getCustomFormatter(
buildCustomFormatters(metrics, currencyFormats, columnFormats, d3Format),
metrics,
key,
) ?? getNumberFormatter(d3Format);

View File

@ -158,5 +158,30 @@ describe('BigNumberWithTrendline', () => {
'1.23',
);
});
it('should format with datasource currency', () => {
const propsWithDatasource = {
...props,
datasource: {
...props.datasource,
currencyFormats: {
value: { symbol: 'USD', symbolPosition: 'prefix' },
},
metrics: [
{
label: 'value',
metric_name: 'value',
d3format: '.2f',
currency: `{symbol: 'USD', symbolPosition: 'prefix' }`,
},
],
},
};
const transformed = transformProps(propsWithDatasource);
// @ts-ignore
expect(transformed.headerFormatter(transformed.bigNumber)).toStrictEqual(
'$ 1.23',
);
});
});
});

View File

@ -21,6 +21,7 @@ import { MinusSquareOutlined, PlusSquareOutlined } from '@ant-design/icons';
import {
AdhocMetric,
BinaryQueryObjectFilterClause,
CurrencyFormatter,
DataRecordValue,
FeatureFlag,
getColumnLabel,
@ -144,6 +145,7 @@ export default function PivotTableChart(props: PivotTableProps) {
selectedFilters,
verboseMap,
columnFormats,
currencyFormats,
metricsLayout,
metricColorFormatters,
dateFormatters,
@ -156,24 +158,39 @@ export default function PivotTableChart(props: PivotTableProps) {
() => getNumberFormatter(valueFormat),
[valueFormat],
);
const columnFormatsArray = useMemo(
() => Object.entries(columnFormats),
[columnFormats],
const customFormatsArray = useMemo(
() =>
Array.from(
new Set([
...Object.keys(columnFormats || {}),
...Object.keys(currencyFormats || {}),
]),
).map(metricName => [
metricName,
columnFormats[metricName] || valueFormat,
currencyFormats[metricName],
]),
[columnFormats, currencyFormats, valueFormat],
);
const hasCustomMetricFormatters = columnFormatsArray.length > 0;
const hasCustomMetricFormatters = customFormatsArray.length > 0;
const metricFormatters = useMemo(
() =>
hasCustomMetricFormatters
? {
[METRIC_KEY]: Object.fromEntries(
columnFormatsArray.map(([metric, format]) => [
customFormatsArray.map(([metric, d3Format, currency]) => [
metric,
getNumberFormatter(format),
currency
? new CurrencyFormatter({
currency,
d3Format,
})
: getNumberFormatter(d3Format),
]),
),
}
: undefined,
[columnFormatsArray, hasCustomMetricFormatters],
[customFormatsArray, hasCustomMetricFormatters],
);
const metricNames = useMemo(

View File

@ -79,7 +79,7 @@ export default function transformProps(chartProps: ChartProps<QueryFormData>) {
rawFormData,
hooks: { setDataMask = () => {}, onContextMenu },
filterState,
datasource: { verboseMap = {}, columnFormats = {} },
datasource: { verboseMap = {}, columnFormats = {}, currencyFormats = {} },
emitCrossFilters,
} = chartProps;
const { data, colnames, coltypes } = queriesData[0];
@ -162,6 +162,7 @@ export default function transformProps(chartProps: ChartProps<QueryFormData>) {
selectedFilters,
verboseMap,
columnFormats,
currencyFormats,
metricsLayout,
metricColorFormatters,
dateFormatters,

View File

@ -28,6 +28,7 @@ import {
QueryFormColumn,
TimeGranularity,
ContextMenuFilters,
Currency,
} from '@superset-ui/core';
import { ColorFormatters } from '@superset-ui/chart-controls';
@ -69,6 +70,7 @@ interface PivotTableCustomizeProps {
selectedFilters?: SelectedFiltersType;
verboseMap: JsonObject;
columnFormats: JsonObject;
currencyFormats: Record<string, Currency>;
metricsLayout?: MetricsLayoutEnum;
metricColorFormatters: ColorFormatters;
dateFormatters: Record<string, DateFormatter | undefined>;

View File

@ -44,6 +44,7 @@ describe('PivotTableChart buildQuery', () => {
combineMetric: false,
verboseMap: {},
columnFormats: {},
currencyFormats: {},
metricColorFormatters: [],
dateFormatters: {},
setDataMask: () => {},

View File

@ -90,6 +90,7 @@ describe('PivotTableChart transformProps', () => {
dateFormatters: {},
emitCrossFilters: false,
columnFormats: {},
currencyFormats: {},
});
});
});

View File

@ -2,30 +2,27 @@
"name": "@superset-ui/plugin-chart-table",
"version": "0.18.25",
"description": "Superset Chart - Table",
"main": "lib/index.js",
"module": "esm/index.js",
"sideEffects": false,
"files": [
"esm",
"lib"
"keywords": [
"superset"
],
"homepage": "https://github.com/apache/superset/tree/master/superset-frontend/plugins/plugin-chart-table#readme",
"bugs": {
"url": "https://github.com/apache/superset/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/apache/superset.git",
"directory": "superset-frontend/plugins/plugin-chart-table"
},
"keywords": [
"superset"
],
"author": "Superset",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/apache/superset/issues"
},
"homepage": "https://github.com/apache/superset/tree/master/superset-frontend/plugins/plugin-chart-table#readme",
"publishConfig": {
"access": "public"
},
"author": "Superset",
"sideEffects": false,
"main": "lib/index.js",
"module": "esm/index.js",
"files": [
"esm",
"lib"
],
"dependencies": {
"@react-icons/all-files": "^4.1.0",
"@types/d3-array": "^2.9.0",
@ -40,15 +37,20 @@
"regenerator-runtime": "^0.13.7",
"xss": "^1.0.10"
},
"devDependencies": {
"@testing-library/react": "^11.2.0"
},
"peerDependencies": {
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@testing-library/dom": "^7.29.4",
"@testing-library/jest-dom": "^5.11.6",
"@testing-library/react": "^11.2.0",
"@testing-library/react-hooks": "^5.0.3",
"@testing-library/user-event": "^12.7.0",
"@types/classnames": "*",
"@types/react": "*",
"react": "^16.13.1",
"react-dom": "^16.13.1"
},
"publishConfig": {
"access": "public"
}
}

View File

@ -18,6 +18,7 @@
*/
import memoizeOne from 'memoize-one';
import {
CurrencyFormatter,
DataRecord,
extractTimegrain,
GenericDataType,
@ -84,7 +85,7 @@ const processColumns = memoizeOne(function processColumns(
props: TableChartProps,
) {
const {
datasource: { columnFormats, verboseMap },
datasource: { columnFormats, currencyFormats, verboseMap },
rawFormData: {
table_timestamp_format: tableTimestampFormat,
metrics: metrics_,
@ -123,6 +124,7 @@ const processColumns = memoizeOne(function processColumns(
const isTime = dataType === GenericDataType.TEMPORAL;
const isNumber = dataType === GenericDataType.NUMERIC;
const savedFormat = columnFormats?.[key];
const currency = currencyFormats?.[key];
const numberFormat = config.d3NumberFormat || savedFormat;
let formatter;
@ -155,7 +157,9 @@ const processColumns = memoizeOne(function processColumns(
// percent metrics have a default format
formatter = getNumberFormatter(numberFormat || PERCENT_3_POINT);
} else if (isMetric || (isNumber && numberFormat)) {
formatter = getNumberFormatter(numberFormat);
formatter = currency
? new CurrencyFormatter({ d3Format: numberFormat, currency })
: getNumberFormatter(numberFormat);
}
return {
key,

View File

@ -31,6 +31,7 @@ import {
QueryFormData,
SetDataMaskHook,
ContextMenuFilters,
CurrencyFormatter,
} from '@superset-ui/core';
import { ColorFormatters, ColumnConfig } from '@superset-ui/chart-controls';
@ -42,7 +43,11 @@ export interface DataColumnMeta {
// `label` is verbose column name used for rendering
label: string;
dataType: GenericDataType;
formatter?: TimeFormatter | NumberFormatter | CustomFormatter;
formatter?:
| TimeFormatter
| NumberFormatter
| CustomFormatter
| CurrencyFormatter;
isMetric?: boolean;
isPercentMetric?: boolean;
isNumeric?: boolean;

View File

@ -47,7 +47,6 @@ function formatValue(
return [false, 'N/A'];
}
if (formatter) {
// in case percent metric can specify percent format in the future
return [false, formatter(value as number)];
}
if (typeof value === 'string') {

View File

@ -27,6 +27,7 @@ export default function isEqualColumns(
const b = propsB[0];
return (
a.datasource.columnFormats === b.datasource.columnFormats &&
a.datasource.currencyFormats === b.datasource.currencyFormats &&
a.datasource.verboseMap === b.datasource.verboseMap &&
a.formData.tableTimestampFormat === b.formData.tableTimestampFormat &&
a.formData.timeGrainSqla === b.formData.timeGrainSqla &&

View File

@ -19,6 +19,7 @@
import React from 'react';
import { CommonWrapper } from 'enzyme';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import TableChart from '../src/TableChart';
import transformProps from '../src/transformProps';
import DateWithFormatter from '../src/utils/DateWithFormatter';
@ -102,6 +103,26 @@ describe('plugin-chart-table', () => {
expect(cells.eq(4).text()).toEqual('2.47k');
});
it('render advanced data with currencies', () => {
render(
ProviderWrapper({
children: (
<TableChart
{...transformProps(testData.advancedWithCurrency)}
sticky={false}
/>
),
}),
);
const cells = document.querySelectorAll('td');
expect(document.querySelectorAll('th')[1]).toHaveTextContent(
'Sum of Num',
);
expect(cells[0]).toHaveTextContent('Michael');
expect(cells[2]).toHaveTextContent('12.346%');
expect(cells[4]).toHaveTextContent('$ 2.47k');
});
it('render empty data', () => {
wrap.setProps({ ...transformProps(testData.empty), sticky: false });
tree = wrap.render();

View File

@ -173,6 +173,16 @@ const advanced: TableChartProps = {
],
};
const advancedWithCurrency = {
...advanced,
datasource: {
...advanced.datasource,
currencyFormats: {
sum__num: { symbol: 'USD', symbolPosition: 'prefix' },
},
},
};
const empty = {
...advanced,
queriesData: [
@ -186,5 +196,6 @@ const empty = {
export default {
basic,
advanced,
advancedWithCurrency,
empty,
};

View File

@ -25,6 +25,9 @@ import Alert from 'src/components/Alert';
import Badge from 'src/components/Badge';
import shortid from 'shortid';
import {
css,
getCurrencySymbol,
ensureIsArray,
FeatureFlag,
styled,
SupersetClient,
@ -146,6 +149,11 @@ const DATA_TYPES = [
{ value: 'BOOLEAN', label: t('BOOLEAN') },
];
const CURRENCY_SYMBOL_POSITION = [
{ value: 'prefix', label: t('Prefix') },
{ value: 'suffix', label: t('Suffix') },
];
const DATASOURCE_TYPES_ARR = [
{ key: 'physical', label: t('Physical (table or view)') },
{ key: 'virtual', label: t('Virtual (SQL)') },
@ -572,6 +580,43 @@ function OwnersSelector({ datasource, onChange }) {
);
}
const CurrencyControlContainer = styled.div`
${({ theme }) => css`
display: flex;
align-items: center;
& > :first-child {
width: 25%;
margin-right: ${theme.gridUnit * 4}px;
}
`}
`;
const CurrencyControl = ({ onChange, value: currency = {}, currencies }) => (
<CurrencyControlContainer>
<Select
ariaLabel={t('Currency prefix or suffix')}
options={CURRENCY_SYMBOL_POSITION}
placeholder={t('Prefix or suffix')}
onChange={symbolPosition => {
onChange({ ...currency, symbolPosition });
}}
value={currency?.symbolPosition}
allowClear
/>
<Select
ariaLabel={t('Currency symbol')}
options={currencies}
placeholder={t('Select or type currency symbol')}
onChange={symbol => {
onChange({ ...currency, symbol });
}}
value={currency?.symbol}
allowClear
allowNewOptions
/>
</CurrencyControlContainer>
);
class DatasourceEditor extends React.PureComponent {
constructor(props) {
super(props);
@ -628,6 +673,12 @@ class DatasourceEditor extends React.PureComponent {
this.allowEditSource = !isFeatureEnabled(
FeatureFlag.DISABLE_DATASET_SOURCE_EDIT,
);
this.currencies = ensureIsArray(props.currencies).map(currencyCode => ({
value: currencyCode,
label: `${getCurrencySymbol({
symbol: currencyCode,
})} (${currencyCode})`,
}));
}
onChange() {
@ -839,6 +890,20 @@ class DatasourceEditor extends React.PureComponent {
),
);
// validate currency code
try {
this.state.datasource.metrics?.forEach(
metric =>
metric.currency?.symbol &&
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: metric.currency.symbol,
}),
);
} catch {
errors = errors.concat([t('Invalid currency code in saved metrics')]);
}
this.setState({ errors }, callback);
}
@ -1228,6 +1293,11 @@ class DatasourceEditor extends React.PureComponent {
<TextControl controlId="d3format" placeholder="%y/%m/%d" />
}
/>
<Field
fieldKey="currency"
label={t('Metric currency')}
control={<CurrencyControl currencies={this.currencies} />}
/>
<Field
label={t('Certified by')}
fieldKey="certified_by"

View File

@ -28,7 +28,7 @@ const props = {
datasource: mockDatasource['7__table'],
addSuccessToast: () => {},
addDangerToast: () => {},
onChange: () => {},
onChange: jest.fn(),
columnLabels: {
state: 'State',
},
@ -217,6 +217,90 @@ describe('DatasourceEditor RTL', () => {
const warningMarkdown = await screen.findByPlaceholderText(/certified by/i);
expect(warningMarkdown.value).toEqual('someone');
});
it('renders currency controls', async () => {
const propsWithCurrency = {
...props,
currencies: ['USD', 'GBP', 'EUR'],
datasource: {
...props.datasource,
metrics: [
{
...props.datasource.metrics[0],
currency: { symbol: 'USD', symbolPosition: 'prefix' },
},
...props.datasource.metrics.slice(1),
],
},
};
await asyncRender(propsWithCurrency);
const metricButton = screen.getByTestId('collection-tab-Metrics');
userEvent.click(metricButton);
const expandToggle = await screen.findAllByLabelText(/toggle expand/i);
userEvent.click(expandToggle[0]);
expect(await screen.findByText('Metric currency')).toBeVisible();
expect(
await waitFor(() =>
document.querySelector(
`[aria-label='Currency prefix or suffix'] .ant-select-selection-item`,
),
),
).toHaveTextContent('Prefix');
await userEvent.click(
screen.getByRole('combobox', { name: 'Currency prefix or suffix' }),
);
const positionOptions = await waitFor(() =>
document.querySelectorAll(
`[aria-label='Currency prefix or suffix'] .ant-select-item-option-content`,
),
);
expect(positionOptions[0]).toHaveTextContent('Prefix');
expect(positionOptions[1]).toHaveTextContent('Suffix');
propsWithCurrency.onChange.mockClear();
await userEvent.click(positionOptions[1]);
expect(propsWithCurrency.onChange.mock.calls[0][0]).toMatchObject(
expect.objectContaining({
metrics: expect.arrayContaining([
expect.objectContaining({
currency: { symbolPosition: 'suffix', symbol: 'USD' },
}),
]),
}),
);
expect(
await waitFor(() =>
document.querySelector(
`[aria-label='Currency symbol'] .ant-select-selection-item`,
),
),
).toHaveTextContent('$ (USD)');
propsWithCurrency.onChange.mockClear();
await userEvent.click(
screen.getByRole('combobox', { name: 'Currency symbol' }),
);
const symbolOptions = await waitFor(() =>
document.querySelectorAll(
`[aria-label='Currency symbol'] .ant-select-item-option-content`,
),
);
expect(symbolOptions[0]).toHaveTextContent('$ (USD)');
expect(symbolOptions[1]).toHaveTextContent('£ (GBP)');
expect(symbolOptions[2]).toHaveTextContent('€ (EUR)');
await userEvent.click(symbolOptions[1]);
expect(propsWithCurrency.onChange.mock.calls[0][0]).toMatchObject(
expect.objectContaining({
metrics: expect.arrayContaining([
expect.objectContaining({
currency: { symbolPosition: 'suffix', symbol: 'GBP' },
}),
]),
}),
);
});
it('properly updates the metric information', async () => {
await asyncRender(props);
const metricButton = screen.getByTestId('collection-tab-Metrics');

View File

@ -19,7 +19,14 @@
import React, { FunctionComponent, useState, useRef } from 'react';
import Alert from 'src/components/Alert';
import Button from 'src/components/Button';
import { FeatureFlag, styled, SupersetClient, t } from '@superset-ui/core';
import {
FeatureFlag,
isDefined,
Metric,
styled,
SupersetClient,
t,
} from '@superset-ui/core';
import Modal from 'src/components/Modal';
import AsyncEsmComponent from 'src/components/AsyncEsmComponent';
@ -27,6 +34,7 @@ import { isFeatureEnabled } from 'src/featureFlags';
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
import withToasts from 'src/components/MessageToasts/withToasts';
import { useSelector } from 'react-redux';
const DatasourceEditor = AsyncEsmComponent(() => import('./DatasourceEditor'));
@ -81,7 +89,21 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
onHide,
show,
}) => {
const [currentDatasource, setCurrentDatasource] = useState(datasource);
const [currentDatasource, setCurrentDatasource] = useState({
...datasource,
metrics: datasource?.metrics?.map((metric: Metric) => ({
...metric,
currency: JSON.parse(metric.currency || 'null'),
})),
});
const currencies = useSelector<
{
common: {
currencies: string[];
};
},
string[]
>(state => state.common?.currencies);
const [errors, setErrors] = useState<any[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [isEditing, setIsEditing] = useState<boolean>(false);
@ -125,7 +147,10 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
description: metric.description,
metric_name: metric.metric_name,
metric_type: metric.metric_type,
d3format: metric.d3format,
d3format: metric.d3format || null,
currency: !isDefined(metric.currency)
? null
: JSON.stringify(metric.currency),
verbose_name: metric.verbose_name,
warning_text: metric.warning_text,
uuid: metric.uuid,
@ -297,6 +322,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
datasource={currentDatasource}
onChange={onDatasourceChange}
setIsEditing={setIsEditing}
currencies={currencies}
/>
{contextHolder}
</StyledDatasourceModal>

View File

@ -29,6 +29,7 @@ export const PLACEHOLDER_DATASOURCE: Datasource = {
column_types: [],
metrics: [],
column_formats: {},
currency_formats: {},
verbose_map: {},
main_dttm_col: '',
description: '',

View File

@ -35,6 +35,7 @@ const CURRENT_DATASOURCE = {
columns: [],
metrics: [],
column_formats: {},
currency_formats: {},
verbose_map: {},
main_dttm_col: '__timestamp',
// eg. ['["ds", true]', 'ds [asc]']
@ -48,6 +49,7 @@ const NEW_DATASOURCE = {
columns: [],
metrics: [],
column_formats: {},
currency_formats: {},
verbose_map: {},
main_dttm_col: '__timestamp',
// eg. ['["ds", true]', 'ds [asc]']

View File

@ -96,6 +96,7 @@ export const hydrateExplore =
if (dashboardId) {
initialFormData.dashboardId = dashboardId;
}
const initialDatasource = dataset;
const initialExploreState = {

View File

@ -52,6 +52,7 @@ describe('controlUtils', () => {
columns: [{ column_name: 'a' }],
metrics: [{ metric_name: 'first' }, { metric_name: 'second' }],
column_formats: {},
currency_formats: {},
verbose_map: {},
main_dttm_col: '',
datasource_name: '1__table',

View File

@ -35,6 +35,7 @@ const sampleDatasource: Dataset = {
],
metrics: [{ metric_name: 'saved_metric_2' }],
column_formats: {},
currency_formats: {},
verbose_map: {},
main_dttm_col: '',
datasource_name: 'Sample Dataset',

View File

@ -136,6 +136,7 @@ export const exploreInitialData: ExplorePageInitialData = {
columns: [{ column_name: 'a' }],
metrics: [{ metric_name: 'first' }, { metric_name: 'second' }],
column_formats: {},
currency_formats: {},
verbose_map: {},
main_dttm_col: '',
datasource_name: '8__table',
@ -154,6 +155,7 @@ export const fallbackExploreInitialData: ExplorePageInitialData = {
columns: [],
metrics: [],
column_formats: {},
currency_formats: {},
verbose_map: {},
main_dttm_col: '',
owners: [],

View File

@ -1,3 +1,5 @@
import { Currency } from '@superset-ui/core';
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
@ -39,6 +41,7 @@ type MetricObject = {
metric_name: string;
metric_type: string;
d3format?: string;
currency?: Currency;
warning_text?: string;
};

View File

@ -26,6 +26,7 @@ const TEST_DATASOURCE = {
columns: [],
metrics: [],
column_formats: {},
currency_formats: {},
verbose_map: {},
main_dttm_col: '__timestamp',
// eg. ['["ds", true]', 'ds [asc]']

View File

@ -374,6 +374,8 @@ class D3Format(TypedDict, total=False):
D3_FORMAT: D3Format = {}
CURRENCIES = ["USD", "EUR", "GBP", "INR", "MXN", "JPY", "CNY"]
# ---------------------------------------------------
# Feature flags
# ---------------------------------------------------

View File

@ -18,9 +18,11 @@ from __future__ import annotations
import builtins
import json
import logging
from collections.abc import Hashable
from datetime import datetime
from enum import Enum
from json.decoder import JSONDecodeError
from typing import Any, TYPE_CHECKING
from flask_appbuilder.security.sqla.models import User
@ -47,6 +49,8 @@ from superset.utils.core import GenericDataType, MediumText
if TYPE_CHECKING:
from superset.db_engine_specs.base import BaseEngineSpec
logger = logging.getLogger(__name__)
METRIC_FORM_DATA_PARAMS = [
"metric",
"metric_2",
@ -224,6 +228,10 @@ class BaseDatasource(
def column_formats(self) -> dict[str, str | None]:
return {m.metric_name: m.d3format for m in self.metrics if m.d3format}
@property
def currency_formats(self) -> dict[str, dict[str, str | None] | None]:
return {m.metric_name: m.currency_json for m in self.metrics if m.currency_json}
def add_missing_metrics(self, metrics: list[BaseMetric]) -> None:
existing_metrics = {m.metric_name for m in self.metrics}
for metric in metrics:
@ -282,6 +290,7 @@ class BaseDatasource(
"id": self.id,
"uid": self.uid,
"column_formats": self.column_formats,
"currency_formats": self.currency_formats,
"description": self.description,
"database": self.database.data, # pylint: disable=no-member
"default_endpoint": self.default_endpoint,
@ -717,6 +726,7 @@ class BaseMetric(AuditMixinNullable, ImportExportMixin):
metric_type = Column(String(32))
description = Column(MediumText())
d3format = Column(String(128))
currency = Column(String(128))
warning_text = Column(Text)
"""
@ -733,6 +743,16 @@ class BaseMetric(AuditMixinNullable, ImportExportMixin):
enable_typechecks=False)
"""
@property
def currency_json(self) -> dict[str, str | None] | None:
try:
return json.loads(self.currency or "{}") or None
except (TypeError, JSONDecodeError) as exc:
logger.error(
"Unable to load currency json: %r. Leaving empty.", exc, exc_info=True
)
return None
@property
def perm(self) -> str | None:
raise NotImplementedError()
@ -751,5 +771,6 @@ class BaseMetric(AuditMixinNullable, ImportExportMixin):
"expression",
"warning_text",
"d3format",
"currency",
)
return {s: getattr(self, s) for s in attrs}

View File

@ -416,6 +416,7 @@ class SqlMetric(Model, BaseMetric, CertificationMixin):
"expression",
"description",
"d3format",
"currency",
"extra",
"warning_text",
]

View File

@ -217,6 +217,7 @@ class SqlMetricInlineView( # pylint: disable=too-many-ancestors
"expression",
"table",
"d3format",
"currency",
"extra",
"warning_text",
]

View File

@ -218,6 +218,7 @@ class DashboardDatasetSchema(Schema):
id = fields.Int()
uid = fields.Str()
column_formats = fields.Dict()
currency_formats = fields.Dict()
database = fields.Nested(DatabaseSchema)
default_endpoint = fields.String()
filter_select = fields.Bool()

View File

@ -171,6 +171,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
"metrics.changed_on",
"metrics.created_on",
"metrics.d3format",
"metrics.currency",
"metrics.description",
"metrics.expression",
"metrics.extra",
@ -201,6 +202,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
"datasource_name",
"name",
"column_formats",
"currency_formats",
"granularity_sqla",
"time_grain_sqla",
"order_by_choices",

View File

@ -71,6 +71,7 @@ class DatasetMetricsPutSchema(Schema):
metric_name = fields.String(required=True, validate=Length(1, 255))
metric_type = fields.String(allow_none=True, validate=Length(1, 32))
d3format = fields.String(allow_none=True, validate=Length(1, 128))
currency = fields.String(allow_none=True, required=False, validate=Length(1, 128))
verbose_name = fields.String(allow_none=True, metadata={Length: (1, 1024)})
warning_text = fields.String(allow_none=True)
uuid = fields.UUID(allow_none=True)
@ -191,6 +192,7 @@ class ImportV1MetricSchema(Schema):
expression = fields.String(required=True)
description = fields.String(allow_none=True)
d3format = fields.String(allow_none=True)
currency = fields.String(allow_none=True, required=False)
extra = fields.Dict(allow_none=True)
warning_text = fields.String(allow_none=True)

View File

@ -158,6 +158,7 @@ class MetricType(TypedDict, total=False):
metric_type: str | None
description: str | None
d3format: str | None
currency: str | None
warning_text: str | None
extra: str | None

View File

@ -25,6 +25,7 @@ class DatasetSchema(Schema):
}
)
column_formats = fields.Dict(metadata={"description": "Column formats."})
currency_formats = fields.Dict(metadata={"description": "Currency formats."})
columns = fields.List(fields.Dict(), metadata={"description": "Columns metadata."})
database = fields.Dict(
metadata={"description": "Database associated with the dataset."}

View File

@ -0,0 +1,42 @@
# 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.
"""add_currency_column_to_metrics
Revision ID: 90139bf715e4
Revises: 83e1abbe777f
Create Date: 2023-06-21 14:02:08.200955
"""
# revision identifiers, used by Alembic.
revision = "90139bf715e4"
down_revision = "83e1abbe777f"
import sqlalchemy as sa
from alembic import op
def upgrade():
op.add_column("metrics", sa.Column("currency", sa.String(128), nullable=True))
op.add_column("sql_metrics", sa.Column("currency", sa.String(128), nullable=True))
def downgrade():
with op.batch_alter_table("sql_metrics") as batch_op_sql_metrics:
batch_op_sql_metrics.drop_column("currency")
with op.batch_alter_table("metrics") as batch_op_metrics:
batch_op_metrics.drop_column("currency")

View File

@ -430,6 +430,7 @@ def cached_common_bootstrap_data(user: User) -> dict[str, Any]:
"locale": locale,
"language_pack": get_language_pack(locale),
"d3_format": conf.get("D3_FORMAT"),
"currencies": conf.get("CURRENCIES"),
"feature_flags": get_feature_flags(),
"extra_sequential_color_schemes": conf["EXTRA_SEQUENTIAL_COLOR_SCHEMES"],
"extra_categorical_color_schemes": conf["EXTRA_CATEGORICAL_COLOR_SCHEMES"],

View File

@ -148,6 +148,7 @@ class TestExportDatasetsCommand(SupersetTestCase):
"main_dttm_col": None,
"metrics": [
{
"currency": None,
"d3format": None,
"description": None,
"expression": "COUNT(*)",
@ -158,6 +159,7 @@ class TestExportDatasetsCommand(SupersetTestCase):
"warning_text": None,
},
{
"currency": None,
"d3format": None,
"description": None,
"expression": "SUM(value)",
@ -381,6 +383,7 @@ class TestImportDatasetsCommand(SupersetTestCase):
assert metric.expression == "count(1)"
assert metric.description is None
assert metric.d3format is None
assert metric.currency is None
assert metric.extra == "{}"
assert metric.warning_text is None

View File

@ -116,6 +116,7 @@ metrics:
expression: COUNT(*)
description: null
d3format: null
currency: null
extra:
warning_markdown: null
warning_text: null