From 3df8335f8792c85d7e2f7fefa5dd60fb2c0befaf Mon Sep 17 00:00:00 2001 From: "Michael S. Molina" <70410625+michael-s-molina@users.noreply.github.com> Date: Tue, 9 Aug 2022 17:02:31 -0300 Subject: [PATCH] feat: Adds drill to detail context menu for ECharts visualizations (#20891) * feat: Adds drill to detail context menu for ECharts visualizations * Rebases and adds time grain * Fixes selected gauge values * Fixes Treemap edge click * Adds right click to big number trendline * Address some comments --- .../src/chart/models/ChartProps.ts | 9 ++ .../superset-ui-core/src/query/types/Query.ts | 3 + .../src/utils/featureFlags.ts | 1 + .../BigNumberTotal/transformProps.ts | 6 +- .../src/BigNumber/BigNumberViz.tsx | 56 +++++++- .../BigNumberWithTrendline/transformProps.ts | 20 ++- .../src/BoxPlot/EchartsBoxPlot.tsx | 35 ++--- .../src/BoxPlot/transformProps.ts | 15 ++- .../plugin-chart-echarts/src/BoxPlot/types.ts | 19 +-- .../src/Funnel/EchartsFunnel.tsx | 35 ++--- .../src/Funnel/transformProps.ts | 16 ++- .../plugin-chart-echarts/src/Funnel/types.ts | 24 ++-- .../src/Gauge/EchartsGauge.tsx | 31 +++-- .../src/Gauge/transformProps.ts | 3 +- .../src/Graph/EchartsGraph.tsx | 54 +++++++- .../src/Graph/transformProps.ts | 15 ++- .../plugin-chart-echarts/src/Graph/types.ts | 22 +++- .../EchartsMixedTimeseries.tsx | 36 ++++- .../src/MixedTimeseries/transformProps.ts | 8 +- .../src/MixedTimeseries/types.ts | 27 ++-- .../src/Pie/EchartsPie.tsx | 35 ++--- .../src/Pie/transformProps.ts | 16 ++- .../plugin-chart-echarts/src/Pie/types.ts | 23 ++-- .../src/Radar/EchartsRadar.tsx | 35 ++--- .../src/Radar/transformProps.ts | 18 ++- .../plugin-chart-echarts/src/Radar/types.ts | 16 +-- .../src/Timeseries/EchartsTimeseries.tsx | 30 +++++ .../src/Timeseries/transformProps.ts | 5 + .../src/Timeseries/transformers.ts | 3 + .../src/Timeseries/types.ts | 5 +- .../src/Treemap/EchartsTreemap.tsx | 23 +++- .../src/Treemap/transformProps.ts | 16 ++- .../plugin-chart-echarts/src/Treemap/types.ts | 18 +-- .../plugins/plugin-chart-echarts/src/types.ts | 6 + .../src/utils/eventHandlers.ts | 76 +++++++++++ .../src/components/Chart/ChartContextMenu.tsx | 123 ++++++++++++++++++ .../src/components/Chart/ChartRenderer.jsx | 99 ++++++++++---- superset/config.py | 1 + 38 files changed, 734 insertions(+), 249 deletions(-) create mode 100644 superset-frontend/plugins/plugin-chart-echarts/src/utils/eventHandlers.ts create mode 100644 superset-frontend/src/components/Chart/ChartContextMenu.tsx diff --git a/superset-frontend/packages/superset-ui-core/src/chart/models/ChartProps.ts b/superset-frontend/packages/superset-ui-core/src/chart/models/ChartProps.ts index e67381711810..324c4dfc456e 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/models/ChartProps.ts +++ b/superset-frontend/packages/superset-ui-core/src/chart/models/ChartProps.ts @@ -50,6 +50,8 @@ type Hooks = { * also handles "change" and "remove". */ onAddFilter?: (newFilters: DataRecordFilters, merge?: boolean) => void; + /** handle right click */ + onContextMenu?: HandlerFunction; /** handle errors */ onError?: HandlerFunction; /** use the vis as control to update state */ @@ -136,6 +138,8 @@ export default class ChartProps { inputRef?: RefObject; + inContextMenu?: boolean; + theme: SupersetTheme; constructor(config: ChartPropsConfig & { formData?: FormData } = {}) { @@ -154,6 +158,7 @@ export default class ChartProps { appSection, isRefreshing, inputRef, + inContextMenu = false, theme, } = config; this.width = width; @@ -172,6 +177,7 @@ export default class ChartProps { this.appSection = appSection; this.isRefreshing = isRefreshing; this.inputRef = inputRef; + this.inContextMenu = inContextMenu; this.theme = theme; } } @@ -193,6 +199,7 @@ ChartProps.createSelector = function create(): ChartPropsSelector { input => input.appSection, input => input.isRefreshing, input => input.inputRef, + input => input.inContextMenu, input => input.theme, ( annotationData, @@ -209,6 +216,7 @@ ChartProps.createSelector = function create(): ChartPropsSelector { appSection, isRefreshing, inputRef, + inContextMenu, theme, ) => new ChartProps({ @@ -226,6 +234,7 @@ ChartProps.createSelector = function create(): ChartPropsSelector { appSection, isRefreshing, inputRef, + inContextMenu, theme, }), ); diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts index 9105e5b9c386..ec600da862c3 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts @@ -40,13 +40,16 @@ export type QueryObjectFilterClause = { | { op: BinaryOperator; val: string | number | boolean; + formattedVal?: string; } | { op: SetOperator; val: (string | number | boolean)[]; + formattedVal?: string[]; } | { op: UnaryOperator; + formattedVal?: string; } ); diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts index 703a7fe5e35e..93678d8d956b 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts @@ -57,6 +57,7 @@ export enum FeatureFlag { USE_ANALAGOUS_COLORS = 'USE_ANALAGOUS_COLORS', DASHBOARD_EDIT_CHART_IN_NEW_TAB = 'DASHBOARD_EDIT_CHART_IN_NEW_TAB', EMBEDDABLE_CHARTS = 'EMBEDDABLE_CHARTS', + DRILL_TO_DETAIL = 'DRILL_TO_DETAIL', } export type ScheduleQueriesProps = { JSONSCHEMA: { 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 23126739346c..47a152fedd50 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 @@ -27,7 +27,8 @@ import { BigNumberTotalChartProps } from '../types'; import { getDateFormatter, parseMetricValue } from '../utils'; export default function transformProps(chartProps: BigNumberTotalChartProps) { - const { width, height, queriesData, formData, rawFormData } = chartProps; + const { width, height, queriesData, formData, rawFormData, hooks } = + chartProps; const { headerFontSize, metric = 'value', @@ -64,6 +65,8 @@ export default function transformProps(chartProps: BigNumberTotalChartProps) { ? formatTime : getNumberFormatter(yAxisFormat ?? metricEntry?.d3format ?? undefined); + const { onContextMenu } = hooks; + return { width, height, @@ -72,5 +75,6 @@ export default function transformProps(chartProps: BigNumberTotalChartProps) { headerFontSize, subheaderFontSize, subheader: formattedSubheader, + onContextMenu, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.tsx index d14cc7dcefa2..7d7658ff2931 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import React from 'react'; +import React, { MouseEvent } from 'react'; import { t, getNumberFormatter, @@ -26,10 +26,12 @@ import { computeMaxFontSize, BRAND_COLOR, styled, + QueryObjectFilterClause, } from '@superset-ui/core'; import { EChartsCoreOption } from 'echarts'; import Echart from '../components/Echart'; -import { TimeSeriesDatum } from './types'; +import { BigNumberWithTrendlineFormData, TimeSeriesDatum } from './types'; +import { EventHandlers } from '../types'; const defaultNumberFormatter = getNumberFormatter(); @@ -62,6 +64,13 @@ type BigNumberVisProps = { trendLineData?: TimeSeriesDatum[]; mainColor: string; echartOptions: EChartsCoreOption; + onContextMenu?: ( + filters: QueryObjectFilterClause[], + offsetX: number, + offsetY: number, + ) => void; + xValueFormatter?: TimeFormatter; + formData?: BigNumberWithTrendlineFormData; }; class BigNumberVis extends React.PureComponent { @@ -159,6 +168,17 @@ class BigNumberVis extends React.PureComponent { }); container.remove(); + const onContextMenu = (e: MouseEvent) => { + if (this.props.onContextMenu) { + e.preventDefault(); + this.props.onContextMenu( + [], + e.nativeEvent.offsetX, + e.nativeEvent.offsetY, + ); + } + }; + return (
{ fontSize, height: maxHeight, }} + onContextMenu={onContextMenu} > {text}
@@ -213,7 +234,7 @@ class BigNumberVis extends React.PureComponent { return null; } - renderTrendline(maxHeight: number) { + renderTrendline(maxHeight: number, chartHeight: number) { const { width, trendLineData, echartOptions } = this.props; // if can't find any non-null values, no point rendering the trendline @@ -221,11 +242,37 @@ class BigNumberVis extends React.PureComponent { return null; } + const eventHandlers: EventHandlers = { + contextmenu: eventParams => { + if (this.props.onContextMenu) { + eventParams.event.stop(); + const { data } = eventParams; + if (data) { + const pointerEvent = eventParams.event.event; + const filters: QueryObjectFilterClause[] = []; + filters.push({ + col: this.props.formData?.granularitySqla, + grain: this.props.formData?.timeGrainSqla, + op: '==', + val: data[0], + formattedVal: this.props.xValueFormatter?.(data[0]), + }); + this.props.onContextMenu( + filters, + pointerEvent.offsetX, + chartHeight - 100, + ); + } + } + }, + }; + return ( ); } @@ -260,7 +307,7 @@ class BigNumberVis extends React.PureComponent { ), )} - {this.renderTrendline(chartHeight)} + {this.renderTrendline(chartHeight, height)} ); } @@ -283,6 +330,7 @@ export default styled(BigNumberVis)` display: flex; flex-direction: column; justify-content: center; + align-items: flex-start; &.no-trendline .subheader-line { padding-bottom: 0.3em; 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 07ca77547b4f..003e4e70eb27 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 @@ -62,8 +62,16 @@ const formatPercentChange = getNumberFormatter( export default function transformProps( chartProps: BigNumberWithTrendlineChartProps, ) { - const { width, height, queriesData, formData, rawFormData, theme } = - chartProps; + const { + width, + height, + queriesData, + formData, + rawFormData, + theme, + hooks, + inContextMenu, + } = chartProps; const { colorPicker, compareLag: compareLag_, @@ -221,7 +229,7 @@ export default function transformProps( }, tooltip: { appendToBody: true, - show: true, + show: !inContextMenu, trigger: 'axis', confine: true, formatter: renderTooltipFactory(formatTime, headerFormatter), @@ -234,6 +242,9 @@ export default function transformProps( }, } : {}; + + const { onContextMenu } = hooks; + return { width, height, @@ -242,6 +253,7 @@ export default function transformProps( className, headerFormatter, formatTime, + formData, headerFontSize, subheaderFontSize, mainColor, @@ -252,5 +264,7 @@ export default function transformProps( timestamp, trendLineData, echartOptions, + onContextMenu, + xValueFormatter: formatTime, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/EchartsBoxPlot.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/EchartsBoxPlot.tsx index f13396d096bc..29c4e2a6e62a 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/EchartsBoxPlot.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/EchartsBoxPlot.tsx @@ -18,19 +18,20 @@ */ import React, { useCallback } from 'react'; import Echart from '../components/Echart'; -import { EventHandlers } from '../types'; +import { allEventHandlers } from '../utils/eventHandlers'; import { BoxPlotChartTransformedProps } from './types'; -export default function EchartsBoxPlot({ - height, - width, - echartOptions, - setDataMask, - labelMap, - groupby, - selectedValues, - formData, -}: BoxPlotChartTransformedProps) { +export default function EchartsBoxPlot(props: BoxPlotChartTransformedProps) { + const { + height, + width, + echartOptions, + setDataMask, + labelMap, + groupby, + selectedValues, + formData, + } = props; const handleChange = useCallback( (values: string[]) => { if (!formData.emitFilter) { @@ -67,17 +68,7 @@ export default function EchartsBoxPlot({ [groupby, labelMap, setDataMask, selectedValues], ); - const eventHandlers: EventHandlers = { - click: props => { - const { name } = props; - const values = Object.values(selectedValues); - if (values.includes(name)) { - handleChange(values.filter(v => v !== name)); - } else { - handleChange([name]); - } - }, - }; + const eventHandlers = allEventHandlers(props, handleChange); return ( {} } = hooks; + const { setDataMask = () => {}, onContextMenu } = hooks; const coltypeMapping = getColtypesMapping(queriesData[0]); const { colorScheme, @@ -268,6 +275,7 @@ export default function transformProps( }, tooltip: { ...defaultTooltip, + show: !inContextMenu, trigger: 'item', axisPointer: { type: 'shadow', @@ -286,5 +294,6 @@ export default function transformProps( labelMap, groupby, selectedValues, + onContextMenu, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/types.ts index 36df083fb818..5fac1ae21686 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/types.ts @@ -19,13 +19,9 @@ import { ChartDataResponseResult, ChartProps, - DataRecordValue, - QueryFormColumn, QueryFormData, - SetDataMaskHook, } from '@superset-ui/core'; -import { EChartsCoreOption } from 'echarts'; -import { EchartsTitleFormData } from '../types'; +import { EchartsTitleFormData, EChartTransformedProps } from '../types'; import { DEFAULT_TITLE_FORM_DATA } from '../constants'; export type BoxPlotQueryFormData = QueryFormData & { @@ -60,14 +56,5 @@ export interface EchartsBoxPlotChartProps queriesData: ChartDataResponseResult[]; } -export interface BoxPlotChartTransformedProps { - formData: BoxPlotQueryFormData; - height: number; - width: number; - echartOptions: EChartsCoreOption; - emitFilter: boolean; - setDataMask: SetDataMaskHook; - labelMap: Record; - groupby: QueryFormColumn[]; - selectedValues: Record; -} +export type BoxPlotChartTransformedProps = + EChartTransformedProps; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/EchartsFunnel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/EchartsFunnel.tsx index 7b157dc8e0f2..52de923659ff 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/EchartsFunnel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/EchartsFunnel.tsx @@ -19,18 +19,19 @@ import React, { useCallback } from 'react'; import { FunnelChartTransformedProps } from './types'; import Echart from '../components/Echart'; -import { EventHandlers } from '../types'; +import { allEventHandlers } from '../utils/eventHandlers'; -export default function EchartsFunnel({ - height, - width, - echartOptions, - setDataMask, - labelMap, - groupby, - selectedValues, - formData, -}: FunnelChartTransformedProps) { +export default function EchartsFunnel(props: FunnelChartTransformedProps) { + const { + height, + width, + echartOptions, + setDataMask, + labelMap, + groupby, + selectedValues, + formData, + } = props; const handleChange = useCallback( (values: string[]) => { if (!formData.emitFilter) { @@ -67,17 +68,7 @@ export default function EchartsFunnel({ [groupby, labelMap, setDataMask, selectedValues], ); - const eventHandlers: EventHandlers = { - click: props => { - const { name } = props; - const values = Object.values(selectedValues); - if (values.includes(name)) { - handleChange(values.filter(v => v !== name)); - } else { - handleChange([name]); - } - }, - }; + const eventHandlers = allEventHandlers(props, handleChange); return ( {} } = hooks; + const { setDataMask = () => {}, onContextMenu } = hooks; const colorFn = CategoricalColorNamespace.getScale(colorScheme as string); const numberFormatter = getNumberFormatter(numberFormat); @@ -209,6 +217,7 @@ export default function transformProps( }, tooltip: { ...defaultTooltip, + show: !inContextMenu, trigger: 'item', formatter: (params: any) => formatFunnelLabel({ @@ -234,5 +243,6 @@ export default function transformProps( labelMap, groupby, selectedValues, + onContextMenu, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/types.ts index cd392997cf72..0d1f3caf5e5a 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/types.ts @@ -16,16 +16,17 @@ * specific language governing permissions and limitations * under the License. */ -import { EChartsCoreOption } from 'echarts'; import { ChartDataResponseResult, ChartProps, - DataRecordValue, - QueryFormColumn, QueryFormData, - SetDataMaskHook, } from '@superset-ui/core'; -import { EchartsLegendFormData, LegendOrientation, LegendType } from '../types'; +import { + EchartsLegendFormData, + EChartTransformedProps, + LegendOrientation, + LegendType, +} from '../types'; import { DEFAULT_LEGEND_FORM_DATA } from '../constants'; export type EchartsFunnelFormData = QueryFormData & @@ -74,14 +75,5 @@ export const DEFAULT_FORM_DATA: EchartsFunnelFormData = { emitFilter: false, }; -export interface FunnelChartTransformedProps { - formData: EchartsFunnelFormData; - height: number; - width: number; - echartOptions: EChartsCoreOption; - emitFilter: boolean; - setDataMask: SetDataMaskHook; - labelMap: Record; - groupby: QueryFormColumn[]; - selectedValues: Record; -} +export type FunnelChartTransformedProps = + EChartTransformedProps; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/EchartsGauge.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/EchartsGauge.tsx index 731aadded839..ef81b6ece6c5 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/EchartsGauge.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/EchartsGauge.tsx @@ -17,9 +17,10 @@ * under the License. */ import React, { useCallback } from 'react'; +import { QueryObjectFilterClause } from '@superset-ui/core'; import { GaugeChartTransformedProps } from './types'; import Echart from '../components/Echart'; -import { EventHandlers } from '../types'; +import { Event, clickEventHandler } from '../utils/eventHandlers'; export default function EchartsGauge({ height, @@ -30,6 +31,7 @@ export default function EchartsGauge({ groupby, selectedValues, formData: { emitFilter }, + onContextMenu, }: GaugeChartTransformedProps) { const handleChange = useCallback( (values: string[]) => { @@ -67,14 +69,25 @@ export default function EchartsGauge({ [groupby, labelMap, setDataMask, selectedValues], ); - const eventHandlers: EventHandlers = { - click: props => { - const { name } = props; - const values = Object.values(selectedValues); - if (values.includes(name)) { - handleChange(values.filter(v => v !== name)); - } else { - handleChange([name]); + const eventHandlers = { + click: clickEventHandler(selectedValues, handleChange), + contextmenu: (e: Event) => { + if (onContextMenu) { + e.event.stop(); + const pointerEvent = e.event.event; + const filters: QueryObjectFilterClause[] = []; + if (groupby.length > 0) { + const values = e.name.split(','); + groupby.forEach((dimension, i) => + filters.push({ + col: dimension, + op: '==', + val: values[i].split(': ')[1], + formattedVal: values[i].split(': ')[1], + }), + ); + } + onContextMenu(filters, pointerEvent.offsetX, pointerEvent.offsetY); } }, }; 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 899417a6390e..59a2e21f8e66 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/transformProps.ts @@ -194,7 +194,7 @@ export default function transformProps( }, ); - const { setDataMask = () => {} } = hooks; + const { setDataMask = () => {}, onContextMenu } = hooks; const progress = { show: showProgress, @@ -298,5 +298,6 @@ export default function transformProps( labelMap: Object.fromEntries(columnsLabelMap), groupby, selectedValues: filterState.selectedValues || [], + onContextMenu, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/EchartsGraph.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/EchartsGraph.tsx index 9b42c6e55357..82e3dcd86e0a 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/EchartsGraph.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/EchartsGraph.tsx @@ -17,13 +17,61 @@ * under the License. */ import React from 'react'; -import { EchartsProps } from '../types'; +import { QueryObjectFilterClause } from '@superset-ui/core'; +import { EventHandlers } from '../types'; import Echart from '../components/Echart'; +import { GraphChartTransformedProps } from './types'; + +type Event = { + name: string; + event: { stop: () => void; event: PointerEvent }; + data: { source: string; target: string }; +}; export default function EchartsGraph({ height, width, echartOptions, -}: EchartsProps) { - return ; + formData, + onContextMenu, +}: GraphChartTransformedProps) { + const eventHandlers: EventHandlers = { + contextmenu: (e: Event) => { + if (onContextMenu) { + e.event.stop(); + const pointerEvent = e.event.event; + const data = (echartOptions as any).series[0].data as { + id: string; + name: string; + }[]; + const sourceValue = data.find(item => item.id === e.data.source)?.name; + const targetValue = data.find(item => item.id === e.data.target)?.name; + if (sourceValue && targetValue) { + const filters: QueryObjectFilterClause[] = [ + { + col: formData.source, + op: '==', + val: sourceValue, + formattedVal: sourceValue, + }, + { + col: formData.target, + op: '==', + val: targetValue, + formattedVal: targetValue, + }, + ]; + onContextMenu(filters, pointerEvent.offsetX, pointerEvent.offsetY); + } + } + }, + }; + return ( + + ); } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/transformProps.ts index 905ecbfa8ebd..659595c6ad6e 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/transformProps.ts @@ -31,9 +31,9 @@ import { EChartGraphNode, DEFAULT_FORM_DATA as DEFAULT_GRAPH_FORM_DATA, EdgeSymbol, + GraphChartTransformedProps, } from './types'; import { DEFAULT_GRAPH_SERIES_OPTION } from './constants'; -import { EchartsProps } from '../types'; import { getChartPadding, getLegendProps, sanitizeHtml } from '../utils/series'; type EdgeWithStyles = GraphEdgeItemOption & { @@ -157,8 +157,11 @@ function getCategoryName(columnName: string, name?: DataRecordValue) { return String(name); } -export default function transformProps(chartProps: ChartProps): EchartsProps { - const { width, height, formData, queriesData } = chartProps; +export default function transformProps( + chartProps: ChartProps, +): GraphChartTransformedProps { + const { width, height, formData, queriesData, hooks, inContextMenu } = + chartProps; const data: DataRecord[] = queriesData[0].data || []; const { @@ -295,6 +298,7 @@ export default function transformProps(chartProps: ChartProps): EchartsProps { animationDuration: DEFAULT_GRAPH_SERIES_OPTION.animationDuration, animationEasing: DEFAULT_GRAPH_SERIES_OPTION.animationEasing, tooltip: { + show: !inContextMenu, formatter: (params: any): string => edgeFormatter( params.data.source, @@ -309,9 +313,14 @@ export default function transformProps(chartProps: ChartProps): EchartsProps { }, series, }; + + const { onContextMenu } = hooks; + return { width, height, + formData, echartOptions, + onContextMenu, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/types.ts index 19938b4b1985..ffad9b9cd65c 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/types.ts @@ -16,10 +16,19 @@ * specific language governing permissions and limitations * under the License. */ -import { QueryFormData } from '@superset-ui/core'; +import { + PlainObject, + QueryFormData, + QueryObjectFilterClause, +} from '@superset-ui/core'; import { GraphNodeItemOption } from 'echarts/types/src/chart/graph/GraphSeries'; import { SeriesTooltipOption } from 'echarts/types/src/util/types'; -import { EchartsLegendFormData, LegendOrientation, LegendType } from '../types'; +import { + EchartsLegendFormData, + EchartsProps, + LegendOrientation, + LegendType, +} from '../types'; import { DEFAULT_LEGEND_FORM_DATA } from '../constants'; export type EdgeSymbol = 'none' | 'circle' | 'arrow'; @@ -75,3 +84,12 @@ export const DEFAULT_FORM_DATA: EchartsGraphFormData = { export type tooltipFormatParams = { data: { [name: string]: string }; }; + +export type GraphChartTransformedProps = EchartsProps & { + formData: PlainObject; + onContextMenu?: ( + filters: QueryObjectFilterClause[], + offsetX: number, + offsetY: number, + ) => void; +}; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/EchartsMixedTimeseries.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/EchartsMixedTimeseries.tsx index d8d4191b43da..f11729f76cc8 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/EchartsMixedTimeseries.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/EchartsMixedTimeseries.tsx @@ -17,6 +17,7 @@ * under the License. */ import React, { useCallback } from 'react'; +import { DataRecordValue, QueryObjectFilterClause } from '@superset-ui/core'; import { EchartsMixedTimeseriesChartTransformedProps } from './types'; import Echart from '../components/Echart'; import { EventHandlers } from '../types'; @@ -34,6 +35,8 @@ export default function EchartsMixedTimeseries({ selectedValues, formData, seriesBreakdown, + onContextMenu, + xValueFormatter, }: EchartsMixedTimeseriesChartTransformedProps) { const isFirstQuery = useCallback( (seriesIndex: number) => seriesIndex < seriesBreakdown, @@ -63,7 +66,9 @@ export default function EchartsMixedTimeseries({ ? [] : [ ...currentGroupBy.map((col, idx) => { - const val = groupbyValues.map(v => v[idx]); + const val: DataRecordValue[] = groupbyValues.map( + v => v[idx], + ); if (val === null || val === undefined) return { col, @@ -105,6 +110,35 @@ export default function EchartsMixedTimeseries({ mouseover: params => { currentSeries.name = params.seriesName; }, + contextmenu: eventParams => { + if (onContextMenu) { + eventParams.event.stop(); + const { data, seriesIndex } = eventParams; + if (data) { + const pointerEvent = eventParams.event.event; + const values = eventParams.seriesName.split(','); + const { queryIndex } = (echartOptions.series as any)[seriesIndex]; + const groupby = queryIndex > 0 ? formData.groupbyB : formData.groupby; + const filters: QueryObjectFilterClause[] = []; + filters.push({ + col: formData.granularitySqla, + grain: formData.timeGrainSqla, + op: '==', + val: data[0], + formattedVal: xValueFormatter(data[0]), + }); + groupby.forEach((dimension, i) => + filters.push({ + col: dimension, + op: '==', + val: values[i], + formattedVal: values[i], + }), + ); + onContextMenu(filters, pointerEvent.offsetX, pointerEvent.offsetY); + } + } + }, }; return ( diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts index 2655f202d6d6..bcdd01a33339 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts @@ -84,6 +84,7 @@ export default function transformProps( filterState, datasource, theme, + inContextMenu, } = chartProps; const { verboseMap = {} } = datasource; const data1 = (queriesData[0].data || []) as TimeseriesDataRecord[]; @@ -198,6 +199,7 @@ export default function transformProps( filterState, seriesKey: entry.name, sliceId, + queryIndex: 0, }); if (transformedSeries) series.push(transformedSeries); }); @@ -217,6 +219,7 @@ export default function transformProps( ? `${entry.name} (1)` : entry.name, sliceId, + queryIndex: 1, }); if (transformedSeries) series.push(transformedSeries); }); @@ -319,7 +322,7 @@ export default function transformProps( }; }, {}) as Record; - const { setDataMask = () => {} } = hooks; + const { setDataMask = () => {}, onContextMenu } = hooks; const alignTicks = yAxisIndex !== yAxisIndexB; const echartOptions: EChartsCoreOption = { @@ -373,6 +376,7 @@ export default function transformProps( ], tooltip: { ...defaultTooltip, + show: !inContextMenu, appendToBody: true, trigger: richTooltip ? 'axis' : 'item', formatter: (params: any) => { @@ -459,5 +463,7 @@ export default function transformProps( groupbyB, seriesBreakdown: rawSeriesA.length, selectedValues: filterState.selectedValues || [], + onContextMenu, + xValueFormatter: tooltipFormatter, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/types.ts index 2cef5cd681a9..478e2c2654c8 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/types.ts @@ -16,23 +16,23 @@ * specific language governing permissions and limitations * under the License. */ -import { EChartsCoreOption } from 'echarts'; import { AnnotationLayer, TimeGranularity, DataRecordValue, - SetDataMaskHook, QueryFormData, ChartProps, ChartDataResponseResult, QueryFormColumn, ContributionType, + TimeFormatter, } from '@superset-ui/core'; import { EchartsLegendFormData, EchartsTitleFormData, StackType, EchartsTimeseriesSeriesType, + EChartTransformedProps, } from '../types'; import { DEFAULT_LEGEND_FORM_DATA, @@ -138,18 +138,11 @@ export interface EchartsMixedTimeseriesProps extends ChartProps { queriesData: ChartDataResponseResult[]; } -export type EchartsMixedTimeseriesChartTransformedProps = { - formData: EchartsMixedTimeseriesFormData; - height: number; - width: number; - echartOptions: EChartsCoreOption; - emitFilter: boolean; - emitFilterB: boolean; - setDataMask: SetDataMaskHook; - groupby: QueryFormColumn[]; - groupbyB: QueryFormColumn[]; - labelMap: Record; - labelMapB: Record; - selectedValues: Record; - seriesBreakdown: number; -}; +export type EchartsMixedTimeseriesChartTransformedProps = + EChartTransformedProps & { + emitFilterB: boolean; + groupbyB: QueryFormColumn[]; + labelMapB: Record; + seriesBreakdown: number; + xValueFormatter: TimeFormatter | StringConstructor; + }; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/EchartsPie.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/EchartsPie.tsx index f508b17eebed..37606ac6f876 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/EchartsPie.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/EchartsPie.tsx @@ -19,18 +19,19 @@ import React, { useCallback } from 'react'; import { PieChartTransformedProps } from './types'; import Echart from '../components/Echart'; -import { EventHandlers } from '../types'; +import { allEventHandlers } from '../utils/eventHandlers'; -export default function EchartsPie({ - height, - width, - echartOptions, - setDataMask, - labelMap, - groupby, - selectedValues, - formData, -}: PieChartTransformedProps) { +export default function EchartsPie(props: PieChartTransformedProps) { + const { + height, + width, + echartOptions, + setDataMask, + labelMap, + groupby, + selectedValues, + formData, + } = props; const handleChange = useCallback( (values: string[]) => { if (!formData.emitFilter) { @@ -67,17 +68,7 @@ export default function EchartsPie({ [groupby, labelMap, setDataMask, selectedValues], ); - const eventHandlers: EventHandlers = { - click: props => { - const { name } = props; - const values = Object.values(selectedValues); - if (values.includes(name)) { - handleChange(values.filter(v => v !== name)); - } else { - handleChange([name]); - } - }, - }; + const eventHandlers = allEventHandlers(props, handleChange); return ( {} } = hooks; + const { setDataMask = () => {}, onContextMenu } = hooks; const colorFn = CategoricalColorNamespace.getScale(colorScheme as string); const numberFormatter = getNumberFormatter(numberFormat); @@ -296,6 +304,7 @@ export default function transformProps( ...defaultGrid, }, tooltip: { + show: !inContextMenu, ...defaultTooltip, trigger: 'item', formatter: (params: any) => @@ -335,5 +344,6 @@ export default function transformProps( labelMap, groupby, selectedValues, + onContextMenu, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/types.ts index 302df265f480..e31127f7b39f 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/types.ts @@ -16,16 +16,18 @@ * specific language governing permissions and limitations * under the License. */ -import { EChartsCoreOption } from 'echarts'; import { ChartDataResponseResult, ChartProps, - DataRecordValue, QueryFormColumn, QueryFormData, - SetDataMaskHook, } from '@superset-ui/core'; -import { EchartsLegendFormData, LegendOrientation, LegendType } from '../types'; +import { + EchartsLegendFormData, + EChartTransformedProps, + LegendOrientation, + LegendType, +} from '../types'; import { DEFAULT_LEGEND_FORM_DATA } from '../constants'; export type EchartsPieFormData = QueryFormData & @@ -81,14 +83,5 @@ export const DEFAULT_FORM_DATA: EchartsPieFormData = { dateFormat: 'smart_date', }; -export interface PieChartTransformedProps { - formData: EchartsPieFormData; - height: number; - width: number; - echartOptions: EChartsCoreOption; - emitFilter: boolean; - setDataMask: SetDataMaskHook; - labelMap: Record; - groupby: QueryFormColumn[]; - selectedValues: Record; -} +export type PieChartTransformedProps = + EChartTransformedProps; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/EchartsRadar.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/EchartsRadar.tsx index 5757c2a95f59..bcba60f5cbba 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/EchartsRadar.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/EchartsRadar.tsx @@ -19,18 +19,19 @@ import React, { useCallback } from 'react'; import { RadarChartTransformedProps } from './types'; import Echart from '../components/Echart'; -import { EventHandlers } from '../types'; +import { allEventHandlers } from '../utils/eventHandlers'; -export default function EchartsRadar({ - height, - width, - echartOptions, - setDataMask, - labelMap, - groupby, - selectedValues, - formData, -}: RadarChartTransformedProps) { +export default function EchartsRadar(props: RadarChartTransformedProps) { + const { + height, + width, + echartOptions, + setDataMask, + labelMap, + groupby, + selectedValues, + formData, + } = props; const handleChange = useCallback( (values: string[]) => { if (!formData.emitFilter) { @@ -67,17 +68,7 @@ export default function EchartsRadar({ [groupby, labelMap, setDataMask, selectedValues], ); - const eventHandlers: EventHandlers = { - click: props => { - const { name } = props; - const values = Object.values(selectedValues); - if (values.includes(name)) { - handleChange(values.filter(v => v !== name)); - } else { - handleChange([name]); - } - }, - }; + const eventHandlers = allEventHandlers(props, handleChange); return ( {} } = hooks; + const { setDataMask = () => {}, onContextMenu } = hooks; const colorFn = CategoricalColorNamespace.getScale(colorScheme as string); const numberFormatter = getNumberFormatter(numberFormat); @@ -222,6 +231,7 @@ export default function transformProps( }, tooltip: { ...defaultTooltip, + show: !inContextMenu, trigger: 'item', }, legend: { @@ -240,9 +250,11 @@ export default function transformProps( width, height, echartOptions, + emitFilter, setDataMask, labelMap: Object.fromEntries(columnsLabelMap), groupby, selectedValues, + onContextMenu, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/types.ts index ebe571f62165..692aa3479956 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/types.ts @@ -16,18 +16,16 @@ * specific language governing permissions and limitations * under the License. */ -import { EChartsCoreOption } from 'echarts'; import { ChartDataResponseResult, ChartProps, - DataRecordValue, QueryFormColumn, QueryFormData, QueryFormMetric, - SetDataMaskHook, } from '@superset-ui/core'; import { EchartsLegendFormData, + EChartTransformedProps, LabelPositionEnum, LegendOrientation, LegendType, @@ -79,13 +77,5 @@ export const DEFAULT_FORM_DATA: EchartsRadarFormData = { isCircle: false, }; -export interface RadarChartTransformedProps { - formData: EchartsRadarFormData; - height: number; - width: number; - echartOptions: EChartsCoreOption; - setDataMask: SetDataMaskHook; - labelMap: Record; - groupby: QueryFormColumn[]; - selectedValues: Record; -} +export type RadarChartTransformedProps = + EChartTransformedProps; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx index d08499c616ac..3ac7ed464c8a 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx @@ -17,6 +17,7 @@ * under the License. */ import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { QueryObjectFilterClause } from '@superset-ui/core'; import { ViewRootGroup } from 'echarts/types/src/util/types'; import GlobalModel from 'echarts/types/src/model/Global'; import ComponentModel from 'echarts/types/src/model/Component'; @@ -40,6 +41,8 @@ export default function EchartsTimeseries({ setDataMask, setControlValue, legendData = [], + onContextMenu, + xValueFormatter, }: TimeseriesChartTransformedProps) { const { emitFilter, stack } = formData; const echartRef = useRef(null); @@ -173,6 +176,33 @@ export default function EchartsTimeseries({ handleDoubleClickChange(); } }, + contextmenu: eventParams => { + if (onContextMenu) { + eventParams.event.stop(); + const { data } = eventParams; + if (data) { + const pointerEvent = eventParams.event.event; + const values = eventParams.seriesName.split(','); + const filters: QueryObjectFilterClause[] = []; + filters.push({ + col: formData.granularitySqla, + grain: formData.timeGrainSqla, + op: '==', + val: data[0], + formattedVal: xValueFormatter(data[0]), + }); + formData.groupby.forEach((dimension, i) => + filters.push({ + col: dimension, + op: '==', + val: values[i], + formattedVal: values[i], + }), + ); + onContextMenu(filters, pointerEvent.offsetX, pointerEvent.offsetY); + } + } + }, }; const zrEventHandlers: EventHandlers = { 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 07f18c3d5e62..d675d6cfab68 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -97,6 +97,7 @@ export default function transformProps( queriesData, datasource, theme, + inContextMenu, } = chartProps; const { verboseMap = {} } = datasource; const [queryData] = queriesData; @@ -302,6 +303,7 @@ export default function transformProps( const { setDataMask = () => {}, setControlValue = (...args: unknown[]) => {}, + onContextMenu, } = hooks; const addYAxisLabelOffset = !!yAxisTitle; @@ -380,6 +382,7 @@ export default function transformProps( xAxis, yAxis, tooltip: { + show: !inContextMenu, ...defaultTooltip, appendToBody: true, trigger: richTooltip ? 'axis' : 'item', @@ -457,5 +460,7 @@ export default function transformProps( setControlValue, width, legendData, + onContextMenu, + xValueFormatter: tooltipFormatter, }; } 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 6a5f466fd460..fe7c22a13eee 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts @@ -97,6 +97,7 @@ export function transformSeries( sliceId?: number; isHorizontal?: boolean; lineStyle?: LineStyleOption; + queryIndex?: number; }, ): SeriesOption | undefined { const { name } = series; @@ -120,6 +121,7 @@ export function transformSeries( seriesKey, sliceId, isHorizontal = false, + queryIndex = 0, } = opts; const contexts = seriesContexts[name || ''] || []; const hasForecast = @@ -197,6 +199,7 @@ export function transformSeries( : { ...opts.lineStyle, opacity }; return { ...series, + queryIndex, yAxisIndex, name: forecastSeries.name, itemStyle, 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 71729bd0f11d..93e173e6693e 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts @@ -24,6 +24,7 @@ import { QueryFormData, TimeGranularity, ContributionType, + TimeFormatter, } from '@superset-ui/core'; import { EchartsLegendFormData, @@ -93,7 +94,9 @@ export interface EchartsTimeseriesChartProps } export type TimeseriesChartTransformedProps = - EChartTransformedProps; + EChartTransformedProps & { + xValueFormatter: TimeFormatter | StringConstructor; + }; export enum AxisType { category = 'category', diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/EchartsTreemap.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/EchartsTreemap.tsx index 2932dc327ddc..c19439d91c53 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/EchartsTreemap.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/EchartsTreemap.tsx @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +import { QueryObjectFilterClause } from '@superset-ui/core'; import React, { useCallback } from 'react'; import Echart from '../components/Echart'; import { EventHandlers } from '../types'; @@ -31,6 +32,7 @@ export default function EchartsTreemap({ groupby, selectedValues, formData, + onContextMenu, }: TreemapTransformedProps) { const handleChange = useCallback( (values: string[]) => { @@ -71,7 +73,7 @@ export default function EchartsTreemap({ const eventHandlers: EventHandlers = { click: props => { const { data, treePathInfo } = props; - // do noting when clicking the parent node + // do nothing when clicking on the parent node if (data?.children) { return; } @@ -84,6 +86,25 @@ export default function EchartsTreemap({ handleChange([name]); } }, + contextmenu: eventParams => { + if (onContextMenu) { + eventParams.event.stop(); + const { treePath } = extractTreePathInfo(eventParams.treePathInfo); + if (treePath.length > 0) { + const pointerEvent = eventParams.event.event; + const filters: QueryObjectFilterClause[] = []; + treePath.forEach((path, i) => + filters.push({ + col: groupby[i], + op: '==', + val: path, + formattedVal: path, + }), + ); + onContextMenu(filters, pointerEvent.offsetX, pointerEvent.offsetY); + } + } + }, }; return ( 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 6c98453feefa..e0a6f63eaa5c 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/transformProps.ts @@ -109,10 +109,18 @@ export function formatTooltip({ export default function transformProps( chartProps: EchartsTreemapChartProps, ): TreemapTransformedProps { - const { formData, height, queriesData, width, hooks, filterState, theme } = - chartProps; + const { + formData, + height, + queriesData, + width, + hooks, + filterState, + theme, + inContextMenu, + } = chartProps; const { data = [] } = queriesData[0]; - const { setDataMask = () => {} } = hooks; + const { setDataMask = () => {}, onContextMenu } = hooks; const coltypeMapping = getColtypesMapping(queriesData[0]); const { @@ -303,6 +311,7 @@ export default function transformProps( const echartOptions: EChartsCoreOption = { tooltip: { ...defaultTooltip, + show: !inContextMenu, trigger: 'item', formatter: (params: any) => formatTooltip({ @@ -323,5 +332,6 @@ export default function transformProps( labelMap: Object.fromEntries(columnsLabelMap), groupby, selectedValues: filterState.selectedValues || [], + onContextMenu, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/types.ts index f4ed29cc7316..9120fb72f726 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/types.ts @@ -19,15 +19,12 @@ import { ChartDataResponseResult, ChartProps, - DataRecordValue, QueryFormColumn, QueryFormData, QueryFormMetric, - SetDataMaskHook, } from '@superset-ui/core'; -import { EChartsCoreOption } from 'echarts'; import { CallbackDataParams } from 'echarts/types/src/util/types'; -import { LabelPositionEnum } from '../types'; +import { EChartTransformedProps, LabelPositionEnum } from '../types'; export type EchartsTreemapFormData = QueryFormData & { colorScheme?: string; @@ -75,14 +72,5 @@ export interface TreemapSeriesCallbackDataParams extends CallbackDataParams { treePathInfo?: TreePathInfo[]; } -export interface TreemapTransformedProps { - formData: EchartsTreemapFormData; - height: number; - width: number; - echartOptions: EChartsCoreOption; - emitFilter: boolean; - setDataMask: SetDataMaskHook; - labelMap: Record; - groupby: QueryFormColumn[]; - selectedValues: Record; -} +export type TreemapTransformedProps = + EChartTransformedProps; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/types.ts index 7f8879cc56db..693a889ac957 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/types.ts @@ -20,6 +20,7 @@ import { DataRecordValue, HandlerFunction, QueryFormColumn, + QueryObjectFilterClause, SetDataMaskHook, } from '@superset-ui/core'; import { EChartsCoreOption, ECharts } from 'echarts'; @@ -115,6 +116,11 @@ export interface EChartTransformedProps { groupby: QueryFormColumn[]; selectedValues: Record; legendData?: OptionName[]; + onContextMenu?: ( + filters: QueryObjectFilterClause[], + offsetX: number, + offsetY: number, + ) => void; } export interface EchartsTitleFormData { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/eventHandlers.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/eventHandlers.ts new file mode 100644 index 000000000000..002596e3eadf --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/eventHandlers.ts @@ -0,0 +1,76 @@ +/** + * 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 { QueryObjectFilterClause } from '@superset-ui/core'; +import { EChartTransformedProps, EventHandlers } from '../types'; + +export type Event = { + name: string; + event: { stop: () => void; event: PointerEvent }; +}; + +export const clickEventHandler = + ( + selectedValues: Record, + handleChange: (values: string[]) => void, + ) => + ({ name }: { name: string }) => { + const values = Object.values(selectedValues); + if (values.includes(name)) { + handleChange(values.filter(v => v !== name)); + } else { + handleChange([name]); + } + }; + +export const contextMenuEventHandler = + ( + groupby: EChartTransformedProps['groupby'], + onContextMenu: EChartTransformedProps['onContextMenu'], + ) => + (e: Event) => { + if (onContextMenu) { + e.event.stop(); + const pointerEvent = e.event.event; + const filters: QueryObjectFilterClause[] = []; + if (groupby.length > 0) { + const values = e.name.split(','); + groupby.forEach((dimension, i) => + filters.push({ + col: dimension, + op: '==', + val: values[i], + formattedVal: values[i], + }), + ); + } + onContextMenu(filters, pointerEvent.offsetX, pointerEvent.offsetY); + } + }; + +export const allEventHandlers = ( + transformedProps: EChartTransformedProps, + handleChange: (values: string[]) => void, +) => { + const { groupby, selectedValues, onContextMenu } = transformedProps; + const eventHandlers: EventHandlers = { + click: clickEventHandler(selectedValues, handleChange), + contextmenu: contextMenuEventHandler(groupby, onContextMenu), + }; + return eventHandlers; +}; diff --git a/superset-frontend/src/components/Chart/ChartContextMenu.tsx b/superset-frontend/src/components/Chart/ChartContextMenu.tsx new file mode 100644 index 000000000000..49e084bb868c --- /dev/null +++ b/superset-frontend/src/components/Chart/ChartContextMenu.tsx @@ -0,0 +1,123 @@ +/** + * 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, { + forwardRef, + RefObject, + useCallback, + useImperativeHandle, + useState, +} from 'react'; +import { QueryObjectFilterClause, t, styled } from '@superset-ui/core'; +import { Menu } from 'src/components/Menu'; +import { AntdDropdown as Dropdown } from 'src/components'; + +export interface ChartContextMenuProps { + id: string; + onSelection: (filters: QueryObjectFilterClause[]) => void; + onClose: () => void; +} + +export interface Ref { + open: ( + filters: QueryObjectFilterClause[], + offsetX: number, + offsetY: number, + ) => void; +} + +const Filter = styled.span` + ${({ theme }) => ` + font-weight: ${theme.typography.weights.bold}; + color: ${theme.colors.primary.base}; + `} +`; + +const ChartContextMenu = ( + { id, onSelection, onClose }: ChartContextMenuProps, + ref: RefObject, +) => { + const [state, setState] = useState<{ + filters: QueryObjectFilterClause[]; + offsetX: number; + offsetY: number; + }>({ filters: [], offsetX: 0, offsetY: 0 }); + + const menu = ( + + {state.filters.map((filter, i) => ( + onSelection([filter])}> + {`${t('Drill to detail by')} `} + {filter.formattedVal} + + ))} + {state.filters.length === 0 && ( + onSelection([])}> + {t('Drill to detail')} + + )} + {state.filters.length > 1 && ( + onSelection(state.filters)}> + {`${t('Drill to detail by')} `} + {t('all')} + + )} + + ); + + const open = useCallback( + (filters: QueryObjectFilterClause[], offsetX: number, offsetY: number) => { + setState({ filters, offsetX, offsetY }); + + // Since Ant Design's Dropdown does not offer an imperative API + // and we can't attach event triggers to charts SVG elements, we + // use a hidden span that gets clicked on when receiving click events + // from the charts. + document.getElementById(`hidden-span-${id}`)?.click(); + }, + [id], + ); + + useImperativeHandle( + ref, + () => ({ + open, + }), + [open], + ); + + return ( + !value && onClose()} + > + + + ); +}; + +export default forwardRef(ChartContextMenu); diff --git a/superset-frontend/src/components/Chart/ChartRenderer.jsx b/superset-frontend/src/components/Chart/ChartRenderer.jsx index ed330ab7afc9..4c11cfc085d3 100644 --- a/superset-frontend/src/components/Chart/ChartRenderer.jsx +++ b/superset-frontend/src/components/Chart/ChartRenderer.jsx @@ -19,9 +19,17 @@ import { snakeCase, isEqual } from 'lodash'; import PropTypes from 'prop-types'; import React from 'react'; -import { SuperChart, logging, Behavior, t } from '@superset-ui/core'; +import { + SuperChart, + logging, + Behavior, + t, + isFeatureEnabled, + FeatureFlag, +} from '@superset-ui/core'; import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils'; import { EmptyStateBig, EmptyStateSmall } from 'src/components/EmptyState'; +import ChartContextMenu from './ChartContextMenu'; const propTypes = { annotationData: PropTypes.object, @@ -73,15 +81,28 @@ const defaultProps = { class ChartRenderer extends React.Component { constructor(props) { super(props); + this.state = { + inContextMenu: false, + }; this.hasQueryResponseChange = false; + this.contextMenuRef = React.createRef(); + this.handleAddFilter = this.handleAddFilter.bind(this); this.handleRenderSuccess = this.handleRenderSuccess.bind(this); this.handleRenderFailure = this.handleRenderFailure.bind(this); this.handleSetControlValue = this.handleSetControlValue.bind(this); + this.handleOnContextMenu = this.handleOnContextMenu.bind(this); + this.handleContextMenuSelected = this.handleContextMenuSelected.bind(this); + this.handleContextMenuClosed = this.handleContextMenuClosed.bind(this); + + const showContextMenu = + props.source === 'dashboard' && + isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL); this.hooks = { onAddFilter: this.handleAddFilter, + onContextMenu: showContextMenu ? this.handleOnContextMenu : undefined, onError: this.handleRenderFailure, setControlValue: this.handleSetControlValue, onFilterMenuOpen: this.props.onFilterMenuOpen, @@ -92,13 +113,16 @@ class ChartRenderer extends React.Component { }; } - shouldComponentUpdate(nextProps) { + shouldComponentUpdate(nextProps, nextState) { const resultsReady = nextProps.queriesResponse && ['success', 'rendered'].indexOf(nextProps.chartStatus) > -1 && !nextProps.queriesResponse?.[0]?.error; if (resultsReady) { + if (!isEqual(this.state, nextState)) { + return true; + } this.hasQueryResponseChange = nextProps.queriesResponse !== this.props.queriesResponse; return ( @@ -172,6 +196,22 @@ class ChartRenderer extends React.Component { } } + handleOnContextMenu(filters, offsetX, offsetY) { + this.contextMenuRef.current.open(filters, offsetX, offsetY); + this.setState({ inContextMenu: true }); + } + + handleContextMenuSelected(filters) { + const extraFilters = this.props.formData.extra_form_data?.filters || []; + // eslint-disable-next-line no-alert + alert(JSON.stringify(filters.concat(extraFilters))); + this.setState({ inContextMenu: false }); + } + + handleContextMenuClosed() { + this.setState({ inContextMenu: false }); + } + render() { const { chartAlert, chartStatus, chartId } = this.props; @@ -247,28 +287,39 @@ class ChartRenderer extends React.Component { } return ( - +
+ {this.props.source === 'dashboard' && ( + + )} + +
); } } diff --git a/superset/config.py b/superset/config.py index 6167bd4dae59..2336f61212ee 100644 --- a/superset/config.py +++ b/superset/config.py @@ -438,6 +438,7 @@ def _try_json_readsha(filepath: str, length: int) -> Optional[str]: "CACHE_IMPERSONATION": False, # Enable sharing charts with embedding "EMBEDDABLE_CHARTS": True, + "DRILL_TO_DETAIL": False, } # Feature flags may also be set via 'SUPERSET_FEATURE_' prefixed environment vars.