From 52648ecd7f6158473ec198e1ade9a5a69008b752 Mon Sep 17 00:00:00 2001 From: Cody Leff Date: Mon, 22 Aug 2022 15:57:18 -0700 Subject: [PATCH] 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. --- .../superset-ui-core/src/query/types/Query.ts | 45 +-- .../src/components/Chart/ChartRenderer.jsx | 26 +- .../src/components/Chart/DrillDetailModal.tsx | 117 ++++++++ .../src/components/Chart/chartAction.js | 23 +- .../DrillDetailPane/DrillDetailPane.tsx | 257 ++++++++++++++++++ .../DrillDetailPane/TableControls.tsx | 138 ++++++++++ .../components/DrillDetailPane/index.ts | 20 ++ .../components/DrillDetailPane/utils.ts | 46 ++++ .../components/SliceHeaderControls/index.tsx | 128 ++++++++- 9 files changed, 755 insertions(+), 45 deletions(-) create mode 100644 superset-frontend/src/components/Chart/DrillDetailModal.tsx create mode 100644 superset-frontend/src/dashboard/components/DrillDetailPane/DrillDetailPane.tsx create mode 100644 superset-frontend/src/dashboard/components/DrillDetailPane/TableControls.tsx create mode 100644 superset-frontend/src/dashboard/components/DrillDetailPane/index.ts create mode 100644 superset-frontend/src/dashboard/components/DrillDetailPane/utils.ts diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts index a3cfa88618..94c48d432f 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts @@ -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 {}; diff --git a/superset-frontend/src/components/Chart/ChartRenderer.jsx b/superset-frontend/src/components/Chart/ChartRenderer.jsx index 4c11cfc085..d1584441e3 100644 --- a/superset-frontend/src/components/Chart/ChartRenderer.jsx +++ b/superset-frontend/src/components/Chart/ChartRenderer.jsx @@ -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 (
{this.props.source === 'dashboard' && ( - + <> + + + )} = ({ 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 } }) => + 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 ( + + + + + } + responsive + resizable + resizableConfig={{ + minHeight: theme.gridUnit * 128, + minWidth: theme.gridUnit * 128, + defaultSize: { + width: 'auto', + height: '75vh', + }, + }} + draggable + destroyOnClose + > + + + ); +}; + +export default DrillDetailModal; diff --git a/superset-frontend/src/components/Chart/chartAction.js b/superset-frontend/src/components/Chart/chartAction.js index 044593eb37..dea41497b8 100644 --- a/superset-frontend/src/components/Chart/chartAction.js +++ b/superset-frontend/src/components/Chart/chartAction.js @@ -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); diff --git a/superset-frontend/src/dashboard/components/DrillDetailPane/DrillDetailPane.tsx b/superset-frontend/src/dashboard/components/DrillDetailPane/DrillDetailPane.tsx new file mode 100644 index 0000000000..bf3f1985b7 --- /dev/null +++ b/superset-frontend/src/dashboard/components/DrillDetailPane/DrillDetailPane.tsx @@ -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[]; + 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>( + 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 = ( +
+        {responseError}
+      
+ ); + } else if (!resultsPages.size) { + // Render loading if first page hasn't loaded + tableContent = ; + } 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 = ; + } else { + // Render table if at least one page has successfully loaded + tableContent = ( + + ); + } + + return ( + <> + + {tableContent} + + ); +} diff --git a/superset-frontend/src/dashboard/components/DrillDetailPane/TableControls.tsx b/superset-frontend/src/dashboard/components/DrillDetailPane/TableControls.tsx new file mode 100644 index 0000000000..9b0e072379 --- /dev/null +++ b/superset-frontend/src/dashboard/components/DrillDetailPane/TableControls.tsx @@ -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 = 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 ( +
+
+ {filterTags.map(({ colName, val }) => ( + + + {colName} + + {val} + + ))} +
+
+ + +
+
+ ); +} diff --git a/superset-frontend/src/dashboard/components/DrillDetailPane/index.ts b/superset-frontend/src/dashboard/components/DrillDetailPane/index.ts new file mode 100644 index 0000000000..7e23e0a55c --- /dev/null +++ b/superset-frontend/src/dashboard/components/DrillDetailPane/index.ts @@ -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'; diff --git a/superset-frontend/src/dashboard/components/DrillDetailPane/utils.ts b/superset-frontend/src/dashboard/components/DrillDetailPane/utils.ts new file mode 100644 index 0000000000..03494024a9 --- /dev/null +++ b/superset-frontend/src/dashboard/components/DrillDetailPane/utils.ts @@ -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, + }; +} diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx index 8673a03848..ee99767b31 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx @@ -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 ( + <> + + {triggerNode} + + {(() => ( + + + + + } + responsive + resizable + resizableConfig={{ + minHeight: theme.gridUnit * 128, + minWidth: theme.gridUnit * 128, + defaultSize: { + width: 'auto', + height: '75vh', + }, + }} + draggable + destroyOnClose + > + {modalBody} + + ))()} + + ); +}; + class SliceHeaderControls extends React.PureComponent< SliceHeaderControlsPropsWithRouter, State @@ -339,7 +432,8 @@ class SliceHeaderControls extends React.PureComponent< {this.props.supersetCanExplore && ( - {t('View as table')} @@ -355,22 +449,26 @@ class SliceHeaderControls extends React.PureComponent< isVisible /> } - modalFooter={ - - } - draggable - resizable - responsive /> )} + {isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL) && + this.props.supersetCanExplore && ( + + + {t('Drill to detail')} + + } + modalTitle={t('Drill to detail: %s', slice.slice_name)} + modalBody={} + /> + + )} + {(slice.description || this.props.supersetCanExplore) && ( )}