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