diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/drilltodetail.test.ts b/superset-frontend/cypress-base/cypress/integration/dashboard/drilltodetail.test.ts index edce9053fd..f89435f1bc 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard/drilltodetail.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/drilltodetail.test.ts @@ -43,13 +43,29 @@ function openModalFromChartContext(targetMenuItem: string) { interceptSamples(); cy.wait(500); - cy.get('.ant-dropdown') - .not('.ant-dropdown-hidden') - .first() - .find("[role='menu'] [role='menuitem']") - .contains(targetMenuItem) - .first() - .click(); + if (targetMenuItem.startsWith('Drill to detail by')) { + cy.get('.ant-dropdown') + .not('.ant-dropdown-hidden') + .first() + .find("[role='menu'] [role='menuitem'] [title='Drill to detail by']") + .trigger('mouseover'); + cy.wait(500); + cy.get('[data-test="drill-to-detail-by-submenu"]') + .not('.ant-dropdown-menu-hidden [data-test="drill-to-detail-by-submenu"]') + .find('[role="menuitem"]') + .contains(new RegExp(`^${targetMenuItem}$`)) + .first() + .click(); + } else { + cy.get('.ant-dropdown') + .not('.ant-dropdown-hidden') + .first() + .find("[role='menu'] [role='menuitem']") + .contains(new RegExp(`^${targetMenuItem}$`)) + .first() + .click(); + } + cy.wait('@samples'); } @@ -404,6 +420,18 @@ describe('Drill to detail modal', () => { }); }); }); + + describe('Bar Chart', () => { + it('opens the modal for unsupported chart without filters', () => { + interceptSamples(); + + cy.get("[data-test-viz-type='dist_bar'] svg").then($canvas => { + cy.wrap($canvas).scrollIntoView().rightclick(70, 150); + openModalFromChartContext('Drill to detail'); + cy.getBySel('filter-val').should('not.exist'); + }); + }); + }); }); describe('Tier 2 charts', () => { 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 e647038593..f9f1a360b6 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 @@ -25,6 +25,12 @@ export type HandlerFunction = (...args: unknown[]) => void; export enum Behavior { INTERACTIVE_CHART = 'INTERACTIVE_CHART', NATIVE_FILTER = 'NATIVE_FILTER', + + /** + * Include `DRILL_TO_DETAIL` behavior if plugin handles `contextmenu` event + * when dimensions are right-clicked on. + */ + DRILL_TO_DETAIL = 'DRILL_TO_DETAIL', } export enum AppSection { diff --git a/superset-frontend/packages/superset-ui-core/src/query/index.ts b/superset-frontend/packages/superset-ui-core/src/query/index.ts index bfc75da205..3ea6dad75f 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/index.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/index.ts @@ -35,6 +35,7 @@ export { isXAxisSet, hasGenericChartAxes, } from './getXAxis'; +export { default as extractQueryFields } from './extractQueryFields'; export * from './types/AnnotationLayer'; export * from './types/QueryFormData'; 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 f8de05e0ef..c845c20da8 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 @@ -121,7 +121,7 @@ function WorldMap(element, props) { formattedVal: val, }, ]; - onContextMenu(filters, pointerEvent.clientX, pointerEvent.clientY); + onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters); } else { logging.warn( t( 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 d97adfadf3..6303caec08 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 @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core'; +import { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core'; import transformProps from './transformProps'; import thumbnail from './images/thumbnail.png'; import example1 from './images/WorldMap1.jpg'; @@ -45,6 +45,7 @@ const metadata = new ChartMetadata({ ], thumbnail, useLegacyApi: true, + behaviors: [Behavior.DRILL_TO_DETAIL], }); export default class WorldMapChartPlugin extends ChartPlugin { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/index.ts index 3f45db74cf..75401411a8 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/index.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core'; +import { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core'; import controlPanel from './controlPanel'; import transformProps from './transformProps'; import buildQuery from './buildQuery'; @@ -46,6 +46,7 @@ const metadata = new ChartMetadata({ t('Description'), ], thumbnail, + behaviors: [Behavior.DRILL_TO_DETAIL], }); export default class BigNumberTotalChartPlugin extends ChartPlugin< 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 9a27bc000c..b7516561cd 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.tsx @@ -26,7 +26,7 @@ import { computeMaxFontSize, BRAND_COLOR, styled, - QueryObjectFilterClause, + BinaryQueryObjectFilterClause, } from '@superset-ui/core'; import { EChartsCoreOption } from 'echarts'; import Echart from '../components/Echart'; @@ -65,9 +65,9 @@ type BigNumberVisProps = { mainColor: string; echartOptions: EChartsCoreOption; onContextMenu?: ( - filters: QueryObjectFilterClause[], clientX: number, clientY: number, + filters?: BinaryQueryObjectFilterClause[], ) => void; xValueFormatter?: TimeFormatter; formData?: BigNumberWithTrendlineFormData; @@ -171,11 +171,7 @@ class BigNumberVis extends React.PureComponent { const onContextMenu = (e: MouseEvent) => { if (this.props.onContextMenu) { e.preventDefault(); - this.props.onContextMenu( - [], - e.nativeEvent.clientX, - e.nativeEvent.clientY, - ); + this.props.onContextMenu(e.nativeEvent.clientX, e.nativeEvent.clientY); } }; @@ -249,7 +245,7 @@ class BigNumberVis extends React.PureComponent { const { data } = eventParams; if (data) { const pointerEvent = eventParams.event.event; - const filters: QueryObjectFilterClause[] = []; + const filters: BinaryQueryObjectFilterClause[] = []; filters.push({ col: this.props.formData?.granularitySqla, grain: this.props.formData?.timeGrainSqla, @@ -258,9 +254,9 @@ class BigNumberVis extends React.PureComponent { formattedVal: this.props.xValueFormatter?.(data[0]), }); this.props.onContextMenu( - filters, pointerEvent.clientX, pointerEvent.clientY, + filters, ); } } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/index.ts index e774db4824..8cd1d2d288 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/index.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core'; +import { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core'; import controlPanel from './controlPanel'; import transformProps from './transformProps'; import buildQuery from './buildQuery'; @@ -45,6 +45,7 @@ const metadata = new ChartMetadata({ t('Trend'), ], thumbnail, + behaviors: [Behavior.DRILL_TO_DETAIL], }); export default class BigNumberWithTrendlineChartPlugin 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 c97dffe5ac..3c8620e9d8 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/index.ts @@ -44,7 +44,7 @@ export default class EchartsBoxPlotChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('./EchartsBoxPlot'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART], + behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], 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 a483262419..ba5a7f0173 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/index.ts @@ -43,7 +43,7 @@ export default class EchartsFunnelChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('./EchartsFunnel'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART], + behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], 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 16a8b6a6cc..a65a380de2 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/index.ts @@ -33,7 +33,7 @@ export default class EchartsGaugeChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('./EchartsGauge'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART], + behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], 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 cefd4f9f6a..0f09fe2386 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/EchartsGraph.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/EchartsGraph.tsx @@ -17,7 +17,7 @@ * under the License. */ import React from 'react'; -import { QueryObjectFilterClause } from '@superset-ui/core'; +import { BinaryQueryObjectFilterClause } from '@superset-ui/core'; import { EventHandlers } from '../types'; import Echart from '../components/Echart'; import { GraphChartTransformedProps } from './types'; @@ -47,7 +47,7 @@ export default function EchartsGraph({ const sourceValue = data.find(item => item.id === e.data.source)?.name; const targetValue = data.find(item => item.id === e.data.target)?.name; if (sourceValue && targetValue) { - const filters: QueryObjectFilterClause[] = [ + const filters: BinaryQueryObjectFilterClause[] = [ { col: formData.source, op: '==', @@ -61,7 +61,7 @@ export default function EchartsGraph({ formattedVal: targetValue, }, ]; - onContextMenu(filters, pointerEvent.clientX, pointerEvent.clientY); + onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters); } } }, 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 b354b61a2f..7e3c26a925 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 } from '@superset-ui/core'; +import { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core'; import controlPanel from './controlPanel'; import transformProps from './transformProps'; import thumbnail from './images/thumbnail.png'; @@ -46,6 +46,7 @@ export default class EchartsGraphChartPlugin extends ChartPlugin { t('Transformable'), ], thumbnail, + behaviors: [Behavior.DRILL_TO_DETAIL], }), transformProps, }); 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 63b55d51bd..70a068c977 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/types.ts @@ -19,7 +19,7 @@ import { PlainObject, QueryFormData, - QueryObjectFilterClause, + BinaryQueryObjectFilterClause, } from '@superset-ui/core'; import { GraphNodeItemOption } from 'echarts/types/src/chart/graph/GraphSeries'; import { SeriesTooltipOption } from 'echarts/types/src/util/types'; @@ -88,8 +88,8 @@ export type tooltipFormatParams = { export type GraphChartTransformedProps = EchartsProps & { formData: PlainObject; onContextMenu?: ( - filters: QueryObjectFilterClause[], clientX: number, clientY: number, + filters?: BinaryQueryObjectFilterClause[], ) => void; }; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/EchartsMixedTimeseries.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/EchartsMixedTimeseries.tsx index de11fadaec..8a5421d217 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/EchartsMixedTimeseries.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/EchartsMixedTimeseries.tsx @@ -21,7 +21,7 @@ import { AxisType, DataRecordValue, DTTM_ALIAS, - QueryObjectFilterClause, + BinaryQueryObjectFilterClause, } from '@superset-ui/core'; import { EchartsMixedTimeseriesChartTransformedProps } from './types'; import Echart from '../components/Echart'; @@ -128,7 +128,7 @@ export default function EchartsMixedTimeseries({ eventParams.seriesName ], ]; - const filters: QueryObjectFilterClause[] = []; + const filters: BinaryQueryObjectFilterClause[] = []; if (xAxis.type === AxisType.time) { filters.push({ col: @@ -154,7 +154,7 @@ export default function EchartsMixedTimeseries({ formattedVal: String(values[i]), }), ); - onContextMenu(filters, pointerEvent.clientX, pointerEvent.clientY); + onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters); } } }, 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 2f6a9fc577..05bc71604d 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/index.ts @@ -53,7 +53,7 @@ export default class EchartsTimeseriesChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('./EchartsMixedTimeseries'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART], + behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], 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 873a6ac234..9f5d61474a 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,7 @@ export default class EchartsPieChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('./EchartsPie'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART], + behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], 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 f57eccdafa..d810a0a321 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/index.ts @@ -44,7 +44,7 @@ export default class EchartsRadarChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('./EchartsRadar'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART], + behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], category: t('Ranking'), 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 8b1407b120..200b25616b 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,7 @@ export default class EchartsAreaChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('../EchartsTimeseries'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART], + behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], 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 74a1646e4e..a178e5d05d 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx @@ -19,7 +19,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { DTTM_ALIAS, - QueryObjectFilterClause, + BinaryQueryObjectFilterClause, AxisType, } from '@superset-ui/core'; import { ViewRootGroup } from 'echarts/types/src/util/types'; @@ -191,7 +191,7 @@ export default function EchartsTimeseries({ ...(eventParams.name ? [eventParams.name] : []), ...labelMap[eventParams.seriesName], ]; - const filters: QueryObjectFilterClause[] = []; + const filters: BinaryQueryObjectFilterClause[] = []; if (xAxis.type === AxisType.time) { filters.push({ col: @@ -216,7 +216,7 @@ export default function EchartsTimeseries({ formattedVal: String(values[i]), }), ); - onContextMenu(filters, pointerEvent.clientX, pointerEvent.clientY); + onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters); } } }, 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 cc63d99dee..6ec20be442 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,7 @@ export default class EchartsTimeseriesBarChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('../../EchartsTimeseries'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART], + behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], 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 e00f2328fb..3a384293e5 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,7 @@ export default class EchartsTimeseriesLineChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('../../EchartsTimeseries'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART], + behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], 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 758b75d0ef..489983cfa6 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,7 @@ export default class EchartsTimeseriesScatterChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('../../EchartsTimeseries'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART], + behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], 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 53c7cdeea7..ae6dc7ad30 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,7 @@ export default class EchartsTimeseriesSmoothLineChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('../../EchartsTimeseries'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART], + behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], 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 c565a74d93..3fdeb5aa83 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,7 @@ export default class EchartsTimeseriesStepChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('../EchartsTimeseries'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART], + behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], 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 e0532e848b..4065a170d0 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,7 @@ export default class EchartsTimeseriesChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('./EchartsTimeseries'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART], + behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], 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 3a9e41b3b2..1ff112cedd 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/EchartsTreemap.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/EchartsTreemap.tsx @@ -16,7 +16,10 @@ * specific language governing permissions and limitations * under the License. */ -import { DataRecordValue, QueryObjectFilterClause } from '@superset-ui/core'; +import { + DataRecordValue, + BinaryQueryObjectFilterClause, +} from '@superset-ui/core'; import React, { useCallback } from 'react'; import Echart from '../components/Echart'; import { NULL_STRING } from '../constants'; @@ -93,7 +96,7 @@ export default function EchartsTreemap({ const { treePath } = extractTreePathInfo(eventParams.treePathInfo); if (treePath.length > 0) { const pointerEvent = eventParams.event.event; - const filters: QueryObjectFilterClause[] = []; + const filters: BinaryQueryObjectFilterClause[] = []; treePath.forEach((path, i) => filters.push({ col: groupby[i], @@ -102,7 +105,7 @@ export default function EchartsTreemap({ formattedVal: path, }), ); - onContextMenu(filters, pointerEvent.clientX, pointerEvent.clientY); + onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters); } } }, 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 575bb41fb9..49be2849ac 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,7 @@ export default class EchartsTreemapChartPlugin extends ChartPlugin< controlPanel, loadChart: () => import('./EchartsTreemap'), metadata: new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART], + behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], category: t('Part of a Whole'), credits: ['https://echarts.apache.org'], description: t( diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/types.ts index 9fc8997120..8c20543e78 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/types.ts @@ -19,7 +19,7 @@ import { HandlerFunction, QueryFormColumn, - QueryObjectFilterClause, + BinaryQueryObjectFilterClause, SetDataMaskHook, } from '@superset-ui/core'; import { EChartsCoreOption, ECharts } from 'echarts'; @@ -116,9 +116,9 @@ export interface EChartTransformedProps { selectedValues: Record; legendData?: OptionName[]; onContextMenu?: ( - filters: QueryObjectFilterClause[], clientX: number, clientY: number, + filters?: BinaryQueryObjectFilterClause[], ) => void; } 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 fc0b271638..d7c552edfc 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,7 @@ * specific language governing permissions and limitations * under the License. */ -import { QueryObjectFilterClause } from '@superset-ui/core'; +import { BinaryQueryObjectFilterClause } from '@superset-ui/core'; import { EChartTransformedProps, EventHandlers } from '../types'; export type Event = { @@ -48,7 +48,7 @@ export const contextMenuEventHandler = if (onContextMenu) { e.event.stop(); const pointerEvent = e.event.event; - const filters: QueryObjectFilterClause[] = []; + const filters: BinaryQueryObjectFilterClause[] = []; if (groupby.length > 0) { const values = labelMap[e.name]; groupby.forEach((dimension, i) => @@ -60,7 +60,7 @@ export const contextMenuEventHandler = }), ); } - onContextMenu(filters, pointerEvent.clientX, pointerEvent.clientY); + onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters); } }; 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 4d740148f7..499f072a50 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx @@ -28,7 +28,7 @@ import { styled, useTheme, isAdhocColumn, - QueryObjectFilterClause, + BinaryQueryObjectFilterClause, } from '@superset-ui/core'; import { PivotTable, sortAs, aggregatorTemplates } from './react-pivottable'; import { @@ -370,7 +370,8 @@ export default function PivotTableChart(props: PivotTableProps) { ) => { if (onContextMenu) { e.preventDefault(); - const filters: QueryObjectFilterClause[] = []; + e.stopPropagation(); + const filters: BinaryQueryObjectFilterClause[] = []; if (colKey && colKey.length > 1) { colKey.forEach((val, i) => { const col = cols[i]; @@ -399,7 +400,7 @@ export default function PivotTableChart(props: PivotTableProps) { }); }); } - onContextMenu(filters, e.clientX, e.clientY); + onContextMenu(e.clientX, e.clientY, filters); } }, [cols, dateFormatters, onContextMenu, rows], 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 423ac59625..f65eefebbe 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 @@ -46,7 +46,7 @@ export default class PivotTableChartPlugin extends ChartPlugin< */ constructor() { const metadata = new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART], + behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], 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-pivot-table/src/types.ts b/superset-frontend/plugins/plugin-chart-pivot-table/src/types.ts index ded6c48b2e..accd68a2e4 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/types.ts +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/types.ts @@ -26,7 +26,7 @@ import { NumberFormatter, QueryFormMetric, QueryFormColumn, - QueryObjectFilterClause, + BinaryQueryObjectFilterClause, } from '@superset-ui/core'; import { ColorFormatters } from '@superset-ui/chart-controls'; @@ -74,9 +74,9 @@ interface PivotTableCustomizeProps { legacy_order_by: QueryFormMetric[] | QueryFormMetric | null; order_desc: boolean; onContextMenu?: ( - filters: QueryObjectFilterClause[], clientX: number, clientY: number, + filters?: BinaryQueryObjectFilterClause[], ) => void; } 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 d107f2e5e1..941887afd1 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/DataTable/DataTable.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/DataTable/DataTable.tsx @@ -279,6 +279,7 @@ export default typedMemo(function DataTable({ onContextMenu={(e: MouseEvent) => { if (onContextMenu) { e.preventDefault(); + e.stopPropagation(); onContextMenu( row.original, e.nativeEvent.clientX, diff --git a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx index 83cc9c0cc4..067da59630 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx @@ -40,7 +40,7 @@ import { ensureIsArray, GenericDataType, getTimeFormatterForGranularity, - QueryObjectFilterClause, + BinaryQueryObjectFilterClause, styled, css, t, @@ -630,7 +630,7 @@ export default function TableChart( const handleContextMenu = onContextMenu && !isRawRecords ? (value: D, clientX: number, clientY: number) => { - const filters: QueryObjectFilterClause[] = []; + const filters: BinaryQueryObjectFilterClause[] = []; columnsMeta.forEach(col => { if (!col.isMetric) { const dataRecordValue = value[col.key]; @@ -642,7 +642,7 @@ export default function TableChart( }); } }); - onContextMenu(filters, clientX, clientY); + onContextMenu(clientX, clientY, filters); } : undefined; diff --git a/superset-frontend/plugins/plugin-chart-table/src/index.ts b/superset-frontend/plugins/plugin-chart-table/src/index.ts index bce2112d92..4e862fc5a5 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/index.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/index.ts @@ -31,7 +31,7 @@ export { default as __hack__ } from './types'; export * from './types'; const metadata = new ChartMetadata({ - behaviors: [Behavior.INTERACTIVE_CHART], + behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL], category: t('Table'), canBeAnnotationTypes: ['EVENT', 'INTERVAL'], description: t( diff --git a/superset-frontend/plugins/plugin-chart-table/src/types.ts b/superset-frontend/plugins/plugin-chart-table/src/types.ts index 6a5cb88f44..1a6f06f4f8 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, - QueryObjectFilterClause, + BinaryQueryObjectFilterClause, } from '@superset-ui/core'; import { ColorFormatters, ColumnConfig } from '@superset-ui/chart-controls'; @@ -113,9 +113,9 @@ export interface TableChartTransformedProps { columnColorFormatters?: ColorFormatters; allowRearrangeColumns?: boolean; onContextMenu?: ( - filters: QueryObjectFilterClause[], clientX: number, clientY: number, + filters?: BinaryQueryObjectFilterClause[], ) => void; } diff --git a/superset-frontend/spec/fixtures/mockChartQueries.js b/superset-frontend/spec/fixtures/mockChartQueries.js index 0175df981a..dc29d71abb 100644 --- a/superset-frontend/spec/fixtures/mockChartQueries.js +++ b/superset-frontend/spec/fixtures/mockChartQueries.js @@ -37,13 +37,13 @@ export default { viz_type: 'pie', slice_id: sliceId, slice_name: 'Genders', - granularity_sqla: null, - time_grain_sqla: null, + granularity_sqla: undefined, + time_grain_sqla: undefined, since: '100 years ago', until: 'now', metrics: ['sum__num'], groupby: ['gender'], - limit: '25', + limit: 25, pie_label_type: 'key', donut: false, show_legend: true, diff --git a/superset-frontend/src/components/Chart/Chart.jsx b/superset-frontend/src/components/Chart/Chart.jsx index 38b092bc87..8be3699450 100644 --- a/superset-frontend/src/components/Chart/Chart.jsx +++ b/superset-frontend/src/components/Chart/Chart.jsx @@ -29,6 +29,7 @@ import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils'; import { URL_PARAMS } from 'src/constants'; import { getUrlParam } from 'src/utils/urlUtils'; import { isCurrentUserBot } from 'src/utils/isBot'; +import { ChartSource } from 'src/types/ChartSource'; import { ResourceStatus } from 'src/hooks/apiResources/apiResources'; import ChartRenderer from './ChartRenderer'; import { ChartErrorMessage } from './ChartErrorMessage'; @@ -237,7 +238,7 @@ class Chart extends React.PureComponent { subtitle={{message}} copyText={message} link={queryResponse ? queryResponse.link : null} - source={dashboardId ? 'dashboard' : 'explore'} + source={dashboardId ? ChartSource.Dashboard : ChartSource.Explore} stackTrace={chartStackTrace} /> ); diff --git a/superset-frontend/src/components/Chart/ChartContextMenu.tsx b/superset-frontend/src/components/Chart/ChartContextMenu.tsx index 2eba7bbaad..784c6bdd65 100644 --- a/superset-frontend/src/components/Chart/ChartContextMenu.tsx +++ b/superset-frontend/src/components/Chart/ChartContextMenu.tsx @@ -23,82 +23,94 @@ import React, { useImperativeHandle, useState, } from 'react'; -import { QueryObjectFilterClause, t, styled } from '@superset-ui/core'; +import ReactDOM from 'react-dom'; +import { useSelector } from 'react-redux'; +import { + BinaryQueryObjectFilterClause, + FeatureFlag, + isFeatureEnabled, + QueryFormData, +} from '@superset-ui/core'; +import { RootState } from 'src/dashboard/types'; +import { findPermission } from 'src/utils/findPermission'; import { Menu } from 'src/components/Menu'; import { AntdDropdown as Dropdown } from 'src/components'; -import ReactDOM from 'react-dom'; +import { DrillDetailMenuItems } from './DrillDetail'; const MENU_ITEM_HEIGHT = 32; const MENU_VERTICAL_SPACING = 32; export interface ChartContextMenuProps { - id: string; - onSelection: (filters: QueryObjectFilterClause[]) => void; + id: number; + formData: QueryFormData; + onSelection: () => void; onClose: () => void; } export interface Ref { open: ( - filters: QueryObjectFilterClause[], clientX: number, clientY: number, + filters?: BinaryQueryObjectFilterClause[], ) => void; } -const Filter = styled.span` - ${({ theme }) => ` - font-weight: ${theme.typography.weights.bold}; - color: ${theme.colors.primary.base}; - `} -`; - const ChartContextMenu = ( - { id, onSelection, onClose }: ChartContextMenuProps, + { id, formData, onSelection, onClose }: ChartContextMenuProps, ref: RefObject, ) => { - const [state, setState] = useState<{ - filters: QueryObjectFilterClause[]; - clientX: number; - clientY: number; - }>({ filters: [], clientX: 0, clientY: 0 }); - - const menu = ( - - {state.filters.map((filter, i) => ( - onSelection([filter])}> - {`${t('Drill to detail by')} `} - {filter.formattedVal} - - ))} - {state.filters.length === 0 && ( - onSelection([])}> - {t('Drill to detail')} - - )} - {state.filters.length > 1 && ( - onSelection(state.filters)}> - {`${t('Drill to detail by')} `} - {t('all')} - - )} - + const canExplore = useSelector((state: RootState) => + findPermission('can_explore', 'Superset', state.user?.roles), ); + const [{ filters, clientX, clientY }, setState] = useState<{ + clientX: number; + clientY: number; + filters?: BinaryQueryObjectFilterClause[]; + }>({ clientX: 0, clientY: 0 }); + + const menuItems = []; + const showDrillToDetail = + isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL) && canExplore; + + if (showDrillToDetail) { + menuItems.push( + , + ); + } + const open = useCallback( - (filters: QueryObjectFilterClause[], clientX: number, clientY: number) => { + ( + clientX: number, + clientY: number, + filters?: BinaryQueryObjectFilterClause[], + ) => { // Viewport height const vh = Math.max( document.documentElement.clientHeight || 0, window.innerHeight || 0, ); - // +1 for automatically added options such as 'All' and 'Drill to detail' - const itemsCount = filters.length + 1; + 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 + const menuHeight = MENU_ITEM_HEIGHT * itemsCount + MENU_VERTICAL_SPACING; // Always show the context menu inside the viewport const adjustedY = vh - clientY < menuHeight ? vh - menuHeight : clientY; - setState({ filters, clientX, clientY: adjustedY }); + setState({ + clientX, + clientY: adjustedY, + filters, + }); // Since Ant Design's Dropdown does not offer an imperative API // and we can't attach event triggers to charts SVG elements, we @@ -106,7 +118,7 @@ const ChartContextMenu = ( // from the charts. document.getElementById(`hidden-span-${id}`)?.click(); }, - [id], + [id, showDrillToDetail], ); useImperativeHandle( @@ -119,7 +131,15 @@ const ChartContextMenu = ( return ReactDOM.createPortal( + {menuItems.length ? ( + menuItems + ) : ( + No actions + )} + + } trigger={['click']} onVisibleChange={value => !value && onClose()} > @@ -128,8 +148,8 @@ const ChartContextMenu = ( css={{ visibility: 'hidden', position: 'fixed', - top: state.clientY, - left: state.clientX, + top: clientY, + left: clientX, width: 1, height: 1, }} diff --git a/superset-frontend/src/components/Chart/ChartRenderer.jsx b/superset-frontend/src/components/Chart/ChartRenderer.jsx index d1584441e3..e1d3f7290a 100644 --- a/superset-frontend/src/components/Chart/ChartRenderer.jsx +++ b/superset-frontend/src/components/Chart/ChartRenderer.jsx @@ -26,11 +26,12 @@ import { t, isFeatureEnabled, FeatureFlag, + getChartMetadataRegistry, } from '@superset-ui/core'; import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils'; import { EmptyStateBig, EmptyStateSmall } from 'src/components/EmptyState'; +import { ChartSource } from 'src/types/ChartSource'; import ChartContextMenu from './ChartContextMenu'; -import DrillDetailModal from './DrillDetailModal'; const propTypes = { annotationData: PropTypes.object, @@ -60,7 +61,7 @@ const propTypes = { onFilterMenuClose: PropTypes.func, ownState: PropTypes.object, postTransformProps: PropTypes.func, - source: PropTypes.oneOf(['dashboard', 'explore']), + source: PropTypes.oneOf([ChartSource.Dashboard, ChartSource.Explore]), }; const BLANK = {}; @@ -83,8 +84,10 @@ class ChartRenderer extends React.Component { constructor(props) { super(props); this.state = { + showContextMenu: + props.source === ChartSource.Dashboard && + isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL), inContextMenu: false, - drillDetailFilters: null, }; this.hasQueryResponseChange = false; @@ -97,14 +100,13 @@ class ChartRenderer extends React.Component { this.handleOnContextMenu = this.handleOnContextMenu.bind(this); this.handleContextMenuSelected = this.handleContextMenuSelected.bind(this); this.handleContextMenuClosed = this.handleContextMenuClosed.bind(this); - - const showContextMenu = - props.source === 'dashboard' && - isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL); + this.onContextMenuFallback = this.onContextMenuFallback.bind(this); this.hooks = { onAddFilter: this.handleAddFilter, - onContextMenu: showContextMenu ? this.handleOnContextMenu : undefined, + onContextMenu: this.state.showContextMenu + ? this.handleOnContextMenu + : undefined, onError: this.handleRenderFailure, setControlValue: this.handleSetControlValue, onFilterMenuOpen: this.props.onFilterMenuOpen, @@ -198,19 +200,28 @@ class ChartRenderer extends React.Component { } } - handleOnContextMenu(filters, offsetX, offsetY) { - this.contextMenuRef.current.open(filters, offsetX, offsetY); + handleOnContextMenu(offsetX, offsetY, filters) { + this.contextMenuRef.current.open(offsetX, offsetY, filters); this.setState({ inContextMenu: true }); } - handleContextMenuSelected(filters) { - this.setState({ inContextMenu: false, drillDetailFilters: filters }); + handleContextMenuSelected() { + this.setState({ inContextMenu: false }); } handleContextMenuClosed() { this.setState({ inContextMenu: false }); } + // When viz plugins don't handle `contextmenu` event, fallback handler + // calls `handleOnContextMenu` with no `filters` param. + onContextMenuFallback(event) { + if (!this.state.inContextMenu) { + event.preventDefault(); + this.handleOnContextMenu(event.clientX, event.clientY); + } + } + render() { const { chartAlert, chartStatus, chartId } = this.props; @@ -265,7 +276,7 @@ class ChartRenderer extends React.Component { let noResultsComponent; const noResultTitle = t('No results were returned for this query'); const noResultDescription = - this.props.source === 'explore' + this.props.source === ChartSource.Explore ? t( 'Make sure that the controls are configured properly and the datasource contains data for the selected time range', ) @@ -285,47 +296,55 @@ class ChartRenderer extends React.Component { ); } + // Check for Behavior.DRILL_TO_DETAIL to tell if chart can receive Drill to + // Detail props or if it'll cause side-effects (e.g. excessive re-renders). + const drillToDetailProps = getChartMetadataRegistry() + .get(formData.viz_type) + ?.behaviors.find(behavior => behavior === Behavior.DRILL_TO_DETAIL) + ? { inContextMenu: this.state.inContextMenu } + : {}; + return ( -
- {this.props.source === 'dashboard' && ( - <> - - - + <> + {this.state.showContextMenu && ( + )} - -
+
+ +
+ ); } } diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailMenuItems.test.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailMenuItems.test.tsx new file mode 100644 index 0000000000..8a0f8dbfc5 --- /dev/null +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailMenuItems.test.tsx @@ -0,0 +1,345 @@ +/** + * 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 { render, screen, within } from 'spec/helpers/testing-library'; +import { getMockStoreWithNativeFilters } from 'spec/fixtures/mockStore'; +import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries'; +import { BinaryQueryObjectFilterClause } from '@superset-ui/core'; +import { Menu } from 'src/components/Menu'; +import DrillDetailMenuItems, { + DrillDetailMenuItemsProps, +} from './DrillDetailMenuItems'; + +/* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect*"] }] */ + +jest.mock( + './DrillDetailPane', + () => + ({ initialFilters }: { initialFilters: BinaryQueryObjectFilterClause[] }) => +
{JSON.stringify(initialFilters)}
, +); + +const { id: defaultChartId, form_data: defaultFormData } = + chartQueries[sliceId]; + +const { slice_name: chartName } = defaultFormData; +const unsupportedChartFormData = { + ...defaultFormData, + viz_type: 'dist_bar', +}; + +const noDimensionsFormData = { + ...defaultFormData, + viz_type: 'table', + query_mode: 'raw', +}; + +const filterA: BinaryQueryObjectFilterClause = { + col: 'sample_column', + op: '==', + val: 1234567890, + formattedVal: 'Yesterday', +}; + +const filterB: BinaryQueryObjectFilterClause = { + col: 'sample_column_2', + op: '==', + val: 987654321, + formattedVal: 'Two days ago', +}; + +const renderMenu = ({ + chartId, + formData, + isContextMenu, + filters, +}: Partial) => { + const store = getMockStoreWithNativeFilters(); + return render( + + + , + { useRouter: true, useRedux: true, store }, + ); +}; + +/** + * Drill to Detail modal should appear with correct initial filters + */ +const expectDrillToDetailModal = async ( + buttonName: string, + filters: BinaryQueryObjectFilterClause[] = [], +) => { + const button = screen.getByRole('menuitem', { name: buttonName }); + userEvent.click(button); + const modal = await screen.findByRole('dialog', { + name: `Drill to detail: ${chartName}`, + }); + + expect(modal).toBeVisible(); + expect(screen.getByTestId('modal-filters')).toHaveTextContent( + JSON.stringify(filters), + ); +}; + +/** + * Menu item should be enabled without explanatory tooltip + */ +const expectMenuItemEnabled = async (menuItem: HTMLElement) => { + expect(menuItem).toBeInTheDocument(); + expect(menuItem).not.toHaveAttribute('aria-disabled'); + const tooltipTrigger = within(menuItem).queryByTestId('tooltip-trigger'); + expect(tooltipTrigger).not.toBeInTheDocument(); +}; + +/** + * Menu item should be disabled, optionally with an explanatory tooltip + */ +const expectMenuItemDisabled = async ( + menuItem: HTMLElement, + tooltipContent?: string, +) => { + expect(menuItem).toBeVisible(); + expect(menuItem).toHaveAttribute('aria-disabled', 'true'); + const tooltipTrigger = within(menuItem).queryByTestId('tooltip-trigger'); + if (tooltipContent) { + userEvent.hover(tooltipTrigger as HTMLElement); + const tooltip = await screen.findByRole('tooltip', { + name: tooltipContent, + }); + + expect(tooltip).toBeInTheDocument(); + } else { + expect(tooltipTrigger).not.toBeInTheDocument(); + } +}; + +/** + * "Drill to detail" item should be enabled and open the correct modal + */ +const expectDrillToDetailEnabled = async () => { + const drillToDetailMenuItem = screen.getByRole('menuitem', { + name: 'Drill to detail', + }); + + await expectMenuItemEnabled(drillToDetailMenuItem); + await expectDrillToDetailModal('Drill to detail'); +}; + +/** + * "Drill to detail" item should be present and disabled + */ +const expectDrillToDetailDisabled = async (tooltipContent?: string) => { + const drillToDetailMenuItem = screen.getByRole('menuitem', { + name: 'Drill to detail', + }); + + await expectMenuItemDisabled(drillToDetailMenuItem, tooltipContent); +}; + +/** + * "Drill to detail by" item should not be present + */ +const expectNoDrillToDetailBy = async () => { + const drillToDetailBy = screen.queryByRole('menuitem', { + name: 'Drill to detail by', + }); + + expect(drillToDetailBy).not.toBeInTheDocument(); +}; + +/** + * "Drill to detail by" submenu should be present and enabled + */ +const expectDrillToDetailByEnabled = async () => { + const drillToDetailBy = screen.getByRole('menuitem', { + name: 'Drill to detail by', + }); + + await expectMenuItemEnabled(drillToDetailBy); + userEvent.hover( + within(drillToDetailBy).getByRole('button', { name: 'Drill to detail by' }), + ); + + expect( + await screen.findByTestId('drill-to-detail-by-submenu'), + ).toBeInTheDocument(); +}; + +/** + * "Drill to detail by" submenu should be present and disabled + */ +const expectDrillToDetailByDisabled = async (tooltipContent?: string) => { + const drillToDetailBySubmenuItem = screen.getByRole('menuitem', { + name: 'Drill to detail by', + }); + + await expectMenuItemDisabled(drillToDetailBySubmenuItem, tooltipContent); +}; + +/** + * "Drill to detail by {dimension}" submenu item should exist and open the correct modal + */ +const expectDrillToDetailByDimension = async ( + filter: BinaryQueryObjectFilterClause, +) => { + userEvent.hover(screen.getByRole('button', { name: 'Drill to detail by' })); + const drillToDetailBySubMenu = await screen.findByTestId( + 'drill-to-detail-by-submenu', + ); + + const menuItemName = `Drill to detail by ${filter.formattedVal}`; + const drillToDetailBySubmenuItem = within(drillToDetailBySubMenu).getByRole( + 'menuitem', + { name: menuItemName }, + ); + + await expectMenuItemEnabled(drillToDetailBySubmenuItem); + await expectDrillToDetailModal(menuItemName, [filter]); +}; + +/** + * "Drill to detail by all" submenu item should exist and open the correct modal + */ +const expectDrillToDetailByAll = async ( + filters: BinaryQueryObjectFilterClause[], +) => { + userEvent.hover(screen.getByRole('button', { name: 'Drill to detail by' })); + const drillToDetailBySubMenu = await screen.findByTestId( + 'drill-to-detail-by-submenu', + ); + + const menuItemName = 'Drill to detail by all'; + const drillToDetailBySubmenuItem = within(drillToDetailBySubMenu).getByRole( + 'menuitem', + { name: menuItemName }, + ); + + await expectMenuItemEnabled(drillToDetailBySubmenuItem); + await expectDrillToDetailModal(menuItemName, filters); +}; + +test('dropdown menu for unsupported chart', async () => { + renderMenu({ formData: unsupportedChartFormData }); + await expectDrillToDetailEnabled(); + await expectNoDrillToDetailBy(); +}); + +test('context menu for unsupported chart', async () => { + renderMenu({ + formData: unsupportedChartFormData, + isContextMenu: true, + }); + + await expectDrillToDetailEnabled(); + await expectDrillToDetailByDisabled( + 'Drill to detail by value is not yet supported for this chart type.', + ); +}); + +test('dropdown menu for supported chart, no dimensions', async () => { + renderMenu({ + formData: noDimensionsFormData, + }); + + await expectDrillToDetailDisabled( + 'Drill to detail is disabled because this chart does not group data by dimension value.', + ); + + await expectNoDrillToDetailBy(); +}); + +test('context menu for supported chart, no dimensions, no filters', async () => { + renderMenu({ + formData: noDimensionsFormData, + isContextMenu: true, + }); + + await expectDrillToDetailDisabled( + 'Drill to detail is disabled because this chart does not group data by dimension value.', + ); + + await expectDrillToDetailByDisabled(); +}); + +test('context menu for supported chart, no dimensions, 1 filter', async () => { + renderMenu({ + formData: noDimensionsFormData, + isContextMenu: true, + filters: [filterA], + }); + + await expectDrillToDetailDisabled( + 'Drill to detail is disabled because this chart does not group data by dimension value.', + ); + + await expectDrillToDetailByDisabled(); +}); + +test('dropdown menu for supported chart, dimensions', async () => { + renderMenu({ formData: defaultFormData }); + await expectDrillToDetailEnabled(); + await expectNoDrillToDetailBy(); +}); + +test('context menu for supported chart, dimensions, no filters', async () => { + renderMenu({ + formData: defaultFormData, + isContextMenu: true, + }); + + await expectDrillToDetailEnabled(); + await expectDrillToDetailByDisabled( + 'Right-click on a dimension value to drill to detail by that value.', + ); +}); + +test('context menu for supported chart, dimensions, 1 filter', async () => { + const filters = [filterA]; + renderMenu({ + formData: defaultFormData, + isContextMenu: true, + filters, + }); + + await expectDrillToDetailEnabled(); + await expectDrillToDetailByEnabled(); + await expectDrillToDetailByDimension(filterA); +}); + +test('context menu for supported chart, dimensions, 2 filters', async () => { + const filters = [filterA, filterB]; + renderMenu({ + formData: defaultFormData, + isContextMenu: true, + filters, + }); + + await expectDrillToDetailEnabled(); + await expectDrillToDetailByEnabled(); + await expectDrillToDetailByDimension(filterA); + await expectDrillToDetailByDimension(filterB); + await expectDrillToDetailByAll(filters); +}); diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailMenuItems.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailMenuItems.tsx new file mode 100644 index 0000000000..8269e85900 --- /dev/null +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailMenuItems.tsx @@ -0,0 +1,236 @@ +/** + * 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, useCallback, useMemo, useState } from 'react'; +import { isEmpty } from 'lodash'; +import { + Behavior, + BinaryQueryObjectFilterClause, + css, + extractQueryFields, + 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'; + +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 }) => ( + +
+ {children} +
+
+); + +const Filter = styled.span` + ${({ theme }) => ` + font-weight: ${theme.typography.weights.bold}; + color: ${theme.colors.primary.base}; + `} +`; + +export type DrillDetailMenuItemsProps = { + chartId: number; + formData: QueryFormData; + filters?: BinaryQueryObjectFilterClause[]; + isContextMenu?: boolean; + onSelection?: () => void; + onClick?: (event: MouseEvent) => void; +}; + +const DrillDetailMenuItems = ({ + chartId, + formData, + filters = [], + isContextMenu = false, + onSelection = () => null, + onClick = () => null, + ...props +}: DrillDetailMenuItemsProps) => { + const [modalFilters, setFilters] = useState( + [], + ); + + const [showModal, setShowModal] = useState(false); + const openModal = useCallback( + (filters, event) => { + onClick(event); + onSelection(); + setFilters(filters); + setShowModal(true); + }, + [onClick, onSelection], + ); + + const closeModal = useCallback(() => { + setShowModal(false); + }, []); + + // Check for Behavior.DRILL_TO_DETAIL to tell if plugin handles the `contextmenu` + // event for dimensions. If it doesn't, tell the user that drill to detail by + // dimension is not supported. If it does, and the `contextmenu` handler didn't + // pass any filters, tell the user that they didn't select a dimension. + const handlesDimensionContextMenu = useMemo( + () => + getChartMetadataRegistry() + .get(formData.viz_type) + ?.behaviors.find(behavior => behavior === Behavior.DRILL_TO_DETAIL), + [formData.viz_type], + ); + + // Check metrics to see if chart's current configuration lacks + // aggregations, in which case Drill to Detail should be disabled. + const noAggregations = useMemo(() => { + const { metrics } = extractQueryFields(formData); + return isEmpty(metrics); + }, [formData]); + + let drillToDetailMenuItem; + if (handlesDimensionContextMenu && noAggregations) { + drillToDetailMenuItem = ( + + {t('Drill to detail')} + + + ); + } else { + drillToDetailMenuItem = ( + + {t('Drill to detail')} + + ); + } + + let drillToDetailByMenuItem; + if (!handlesDimensionContextMenu) { + drillToDetailByMenuItem = ( + + {t('Drill to detail by')} + + + ); + } + + if (handlesDimensionContextMenu && noAggregations) { + drillToDetailByMenuItem = ( + + {t('Drill to detail by')} + + ); + } + + if (handlesDimensionContextMenu && !noAggregations && filters?.length) { + drillToDetailByMenuItem = ( + +
+ {filters.map((filter, i) => ( + + {`${t('Drill to detail by')} `} + {filter.formattedVal} + + ))} + {filters.length > 1 && ( + + {`${t('Drill to detail by')} `} + {t('all')} + + )} +
+
+ ); + } + + if (handlesDimensionContextMenu && !noAggregations && !filters?.length) { + drillToDetailByMenuItem = ( + + {t('Drill to detail by')} + + + ); + } + + return ( + <> + {drillToDetailMenuItem} + {isContextMenu && drillToDetailByMenuItem} + + + ); +}; + +export default DrillDetailMenuItems; diff --git a/superset-frontend/src/components/Chart/DrillDetailModal.test.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.test.tsx similarity index 59% rename from superset-frontend/src/components/Chart/DrillDetailModal.test.tsx rename to superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.test.tsx index 20f319ce4f..038541d390 100644 --- a/superset-frontend/src/components/Chart/DrillDetailModal.test.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.test.tsx @@ -16,44 +16,15 @@ * specific language governing permissions and limitations * under the License. */ -import React from 'react'; -import { render, screen, waitFor } from 'spec/helpers/testing-library'; + +import React, { useState } from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from 'spec/helpers/testing-library'; import { getMockStoreWithNativeFilters } from 'spec/fixtures/mockStore'; import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries'; -import { QueryFormData } from '@superset-ui/core'; -import fetchMock from 'fetch-mock'; -import userEvent from '@testing-library/user-event'; import DrillDetailModal from './DrillDetailModal'; -const chart = chartQueries[sliceId]; -const setup = (overrides: Record = {}) => { - const store = getMockStoreWithNativeFilters(); - const props = { - chartId: sliceId, - initialFilters: [], - formData: chart.form_data as unknown as QueryFormData, - ...overrides, - }; - return render(, { - useRedux: true, - useRouter: true, - store, - }); -}; -const waitForRender = (overrides: Record = {}) => - waitFor(() => setup(overrides)); - -fetchMock.post( - 'end:/datasource/samples?force=false&datasource_type=table&datasource_id=7&per_page=50&page=1', - { - result: { - data: [], - colnames: [], - coltypes: [], - }, - }, -); - +jest.mock('./DrillDetailPane', () => () => null); const mockHistoryPush = jest.fn(); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -62,32 +33,46 @@ jest.mock('react-router-dom', () => ({ }), })); -test('should render', async () => { - const { container } = await waitForRender(); - expect(container).toBeInTheDocument(); -}); +const { id: chartId, form_data: formData } = chartQueries[sliceId]; +const { slice_name: chartName } = formData; + +const renderModal = async () => { + const store = getMockStoreWithNativeFilters(); + const DrillDetailModalWrapper = () => { + const [showModal, setShowModal] = useState(false); + return ( + <> + + setShowModal(false)} + /> + + ); + }; + + render(, { + useRouter: true, + useRedux: true, + store, + }); + + userEvent.click(screen.getByRole('button', { name: 'Show modal' })); + await screen.findByRole('dialog', { name: `Drill to detail: ${chartName}` }); +}; test('should render the title', async () => { - await waitForRender(); - expect( - screen.getByText(`Drill to detail: ${chart.form_data.slice_name}`), - ).toBeInTheDocument(); -}); - -test('should render the modal', async () => { - await waitForRender(); - expect(screen.getByRole('dialog')).toBeInTheDocument(); -}); - -test('should not render the modal', async () => { - await waitForRender({ - initialFilters: undefined, - }); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + await renderModal(); + expect(screen.getByText(`Drill to detail: ${chartName}`)).toBeInTheDocument(); }); test('should render the button', async () => { - await waitForRender(); + await renderModal(); expect( screen.getByRole('button', { name: 'Edit chart' }), ).toBeInTheDocument(); @@ -95,14 +80,14 @@ test('should render the button', async () => { }); test('should close the modal', async () => { - await waitForRender(); + await renderModal(); expect(screen.getByRole('dialog')).toBeInTheDocument(); userEvent.click(screen.getAllByRole('button', { name: 'Close' })[1]); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); test('should forward to Explore', async () => { - await waitForRender(); + await renderModal(); userEvent.click(screen.getByRole('button', { name: 'Edit chart' })); expect(mockHistoryPush).toHaveBeenCalledWith( `/explore/?dashboard_page_id=&slice_id=${sliceId}`, diff --git a/superset-frontend/src/components/Chart/DrillDetailModal.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.tsx similarity index 65% rename from superset-frontend/src/components/Chart/DrillDetailModal.tsx rename to superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.tsx index 448cc2566f..160796c308 100644 --- a/superset-frontend/src/components/Chart/DrillDetailModal.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.tsx @@ -17,14 +17,7 @@ * under the License. */ -import React, { - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from 'react'; -import { useSelector } from 'react-redux'; +import React, { useCallback, useContext, useMemo } from 'react'; import { useHistory } from 'react-router-dom'; import { BinaryQueryObjectFilterClause, @@ -33,22 +26,51 @@ import { t, useTheme, } from '@superset-ui/core'; -import DrillDetailPane from 'src/dashboard/components/DrillDetailPane'; +import Modal from 'src/components/Modal'; +import Button from 'src/components/Button'; +import { useSelector } from 'react-redux'; import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage'; import { Slice } from 'src/types/Chart'; -import Modal from '../Modal'; -import Button from '../Button'; +import DrillDetailPane from './DrillDetailPane'; -const DrillDetailModal: React.FC<{ +interface ModalFooterProps { + exploreChart: () => void; + closeModal?: () => void; +} + +const ModalFooter = ({ exploreChart, closeModal }: ModalFooterProps) => ( + <> + + + +); + +interface DrillDetailModalProps { chartId: number; - initialFilters?: BinaryQueryObjectFilterClause[]; formData: QueryFormData; -}> = ({ chartId, initialFilters, formData }) => { - const [showModal, setShowModal] = useState(false); - const openModal = useCallback(() => setShowModal(true), []); - const closeModal = useCallback(() => setShowModal(false), []); - const history = useHistory(); + initialFilters: BinaryQueryObjectFilterClause[]; + showModal: boolean; + onHideModal: () => void; +} + +export default function DrillDetailModal({ + chartId, + formData, + initialFilters, + showModal, + onHideModal, +}: DrillDetailModalProps) { const theme = useTheme(); + const history = useHistory(); const dashboardPageId = useContext(DashboardPageIdContext); const { slice_name: chartName } = useSelector( (state: { sliceEntities: { slices: Record } }) => @@ -64,43 +86,18 @@ const DrillDetailModal: React.FC<{ history.push(exploreUrl); }, [exploreUrl, history]); - // Trigger modal open when initial filters change - useEffect(() => { - if (initialFilters) { - openModal(); - } - }, [initialFilters, openModal]); - return ( null)} css={css` .ant-modal-body { display: flex; flex-direction: column; } `} - show={showModal} - onHide={closeModal} title={t('Drill to detail: %s', chartName)} - footer={ - <> - - - - } + footer={} responsive resizable resizableConfig={{ @@ -117,6 +114,4 @@ const DrillDetailModal: React.FC<{ ); -}; - -export default DrillDetailModal; +} diff --git a/superset-frontend/src/dashboard/components/DrillDetailPane/DrillDetailPane.test.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx similarity index 100% rename from superset-frontend/src/dashboard/components/DrillDetailPane/DrillDetailPane.test.tsx rename to superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx diff --git a/superset-frontend/src/dashboard/components/DrillDetailPane/DrillDetailPane.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx similarity index 91% rename from superset-frontend/src/dashboard/components/DrillDetailPane/DrillDetailPane.tsx rename to superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx index ea3c6f5734..7d2d572d12 100644 --- a/superset-frontend/src/dashboard/components/DrillDetailPane/DrillDetailPane.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx @@ -44,7 +44,7 @@ import MetadataBar, { } from 'src/components/MetadataBar'; import Alert from 'src/components/Alert'; import { useApiV1Resource } from 'src/hooks/apiResources'; -import TableControls from './TableControls'; +import TableControls from './DrillDetailTableControls'; import { getDrillPayload } from './utils'; import { Dataset, ResultsPage } from './types'; @@ -55,12 +55,12 @@ export default function DrillDetailPane({ initialFilters, }: { formData: QueryFormData; - initialFilters?: BinaryQueryObjectFilterClause[]; + initialFilters: BinaryQueryObjectFilterClause[]; }) { const theme = useTheme(); const [pageIndex, setPageIndex] = useState(0); const lastPageIndex = useRef(pageIndex); - const [filters, setFilters] = useState(initialFilters || []); + const [filters, setFilters] = useState(initialFilters); const [isLoading, setIsLoading] = useState(false); const [responseError, setResponseError] = useState(''); const [resultsPages, setResultsPages] = useState>( @@ -72,13 +72,13 @@ export default function DrillDetailPane({ state.common.conf.SAMPLES_ROW_LIMIT, ); - // Extract datasource ID/type from string ID + // Extract datasource ID/type from string ID const [datasourceId, datasourceType] = useMemo( () => formData.datasource.split('__'), [formData.datasource], ); - // Get page of results + // Get page of results const resultsPage = useMemo(() => { const nextResultsPage = resultsPages.get(pageIndex); if (nextResultsPage) { @@ -98,7 +98,7 @@ export default function DrillDetailPane({ formData.datasource, ); - // Disable sorting on columns + // Disable sorting on columns const sortDisabledColumns = useMemo( () => columns.map(column => ({ @@ -108,26 +108,26 @@ export default function DrillDetailPane({ [columns], ); - // Update page index on pagination click + // Update page index on pagination click const onServerPagination = useCallback(({ pageIndex }) => { setPageIndex(pageIndex); }, []); - // Clear cache on reload button click + // Clear cache on reload button click const handleReload = useCallback(() => { setResponseError(''); setResultsPages(new Map()); setPageIndex(0); }, []); - // Clear cache and reset page index if filters change + // Clear cache and reset page index if filters change useEffect(() => { setResponseError(''); setResultsPages(new Map()); setPageIndex(0); }, [filters]); - // Update cache order if page in cache + // Update cache order if page in cache useEffect(() => { if ( resultsPages.has(pageIndex) && @@ -144,7 +144,7 @@ export default function DrillDetailPane({ } }, [pageIndex, resultsPages]); - // Download page of results & trim cache if page not in cache + // Download page of results & trim cache if page not in cache useEffect(() => { if (!responseError && !isLoading && !resultsPages.has(pageIndex)) { setIsLoading(true); @@ -196,7 +196,7 @@ export default function DrillDetailPane({ let tableContent = null; if (responseError) { - // Render error if page download failed + // Render error if page download failed tableContent = (
     );
   } else if (!resultsPages.size) {
-    //  Render loading if first page hasn't loaded
+    // Render loading if first page hasn't loaded
     tableContent = ;
   } else if (resultsPage?.total === 0) {
-    //  Render empty state if no results are returned for page
+    // Render empty state if no results are returned for page
     const title = t('No rows were returned for this dataset');
     tableContent = ;
   } else {
-    //  Render table if at least one page has successfully loaded
+    // Render table if at least one page has successfully loaded
     tableContent = (
       
 
 const renderWrapper = (overrideProps?: SliceHeaderControlsProps) => {
   const props = overrideProps || createProps();
+  const store = getMockStore();
   return render(, {
     useRedux: true,
     useRouter: true,
+    store,
   });
 };
 
@@ -253,6 +256,7 @@ test('Should show the "Drill to detail"', () => {
     [FeatureFlag.DRILL_TO_DETAIL]: true,
   };
   const props = createProps();
+  props.slice.slice_id = 18;
   renderWrapper(props);
   expect(screen.getByText('Drill to detail')).toBeInTheDocument();
 });
diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx
index 19bb612b04..5bdc442ba1 100644
--- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx
+++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx
@@ -53,7 +53,7 @@ import Button from 'src/components/Button';
 import ViewQueryModal from 'src/explore/components/controls/ViewQueryModal';
 import { ResultsPaneOnDashboard } from 'src/explore/components/DataTablesPane';
 import Modal from 'src/components/Modal';
-import DrillDetailPane from 'src/dashboard/components/DrillDetailPane';
+import { DrillDetailMenuItems } from 'src/components/Chart/DrillDetail';
 
 const MENU_KEYS = {
   CROSS_FILTER_SCOPING: 'cross_filter_scoping',
@@ -156,7 +156,7 @@ const dropdownIconsStyles = css`
   }
 `;
 
-const DashboardChartModalTrigger = ({
+const ViewResultsModalTrigger = ({
   exploreUrl,
   triggerNode,
   modalTitle,
@@ -205,7 +205,6 @@ const DashboardChartModalTrigger = ({
                 {t('Edit chart')}