You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by di...@apache.org on 2022/08/22 18:00:05 UTC
[superset] 01/19: Add drill-to-detail modal.
This is an automated email from the ASF dual-hosted git repository.
diegopucci pushed a commit to branch chore/e2e-tests-drilltodetail-modal
in repository https://gitbox.apache.org/repos/asf/superset.git
commit e0e703820ff2f0d6e642425fd48e92d50811c3db
Author: Cody Leff <co...@preset.io>
AuthorDate: Wed Aug 10 14:40:30 2022 -0600
Add drill-to-detail modal.
---
.../superset-ui-core/src/query/types/Query.ts | 45 ++--
.../src/components/Chart/chartAction.js | 7 +-
.../components/DrillDetailPane/DrillDetailPane.tsx | 239 +++++++++++++++++++++
.../components/DrillDetailPane/TableControls.tsx | 134 ++++++++++++
.../dashboard/components/DrillDetailPane/index.ts | 22 ++
.../components/SliceHeaderControls/index.tsx | 111 ++++++++--
6 files changed, 526 insertions(+), 32 deletions(-)
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 ec600da862..6c86b397fd 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;
- formattedVal?: string;
- }
- | {
- op: SetOperator;
- val: (string | number | boolean)[];
- formattedVal?: string[];
- }
- | {
- op: UnaryOperator;
- formattedVal?: string;
- }
-);
+};
+
+export type BinaryQueryObjectFilterClause = BaseQueryObjectFilterClause & {
+ op: BinaryOperator;
+ val: string | number | boolean;
+ formattedVal?: string;
+};
+
+export type SetQueryObjectFilterClause = BaseQueryObjectFilterClause & {
+ op: SetOperator;
+ val: (string | number | boolean)[];
+ 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/chartAction.js b/superset-frontend/src/components/Chart/chartAction.js
index 044593eb37..f45c5f42cc 100644
--- a/superset-frontend/src/components/Chart/chartAction.js
+++ b/superset-frontend/src/components/Chart/chartAction.js
@@ -603,8 +603,13 @@ export const getDatasourceSamples = async (
datasourceId,
force,
jsonPayload,
+ pagination,
) => {
- const endpoint = `/datasource/samples?force=${force}&datasource_type=${datasourceType}&datasource_id=${datasourceId}`;
+ let endpoint = `/datasource/samples?force=${force}&datasource_type=${datasourceType}&datasource_id=${datasourceId}`;
+ if (pagination) {
+ endpoint += `&page=${pagination.page}&per_page=${pagination.perPage}`;
+ }
+
try {
const response = await SupersetClient.post({ endpoint, jsonPayload });
return response.json.result;
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..f6b50f9d17
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/DrillDetailPane/DrillDetailPane.tsx
@@ -0,0 +1,239 @@
+/**
+ * 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 {
+ BinaryQueryObjectFilterClause,
+ css,
+ ensureIsArray,
+ GenericDataType,
+ t,
+ useTheme,
+} 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';
+
+type ResultsPage = {
+ total: number;
+ data: Record<string, any>[];
+ colNames: string[];
+ colTypes: GenericDataType[];
+};
+
+const PAGE_SIZE = 50;
+const MAX_CACHED_PAGES = 5;
+
+export default function DrillDetailPane({
+ datasource,
+ initialFilters,
+}: {
+ datasource: string;
+ 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(),
+ );
+
+ // Get string identifier for dataset
+ const [datasourceId, datasourceType] = useMemo(
+ () => datasource.split('__'),
+ [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]);
+
+ // Clear cache and reset page index if filters change
+ useEffect(() => {
+ 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 (!resultsPages.has(pageIndex)) {
+ setIsLoading(true);
+ getDatasourceSamples(
+ datasourceType,
+ datasourceId,
+ true,
+ filters.length ? { filters } : null,
+ { page: pageIndex + 1, perPage: PAGE_SIZE },
+ )
+ .then(response => {
+ setResultsPages(
+ new Map([
+ ...[...resultsPages.entries()].slice(-MAX_CACHED_PAGES + 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);
+ });
+ }
+ }, [datasourceId, datasourceType, filters, 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,
+ datasource,
+ );
+
+ const sortDisabledColumns = columns.map(column => ({
+ ...column,
+ disableSortBy: true,
+ }));
+
+ // Update page index on pagination click
+ const onServerPagination = useCallback(({ pageIndex }) => {
+ setPageIndex(pageIndex);
+ }, []);
+
+ // Clear cache on reload button click
+ const handleReload = useCallback(() => {
+ setResultsPages(new Map());
+ }, []);
+
+ // Render error if page download failed
+ if (responseError) {
+ return (
+ <pre
+ css={css`
+ margin-top: ${theme.gridUnit * 4}px;
+ `}
+ >
+ {responseError}
+ </pre>
+ );
+ }
+
+ // Render loading if first page hasn't loaded
+ if (!resultsPages.size) {
+ return (
+ <div
+ css={css`
+ height: ${theme.gridUnit * 25}px;
+ `}
+ >
+ <Loading />
+ </div>
+ );
+ }
+
+ // Render empty state if no results are returned for page
+ if (resultsPage?.total === 0) {
+ const title = t('No rows were returned for this dataset');
+ return <EmptyStateMedium image="document.svg" title={title} />;
+ }
+
+ // Render chart if at least one page has successfully loaded
+ return (
+ <div
+ css={css`
+ display: flex;
+ flex-direction: column;
+ `}
+ >
+ <TableControls
+ filters={filters}
+ setFilters={setFilters}
+ totalCount={resultsPage?.total}
+ onReload={handleReload}
+ />
+ <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`
+ min-height: 0;
+ overflow: scroll;
+ height: ${theme.gridUnit * 128}px;
+ `}
+ />
+ </div>
+ );
+}
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..0be301de46
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/DrillDetailPane/TableControls.tsx
@@ -0,0 +1,134 @@
+/**
+ * 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,
+ 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 }]) => ({ colName, 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 rowcount={totalCount} />
+ <Icons.ReloadOutlined
+ iconColor={theme.colors.grayscale.light1}
+ iconSize="l"
+ aria-label={t('Reload')}
+ role="button"
+ onClick={onReload}
+ />
+ </div>
+ </div>
+ );
+}
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..aeaf795770
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/DrillDetailPane/index.ts
@@ -0,0 +1,22 @@
+/**
+ * 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 DrillDetailPane from './DrillDetailPane';
+
+export default DrillDetailPane;
diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx
index 8673a03848..bb41c82d3e 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,
@@ -40,6 +51,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 +65,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 +111,7 @@ export interface SliceHeaderControlsProps {
slice_id: number;
slice_description: string;
form_data?: { emit_filter?: boolean };
+ datasource: string;
};
componentId: string;
@@ -140,6 +155,68 @@ 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);
+
+ return (
+ <>
+ <span
+ data-test="span-modal-trigger"
+ onClick={openModal}
+ role="button"
+ tabIndex={0}
+ >
+ {triggerNode}
+ </span>
+ {(() => (
+ <Modal
+ 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
+ draggable
+ destroyOnClose
+ >
+ {modalBody}
+ </Modal>
+ ))()}
+ </>
+ );
+};
+
class SliceHeaderControls extends React.PureComponent<
SliceHeaderControlsPropsWithRouter,
State
@@ -339,7 +416,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,18 +433,23 @@ class SliceHeaderControls extends React.PureComponent<
isVisible
/>
}
- modalFooter={
- <Button
- buttonStyle="secondary"
- buttonSize="small"
- onClick={() => this.props.history.push(this.props.exploreUrl)}
- >
- {t('Edit chart')}
- </Button>
+ />
+ </Menu.Item>
+ )}
+
+ {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 datasource={this.props.slice.datasource} />
}
- draggable
- resizable
- responsive
/>
</Menu.Item>
)}