From 871cab8cbe20971efd9b81f647ed537ad4fbe12b Mon Sep 17 00:00:00 2001 From: Kamil Gabryjelski Date: Wed, 22 Feb 2023 11:42:56 +0100 Subject: [PATCH] feat: Enable cross fitlers in WorldMap and Graph charts (#22886) --- .../src/WorldMap.js | 68 ++++++- .../src/index.js | 2 +- .../src/transformProps.js | 17 +- .../src/Graph/EchartsGraph.tsx | 187 +++++++++++++----- .../plugin-chart-echarts/src/Graph/index.ts | 4 +- .../src/Graph/transformProps.ts | 24 ++- .../plugin-chart-echarts/src/Graph/types.ts | 1 + .../plugins/plugin-chart-echarts/src/types.ts | 5 + .../test/Graph/transformProps.test.ts | 6 + 9 files changed, 247 insertions(+), 67 deletions(-) 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 7b56d432eaaa7..eba928faed7c8 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 @@ -46,6 +46,9 @@ const propTypes = { showBubbles: PropTypes.bool, linearColorScheme: PropTypes.string, color: PropTypes.string, + setDataMask: PropTypes.func, + onContextMenu: PropTypes.func, + emitCrossFilters: PropTypes.bool, }; const formatter = getNumberFormatter(); @@ -66,7 +69,10 @@ function WorldMap(element, props) { sliceId, theme, onContextMenu, + setDataMask, inContextMenu, + filterState, + emitCrossFilters, } = props; const div = d3.select(element); div.classed('superset-legacy-chart-world-map', true); @@ -108,11 +114,47 @@ function WorldMap(element, props) { mapData[d.country] = d; }); + 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; + } + + setDataMask({ + extraFormData: { + filters: val + ? [ + { + col: entity, + op: 'IN', + val: [val], + }, + ] + : [], + }, + filterState: { + value: val ?? null, + selectedValues: val ? [key] : [], + }, + }); + }; + const handleContextMenu = source => { const pointerEvent = d3.event; pointerEvent.preventDefault(); const key = source.id || source.country; - const val = countryFieldtype === 'name' ? mapData[key]?.name : key; + const val = + countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.country; if (val) { const filters = [ { @@ -178,7 +220,8 @@ function WorldMap(element, props) { done: datamap => { datamap.svg .selectAll('.datamaps-subunit') - .on('contextmenu', handleContextMenu); + .on('contextmenu', handleContextMenu) + .on('click', handleClick); }, }); @@ -190,7 +233,26 @@ function WorldMap(element, props) { .selectAll('circle.datamaps-bubble') .style('fill', color) .style('stroke', color) - .on('contextmenu', handleContextMenu); + .on('contextmenu', handleContextMenu) + .on('click', handleClick); + } + + if (filterState.selectedValues?.length > 0) { + d3.selectAll('path.datamaps-subunit') + .filter( + countryFeature => + !filterState.selectedValues.includes(countryFeature.id), + ) + .style('fill-opacity', theme.opacity.mediumLight); + + // hack to ensure that the clicked country's color is preserved + // sometimes the fill color would get default grey value after applying cross filter + filterState.selectedValues.forEach(value => { + d3.select(`path.datamaps-subunit.${value}`).style( + 'fill', + mapData[value]?.fillColor, + ); + }); } } diff --git a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/index.js b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/index.js index 6303caec08db7..8fc0d9aad68a8 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/index.js +++ b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/index.js @@ -45,7 +45,7 @@ const metadata = new ChartMetadata({ ], thumbnail, useLegacyApi: true, - behaviors: [Behavior.DRILL_TO_DETAIL], + behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], }); export default class WorldMapChartPlugin extends ChartPlugin { diff --git a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/transformProps.js b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/transformProps.js index 6348874eaba03..5f8c718449174 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/transformProps.js +++ b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/transformProps.js @@ -19,9 +19,17 @@ import { rgb } from 'd3-color'; export default function transformProps(chartProps) { - const { width, height, formData, queriesData, hooks, inContextMenu } = - chartProps; - const { onContextMenu } = hooks; + const { + width, + height, + formData, + queriesData, + hooks, + inContextMenu, + filterState, + emitCrossFilters, + } = chartProps; + const { onContextMenu, setDataMask } = hooks; const { countryFieldtype, entity, @@ -49,6 +57,9 @@ export default function transformProps(chartProps) { colorScheme, sliceId, onContextMenu, + setDataMask, inContextMenu, + filterState, + emitCrossFilters, }; } 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 4f83d1bcaf578..9c8bc2fb7c8dc 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/EchartsGraph.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/EchartsGraph.tsx @@ -16,64 +16,147 @@ * specific language governing permissions and limitations * under the License. */ -import React from 'react'; -import { BinaryQueryObjectFilterClause } from '@superset-ui/core'; +import React, { useMemo } from 'react'; import { EventHandlers } from '../types'; import Echart from '../components/Echart'; import { GraphChartTransformedProps } from './types'; +type DataRow = { + source?: string; + target?: string; + id?: string; + col: string; + name: string; +}; +type Data = DataRow[]; type Event = { name: string; event: { stop: () => void; event: PointerEvent }; - data: { source: string; target: string }; + data: DataRow; + dataType: 'node' | 'edge'; }; -export default function EchartsGraph({ - height, - width, - echartOptions, - formData, - onContextMenu, - refs, -}: 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: BinaryQueryObjectFilterClause[] = [ - { - col: formData.source, - op: '==', - val: sourceValue, - formattedVal: sourceValue, - }, - { - col: formData.target, - op: '==', - val: targetValue, - formattedVal: targetValue, - }, - ]; - onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters); - } - } - }, - }; - return ( - - ); -} +const EchartsGraph = React.memo( + ({ + height, + width, + echartOptions, + formData, + onContextMenu, + setDataMask, + filterState, + refs, + emitCrossFilters, + }: GraphChartTransformedProps) => { + 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, + }, + { + 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 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; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/index.ts index 621e063d84a39..b3bc239d2ba82 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/index.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core'; +import { Behavior, ChartMetadata, ChartPlugin, t } from '@superset-ui/core'; import controlPanel from './controlPanel'; import transformProps from './transformProps'; import thumbnail from './images/thumbnail.png'; @@ -48,7 +48,7 @@ export default class EchartsGraphChartPlugin extends ChartPlugin { t('Transformable'), ], thumbnail, - behaviors: [Behavior.DRILL_TO_DETAIL], + behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], }), transformProps, }); 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 593b02907dfbb..a2c01b4839576 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/transformProps.ts @@ -162,8 +162,16 @@ function getCategoryName(columnName: string, name?: DataRecordValue) { export default function transformProps( chartProps: EchartsGraphChartProps, ): GraphChartTransformedProps { - const { width, height, formData, queriesData, hooks, inContextMenu } = - chartProps; + const { + width, + height, + formData, + queriesData, + hooks, + inContextMenu, + filterState, + emitCrossFilters, + } = chartProps; const data: DataRecord[] = queriesData[0].data || []; const { @@ -204,12 +212,13 @@ export default function transformProps( * Get the node id of an existing node, * or create a new node if it doesn't exist. */ - function getOrCreateNode(name: string, category?: string) { + function getOrCreateNode(name: string, col: string, category?: string) { if (!(name in nodes)) { nodes[name] = echartNodes.length; echartNodes.push({ id: String(nodes[name]), name, + col, value: 0, category, select: DEFAULT_GRAPH_SERIES_OPTION.select, @@ -244,8 +253,8 @@ export default function transformProps( const targetCategoryName = targetCategory ? getCategoryName(targetCategory, link[targetCategory]) : undefined; - const sourceNode = getOrCreateNode(sourceName, sourceCategoryName); - const targetNode = getOrCreateNode(targetName, targetCategoryName); + const sourceNode = getOrCreateNode(sourceName, source, sourceCategoryName); + const targetNode = getOrCreateNode(targetName, target, targetCategoryName); sourceNode.value += value; targetNode.value += value; @@ -321,7 +330,7 @@ export default function transformProps( series, }; - const { onContextMenu } = hooks; + const { onContextMenu, setDataMask } = hooks; return { width, @@ -329,6 +338,9 @@ export default function transformProps( formData, echartOptions, onContextMenu, + setDataMask, + filterState, refs, + emitCrossFilters, }; } 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 95dc386eb238a..4a45f79c41575 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/types.ts @@ -55,6 +55,7 @@ export type EchartsGraphFormData = QueryFormData & export type EChartGraphNode = Omit & { value: number; + col: string; tooltip?: Pick; }; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/types.ts index c24e2d84f46d1..f56090b0c9aad 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/types.ts @@ -21,6 +21,7 @@ import { BinaryQueryObjectFilterClause, ChartDataResponseResult, ChartProps, + FilterState, HandlerFunction, PlainObject, QueryFormColumn, @@ -125,8 +126,11 @@ export interface BaseTransformedProps { clientY: number, filters?: BinaryQueryObjectFilterClause[], ) => void; + setDataMask?: SetDataMaskHook; + filterState?: FilterState; refs: Refs; width: number; + emitCrossFilters?: boolean; } export type CrossFilterTransformedProps = { @@ -144,6 +148,7 @@ export type ContextMenuTransformedProps = { clientY: number, filters?: BinaryQueryObjectFilterClause[], ) => void; + setDataMask?: SetDataMaskHook; }; export interface TitleFormData { diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Graph/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Graph/transformProps.test.ts index 61adda8a98c74..100e39a27e82a 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Graph/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Graph/transformProps.test.ts @@ -71,6 +71,7 @@ describe('EchartsGraph transformProps', () => { expect.objectContaining({ data: [ { + col: 'source_column', category: undefined, id: '0', label: { show: true }, @@ -88,6 +89,7 @@ describe('EchartsGraph transformProps', () => { value: 6, }, { + col: 'target_column', category: undefined, id: '1', label: { show: true }, @@ -105,6 +107,7 @@ describe('EchartsGraph transformProps', () => { value: 6, }, { + col: 'source_column', category: undefined, id: '2', label: { show: true }, @@ -122,6 +125,7 @@ describe('EchartsGraph transformProps', () => { value: 5, }, { + col: 'target_column', category: undefined, id: '3', label: { show: true }, @@ -229,6 +233,7 @@ describe('EchartsGraph transformProps', () => { data: [ { id: '0', + col: 'source_column', name: 'source_value', value: 11, symbolSize: 10, @@ -243,6 +248,7 @@ describe('EchartsGraph transformProps', () => { }, { id: '1', + col: 'target_column', name: 'target_value', value: 11, symbolSize: 10,