mirror of
https://github.com/apache/superset.git
synced 2024-09-06 13:57:40 -04:00
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:
parent
ca98fd8468
commit
52648ecd7f
@ -32,26 +32,33 @@ import { PostProcessingRule } from './PostProcessing';
|
|||||||
import { JsonObject } from '../../connection';
|
import { JsonObject } from '../../connection';
|
||||||
import { TimeGranularity } from '../../time-format';
|
import { TimeGranularity } from '../../time-format';
|
||||||
|
|
||||||
export type QueryObjectFilterClause = {
|
export type BaseQueryObjectFilterClause = {
|
||||||
col: QueryFormColumn;
|
col: QueryFormColumn;
|
||||||
grain?: TimeGranularity;
|
grain?: TimeGranularity;
|
||||||
isExtra?: boolean;
|
isExtra?: boolean;
|
||||||
} & (
|
};
|
||||||
| {
|
|
||||||
|
export type BinaryQueryObjectFilterClause = BaseQueryObjectFilterClause & {
|
||||||
op: BinaryOperator;
|
op: BinaryOperator;
|
||||||
val: string | number | boolean | null | Date;
|
val: string | number | boolean | null | Date;
|
||||||
formattedVal?: string;
|
formattedVal?: string;
|
||||||
}
|
};
|
||||||
| {
|
|
||||||
|
export type SetQueryObjectFilterClause = BaseQueryObjectFilterClause & {
|
||||||
op: SetOperator;
|
op: SetOperator;
|
||||||
val: (string | number | boolean | null | Date)[];
|
val: (string | number | boolean | null | Date)[];
|
||||||
formattedVal?: string[];
|
formattedVal?: string[];
|
||||||
}
|
};
|
||||||
| {
|
|
||||||
|
export type UnaryQueryObjectFilterClause = BaseQueryObjectFilterClause & {
|
||||||
op: UnaryOperator;
|
op: UnaryOperator;
|
||||||
formattedVal?: string;
|
formattedVal?: string;
|
||||||
}
|
};
|
||||||
);
|
|
||||||
|
export type QueryObjectFilterClause =
|
||||||
|
| BinaryQueryObjectFilterClause
|
||||||
|
| SetQueryObjectFilterClause
|
||||||
|
| UnaryQueryObjectFilterClause;
|
||||||
|
|
||||||
export type QueryObjectExtras = Partial<{
|
export type QueryObjectExtras = Partial<{
|
||||||
/** HAVING condition for Druid */
|
/** HAVING condition for Druid */
|
||||||
@ -402,4 +409,8 @@ export enum ContributionType {
|
|||||||
Column = 'column',
|
Column = 'column',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DatasourceSamplesQuery = {
|
||||||
|
filters?: QueryObjectFilterClause[];
|
||||||
|
};
|
||||||
|
|
||||||
export default {};
|
export default {};
|
||||||
|
@ -30,6 +30,7 @@ import {
|
|||||||
import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
|
import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
|
||||||
import { EmptyStateBig, EmptyStateSmall } from 'src/components/EmptyState';
|
import { EmptyStateBig, EmptyStateSmall } from 'src/components/EmptyState';
|
||||||
import ChartContextMenu from './ChartContextMenu';
|
import ChartContextMenu from './ChartContextMenu';
|
||||||
|
import DrillDetailModal from './DrillDetailModal';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
annotationData: PropTypes.object,
|
annotationData: PropTypes.object,
|
||||||
@ -83,6 +84,7 @@ class ChartRenderer extends React.Component {
|
|||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
inContextMenu: false,
|
inContextMenu: false,
|
||||||
|
drillDetailFilters: null,
|
||||||
};
|
};
|
||||||
this.hasQueryResponseChange = false;
|
this.hasQueryResponseChange = false;
|
||||||
|
|
||||||
@ -202,10 +204,7 @@ class ChartRenderer extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleContextMenuSelected(filters) {
|
handleContextMenuSelected(filters) {
|
||||||
const extraFilters = this.props.formData.extra_form_data?.filters || [];
|
this.setState({ inContextMenu: false, drillDetailFilters: filters });
|
||||||
// eslint-disable-next-line no-alert
|
|
||||||
alert(JSON.stringify(filters.concat(extraFilters)));
|
|
||||||
this.setState({ inContextMenu: false });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleContextMenuClosed() {
|
handleContextMenuClosed() {
|
||||||
@ -289,12 +288,19 @@ class ChartRenderer extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{this.props.source === 'dashboard' && (
|
{this.props.source === 'dashboard' && (
|
||||||
|
<>
|
||||||
<ChartContextMenu
|
<ChartContextMenu
|
||||||
ref={this.contextMenuRef}
|
ref={this.contextMenuRef}
|
||||||
id={chartId}
|
id={chartId}
|
||||||
onSelection={this.handleContextMenuSelected}
|
onSelection={this.handleContextMenuSelected}
|
||||||
onClose={this.handleContextMenuClosed}
|
onClose={this.handleContextMenuClosed}
|
||||||
/>
|
/>
|
||||||
|
<DrillDetailModal
|
||||||
|
chartId={chartId}
|
||||||
|
initialFilters={this.state.drillDetailFilters}
|
||||||
|
formData={currentFormData}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<SuperChart
|
<SuperChart
|
||||||
disableErrorBoundary
|
disableErrorBoundary
|
||||||
|
117
superset-frontend/src/components/Chart/DrillDetailModal.tsx
Normal file
117
superset-frontend/src/components/Chart/DrillDetailModal.tsx
Normal 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;
|
@ -19,7 +19,7 @@
|
|||||||
/* eslint no-undef: 'error' */
|
/* eslint no-undef: 'error' */
|
||||||
/* eslint no-param-reassign: ["error", { "props": false }] */
|
/* eslint no-param-reassign: ["error", { "props": false }] */
|
||||||
import moment from 'moment';
|
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 { getControlsState } from 'src/explore/store';
|
||||||
import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';
|
import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';
|
||||||
import {
|
import {
|
||||||
@ -603,10 +603,27 @@ export const getDatasourceSamples = async (
|
|||||||
datasourceId,
|
datasourceId,
|
||||||
force,
|
force,
|
||||||
jsonPayload,
|
jsonPayload,
|
||||||
|
perPage,
|
||||||
|
page,
|
||||||
) => {
|
) => {
|
||||||
const endpoint = `/datasource/samples?force=${force}&datasource_type=${datasourceType}&datasource_id=${datasourceId}`;
|
|
||||||
try {
|
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;
|
return response.json.result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const clientError = await getClientErrorObject(err);
|
const clientError = await getClientErrorObject(err);
|
||||||
|
@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -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';
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
@ -16,8 +16,19 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import React, { MouseEvent, Key } from 'react';
|
import React, {
|
||||||
import { Link, RouteComponentProps, withRouter } from 'react-router-dom';
|
MouseEvent,
|
||||||
|
Key,
|
||||||
|
ReactChild,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
Link,
|
||||||
|
RouteComponentProps,
|
||||||
|
useHistory,
|
||||||
|
withRouter,
|
||||||
|
} from 'react-router-dom';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import {
|
import {
|
||||||
Behavior,
|
Behavior,
|
||||||
@ -26,6 +37,7 @@ import {
|
|||||||
QueryFormData,
|
QueryFormData,
|
||||||
styled,
|
styled,
|
||||||
t,
|
t,
|
||||||
|
useTheme,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import { Menu } from 'src/components/Menu';
|
import { Menu } from 'src/components/Menu';
|
||||||
import { NoAnimationDropdown } from 'src/components/Dropdown';
|
import { NoAnimationDropdown } from 'src/components/Dropdown';
|
||||||
@ -40,6 +52,8 @@ import ModalTrigger from 'src/components/ModalTrigger';
|
|||||||
import Button from 'src/components/Button';
|
import Button from 'src/components/Button';
|
||||||
import ViewQueryModal from 'src/explore/components/controls/ViewQueryModal';
|
import ViewQueryModal from 'src/explore/components/controls/ViewQueryModal';
|
||||||
import { ResultsPaneOnDashboard } from 'src/explore/components/DataTablesPane';
|
import { ResultsPaneOnDashboard } from 'src/explore/components/DataTablesPane';
|
||||||
|
import Modal from 'src/components/Modal';
|
||||||
|
import DrillDetailPane from 'src/dashboard/components/DrillDetailPane';
|
||||||
|
|
||||||
const MENU_KEYS = {
|
const MENU_KEYS = {
|
||||||
CROSS_FILTER_SCOPING: 'cross_filter_scoping',
|
CROSS_FILTER_SCOPING: 'cross_filter_scoping',
|
||||||
@ -52,6 +66,7 @@ const MENU_KEYS = {
|
|||||||
TOGGLE_CHART_DESCRIPTION: 'toggle_chart_description',
|
TOGGLE_CHART_DESCRIPTION: 'toggle_chart_description',
|
||||||
VIEW_QUERY: 'view_query',
|
VIEW_QUERY: 'view_query',
|
||||||
VIEW_RESULTS: 'view_results',
|
VIEW_RESULTS: 'view_results',
|
||||||
|
DRILL_TO_DETAIL: 'drill_to_detail',
|
||||||
};
|
};
|
||||||
|
|
||||||
const VerticalDotsContainer = styled.div`
|
const VerticalDotsContainer = styled.div`
|
||||||
@ -97,6 +112,7 @@ export interface SliceHeaderControlsProps {
|
|||||||
slice_id: number;
|
slice_id: number;
|
||||||
slice_description: string;
|
slice_description: string;
|
||||||
form_data?: { emit_filter?: boolean };
|
form_data?: { emit_filter?: boolean };
|
||||||
|
datasource: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
componentId: 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<
|
class SliceHeaderControls extends React.PureComponent<
|
||||||
SliceHeaderControlsPropsWithRouter,
|
SliceHeaderControlsPropsWithRouter,
|
||||||
State
|
State
|
||||||
@ -339,7 +432,8 @@ class SliceHeaderControls extends React.PureComponent<
|
|||||||
|
|
||||||
{this.props.supersetCanExplore && (
|
{this.props.supersetCanExplore && (
|
||||||
<Menu.Item key={MENU_KEYS.VIEW_RESULTS}>
|
<Menu.Item key={MENU_KEYS.VIEW_RESULTS}>
|
||||||
<ModalTrigger
|
<DashboardChartModalTrigger
|
||||||
|
exploreUrl={this.props.exploreUrl}
|
||||||
triggerNode={
|
triggerNode={
|
||||||
<span data-test="view-query-menu-item">
|
<span data-test="view-query-menu-item">
|
||||||
{t('View as table')}
|
{t('View as table')}
|
||||||
@ -355,18 +449,22 @@ class SliceHeaderControls extends React.PureComponent<
|
|||||||
isVisible
|
isVisible
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
modalFooter={
|
/>
|
||||||
<Button
|
</Menu.Item>
|
||||||
buttonStyle="secondary"
|
)}
|
||||||
buttonSize="small"
|
|
||||||
onClick={() => this.props.history.push(this.props.exploreUrl)}
|
{isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL) &&
|
||||||
>
|
this.props.supersetCanExplore && (
|
||||||
{t('Edit chart')}
|
<Menu.Item key={MENU_KEYS.DRILL_TO_DETAIL}>
|
||||||
</Button>
|
<DashboardChartModalTrigger
|
||||||
|
exploreUrl={this.props.exploreUrl}
|
||||||
|
triggerNode={
|
||||||
|
<span data-test="view-query-menu-item">
|
||||||
|
{t('Drill to detail')}
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
draggable
|
modalTitle={t('Drill to detail: %s', slice.slice_name)}
|
||||||
resizable
|
modalBody={<DrillDetailPane formData={this.props.formData} />}
|
||||||
responsive
|
|
||||||
/>
|
/>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
|
Loading…
Reference in New Issue
Block a user