feat(dashboard): Add Drill to Detail modal w/ chart menu + right-click support (#20728)

* Add drill-to-detail modal.

* Include additional filters from dashboard context in request.

* Set page cache size to be approximately equal to memory usage of Samples pane.

* Update getDatasourceSamples signature.

* One-line import/export.

* Fix incorrect argument order in getDatasourceSamples invocation.

* Fix height of modal.

* Disable option in chart menu unless feature flag is set.

* Open modal on right-click.

* Fix double requests on modal open, controls disappearing on filter update.

* Show formattedVal in clearable filter tag.

* Set force=false for all requests.

* Rearrange/refactor DrillDetailPane.

* Reset page index on reload.

* Fix endless re-requests on request failure.

* Fix modal layout issues.
This commit is contained in:
Cody Leff 2022-08-22 15:57:18 -07:00 committed by GitHub
parent ca98fd8468
commit 52648ecd7f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 755 additions and 45 deletions

View File

@ -32,26 +32,33 @@ import { PostProcessingRule } from './PostProcessing';
import { JsonObject } from '../../connection';
import { TimeGranularity } from '../../time-format';
export type QueryObjectFilterClause = {
export type BaseQueryObjectFilterClause = {
col: QueryFormColumn;
grain?: TimeGranularity;
isExtra?: boolean;
} & (
| {
op: BinaryOperator;
val: string | number | boolean | null | Date;
formattedVal?: string;
}
| {
op: SetOperator;
val: (string | number | boolean | null | Date)[];
formattedVal?: string[];
}
| {
op: UnaryOperator;
formattedVal?: string;
}
);
};
export type BinaryQueryObjectFilterClause = BaseQueryObjectFilterClause & {
op: BinaryOperator;
val: string | number | boolean | null | Date;
formattedVal?: string;
};
export type SetQueryObjectFilterClause = BaseQueryObjectFilterClause & {
op: SetOperator;
val: (string | number | boolean | null | Date)[];
formattedVal?: string[];
};
export type UnaryQueryObjectFilterClause = BaseQueryObjectFilterClause & {
op: UnaryOperator;
formattedVal?: string;
};
export type QueryObjectFilterClause =
| BinaryQueryObjectFilterClause
| SetQueryObjectFilterClause
| UnaryQueryObjectFilterClause;
export type QueryObjectExtras = Partial<{
/** HAVING condition for Druid */
@ -402,4 +409,8 @@ export enum ContributionType {
Column = 'column',
}
export type DatasourceSamplesQuery = {
filters?: QueryObjectFilterClause[];
};
export default {};

View File

@ -30,6 +30,7 @@ import {
import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
import { EmptyStateBig, EmptyStateSmall } from 'src/components/EmptyState';
import ChartContextMenu from './ChartContextMenu';
import DrillDetailModal from './DrillDetailModal';
const propTypes = {
annotationData: PropTypes.object,
@ -83,6 +84,7 @@ class ChartRenderer extends React.Component {
super(props);
this.state = {
inContextMenu: false,
drillDetailFilters: null,
};
this.hasQueryResponseChange = false;
@ -202,10 +204,7 @@ class ChartRenderer extends React.Component {
}
handleContextMenuSelected(filters) {
const extraFilters = this.props.formData.extra_form_data?.filters || [];
// eslint-disable-next-line no-alert
alert(JSON.stringify(filters.concat(extraFilters)));
this.setState({ inContextMenu: false });
this.setState({ inContextMenu: false, drillDetailFilters: filters });
}
handleContextMenuClosed() {
@ -289,12 +288,19 @@ class ChartRenderer extends React.Component {
return (
<div>
{this.props.source === 'dashboard' && (
<ChartContextMenu
ref={this.contextMenuRef}
id={chartId}
onSelection={this.handleContextMenuSelected}
onClose={this.handleContextMenuClosed}
/>
<>
<ChartContextMenu
ref={this.contextMenuRef}
id={chartId}
onSelection={this.handleContextMenuSelected}
onClose={this.handleContextMenuClosed}
/>
<DrillDetailModal
chartId={chartId}
initialFilters={this.state.drillDetailFilters}
formData={currentFormData}
/>
</>
)}
<SuperChart
disableErrorBoundary

View File

@ -0,0 +1,117 @@
/**
* 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, {
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import {
BinaryQueryObjectFilterClause,
css,
QueryFormData,
t,
useTheme,
} from '@superset-ui/core';
import DrillDetailPane from 'src/dashboard/components/DrillDetailPane';
import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
import { Slice } from 'src/types/Chart';
import Modal from '../Modal';
import Button from '../Button';
const DrillDetailModal: React.FC<{
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();
const theme = useTheme();
const dashboardPageId = useContext(DashboardPageIdContext);
const { slice_name: chartName } = useSelector(
(state: { sliceEntities: { slices: Record<number, Slice> } }) =>
state.sliceEntities.slices[chartId],
);
const exploreUrl = useMemo(
() => `/explore/?dashboard_page_id=${dashboardPageId}&slice_id=${chartId}`,
[chartId, dashboardPageId],
);
const exploreChart = useCallback(() => {
history.push(exploreUrl);
}, [exploreUrl, history]);
// Trigger modal open when initial filters change
useEffect(() => {
if (initialFilters) {
openModal();
}
}, [initialFilters, openModal]);
return (
<Modal
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 buttonStyle="primary" buttonSize="small" onClick={closeModal}>
{t('Close')}
</Button>
</>
}
responsive
resizable
resizableConfig={{
minHeight: theme.gridUnit * 128,
minWidth: theme.gridUnit * 128,
defaultSize: {
width: 'auto',
height: '75vh',
},
}}
draggable
destroyOnClose
>
<DrillDetailPane formData={formData} initialFilters={initialFilters} />
</Modal>
);
};
export default DrillDetailModal;

View File

@ -19,7 +19,7 @@
/* eslint no-undef: 'error' */
/* eslint no-param-reassign: ["error", { "props": false }] */
import moment from 'moment';
import { t, SupersetClient } from '@superset-ui/core';
import { t, SupersetClient, isDefined } from '@superset-ui/core';
import { getControlsState } from 'src/explore/store';
import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';
import {
@ -603,10 +603,27 @@ export const getDatasourceSamples = async (
datasourceId,
force,
jsonPayload,
perPage,
page,
) => {
const endpoint = `/datasource/samples?force=${force}&datasource_type=${datasourceType}&datasource_id=${datasourceId}`;
try {
const response = await SupersetClient.post({ endpoint, jsonPayload });
const searchParams = {
force,
datasource_type: datasourceType,
datasource_id: datasourceId,
};
if (isDefined(perPage) && isDefined(page)) {
searchParams.per_page = perPage;
searchParams.page = page;
}
const response = await SupersetClient.post({
endpoint: '/datasource/samples',
jsonPayload,
searchParams,
});
return response.json.result;
} catch (err) {
const clientError = await getClientErrorObject(err);

View File

@ -0,0 +1,257 @@
/**
* 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, {
useState,
useEffect,
useMemo,
useCallback,
useRef,
} from 'react';
import { useSelector } from 'react-redux';
import {
BinaryQueryObjectFilterClause,
css,
ensureIsArray,
GenericDataType,
t,
useTheme,
QueryFormData,
JsonObject,
} from '@superset-ui/core';
import Loading from 'src/components/Loading';
import { EmptyStateMedium } from 'src/components/EmptyState';
import TableView, { EmptyWrapperType } from 'src/components/TableView';
import { useTableColumns } from 'src/explore/components/DataTableControl';
import { getDatasourceSamples } from 'src/components/Chart/chartAction';
import TableControls from './TableControls';
import { getDrillPayload } from './utils';
type ResultsPage = {
total: number;
data: Record<string, any>[];
colNames: string[];
colTypes: GenericDataType[];
};
const PAGE_SIZE = 50;
export default function DrillDetailPane({
formData,
initialFilters,
}: {
formData: QueryFormData;
initialFilters?: BinaryQueryObjectFilterClause[];
}) {
const theme = useTheme();
const [pageIndex, setPageIndex] = useState(0);
const lastPageIndex = useRef(pageIndex);
const [filters, setFilters] = useState(initialFilters || []);
const [isLoading, setIsLoading] = useState(false);
const [responseError, setResponseError] = useState('');
const [resultsPages, setResultsPages] = useState<Map<number, ResultsPage>>(
new Map(),
);
const SAMPLES_ROW_LIMIT = useSelector(
(state: { common: { conf: JsonObject } }) =>
state.common.conf.SAMPLES_ROW_LIMIT,
);
// Extract datasource ID/type from string ID
const [datasourceId, datasourceType] = useMemo(
() => formData.datasource.split('__'),
[formData.datasource],
);
// Get page of results
const resultsPage = useMemo(() => {
const nextResultsPage = resultsPages.get(pageIndex);
if (nextResultsPage) {
lastPageIndex.current = pageIndex;
return nextResultsPage;
}
return resultsPages.get(lastPageIndex.current);
}, [pageIndex, resultsPages]);
// this is to preserve the order of the columns, even if there are integer values,
// while also only grabbing the first column's keys
const columns = useTableColumns(
resultsPage?.colNames,
resultsPage?.colTypes,
resultsPage?.data,
formData.datasource,
);
// Disable sorting on columns
const sortDisabledColumns = useMemo(
() =>
columns.map(column => ({
...column,
disableSortBy: true,
})),
[columns],
);
// Update page index on pagination click
const onServerPagination = useCallback(({ pageIndex }) => {
setPageIndex(pageIndex);
}, []);
// Clear cache on reload button click
const handleReload = useCallback(() => {
setResponseError('');
setResultsPages(new Map());
setPageIndex(0);
}, []);
// Clear cache and reset page index if filters change
useEffect(() => {
setResponseError('');
setResultsPages(new Map());
setPageIndex(0);
}, [filters]);
// Update cache order if page in cache
useEffect(() => {
if (
resultsPages.has(pageIndex) &&
[...resultsPages.keys()].at(-1) !== pageIndex
) {
const nextResultsPages = new Map(resultsPages);
nextResultsPages.delete(pageIndex);
setResultsPages(
nextResultsPages.set(
pageIndex,
resultsPages.get(pageIndex) as ResultsPage,
),
);
}
}, [pageIndex, resultsPages]);
// Download page of results & trim cache if page not in cache
useEffect(() => {
if (!responseError && !isLoading && !resultsPages.has(pageIndex)) {
setIsLoading(true);
const jsonPayload = getDrillPayload(formData, filters);
const cachePageLimit = Math.ceil(SAMPLES_ROW_LIMIT / PAGE_SIZE);
getDatasourceSamples(
datasourceType,
datasourceId,
false,
jsonPayload,
PAGE_SIZE,
pageIndex + 1,
)
.then(response => {
setResultsPages(
new Map([
...[...resultsPages.entries()].slice(-cachePageLimit + 1),
[
pageIndex,
{
total: response.total_count,
data: response.data,
colNames: ensureIsArray(response.colnames),
colTypes: ensureIsArray(response.coltypes),
},
],
]),
);
setResponseError('');
})
.catch(error => {
setResponseError(`${error.name}: ${error.message}`);
})
.finally(() => {
setIsLoading(false);
});
}
}, [
SAMPLES_ROW_LIMIT,
datasourceId,
datasourceType,
filters,
formData,
isLoading,
pageIndex,
responseError,
resultsPages,
]);
let tableContent = null;
if (responseError) {
// Render error if page download failed
tableContent = (
<pre
css={css`
margin-top: ${theme.gridUnit * 4}px;
`}
>
{responseError}
</pre>
);
} else if (!resultsPages.size) {
// 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
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
tableContent = (
<TableView
columns={sortDisabledColumns}
data={resultsPage?.data || []}
pageSize={PAGE_SIZE}
totalCount={resultsPage?.total}
serverPagination
initialPageIndex={pageIndex}
onServerPagination={onServerPagination}
loading={isLoading}
noDataText={t('No results')}
emptyWrapperType={EmptyWrapperType.Small}
className="table-condensed"
isPaginationSticky
showRowCount={false}
small
css={css`
overflow: auto;
.table {
margin-bottom: 0;
}
`}
/>
);
}
return (
<>
<TableControls
filters={filters}
setFilters={setFilters}
totalCount={resultsPage?.total}
loading={isLoading}
onReload={handleReload}
/>
{tableContent}
</>
);
}

View File

@ -0,0 +1,138 @@
/**
* 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, { useCallback, useMemo } from 'react';
import { Tag } from 'antd';
import {
BinaryQueryObjectFilterClause,
css,
isAdhocColumn,
t,
useTheme,
} from '@superset-ui/core';
import RowCountLabel from 'src/explore/components/RowCountLabel';
import Icons from 'src/components/Icons';
export default function TableControls({
filters,
setFilters,
totalCount,
loading,
onReload,
}: {
filters: BinaryQueryObjectFilterClause[];
setFilters: (filters: BinaryQueryObjectFilterClause[]) => void;
totalCount?: number;
loading: boolean;
onReload: () => void;
}) {
const theme = useTheme();
const filterMap: Record<string, BinaryQueryObjectFilterClause> = useMemo(
() =>
Object.assign(
{},
...filters.map(filter => ({
[isAdhocColumn(filter.col)
? (filter.col.label as string)
: filter.col]: filter,
})),
),
[filters],
);
const removeFilter = useCallback(
colName => {
const updatedFilterMap = { ...filterMap };
delete updatedFilterMap[colName];
setFilters([...Object.values(updatedFilterMap)]);
},
[filterMap, setFilters],
);
const filterTags = useMemo(
() =>
Object.entries(filterMap)
.map(([colName, { val, formattedVal }]) => ({
colName,
val: formattedVal ?? val,
}))
.sort((a, b) => a.colName.localeCompare(b.colName)),
[filterMap],
);
return (
<div
css={css`
display: flex;
justify-content: space-between;
padding: ${theme.gridUnit / 2}px 0;
`}
>
<div
css={css`
display: flex;
flex-wrap: wrap;
margin-bottom: -${theme.gridUnit * 4}px;
`}
>
{filterTags.map(({ colName, val }) => (
<Tag
closable
onClose={removeFilter.bind(null, colName)}
key={colName}
css={css`
height: ${theme.gridUnit * 6}px;
display: flex;
align-items: center;
padding: ${theme.gridUnit / 2}px ${theme.gridUnit * 2}px;
margin-right: ${theme.gridUnit * 4}px;
margin-bottom: ${theme.gridUnit * 4}px;
line-height: 1.2;
`}
>
<span
css={css`
margin-right: ${theme.gridUnit}px;
`}
>
{colName}
</span>
<strong>{val}</strong>
</Tag>
))}
</div>
<div
css={css`
display: flex;
align-items: center;
height: min-content;
`}
>
<RowCountLabel loading={loading && !totalCount} rowcount={totalCount} />
<Icons.ReloadOutlined
iconColor={theme.colors.grayscale.light1}
iconSize="l"
aria-label={t('Reload')}
role="button"
onClick={onReload}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,20 @@
/**
* 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 { default } from './DrillDetailPane';

View File

@ -0,0 +1,46 @@
/**
* 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 { omit } from 'lodash';
import {
ensureIsArray,
QueryFormData,
BinaryQueryObjectFilterClause,
buildQueryObject,
} from '@superset-ui/core';
export function getDrillPayload(
queryFormData?: QueryFormData,
drillFilters?: BinaryQueryObjectFilterClause[],
) {
if (!queryFormData) {
return undefined;
}
const queryObject = buildQueryObject(queryFormData);
const extras = omit(queryObject.extras, 'having');
const filters = [
...ensureIsArray(queryObject.filters),
...ensureIsArray(drillFilters).map(f => omit(f, 'formattedVal')),
];
return {
granularity: queryObject.granularity,
time_range: queryObject.time_range,
filters,
extras,
};
}

View File

@ -16,8 +16,19 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { MouseEvent, Key } from 'react';
import { Link, RouteComponentProps, withRouter } from 'react-router-dom';
import React, {
MouseEvent,
Key,
ReactChild,
useState,
useCallback,
} from 'react';
import {
Link,
RouteComponentProps,
useHistory,
withRouter,
} from 'react-router-dom';
import moment from 'moment';
import {
Behavior,
@ -26,6 +37,7 @@ import {
QueryFormData,
styled,
t,
useTheme,
} from '@superset-ui/core';
import { Menu } from 'src/components/Menu';
import { NoAnimationDropdown } from 'src/components/Dropdown';
@ -40,6 +52,8 @@ import ModalTrigger from 'src/components/ModalTrigger';
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';
const MENU_KEYS = {
CROSS_FILTER_SCOPING: 'cross_filter_scoping',
@ -52,6 +66,7 @@ const MENU_KEYS = {
TOGGLE_CHART_DESCRIPTION: 'toggle_chart_description',
VIEW_QUERY: 'view_query',
VIEW_RESULTS: 'view_results',
DRILL_TO_DETAIL: 'drill_to_detail',
};
const VerticalDotsContainer = styled.div`
@ -97,6 +112,7 @@ export interface SliceHeaderControlsProps {
slice_id: number;
slice_description: string;
form_data?: { emit_filter?: boolean };
datasource: string;
};
componentId: string;
@ -140,6 +156,83 @@ const dropdownIconsStyles = css`
}
`;
const DashboardChartModalTrigger = ({
exploreUrl,
triggerNode,
modalTitle,
modalBody,
}: {
exploreUrl: string;
triggerNode: ReactChild;
modalTitle: ReactChild;
modalBody: ReactChild;
}) => {
const [showModal, setShowModal] = useState(false);
const openModal = useCallback(() => setShowModal(true), []);
const closeModal = useCallback(() => setShowModal(false), []);
const history = useHistory();
const exploreChart = () => history.push(exploreUrl);
const theme = useTheme();
return (
<>
<span
data-test="span-modal-trigger"
onClick={openModal}
role="button"
tabIndex={0}
>
{triggerNode}
</span>
{(() => (
<Modal
css={css`
.ant-modal-body {
display: flex;
flex-direction: column;
}
`}
show={showModal}
onHide={closeModal}
title={modalTitle}
footer={
<>
<Button
buttonStyle="secondary"
buttonSize="small"
onClick={exploreChart}
>
{t('Edit chart')}
</Button>
<Button
buttonStyle="primary"
buttonSize="small"
onClick={closeModal}
>
{t('Close')}
</Button>
</>
}
responsive
resizable
resizableConfig={{
minHeight: theme.gridUnit * 128,
minWidth: theme.gridUnit * 128,
defaultSize: {
width: 'auto',
height: '75vh',
},
}}
draggable
destroyOnClose
>
{modalBody}
</Modal>
))()}
</>
);
};
class SliceHeaderControls extends React.PureComponent<
SliceHeaderControlsPropsWithRouter,
State
@ -339,7 +432,8 @@ class SliceHeaderControls extends React.PureComponent<
{this.props.supersetCanExplore && (
<Menu.Item key={MENU_KEYS.VIEW_RESULTS}>
<ModalTrigger
<DashboardChartModalTrigger
exploreUrl={this.props.exploreUrl}
triggerNode={
<span data-test="view-query-menu-item">
{t('View as table')}
@ -355,22 +449,26 @@ class SliceHeaderControls extends React.PureComponent<
isVisible
/>
}
modalFooter={
<Button
buttonStyle="secondary"
buttonSize="small"
onClick={() => this.props.history.push(this.props.exploreUrl)}
>
{t('Edit chart')}
</Button>
}
draggable
resizable
responsive
/>
</Menu.Item>
)}
{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>
)}
{(slice.description || this.props.supersetCanExplore) && (
<Menu.Divider />
)}