diff --git a/superset-frontend/packages/superset-ui-demo/storybook/stories/plugins/plugin-chart-echarts/Timeseries/Stories.tsx b/superset-frontend/packages/superset-ui-demo/storybook/stories/plugins/plugin-chart-echarts/Timeseries/Stories.tsx index 3c20a76c06345..3f219ed6e66ab 100644 --- a/superset-frontend/packages/superset-ui-demo/storybook/stories/plugins/plugin-chart-echarts/Timeseries/Stories.tsx +++ b/superset-frontend/packages/superset-ui-demo/storybook/stories/plugins/plugin-chart-echarts/Timeseries/Stories.tsx @@ -74,6 +74,9 @@ export const Timeseries = ({ width, height }) => { logAxis: boolean('Log axis', false), yAxisFormat: 'SMART_NUMBER', stack: boolean('Stack', false), + showValue: boolean('Show Values', false), + onlyTotal: boolean('Only Total', false), + percentageThreshold: number('Percentage Threshold', 0), area: boolean('Area chart', false), markerEnabled: boolean('Enable markers', false), markerSize: number('Marker Size', 6), 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 b63feb35737c5..44f97e9bac020 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -111,6 +111,7 @@ export default function transformProps( groupby, showValue, onlyTotal, + percentageThreshold, xAxisTitle, yAxisTitle, xAxisTitleMargin, @@ -130,6 +131,7 @@ export default function transformProps( const totalStackedValues: number[] = []; const showValueIndexes: number[] = []; + const thresholdValues: number[] = []; rebasedData.forEach(data => { const values = Object.keys(data).reduce((prev, curr) => { @@ -140,6 +142,7 @@ export default function transformProps( return prev + (value as number); }, 0); totalStackedValues.push(values); + thresholdValues.push(((percentageThreshold || 0) / 100) * values); }); if (stack) { @@ -168,6 +171,7 @@ export default function transformProps( onlyTotal, totalStackedValues, showValueIndexes, + thresholdValues, richTooltip, }); if (transformedSeries) series.push(transformedSeries); 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 3ca56006adce7..d6cbcdc77d274 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts @@ -81,6 +81,7 @@ export function transformSeries( formatter?: NumberFormatter; totalStackedValues?: number[]; showValueIndexes?: number[]; + thresholdValues?: number[]; richTooltip?: boolean; }, ): SeriesOption | undefined { @@ -100,6 +101,7 @@ export function transformSeries( formatter, totalStackedValues = [], showValueIndexes = [], + thresholdValues = [], richTooltip, } = opts; const contexts = seriesContexts[name || ''] || []; @@ -211,8 +213,12 @@ export function transformSeries( } = params; const isSelectedLegend = currentSeries.legend === seriesName; if (!formatter) return numericValue; - if (!stack || !onlyTotal || isSelectedLegend) { - return formatter(numericValue); + if (!stack || isSelectedLegend) return formatter(numericValue); + if (!onlyTotal) { + if (numericValue >= thresholdValues[dataIndex]) { + return formatter(numericValue); + } + return ''; } if (seriesIndex === showValueIndexes[dataIndex]) { return formatter(totalStackedValues[dataIndex]); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts index ac634239229de..9b32b6bf704c0 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts @@ -81,6 +81,7 @@ export type EchartsTimeseriesFormData = QueryFormData & { groupby: QueryFormColumn[]; showValue: boolean; onlyTotal: boolean; + percentageThreshold: number; } & EchartsLegendFormData & EchartsTitleFormData; @@ -117,6 +118,7 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = { groupby: [], showValue: false, onlyTotal: false, + percentageThreshold: 0, ...DEFAULT_TITLE_FORM_DATA, }; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx index 76c7c42186ffc..614a0481e1951 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx @@ -24,6 +24,7 @@ import { sharedControls, } from '@superset-ui/chart-controls'; import { DEFAULT_LEGEND_FORM_DATA } from './types'; +import { DEFAULT_FORM_DATA } from './Timeseries/types'; const { legendMargin, legendOrientation, legendType, showLegend } = DEFAULT_LEGEND_FORM_DATA; @@ -136,10 +137,29 @@ const onlyTotalControl = { }, }; +const percentageThresholdControl = { + name: 'percentage_threshold', + config: { + type: 'TextControl', + label: t('Percentage threshold'), + renderTrigger: true, + isFloat: true, + default: DEFAULT_FORM_DATA.percentageThreshold, + description: t( + 'Minimum threshold in percentage points for showing labels.', + ), + visibility: ({ controls }: ControlPanelsContainerProps) => + Boolean(controls?.show_value?.value) && + Boolean(controls?.stack?.value) && + Boolean(!controls?.only_total?.value), + }, +}; + export const showValueSection = [ [showValueControl], [stackControl], [onlyTotalControl], + [percentageThresholdControl], ]; const richTooltipControl = { diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts index 96c5865d76c66..ab82417f5492f 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts @@ -22,10 +22,13 @@ import { FormulaAnnotationLayer, IntervalAnnotationLayer, TimeseriesAnnotationLayer, + AnnotationStyle, + AnnotationType, + AnnotationSourceType, } from '@superset-ui/core'; import transformProps from '../../src/Timeseries/transformProps'; -describe('EchartsTimeseries tranformProps', () => { +describe('EchartsTimeseries transformProps', () => { const formData = { colorScheme: 'bnbColors', datasource: '3__table', @@ -82,9 +85,10 @@ describe('EchartsTimeseries tranformProps', () => { it('should add a formula annotation to viz', () => { const formula: FormulaAnnotationLayer = { name: 'My Formula', - annotationType: 'FORMULA', + annotationType: AnnotationType.Formula, value: 'x+1', - style: 'solid', + style: AnnotationStyle.Solid, + showLabel: true, show: true, }; const chartProps = new ChartProps({ @@ -132,33 +136,36 @@ describe('EchartsTimeseries tranformProps', () => { it('should add an interval, event and timeseries annotation to viz', () => { const event: EventAnnotationLayer = { - annotationType: 'EVENT', + annotationType: AnnotationType.Event, name: 'My Event', show: true, - sourceType: 'NATIVE', - style: 'solid', + showLabel: true, + sourceType: AnnotationSourceType.Native, + style: AnnotationStyle.Solid, value: 1, }; const interval: IntervalAnnotationLayer = { - annotationType: 'INTERVAL', + annotationType: AnnotationType.Interval, name: 'My Interval', show: true, - sourceType: 'table', + showLabel: true, + sourceType: AnnotationSourceType.Table, titleColumn: '', timeColumn: 'start', intervalEndColumn: '', descriptionColumns: [], - style: 'dashed', + style: AnnotationStyle.Dashed, value: 2, }; const timeseries: TimeseriesAnnotationLayer = { - annotationType: 'TIME_SERIES', + annotationType: AnnotationType.Timeseries, name: 'My Timeseries', show: true, - sourceType: 'line', - style: 'solid', + showLabel: true, + sourceType: AnnotationSourceType.Line, + style: AnnotationStyle.Solid, titleColumn: '', value: 3, }; @@ -244,3 +251,198 @@ describe('EchartsTimeseries tranformProps', () => { ); }); }); + +describe('Does transformProps transform series correctly', () => { + type seriesDataType = [Date, number]; + type labelFormatterType = (params: { + value: seriesDataType; + dataIndex: number; + seriesIndex: number; + }) => string; + type seriesType = { + label: { show: boolean; formatter: labelFormatterType }; + data: seriesDataType[]; + name: string; + }; + + const formData = { + colorScheme: 'bnbColors', + datasource: '3__table', + granularity_sqla: 'ds', + metric: 'sum__num', + groupby: ['foo', 'bar'], + showValue: true, + stack: true, + onlyTotal: false, + percentageThreshold: 50, + }; + const queriesData = [ + { + data: [ + { + 'San Francisco': 1, + 'New York': 2, + Boston: 1, + __timestamp: 599616000000, + }, + { + 'San Francisco': 3, + 'New York': 4, + Boston: 1, + __timestamp: 599916000000, + }, + { + 'San Francisco': 5, + 'New York': 8, + Boston: 6, + __timestamp: 600216000000, + }, + { + 'San Francisco': 2, + 'New York': 7, + Boston: 2, + __timestamp: 600516000000, + }, + ], + }, + ]; + const chartPropsConfig = { + formData, + width: 800, + height: 600, + queriesData, + }; + + const totalStackedValues = queriesData[0].data.reduce( + (totals, currentStack) => { + const total = Object.keys(currentStack).reduce((stackSum, key) => { + if (key === '__timestamp') return stackSum; + return stackSum + currentStack[key]; + }, 0); + totals.push(total); + return totals; + }, + [] as number[], + ); + + it('should show labels when showValue is true', () => { + const chartProps = new ChartProps(chartPropsConfig); + + const transformedSeries = transformProps(chartProps).echartOptions + .series as seriesType[]; + + transformedSeries.forEach(series => { + expect(series.label.show).toBe(true); + }); + }); + + it('should not show labels when showValue is false', () => { + const updatedChartPropsConfig = { + ...chartPropsConfig, + formData: { ...formData, showValue: false }, + }; + + const chartProps = new ChartProps(updatedChartPropsConfig); + + const transformedSeries = transformProps(chartProps).echartOptions + .series as seriesType[]; + + transformedSeries.forEach(series => { + expect(series.label.show).toBe(false); + }); + }); + + it('should show only totals when onlyTotal is true', () => { + const updatedChartPropsConfig = { + ...chartPropsConfig, + formData: { ...formData, onlyTotal: true }, + }; + + const chartProps = new ChartProps(updatedChartPropsConfig); + + const transformedSeries = transformProps(chartProps).echartOptions + .series as seriesType[]; + + const showValueIndexes: number[] = []; + + transformedSeries.forEach((entry, seriesIndex) => { + const { data = [] } = entry; + (data as [Date, number][]).forEach((datum, dataIndex) => { + if (datum[1] !== null) { + showValueIndexes[dataIndex] = seriesIndex; + } + }); + }); + + transformedSeries.forEach((series, seriesIndex) => { + expect(series.label.show).toBe(true); + series.data.forEach((value, dataIndex) => { + const params = { + value, + dataIndex, + seriesIndex, + }; + + let expectedLabel: string; + + if (seriesIndex === showValueIndexes[dataIndex]) { + expectedLabel = String(totalStackedValues[dataIndex]); + } else { + expectedLabel = ''; + } + + expect(series.label.formatter(params)).toBe(expectedLabel); + }); + }); + }); + + it('should show labels on values >= percentageThreshold if onlyTotal is false', () => { + const chartProps = new ChartProps(chartPropsConfig); + + const transformedSeries = transformProps(chartProps).echartOptions + .series as seriesType[]; + + const expectedThresholds = totalStackedValues.map( + total => ((formData.percentageThreshold || 0) / 100) * total, + ); + + transformedSeries.forEach((series, seriesIndex) => { + expect(series.label.show).toBe(true); + series.data.forEach((value, dataIndex) => { + const params = { + value, + dataIndex, + seriesIndex, + }; + const expectedLabel = + value[1] >= expectedThresholds[dataIndex] ? String(value[1]) : ''; + expect(series.label.formatter(params)).toBe(expectedLabel); + }); + }); + }); + + it('should not apply percentage threshold when showValue is true and stack is false', () => { + const updatedChartPropsConfig = { + ...chartPropsConfig, + formData: { ...formData, stack: false }, + }; + + const chartProps = new ChartProps(updatedChartPropsConfig); + + const transformedSeries = transformProps(chartProps).echartOptions + .series as seriesType[]; + + transformedSeries.forEach((series, seriesIndex) => { + expect(series.label.show).toBe(true); + series.data.forEach((value, dataIndex) => { + const params = { + value, + dataIndex, + seriesIndex, + }; + const expectedLabel = String(value[1]); + expect(series.label.formatter(params)).toBe(expectedLabel); + }); + }); + }); +});