From ee1952e488f2cd0913fe6f35ffe551d18ee3d143 Mon Sep 17 00:00:00 2001 From: Kamil Gabryjelski Date: Thu, 23 Feb 2023 17:05:41 +0100 Subject: [PATCH] feat(dashboard): Add cross filter from context menu (#23141) --- .../superset-ui-core/src/chart/types/Base.ts | 10 +- .../src/WorldMap.js | 87 ++++--- .../src/BigNumber/BigNumberViz.tsx | 6 +- .../src/BigNumber/types.ts | 4 +- .../src/BoxPlot/EchartsBoxPlot.tsx | 51 +--- .../src/Funnel/EchartsFunnel.tsx | 51 +--- .../src/Gauge/EchartsGauge.tsx | 51 +--- .../src/Graph/EchartsGraph.tsx | 240 +++++++++--------- .../EchartsMixedTimeseries.tsx | 103 ++++---- .../src/Pie/EchartsPie.tsx | 51 +--- .../src/Radar/EchartsRadar.tsx | 52 +--- .../src/Sunburst/EchartsSunburst.tsx | 91 ++++--- .../src/Timeseries/EchartsTimeseries.tsx | 100 ++++---- .../src/Treemap/EchartsTreemap.tsx | 103 +++++--- .../plugins/plugin-chart-echarts/src/types.ts | 6 +- .../src/utils/eventHandlers.ts | 106 ++++++-- .../src/PivotTableChart.tsx | 76 +++++- .../src/react-pivottable/TableRenderers.jsx | 12 + .../plugin-chart-pivot-table/src/types.ts | 4 +- .../src/DataTable/DataTable.tsx | 19 +- .../plugin-chart-table/src/TableChart.tsx | 209 ++++++++------- .../plugins/plugin-chart-table/src/types.ts | 4 +- .../src/components/Chart/ChartContextMenu.tsx | 123 +++++++-- .../src/components/Chart/ChartRenderer.jsx | 3 +- .../Chart/DisabledMenuItemTooltip.tsx | 48 ++++ .../DrillDetail/DrillDetailMenuItems.tsx | 29 +-- 26 files changed, 891 insertions(+), 748 deletions(-) create mode 100644 superset-frontend/src/components/Chart/DisabledMenuItemTooltip.tsx diff --git a/superset-frontend/packages/superset-ui-core/src/chart/types/Base.ts b/superset-frontend/packages/superset-ui-core/src/chart/types/Base.ts index f9f1a360b6273..7fa2ba1f77ccc 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/types/Base.ts +++ b/superset-frontend/packages/superset-ui-core/src/chart/types/Base.ts @@ -17,7 +17,7 @@ * under the License. */ -import { ExtraFormData } from '../../query'; +import { BinaryQueryObjectFilterClause, ExtraFormData } from '../../query'; import { JsonObject } from '../..'; export type HandlerFunction = (...args: unknown[]) => void; @@ -33,6 +33,14 @@ export enum Behavior { DRILL_TO_DETAIL = 'DRILL_TO_DETAIL', } +export interface ContextMenuFilters { + crossFilter?: { + dataMask: DataMask; + isCurrentValueSelected?: boolean; + }; + drillToDetail?: BinaryQueryObjectFilterClause[]; +} + export enum AppSection { EXPLORE = 'EXPLORE', DASHBOARD = 'DASHBOARD', diff --git a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.js b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.js index eba928faed7c8..abb9e19b9f118 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.js +++ b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.js @@ -24,8 +24,6 @@ import { getNumberFormatter, getSequentialSchemeRegistry, CategoricalColorNamespace, - logging, - t, } from '@superset-ui/core'; import Datamap from 'datamaps/dist/datamaps.world.min'; import { ColorBy } from './utils'; @@ -114,39 +112,57 @@ function WorldMap(element, props) { mapData[d.country] = d; }); + const getCrossFilterDataMask = source => { + const selected = Object.values(filterState.selectedValues || {}); + const key = source.id || source.country; + const country = + countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.country; + + if (!country) { + return undefined; + } + + let values; + if (selected.includes(key)) { + values = []; + } else { + values = [country]; + } + + return { + dataMask: { + extraFormData: { + filters: values.length + ? [ + { + col: entity, + op: 'IN', + val: values, + }, + ] + : [], + }, + filterState: { + value: values.length ? values : null, + selectedValues: values.length ? [key] : null, + }, + }, + isCurrentValueSelected: selected.includes(key), + }; + }; + const handleClick = source => { if (!emitCrossFilters) { return; } const pointerEvent = d3.event; pointerEvent.preventDefault(); - const key = source.id || source.country; - let val = - countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.country; - if (!val) { - return; - } - if (val === filterState.value) { - val = null; - } + getCrossFilterDataMask(source); - setDataMask({ - extraFormData: { - filters: val - ? [ - { - col: entity, - op: 'IN', - val: [val], - }, - ] - : [], - }, - filterState: { - value: val ?? null, - selectedValues: val ? [key] : [], - }, - }); + const dataMask = getCrossFilterDataMask(source)?.dataMask; + if (dataMask) { + setDataMask(dataMask); + } }; const handleContextMenu = source => { @@ -155,8 +171,9 @@ function WorldMap(element, props) { const key = source.id || source.country; const val = countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.country; + let drillToDetailFilters; if (val) { - const filters = [ + drillToDetailFilters = [ { col: entity, op: '==', @@ -164,15 +181,11 @@ function WorldMap(element, props) { formattedVal: val, }, ]; - onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters); - } else { - logging.warn( - t( - `Unable to process right-click on %s. Check you chart configuration.`, - ), - key, - ); } + onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { + drillToDetail: drillToDetailFilters, + crossFilter: getCrossFilterDataMask(source), + }); }; const map = new Datamap({ 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 669926d58ba8e..4762a789d0021 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.tsx @@ -220,8 +220,8 @@ class BigNumberVis extends React.PureComponent { const { data } = eventParams; if (data) { const pointerEvent = eventParams.event.event; - const filters: BinaryQueryObjectFilterClause[] = []; - filters.push({ + const drillToDetailFilters: BinaryQueryObjectFilterClause[] = []; + drillToDetailFilters.push({ col: this.props.formData?.granularitySqla, grain: this.props.formData?.timeGrainSqla, op: '==', @@ -231,7 +231,7 @@ class BigNumberVis extends React.PureComponent { this.props.onContextMenu( pointerEvent.clientX, pointerEvent.clientY, - filters, + { drillToDetail: drillToDetailFilters }, ); } } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/types.ts index 90b852b01e4e8..f0a17e708b897 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/types.ts @@ -19,8 +19,8 @@ import { EChartsCoreOption } from 'echarts'; import { - BinaryQueryObjectFilterClause, ChartDataResponseResult, + ContextMenuFilters, DataRecordValue, NumberFormatter, QueryFormData, @@ -89,7 +89,7 @@ export type BigNumberVizProps = { onContextMenu?: ( clientX: number, clientY: number, - filters?: BinaryQueryObjectFilterClause[], + filters?: ContextMenuFilters, ) => void; xValueFormatter?: TimeFormatter; formData?: BigNumberWithTrendlineFormData; 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 4c18f7f7d6205..e66035a3c7989 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/EchartsBoxPlot.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/EchartsBoxPlot.tsx @@ -16,60 +16,15 @@ * specific language governing permissions and limitations * under the License. */ -import React, { useCallback } from 'react'; +import React from 'react'; import Echart from '../components/Echart'; import { allEventHandlers } from '../utils/eventHandlers'; import { BoxPlotChartTransformedProps } from './types'; export default function EchartsBoxPlot(props: BoxPlotChartTransformedProps) { - const { - height, - width, - echartOptions, - setDataMask, - labelMap, - groupby, - selectedValues, - refs, - emitCrossFilters, - } = props; - const handleChange = useCallback( - (values: string[]) => { - if (!emitCrossFilters) { - return; - } + const { height, width, echartOptions, selectedValues, refs } = props; - 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, - }, - }); - }, - [groupby, labelMap, setDataMask, selectedValues], - ); - - const eventHandlers = allEventHandlers(props, handleChange); + const eventHandlers = allEventHandlers(props); return ( { - if (!emitCrossFilters) { - return; - } + const { height, width, echartOptions, selectedValues, refs } = props; - 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, - }, - }); - }, - [groupby, labelMap, setDataMask, selectedValues], - ); - - const eventHandlers = allEventHandlers(props, handleChange); + const eventHandlers = allEventHandlers(props); return ( { - if (!emitCrossFilters) { - return; - } + const { height, width, echartOptions, selectedValues, refs } = props; - 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, - }, - }); - }, - [groupby, labelMap, setDataMask, selectedValues], - ); - - const eventHandlers = allEventHandlers(props, handleChange); + const eventHandlers = allEventHandlers(props); return ( { - const eventHandlers: EventHandlers = useMemo( - () => ({ - click: (e: Event) => { - if (!emitCrossFilters || !setDataMask) { - return; - } - e.event.stop(); - const data = (echartOptions as any).series[0].data as Data; - const node = data.find(item => item.id === e.data.id); - const val = filterState?.value === node?.name ? null : node?.name; - if (node?.col) { - setDataMask({ - extraFormData: { - filters: val - ? [ - { - col: node.col, - op: '==', - val, - }, - ] - : [], - }, - filterState: { - value: val, - selectedValues: [val], - }, - }); - } - }, - contextmenu: (e: Event) => { - const handleNodeClick = (data: Data) => { - const node = data.find(item => item.id === e.data.id); - if (node?.name) { - return [ - { - col: node.col, - op: '==' as const, - val: node.name, - formattedVal: node.name, - }, - ]; - } - return undefined; - }; - const handleEdgeClick = (data: Data) => { - 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) { - return [ - { - col: formData.source, - op: '==' as const, - val: sourceValue, - formattedVal: sourceValue, - }, +export default function EchartsGraph({ + height, + width, + echartOptions, + formData, + onContextMenu, + setDataMask, + filterState, + emitCrossFilters, + refs, +}: GraphChartTransformedProps) { + const getCrossFilterDataMask = (node: DataRow | undefined) => { + if (!node?.name || !node?.col) { + return undefined; + } + const { name, col } = node; + const selected = Object.values( + filterState?.selectedValues || {}, + ) as string[]; + let values: string[]; + if (selected.includes(name)) { + values = selected.filter(v => v !== name); + } else { + values = [name]; + } + return { + dataMask: { + extraFormData: { + filters: values.length + ? [ { - col: formData.target, - op: '==' as const, - val: targetValue, - formattedVal: targetValue, + col, + op: 'IN' as const, + val: values, }, - ]; - } - return undefined; - }; - if (onContextMenu) { - e.event.stop(); - const pointerEvent = e.event.event; - const data = (echartOptions as any).series[0].data as Data; - const filters = - e.dataType === 'node' - ? handleNodeClick(data) - : handleEdgeClick(data); - - if (filters) { - onContextMenu( - pointerEvent.clientX, - pointerEvent.clientY, - filters, - ); - } - } + ] + : [], }, - }), - [ - echartOptions, - emitCrossFilters, - filterState?.value, - formData.source, - formData.target, - onContextMenu, - setDataMask, - ], - ); - return ( - - ); - }, -); - -export default EchartsGraph; + filterState: { + value: values.length ? values : null, + selectedValues: values.length ? values : null, + }, + }, + isCurrentValueSelected: selected.includes(name), + }; + }; + const eventHandlers: EventHandlers = { + click: (e: Event) => { + if (!emitCrossFilters || !setDataMask) { + return; + } + e.event.stop(); + const data = (echartOptions as any).series[0].data as Data; + const node = data.find(item => item.id === e.data.id); + const dataMask = getCrossFilterDataMask(node)?.dataMask; + if (dataMask) { + setDataMask(dataMask); + } + }, + contextmenu: (e: Event) => { + const handleNodeClick = (data: Data) => { + const node = data.find(item => item.id === e.data.id); + if (node?.name) { + return [ + { + col: node.col, + op: '==' as const, + val: node.name, + formattedVal: node.name, + }, + ]; + } + return undefined; + }; + const handleEdgeClick = (data: Data) => { + 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) { + return [ + { + col: formData.source, + op: '==' as const, + val: sourceValue, + formattedVal: sourceValue, + }, + { + col: formData.target, + op: '==' as const, + val: targetValue, + formattedVal: targetValue, + }, + ]; + } + return undefined; + }; + if (onContextMenu) { + e.event.stop(); + const pointerEvent = e.event.event; + const data = (echartOptions as any).series[0].data as Data; + const drillToDetailFilters = + e.dataType === 'node' ? handleNodeClick(data) : handleEdgeClick(data); + onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { + drillToDetail: drillToDetailFilters, + crossFilter: getCrossFilterDataMask( + data.find(item => item.id === e.data.id), + ), + }); + } + }, + }; + return ( + + ); +} 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 b532c7d9da770..0018c0e876e54 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/EchartsMixedTimeseries.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/EchartsMixedTimeseries.tsx @@ -51,10 +51,14 @@ export default function EchartsMixedTimeseries({ [seriesBreakdown], ); - const handleChange = useCallback( - (values: string[], seriesIndex: number) => { - if (!emitCrossFilters) { - return; + const getCrossFilterDataMask = useCallback( + (seriesName, seriesIndex) => { + const selected: string[] = Object.values(selectedValues || {}); + let values: string[]; + if (selected.includes(seriesName)) { + values = selected.filter(v => v !== seriesName); + } else { + values = [seriesName]; } const currentGroupBy = isFirstQuery(seriesIndex) ? groupby : groupbyB; @@ -63,51 +67,57 @@ export default function EchartsMixedTimeseries({ .map(value => currentLabelMap?.[value]) .filter(value => !!value); - setDataMask({ - extraFormData: { - // @ts-ignore - filters: - values.length === 0 - ? [] - : [ - ...currentGroupBy.map((col, idx) => { - const val: DataRecordValue[] = groupbyValues.map( - v => v[idx], - ); - if (val === null || val === undefined) + return { + dataMask: { + extraFormData: { + // @ts-ignore + filters: + values.length === 0 + ? [] + : [ + ...currentGroupBy.map((col, idx) => { + const val: DataRecordValue[] = groupbyValues.map( + v => v[idx], + ); + if (val === null || val === undefined) + return { + col, + op: 'IS NULL' as const, + }; return { col, - op: 'IS NULL', + op: 'IN' as const, + val: val as (string | number | boolean)[], }; - return { - col, - op: 'IN', - val: val as (string | number | boolean)[], - }; - }), - ], + }), + ], + }, + filterState: { + value: !groupbyValues.length ? null : groupbyValues, + selectedValues: values.length ? values : null, + }, }, - filterState: { - value: !groupbyValues.length ? null : groupbyValues, - selectedValues: values.length ? values : null, - }, - }); + isCurrentValueSelected: selected.includes(seriesName), + }; }, - [groupby, groupbyB, labelMap, labelMapB, setDataMask, selectedValues], + [groupby, groupbyB, isFirstQuery, labelMap, labelMapB, selectedValues], + ); + + const handleChange = useCallback( + (seriesName: string, seriesIndex: number) => { + if (!emitCrossFilters) { + return; + } + + setDataMask(getCrossFilterDataMask(seriesName, seriesIndex).dataMask); + }, + [emitCrossFilters, setDataMask, getCrossFilterDataMask], ); const eventHandlers: EventHandlers = { click: props => { const { seriesName, seriesIndex } = props; - const values: string[] = Object.values(selectedValues || {}); - if (values.includes(seriesName)) { - handleChange( - values.filter(v => v !== seriesName), - seriesIndex, - ); - } else { - handleChange([seriesName], seriesIndex); - } + handleChange(seriesName, seriesIndex); }, mouseout: () => { currentSeries.name = ''; @@ -118,18 +128,18 @@ export default function EchartsMixedTimeseries({ contextmenu: eventParams => { if (onContextMenu) { eventParams.event.stop(); - const { data, seriesIndex } = eventParams; + const { data, seriesName, seriesIndex } = eventParams; + const pointerEvent = eventParams.event.event; + const drillToDetailFilters: BinaryQueryObjectFilterClause[] = []; if (data) { - const pointerEvent = eventParams.event.event; const values = [ ...(eventParams.name ? [eventParams.name] : []), ...(isFirstQuery(seriesIndex) ? labelMap : labelMapB)[ eventParams.seriesName ], ]; - const filters: BinaryQueryObjectFilterClause[] = []; if (xAxis.type === AxisType.time) { - filters.push({ + drillToDetailFilters.push({ col: xAxis.label === DTTM_ALIAS ? formData.granularitySqla @@ -146,15 +156,18 @@ export default function EchartsMixedTimeseries({ ? formData.groupby : formData.groupbyB), ].forEach((dimension, i) => - filters.push({ + drillToDetailFilters.push({ col: dimension, op: '==', val: values[i], formattedVal: String(values[i]), }), ); - onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters); } + onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { + drillToDetail: drillToDetailFilters, + crossFilter: getCrossFilterDataMask(seriesName, seriesIndex), + }); } }, }; 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 9fecfeac0ef78..3cd697248c6e1 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/EchartsPie.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/EchartsPie.tsx @@ -16,60 +16,15 @@ * specific language governing permissions and limitations * under the License. */ -import React, { useCallback } from 'react'; +import React from 'react'; import { PieChartTransformedProps } from './types'; import Echart from '../components/Echart'; import { allEventHandlers } from '../utils/eventHandlers'; export default function EchartsPie(props: PieChartTransformedProps) { - const { - height, - width, - echartOptions, - setDataMask, - labelMap, - groupby, - selectedValues, - refs, - emitCrossFilters, - } = props; - const handleChange = useCallback( - (values: string[]) => { - if (!emitCrossFilters) { - return; - } + const { height, width, echartOptions, selectedValues, refs } = props; - 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, - }, - }); - }, - [groupby, labelMap, setDataMask, selectedValues], - ); - - const eventHandlers = allEventHandlers(props, handleChange); + const eventHandlers = allEventHandlers(props); return ( { - if (!emitCrossFilters) { - return; - } - - 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, - }, - }); - }, - [groupby, labelMap, setDataMask, selectedValues], - ); - - const eventHandlers = allEventHandlers(props, handleChange); + const { height, width, echartOptions, selectedValues, refs } = props; + const eventHandlers = allEventHandlers(props); return ( { - if (!emitCrossFilters) { - return; + const getCrossFilterDataMask = useCallback( + (treePathInfo: TreePathInfo[]) => { + const treePath = extractTreePathInfo(treePathInfo); + const name = treePath.join(','); + const selected = Object.values(selectedValues); + let values: string[]; + if (selected.includes(name)) { + values = selected.filter(v => v !== name); + } else { + values = [name]; } - const labels = values.map(value => labelMap[value]); - setDataMask({ - extraFormData: { - filters: - values.length === 0 || !columns - ? [] - : columns.map((col, idx) => { - const val = labels.map(v => v[idx]); - if (val === null || val === undefined) + return { + dataMask: { + extraFormData: { + filters: + values.length === 0 || !columns + ? [] + : columns.map((col, idx) => { + const val = labels.map(v => v[idx]); + if (val === null || val === undefined) + return { + col, + op: 'IS NULL' as const, + }; return { col, - op: 'IS NULL', + op: 'IN' as const, + val: val as (string | number | boolean)[], }; - return { - col, - op: 'IN', - val: val as (string | number | boolean)[], - }; - }), - }, - filterState: { - value: labels.length ? labels : null, - selectedValues: values.length ? values : null, + }), + }, + filterState: { + value: labels.length ? labels : null, + selectedValues: values.length ? values : null, + }, }, - }); + isCurrentValueSelected: selected.includes(name), + }; + }, + [columns, labelMap, selectedValues], + ); + + const handleChange = useCallback( + (treePathInfo: TreePathInfo[]) => { + if (!emitCrossFilters) { + return; + } + + setDataMask(getCrossFilterDataMask(treePathInfo).dataMask); }, - [emitCrossFilters, setDataMask, columns, labelMap], + [emitCrossFilters, setDataMask, getCrossFilterDataMask], ); const eventHandlers: EventHandlers = { click: props => { const { treePathInfo } = props; - const treePath = extractTreePathInfo(treePathInfo); - const name = treePath.join(','); - const values = Object.values(selectedValues); - if (values.includes(name)) { - handleChange(values.filter(v => v !== name)); - } else { - handleChange([name]); - } + handleChange(treePathInfo); }, contextmenu: eventParams => { if (onContextMenu) { eventParams.event.stop(); - const { data } = eventParams; + const { data, treePathInfo } = eventParams; const { records } = data; const treePath = extractTreePathInfo(eventParams.treePathInfo); const pointerEvent = eventParams.event.event; - const filters: BinaryQueryObjectFilterClause[] = []; + const drillToDetailFilters: BinaryQueryObjectFilterClause[] = []; if (columns?.length) { treePath.forEach((path, i) => - filters.push({ + drillToDetailFilters.push({ col: columns[i], op: '==', val: records[i], @@ -109,7 +121,10 @@ export default function EchartsSunburst(props: SunburstTransformedProps) { }), ); } - onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters); + onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { + drillToDetail: drillToDetailFilters, + crossFilter: getCrossFilterDataMask(treePathInfo), + }); } }, }; 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 0cf7f3cf192cd..db4f730aff949 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx @@ -108,40 +108,56 @@ export default function EchartsTimeseries({ return model; }; - const handleChange = useCallback( - (values: string[]) => { - if (!emitCrossFilters) { - return; + const getCrossFilterDataMask = useCallback( + (value: string) => { + const selected: string[] = Object.values(selectedValues); + let values: string[]; + if (selected.includes(value)) { + values = selected.filter(v => v !== value); + } else { + values = [value]; } 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 { + dataMask: { + 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' as const, + }; return { col, - op: 'IS NULL', + op: 'IN' as const, + val: val as (string | number | boolean)[], }; - return { - col, - op: 'IN', - val: val as (string | number | boolean)[], - }; - }), - }, - filterState: { - label: groupbyValues.length ? groupbyValues : undefined, - value: groupbyValues.length ? groupbyValues : null, - selectedValues: values.length ? values : null, + }), + }, + filterState: { + label: groupbyValues.length ? groupbyValues : undefined, + value: groupbyValues.length ? groupbyValues : null, + selectedValues: values.length ? values : null, + }, }, - }); + isCurrentValueSelected: selected.includes(value), + }; }, - [groupby, labelMap, setDataMask, emitCrossFilters], + [groupby, labelMap, selectedValues], + ); + + const handleChange = useCallback( + (value: string) => { + if (!emitCrossFilters) { + return; + } + setDataMask(getCrossFilterDataMask(value).dataMask); + }, + [emitCrossFilters, setDataMask, getCrossFilterDataMask], ); const eventHandlers: EventHandlers = { @@ -152,12 +168,7 @@ export default function EchartsTimeseries({ // Ensure that double-click events do not trigger single click event. So we put it in the timer. clickTimer.current = setTimeout(() => { const { seriesName: name } = props; - const values = Object.values(selectedValues); - if (values.includes(name)) { - handleChange(values.filter(v => v !== name)); - } else { - handleChange([name]); - } + handleChange(name); }, TIMER_DURATION); }, mouseout: () => { @@ -188,16 +199,16 @@ export default function EchartsTimeseries({ contextmenu: eventParams => { if (onContextMenu) { eventParams.event.stop(); - const { data } = eventParams; + const { data, seriesName } = eventParams; + const drillToDetailFilters: BinaryQueryObjectFilterClause[] = []; + const pointerEvent = eventParams.event.event; + const values = [ + ...(eventParams.name ? [eventParams.name] : []), + ...labelMap[eventParams.seriesName], + ]; if (data) { - const pointerEvent = eventParams.event.event; - const values = [ - ...(eventParams.name ? [eventParams.name] : []), - ...labelMap[eventParams.seriesName], - ]; - const filters: BinaryQueryObjectFilterClause[] = []; if (xAxis.type === AxisType.time) { - filters.push({ + drillToDetailFilters.push({ col: // if the xAxis is '__timestamp', granularity_sqla will be the column of filter xAxis.label === DTTM_ALIAS @@ -213,15 +224,18 @@ export default function EchartsTimeseries({ ...(xAxis.type === AxisType.category ? [xAxis.label] : []), ...formData.groupby, ].forEach((dimension, i) => - filters.push({ + drillToDetailFilters.push({ col: dimension, op: '==', val: values[i], formattedVal: String(values[i]), }), ); - onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters); } + onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { + drillToDetail: drillToDetailFilters, + crossFilter: getCrossFilterDataMask(seriesName), + }); } }, }; 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 0a2f01b4049c9..1ee793cfc7ea2 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/EchartsTreemap.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/EchartsTreemap.tsx @@ -39,74 +39,95 @@ export default function EchartsTreemap({ selectedValues, width, }: TreemapTransformedProps) { - const handleChange = useCallback( - (values: string[]) => { - if (!emitCrossFilters) { - return; + const getCrossFilterDataMask = useCallback( + (data, treePathInfo) => { + if (data?.children) { + return undefined; + } + const { treePath } = extractTreePathInfo(treePathInfo); + const name = treePath.join(','); + const selected = Object.values(selectedValues); + let values: string[]; + if (selected.includes(name)) { + values = selected.filter(v => v !== name); + } else { + values = [name]; } const groupbyValues = values.map(value => labelMap[value]); - setDataMask({ - extraFormData: { - filters: - values.length === 0 - ? [] - : groupby.map((col, idx) => { - const val: DataRecordValue[] = groupbyValues.map(v => v[idx]); - if (val === null || val === undefined) + return { + dataMask: { + extraFormData: { + filters: + values.length === 0 + ? [] + : groupby.map((col, idx) => { + const val: DataRecordValue[] = groupbyValues.map( + v => v[idx], + ); + if (val === null || val === undefined) + return { + col, + op: 'IS NULL' as const, + }; return { col, - op: 'IS NULL', + op: 'IN' as const, + val: val as (string | number | boolean)[], }; - return { - col, - op: 'IN', - val: val as (string | number | boolean)[], - }; - }), - }, - filterState: { - value: groupbyValues.length ? groupbyValues : null, - selectedValues: values.length ? values : null, + }), + }, + filterState: { + value: groupbyValues.length ? groupbyValues : null, + selectedValues: values.length ? values : null, + }, }, - }); + isCurrentValueSelected: selected.includes(name), + }; }, - [groupby, labelMap, setDataMask, selectedValues], + [groupby, labelMap, selectedValues], ); - const eventHandlers: EventHandlers = { - click: props => { - const { data, treePathInfo } = props; - // do nothing when clicking on the parent node - if (data?.children) { + const handleChange = useCallback( + (data, treePathInfo) => { + if (!emitCrossFilters) { return; } - const { treePath } = extractTreePathInfo(treePathInfo); - const name = treePath.join(','); - const values = Object.values(selectedValues); - if (values.includes(name)) { - handleChange(values.filter(v => v !== name)); - } else { - handleChange([name]); + + const dataMask = getCrossFilterDataMask(data, treePathInfo)?.dataMask; + if (dataMask) { + setDataMask(dataMask); } }, + [emitCrossFilters, getCrossFilterDataMask, setDataMask], + ); + + const eventHandlers: EventHandlers = { + click: props => { + const { data, treePathInfo } = props; + handleChange(data, treePathInfo); + }, contextmenu: eventParams => { if (onContextMenu) { eventParams.event.stop(); - const { treePath } = extractTreePathInfo(eventParams.treePathInfo); + const { data, treePathInfo } = eventParams; + const { treePath } = extractTreePathInfo(treePathInfo); if (treePath.length > 0) { const pointerEvent = eventParams.event.event; - const filters: BinaryQueryObjectFilterClause[] = []; + const drillToDetailFilters: BinaryQueryObjectFilterClause[] = []; treePath.forEach((path, i) => - filters.push({ + drillToDetailFilters.push({ col: groupby[i], op: '==', val: path === 'null' ? NULL_STRING : path, formattedVal: path, }), ); - onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters); + onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { + drillToDetail: drillToDetailFilters, + crossFilter: getCrossFilterDataMask(data, treePathInfo), + }); } } }, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/types.ts index f56090b0c9aad..d51102439f08c 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/types.ts @@ -18,9 +18,9 @@ */ import React, { RefObject } from 'react'; import { - BinaryQueryObjectFilterClause, ChartDataResponseResult, ChartProps, + ContextMenuFilters, FilterState, HandlerFunction, PlainObject, @@ -124,7 +124,7 @@ export interface BaseTransformedProps { onContextMenu?: ( clientX: number, clientY: number, - filters?: BinaryQueryObjectFilterClause[], + filters?: ContextMenuFilters, ) => void; setDataMask?: SetDataMaskHook; filterState?: FilterState; @@ -146,7 +146,7 @@ export type ContextMenuTransformedProps = { onContextMenu?: ( clientX: number, clientY: number, - filters?: BinaryQueryObjectFilterClause[], + filters?: ContextMenuFilters, ) => void; setDataMask?: SetDataMaskHook; }; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/eventHandlers.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/eventHandlers.ts index 0d26b92d08df4..6dafe7ba60e99 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/eventHandlers.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/eventHandlers.ts @@ -16,7 +16,12 @@ * specific language governing permissions and limitations * under the License. */ -import { BinaryQueryObjectFilterClause } from '@superset-ui/core'; +import { + BinaryQueryObjectFilterClause, + ContextMenuFilters, + DataMask, + QueryFormColumn, +} from '@superset-ui/core'; import { BaseTransformedProps, CrossFilterTransformedProps, @@ -28,17 +33,67 @@ export type Event = { event: { stop: () => void; event: PointerEvent }; }; -export const clickEventHandler = +const getCrossFilterDataMask = ( selectedValues: Record, - handleChange: (values: string[]) => void, + groupby: QueryFormColumn[], + labelMap: Record, ) => - ({ name }: { name: string }) => { - const values = Object.values(selectedValues); - if (values.includes(name)) { - handleChange(values.filter(v => v !== name)); + (value: string) => { + const selected = Object.values(selectedValues); + let values: string[]; + if (selected.includes(value)) { + values = selected.filter(v => v !== value); } else { - handleChange([name]); + values = [value]; + } + + const groupbyValues = values.map(value => labelMap[value]); + + return { + dataMask: { + 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' as const, + }; + return { + col, + op: 'IN' as const, + val: val as (string | number | boolean)[], + }; + }), + }, + filterState: { + value: groupbyValues.length ? groupbyValues : null, + selectedValues: values.length ? values : null, + }, + }, + isCurrentValueSelected: selected.includes(value), + }; + }; + +export const clickEventHandler = + ( + getCrossFilterDataMask: ( + value: string, + ) => ContextMenuFilters['crossFilter'], + setDataMask: (dataMask: DataMask) => void, + emitCrossFilters?: boolean, + ) => + ({ name }: { name: string }) => { + if (!emitCrossFilters) { + return; + } + const dataMask = getCrossFilterDataMask(name)?.dataMask; + if (dataMask) { + setDataMask(dataMask); } }; @@ -48,16 +103,19 @@ export const contextMenuEventHandler = CrossFilterTransformedProps)['groupby'], onContextMenu: BaseTransformedProps['onContextMenu'], labelMap: Record, + getCrossFilterDataMask: ( + value: string, + ) => ContextMenuFilters['crossFilter'], ) => (e: Event) => { if (onContextMenu) { e.event.stop(); const pointerEvent = e.event.event; - const filters: BinaryQueryObjectFilterClause[] = []; + const drillToDetailFilters: BinaryQueryObjectFilterClause[] = []; if (groupby.length > 0) { const values = labelMap[e.name]; groupby.forEach((dimension, i) => - filters.push({ + drillToDetailFilters.push({ col: dimension, op: '==', val: values[i], @@ -65,18 +123,36 @@ export const contextMenuEventHandler = }), ); } - onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters); + onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { + drillToDetail: drillToDetailFilters, + crossFilter: getCrossFilterDataMask(e.name), + }); } }; export const allEventHandlers = ( transformedProps: BaseTransformedProps & CrossFilterTransformedProps, - handleChange: (values: string[]) => void, ) => { - const { groupby, selectedValues, onContextMenu, labelMap } = transformedProps; + const { + groupby, + onContextMenu, + setDataMask, + labelMap, + emitCrossFilters, + selectedValues, + } = transformedProps; const eventHandlers: EventHandlers = { - click: clickEventHandler(selectedValues, handleChange), - contextmenu: contextMenuEventHandler(groupby, onContextMenu, labelMap), + click: clickEventHandler( + getCrossFilterDataMask(selectedValues, groupby, labelMap), + setDataMask, + emitCrossFilters, + ), + contextmenu: contextMenuEventHandler( + groupby, + onContextMenu, + labelMap, + getCrossFilterDataMask(selectedValues, groupby, labelMap), + ), }; return eventHandlers; }; diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx b/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx index d9653686e11d6..84a64adfc7745 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx @@ -279,6 +279,70 @@ export default function PivotTableChart(props: PivotTableProps) { [groupbyColumnsRaw, groupbyRowsRaw, setDataMask], ); + const getCrossFilterDataMask = useCallback( + (value: { [key: string]: string }) => { + const isActiveFilterValue = (key: string, val: DataRecordValue) => + !!selectedFilters && selectedFilters[key]?.includes(val); + + if (!value) { + return undefined; + } + + const [key, val] = Object.entries(value)[0]; + let values = { ...selectedFilters }; + if (isActiveFilterValue(key, val)) { + values = {}; + } else { + values = { [key]: [val] }; + } + + const filterKeys = Object.keys(values); + const groupby = [...groupbyRowsRaw, ...groupbyColumnsRaw]; + return { + dataMask: { + extraFormData: { + filters: + filterKeys.length === 0 + ? undefined + : filterKeys.map(key => { + const val = values?.[key]; + const col = + groupby.find(item => { + if (isPhysicalColumn(item)) { + return item === key; + } + if (isAdhocColumn(item)) { + return item.label === key; + } + return false; + }) ?? ''; + if (val === null || val === undefined) + return { + col, + op: 'IS NULL' as const, + }; + return { + col, + op: 'IN' as const, + val: val as (string | number | boolean)[], + }; + }), + }, + filterState: { + value: + values && Object.keys(values).length + ? Object.values(values) + : null, + selectedFilters: + values && Object.keys(values).length ? values : null, + }, + }, + isCurrentValueSelected: isActiveFilterValue(key, val), + }; + }, + [groupbyColumnsRaw, groupbyRowsRaw, selectedFilters], + ); + const toggleFilter = useCallback( ( e: MouseEvent, @@ -369,18 +433,19 @@ export default function PivotTableChart(props: PivotTableProps) { e: MouseEvent, colKey: (string | number | boolean)[] | undefined, rowKey: (string | number | boolean)[] | undefined, + dataPoint: { [key: string]: string }, ) => { if (onContextMenu) { e.preventDefault(); e.stopPropagation(); - const filters: BinaryQueryObjectFilterClause[] = []; + const drillToDetailFilters: BinaryQueryObjectFilterClause[] = []; if (colKey && colKey.length > 1) { colKey.forEach((val, i) => { const col = cols[i]; const formatter = dateFormatters[col]; const formattedVal = formatter?.(val as number) || String(val); if (i > 0) { - filters.push({ + drillToDetailFilters.push({ col, op: '==', val, @@ -395,7 +460,7 @@ export default function PivotTableChart(props: PivotTableProps) { const col = rows[i]; const formatter = dateFormatters[col]; const formattedVal = formatter?.(val as number) || String(val); - filters.push({ + drillToDetailFilters.push({ col, op: '==', val, @@ -404,7 +469,10 @@ export default function PivotTableChart(props: PivotTableProps) { }); }); } - onContextMenu(e.clientX, e.clientY, filters); + onContextMenu(e.clientX, e.clientY, { + drillToDetail: drillToDetailFilters, + crossFilter: getCrossFilterDataMask(dataPoint), + }); } }, [cols, dateFormatters, onContextMenu, rows, timeGrainSqla], diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.jsx b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.jsx index 576325a21ddec..4ca3f6d5af84b 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.jsx +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.jsx @@ -393,6 +393,7 @@ export class TableRenderer extends React.Component { // Iterate through columns. Jump over duplicate values. let i = 0; while (i < visibleColKeys.length) { + let handleContextMenu; const colKey = visibleColKeys[i]; const colSpan = attrIdx < colKey.length ? colAttrSpans[i][attrIdx] : 1; let colLabelClass = 'pvtColLabel'; @@ -402,6 +403,10 @@ export class TableRenderer extends React.Component { !omittedHighlightHeaderGroups.includes(colAttrs[attrIdx]) ) { colLabelClass += ' hoverable'; + handleContextMenu = e => + this.props.onContextMenu(e, colKey, undefined, { + [attrName]: colKey[attrIdx], + }); } if ( highlightedHeaderCells && @@ -434,6 +439,7 @@ export class TableRenderer extends React.Component { attrIdx, this.props.tableOptions.clickColumnHeaderCallback, )} + onContextMenu={handleContextMenu} > {displayHeaderCell( needToggle, @@ -590,12 +596,17 @@ export class TableRenderer extends React.Component { const colIncrSpan = colAttrs.length !== 0 ? 1 : 0; const attrValueCells = rowKey.map((r, i) => { + let handleContextMenu; let valueCellClassName = 'pvtRowLabel'; if ( highlightHeaderCellsOnHover && !omittedHighlightHeaderGroups.includes(rowAttrs[i]) ) { valueCellClassName += ' hoverable'; + handleContextMenu = e => + this.props.onContextMenu(e, undefined, rowKey, { + [rowAttrs[i]]: r, + }); } if ( highlightedHeaderCells && @@ -631,6 +642,7 @@ export class TableRenderer extends React.Component { i, this.props.tableOptions.clickRowHeaderCallback, )} + onContextMenu={handleContextMenu} > {displayHeaderCell( needRowToggle, diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/types.ts b/superset-frontend/plugins/plugin-chart-pivot-table/src/types.ts index 24d78e2dcaec5..8cf9a12ea3500 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/types.ts +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/types.ts @@ -26,8 +26,8 @@ import { NumberFormatter, QueryFormMetric, QueryFormColumn, - BinaryQueryObjectFilterClause, TimeGranularity, + ContextMenuFilters, } from '@superset-ui/core'; import { ColorFormatters } from '@superset-ui/chart-controls'; @@ -77,7 +77,7 @@ interface PivotTableCustomizeProps { onContextMenu?: ( clientX: number, clientY: number, - filters?: BinaryQueryObjectFilterClause[], + filters?: ContextMenuFilters, ) => void; timeGrainSqla?: TimeGranularity; } diff --git a/superset-frontend/plugins/plugin-chart-table/src/DataTable/DataTable.tsx b/superset-frontend/plugins/plugin-chart-table/src/DataTable/DataTable.tsx index 941887afd1962..85580e7b63a3d 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/DataTable/DataTable.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/DataTable/DataTable.tsx @@ -23,7 +23,6 @@ import React, { HTMLProps, MutableRefObject, CSSProperties, - MouseEvent, } from 'react'; import { useTable, @@ -67,7 +66,6 @@ export interface DataTableProps extends TableOptions { rowCount: number; wrapperRef?: MutableRefObject; onColumnOrderChange: () => void; - onContextMenu?: (value: D, clientX: number, clientY: number) => void; } export interface RenderHTMLCellProps extends HTMLProps { @@ -100,7 +98,6 @@ export default typedMemo(function DataTable({ serverPagination, wrapperRef: userWrapperRef, onColumnOrderChange, - onContextMenu, ...moreUseTableOptions }: DataTableProps): JSX.Element { const tableHooks: PluginHook[] = [ @@ -273,21 +270,7 @@ export default typedMemo(function DataTable({ prepareRow(row); const { key: rowKey, ...rowProps } = row.getRowProps(); return ( - { - if (onContextMenu) { - e.preventDefault(); - e.stopPropagation(); - onContextMenu( - row.original, - e.nativeEvent.clientX, - e.nativeEvent.clientY, - ); - } - }} - > + {row.cells.map(cell => cell.render('Cell', { key: cell.column.id }), )} diff --git a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx index e114a823d6dfc..9843a4ae8b17b 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx @@ -22,11 +22,13 @@ import React, { useLayoutEffect, useMemo, useState, + MouseEvent, } from 'react'; import { ColumnInstance, ColumnWithLooseAccessor, DefaultSortTypes, + Row, } from 'react-table'; import { extent as d3Extent, max as d3Max } from 'd3-array'; import { FaSort } from '@react-icons/all-files/fa/FaSort'; @@ -241,57 +243,6 @@ export default function TableChart( // keep track of whether column order changed, so that column widths can too const [columnOrderToggle, setColumnOrderToggle] = useState(false); - const handleChange = useCallback( - (filters: { [x: string]: DataRecordValue[] }) => { - if (!emitCrossFilters) { - return; - } - - const groupBy = Object.keys(filters); - const groupByValues = Object.values(filters); - const labelElements: string[] = []; - groupBy.forEach(col => { - const isTimestamp = col === DTTM_ALIAS; - const filterValues = ensureIsArray(filters?.[col]); - if (filterValues.length) { - const valueLabels = filterValues.map(value => - isTimestamp ? timestampFormatter(value) : value, - ); - labelElements.push(`${valueLabels.join(', ')}`); - } - }); - setDataMask({ - extraFormData: { - filters: - groupBy.length === 0 - ? [] - : groupBy.map(col => { - const val = ensureIsArray(filters?.[col]); - if (!val.length) - return { - col, - op: 'IS NULL', - }; - return { - col, - op: 'IN', - val: val.map(el => - el instanceof Date ? el.getTime() : el!, - ), - grain: col === DTTM_ALIAS ? timeGrain : undefined, - }; - }), - }, - filterState: { - label: labelElements.join(', '), - value: groupByValues.length ? groupByValues : null, - filters: filters && Object.keys(filters).length ? filters : null, - }, - }); - }, - [emitCrossFilters, setDataMask], - ); - // only take relevant page size options const pageSizeOptions = useMemo(() => { const getServerPagination = (n: number) => n <= rowCount; @@ -322,25 +273,80 @@ export default function TableChart( [filters], ); + const getCrossFilterDataMask = (key: string, value: DataRecordValue) => { + let updatedFilters = { ...(filters || {}) }; + if (filters && isActiveFilterValue(key, value)) { + updatedFilters = {}; + } else { + updatedFilters = { + [key]: [value], + }; + } + if ( + Array.isArray(updatedFilters[key]) && + updatedFilters[key].length === 0 + ) { + delete updatedFilters[key]; + } + + const groupBy = Object.keys(updatedFilters); + const groupByValues = Object.values(updatedFilters); + const labelElements: string[] = []; + groupBy.forEach(col => { + const isTimestamp = col === DTTM_ALIAS; + const filterValues = ensureIsArray(updatedFilters?.[col]); + if (filterValues.length) { + const valueLabels = filterValues.map(value => + isTimestamp ? timestampFormatter(value) : value, + ); + labelElements.push(`${valueLabels.join(', ')}`); + } + }); + + return { + dataMask: { + extraFormData: { + filters: + groupBy.length === 0 + ? [] + : groupBy.map(col => { + const val = ensureIsArray(updatedFilters?.[col]); + if (!val.length) + return { + col, + op: 'IS NULL' as const, + }; + return { + col, + op: 'IN' as const, + val: val.map(el => + el instanceof Date ? el.getTime() : el!, + ), + grain: col === DTTM_ALIAS ? timeGrain : undefined, + }; + }), + }, + filterState: { + label: labelElements.join(', '), + value: groupByValues.length ? groupByValues : null, + filters: + updatedFilters && Object.keys(updatedFilters).length + ? updatedFilters + : null, + }, + }, + isCurrentValueSelected: isActiveFilterValue(key, value), + }; + }; + const toggleFilter = useCallback( function toggleFilter(key: string, val: DataRecordValue) { - let updatedFilters = { ...(filters || {}) }; - if (filters && isActiveFilterValue(key, val)) { - updatedFilters = {}; - } else { - updatedFilters = { - [key]: [val], - }; - } - if ( - Array.isArray(updatedFilters[key]) && - updatedFilters[key].length === 0 - ) { - delete updatedFilters[key]; + if (!emitCrossFilters) { + return; } - handleChange(updatedFilters); + setDataMask(getCrossFilterDataMask(key, val).dataMask); }, - [filters, handleChange, isActiveFilterValue], + [emitCrossFilters, getCrossFilterDataMask, setDataMask], ); const getSharedStyle = (column: DataColumnMeta): CSSProperties => { @@ -355,6 +361,39 @@ export default function TableChart( }; }; + const handleContextMenu = + onContextMenu && !isRawRecords + ? ( + value: D, + cellPoint: { + key: string; + value: DataRecordValue; + isMetric?: boolean; + }, + clientX: number, + clientY: number, + ) => { + const drillToDetailFilters: BinaryQueryObjectFilterClause[] = []; + columnsMeta.forEach(col => { + if (!col.isMetric) { + const dataRecordValue = value[col.key]; + drillToDetailFilters.push({ + col: col.key, + op: '==', + val: dataRecordValue as string | number | boolean, + formattedVal: formatColumnValue(col, dataRecordValue)[1], + }); + } + }); + onContextMenu(clientX, clientY, { + drillToDetail: drillToDetailFilters, + crossFilter: cellPoint.isMetric + ? undefined + : getCrossFilterDataMask(cellPoint.key, cellPoint.value), + }); + } + : undefined; + const getColumnConfigs = useCallback( (column: DataColumnMeta, i: number): ColumnWithLooseAccessor => { const { key, label, isNumeric, dataType, isMetric, config = {} } = column; @@ -390,7 +429,7 @@ export default function TableChart( getValueRange(key, alignPositiveNegative); let className = ''; - if (emitCrossFilters) { + if (emitCrossFilters && !isMetric) { className += ' dt-is-filter'; } @@ -400,7 +439,7 @@ export default function TableChart( // typing is incorrect in current version of `@types/react-table` // so we ask TS not to check. accessor: ((datum: D) => datum[key]) as never, - Cell: ({ value }: { value: DataRecordValue }) => { + Cell: ({ value, row }: { value: DataRecordValue; row: Row }) => { const [isHtml, text] = formatColumnValue(column, value); const html = isHtml ? { __html: text } : undefined; @@ -453,9 +492,21 @@ export default function TableChart( // show raw number in title in case of numeric values title: typeof value === 'number' ? String(value) : undefined, onClick: - emitCrossFilters && !valueRange + emitCrossFilters && !valueRange && !isMetric ? () => toggleFilter(key, value) : undefined, + onContextMenu: (e: MouseEvent) => { + if (handleContextMenu) { + e.preventDefault(); + e.stopPropagation(); + handleContextMenu( + row.original, + { key, value, isMetric }, + e.nativeEvent.clientX, + e.nativeEvent.clientY, + ); + } + }, className: [ className, value == null ? 'dt-is-null' : '', @@ -621,25 +672,6 @@ export default function TableChart( const { width: widthFromState, height: heightFromState } = tableSize; - const handleContextMenu = - onContextMenu && !isRawRecords - ? (value: D, clientX: number, clientY: number) => { - const filters: BinaryQueryObjectFilterClause[] = []; - columnsMeta.forEach(col => { - if (!col.isMetric) { - const dataRecordValue = value[col.key]; - filters.push({ - col: col.key, - op: '==', - val: dataRecordValue as string | number | boolean, - formattedVal: formatColumnValue(col, dataRecordValue)[1], - }); - } - }); - onContextMenu(clientX, clientY, filters); - } - : undefined; - return ( @@ -662,7 +694,6 @@ export default function TableChart( selectPageSize={pageSize !== null && SelectPageSize} // not in use in Superset, but needed for unit tests sticky={sticky} - onContextMenu={handleContextMenu} /> ); diff --git a/superset-frontend/plugins/plugin-chart-table/src/types.ts b/superset-frontend/plugins/plugin-chart-table/src/types.ts index 3a591e8682ed1..f76d2718b4fd1 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/types.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/types.ts @@ -30,7 +30,7 @@ import { ChartDataResponseResult, QueryFormData, SetDataMaskHook, - BinaryQueryObjectFilterClause, + ContextMenuFilters, } from '@superset-ui/core'; import { ColorFormatters, ColumnConfig } from '@superset-ui/chart-controls'; @@ -114,7 +114,7 @@ export interface TableChartTransformedProps { onContextMenu?: ( clientX: number, clientY: number, - filters?: BinaryQueryObjectFilterClause[], + filters?: ContextMenuFilters, ) => void; } diff --git a/superset-frontend/src/components/Chart/ChartContextMenu.tsx b/superset-frontend/src/components/Chart/ChartContextMenu.tsx index c202d9ee9dd61..0f0082aee9671 100644 --- a/superset-frontend/src/components/Chart/ChartContextMenu.tsx +++ b/superset-frontend/src/components/Chart/ChartContextMenu.tsx @@ -18,18 +18,23 @@ */ import React, { forwardRef, + ReactNode, RefObject, useCallback, useImperativeHandle, useState, } from 'react'; import ReactDOM from 'react-dom'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { - BinaryQueryObjectFilterClause, + Behavior, + ContextMenuFilters, FeatureFlag, + getChartMetadataRegistry, isFeatureEnabled, QueryFormData, + t, + useTheme, } from '@superset-ui/core'; import { RootState } from 'src/dashboard/types'; import { findPermission } from 'src/utils/findPermission'; @@ -37,6 +42,8 @@ import { Menu } from 'src/components/Menu'; import { AntdDropdown as Dropdown } from 'src/components'; import { DrillDetailMenuItems } from './DrillDetail'; import { getMenuAdjustedY } from './utils'; +import { updateDataMask } from '../../dataMask/actions'; +import { MenuItemTooltip } from './DisabledMenuItemTooltip'; export interface ChartContextMenuProps { id: number; @@ -49,7 +56,7 @@ export interface Ref { open: ( clientX: number, clientY: number, - filters?: BinaryQueryObjectFilterClause[], + filters?: ContextMenuFilters, ) => void; } @@ -57,26 +64,119 @@ const ChartContextMenu = ( { id, formData, onSelection, onClose }: ChartContextMenuProps, ref: RefObject, ) => { + const theme = useTheme(); + const dispatch = useDispatch(); const canExplore = useSelector((state: RootState) => findPermission('can_explore', 'Superset', state.user?.roles), ); + const crossFiltersEnabled = useSelector( + ({ dashboardInfo }) => dashboardInfo.crossFiltersEnabled, + ); const [{ filters, clientX, clientY }, setState] = useState<{ clientX: number; clientY: number; - filters?: BinaryQueryObjectFilterClause[]; + filters?: ContextMenuFilters; }>({ clientX: 0, clientY: 0 }); const menuItems = []; + const showDrillToDetail = isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL) && canExplore; + const isCrossFilteringSupportedByChart = getChartMetadataRegistry() + .get(formData.viz_type) + ?.behaviors?.includes(Behavior.INTERACTIVE_CHART); + + let itemsCount = 0; + if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) { + itemsCount += 1; + } + if (showDrillToDetail) { + itemsCount += 2; // Drill to detail always has 2 top-level menu items + } + if (itemsCount === 0) { + itemsCount = 1; // "No actions" appears if no actions in menu + } + + if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) { + const isCrossFilterDisabled = + !isCrossFilteringSupportedByChart || + !crossFiltersEnabled || + !filters?.crossFilter; + + let crossFilteringTooltipTitle: ReactNode = null; + if (!isCrossFilterDisabled) { + crossFilteringTooltipTitle = ( + <> +
+ {t( + 'Cross-filter will be applied to all of the charts that use this dataset.', + )} +
+
+ {t('You can also just click on the chart to apply cross-filter.')} +
+ + ); + } else if (!crossFiltersEnabled) { + crossFilteringTooltipTitle = ( + <> +
{t('Cross-filtering is not enabled for this dashboard.')}
+ + ); + } else if (!isCrossFilteringSupportedByChart) { + crossFilteringTooltipTitle = ( + <> +
+ {t('This visualization type does not support cross-filtering.')} +
+ + ); + } else if (!filters?.crossFilter) { + crossFilteringTooltipTitle = ( + <> +
{t(`You can't apply cross-filter on this data point.`)}
+ + ); + } + menuItems.push( + <> + { + if (filters?.crossFilter) { + dispatch(updateDataMask(id, filters.crossFilter.dataMask)); + } + }} + > + {filters?.crossFilter?.isCurrentValueSelected ? ( + t('Remove cross-filter') + ) : ( +
+ {t('Add cross-filter')} + +
+ )} +
+ {itemsCount > 1 && } + , + ); + } if (showDrillToDetail) { menuItems.push( { - const itemsCount = - [ - showDrillToDetail ? 2 : 0, // Drill to detail always has 2 top-level menu items - ].reduce((a, b) => a + b, 0) || 1; // "No actions" appears if no actions in menu - + (clientX: number, clientY: number, filters?: ContextMenuFilters) => { const adjustedY = getMenuAdjustedY(clientY, itemsCount); setState({ clientX, @@ -108,7 +199,7 @@ const ChartContextMenu = ( // from the charts. document.getElementById(`hidden-span-${id}`)?.click(); }, - [id, showDrillToDetail], + [id, itemsCount], ); useImperativeHandle( diff --git a/superset-frontend/src/components/Chart/ChartRenderer.jsx b/superset-frontend/src/components/Chart/ChartRenderer.jsx index 588e2b4e4dfbe..1b72b4afe10f5 100644 --- a/superset-frontend/src/components/Chart/ChartRenderer.jsx +++ b/superset-frontend/src/components/Chart/ChartRenderer.jsx @@ -87,7 +87,8 @@ class ChartRenderer extends React.Component { this.state = { showContextMenu: props.source === ChartSource.Dashboard && - isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL), + (isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL) || + isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)), inContextMenu: false, }; this.hasQueryResponseChange = false; diff --git a/superset-frontend/src/components/Chart/DisabledMenuItemTooltip.tsx b/superset-frontend/src/components/Chart/DisabledMenuItemTooltip.tsx new file mode 100644 index 0000000000000..e83ad9ac334d9 --- /dev/null +++ b/superset-frontend/src/components/Chart/DisabledMenuItemTooltip.tsx @@ -0,0 +1,48 @@ +/** + * 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, { ReactNode } from 'react'; +import { css, SupersetTheme } from '@superset-ui/core'; +import Icons from 'src/components/Icons'; +import { Tooltip } from 'src/components/Tooltip'; + +export const MenuItemTooltip = ({ + title, + color, +}: { + title: ReactNode; + color?: string; +}) => ( + + css` + color: ${color || theme.colors.text.label}; + margin-left: ${theme.gridUnit * 2}px; + &.anticon { + font-size: unset; + .anticon { + line-height: unset; + vertical-align: unset; + } + } + `} + /> + +); diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailMenuItems.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailMenuItems.tsx index 6159e212e34f1..b3daada2d4d9c 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailMenuItems.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailMenuItems.tsx @@ -27,37 +27,16 @@ import { getChartMetadataRegistry, QueryFormData, styled, - SupersetTheme, t, } from '@superset-ui/core'; import { Menu } from 'src/components/Menu'; -import Icons from 'src/components/Icons'; -import { Tooltip } from 'src/components/Tooltip'; import DrillDetailModal from './DrillDetailModal'; import { getMenuAdjustedY, MENU_ITEM_HEIGHT } from '../utils'; +import { MenuItemTooltip } from '../DisabledMenuItemTooltip'; const MENU_PADDING = 4; const DRILL_TO_DETAIL_TEXT = t('Drill to detail by'); -const DisabledMenuItemTooltip = ({ title }: { title: ReactNode }) => ( - - css` - color: ${theme.colors.text.label}; - margin-left: ${theme.gridUnit * 2}px; - &.anticon { - font-size: unset; - .anticon { - line-height: unset; - vertical-align: unset; - } - } - `} - /> - -); - const DisabledMenuItem = ({ children, ...props }: { children: ReactNode }) => (
{t('Drill to detail')} - {DRILL_TO_DETAIL_TEXT} - {DRILL_TO_DETAIL_TEXT} -