mirror of https://github.com/apache/superset.git
feat(dashboard): menu improvements, fallback support for Drill to Detail (#21351)
This commit is contained in:
parent
54f6fd6a82
commit
76e57ec651
|
@ -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', () => {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -35,6 +35,7 @@ export {
|
|||
isXAxisSet,
|
||||
hasGenericChartAxes,
|
||||
} from './getXAxis';
|
||||
export { default as extractQueryFields } from './extractQueryFields';
|
||||
|
||||
export * from './types/AnnotationLayer';
|
||||
export * from './types/QueryFormData';
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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<
|
||||
|
|
|
@ -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<BigNumberVisProps> {
|
|||
const onContextMenu = (e: MouseEvent<HTMLDivElement>) => {
|
||||
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<BigNumberVisProps> {
|
|||
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<BigNumberVisProps> {
|
|||
formattedVal: this.props.xValueFormatter?.(data[0]),
|
||||
});
|
||||
this.props.onContextMenu(
|
||||
filters,
|
||||
pointerEvent.clientX,
|
||||
pointerEvent.clientY,
|
||||
filters,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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<F> {
|
|||
selectedValues: Record<number, string>;
|
||||
legendData?: OptionName[];
|
||||
onContextMenu?: (
|
||||
filters: QueryObjectFilterClause[],
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
filters?: BinaryQueryObjectFilterClause[],
|
||||
) => void;
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -279,6 +279,7 @@ export default typedMemo(function DataTable<D extends object>({
|
|||
onContextMenu={(e: MouseEvent) => {
|
||||
if (onContextMenu) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(
|
||||
row.original,
|
||||
e.nativeEvent.clientX,
|
||||
|
|
|
@ -40,7 +40,7 @@ import {
|
|||
ensureIsArray,
|
||||
GenericDataType,
|
||||
getTimeFormatterForGranularity,
|
||||
QueryObjectFilterClause,
|
||||
BinaryQueryObjectFilterClause,
|
||||
styled,
|
||||
css,
|
||||
t,
|
||||
|
@ -630,7 +630,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
|||
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<D extends DataRecord = DataRecord>(
|
|||
});
|
||||
}
|
||||
});
|
||||
onContextMenu(filters, clientX, clientY);
|
||||
onContextMenu(clientX, clientY, filters);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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<D extends DataRecord = DataRecord> {
|
|||
columnColorFormatters?: ColorFormatters;
|
||||
allowRearrangeColumns?: boolean;
|
||||
onContextMenu?: (
|
||||
filters: QueryObjectFilterClause[],
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
filters?: BinaryQueryObjectFilterClause[],
|
||||
) => void;
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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={<MonospaceDiv>{message}</MonospaceDiv>}
|
||||
copyText={message}
|
||||
link={queryResponse ? queryResponse.link : null}
|
||||
source={dashboardId ? 'dashboard' : 'explore'}
|
||||
source={dashboardId ? ChartSource.Dashboard : ChartSource.Explore}
|
||||
stackTrace={chartStackTrace}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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<Ref>,
|
||||
) => {
|
||||
const [state, setState] = useState<{
|
||||
filters: QueryObjectFilterClause[];
|
||||
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 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(
|
||||
<DrillDetailMenuItems
|
||||
chartId={id}
|
||||
formData={formData}
|
||||
isContextMenu
|
||||
filters={filters}
|
||||
onSelection={onSelection}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<Dropdown
|
||||
overlay={menu}
|
||||
overlay={
|
||||
<Menu>
|
||||
{menuItems.length ? (
|
||||
menuItems
|
||||
) : (
|
||||
<Menu.Item disabled>No actions</Menu.Item>
|
||||
)}
|
||||
</Menu>
|
||||
}
|
||||
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,
|
||||
}}
|
||||
|
|
|
@ -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 (
|
||||
<div>
|
||||
{this.props.source === 'dashboard' && (
|
||||
<>
|
||||
<ChartContextMenu
|
||||
ref={this.contextMenuRef}
|
||||
id={chartId}
|
||||
onSelection={this.handleContextMenuSelected}
|
||||
onClose={this.handleContextMenuClosed}
|
||||
/>
|
||||
<DrillDetailModal
|
||||
chartId={chartId}
|
||||
initialFilters={this.state.drillDetailFilters}
|
||||
formData={currentFormData}
|
||||
/>
|
||||
</>
|
||||
<>
|
||||
{this.state.showContextMenu && (
|
||||
<ChartContextMenu
|
||||
ref={this.contextMenuRef}
|
||||
id={chartId}
|
||||
formData={currentFormData}
|
||||
onSelection={this.handleContextMenuSelected}
|
||||
onClose={this.handleContextMenuClosed}
|
||||
/>
|
||||
)}
|
||||
<SuperChart
|
||||
disableErrorBoundary
|
||||
key={`${chartId}${webpackHash}`}
|
||||
id={`chart-id-${chartId}`}
|
||||
className={chartClassName}
|
||||
chartType={vizType}
|
||||
width={width}
|
||||
height={height}
|
||||
annotationData={annotationData}
|
||||
datasource={datasource}
|
||||
initialValues={initialValues}
|
||||
formData={currentFormData}
|
||||
ownState={ownState}
|
||||
filterState={filterState}
|
||||
hooks={this.hooks}
|
||||
behaviors={behaviors}
|
||||
queriesData={queriesResponse}
|
||||
onRenderSuccess={this.handleRenderSuccess}
|
||||
onRenderFailure={this.handleRenderFailure}
|
||||
noResults={noResultsComponent}
|
||||
postTransformProps={postTransformProps}
|
||||
inContextMenu={this.state.inContextMenu}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
onContextMenu={
|
||||
this.state.showContextMenu ? this.onContextMenuFallback : undefined
|
||||
}
|
||||
>
|
||||
<SuperChart
|
||||
disableErrorBoundary
|
||||
key={`${chartId}${webpackHash}`}
|
||||
id={`chart-id-${chartId}`}
|
||||
className={chartClassName}
|
||||
chartType={vizType}
|
||||
width={width}
|
||||
height={height}
|
||||
annotationData={annotationData}
|
||||
datasource={datasource}
|
||||
initialValues={initialValues}
|
||||
formData={currentFormData}
|
||||
ownState={ownState}
|
||||
filterState={filterState}
|
||||
hooks={this.hooks}
|
||||
behaviors={behaviors}
|
||||
queriesData={queriesResponse}
|
||||
onRenderSuccess={this.handleRenderSuccess}
|
||||
onRenderFailure={this.handleRenderFailure}
|
||||
noResults={noResultsComponent}
|
||||
postTransformProps={postTransformProps}
|
||||
{...drillToDetailProps}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
|
@ -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;
|
|
@ -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<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: [],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
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 (
|
||||
<>
|
||||
<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 () => {
|
||||
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}`,
|
|
@ -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) => (
|
||||
<>
|
||||
<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;
|
||||
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<number, Slice> } }) =>
|
||||
|
@ -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 (
|
||||
<Modal
|
||||
show={showModal}
|
||||
onHide={onHideModal ?? (() => null)}
|
||||
css={css`
|
||||
.ant-modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
`}
|
||||
show={showModal}
|
||||
onHide={closeModal}
|
||||
title={t('Drill to detail: %s', chartName)}
|
||||
footer={
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
}
|
||||
footer={<ModalFooter exploreChart={exploreChart} />}
|
||||
responsive
|
||||
resizable
|
||||
resizableConfig={{
|
||||
|
@ -117,6 +114,4 @@ const DrillDetailModal: React.FC<{
|
|||
<DrillDetailPane formData={formData} initialFilters={initialFilters} />
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DrillDetailModal;
|
||||
}
|
|
@ -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<Map<number, ResultsPage>>(
|
||||
|
@ -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 = (
|
||||
<pre
|
||||
css={css`
|
||||
|
@ -207,14 +207,14 @@ export default function DrillDetailPane({
|
|||
</pre>
|
||||
);
|
||||
} else if (!resultsPages.size) {
|
||||
// Render loading if first page hasn't loaded
|
||||
// Render loading if first page hasn't loaded
|
||||
tableContent = <Loading />;
|
||||
} 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 = <EmptyStateMedium image="document.svg" title={title} />;
|
||||
} else {
|
||||
// Render table if at least one page has successfully loaded
|
||||
// Render table if at least one page has successfully loaded
|
||||
tableContent = (
|
||||
<TableView
|
||||
columns={sortDisabledColumns}
|
|
@ -19,7 +19,7 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import TableControls from './TableControls';
|
||||
import TableControls from './DrillDetailTableControls';
|
||||
|
||||
const setFilters = jest.fn();
|
||||
const onReload = jest.fn();
|
|
@ -17,4 +17,4 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
export { default } from './DrillDetailPane';
|
||||
export { default as DrillDetailMenuItems } from './DrillDetailMenuItems';
|
|
@ -19,6 +19,7 @@
|
|||
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { getMockStore } from 'spec/fixtures/mockStore';
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import { FeatureFlag } from 'src/featureFlags';
|
||||
import SliceHeaderControls, { SliceHeaderControlsProps } from '.';
|
||||
|
@ -96,9 +97,11 @@ const createProps = (viz_type = 'sunburst') =>
|
|||
|
||||
const renderWrapper = (overrideProps?: SliceHeaderControlsProps) => {
|
||||
const props = overrideProps || createProps();
|
||||
const store = getMockStore();
|
||||
return render(<SliceHeaderControls {...props} />, {
|
||||
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();
|
||||
});
|
||||
|
|
|
@ -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')}
|
||||
</Button>
|
||||
<Button
|
||||
data-test="close-drilltodetail-modal"
|
||||
buttonStyle="primary"
|
||||
buttonSize="small"
|
||||
onClick={closeModal}
|
||||
|
@ -430,7 +429,7 @@ class SliceHeaderControls extends React.PureComponent<
|
|||
|
||||
{this.props.supersetCanExplore && (
|
||||
<Menu.Item key={MENU_KEYS.VIEW_RESULTS}>
|
||||
<DashboardChartModalTrigger
|
||||
<ViewResultsModalTrigger
|
||||
exploreUrl={this.props.exploreUrl}
|
||||
triggerNode={
|
||||
<span data-test="view-query-menu-item">
|
||||
|
@ -453,18 +452,10 @@ class SliceHeaderControls extends React.PureComponent<
|
|||
|
||||
{isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL) &&
|
||||
this.props.supersetCanExplore && (
|
||||
<Menu.Item key={MENU_KEYS.DRILL_TO_DETAIL}>
|
||||
<DashboardChartModalTrigger
|
||||
exploreUrl={this.props.exploreUrl}
|
||||
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>
|
||||
<DrillDetailMenuItems
|
||||
chartId={slice.slice_id}
|
||||
formData={this.props.formData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(slice.description || this.props.supersetCanExplore) && (
|
||||
|
|
|
@ -29,7 +29,7 @@ import { chart } from 'src/components/Chart/chartReducer';
|
|||
import componentTypes from 'src/dashboard/util/componentTypes';
|
||||
import { UrlParamEntries } from 'src/utils/urlUtils';
|
||||
|
||||
import { User } from 'src/types/bootstrapTypes';
|
||||
import { BootstrapUser } from 'src/types/bootstrapTypes';
|
||||
import { ChartState } from '../explore/types';
|
||||
|
||||
export { Dashboard } from 'src/types/Dashboard';
|
||||
|
@ -117,7 +117,7 @@ export type RootState = {
|
|||
dataMask: DataMaskStateWithId;
|
||||
impressionId: string;
|
||||
nativeFilters: NativeFiltersState;
|
||||
user: User;
|
||||
user: BootstrapUser;
|
||||
};
|
||||
|
||||
/** State of dashboardLayout in redux */
|
||||
|
|
|
@ -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',
|
||||
}
|
Loading…
Reference in New Issue