diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index d2493fdb3ab36..d926b16fec998 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -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", diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/fixtures.ts b/superset-frontend/packages/superset-ui-chart-controls/src/fixtures.ts index 1e5152b2bd904..cc0b678f6db6c 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/fixtures.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/fixtures.ts @@ -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, diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts index 8ba428d7bca91..dadd78cdd0fde 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts @@ -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; + currency_formats: Record; verbose_map: Record; main_dttm_col: string; // eg. ['["ds", true]', 'ds [asc]'] diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/utils/columnChoices.test.tsx b/superset-frontend/packages/superset-ui-chart-controls/test/utils/columnChoices.test.tsx index e27fa95120848..aaaccda95d3c2 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/test/utils/columnChoices.test.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/test/utils/columnChoices.test.tsx @@ -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', }), diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/utils/defineSavedMetrics.test.tsx b/superset-frontend/packages/superset-ui-chart-controls/test/utils/defineSavedMetrics.test.tsx index 765412d592c24..f7ae98520c098 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/test/utils/defineSavedMetrics.test.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/test/utils/defineSavedMetrics.test.tsx @@ -40,6 +40,7 @@ describe('defineSavedMetrics', () => { columns: [], verbose_map: {}, column_formats: {}, + currency_formats: {}, datasource_name: 'my_datasource', description: 'this is my datasource', }; diff --git a/superset-frontend/packages/superset-ui-core/src/currency-format/CurrencyFormatter.ts b/superset-frontend/packages/superset-ui-core/src/currency-format/CurrencyFormatter.ts new file mode 100644 index 0000000000000..7c082abd3425e --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/currency-format/CurrencyFormatter.ts @@ -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; diff --git a/superset-frontend/packages/superset-ui-core/src/currency-format/index.ts b/superset-frontend/packages/superset-ui-core/src/currency-format/index.ts new file mode 100644 index 0000000000000..c7fa5a0388b07 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/currency-format/index.ts @@ -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'; diff --git a/superset-frontend/packages/superset-ui-core/src/index.ts b/superset-frontend/packages/superset-ui-core/src/index.ts index 5ee5acbce4f43..ea7a4efde7fff 100644 --- a/superset-frontend/packages/superset-ui-core/src/index.ts +++ b/superset-frontend/packages/superset-ui-core/src/index.ts @@ -36,3 +36,4 @@ export * from './components'; export * from './math-expression'; export * from './ui-overrides'; export * from './hooks'; +export * from './currency-format'; diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts index 9639a000d0151..ab5ff950cc109 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts @@ -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; }; diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Metric.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Metric.ts index c0f770f9041d4..ac6523bedb35d 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/types/Metric.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/types/Metric.ts @@ -65,6 +65,7 @@ export interface Metric { certification_details?: Maybe; certified_by?: Maybe; d3format?: Maybe; + currency?: Maybe; description?: Maybe; is_certified?: boolean; verbose_name?: string; diff --git a/superset-frontend/packages/superset-ui-core/src/types/index.ts b/superset-frontend/packages/superset-ui-core/src/types/index.ts index a1c527afd6f06..cb6e6dcfcbb45 100644 --- a/superset-frontend/packages/superset-ui-core/src/types/index.ts +++ b/superset-frontend/packages/superset-ui-core/src/types/index.ts @@ -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 | null; @@ -23,3 +26,5 @@ export type Maybe = T | null; export type Optional = T | undefined; export type ValueOf = T[keyof T]; + +export type ValueFormatter = NumberFormatter | CurrencyFormatter; diff --git a/superset-frontend/packages/superset-ui-core/test/currency-format/CurrencyFormatter.test.ts b/superset-frontend/packages/superset-ui-core/test/currency-format/CurrencyFormatter.test.ts new file mode 100644 index 0000000000000..5172e3b0ed387 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/test/currency-format/CurrencyFormatter.test.ts @@ -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'); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/transformProps.ts index 8624e5bc54a62..5486030f461de 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/transformProps.ts @@ -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; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts index bd4553479e487..c05a427f31776 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts @@ -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(); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/types.ts index c517fcc0b9ef6..2081460ad1c81 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/types.ts @@ -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; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/transformProps.ts index ac2f650e328d7..796aa36e40824 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/transformProps.ts @@ -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; 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({ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/transformProps.ts index 27e6c9f19795a..ffbb746bf0d4f 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/transformProps.ts @@ -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); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts index 7d30917b188ab..7ea9cc1ffb915 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts @@ -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; 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 => { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts index c89bff2e8c393..2066148c84800 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -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, + 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(`${content}`); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts index fb4739dc74658..0bcc5baf8dcca 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts @@ -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[]; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/transformProps.ts index 89088be5fa03e..9e0454b38617d 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/transformProps.ts @@ -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, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/forecast.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/forecast.ts index 485e9fb8968a6..18b160b9c66e9 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/forecast.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/forecast.ts @@ -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; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts index 0f6efd72f22fe..f9477ea25ae15 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts @@ -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; } = {}, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/valueFormatter.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/valueFormatter.ts new file mode 100644 index 0000000000000..5d995c9f00081 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/valueFormatter.ts @@ -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, + columnFormats: Record, + 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, + 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, + columnFormats: Record, + d3Format: string | undefined, + key?: string, +) => + getCustomFormatter( + buildCustomFormatters(metrics, currencyFormats, columnFormats, d3Format), + metrics, + key, + ) ?? getNumberFormatter(d3Format); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts index ce00bb71906f1..bdbbbcd9d1b9d 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts @@ -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', + ); + }); }); }); diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx b/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx index a5610756b236d..f463990b1d351 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx @@ -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( diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/transformProps.ts b/superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/transformProps.ts index 43d73e6193ea4..f335c6978e166 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/transformProps.ts @@ -79,7 +79,7 @@ export default function transformProps(chartProps: ChartProps) { 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) { selectedFilters, verboseMap, columnFormats, + currencyFormats, metricsLayout, metricColorFormatters, dateFormatters, diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/types.ts b/superset-frontend/plugins/plugin-chart-pivot-table/src/types.ts index e011f4593125d..dea5236666645 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/types.ts +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/types.ts @@ -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; metricsLayout?: MetricsLayoutEnum; metricColorFormatters: ColorFormatters; dateFormatters: Record; diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/test/plugin/buildQuery.test.ts b/superset-frontend/plugins/plugin-chart-pivot-table/test/plugin/buildQuery.test.ts index 770cb9849b86c..f3ac8b82d4c49 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/test/plugin/buildQuery.test.ts +++ b/superset-frontend/plugins/plugin-chart-pivot-table/test/plugin/buildQuery.test.ts @@ -44,6 +44,7 @@ describe('PivotTableChart buildQuery', () => { combineMetric: false, verboseMap: {}, columnFormats: {}, + currencyFormats: {}, metricColorFormatters: [], dateFormatters: {}, setDataMask: () => {}, diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/test/plugin/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-pivot-table/test/plugin/transformProps.test.ts index 3edb4619afce0..91fc5d260efa2 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/test/plugin/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-pivot-table/test/plugin/transformProps.test.ts @@ -90,6 +90,7 @@ describe('PivotTableChart transformProps', () => { dateFormatters: {}, emitCrossFilters: false, columnFormats: {}, + currencyFormats: {}, }); }); }); diff --git a/superset-frontend/plugins/plugin-chart-table/package.json b/superset-frontend/plugins/plugin-chart-table/package.json index ac3b1756606f4..54b209c2c568e 100644 --- a/superset-frontend/plugins/plugin-chart-table/package.json +++ b/superset-frontend/plugins/plugin-chart-table/package.json @@ -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" - ], - "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", + "homepage": "https://github.com/apache/superset/tree/master/superset-frontend/plugins/plugin-chart-table#readme", "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" + "repository": { + "type": "git", + "url": "https://github.com/apache/superset.git", + "directory": "superset-frontend/plugins/plugin-chart-table" }, + "license": "Apache-2.0", + "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" } } diff --git a/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts b/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts index e03667a695d7a..dfb398cab0a3c 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts @@ -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, diff --git a/superset-frontend/plugins/plugin-chart-table/src/types.ts b/superset-frontend/plugins/plugin-chart-table/src/types.ts index f76d2718b4fd1..35a463fe2c0f7 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/types.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/types.ts @@ -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; diff --git a/superset-frontend/plugins/plugin-chart-table/src/utils/formatValue.ts b/superset-frontend/plugins/plugin-chart-table/src/utils/formatValue.ts index 607afa8ac3989..139f92336c12b 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/utils/formatValue.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/utils/formatValue.ts @@ -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') { diff --git a/superset-frontend/plugins/plugin-chart-table/src/utils/isEqualColumns.ts b/superset-frontend/plugins/plugin-chart-table/src/utils/isEqualColumns.ts index bd4b704391d26..fc060e6138523 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/utils/isEqualColumns.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/utils/isEqualColumns.ts @@ -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 && diff --git a/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx b/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx index 1e699b68883ef..bd859b467c81c 100644 --- a/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx +++ b/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx @@ -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: ( + + ), + }), + ); + 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(); diff --git a/superset-frontend/plugins/plugin-chart-table/test/testData.ts b/superset-frontend/plugins/plugin-chart-table/test/testData.ts index 9896e7bf491d8..3f464181b7dc9 100644 --- a/superset-frontend/plugins/plugin-chart-table/test/testData.ts +++ b/superset-frontend/plugins/plugin-chart-table/test/testData.ts @@ -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, }; diff --git a/superset-frontend/src/components/Datasource/DatasourceEditor.jsx b/superset-frontend/src/components/Datasource/DatasourceEditor.jsx index 79b10b8fc74e6..d95839d972f2a 100644 --- a/superset-frontend/src/components/Datasource/DatasourceEditor.jsx +++ b/superset-frontend/src/components/Datasource/DatasourceEditor.jsx @@ -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 }) => ( + + { + onChange({ ...currency, symbol }); + }} + value={currency?.symbol} + allowClear + allowNewOptions + /> + +); + 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 { } /> + } + /> {}, 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'); diff --git a/superset-frontend/src/components/Datasource/DatasourceModal.tsx b/superset-frontend/src/components/Datasource/DatasourceModal.tsx index 1a7bf227ec356..c5063890cb89c 100644 --- a/superset-frontend/src/components/Datasource/DatasourceModal.tsx +++ b/superset-frontend/src/components/Datasource/DatasourceModal.tsx @@ -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 = ({ 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([]); const [isSaving, setIsSaving] = useState(false); const [isEditing, setIsEditing] = useState(false); @@ -125,7 +147,10 @@ const DatasourceModal: FunctionComponent = ({ 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 = ({ datasource={currentDatasource} onChange={onDatasourceChange} setIsEditing={setIsEditing} + currencies={currencies} /> {contextHolder} diff --git a/superset-frontend/src/dashboard/constants.ts b/superset-frontend/src/dashboard/constants.ts index 588e5b6cd5f71..ecc893bcb7693 100644 --- a/superset-frontend/src/dashboard/constants.ts +++ b/superset-frontend/src/dashboard/constants.ts @@ -29,6 +29,7 @@ export const PLACEHOLDER_DATASOURCE: Datasource = { column_types: [], metrics: [], column_formats: {}, + currency_formats: {}, verbose_map: {}, main_dttm_col: '', description: '', diff --git a/superset-frontend/src/explore/actions/datasourcesActions.test.ts b/superset-frontend/src/explore/actions/datasourcesActions.test.ts index bca3aecfd65ae..a844ff47893f6 100644 --- a/superset-frontend/src/explore/actions/datasourcesActions.test.ts +++ b/superset-frontend/src/explore/actions/datasourcesActions.test.ts @@ -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]'] diff --git a/superset-frontend/src/explore/actions/hydrateExplore.ts b/superset-frontend/src/explore/actions/hydrateExplore.ts index e259f62671c33..bced548b8d3be 100644 --- a/superset-frontend/src/explore/actions/hydrateExplore.ts +++ b/superset-frontend/src/explore/actions/hydrateExplore.ts @@ -96,6 +96,7 @@ export const hydrateExplore = if (dashboardId) { initialFormData.dashboardId = dashboardId; } + const initialDatasource = dataset; const initialExploreState = { diff --git a/superset-frontend/src/explore/controlUtils/controlUtils.test.tsx b/superset-frontend/src/explore/controlUtils/controlUtils.test.tsx index e03aba06d11df..c18873460a922 100644 --- a/superset-frontend/src/explore/controlUtils/controlUtils.test.tsx +++ b/superset-frontend/src/explore/controlUtils/controlUtils.test.tsx @@ -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', diff --git a/superset-frontend/src/explore/controlUtils/getControlValuesCompatibleWithDatasource.test.ts b/superset-frontend/src/explore/controlUtils/getControlValuesCompatibleWithDatasource.test.ts index c8d34f749d3dc..f0eb399267b11 100644 --- a/superset-frontend/src/explore/controlUtils/getControlValuesCompatibleWithDatasource.test.ts +++ b/superset-frontend/src/explore/controlUtils/getControlValuesCompatibleWithDatasource.test.ts @@ -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', diff --git a/superset-frontend/src/explore/fixtures.tsx b/superset-frontend/src/explore/fixtures.tsx index a0f3c112ec1b8..7502094ca2431 100644 --- a/superset-frontend/src/explore/fixtures.tsx +++ b/superset-frontend/src/explore/fixtures.tsx @@ -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: [], diff --git a/superset-frontend/src/features/datasets/types.ts b/superset-frontend/src/features/datasets/types.ts index 97d6f5a28048b..4c2c5b8a95516 100644 --- a/superset-frontend/src/features/datasets/types.ts +++ b/superset-frontend/src/features/datasets/types.ts @@ -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; }; diff --git a/superset-frontend/src/utils/getDatasourceUid.test.ts b/superset-frontend/src/utils/getDatasourceUid.test.ts index d3a629efcfed3..ed7ec6256bb50 100644 --- a/superset-frontend/src/utils/getDatasourceUid.test.ts +++ b/superset-frontend/src/utils/getDatasourceUid.test.ts @@ -26,6 +26,7 @@ const TEST_DATASOURCE = { columns: [], metrics: [], column_formats: {}, + currency_formats: {}, verbose_map: {}, main_dttm_col: '__timestamp', // eg. ['["ds", true]', 'ds [asc]'] diff --git a/superset/config.py b/superset/config.py index d62003991a8fb..27486ae404bcb 100644 --- a/superset/config.py +++ b/superset/config.py @@ -374,6 +374,8 @@ class D3Format(TypedDict, total=False): D3_FORMAT: D3Format = {} +CURRENCIES = ["USD", "EUR", "GBP", "INR", "MXN", "JPY", "CNY"] + # --------------------------------------------------- # Feature flags # --------------------------------------------------- diff --git a/superset/connectors/base/models.py b/superset/connectors/base/models.py index 647df374c9db6..56e5ef18ad2bd 100644 --- a/superset/connectors/base/models.py +++ b/superset/connectors/base/models.py @@ -18,9 +18,11 @@ 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 @@ 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 @@ def explore_url(self) -> str: 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 @@ def data(self) -> dict[str, Any]: "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 @@ def data(self) -> dict[str, Any]: "expression", "warning_text", "d3format", + "currency", ) return {s: getattr(self, s) for s in attrs} diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index 4eebec6be7a16..9efbd1db92e32 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -416,6 +416,7 @@ class SqlMetric(Model, BaseMetric, CertificationMixin): "expression", "description", "d3format", + "currency", "extra", "warning_text", ] diff --git a/superset/connectors/sqla/views.py b/superset/connectors/sqla/views.py index 9116b9636e220..65c6f110e4fe2 100644 --- a/superset/connectors/sqla/views.py +++ b/superset/connectors/sqla/views.py @@ -217,6 +217,7 @@ class SqlMetricInlineView( # pylint: disable=too-many-ancestors "expression", "table", "d3format", + "currency", "extra", "warning_text", ] diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py index 9d6e712a7387b..7905641f80735 100644 --- a/superset/dashboards/schemas.py +++ b/superset/dashboards/schemas.py @@ -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() diff --git a/superset/datasets/api.py b/superset/datasets/api.py index 2b6f417e37775..87e1d9e74c842 100644 --- a/superset/datasets/api.py +++ b/superset/datasets/api.py @@ -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", diff --git a/superset/datasets/schemas.py b/superset/datasets/schemas.py index f95897ce59b15..2b65d674ed41f 100644 --- a/superset/datasets/schemas.py +++ b/superset/datasets/schemas.py @@ -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 @@ def fix_extra(self, data: dict[str, Any], **kwargs: Any) -> dict[str, Any]: 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) diff --git a/superset/db_engine_specs/base.py b/superset/db_engine_specs/base.py index f4b75c50b1e93..d117766754c3b 100644 --- a/superset/db_engine_specs/base.py +++ b/superset/db_engine_specs/base.py @@ -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 diff --git a/superset/explore/schemas.py b/superset/explore/schemas.py index 37044c0394284..75c3dcac2cf30 100644 --- a/superset/explore/schemas.py +++ b/superset/explore/schemas.py @@ -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."} diff --git a/superset/migrations/versions/2023-06-21_14-02_90139bf715e4_add_currency_column_to_metrics.py b/superset/migrations/versions/2023-06-21_14-02_90139bf715e4_add_currency_column_to_metrics.py new file mode 100644 index 0000000000000..7d6f0f2ba0630 --- /dev/null +++ b/superset/migrations/versions/2023-06-21_14-02_90139bf715e4_add_currency_column_to_metrics.py @@ -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") diff --git a/superset/views/base.py b/superset/views/base.py index e66fea0a48c21..717efdff84264 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -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"], diff --git a/tests/integration_tests/datasets/commands_tests.py b/tests/integration_tests/datasets/commands_tests.py index 34a0625b36926..4919b8886dc4c 100644 --- a/tests/integration_tests/datasets/commands_tests.py +++ b/tests/integration_tests/datasets/commands_tests.py @@ -148,6 +148,7 @@ def test_export_dataset_command(self, mock_g): "main_dttm_col": None, "metrics": [ { + "currency": None, "d3format": None, "description": None, "expression": "COUNT(*)", @@ -158,6 +159,7 @@ def test_export_dataset_command(self, mock_g): "warning_text": None, }, { + "currency": None, "d3format": None, "description": None, "expression": "SUM(value)", @@ -381,6 +383,7 @@ def test_import_v1_dataset(self, sm_g, utils_g): 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 diff --git a/tests/unit_tests/datasets/commands/export_test.py b/tests/unit_tests/datasets/commands/export_test.py index c3ad4f764c4f3..17913c2ca4bd4 100644 --- a/tests/unit_tests/datasets/commands/export_test.py +++ b/tests/unit_tests/datasets/commands/export_test.py @@ -116,6 +116,7 @@ def test_export(session: Session) -> None: expression: COUNT(*) description: null d3format: null + currency: null extra: warning_markdown: null warning_text: null