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();
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', () => {

View File

@ -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 {

View File

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

View File

@ -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(

View File

@ -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 {

View File

@ -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<

View File

@ -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,
);
}
}

View File

@ -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<

View File

@ -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(

View File

@ -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(

View File

@ -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(

View File

@ -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);
}
}
},

View File

@ -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,
});

View File

@ -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;
};

View File

@ -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);
}
}
},

View File

@ -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

View File

@ -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:

View File

@ -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(

View File

@ -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

View File

@ -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);
}
}
},

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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);
}
}
},

View File

@ -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(

View File

@ -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;
}

View File

@ -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);
}
};

View File

@ -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],

View File

@ -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.',

View File

@ -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;
}

View File

@ -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,

View File

@ -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;

View File

@ -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(

View File

@ -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;
}

View File

@ -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,

View File

@ -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}
/>
);

View File

@ -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,
}}

View File

@ -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>
</>
);
}
}

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
* 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}`,

View File

@ -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;
}

View File

@ -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}

View File

@ -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();

View File

@ -17,4 +17,4 @@
* 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 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();
});

View File

@ -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) && (

View File

@ -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 */

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',
}