diff --git a/superset-frontend/packages/superset-ui-demo/storybook/stories/plugins/plugin-chart-echarts/Waterfall/Stories.tsx b/superset-frontend/packages/superset-ui-demo/storybook/stories/plugins/plugin-chart-echarts/Waterfall/Stories.tsx new file mode 100644 index 0000000000000..6b880fd6f8c2f --- /dev/null +++ b/superset-frontend/packages/superset-ui-demo/storybook/stories/plugins/plugin-chart-echarts/Waterfall/Stories.tsx @@ -0,0 +1,69 @@ +/* + * 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 React from 'react'; +import { SuperChart, getChartTransformPropsRegistry } from '@superset-ui/core'; +import { withKnobs } from '@storybook/addon-knobs'; +import { + EchartsWaterfallChartPlugin, + WaterfallTransformProps, +} from '@superset-ui/plugin-chart-echarts'; +import data from './data'; +import { withResizableChartDemo } from '../../../../shared/components/ResizableChartDemo'; + +new EchartsWaterfallChartPlugin() + .configure({ key: 'echarts-waterfall' }) + .register(); + +getChartTransformPropsRegistry().registerValue( + 'echarts-waterfall', + WaterfallTransformProps, +); + +export default { + title: 'Chart Plugins|plugin-chart-echarts/Waterfall', + decorators: [withKnobs, withResizableChartDemo], +}; + +export const Waterfall = ({ width, height }) => ( + +); diff --git a/superset-frontend/packages/superset-ui-demo/storybook/stories/plugins/plugin-chart-echarts/Waterfall/data.ts b/superset-frontend/packages/superset-ui-demo/storybook/stories/plugins/plugin-chart-echarts/Waterfall/data.ts new file mode 100644 index 0000000000000..e072eedc7b72f --- /dev/null +++ b/superset-frontend/packages/superset-ui-demo/storybook/stories/plugins/plugin-chart-echarts/Waterfall/data.ts @@ -0,0 +1,80 @@ +/** + * 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 [ + { due_to_group: 'Facebook', period: '2020', 'SUM(decomp_volume)': 1945565.5 }, + { + due_to_group: 'Competitor TV Advertising', + period: '2019', + 'SUM(decomp_volume)': 1213252, + }, + { + due_to_group: 'Online Advertising', + period: '2018', + 'SUM(decomp_volume)': 999990, + }, + { due_to_group: 'COREBASE', period: '2017', 'SUM(decomp_volume)': 852094 }, + { due_to_group: 'COREBASE', period: '2018', 'SUM(decomp_volume)': 736576 }, + { due_to_group: 'DISPLAY', period: '2017', 'SUM(decomp_volume)': 621608 }, + { due_to_group: 'DISPLAY', period: '2018', 'SUM(decomp_volume)': 388904 }, + { due_to_group: 'Facebook', period: '2019', 'SUM(decomp_volume)': 94909 }, + { + due_to_group: 'Online Advertising', + period: '2017', + 'SUM(decomp_volume)': 81334, + }, + { due_to_group: 'Halo TV', period: '2018', 'SUM(decomp_volume)': 66828 }, + { due_to_group: 'Halo TV', period: '2017', 'SUM(decomp_volume)': 46818 }, + { + due_to_group: 'Competitor TV Advertising', + period: '2017', + 'SUM(decomp_volume)': 25252, + }, + { due_to_group: 'Facebook', period: '2017', 'SUM(decomp_volume)': 23932 }, + { due_to_group: 'DFSI', period: '2017', 'SUM(decomp_volume)': 21466 }, + { due_to_group: 'Coupons', period: '2017', 'SUM(decomp_volume)': 11160 }, + { due_to_group: 'Facebook', period: '2018', 'SUM(decomp_volume)': 9444 }, + { due_to_group: 'DFSI', period: '2019', 'SUM(decomp_volume)': 8785 }, + { + due_to_group: 'Competitive Coupons', + period: '2017', + 'SUM(decomp_volume)': 8724, + }, + { + due_to_group: 'Competitive Coupons', + period: '2019', + 'SUM(decomp_volume)': 8724, + }, + { due_to_group: 'Coupons', period: '2019', 'SUM(decomp_volume)': 2950 }, + { due_to_group: 'BB Display', period: '2019', 'SUM(decomp_volume)': 1844 }, + { due_to_group: 'BB Display', period: '2017', 'SUM(decomp_volume)': 1844 }, + { due_to_group: 'Email', period: '2017', 'SUM(decomp_volume)': 810 }, + { due_to_group: 'OTHER', period: '2017', 'SUM(decomp_volume)': 78 }, + { due_to_group: 'Email', period: '2019', 'SUM(decomp_volume)': -987000 }, + { due_to_group: 'Email', period: '2020', 'SUM(decomp_volume)': -998988 }, + { + due_to_group: 'Online Advertising', + period: '2020', + 'SUM(decomp_volume)': -1500000.7, + }, + { + due_to_group: 'Online Advertising', + period: '2019', + 'SUM(decomp_volume)': -1671652, + }, +]; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/EchartsWaterfall.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/EchartsWaterfall.tsx new file mode 100644 index 0000000000000..a448c9f93eab6 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/EchartsWaterfall.tsx @@ -0,0 +1,84 @@ +/** + * 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 React, { useCallback } from 'react'; +import Echart from '../components/Echart'; +import { allEventHandlers } from '../utils/eventHandlers'; +import { WaterfallChartTransformedProps } from './types'; + +export default function EchartsWaterfall( + props: WaterfallChartTransformedProps, +) { + const { + height, + width, + echartOptions, + setDataMask, + labelMap, + groupby, + refs, + selectedValues, + } = props; + const handleChange = useCallback( + (values: string[]) => { + const groupbyValues = values.map(value => labelMap[value]); + + setDataMask({ + extraFormData: { + filters: + values.length === 0 + ? [] + : groupby.map((col, idx) => { + const val = groupbyValues.map(v => v[idx]); + if (val === null || val === undefined) + return { + col, + op: 'IS NULL', + }; + return { + col, + op: 'IN', + val: val as (string | number | boolean)[], + }; + }), + }, + filterState: { + value: groupbyValues.length ? groupbyValues : null, + selectedValues: values.length ? values : null, + }, + }); + }, + [setDataMask, groupby, labelMap], + ); + + const eventHandlers = { + ...allEventHandlers(props), + handleChange, + }; + + return ( + + ); +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/buildQuery.ts new file mode 100644 index 0000000000000..353ee8fa20e00 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/buildQuery.ts @@ -0,0 +1,29 @@ +/** + * 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 { buildQueryContext, QueryFormData } from '@superset-ui/core'; + +export default function buildQuery(formData: QueryFormData) { + const { series, columns } = formData; + return buildQueryContext(formData, baseQueryObject => [ + { + ...baseQueryObject, + columns: columns?.length ? [series, columns] : [series], + }, + ]); +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/constants.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/constants.ts new file mode 100644 index 0000000000000..17b7861f80ade --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/constants.ts @@ -0,0 +1,29 @@ +/** + * 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 { t } from '@superset-ui/core'; + +export const TOTAL_MARK = t('Total'); +export const ASSIST_MARK = t('Assist'); +export const LEGEND = { + INCREASE: t('Increase'), + DECREASE: t('Decrease'), + TOTAL: t('Total'), +}; +export const TOKEN = '-'; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/controlPanel.tsx new file mode 100644 index 0000000000000..852f2680b3c15 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/controlPanel.tsx @@ -0,0 +1,142 @@ +/** + * 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 React from 'react'; +import { ensureIsArray, t } from '@superset-ui/core'; +import { + ControlPanelConfig, + formatSelectOptions, + getStandardizedControls, + sections, +} from '@superset-ui/chart-controls'; +import { showValueControl } from '../controls'; + +const config: ControlPanelConfig = { + controlPanelSections: [ + sections.legacyTimeseriesTime, + { + label: t('Query'), + expanded: true, + controlSetRows: [ + ['series'], + ['columns'], + ['metric'], + ['adhoc_filters'], + ['row_limit'], + ], + }, + { + label: t('Chart Options'), + expanded: true, + controlSetRows: [ + ['color_scheme'], + [showValueControl], + [ + { + name: 'show_legend', + config: { + type: 'CheckboxControl', + label: t('Show legend'), + renderTrigger: true, + default: false, + description: t('Whether to display a legend for the chart'), + }, + }, + ], + [ + { + name: 'rich_tooltip', + config: { + type: 'CheckboxControl', + label: t('Rich tooltip'), + renderTrigger: true, + default: true, + description: t( + 'Shows a list of all series available at that point in time', + ), + }, + }, + ], + [
{t('X Axis')}
], + [ + { + name: 'x_axis_label', + config: { + type: 'TextControl', + label: t('X Axis Label'), + renderTrigger: true, + default: '', + }, + }, + ], + [ + { + name: 'x_ticks_layout', + config: { + type: 'SelectControl', + label: t('X Tick Layout'), + choices: formatSelectOptions([ + 'auto', + 'flat', + '45°', + '90°', + 'staggered', + ]), + default: 'auto', + clearable: false, + renderTrigger: true, + description: t('The way the ticks are laid out on the X-axis'), + }, + }, + ], + [
{t('Y Axis')}
], + [ + { + name: 'y_axis_label', + config: { + type: 'TextControl', + label: t('Y Axis Label'), + renderTrigger: true, + default: '', + }, + }, + ], + ['y_axis_format'], + ], + }, + ], + controlOverrides: { + columns: { + label: t('Breakdowns'), + description: t('Defines how each series is broken down'), + multi: false, + }, + }, + formDataOverrides: formData => { + const series = getStandardizedControls() + .popAllColumns() + .filter(col => !ensureIsArray(formData.columns).includes(col)); + return { + ...formData, + series, + metric: getStandardizedControls().shiftMetric(), + }; + }, +}; + +export default config; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/images/thumbnail.png b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/images/thumbnail.png new file mode 100644 index 0000000000000..91ef20f515f92 Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/images/thumbnail.png differ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/index.ts new file mode 100644 index 0000000000000..5242434f94c1f --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/index.ts @@ -0,0 +1,59 @@ +/** + * 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 + * regardin + * g 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 { Behavior, ChartMetadata, ChartPlugin, t } from '@superset-ui/core'; +import buildQuery from './buildQuery'; +import controlPanel from './controlPanel'; +import transformProps from './transformProps'; +import thumbnail from './images/thumbnail.png'; +import { EchartsWaterfallChartProps, EchartsWaterfallFormData } from './types'; + +export default class EchartsWaterfallChartPlugin extends ChartPlugin< + EchartsWaterfallFormData, + EchartsWaterfallChartProps +> { + /** + * The constructor is used to pass relevant metadata and callbacks that get + * registered in respective registries that are used throughout the library + * and application. A more thorough description of each property is given in + * the respective imported file. + * + * It is worth noting that `buildQuery` and is optional, and only needed for + * advanced visualizations that require either post processing operations + * (pivoting, rolling aggregations, sorting etc) or submitting multiple queries. + */ + constructor() { + super({ + buildQuery, + controlPanel, + loadChart: () => import('./EchartsWaterfall'), + metadata: new ChartMetadata({ + behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], + credits: ['https://echarts.apache.org'], + category: t('Evolution'), + description: '', + exampleGallery: [], + name: t('Waterfall Chart'), + thumbnail, + tags: [], + }), + transformProps, + }); + } +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/transformProps.ts new file mode 100644 index 0000000000000..8ea8f688264bf --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/transformProps.ts @@ -0,0 +1,401 @@ +/** + * 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 { + CategoricalColorNamespace, + DataRecord, + getColumnLabel, + getMetricLabel, + getNumberFormatter, + NumberFormatter, + SupersetTheme, +} from '@superset-ui/core'; +import { EChartsOption, BarSeriesOption } from 'echarts'; +import { CallbackDataParams } from 'echarts/types/src/util/types'; +import { + EchartsWaterfallFormData, + EchartsWaterfallChartProps, + ISeriesData, + WaterfallChartTransformedProps, +} from './types'; +import { getDefaultTooltip } from '../utils/tooltip'; +import { defaultGrid, defaultYAxis } from '../defaults'; +import { ASSIST_MARK, LEGEND, TOKEN, TOTAL_MARK } from './constants'; +import { extractGroupbyLabel, getColtypesMapping } from '../utils/series'; +import { Refs } from '../types'; + +function formatTooltip({ + theme, + params, + numberFormatter, + richTooltip, +}: { + theme: SupersetTheme; + params: any; + numberFormatter: NumberFormatter; + richTooltip: boolean; +}) { + const htmlMaker = (params: any) => + ` +
${params.name}
+
+ ${params.marker} + + ${params.seriesName}: + + + ${numberFormatter(params.data)} + +
+ `; + + if (richTooltip) { + const [, increaseParams, decreaseParams, totalParams] = params; + if (increaseParams.data !== TOKEN || increaseParams.data === null) { + return htmlMaker(increaseParams); + } + if (decreaseParams.data !== TOKEN) { + return htmlMaker(decreaseParams); + } + if (totalParams.data !== TOKEN) { + return htmlMaker(totalParams); + } + } else if (params.seriesName !== ASSIST_MARK) { + return htmlMaker(params); + } + return ''; +} + +function transformer({ + data, + breakdown, + series, + metric, +}: { + data: DataRecord[]; + breakdown: string; + series: string; + metric: string; +}) { + // Group by series (temporary map) + const groupedData = data.reduce((acc, cur) => { + const categoryLabel = cur[series] as string; + const categoryData = acc.get(categoryLabel) || []; + categoryData.push(cur); + acc.set(categoryLabel, categoryData); + return acc; + }, new Map()); + + const transformedData: DataRecord[] = []; + + if (breakdown?.length) { + groupedData.forEach((value, key) => { + const tempValue = value; + // Calc total per period + const sum = tempValue.reduce( + (acc, cur) => acc + ((cur[metric] as number) ?? 0), + 0, + ); + // Push total per period to the end of period values array + tempValue.push({ + [series]: key, + [breakdown]: TOTAL_MARK, + [metric]: sum, + }); + transformedData.push(...tempValue); + }); + } else { + let total = 0; + groupedData.forEach((value, key) => { + const sum = value.reduce( + (acc, cur) => acc + ((cur[metric] as number) ?? 0), + 0, + ); + transformedData.push({ + [series]: key, + [metric]: sum, + }); + total += sum; + }); + transformedData.push({ + [series]: TOTAL_MARK, + [metric]: total, + }); + } + + return transformedData; +} + +export default function transformProps( + chartProps: EchartsWaterfallChartProps, +): WaterfallChartTransformedProps { + const { + width, + height, + formData, + queriesData, + hooks, + filterState, + theme, + inContextMenu, + } = chartProps; + const refs: Refs = {}; + const { data = [] } = queriesData[0]; + const coltypeMapping = getColtypesMapping(queriesData[0]); + const { setDataMask = () => {}, onContextMenu } = hooks; + const { + colorScheme, + metric = '', + columns, + series, + xTicksLayout, + showLegend, + yAxisLabel, + xAxisLabel, + yAxisFormat, + richTooltip, + showValue, + sliceId, + } = formData as EchartsWaterfallFormData; + const colorFn = CategoricalColorNamespace.getScale(colorScheme as string); + const numberFormatter = getNumberFormatter(yAxisFormat); + const formatter = (params: CallbackDataParams) => { + const { value, seriesName } = params; + let formattedValue = numberFormatter(value as number); + if (seriesName === LEGEND.DECREASE) { + formattedValue = `-${formattedValue}`; + } + return formattedValue; + }; + const breakdown = columns?.length ? columns : ''; + const groupby = breakdown ? [series, breakdown] : [series]; + const metricLabel = getMetricLabel(metric); + const columnLabels = groupby.map(getColumnLabel); + const columnsLabelMap = new Map(); + + const transformedData = transformer({ + data, + breakdown, + series, + metric: metricLabel, + }); + + const assistData: ISeriesData[] = []; + const increaseData: ISeriesData[] = []; + const decreaseData: ISeriesData[] = []; + const totalData: ISeriesData[] = []; + + transformedData.forEach((datum, index, self) => { + const totalSum = self.slice(0, index + 1).reduce((prev, cur, i) => { + if (breakdown?.length) { + if (cur[breakdown] !== TOTAL_MARK || i === 0) { + return prev + ((cur[metricLabel] as number) ?? 0); + } + } else if (cur[series] !== TOTAL_MARK) { + return prev + ((cur[metricLabel] as number) ?? 0); + } + return prev; + }, 0); + + const joinedName = extractGroupbyLabel({ + datum, + groupby: columnLabels, + coltypeMapping, + }); + columnsLabelMap.set( + joinedName, + columnLabels.map(col => datum[col] as string), + ); + const value = datum[metricLabel] as number; + const isNegative = value < 0; + if (datum[breakdown] === TOTAL_MARK || datum[series] === TOTAL_MARK) { + increaseData.push(TOKEN); + decreaseData.push(TOKEN); + assistData.push(TOKEN); + totalData.push(totalSum); + } else if (isNegative) { + increaseData.push(TOKEN); + decreaseData.push(Math.abs(value)); + assistData.push(totalSum); + totalData.push(TOKEN); + } else { + increaseData.push(value); + decreaseData.push(TOKEN); + assistData.push(totalSum - value); + totalData.push(TOKEN); + } + }); + + let axisLabel; + if (xTicksLayout === '45°') { + axisLabel = { rotate: -45 }; + } else if (xTicksLayout === '90°') { + axisLabel = { rotate: -90 }; + } else if (xTicksLayout === 'flat') { + axisLabel = { rotate: 0 }; + } else if (xTicksLayout === 'staggered') { + axisLabel = { rotate: -45 }; + } else { + axisLabel = { show: true }; + } + + let xAxisData: string[] = []; + if (breakdown?.length) { + xAxisData = transformedData.map(row => { + if (row[breakdown] === TOTAL_MARK) { + return row[series] as string; + } + return row[breakdown] as string; + }); + } else { + xAxisData = transformedData.map(row => row[series] as string); + } + + const barSeries: BarSeriesOption[] = [ + { + name: ASSIST_MARK, + type: 'bar', + stack: 'stack', + itemStyle: { + borderColor: 'transparent', + color: 'transparent', + }, + emphasis: { + itemStyle: { + borderColor: 'transparent', + color: 'transparent', + }, + }, + data: assistData, + }, + { + name: LEGEND.INCREASE, + type: 'bar', + stack: 'stack', + label: { + show: showValue, + position: 'top', + formatter, + }, + itemStyle: { + color: colorFn(LEGEND.INCREASE, sliceId), + }, + data: increaseData, + }, + { + name: LEGEND.DECREASE, + type: 'bar', + stack: 'stack', + label: { + show: showValue, + position: 'bottom', + formatter, + }, + itemStyle: { + color: colorFn(LEGEND.DECREASE, sliceId), + }, + data: decreaseData, + }, + { + name: LEGEND.TOTAL, + type: 'bar', + stack: 'stack', + label: { + show: showValue, + position: 'top', + formatter, + }, + itemStyle: { + color: colorFn(LEGEND.TOTAL, sliceId), + }, + data: totalData, + }, + ]; + + const echartOptions: EChartsOption = { + grid: { + ...defaultGrid, + top: theme.gridUnit * 7, + bottom: theme.gridUnit * 7, + left: theme.gridUnit * 5, + right: theme.gridUnit * 7, + }, + legend: { + show: showLegend, + data: [LEGEND.INCREASE, LEGEND.DECREASE, LEGEND.TOTAL], + }, + xAxis: { + type: 'category', + data: xAxisData, + name: xAxisLabel, + nameTextStyle: { + padding: [theme.gridUnit * 4, 0, 0, 0], + }, + nameLocation: 'middle', + axisLabel, + }, + yAxis: { + ...defaultYAxis, + type: 'value', + nameTextStyle: { + padding: [0, 0, theme.gridUnit * 5, 0], + }, + nameLocation: 'middle', + name: yAxisLabel, + axisLabel: { formatter: numberFormatter }, + }, + tooltip: { + ...getDefaultTooltip(refs), + appendToBody: true, + trigger: richTooltip ? 'axis' : 'item', + show: !inContextMenu, + formatter: (params: any) => + formatTooltip({ + theme, + params, + numberFormatter, + richTooltip, + }), + }, + series: barSeries, + }; + + return { + refs, + formData, + width, + height, + echartOptions, + setDataMask, + labelMap: Object.fromEntries(columnsLabelMap), + groupby, + selectedValues: filterState.selectedValues || [], + onContextMenu, + }; +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/types.ts new file mode 100644 index 0000000000000..9821cf3146392 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/types.ts @@ -0,0 +1,66 @@ +/** + * 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 { + ChartDataResponseResult, + ChartProps, + QueryFormData, + QueryFormMetric, +} from '@superset-ui/core'; +import { BarDataItemOption } from 'echarts/types/src/chart/bar/BarSeries'; +import { OptionDataValue } from 'echarts/types/src/util/types'; +import { + BaseTransformedProps, + CrossFilterTransformedProps, + LegendFormData, +} from '../types'; + +export type WaterfallFormXTicksLayout = + | '45°' + | '90°' + | 'auto' + | 'flat' + | 'staggered'; + +export type ISeriesData = + | BarDataItemOption + | OptionDataValue + | OptionDataValue[]; + +export type EchartsWaterfallFormData = QueryFormData & + LegendFormData & { + metric: QueryFormMetric; + yAxisLabel: string; + xAxisLabel: string; + yAxisFormat: string; + xTicksLayout?: WaterfallFormXTicksLayout; + series: string; + columns?: string; + }; + +export const DEFAULT_FORM_DATA: Partial = { + showLegend: true, +}; + +export interface EchartsWaterfallChartProps extends ChartProps { + formData: EchartsWaterfallFormData; + queriesData: ChartDataResponseResult[]; +} + +export type WaterfallChartTransformedProps = + BaseTransformedProps & CrossFilterTransformedProps; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/index.ts index f8c7cf61036fb..1a7458a58605e 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/index.ts @@ -35,6 +35,7 @@ export { default as EchartsTreemapChartPlugin } from './Treemap'; export { BigNumberChartPlugin, BigNumberTotalChartPlugin } from './BigNumber'; export { default as EchartsSunburstChartPlugin } from './Sunburst'; export { default as EchartsBubbleChartPlugin } from './Bubble'; +export { default as EchartsWaterfallChartPlugin } from './Waterfall'; export { default as BoxPlotTransformProps } from './BoxPlot/transformProps'; export { default as FunnelTransformProps } from './Funnel/transformProps'; @@ -48,6 +49,7 @@ export { default as TreeTransformProps } from './Tree/transformProps'; export { default as TreemapTransformProps } from './Treemap/transformProps'; export { default as SunburstTransformProps } from './Sunburst/transformProps'; export { default as BubbleTransformProps } from './Bubble/transformProps'; +export { default as WaterfallTransformProps } from './Waterfall/transformProps'; export { DEFAULT_FORM_DATA as TimeseriesDefaultFormData } from './Timeseries/constants'; diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Waterfall/buildQuery.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Waterfall/buildQuery.test.ts new file mode 100644 index 0000000000000..9c5d28376ba28 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Waterfall/buildQuery.test.ts @@ -0,0 +1,38 @@ +/** + * 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 { SqlaFormData } from '@superset-ui/core'; +import buildQuery from '../../src/Waterfall/buildQuery'; + +describe('Waterfall buildQuery', () => { + const formData = { + datasource: '5__table', + granularity_sqla: 'ds', + metric: 'foo', + series: 'bar', + columns: 'baz', + viz_type: 'my_chart', + }; + + it('should build query fields from form data', () => { + const queryContext = buildQuery(formData as unknown as SqlaFormData); + const [query] = queryContext.queries; + expect(query.metrics).toEqual(['foo']); + expect(query.columns).toEqual(['bar', 'baz']); + }); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Waterfall/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Waterfall/transformProps.test.ts new file mode 100644 index 0000000000000..c221b9303358a --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Waterfall/transformProps.test.ts @@ -0,0 +1,121 @@ +/** + * 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 { ChartProps, supersetTheme } from '@superset-ui/core'; +import { EchartsWaterfallChartProps } from '../../src/Waterfall/types'; +import transformProps from '../../src/Waterfall/transformProps'; + +describe('Waterfall tranformProps', () => { + const data = [ + { foo: 'Sylvester', bar: '2019', sum: 10 }, + { foo: 'Arnold', bar: '2019', sum: 3 }, + { foo: 'Sylvester', bar: '2020', sum: -10 }, + { foo: 'Arnold', bar: '2020', sum: 5 }, + ]; + + it('should tranform chart props for viz when breakdown not exist', () => { + const formData1 = { + colorScheme: 'bnbColors', + datasource: '3__table', + granularity_sqla: 'ds', + metric: 'sum', + series: 'bar', + }; + const chartProps = new ChartProps({ + formData: formData1, + width: 800, + height: 600, + queriesData: [ + { + data, + }, + ], + theme: supersetTheme, + }); + expect( + transformProps(chartProps as unknown as EchartsWaterfallChartProps), + ).toEqual( + expect.objectContaining({ + width: 800, + height: 600, + echartOptions: expect.objectContaining({ + series: [ + expect.objectContaining({ + data: [0, 8, '-'], + }), + expect.objectContaining({ + data: [13, '-', '-'], + }), + expect.objectContaining({ + data: ['-', 5, '-'], + }), + expect.objectContaining({ + data: ['-', '-', 8], + }), + ], + }), + }), + ); + }); + + it('should tranform chart props for viz when breakdown exist', () => { + const formData1 = { + colorScheme: 'bnbColors', + datasource: '3__table', + granularity_sqla: 'ds', + metric: 'sum', + series: 'bar', + columns: 'foo', + }; + const chartProps = new ChartProps({ + formData: formData1, + width: 800, + height: 600, + queriesData: [ + { + data, + }, + ], + theme: supersetTheme, + }); + expect( + transformProps(chartProps as unknown as EchartsWaterfallChartProps), + ).toEqual( + expect.objectContaining({ + width: 800, + height: 600, + echartOptions: expect.objectContaining({ + series: [ + expect.objectContaining({ + data: [0, 10, '-', 3, 3, '-'], + }), + expect.objectContaining({ + data: [10, 3, '-', '-', 5, '-'], + }), + expect.objectContaining({ + data: ['-', '-', '-', 10, '-', '-'], + }), + expect.objectContaining({ + data: ['-', '-', 13, '-', '-', 8], + }), + ], + }), + }), + ); + }); +}); diff --git a/superset-frontend/src/visualizations/presets/MainPreset.js b/superset-frontend/src/visualizations/presets/MainPreset.js index 451196c35dbac..f37a9155e1d71 100644 --- a/superset-frontend/src/visualizations/presets/MainPreset.js +++ b/superset-frontend/src/visualizations/presets/MainPreset.js @@ -66,6 +66,7 @@ import { EchartsTreeChartPlugin, EchartsSunburstChartPlugin, EchartsBubbleChartPlugin, + EchartsWaterfallChartPlugin, } from '@superset-ui/plugin-chart-echarts'; import { SelectFilterPlugin, @@ -153,6 +154,9 @@ export default class MainPreset extends Preset { new EchartsTimeseriesStepChartPlugin().configure({ key: 'echarts_timeseries_step', }), + new EchartsWaterfallChartPlugin().configure({ + key: 'waterfall', + }), new SelectFilterPlugin().configure({ key: 'filter_select' }), new RangeFilterPlugin().configure({ key: 'filter_range' }), new TimeFilterPlugin().configure({ key: 'filter_time' }),