From 9fbfd1c1d883f983ef96b8812297721e2a1a9695 Mon Sep 17 00:00:00 2001 From: Kamil Gabryjelski Date: Wed, 29 Mar 2023 15:01:51 +0200 Subject: [PATCH] feat: Implement context menu for drill by (#23454) --- .../superset-ui-core/src/chart/types/Base.ts | 6 + .../src/WorldMap.js | 9 + .../src/index.js | 6 +- .../plugin-chart-echarts/src/BoxPlot/index.ts | 8 +- .../plugin-chart-echarts/src/Funnel/index.ts | 6 +- .../plugin-chart-echarts/src/Gauge/index.ts | 6 +- .../src/Graph/EchartsGraph.tsx | 11 +- .../plugin-chart-echarts/src/Graph/index.ts | 6 +- .../EchartsMixedTimeseries.tsx | 68 +++--- .../src/MixedTimeseries/index.ts | 6 +- .../plugin-chart-echarts/src/Pie/index.ts | 6 +- .../plugin-chart-echarts/src/Radar/index.ts | 6 +- .../src/Sunburst/EchartsSunburst.tsx | 10 +- .../src/Sunburst/index.ts | 8 +- .../src/Timeseries/Area/index.ts | 6 +- .../src/Timeseries/EchartsTimeseries.tsx | 60 ++--- .../src/Timeseries/Regular/Bar/index.ts | 6 +- .../src/Timeseries/Regular/Line/index.ts | 6 +- .../src/Timeseries/Regular/Scatter/index.ts | 6 +- .../Timeseries/Regular/SmoothLine/index.ts | 6 +- .../src/Timeseries/Step/index.ts | 6 +- .../src/Timeseries/index.ts | 6 +- .../src/Treemap/EchartsTreemap.tsx | 16 +- .../plugin-chart-echarts/src/Treemap/index.ts | 6 +- .../src/utils/eventHandlers.ts | 7 +- .../src/PivotTableChart.tsx | 19 +- .../src/plugin/index.ts | 10 +- .../plugin-chart-table/src/TableChart.tsx | 12 + .../plugins/plugin-chart-table/src/index.ts | 8 +- .../src/components/Chart/ChartContextMenu.tsx | 30 ++- .../Chart/DrillBy/DrillByMenuItems.test.tsx | 190 +++++++++++++++ .../Chart/DrillBy/DrillByMenuItems.tsx | 221 ++++++++++++++++++ .../DrillDetail/DrillDetailMenuItems.tsx | 34 +-- .../Chart/MenuItemWithTruncation.tsx | 58 +++++ .../src/components/Chart/utils.test.ts | 3 + .../src/components/Chart/utils.ts | 38 ++- .../FiltersConfigForm/ColumnSelect.tsx | 2 +- .../FiltersConfigForm/DatasetSelect.tsx | 3 +- .../FiltersConfigForm/FiltersConfigForm.tsx | 2 +- .../FiltersConfigForm/utils.ts | 16 +- .../dashboard/containers/DashboardPage.tsx | 14 +- superset-frontend/src/dashboard/styles.ts | 7 + .../src/utils/cachedSupersetGet.ts | 29 +++ 43 files changed, 860 insertions(+), 129 deletions(-) create mode 100644 superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.test.tsx create mode 100644 superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx create mode 100644 superset-frontend/src/components/Chart/MenuItemWithTruncation.tsx create mode 100644 superset-frontend/src/utils/cachedSupersetGet.ts 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 7fa2ba1f77..418d6a36fc 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 @@ -31,6 +31,7 @@ export enum Behavior { * when dimensions are right-clicked on. */ DRILL_TO_DETAIL = 'DRILL_TO_DETAIL', + DRILL_BY = 'DRILL_BY', } export interface ContextMenuFilters { @@ -39,6 +40,11 @@ export interface ContextMenuFilters { isCurrentValueSelected?: boolean; }; drillToDetail?: BinaryQueryObjectFilterClause[]; + drillBy?: { + filters: BinaryQueryObjectFilterClause[]; + groupbyFieldName: string; + adhocFilterFieldName?: string; + }; } export enum AppSection { 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 abb9e19b9f..c8aa2cdc2a 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 @@ -172,6 +172,7 @@ function WorldMap(element, props) { const val = countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.country; let drillToDetailFilters; + let drillByFilters; if (val) { drillToDetailFilters = [ { @@ -181,10 +182,18 @@ function WorldMap(element, props) { formattedVal: val, }, ]; + drillByFilters = [ + { + col: entity, + op: '==', + val, + }, + ]; } onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { drillToDetail: drillToDetailFilters, crossFilter: getCrossFilterDataMask(source), + drillBy: { filters: drillByFilters, groupbyFieldName: 'entity' }, }); }; 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 8fc0d9aad6..95eb6b59fd 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,11 @@ const metadata = new ChartMetadata({ ], thumbnail, useLegacyApi: true, - behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], + behaviors: [ + Behavior.INTERACTIVE_CHART, + Behavior.DRILL_TO_DETAIL, + Behavior.DRILL_BY, + ], }); export default class WorldMapChartPlugin extends ChartPlugin { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/index.ts index 3c8620e9d8..15d1a4b49e 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/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 buildQuery from './buildQuery'; import controlPanel from './controlPanel'; import transformProps from './transformProps'; @@ -44,7 +44,11 @@ export default class EchartsBoxPlotChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('./EchartsBoxPlot'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], + behaviors: [ + Behavior.INTERACTIVE_CHART, + Behavior.DRILL_TO_DETAIL, + Behavior.DRILL_BY, + ], category: t('Distribution'), credits: ['https://echarts.apache.org'], description: t( diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/index.ts index 742b92151a..39161c8ad6 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/index.ts @@ -44,7 +44,11 @@ export default class EchartsFunnelChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('./EchartsFunnel'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], + behaviors: [ + Behavior.INTERACTIVE_CHART, + Behavior.DRILL_TO_DETAIL, + Behavior.DRILL_BY, + ], category: t('KPI'), credits: ['https://echarts.apache.org'], description: t( diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/index.ts index 15de1cd9be..e3036dfbbd 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/index.ts @@ -35,7 +35,11 @@ export default class EchartsGaugeChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('./EchartsGauge'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], + behaviors: [ + Behavior.INTERACTIVE_CHART, + Behavior.DRILL_TO_DETAIL, + Behavior.DRILL_BY, + ], category: t('KPI'), credits: ['https://echarts.apache.org'], description: t( 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 47bcb00a3d..8e90bf1791 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/EchartsGraph.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/EchartsGraph.tsx @@ -137,11 +137,16 @@ export default function EchartsGraph({ const data = (echartOptions as any).series[0].data as Data; const drillToDetailFilters = e.dataType === 'node' ? handleNodeClick(data) : handleEdgeClick(data); + const node = data.find(item => item.id === e.data.id); + onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { drillToDetail: drillToDetailFilters, - crossFilter: getCrossFilterDataMask( - data.find(item => item.id === e.data.id), - ), + crossFilter: getCrossFilterDataMask(node), + drillBy: node && { + filters: [{ col: node.col, op: '==', val: node.name }], + groupbyFieldName: + node.col === formData.source ? 'source' : 'target', + }, }); } }, 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 b3bc239d2b..a0275c5bbd 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/index.ts @@ -48,7 +48,11 @@ export default class EchartsGraphChartPlugin extends ChartPlugin { t('Transformable'), ], thumbnail, - behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], + behaviors: [ + Behavior.INTERACTIVE_CHART, + Behavior.DRILL_TO_DETAIL, + Behavior.DRILL_BY, + ], }), transformProps, }); 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 0018c0e876..02583d4162 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/EchartsMixedTimeseries.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/EchartsMixedTimeseries.tsx @@ -131,42 +131,52 @@ export default function EchartsMixedTimeseries({ const { data, seriesName, seriesIndex } = eventParams; const pointerEvent = eventParams.event.event; const drillToDetailFilters: BinaryQueryObjectFilterClause[] = []; - if (data) { - const values = [ - ...(eventParams.name ? [eventParams.name] : []), - ...(isFirstQuery(seriesIndex) ? labelMap : labelMapB)[ - eventParams.seriesName - ], - ]; - if (xAxis.type === AxisType.time) { - drillToDetailFilters.push({ - col: - xAxis.label === DTTM_ALIAS - ? formData.granularitySqla - : xAxis.label, - grain: formData.timeGrainSqla, - op: '==', - val: data[0], - formattedVal: xValueFormatter(data[0]), - }); - } - [ - ...(xAxis.type === AxisType.category ? [xAxis.label] : []), - ...(isFirstQuery(seriesIndex) - ? formData.groupby - : formData.groupbyB), - ].forEach((dimension, i) => - drillToDetailFilters.push({ + const drillByFilters: BinaryQueryObjectFilterClause[] = []; + const isFirst = isFirstQuery(seriesIndex); + const values = [ + ...(eventParams.name ? [eventParams.name] : []), + ...(isFirst ? labelMap : labelMapB)[eventParams.seriesName], + ]; + if (data && xAxis.type === AxisType.time) { + drillToDetailFilters.push({ + col: + xAxis.label === DTTM_ALIAS + ? formData.granularitySqla + : xAxis.label, + grain: formData.timeGrainSqla, + op: '==', + val: data[0], + formattedVal: xValueFormatter(data[0]), + }); + } + [ + ...(data && xAxis.type === AxisType.category ? [xAxis.label] : []), + ...(isFirst ? formData.groupby : formData.groupbyB), + ].forEach((dimension, i) => + drillToDetailFilters.push({ + col: dimension, + op: '==', + val: values[i], + formattedVal: String(values[i]), + }), + ); + + [...(isFirst ? formData.groupby : formData.groupbyB)].forEach( + (dimension, i) => + drillByFilters.push({ col: dimension, op: '==', val: values[i], - formattedVal: String(values[i]), }), - ); - } + ); onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { drillToDetail: drillToDetailFilters, crossFilter: getCrossFilterDataMask(seriesName, seriesIndex), + drillBy: { + filters: drillByFilters, + groupbyFieldName: isFirst ? 'groupby' : 'groupby_b', + adhocFilterFieldName: isFirst ? 'adhoc_filters' : 'adhoc_filters_b', + }, }); } }, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/index.ts index eb1bef3c9b..66e471235f 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/index.ts @@ -54,7 +54,11 @@ export default class EchartsTimeseriesChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('./EchartsMixedTimeseries'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], + behaviors: [ + Behavior.INTERACTIVE_CHART, + Behavior.DRILL_TO_DETAIL, + Behavior.DRILL_BY, + ], category: t('Evolution'), credits: ['https://echarts.apache.org'], description: hasGenericChartAxes diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/index.ts index 9f5d61474a..500b40f59f 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/index.ts @@ -47,7 +47,11 @@ export default class EchartsPieChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('./EchartsPie'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], + behaviors: [ + Behavior.INTERACTIVE_CHART, + Behavior.DRILL_TO_DETAIL, + Behavior.DRILL_BY, + ], category: t('Part of a Whole'), credits: ['https://echarts.apache.org'], description: diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/index.ts index 69f1ee8dac..a544e28b26 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/index.ts @@ -46,7 +46,11 @@ export default class EchartsRadarChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('./EchartsRadar'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], + behaviors: [ + Behavior.INTERACTIVE_CHART, + Behavior.DRILL_TO_DETAIL, + Behavior.DRILL_BY, + ], category: t('Ranking'), credits: ['https://echarts.apache.org'], description: t( diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/EchartsSunburst.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/EchartsSunburst.tsx index 5552f7d0ee..7f40574665 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/EchartsSunburst.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/EchartsSunburst.tsx @@ -40,7 +40,6 @@ export default function EchartsSunburst(props: SunburstTransformedProps) { refs, emitCrossFilters, } = props; - const { columns } = formData; const getCrossFilterDataMask = useCallback( @@ -62,7 +61,7 @@ export default function EchartsSunburst(props: SunburstTransformedProps) { filters: values.length === 0 || !columns ? [] - : columns.map((col, idx) => { + : columns.slice(0, treePath.length).map((col, idx) => { const val = labels.map(v => v[idx]); if (val === null || val === undefined) return { @@ -111,6 +110,7 @@ export default function EchartsSunburst(props: SunburstTransformedProps) { const treePath = extractTreePathInfo(eventParams.treePathInfo); const pointerEvent = eventParams.event.event; const drillToDetailFilters: BinaryQueryObjectFilterClause[] = []; + const drillByFilters: BinaryQueryObjectFilterClause[] = []; if (columns?.length) { treePath.forEach((path, i) => drillToDetailFilters.push({ @@ -120,10 +120,16 @@ export default function EchartsSunburst(props: SunburstTransformedProps) { formattedVal: path, }), ); + drillByFilters.push({ + col: columns[treePath.length - 1], + op: '==', + val: treePath[treePath.length - 1], + }); } onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { drillToDetail: drillToDetailFilters, crossFilter: getCrossFilterDataMask(treePathInfo), + drillBy: { filters: drillByFilters, groupbyFieldName: 'columns' }, }); } }, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/index.ts index 5ca8d5a8fc..e6bb0f8b39 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/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 transformProps from './transformProps'; import thumbnail from './images/thumbnail.png'; import controlPanel from './controlPanel'; @@ -31,7 +31,11 @@ export default class EchartsSunburstChartPlugin extends ChartPlugin { controlPanel, loadChart: () => import('./EchartsSunburst'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], + behaviors: [ + Behavior.INTERACTIVE_CHART, + Behavior.DRILL_TO_DETAIL, + Behavior.DRILL_BY, + ], category: t('Part of a Whole'), credits: ['https://echarts.apache.org'], description: t( diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/index.ts index b560cf0b4f..733db0a9cb 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/index.ts @@ -50,7 +50,11 @@ export default class EchartsAreaChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('../EchartsTimeseries'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], + behaviors: [ + Behavior.INTERACTIVE_CHART, + Behavior.DRILL_TO_DETAIL, + Behavior.DRILL_BY, + ], category: t('Evolution'), credits: ['https://echarts.apache.org'], description: hasGenericChartAxes 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 db4f730aff..516255de0c 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx @@ -201,40 +201,48 @@ export default function EchartsTimeseries({ eventParams.event.stop(); const { data, seriesName } = eventParams; const drillToDetailFilters: BinaryQueryObjectFilterClause[] = []; + const drillByFilters: BinaryQueryObjectFilterClause[] = []; const pointerEvent = eventParams.event.event; const values = [ ...(eventParams.name ? [eventParams.name] : []), - ...labelMap[eventParams.seriesName], + ...labelMap[seriesName], ]; - if (data) { - if (xAxis.type === AxisType.time) { - drillToDetailFilters.push({ - col: - // if the xAxis is '__timestamp', granularity_sqla will be the column of filter - xAxis.label === DTTM_ALIAS - ? formData.granularitySqla - : xAxis.label, - grain: formData.timeGrainSqla, - op: '==', - val: data[0], - formattedVal: xValueFormatter(data[0]), - }); - } - [ - ...(xAxis.type === AxisType.category ? [xAxis.label] : []), - ...formData.groupby, - ].forEach((dimension, i) => - drillToDetailFilters.push({ - col: dimension, - op: '==', - val: values[i], - formattedVal: String(values[i]), - }), - ); + if (data && xAxis.type === AxisType.time) { + drillToDetailFilters.push({ + col: + // if the xAxis is '__timestamp', granularity_sqla will be the column of filter + xAxis.label === DTTM_ALIAS + ? formData.granularitySqla + : xAxis.label, + grain: formData.timeGrainSqla, + op: '==', + val: data[0], + formattedVal: xValueFormatter(data[0]), + }); } + [ + ...(xAxis.type === AxisType.category && data ? [xAxis.label] : []), + ...formData.groupby, + ].forEach((dimension, i) => + drillToDetailFilters.push({ + col: dimension, + op: '==', + val: values[i], + formattedVal: String(values[i]), + }), + ); + formData.groupby.forEach((dimension, i) => { + drillByFilters.push({ + col: dimension, + op: '==', + val: labelMap[seriesName][i], + }); + }); + onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { drillToDetail: drillToDetailFilters, crossFilter: getCrossFilterDataMask(seriesName), + drillBy: { filters: drillByFilters, groupbyFieldName: 'groupby' }, }); } }, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/index.ts index de0050edaa..81f7c15ece 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/index.ts @@ -56,7 +56,11 @@ export default class EchartsTimeseriesBarChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('../../EchartsTimeseries'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], + behaviors: [ + Behavior.INTERACTIVE_CHART, + Behavior.DRILL_TO_DETAIL, + Behavior.DRILL_BY, + ], category: t('Evolution'), credits: ['https://echarts.apache.org'], description: hasGenericChartAxes diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/index.ts index b6f7f1fceb..cf96910555 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/index.ts @@ -55,7 +55,11 @@ export default class EchartsTimeseriesLineChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('../../EchartsTimeseries'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], + behaviors: [ + Behavior.INTERACTIVE_CHART, + Behavior.DRILL_TO_DETAIL, + Behavior.DRILL_BY, + ], category: t('Evolution'), credits: ['https://echarts.apache.org'], description: hasGenericChartAxes diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/index.ts index 489983cfa6..7fa77763bc 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/index.ts @@ -54,7 +54,11 @@ export default class EchartsTimeseriesScatterChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('../../EchartsTimeseries'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], + behaviors: [ + Behavior.INTERACTIVE_CHART, + Behavior.DRILL_TO_DETAIL, + Behavior.DRILL_BY, + ], category: t('Evolution'), credits: ['https://echarts.apache.org'], description: hasGenericChartAxes diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/SmoothLine/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/SmoothLine/index.ts index ae6dc7ad30..9608407d02 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/SmoothLine/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/SmoothLine/index.ts @@ -54,7 +54,11 @@ export default class EchartsTimeseriesSmoothLineChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('../../EchartsTimeseries'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], + behaviors: [ + Behavior.INTERACTIVE_CHART, + Behavior.DRILL_TO_DETAIL, + Behavior.DRILL_BY, + ], category: t('Evolution'), credits: ['https://echarts.apache.org'], description: hasGenericChartAxes diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Step/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Step/index.ts index 3fdeb5aa83..93d439851d 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Step/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Step/index.ts @@ -45,7 +45,11 @@ export default class EchartsTimeseriesStepChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('../EchartsTimeseries'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], + behaviors: [ + Behavior.INTERACTIVE_CHART, + Behavior.DRILL_TO_DETAIL, + Behavior.DRILL_BY, + ], category: t('Evolution'), credits: ['https://echarts.apache.org'], description: hasGenericChartAxes diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/index.ts index c8210cd981..4cf4337fdc 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/index.ts @@ -44,7 +44,11 @@ export default class EchartsTimeseriesChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('./EchartsTimeseries'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], + behaviors: [ + Behavior.INTERACTIVE_CHART, + Behavior.DRILL_TO_DETAIL, + Behavior.DRILL_BY, + ], category: t('Evolution'), credits: ['https://echarts.apache.org'], description: hasGenericChartAxes 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 1ee793cfc7..f9363bd4b6 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/EchartsTreemap.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/EchartsTreemap.tsx @@ -116,17 +116,25 @@ export default function EchartsTreemap({ if (treePath.length > 0) { const pointerEvent = eventParams.event.event; const drillToDetailFilters: BinaryQueryObjectFilterClause[] = []; - treePath.forEach((path, i) => + const drillByFilters: BinaryQueryObjectFilterClause[] = []; + treePath.forEach((path, i) => { + const val = path === 'null' ? NULL_STRING : path; drillToDetailFilters.push({ col: groupby[i], op: '==', - val: path === 'null' ? NULL_STRING : path, + val, formattedVal: path, - }), - ); + }); + drillByFilters.push({ + col: groupby[i], + op: '==', + val, + }); + }); onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { drillToDetail: drillToDetailFilters, crossFilter: getCrossFilterDataMask(data, treePathInfo), + drillBy: { filters: drillByFilters, groupbyFieldName: 'groupby' }, }); } } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/index.ts index ec6d2d3823..9e91965954 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/index.ts @@ -46,7 +46,11 @@ export default class EchartsTreemapChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('./EchartsTreemap'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], + behaviors: [ + Behavior.INTERACTIVE_CHART, + Behavior.DRILL_TO_DETAIL, + Behavior.DRILL_BY, + ], category: t('Part of a Whole'), credits: ['https://echarts.apache.org'], description: t( 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 6dafe7ba60..7651bd83bd 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/eventHandlers.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/eventHandlers.ts @@ -111,11 +111,11 @@ export const contextMenuEventHandler = if (onContextMenu) { e.event.stop(); const pointerEvent = e.event.event; - const drillToDetailFilters: BinaryQueryObjectFilterClause[] = []; + const drillFilters: BinaryQueryObjectFilterClause[] = []; if (groupby.length > 0) { const values = labelMap[e.name]; groupby.forEach((dimension, i) => - drillToDetailFilters.push({ + drillFilters.push({ col: dimension, op: '==', val: values[i], @@ -124,8 +124,9 @@ export const contextMenuEventHandler = ); } onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { - drillToDetail: drillToDetailFilters, + drillToDetail: drillFilters, crossFilter: getCrossFilterDataMask(e.name), + drillBy: { filters: drillFilters, groupbyFieldName: 'groupby' }, }); } }; 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 3bbf8af9a0..8ec1cb9ad1 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx @@ -478,10 +478,27 @@ export default function PivotTableChart(props: PivotTableProps) { onContextMenu(e.clientX, e.clientY, { drillToDetail: drillToDetailFilters, crossFilter: getCrossFilterDataMask(dataPoint), + drillBy: dataPoint && { + filters: [ + { + col: Object.keys(dataPoint)[0], + op: '==', + val: Object.values(dataPoint)[0], + }, + ], + groupbyFieldName: rowKey ? 'groupbyRows' : 'groupbyColumns', + }, }); } }, - [cols, dateFormatters, onContextMenu, rows, timeGrainSqla], + [ + cols, + dateFormatters, + getCrossFilterDataMask, + onContextMenu, + rows, + timeGrainSqla, + ], ); return ( diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/index.ts b/superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/index.ts index b2d355f0ff..6963c67f0d 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/index.ts +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/index.ts @@ -17,12 +17,12 @@ * under the License. */ import { - t, + Behavior, ChartMetadata, ChartPlugin, - Behavior, ChartProps, QueryFormData, + t, } from '@superset-ui/core'; import buildQuery from './buildQuery'; import controlPanel from './controlPanel'; @@ -47,7 +47,11 @@ export default class PivotTableChartPlugin extends ChartPlugin< */ constructor() { const metadata = new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], + behaviors: [ + Behavior.INTERACTIVE_CHART, + Behavior.DRILL_TO_DETAIL, + Behavior.DRILL_BY, + ], category: t('Table'), description: t( 'Used to summarize a set of data by grouping together multiple statistics along two axes. Examples: Sales numbers by region and month, tasks by status and assignee, active users by age and location. Not the most visually stunning visualization, but highly informative and versatile.', diff --git a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx index ac02a10137..fd5ce8aa3e 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx @@ -391,6 +391,18 @@ export default function TableChart( crossFilter: cellPoint.isMetric ? undefined : getCrossFilterDataMask(cellPoint.key, cellPoint.value), + drillBy: cellPoint.isMetric + ? undefined + : { + filters: [ + { + col: cellPoint.key, + op: '==', + val: cellPoint.value as string | number | boolean, + }, + ], + groupbyFieldName: 'groupby', + }, }); } : undefined; diff --git a/superset-frontend/plugins/plugin-chart-table/src/index.ts b/superset-frontend/plugins/plugin-chart-table/src/index.ts index 4e862fc5a5..f6ce3b484e 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/index.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/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 transformProps from './transformProps'; import thumbnail from './images/thumbnail.png'; import example1 from './images/Table.jpg'; @@ -31,7 +31,11 @@ export { default as __hack__ } from './types'; export * from './types'; const metadata = new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], + behaviors: [ + Behavior.INTERACTIVE_CHART, + Behavior.DRILL_TO_DETAIL, + Behavior.DRILL_BY, + ], category: t('Table'), canBeAnnotationTypes: ['EVENT', 'INTERVAL'], description: t( diff --git a/superset-frontend/src/components/Chart/ChartContextMenu.tsx b/superset-frontend/src/components/Chart/ChartContextMenu.tsx index 0f0082aee9..3ddbc91b22 100644 --- a/superset-frontend/src/components/Chart/ChartContextMenu.tsx +++ b/superset-frontend/src/components/Chart/ChartContextMenu.tsx @@ -44,6 +44,7 @@ import { DrillDetailMenuItems } from './DrillDetail'; import { getMenuAdjustedY } from './utils'; import { updateDataMask } from '../../dataMask/actions'; import { MenuItemTooltip } from './DisabledMenuItemTooltip'; +import { DrillByMenuItems } from './DrillBy/DrillByMenuItems'; export interface ChartContextMenuProps { id: number; @@ -84,17 +85,25 @@ const ChartContextMenu = ( const showDrillToDetail = isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL) && canExplore; + const showDrillBy = isFeatureEnabled(FeatureFlag.DRILL_BY) && canExplore; + + const showCrossFilters = isFeatureEnabled( + FeatureFlag.DASHBOARD_CROSS_FILTERS, + ); const isCrossFilteringSupportedByChart = getChartMetadataRegistry() .get(formData.viz_type) ?.behaviors?.includes(Behavior.INTERACTIVE_CHART); let itemsCount = 0; - if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) { + if (showCrossFilters) { itemsCount += 1; } if (showDrillToDetail) { itemsCount += 2; // Drill to detail always has 2 top-level menu items } + if (showDrillBy) { + itemsCount += 1; + } if (itemsCount === 0) { itemsCount = 1; // "No actions" appears if no actions in menu } @@ -180,6 +189,25 @@ const ChartContextMenu = ( isContextMenu contextMenuY={clientY} onSelection={onSelection} + submenuIndex={showCrossFilters ? 2 : 1} + />, + ); + } + if (showDrillBy) { + let submenuIndex = 0; + if (showCrossFilters) { + submenuIndex += 1; + } + if (showDrillToDetail) { + submenuIndex += 2; + } + menuItems.push( + , ); } diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.test.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.test.tsx new file mode 100644 index 0000000000..e7db5efe8e --- /dev/null +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.test.tsx @@ -0,0 +1,190 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { + Behavior, + ChartMetadata, + getChartMetadataRegistry, +} from '@superset-ui/core'; +import fetchMock from 'fetch-mock'; +import { render, screen, within, waitFor } from 'spec/helpers/testing-library'; +import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries'; +import { Menu } from 'src/components/Menu'; +import { supersetGetCache } from 'src/utils/cachedSupersetGet'; +import { DrillByMenuItems, DrillByMenuItemsProps } from './DrillByMenuItems'; + +/* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect*"] }] */ + +const datasetEndpointMatcher = 'glob:*/api/v1/dataset/7'; +const { form_data: defaultFormData } = chartQueries[sliceId]; + +const defaultColumns = [ + { column_name: 'col1', groupby: true }, + { column_name: 'col2', groupby: true }, + { column_name: 'col3', groupby: true }, + { column_name: 'col4', groupby: true }, + { column_name: 'col5', groupby: true }, + { column_name: 'col6', groupby: true }, + { column_name: 'col7', groupby: true }, + { column_name: 'col8', groupby: true }, + { column_name: 'col9', groupby: true }, + { column_name: 'col10', groupby: true }, + { column_name: 'col11', groupby: true }, +]; + +const defaultFilters = [ + { + col: 'filter_col', + op: '==' as const, + val: 'val', + }, +]; + +const renderMenu = ({ + formData = defaultFormData, + filters = defaultFilters, +}: Partial) => + render( + + + , + { useRouter: true, useRedux: true }, + ); + +const expectDrillByDisabled = async (tooltipContent: string) => { + const drillByMenuItem = screen.getByRole('menuitem', { + name: 'Drill by', + }); + + expect(drillByMenuItem).toBeVisible(); + expect(drillByMenuItem).toHaveAttribute('aria-disabled', 'true'); + const tooltipTrigger = within(drillByMenuItem).getByTestId('tooltip-trigger'); + userEvent.hover(tooltipTrigger as HTMLElement); + const tooltip = await screen.findByRole('tooltip', { name: tooltipContent }); + + expect(tooltip).toBeInTheDocument(); +}; + +const expectDrillByEnabled = async () => { + const drillByMenuItem = screen.getByRole('menuitem', { + name: 'Drill by', + }); + expect(drillByMenuItem).toBeInTheDocument(); + await waitFor(() => + expect(drillByMenuItem).not.toHaveAttribute('aria-disabled'), + ); + const tooltipTrigger = + within(drillByMenuItem).queryByTestId('tooltip-trigger'); + expect(tooltipTrigger).not.toBeInTheDocument(); + + userEvent.hover( + within(drillByMenuItem).getByRole('button', { name: 'Drill by' }), + ); + expect(await screen.findByTestId('drill-by-submenu')).toBeInTheDocument(); +}; + +getChartMetadataRegistry().registerValue( + 'pie', + new ChartMetadata({ + name: 'fake pie', + thumbnail: '.png', + useLegacyApi: false, + behaviors: [Behavior.DRILL_BY], + }), +); + +describe('Drill by menu items', () => { + afterEach(() => { + supersetGetCache.clear(); + fetchMock.restore(); + }); + + test('render disabled menu item for unsupported chart', async () => { + renderMenu({ + formData: { ...defaultFormData, viz_type: 'unsupported_viz' }, + }); + await expectDrillByDisabled( + 'Drill by is not yet supported for this chart type', + ); + }); + + test('render disabled menu item for supported chart, no filters', async () => { + renderMenu({ filters: [] }); + await expectDrillByDisabled( + 'Drill by is not available for this data point', + ); + }); + + test('render disabled menu item for supported chart, no columns', async () => { + fetchMock.get(datasetEndpointMatcher, { result: { columns: [] } }); + renderMenu({}); + await waitFor(() => fetchMock.called(datasetEndpointMatcher)); + await expectDrillByDisabled('No dimensions available for drill by'); + }); + + test('render menu item with submenu without searchbox', async () => { + const slicedColumns = defaultColumns.slice(0, 9); + fetchMock.get(datasetEndpointMatcher, { + result: { columns: slicedColumns }, + }); + renderMenu({}); + await waitFor(() => fetchMock.called(datasetEndpointMatcher)); + await expectDrillByEnabled(); + slicedColumns.forEach(column => { + expect(screen.getByText(column.column_name)).toBeInTheDocument(); + }); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + }); + + test('render menu item with submenu and searchbox', async () => { + fetchMock.get(datasetEndpointMatcher, { + result: { columns: defaultColumns }, + }); + renderMenu({}); + await waitFor(() => fetchMock.called(datasetEndpointMatcher)); + await expectDrillByEnabled(); + defaultColumns.forEach(column => { + expect(screen.getByText(column.column_name)).toBeInTheDocument(); + }); + + const searchbox = screen.getByRole('textbox'); + expect(searchbox).toBeInTheDocument(); + + userEvent.type(searchbox, 'col1'); + + await screen.findByText('col1'); + + const expectedFilteredColumnNames = ['col1', 'col10', 'col11']; + + defaultColumns + .filter(col => !expectedFilteredColumnNames.includes(col.column_name)) + .forEach(col => { + expect(screen.queryByText(col.column_name)).not.toBeInTheDocument(); + }); + + expectedFilteredColumnNames.forEach(colName => { + expect(screen.getByText(colName)).toBeInTheDocument(); + }); + }); +}); diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx new file mode 100644 index 0000000000..1da50a412f --- /dev/null +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx @@ -0,0 +1,221 @@ +/** + * 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, { + ChangeEvent, + ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { Menu } from 'src/components/Menu'; +import { + BaseFormData, + Behavior, + BinaryQueryObjectFilterClause, + Column, + css, + ensureIsArray, + getChartMetadataRegistry, + t, + useTheme, +} from '@superset-ui/core'; +import Icons from 'src/components/Icons'; +import { Input } from 'src/components/Input'; +import { + cachedSupersetGet, + supersetGetCache, +} from 'src/utils/cachedSupersetGet'; +import { MenuItemTooltip } from '../DisabledMenuItemTooltip'; +import { getSubmenuYOffset } from '../utils'; +import { MenuItemWithTruncation } from '../MenuItemWithTruncation'; + +const MAX_SUBMENU_HEIGHT = 200; +const SHOW_COLUMNS_SEARCH_THRESHOLD = 10; +const SEARCH_INPUT_HEIGHT = 48; + +export interface DrillByMenuItemsProps { + filters?: BinaryQueryObjectFilterClause[]; + formData: BaseFormData & { [key: string]: any }; + contextMenuY?: number; + submenuIndex?: number; + groupbyFieldName?: string; +} +export const DrillByMenuItems = ({ + filters, + groupbyFieldName, + formData, + contextMenuY = 0, + submenuIndex = 0, + ...rest +}: DrillByMenuItemsProps) => { + const theme = useTheme(); + const [searchInput, setSearchInput] = useState(''); + const [columns, setColumns] = useState([]); + useEffect(() => { + // Input is displayed only when columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD + // Reset search input in case Input gets removed + setSearchInput(''); + }, [columns.length]); + + const hasDrillBy = ensureIsArray(filters).length && groupbyFieldName; + + const handlesDimensionContextMenu = useMemo( + () => + getChartMetadataRegistry() + .get(formData.viz_type) + ?.behaviors.find(behavior => behavior === Behavior.DRILL_BY), + [formData.viz_type], + ); + + useEffect(() => { + if (handlesDimensionContextMenu && hasDrillBy) { + const datasetId = formData.datasource.split('__')[0]; + cachedSupersetGet({ + endpoint: `/api/v1/dataset/${datasetId}`, + }) + .then(({ json: { result } }) => { + setColumns( + ensureIsArray(result.columns) + .filter(column => column.groupby) + .filter( + column => + !ensureIsArray(formData[groupbyFieldName]).includes( + column.column_name, + ), + ), + ); + }) + .catch(() => { + supersetGetCache.delete(`/api/v1/dataset/${datasetId}`); + }); + } + }, [formData, groupbyFieldName, handlesDimensionContextMenu, hasDrillBy]); + + const handleInput = useCallback((e: ChangeEvent) => { + e.stopPropagation(); + const input = e?.target?.value; + setSearchInput(input); + }, []); + + const filteredColumns = useMemo( + () => + columns.filter(column => + (column.verbose_name || column.column_name) + .toLowerCase() + .includes(searchInput.toLowerCase()), + ), + [columns, searchInput], + ); + + const submenuYOffset = useMemo( + () => + getSubmenuYOffset( + contextMenuY, + filteredColumns.length || 1, + submenuIndex, + MAX_SUBMENU_HEIGHT, + columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD + ? SEARCH_INPUT_HEIGHT + : 0, + ), + [contextMenuY, filteredColumns.length, submenuIndex, columns.length], + ); + + let tooltip: ReactNode; + + if (!handlesDimensionContextMenu) { + tooltip = t('Drill by is not yet supported for this chart type'); + } else if (!hasDrillBy) { + tooltip = t('Drill by is not available for this data point'); + } else if (columns.length === 0) { + tooltip = t('No dimensions available for drill by'); + } + + if (!handlesDimensionContextMenu || !hasDrillBy || columns.length === 0) { + return ( + +
+ {t('Drill by')} + +
+
+ ); + } + + return ( + +
+ {columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD && ( + + } + onChange={handleInput} + placeholder={t('Search columns')} + value={searchInput} + onClick={e => { + // prevent closing menu when clicking on input + e.nativeEvent.stopImmediatePropagation(); + }} + allowClear + css={css` + width: auto; + max-width: 100%; + margin: ${theme.gridUnit * 2}px ${theme.gridUnit * 3}px; + box-shadow: none; + `} + /> + )} + {filteredColumns.length ? ( +
+ {filteredColumns.map(column => ( + + {column.verbose_name || column.column_name} + + ))} +
+ ) : ( + + {t('No columns found')} + + )} +
+
+ ); +}; diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailMenuItems.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailMenuItems.tsx index b3daada2d4..98fe90eafa 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailMenuItems.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailMenuItems.tsx @@ -31,10 +31,10 @@ import { } from '@superset-ui/core'; import { Menu } from 'src/components/Menu'; import DrillDetailModal from './DrillDetailModal'; -import { getMenuAdjustedY, MENU_ITEM_HEIGHT } from '../utils'; +import { getSubmenuYOffset } from '../utils'; import { MenuItemTooltip } from '../DisabledMenuItemTooltip'; +import { MenuItemWithTruncation } from '../MenuItemWithTruncation'; -const MENU_PADDING = 4; const DRILL_TO_DETAIL_TEXT = t('Drill to detail by'); const DisabledMenuItem = ({ children, ...props }: { children: ReactNode }) => ( @@ -65,6 +65,7 @@ export type DrillDetailMenuItemsProps = { contextMenuY?: number; onSelection?: () => void; onClick?: (event: MouseEvent) => void; + submenuIndex?: number; }; const DrillDetailMenuItems = ({ @@ -75,6 +76,7 @@ const DrillDetailMenuItems = ({ contextMenuY = 0, onSelection = () => null, onClick = () => null, + submenuIndex = 0, ...props }: DrillDetailMenuItemsProps) => { const [modalFilters, setFilters] = useState( @@ -162,31 +164,35 @@ const DrillDetailMenuItems = ({ } // Ensure submenu doesn't appear offscreen - const submenuYOffset = useMemo(() => { - const itemsCount = filters.length > 1 ? filters.length + 1 : filters.length; - const submenuY = - contextMenuY + MENU_PADDING + MENU_ITEM_HEIGHT + MENU_PADDING; - - return getMenuAdjustedY(submenuY, itemsCount) - submenuY; - }, [contextMenuY, filters.length]); + const submenuYOffset = useMemo( + () => + getSubmenuYOffset( + contextMenuY, + filters.length > 1 ? filters.length + 1 : filters.length, + submenuIndex, + ), + [contextMenuY, filters.length, submenuIndex], + ); if (handlesDimensionContextMenu && !noAggregations && filters?.length) { drillToDetailByMenuItem = (
{filters.map((filter, i) => ( - {`${DRILL_TO_DETAIL_TEXT} `} {filter.formattedVal} - + ))} {filters.length > 1 && ( - {`${DRILL_TO_DETAIL_TEXT} `} - {t('all')} +
+ {`${DRILL_TO_DETAIL_TEXT} `} + {t('all')} +
)}
diff --git a/superset-frontend/src/components/Chart/MenuItemWithTruncation.tsx b/superset-frontend/src/components/Chart/MenuItemWithTruncation.tsx new file mode 100644 index 0000000000..24c58d64bc --- /dev/null +++ b/superset-frontend/src/components/Chart/MenuItemWithTruncation.tsx @@ -0,0 +1,58 @@ +/** + * 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, truncationCSS, useCSSTextTruncation } from '@superset-ui/core'; +import { Menu } from 'src/components/Menu'; +import { Tooltip } from 'src/components/Tooltip'; + +export type MenuItemWithTruncationProps = { + tooltipText: ReactNode; + children: ReactNode; + onClick?: () => void; +}; + +export const MenuItemWithTruncation = ({ + tooltipText, + children, + ...props +}: MenuItemWithTruncationProps) => { + const [itemRef, itemIsTruncated] = useCSSTextTruncation(); + + return ( + + +
+ {children} +
+
+
+ ); +}; diff --git a/superset-frontend/src/components/Chart/utils.test.ts b/superset-frontend/src/components/Chart/utils.test.ts index b8de155a2a..192905e69d 100644 --- a/superset-frontend/src/components/Chart/utils.test.ts +++ b/superset-frontend/src/components/Chart/utils.test.ts @@ -39,4 +39,7 @@ test('correctly positions at lower edge of screen', () => { expect(getMenuAdjustedY(425, 1)).toEqual(425); // No adjustment expect(getMenuAdjustedY(425, 2)).toEqual(404); // Adjustment expect(getMenuAdjustedY(425, 3)).toEqual(372); // Adjustment + + expect(getMenuAdjustedY(425, 8, 200)).toEqual(268); + expect(getMenuAdjustedY(425, 8, 200, 48)).toEqual(220); }); diff --git a/superset-frontend/src/components/Chart/utils.ts b/superset-frontend/src/components/Chart/utils.ts index 54fc5e8926..6dd089f8a1 100644 --- a/superset-frontend/src/components/Chart/utils.ts +++ b/superset-frontend/src/components/Chart/utils.ts @@ -18,6 +18,7 @@ */ export const MENU_ITEM_HEIGHT = 32; +const MENU_PADDING = 4; const MENU_VERTICAL_SPACING = 32; /** @@ -27,14 +28,45 @@ const MENU_VERTICAL_SPACING = 32; * @param clientY The original Y-offset * @param itemsCount The number of menu items */ -export function getMenuAdjustedY(clientY: number, itemsCount: number) { +export const getMenuAdjustedY = ( + clientY: number, + itemsCount: number, + maxItemsContainerHeight = Number.MAX_SAFE_INTEGER, + additionalItemsHeight = 0, +) => { // Viewport height const vh = Math.max( document.documentElement.clientHeight || 0, window.innerHeight || 0, ); - const menuHeight = MENU_ITEM_HEIGHT * itemsCount + MENU_VERTICAL_SPACING; + const menuHeight = + Math.min(MENU_ITEM_HEIGHT * itemsCount, maxItemsContainerHeight) + + MENU_VERTICAL_SPACING + + additionalItemsHeight; // Always show the context menu inside the viewport return vh - clientY < menuHeight ? vh - menuHeight : clientY; -} +}; + +export const getSubmenuYOffset = ( + contextMenuY: number, + itemsCount: number, + submenuIndex = 0, + maxItemsContainerHeight = Number.MAX_SAFE_INTEGER, + additionalItemsHeight = 0, +) => { + const submenuY = + contextMenuY + + MENU_PADDING + + MENU_ITEM_HEIGHT * submenuIndex + + MENU_PADDING; + + return ( + getMenuAdjustedY( + submenuY, + itemsCount, + maxItemsContainerHeight, + additionalItemsHeight, + ) - submenuY + ); +}; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/ColumnSelect.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/ColumnSelect.tsx index fdd7811df2..85fe8af34e 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/ColumnSelect.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/ColumnSelect.tsx @@ -22,8 +22,8 @@ import { Column, ensureIsArray, t, useChangeEffect } from '@superset-ui/core'; import { Select, FormInstance } from 'src/components'; import { useToasts } from 'src/components/MessageToasts/withToasts'; import { getClientErrorObject } from 'src/utils/getClientErrorObject'; +import { cachedSupersetGet } from 'src/utils/cachedSupersetGet'; import { NativeFiltersForm } from '../types'; -import { cachedSupersetGet } from './utils'; interface ColumnSelectProps { allowClear?: boolean; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.tsx index db1c6d4124..e5e62b008e 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.tsx @@ -24,7 +24,8 @@ import { ClientErrorObject, getClientErrorObject, } from 'src/utils/getClientErrorObject'; -import { cachedSupersetGet, datasetToSelectOption } from './utils'; +import { cachedSupersetGet } from 'src/utils/cachedSupersetGet'; +import { datasetToSelectOption } from './utils'; interface DatasetSelectProps { onChange: (value: { label: string; value: number }) => void; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx index c577eb9213..1abc121b86 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx @@ -60,6 +60,7 @@ import { addDangerToast } from 'src/components/MessageToasts/actions'; import { Radio } from 'src/components/Radio'; import Tabs from 'src/components/Tabs'; import { Tooltip } from 'src/components/Tooltip'; +import { cachedSupersetGet } from 'src/utils/cachedSupersetGet'; import { Chart, ChartsState, @@ -90,7 +91,6 @@ import getControlItemsMap from './getControlItemsMap'; import RemovedFilter from './RemovedFilter'; import { useBackendFormUpdate, useDefaultValue } from './state'; import { - cachedSupersetGet, hasTemporalColumns, mostUsedDataset, setNativeFilterFieldValues, diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts index de48dbf553..92185039d8 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts @@ -20,14 +20,8 @@ import { flatMapDeep } from 'lodash'; import { FormInstance } from 'src/components'; import React from 'react'; import { CustomControlItem, Dataset } from '@superset-ui/chart-controls'; -import { - Column, - ensureIsArray, - GenericDataType, - SupersetClient, -} from '@superset-ui/core'; +import { Column, ensureIsArray, GenericDataType } from '@superset-ui/core'; import { DatasourcesState, ChartsState } from 'src/dashboard/types'; -import { cacheWrapper } from 'src/utils/cacheWrapper'; import { FILTER_SUPPORTED_TYPES } from './constants'; const FILTERS_FIELD_NAME = 'filters'; @@ -124,11 +118,3 @@ export const mostUsedDataset = ( return datasets[mostUsedDataset]?.id; }; - -const localCache = new Map(); - -export const cachedSupersetGet = cacheWrapper( - SupersetClient.get, - localCache, - ({ endpoint }) => endpoint || '', -); diff --git a/superset-frontend/src/dashboard/containers/DashboardPage.tsx b/superset-frontend/src/dashboard/containers/DashboardPage.tsx index 7323f120c9..79b242a179 100644 --- a/superset-frontend/src/dashboard/containers/DashboardPage.tsx +++ b/superset-frontend/src/dashboard/containers/DashboardPage.tsx @@ -59,7 +59,11 @@ import { DashboardContextForExplore } from 'src/types/DashboardContextForExplore import shortid from 'shortid'; import { RootState } from '../types'; import { getActiveFilters } from '../util/activeDashboardFilters'; -import { filterCardPopoverStyle, headerStyles } from '../styles'; +import { + chartContextMenuStyles, + filterCardPopoverStyle, + headerStyles, +} from '../styles'; export const DashboardPageIdContext = React.createContext(''); @@ -279,7 +283,13 @@ export const DashboardPage: FC = ({ idOrSlug }: PageProps) => { return ( <> - + diff --git a/superset-frontend/src/dashboard/styles.ts b/superset-frontend/src/dashboard/styles.ts index a5f49acb85..5d431f50b6 100644 --- a/superset-frontend/src/dashboard/styles.ts +++ b/superset-frontend/src/dashboard/styles.ts @@ -87,3 +87,10 @@ export const filterCardPopoverStyle = (theme: SupersetTheme) => css` } } `; + +export const chartContextMenuStyles = (theme: SupersetTheme) => css` + .ant-dropdown-menu-submenu.chart-context-submenu { + max-width: ${theme.gridUnit * 60}px; + min-width: ${theme.gridUnit * 40}px; + } +`; diff --git a/superset-frontend/src/utils/cachedSupersetGet.ts b/superset-frontend/src/utils/cachedSupersetGet.ts new file mode 100644 index 0000000000..2f319cac35 --- /dev/null +++ b/superset-frontend/src/utils/cachedSupersetGet.ts @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SupersetClient } from '@superset-ui/core'; +import { cacheWrapper } from './cacheWrapper'; + +export const supersetGetCache = new Map(); + +export const cachedSupersetGet = cacheWrapper( + SupersetClient.get, + supersetGetCache, + ({ endpoint }) => endpoint || '', +);