feat(dashboard): menu improvements, fallback support for Drill to Detail (#21351)

This commit is contained in:
Cody Leff 2022-10-19 15:34:46 -06:00 committed by GitHub
parent 54f6fd6a82
commit 76e57ec651
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
56 changed files with 969 additions and 310 deletions

View File

@ -43,13 +43,29 @@ function openModalFromChartContext(targetMenuItem: string) {
interceptSamples(); interceptSamples();
cy.wait(500); cy.wait(500);
cy.get('.ant-dropdown') if (targetMenuItem.startsWith('Drill to detail by')) {
.not('.ant-dropdown-hidden') cy.get('.ant-dropdown')
.first() .not('.ant-dropdown-hidden')
.find("[role='menu'] [role='menuitem']") .first()
.contains(targetMenuItem) .find("[role='menu'] [role='menuitem'] [title='Drill to detail by']")
.first() .trigger('mouseover');
.click(); 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'); 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', () => { describe('Tier 2 charts', () => {

View File

@ -25,6 +25,12 @@ export type HandlerFunction = (...args: unknown[]) => void;
export enum Behavior { export enum Behavior {
INTERACTIVE_CHART = 'INTERACTIVE_CHART', INTERACTIVE_CHART = 'INTERACTIVE_CHART',
NATIVE_FILTER = 'NATIVE_FILTER', 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 { export enum AppSection {

View File

@ -35,6 +35,7 @@ export {
isXAxisSet, isXAxisSet,
hasGenericChartAxes, hasGenericChartAxes,
} from './getXAxis'; } from './getXAxis';
export { default as extractQueryFields } from './extractQueryFields';
export * from './types/AnnotationLayer'; export * from './types/AnnotationLayer';
export * from './types/QueryFormData'; export * from './types/QueryFormData';

View File

@ -121,7 +121,7 @@ function WorldMap(element, props) {
formattedVal: val, formattedVal: val,
}, },
]; ];
onContextMenu(filters, pointerEvent.clientX, pointerEvent.clientY); onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters);
} else { } else {
logging.warn( logging.warn(
t( t(

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * 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 transformProps from './transformProps';
import thumbnail from './images/thumbnail.png'; import thumbnail from './images/thumbnail.png';
import example1 from './images/WorldMap1.jpg'; import example1 from './images/WorldMap1.jpg';
@ -45,6 +45,7 @@ const metadata = new ChartMetadata({
], ],
thumbnail, thumbnail,
useLegacyApi: true, useLegacyApi: true,
behaviors: [Behavior.DRILL_TO_DETAIL],
}); });
export default class WorldMapChartPlugin extends ChartPlugin { export default class WorldMapChartPlugin extends ChartPlugin {

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * 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 controlPanel from './controlPanel';
import transformProps from './transformProps'; import transformProps from './transformProps';
import buildQuery from './buildQuery'; import buildQuery from './buildQuery';
@ -46,6 +46,7 @@ const metadata = new ChartMetadata({
t('Description'), t('Description'),
], ],
thumbnail, thumbnail,
behaviors: [Behavior.DRILL_TO_DETAIL],
}); });
export default class BigNumberTotalChartPlugin extends ChartPlugin< export default class BigNumberTotalChartPlugin extends ChartPlugin<

View File

@ -26,7 +26,7 @@ import {
computeMaxFontSize, computeMaxFontSize,
BRAND_COLOR, BRAND_COLOR,
styled, styled,
QueryObjectFilterClause, BinaryQueryObjectFilterClause,
} from '@superset-ui/core'; } from '@superset-ui/core';
import { EChartsCoreOption } from 'echarts'; import { EChartsCoreOption } from 'echarts';
import Echart from '../components/Echart'; import Echart from '../components/Echart';
@ -65,9 +65,9 @@ type BigNumberVisProps = {
mainColor: string; mainColor: string;
echartOptions: EChartsCoreOption; echartOptions: EChartsCoreOption;
onContextMenu?: ( onContextMenu?: (
filters: QueryObjectFilterClause[],
clientX: number, clientX: number,
clientY: number, clientY: number,
filters?: BinaryQueryObjectFilterClause[],
) => void; ) => void;
xValueFormatter?: TimeFormatter; xValueFormatter?: TimeFormatter;
formData?: BigNumberWithTrendlineFormData; formData?: BigNumberWithTrendlineFormData;
@ -171,11 +171,7 @@ class BigNumberVis extends React.PureComponent<BigNumberVisProps> {
const onContextMenu = (e: MouseEvent<HTMLDivElement>) => { const onContextMenu = (e: MouseEvent<HTMLDivElement>) => {
if (this.props.onContextMenu) { if (this.props.onContextMenu) {
e.preventDefault(); e.preventDefault();
this.props.onContextMenu( this.props.onContextMenu(e.nativeEvent.clientX, e.nativeEvent.clientY);
[],
e.nativeEvent.clientX,
e.nativeEvent.clientY,
);
} }
}; };
@ -249,7 +245,7 @@ class BigNumberVis extends React.PureComponent<BigNumberVisProps> {
const { data } = eventParams; const { data } = eventParams;
if (data) { if (data) {
const pointerEvent = eventParams.event.event; const pointerEvent = eventParams.event.event;
const filters: QueryObjectFilterClause[] = []; const filters: BinaryQueryObjectFilterClause[] = [];
filters.push({ filters.push({
col: this.props.formData?.granularitySqla, col: this.props.formData?.granularitySqla,
grain: this.props.formData?.timeGrainSqla, grain: this.props.formData?.timeGrainSqla,
@ -258,9 +254,9 @@ class BigNumberVis extends React.PureComponent<BigNumberVisProps> {
formattedVal: this.props.xValueFormatter?.(data[0]), formattedVal: this.props.xValueFormatter?.(data[0]),
}); });
this.props.onContextMenu( this.props.onContextMenu(
filters,
pointerEvent.clientX, pointerEvent.clientX,
pointerEvent.clientY, pointerEvent.clientY,
filters,
); );
} }
} }

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * 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 controlPanel from './controlPanel';
import transformProps from './transformProps'; import transformProps from './transformProps';
import buildQuery from './buildQuery'; import buildQuery from './buildQuery';
@ -45,6 +45,7 @@ const metadata = new ChartMetadata({
t('Trend'), t('Trend'),
], ],
thumbnail, thumbnail,
behaviors: [Behavior.DRILL_TO_DETAIL],
}); });
export default class BigNumberWithTrendlineChartPlugin extends ChartPlugin< export default class BigNumberWithTrendlineChartPlugin extends ChartPlugin<

View File

@ -44,7 +44,7 @@ export default class EchartsBoxPlotChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('./EchartsBoxPlot'), loadChart: () => import('./EchartsBoxPlot'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART], behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL],
category: t('Distribution'), category: t('Distribution'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: t( description: t(

View File

@ -43,7 +43,7 @@ export default class EchartsFunnelChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('./EchartsFunnel'), loadChart: () => import('./EchartsFunnel'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART], behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL],
category: t('KPI'), category: t('KPI'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: t( description: t(

View File

@ -33,7 +33,7 @@ export default class EchartsGaugeChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('./EchartsGauge'), loadChart: () => import('./EchartsGauge'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART], behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL],
category: t('KPI'), category: t('KPI'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: t( description: t(

View File

@ -17,7 +17,7 @@
* under the License. * under the License.
*/ */
import React from 'react'; import React from 'react';
import { QueryObjectFilterClause } from '@superset-ui/core'; import { BinaryQueryObjectFilterClause } from '@superset-ui/core';
import { EventHandlers } from '../types'; import { EventHandlers } from '../types';
import Echart from '../components/Echart'; import Echart from '../components/Echart';
import { GraphChartTransformedProps } from './types'; import { GraphChartTransformedProps } from './types';
@ -47,7 +47,7 @@ export default function EchartsGraph({
const sourceValue = data.find(item => item.id === e.data.source)?.name; const sourceValue = data.find(item => item.id === e.data.source)?.name;
const targetValue = data.find(item => item.id === e.data.target)?.name; const targetValue = data.find(item => item.id === e.data.target)?.name;
if (sourceValue && targetValue) { if (sourceValue && targetValue) {
const filters: QueryObjectFilterClause[] = [ const filters: BinaryQueryObjectFilterClause[] = [
{ {
col: formData.source, col: formData.source,
op: '==', op: '==',
@ -61,7 +61,7 @@ export default function EchartsGraph({
formattedVal: targetValue, formattedVal: targetValue,
}, },
]; ];
onContextMenu(filters, pointerEvent.clientX, pointerEvent.clientY); onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters);
} }
} }
}, },

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * 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 controlPanel from './controlPanel';
import transformProps from './transformProps'; import transformProps from './transformProps';
import thumbnail from './images/thumbnail.png'; import thumbnail from './images/thumbnail.png';
@ -46,6 +46,7 @@ export default class EchartsGraphChartPlugin extends ChartPlugin {
t('Transformable'), t('Transformable'),
], ],
thumbnail, thumbnail,
behaviors: [Behavior.DRILL_TO_DETAIL],
}), }),
transformProps, transformProps,
}); });

View File

@ -19,7 +19,7 @@
import { import {
PlainObject, PlainObject,
QueryFormData, QueryFormData,
QueryObjectFilterClause, BinaryQueryObjectFilterClause,
} from '@superset-ui/core'; } from '@superset-ui/core';
import { GraphNodeItemOption } from 'echarts/types/src/chart/graph/GraphSeries'; import { GraphNodeItemOption } from 'echarts/types/src/chart/graph/GraphSeries';
import { SeriesTooltipOption } from 'echarts/types/src/util/types'; import { SeriesTooltipOption } from 'echarts/types/src/util/types';
@ -88,8 +88,8 @@ export type tooltipFormatParams = {
export type GraphChartTransformedProps = EchartsProps & { export type GraphChartTransformedProps = EchartsProps & {
formData: PlainObject; formData: PlainObject;
onContextMenu?: ( onContextMenu?: (
filters: QueryObjectFilterClause[],
clientX: number, clientX: number,
clientY: number, clientY: number,
filters?: BinaryQueryObjectFilterClause[],
) => void; ) => void;
}; };

View File

@ -21,7 +21,7 @@ import {
AxisType, AxisType,
DataRecordValue, DataRecordValue,
DTTM_ALIAS, DTTM_ALIAS,
QueryObjectFilterClause, BinaryQueryObjectFilterClause,
} from '@superset-ui/core'; } from '@superset-ui/core';
import { EchartsMixedTimeseriesChartTransformedProps } from './types'; import { EchartsMixedTimeseriesChartTransformedProps } from './types';
import Echart from '../components/Echart'; import Echart from '../components/Echart';
@ -128,7 +128,7 @@ export default function EchartsMixedTimeseries({
eventParams.seriesName eventParams.seriesName
], ],
]; ];
const filters: QueryObjectFilterClause[] = []; const filters: BinaryQueryObjectFilterClause[] = [];
if (xAxis.type === AxisType.time) { if (xAxis.type === AxisType.time) {
filters.push({ filters.push({
col: col:
@ -154,7 +154,7 @@ export default function EchartsMixedTimeseries({
formattedVal: String(values[i]), formattedVal: String(values[i]),
}), }),
); );
onContextMenu(filters, pointerEvent.clientX, pointerEvent.clientY); onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters);
} }
} }
}, },

View File

@ -53,7 +53,7 @@ export default class EchartsTimeseriesChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('./EchartsMixedTimeseries'), loadChart: () => import('./EchartsMixedTimeseries'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART], behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL],
category: t('Evolution'), category: t('Evolution'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: hasGenericChartAxes description: hasGenericChartAxes

View File

@ -47,7 +47,7 @@ export default class EchartsPieChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('./EchartsPie'), loadChart: () => import('./EchartsPie'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART], behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL],
category: t('Part of a Whole'), category: t('Part of a Whole'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: description:

View File

@ -44,7 +44,7 @@ export default class EchartsRadarChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('./EchartsRadar'), loadChart: () => import('./EchartsRadar'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART], behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL],
category: t('Ranking'), category: t('Ranking'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: t( description: t(

View File

@ -50,7 +50,7 @@ export default class EchartsAreaChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('../EchartsTimeseries'), loadChart: () => import('../EchartsTimeseries'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART], behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL],
category: t('Evolution'), category: t('Evolution'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: hasGenericChartAxes description: hasGenericChartAxes

View File

@ -19,7 +19,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { import {
DTTM_ALIAS, DTTM_ALIAS,
QueryObjectFilterClause, BinaryQueryObjectFilterClause,
AxisType, AxisType,
} from '@superset-ui/core'; } from '@superset-ui/core';
import { ViewRootGroup } from 'echarts/types/src/util/types'; import { ViewRootGroup } from 'echarts/types/src/util/types';
@ -191,7 +191,7 @@ export default function EchartsTimeseries({
...(eventParams.name ? [eventParams.name] : []), ...(eventParams.name ? [eventParams.name] : []),
...labelMap[eventParams.seriesName], ...labelMap[eventParams.seriesName],
]; ];
const filters: QueryObjectFilterClause[] = []; const filters: BinaryQueryObjectFilterClause[] = [];
if (xAxis.type === AxisType.time) { if (xAxis.type === AxisType.time) {
filters.push({ filters.push({
col: col:
@ -216,7 +216,7 @@ export default function EchartsTimeseries({
formattedVal: String(values[i]), formattedVal: String(values[i]),
}), }),
); );
onContextMenu(filters, pointerEvent.clientX, pointerEvent.clientY); onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters);
} }
} }
}, },

View File

@ -56,7 +56,7 @@ export default class EchartsTimeseriesBarChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('../../EchartsTimeseries'), loadChart: () => import('../../EchartsTimeseries'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART], behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL],
category: t('Evolution'), category: t('Evolution'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: hasGenericChartAxes description: hasGenericChartAxes

View File

@ -55,7 +55,7 @@ export default class EchartsTimeseriesLineChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('../../EchartsTimeseries'), loadChart: () => import('../../EchartsTimeseries'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART], behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL],
category: t('Evolution'), category: t('Evolution'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: hasGenericChartAxes description: hasGenericChartAxes

View File

@ -54,7 +54,7 @@ export default class EchartsTimeseriesScatterChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('../../EchartsTimeseries'), loadChart: () => import('../../EchartsTimeseries'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART], behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL],
category: t('Evolution'), category: t('Evolution'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: hasGenericChartAxes description: hasGenericChartAxes

View File

@ -54,7 +54,7 @@ export default class EchartsTimeseriesSmoothLineChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('../../EchartsTimeseries'), loadChart: () => import('../../EchartsTimeseries'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART], behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL],
category: t('Evolution'), category: t('Evolution'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: hasGenericChartAxes description: hasGenericChartAxes

View File

@ -45,7 +45,7 @@ export default class EchartsTimeseriesStepChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('../EchartsTimeseries'), loadChart: () => import('../EchartsTimeseries'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART], behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL],
category: t('Evolution'), category: t('Evolution'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: hasGenericChartAxes description: hasGenericChartAxes

View File

@ -44,7 +44,7 @@ export default class EchartsTimeseriesChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('./EchartsTimeseries'), loadChart: () => import('./EchartsTimeseries'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART], behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL],
category: t('Evolution'), category: t('Evolution'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: hasGenericChartAxes description: hasGenericChartAxes

View File

@ -16,7 +16,10 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { DataRecordValue, QueryObjectFilterClause } from '@superset-ui/core'; import {
DataRecordValue,
BinaryQueryObjectFilterClause,
} from '@superset-ui/core';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import Echart from '../components/Echart'; import Echart from '../components/Echart';
import { NULL_STRING } from '../constants'; import { NULL_STRING } from '../constants';
@ -93,7 +96,7 @@ export default function EchartsTreemap({
const { treePath } = extractTreePathInfo(eventParams.treePathInfo); const { treePath } = extractTreePathInfo(eventParams.treePathInfo);
if (treePath.length > 0) { if (treePath.length > 0) {
const pointerEvent = eventParams.event.event; const pointerEvent = eventParams.event.event;
const filters: QueryObjectFilterClause[] = []; const filters: BinaryQueryObjectFilterClause[] = [];
treePath.forEach((path, i) => treePath.forEach((path, i) =>
filters.push({ filters.push({
col: groupby[i], col: groupby[i],
@ -102,7 +105,7 @@ export default function EchartsTreemap({
formattedVal: path, formattedVal: path,
}), }),
); );
onContextMenu(filters, pointerEvent.clientX, pointerEvent.clientY); onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters);
} }
} }
}, },

View File

@ -46,7 +46,7 @@ export default class EchartsTreemapChartPlugin extends ChartPlugin<
controlPanel, controlPanel,
loadChart: () => import('./EchartsTreemap'), loadChart: () => import('./EchartsTreemap'),
metadata: new ChartMetadata({ metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART], behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL],
category: t('Part of a Whole'), category: t('Part of a Whole'),
credits: ['https://echarts.apache.org'], credits: ['https://echarts.apache.org'],
description: t( description: t(

View File

@ -19,7 +19,7 @@
import { import {
HandlerFunction, HandlerFunction,
QueryFormColumn, QueryFormColumn,
QueryObjectFilterClause, BinaryQueryObjectFilterClause,
SetDataMaskHook, SetDataMaskHook,
} from '@superset-ui/core'; } from '@superset-ui/core';
import { EChartsCoreOption, ECharts } from 'echarts'; import { EChartsCoreOption, ECharts } from 'echarts';
@ -116,9 +116,9 @@ export interface EChartTransformedProps<F> {
selectedValues: Record<number, string>; selectedValues: Record<number, string>;
legendData?: OptionName[]; legendData?: OptionName[];
onContextMenu?: ( onContextMenu?: (
filters: QueryObjectFilterClause[],
clientX: number, clientX: number,
clientY: number, clientY: number,
filters?: BinaryQueryObjectFilterClause[],
) => void; ) => void;
} }

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { QueryObjectFilterClause } from '@superset-ui/core'; import { BinaryQueryObjectFilterClause } from '@superset-ui/core';
import { EChartTransformedProps, EventHandlers } from '../types'; import { EChartTransformedProps, EventHandlers } from '../types';
export type Event = { export type Event = {
@ -48,7 +48,7 @@ export const contextMenuEventHandler =
if (onContextMenu) { if (onContextMenu) {
e.event.stop(); e.event.stop();
const pointerEvent = e.event.event; const pointerEvent = e.event.event;
const filters: QueryObjectFilterClause[] = []; const filters: BinaryQueryObjectFilterClause[] = [];
if (groupby.length > 0) { if (groupby.length > 0) {
const values = labelMap[e.name]; const values = labelMap[e.name];
groupby.forEach((dimension, i) => groupby.forEach((dimension, i) =>
@ -60,7 +60,7 @@ export const contextMenuEventHandler =
}), }),
); );
} }
onContextMenu(filters, pointerEvent.clientX, pointerEvent.clientY); onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters);
} }
}; };

View File

@ -28,7 +28,7 @@ import {
styled, styled,
useTheme, useTheme,
isAdhocColumn, isAdhocColumn,
QueryObjectFilterClause, BinaryQueryObjectFilterClause,
} from '@superset-ui/core'; } from '@superset-ui/core';
import { PivotTable, sortAs, aggregatorTemplates } from './react-pivottable'; import { PivotTable, sortAs, aggregatorTemplates } from './react-pivottable';
import { import {
@ -370,7 +370,8 @@ export default function PivotTableChart(props: PivotTableProps) {
) => { ) => {
if (onContextMenu) { if (onContextMenu) {
e.preventDefault(); e.preventDefault();
const filters: QueryObjectFilterClause[] = []; e.stopPropagation();
const filters: BinaryQueryObjectFilterClause[] = [];
if (colKey && colKey.length > 1) { if (colKey && colKey.length > 1) {
colKey.forEach((val, i) => { colKey.forEach((val, i) => {
const col = cols[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], [cols, dateFormatters, onContextMenu, rows],

View File

@ -46,7 +46,7 @@ export default class PivotTableChartPlugin extends ChartPlugin<
*/ */
constructor() { constructor() {
const metadata = new ChartMetadata({ const metadata = new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART], behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL],
category: t('Table'), category: t('Table'),
description: t( 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.', '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.',

View File

@ -26,7 +26,7 @@ import {
NumberFormatter, NumberFormatter,
QueryFormMetric, QueryFormMetric,
QueryFormColumn, QueryFormColumn,
QueryObjectFilterClause, BinaryQueryObjectFilterClause,
} from '@superset-ui/core'; } from '@superset-ui/core';
import { ColorFormatters } from '@superset-ui/chart-controls'; import { ColorFormatters } from '@superset-ui/chart-controls';
@ -74,9 +74,9 @@ interface PivotTableCustomizeProps {
legacy_order_by: QueryFormMetric[] | QueryFormMetric | null; legacy_order_by: QueryFormMetric[] | QueryFormMetric | null;
order_desc: boolean; order_desc: boolean;
onContextMenu?: ( onContextMenu?: (
filters: QueryObjectFilterClause[],
clientX: number, clientX: number,
clientY: number, clientY: number,
filters?: BinaryQueryObjectFilterClause[],
) => void; ) => void;
} }

View File

@ -279,6 +279,7 @@ export default typedMemo(function DataTable<D extends object>({
onContextMenu={(e: MouseEvent) => { onContextMenu={(e: MouseEvent) => {
if (onContextMenu) { if (onContextMenu) {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
onContextMenu( onContextMenu(
row.original, row.original,
e.nativeEvent.clientX, e.nativeEvent.clientX,

View File

@ -40,7 +40,7 @@ import {
ensureIsArray, ensureIsArray,
GenericDataType, GenericDataType,
getTimeFormatterForGranularity, getTimeFormatterForGranularity,
QueryObjectFilterClause, BinaryQueryObjectFilterClause,
styled, styled,
css, css,
t, t,
@ -630,7 +630,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
const handleContextMenu = const handleContextMenu =
onContextMenu && !isRawRecords onContextMenu && !isRawRecords
? (value: D, clientX: number, clientY: number) => { ? (value: D, clientX: number, clientY: number) => {
const filters: QueryObjectFilterClause[] = []; const filters: BinaryQueryObjectFilterClause[] = [];
columnsMeta.forEach(col => { columnsMeta.forEach(col => {
if (!col.isMetric) { if (!col.isMetric) {
const dataRecordValue = value[col.key]; const dataRecordValue = value[col.key];
@ -642,7 +642,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
}); });
} }
}); });
onContextMenu(filters, clientX, clientY); onContextMenu(clientX, clientY, filters);
} }
: undefined; : undefined;

View File

@ -31,7 +31,7 @@ export { default as __hack__ } from './types';
export * from './types'; export * from './types';
const metadata = new ChartMetadata({ const metadata = new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART], behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL],
category: t('Table'), category: t('Table'),
canBeAnnotationTypes: ['EVENT', 'INTERVAL'], canBeAnnotationTypes: ['EVENT', 'INTERVAL'],
description: t( description: t(

View File

@ -30,7 +30,7 @@ import {
ChartDataResponseResult, ChartDataResponseResult,
QueryFormData, QueryFormData,
SetDataMaskHook, SetDataMaskHook,
QueryObjectFilterClause, BinaryQueryObjectFilterClause,
} from '@superset-ui/core'; } from '@superset-ui/core';
import { ColorFormatters, ColumnConfig } from '@superset-ui/chart-controls'; import { ColorFormatters, ColumnConfig } from '@superset-ui/chart-controls';
@ -113,9 +113,9 @@ export interface TableChartTransformedProps<D extends DataRecord = DataRecord> {
columnColorFormatters?: ColorFormatters; columnColorFormatters?: ColorFormatters;
allowRearrangeColumns?: boolean; allowRearrangeColumns?: boolean;
onContextMenu?: ( onContextMenu?: (
filters: QueryObjectFilterClause[],
clientX: number, clientX: number,
clientY: number, clientY: number,
filters?: BinaryQueryObjectFilterClause[],
) => void; ) => void;
} }

View File

@ -37,13 +37,13 @@ export default {
viz_type: 'pie', viz_type: 'pie',
slice_id: sliceId, slice_id: sliceId,
slice_name: 'Genders', slice_name: 'Genders',
granularity_sqla: null, granularity_sqla: undefined,
time_grain_sqla: null, time_grain_sqla: undefined,
since: '100 years ago', since: '100 years ago',
until: 'now', until: 'now',
metrics: ['sum__num'], metrics: ['sum__num'],
groupby: ['gender'], groupby: ['gender'],
limit: '25', limit: 25,
pie_label_type: 'key', pie_label_type: 'key',
donut: false, donut: false,
show_legend: true, show_legend: true,

View File

@ -29,6 +29,7 @@ import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
import { URL_PARAMS } from 'src/constants'; import { URL_PARAMS } from 'src/constants';
import { getUrlParam } from 'src/utils/urlUtils'; import { getUrlParam } from 'src/utils/urlUtils';
import { isCurrentUserBot } from 'src/utils/isBot'; import { isCurrentUserBot } from 'src/utils/isBot';
import { ChartSource } from 'src/types/ChartSource';
import { ResourceStatus } from 'src/hooks/apiResources/apiResources'; import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
import ChartRenderer from './ChartRenderer'; import ChartRenderer from './ChartRenderer';
import { ChartErrorMessage } from './ChartErrorMessage'; import { ChartErrorMessage } from './ChartErrorMessage';
@ -237,7 +238,7 @@ class Chart extends React.PureComponent {
subtitle={<MonospaceDiv>{message}</MonospaceDiv>} subtitle={<MonospaceDiv>{message}</MonospaceDiv>}
copyText={message} copyText={message}
link={queryResponse ? queryResponse.link : null} link={queryResponse ? queryResponse.link : null}
source={dashboardId ? 'dashboard' : 'explore'} source={dashboardId ? ChartSource.Dashboard : ChartSource.Explore}
stackTrace={chartStackTrace} stackTrace={chartStackTrace}
/> />
); );

View File

@ -23,82 +23,94 @@ import React, {
useImperativeHandle, useImperativeHandle,
useState, useState,
} from 'react'; } 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 { Menu } from 'src/components/Menu';
import { AntdDropdown as Dropdown } from 'src/components'; import { AntdDropdown as Dropdown } from 'src/components';
import ReactDOM from 'react-dom'; import { DrillDetailMenuItems } from './DrillDetail';
const MENU_ITEM_HEIGHT = 32; const MENU_ITEM_HEIGHT = 32;
const MENU_VERTICAL_SPACING = 32; const MENU_VERTICAL_SPACING = 32;
export interface ChartContextMenuProps { export interface ChartContextMenuProps {
id: string; id: number;
onSelection: (filters: QueryObjectFilterClause[]) => void; formData: QueryFormData;
onSelection: () => void;
onClose: () => void; onClose: () => void;
} }
export interface Ref { export interface Ref {
open: ( open: (
filters: QueryObjectFilterClause[],
clientX: number, clientX: number,
clientY: number, clientY: number,
filters?: BinaryQueryObjectFilterClause[],
) => void; ) => void;
} }
const Filter = styled.span`
${({ theme }) => `
font-weight: ${theme.typography.weights.bold};
color: ${theme.colors.primary.base};
`}
`;
const ChartContextMenu = ( const ChartContextMenu = (
{ id, onSelection, onClose }: ChartContextMenuProps, { id, formData, onSelection, onClose }: ChartContextMenuProps,
ref: RefObject<Ref>, ref: RefObject<Ref>,
) => { ) => {
const [state, setState] = useState<{ const canExplore = useSelector((state: RootState) =>
filters: QueryObjectFilterClause[]; findPermission('can_explore', 'Superset', state.user?.roles),
clientX: number;
clientY: number;
}>({ filters: [], clientX: 0, clientY: 0 });
const menu = (
<Menu>
{state.filters.map((filter, i) => (
<Menu.Item key={i} onClick={() => onSelection([filter])}>
{`${t('Drill to detail by')} `}
<Filter>{filter.formattedVal}</Filter>
</Menu.Item>
))}
{state.filters.length === 0 && (
<Menu.Item key="none" onClick={() => onSelection([])}>
{t('Drill to detail')}
</Menu.Item>
)}
{state.filters.length > 1 && (
<Menu.Item key="all" onClick={() => onSelection(state.filters)}>
{`${t('Drill to detail by')} `}
<Filter>{t('all')}</Filter>
</Menu.Item>
)}
</Menu>
); );
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(
<DrillDetailMenuItems
chartId={id}
formData={formData}
isContextMenu
filters={filters}
onSelection={onSelection}
/>,
);
}
const open = useCallback( const open = useCallback(
(filters: QueryObjectFilterClause[], clientX: number, clientY: number) => { (
clientX: number,
clientY: number,
filters?: BinaryQueryObjectFilterClause[],
) => {
// Viewport height // Viewport height
const vh = Math.max( const vh = Math.max(
document.documentElement.clientHeight || 0, document.documentElement.clientHeight || 0,
window.innerHeight || 0, window.innerHeight || 0,
); );
// +1 for automatically added options such as 'All' and 'Drill to detail' const itemsCount =
const itemsCount = filters.length + 1; [
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; const menuHeight = MENU_ITEM_HEIGHT * itemsCount + MENU_VERTICAL_SPACING;
// Always show the context menu inside the viewport // Always show the context menu inside the viewport
const adjustedY = vh - clientY < menuHeight ? vh - menuHeight : clientY; 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 // Since Ant Design's Dropdown does not offer an imperative API
// and we can't attach event triggers to charts SVG elements, we // and we can't attach event triggers to charts SVG elements, we
@ -106,7 +118,7 @@ const ChartContextMenu = (
// from the charts. // from the charts.
document.getElementById(`hidden-span-${id}`)?.click(); document.getElementById(`hidden-span-${id}`)?.click();
}, },
[id], [id, showDrillToDetail],
); );
useImperativeHandle( useImperativeHandle(
@ -119,7 +131,15 @@ const ChartContextMenu = (
return ReactDOM.createPortal( return ReactDOM.createPortal(
<Dropdown <Dropdown
overlay={menu} overlay={
<Menu>
{menuItems.length ? (
menuItems
) : (
<Menu.Item disabled>No actions</Menu.Item>
)}
</Menu>
}
trigger={['click']} trigger={['click']}
onVisibleChange={value => !value && onClose()} onVisibleChange={value => !value && onClose()}
> >
@ -128,8 +148,8 @@ const ChartContextMenu = (
css={{ css={{
visibility: 'hidden', visibility: 'hidden',
position: 'fixed', position: 'fixed',
top: state.clientY, top: clientY,
left: state.clientX, left: clientX,
width: 1, width: 1,
height: 1, height: 1,
}} }}

View File

@ -26,11 +26,12 @@ import {
t, t,
isFeatureEnabled, isFeatureEnabled,
FeatureFlag, FeatureFlag,
getChartMetadataRegistry,
} from '@superset-ui/core'; } from '@superset-ui/core';
import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils'; import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
import { EmptyStateBig, EmptyStateSmall } from 'src/components/EmptyState'; import { EmptyStateBig, EmptyStateSmall } from 'src/components/EmptyState';
import { ChartSource } from 'src/types/ChartSource';
import ChartContextMenu from './ChartContextMenu'; import ChartContextMenu from './ChartContextMenu';
import DrillDetailModal from './DrillDetailModal';
const propTypes = { const propTypes = {
annotationData: PropTypes.object, annotationData: PropTypes.object,
@ -60,7 +61,7 @@ const propTypes = {
onFilterMenuClose: PropTypes.func, onFilterMenuClose: PropTypes.func,
ownState: PropTypes.object, ownState: PropTypes.object,
postTransformProps: PropTypes.func, postTransformProps: PropTypes.func,
source: PropTypes.oneOf(['dashboard', 'explore']), source: PropTypes.oneOf([ChartSource.Dashboard, ChartSource.Explore]),
}; };
const BLANK = {}; const BLANK = {};
@ -83,8 +84,10 @@ class ChartRenderer extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
showContextMenu:
props.source === ChartSource.Dashboard &&
isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL),
inContextMenu: false, inContextMenu: false,
drillDetailFilters: null,
}; };
this.hasQueryResponseChange = false; this.hasQueryResponseChange = false;
@ -97,14 +100,13 @@ class ChartRenderer extends React.Component {
this.handleOnContextMenu = this.handleOnContextMenu.bind(this); this.handleOnContextMenu = this.handleOnContextMenu.bind(this);
this.handleContextMenuSelected = this.handleContextMenuSelected.bind(this); this.handleContextMenuSelected = this.handleContextMenuSelected.bind(this);
this.handleContextMenuClosed = this.handleContextMenuClosed.bind(this); this.handleContextMenuClosed = this.handleContextMenuClosed.bind(this);
this.onContextMenuFallback = this.onContextMenuFallback.bind(this);
const showContextMenu =
props.source === 'dashboard' &&
isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL);
this.hooks = { this.hooks = {
onAddFilter: this.handleAddFilter, onAddFilter: this.handleAddFilter,
onContextMenu: showContextMenu ? this.handleOnContextMenu : undefined, onContextMenu: this.state.showContextMenu
? this.handleOnContextMenu
: undefined,
onError: this.handleRenderFailure, onError: this.handleRenderFailure,
setControlValue: this.handleSetControlValue, setControlValue: this.handleSetControlValue,
onFilterMenuOpen: this.props.onFilterMenuOpen, onFilterMenuOpen: this.props.onFilterMenuOpen,
@ -198,19 +200,28 @@ class ChartRenderer extends React.Component {
} }
} }
handleOnContextMenu(filters, offsetX, offsetY) { handleOnContextMenu(offsetX, offsetY, filters) {
this.contextMenuRef.current.open(filters, offsetX, offsetY); this.contextMenuRef.current.open(offsetX, offsetY, filters);
this.setState({ inContextMenu: true }); this.setState({ inContextMenu: true });
} }
handleContextMenuSelected(filters) { handleContextMenuSelected() {
this.setState({ inContextMenu: false, drillDetailFilters: filters }); this.setState({ inContextMenu: false });
} }
handleContextMenuClosed() { handleContextMenuClosed() {
this.setState({ inContextMenu: false }); 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() { render() {
const { chartAlert, chartStatus, chartId } = this.props; const { chartAlert, chartStatus, chartId } = this.props;
@ -265,7 +276,7 @@ class ChartRenderer extends React.Component {
let noResultsComponent; let noResultsComponent;
const noResultTitle = t('No results were returned for this query'); const noResultTitle = t('No results were returned for this query');
const noResultDescription = const noResultDescription =
this.props.source === 'explore' this.props.source === ChartSource.Explore
? t( ? t(
'Make sure that the controls are configured properly and the datasource contains data for the selected time range', '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 ( return (
<div> <>
{this.props.source === 'dashboard' && ( {this.state.showContextMenu && (
<> <ChartContextMenu
<ChartContextMenu ref={this.contextMenuRef}
ref={this.contextMenuRef} id={chartId}
id={chartId} formData={currentFormData}
onSelection={this.handleContextMenuSelected} onSelection={this.handleContextMenuSelected}
onClose={this.handleContextMenuClosed} onClose={this.handleContextMenuClosed}
/> />
<DrillDetailModal
chartId={chartId}
initialFilters={this.state.drillDetailFilters}
formData={currentFormData}
/>
</>
)} )}
<SuperChart <div
disableErrorBoundary onContextMenu={
key={`${chartId}${webpackHash}`} this.state.showContextMenu ? this.onContextMenuFallback : undefined
id={`chart-id-${chartId}`} }
className={chartClassName} >
chartType={vizType} <SuperChart
width={width} disableErrorBoundary
height={height} key={`${chartId}${webpackHash}`}
annotationData={annotationData} id={`chart-id-${chartId}`}
datasource={datasource} className={chartClassName}
initialValues={initialValues} chartType={vizType}
formData={currentFormData} width={width}
ownState={ownState} height={height}
filterState={filterState} annotationData={annotationData}
hooks={this.hooks} datasource={datasource}
behaviors={behaviors} initialValues={initialValues}
queriesData={queriesResponse} formData={currentFormData}
onRenderSuccess={this.handleRenderSuccess} ownState={ownState}
onRenderFailure={this.handleRenderFailure} filterState={filterState}
noResults={noResultsComponent} hooks={this.hooks}
postTransformProps={postTransformProps} behaviors={behaviors}
inContextMenu={this.state.inContextMenu} queriesData={queriesResponse}
/> onRenderSuccess={this.handleRenderSuccess}
</div> onRenderFailure={this.handleRenderFailure}
noResults={noResultsComponent}
postTransformProps={postTransformProps}
{...drillToDetailProps}
/>
</div>
</>
); );
} }
} }

View File

@ -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[] }) =>
<pre data-test="modal-filters">{JSON.stringify(initialFilters)}</pre>,
);
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<DrillDetailMenuItemsProps>) => {
const store = getMockStoreWithNativeFilters();
return render(
<Menu>
<DrillDetailMenuItems
chartId={chartId ?? defaultChartId}
formData={formData ?? defaultFormData}
filters={filters}
isContextMenu={isContextMenu}
/>
</Menu>,
{ 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);
});

View File

@ -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 }) => (
<Tooltip title={title} placement="top">
<Icons.InfoCircleOutlined
data-test="tooltip-trigger"
css={(theme: SupersetTheme) => css`
color: ${theme.colors.text.label};
margin-left: ${theme.gridUnit * 2}px;
&.anticon {
font-size: unset;
.anticon {
line-height: unset;
vertical-align: unset;
}
}
`}
/>
</Tooltip>
);
const DisabledMenuItem = ({ children, ...props }: { children: ReactNode }) => (
<Menu.Item disabled {...props}>
<div
css={css`
white-space: normal;
max-width: 160px;
`}
>
{children}
</div>
</Menu.Item>
);
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<BinaryQueryObjectFilterClause[]>(
[],
);
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 = (
<DisabledMenuItem {...props} key="drill-detail-no-aggregations">
{t('Drill to detail')}
<DisabledMenuItemTooltip
title={t(
'Drill to detail is disabled because this chart does not group data by dimension value.',
)}
/>
</DisabledMenuItem>
);
} else {
drillToDetailMenuItem = (
<Menu.Item
{...props}
key="drill-detail-no-filters"
onClick={openModal.bind(null, [])}
>
{t('Drill to detail')}
</Menu.Item>
);
}
let drillToDetailByMenuItem;
if (!handlesDimensionContextMenu) {
drillToDetailByMenuItem = (
<DisabledMenuItem {...props} key="drill-detail-by-chart-not-supported">
{t('Drill to detail by')}
<DisabledMenuItemTooltip
title={t(
'Drill to detail by value is not yet supported for this chart type.',
)}
/>
</DisabledMenuItem>
);
}
if (handlesDimensionContextMenu && noAggregations) {
drillToDetailByMenuItem = (
<DisabledMenuItem {...props} key="drill-detail-by-no-aggregations">
{t('Drill to detail by')}
</DisabledMenuItem>
);
}
if (handlesDimensionContextMenu && !noAggregations && filters?.length) {
drillToDetailByMenuItem = (
<Menu.SubMenu {...props} title={t('Drill to detail by')}>
<div data-test="drill-to-detail-by-submenu">
{filters.map((filter, i) => (
<Menu.Item
{...props}
key={`drill-detail-filter-${i}`}
onClick={openModal.bind(null, [filter])}
>
{`${t('Drill to detail by')} `}
<Filter>{filter.formattedVal}</Filter>
</Menu.Item>
))}
{filters.length > 1 && (
<Menu.Item
{...props}
key="drill-detail-filter-all"
onClick={openModal.bind(null, filters)}
>
{`${t('Drill to detail by')} `}
<Filter>{t('all')}</Filter>
</Menu.Item>
)}
</div>
</Menu.SubMenu>
);
}
if (handlesDimensionContextMenu && !noAggregations && !filters?.length) {
drillToDetailByMenuItem = (
<DisabledMenuItem {...props} key="drill-detail-by-select-aggregation">
{t('Drill to detail by')}
<DisabledMenuItemTooltip
title={t(
'Right-click on a dimension value to drill to detail by that value.',
)}
/>
</DisabledMenuItem>
);
}
return (
<>
{drillToDetailMenuItem}
{isContextMenu && drillToDetailByMenuItem}
<DrillDetailModal
chartId={chartId}
formData={formData}
initialFilters={modalFilters}
showModal={showModal}
onHideModal={closeModal}
/>
</>
);
};
export default DrillDetailMenuItems;

View File

@ -16,44 +16,15 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * 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 { getMockStoreWithNativeFilters } from 'spec/fixtures/mockStore';
import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries'; 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'; import DrillDetailModal from './DrillDetailModal';
const chart = chartQueries[sliceId]; jest.mock('./DrillDetailPane', () => () => null);
const setup = (overrides: Record<string, any> = {}) => {
const store = getMockStoreWithNativeFilters();
const props = {
chartId: sliceId,
initialFilters: [],
formData: chart.form_data as unknown as QueryFormData,
...overrides,
};
return render(<DrillDetailModal {...props} />, {
useRedux: true,
useRouter: true,
store,
});
};
const waitForRender = (overrides: Record<string, any> = {}) =>
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: [],
},
},
);
const mockHistoryPush = jest.fn(); const mockHistoryPush = jest.fn();
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual('react-router-dom'),
@ -62,32 +33,46 @@ jest.mock('react-router-dom', () => ({
}), }),
})); }));
test('should render', async () => { const { id: chartId, form_data: formData } = chartQueries[sliceId];
const { container } = await waitForRender(); const { slice_name: chartName } = formData;
expect(container).toBeInTheDocument();
}); const renderModal = async () => {
const store = getMockStoreWithNativeFilters();
const DrillDetailModalWrapper = () => {
const [showModal, setShowModal] = useState(false);
return (
<>
<button type="button" onClick={() => setShowModal(true)}>
Show modal
</button>
<DrillDetailModal
chartId={chartId}
formData={formData}
initialFilters={[]}
showModal={showModal}
onHideModal={() => setShowModal(false)}
/>
</>
);
};
render(<DrillDetailModalWrapper />, {
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 () => { test('should render the title', async () => {
await waitForRender(); await renderModal();
expect( expect(screen.getByText(`Drill to detail: ${chartName}`)).toBeInTheDocument();
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();
}); });
test('should render the button', async () => { test('should render the button', async () => {
await waitForRender(); await renderModal();
expect( expect(
screen.getByRole('button', { name: 'Edit chart' }), screen.getByRole('button', { name: 'Edit chart' }),
).toBeInTheDocument(); ).toBeInTheDocument();
@ -95,14 +80,14 @@ test('should render the button', async () => {
}); });
test('should close the modal', async () => { test('should close the modal', async () => {
await waitForRender(); await renderModal();
expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
userEvent.click(screen.getAllByRole('button', { name: 'Close' })[1]); userEvent.click(screen.getAllByRole('button', { name: 'Close' })[1]);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
}); });
test('should forward to Explore', async () => { test('should forward to Explore', async () => {
await waitForRender(); await renderModal();
userEvent.click(screen.getByRole('button', { name: 'Edit chart' })); userEvent.click(screen.getByRole('button', { name: 'Edit chart' }));
expect(mockHistoryPush).toHaveBeenCalledWith( expect(mockHistoryPush).toHaveBeenCalledWith(
`/explore/?dashboard_page_id=&slice_id=${sliceId}`, `/explore/?dashboard_page_id=&slice_id=${sliceId}`,

View File

@ -17,14 +17,7 @@
* under the License. * under the License.
*/ */
import React, { import React, { useCallback, useContext, useMemo } from 'react';
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { import {
BinaryQueryObjectFilterClause, BinaryQueryObjectFilterClause,
@ -33,22 +26,51 @@ import {
t, t,
useTheme, useTheme,
} from '@superset-ui/core'; } 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 { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
import { Slice } from 'src/types/Chart'; import { Slice } from 'src/types/Chart';
import Modal from '../Modal'; import DrillDetailPane from './DrillDetailPane';
import Button from '../Button';
const DrillDetailModal: React.FC<{ interface ModalFooterProps {
exploreChart: () => void;
closeModal?: () => void;
}
const ModalFooter = ({ exploreChart, closeModal }: ModalFooterProps) => (
<>
<Button buttonStyle="secondary" buttonSize="small" onClick={exploreChart}>
{t('Edit chart')}
</Button>
<Button
buttonStyle="primary"
buttonSize="small"
onClick={closeModal}
data-test="close-drilltodetail-modal"
>
{t('Close')}
</Button>
</>
);
interface DrillDetailModalProps {
chartId: number; chartId: number;
initialFilters?: BinaryQueryObjectFilterClause[];
formData: QueryFormData; formData: QueryFormData;
}> = ({ chartId, initialFilters, formData }) => { initialFilters: BinaryQueryObjectFilterClause[];
const [showModal, setShowModal] = useState(false); showModal: boolean;
const openModal = useCallback(() => setShowModal(true), []); onHideModal: () => void;
const closeModal = useCallback(() => setShowModal(false), []); }
const history = useHistory();
export default function DrillDetailModal({
chartId,
formData,
initialFilters,
showModal,
onHideModal,
}: DrillDetailModalProps) {
const theme = useTheme(); const theme = useTheme();
const history = useHistory();
const dashboardPageId = useContext(DashboardPageIdContext); const dashboardPageId = useContext(DashboardPageIdContext);
const { slice_name: chartName } = useSelector( const { slice_name: chartName } = useSelector(
(state: { sliceEntities: { slices: Record<number, Slice> } }) => (state: { sliceEntities: { slices: Record<number, Slice> } }) =>
@ -64,43 +86,18 @@ const DrillDetailModal: React.FC<{
history.push(exploreUrl); history.push(exploreUrl);
}, [exploreUrl, history]); }, [exploreUrl, history]);
// Trigger modal open when initial filters change
useEffect(() => {
if (initialFilters) {
openModal();
}
}, [initialFilters, openModal]);
return ( return (
<Modal <Modal
show={showModal}
onHide={onHideModal ?? (() => null)}
css={css` css={css`
.ant-modal-body { .ant-modal-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
`} `}
show={showModal}
onHide={closeModal}
title={t('Drill to detail: %s', chartName)} title={t('Drill to detail: %s', chartName)}
footer={ footer={<ModalFooter exploreChart={exploreChart} />}
<>
<Button
buttonStyle="secondary"
buttonSize="small"
onClick={exploreChart}
>
{t('Edit chart')}
</Button>
<Button
data-test="close-drilltodetail-modal"
buttonStyle="primary"
buttonSize="small"
onClick={closeModal}
>
{t('Close')}
</Button>
</>
}
responsive responsive
resizable resizable
resizableConfig={{ resizableConfig={{
@ -117,6 +114,4 @@ const DrillDetailModal: React.FC<{
<DrillDetailPane formData={formData} initialFilters={initialFilters} /> <DrillDetailPane formData={formData} initialFilters={initialFilters} />
</Modal> </Modal>
); );
}; }
export default DrillDetailModal;

View File

@ -44,7 +44,7 @@ import MetadataBar, {
} from 'src/components/MetadataBar'; } from 'src/components/MetadataBar';
import Alert from 'src/components/Alert'; import Alert from 'src/components/Alert';
import { useApiV1Resource } from 'src/hooks/apiResources'; import { useApiV1Resource } from 'src/hooks/apiResources';
import TableControls from './TableControls'; import TableControls from './DrillDetailTableControls';
import { getDrillPayload } from './utils'; import { getDrillPayload } from './utils';
import { Dataset, ResultsPage } from './types'; import { Dataset, ResultsPage } from './types';
@ -55,12 +55,12 @@ export default function DrillDetailPane({
initialFilters, initialFilters,
}: { }: {
formData: QueryFormData; formData: QueryFormData;
initialFilters?: BinaryQueryObjectFilterClause[]; initialFilters: BinaryQueryObjectFilterClause[];
}) { }) {
const theme = useTheme(); const theme = useTheme();
const [pageIndex, setPageIndex] = useState(0); const [pageIndex, setPageIndex] = useState(0);
const lastPageIndex = useRef(pageIndex); const lastPageIndex = useRef(pageIndex);
const [filters, setFilters] = useState(initialFilters || []); const [filters, setFilters] = useState(initialFilters);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [responseError, setResponseError] = useState(''); const [responseError, setResponseError] = useState('');
const [resultsPages, setResultsPages] = useState<Map<number, ResultsPage>>( const [resultsPages, setResultsPages] = useState<Map<number, ResultsPage>>(
@ -72,13 +72,13 @@ export default function DrillDetailPane({
state.common.conf.SAMPLES_ROW_LIMIT, state.common.conf.SAMPLES_ROW_LIMIT,
); );
// Extract datasource ID/type from string ID // Extract datasource ID/type from string ID
const [datasourceId, datasourceType] = useMemo( const [datasourceId, datasourceType] = useMemo(
() => formData.datasource.split('__'), () => formData.datasource.split('__'),
[formData.datasource], [formData.datasource],
); );
// Get page of results // Get page of results
const resultsPage = useMemo(() => { const resultsPage = useMemo(() => {
const nextResultsPage = resultsPages.get(pageIndex); const nextResultsPage = resultsPages.get(pageIndex);
if (nextResultsPage) { if (nextResultsPage) {
@ -98,7 +98,7 @@ export default function DrillDetailPane({
formData.datasource, formData.datasource,
); );
// Disable sorting on columns // Disable sorting on columns
const sortDisabledColumns = useMemo( const sortDisabledColumns = useMemo(
() => () =>
columns.map(column => ({ columns.map(column => ({
@ -108,26 +108,26 @@ export default function DrillDetailPane({
[columns], [columns],
); );
// Update page index on pagination click // Update page index on pagination click
const onServerPagination = useCallback(({ pageIndex }) => { const onServerPagination = useCallback(({ pageIndex }) => {
setPageIndex(pageIndex); setPageIndex(pageIndex);
}, []); }, []);
// Clear cache on reload button click // Clear cache on reload button click
const handleReload = useCallback(() => { const handleReload = useCallback(() => {
setResponseError(''); setResponseError('');
setResultsPages(new Map()); setResultsPages(new Map());
setPageIndex(0); setPageIndex(0);
}, []); }, []);
// Clear cache and reset page index if filters change // Clear cache and reset page index if filters change
useEffect(() => { useEffect(() => {
setResponseError(''); setResponseError('');
setResultsPages(new Map()); setResultsPages(new Map());
setPageIndex(0); setPageIndex(0);
}, [filters]); }, [filters]);
// Update cache order if page in cache // Update cache order if page in cache
useEffect(() => { useEffect(() => {
if ( if (
resultsPages.has(pageIndex) && resultsPages.has(pageIndex) &&
@ -144,7 +144,7 @@ export default function DrillDetailPane({
} }
}, [pageIndex, resultsPages]); }, [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(() => { useEffect(() => {
if (!responseError && !isLoading && !resultsPages.has(pageIndex)) { if (!responseError && !isLoading && !resultsPages.has(pageIndex)) {
setIsLoading(true); setIsLoading(true);
@ -196,7 +196,7 @@ export default function DrillDetailPane({
let tableContent = null; let tableContent = null;
if (responseError) { if (responseError) {
// Render error if page download failed // Render error if page download failed
tableContent = ( tableContent = (
<pre <pre
css={css` css={css`
@ -207,14 +207,14 @@ export default function DrillDetailPane({
</pre> </pre>
); );
} else if (!resultsPages.size) { } else if (!resultsPages.size) {
// Render loading if first page hasn't loaded // Render loading if first page hasn't loaded
tableContent = <Loading />; tableContent = <Loading />;
} else if (resultsPage?.total === 0) { } 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'); const title = t('No rows were returned for this dataset');
tableContent = <EmptyStateMedium image="document.svg" title={title} />; tableContent = <EmptyStateMedium image="document.svg" title={title} />;
} else { } else {
// Render table if at least one page has successfully loaded // Render table if at least one page has successfully loaded
tableContent = ( tableContent = (
<TableView <TableView
columns={sortDisabledColumns} columns={sortDisabledColumns}

View File

@ -19,7 +19,7 @@
import React from 'react'; import React from 'react';
import { render, screen } from 'spec/helpers/testing-library'; import { render, screen } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import TableControls from './TableControls'; import TableControls from './DrillDetailTableControls';
const setFilters = jest.fn(); const setFilters = jest.fn();
const onReload = jest.fn(); const onReload = jest.fn();

View File

@ -17,4 +17,4 @@
* under the License. * under the License.
*/ */
export { default } from './DrillDetailPane'; export { default as DrillDetailMenuItems } from './DrillDetailMenuItems';

View File

@ -19,6 +19,7 @@
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import React from 'react'; import React from 'react';
import { getMockStore } from 'spec/fixtures/mockStore';
import { render, screen } from 'spec/helpers/testing-library'; import { render, screen } from 'spec/helpers/testing-library';
import { FeatureFlag } from 'src/featureFlags'; import { FeatureFlag } from 'src/featureFlags';
import SliceHeaderControls, { SliceHeaderControlsProps } from '.'; import SliceHeaderControls, { SliceHeaderControlsProps } from '.';
@ -96,9 +97,11 @@ const createProps = (viz_type = 'sunburst') =>
const renderWrapper = (overrideProps?: SliceHeaderControlsProps) => { const renderWrapper = (overrideProps?: SliceHeaderControlsProps) => {
const props = overrideProps || createProps(); const props = overrideProps || createProps();
const store = getMockStore();
return render(<SliceHeaderControls {...props} />, { return render(<SliceHeaderControls {...props} />, {
useRedux: true, useRedux: true,
useRouter: true, useRouter: true,
store,
}); });
}; };
@ -253,6 +256,7 @@ test('Should show the "Drill to detail"', () => {
[FeatureFlag.DRILL_TO_DETAIL]: true, [FeatureFlag.DRILL_TO_DETAIL]: true,
}; };
const props = createProps(); const props = createProps();
props.slice.slice_id = 18;
renderWrapper(props); renderWrapper(props);
expect(screen.getByText('Drill to detail')).toBeInTheDocument(); expect(screen.getByText('Drill to detail')).toBeInTheDocument();
}); });

View File

@ -53,7 +53,7 @@ import Button from 'src/components/Button';
import ViewQueryModal from 'src/explore/components/controls/ViewQueryModal'; import ViewQueryModal from 'src/explore/components/controls/ViewQueryModal';
import { ResultsPaneOnDashboard } from 'src/explore/components/DataTablesPane'; import { ResultsPaneOnDashboard } from 'src/explore/components/DataTablesPane';
import Modal from 'src/components/Modal'; import Modal from 'src/components/Modal';
import DrillDetailPane from 'src/dashboard/components/DrillDetailPane'; import { DrillDetailMenuItems } from 'src/components/Chart/DrillDetail';
const MENU_KEYS = { const MENU_KEYS = {
CROSS_FILTER_SCOPING: 'cross_filter_scoping', CROSS_FILTER_SCOPING: 'cross_filter_scoping',
@ -156,7 +156,7 @@ const dropdownIconsStyles = css`
} }
`; `;
const DashboardChartModalTrigger = ({ const ViewResultsModalTrigger = ({
exploreUrl, exploreUrl,
triggerNode, triggerNode,
modalTitle, modalTitle,
@ -205,7 +205,6 @@ const DashboardChartModalTrigger = ({
{t('Edit chart')} {t('Edit chart')}
</Button> </Button>
<Button <Button
data-test="close-drilltodetail-modal"
buttonStyle="primary" buttonStyle="primary"
buttonSize="small" buttonSize="small"
onClick={closeModal} onClick={closeModal}
@ -430,7 +429,7 @@ class SliceHeaderControls extends React.PureComponent<
{this.props.supersetCanExplore && ( {this.props.supersetCanExplore && (
<Menu.Item key={MENU_KEYS.VIEW_RESULTS}> <Menu.Item key={MENU_KEYS.VIEW_RESULTS}>
<DashboardChartModalTrigger <ViewResultsModalTrigger
exploreUrl={this.props.exploreUrl} exploreUrl={this.props.exploreUrl}
triggerNode={ triggerNode={
<span data-test="view-query-menu-item"> <span data-test="view-query-menu-item">
@ -453,18 +452,10 @@ class SliceHeaderControls extends React.PureComponent<
{isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL) && {isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL) &&
this.props.supersetCanExplore && ( this.props.supersetCanExplore && (
<Menu.Item key={MENU_KEYS.DRILL_TO_DETAIL}> <DrillDetailMenuItems
<DashboardChartModalTrigger chartId={slice.slice_id}
exploreUrl={this.props.exploreUrl} formData={this.props.formData}
triggerNode={ />
<span data-test="view-query-menu-item">
{t('Drill to detail')}
</span>
}
modalTitle={t('Drill to detail: %s', slice.slice_name)}
modalBody={<DrillDetailPane formData={this.props.formData} />}
/>
</Menu.Item>
)} )}
{(slice.description || this.props.supersetCanExplore) && ( {(slice.description || this.props.supersetCanExplore) && (

View File

@ -29,7 +29,7 @@ import { chart } from 'src/components/Chart/chartReducer';
import componentTypes from 'src/dashboard/util/componentTypes'; import componentTypes from 'src/dashboard/util/componentTypes';
import { UrlParamEntries } from 'src/utils/urlUtils'; import { UrlParamEntries } from 'src/utils/urlUtils';
import { User } from 'src/types/bootstrapTypes'; import { BootstrapUser } from 'src/types/bootstrapTypes';
import { ChartState } from '../explore/types'; import { ChartState } from '../explore/types';
export { Dashboard } from 'src/types/Dashboard'; export { Dashboard } from 'src/types/Dashboard';
@ -117,7 +117,7 @@ export type RootState = {
dataMask: DataMaskStateWithId; dataMask: DataMaskStateWithId;
impressionId: string; impressionId: string;
nativeFilters: NativeFiltersState; nativeFilters: NativeFiltersState;
user: User; user: BootstrapUser;
}; };
/** State of dashboardLayout in redux */ /** State of dashboardLayout in redux */

View File

@ -0,0 +1,23 @@
/**
* 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.
*/
export enum ChartSource {
Explore = 'explore',
Dashboard = 'dashboard',
}