You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by kg...@apache.org on 2023/04/04 11:35:15 UTC

[superset] branch master updated: feat: drill by display chart (#23524)

This is an automated email from the ASF dual-hosted git repository.

kgabryje pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 4452a65095 feat: drill by display chart (#23524)
4452a65095 is described below

commit 4452a650956ac928da48e6d63f52065be53aeb6d
Author: Lily Kuang <li...@preset.io>
AuthorDate: Tue Apr 4 04:34:59 2023 -0700

    feat: drill by display chart (#23524)
---
 .../spec/fixtures/mockChartQueries.js              |  1 +
 .../components/Chart/DrillBy/DrillByChart.test.tsx | 90 +++++++++++++++++++++
 .../src/components/Chart/DrillBy/DrillByChart.tsx  | 94 ++++++++++++++++++++++
 .../components/Chart/DrillBy/DrillByMenuItems.tsx  | 21 +++--
 .../components/Chart/DrillBy/DrillByModal.test.tsx | 10 ++-
 .../src/components/Chart/DrillBy/DrillByModal.tsx  | 14 +++-
 .../components/Chart/MenuItemWithTruncation.tsx    |  3 +-
 .../getFormDataWithDashboardContext.ts             | 47 +----------
 superset-frontend/src/utils/simpleFilterToAdhoc.ts | 69 ++++++++++++++++
 9 files changed, 292 insertions(+), 57 deletions(-)

diff --git a/superset-frontend/spec/fixtures/mockChartQueries.js b/superset-frontend/spec/fixtures/mockChartQueries.js
index dc29d71abb..5d5afc483f 100644
--- a/superset-frontend/spec/fixtures/mockChartQueries.js
+++ b/superset-frontend/spec/fixtures/mockChartQueries.js
@@ -33,6 +33,7 @@ export default {
     triggerQuery: false,
     lastRendered: 0,
     form_data: {
+      adhoc_filters: [],
       datasource: datasourceId,
       viz_type: 'pie',
       slice_id: sliceId,
diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByChart.test.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByChart.test.tsx
new file mode 100644
index 0000000000..d9d85566dc
--- /dev/null
+++ b/superset-frontend/src/components/Chart/DrillBy/DrillByChart.test.tsx
@@ -0,0 +1,90 @@
+/**
+ * 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 from 'react';
+import { render, screen, waitFor } from 'spec/helpers/testing-library';
+import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries';
+import fetchMock from 'fetch-mock';
+import DrillByChart from './DrillByChart';
+
+const CHART_DATA_ENDPOINT =
+  'glob:*api/v1/chart/data?form_data=%7B%22slice_id%22%3A18%7D';
+
+const chart = chartQueries[sliceId];
+
+const fetchWithNoData = () => {
+  fetchMock.post(CHART_DATA_ENDPOINT, {
+    result: [
+      {
+        total_count: 0,
+        data: [],
+        colnames: [],
+        coltypes: [],
+      },
+    ],
+  });
+};
+
+const setup = (overrides: Record<string, any> = {}) => {
+  const props = {
+    column: { column_name: 'state' },
+    formData: { ...chart.form_data, viz_type: 'pie' },
+    groupbyFieldName: 'groupby',
+    ...overrides,
+  };
+  return render(
+    <DrillByChart
+      filters={[
+        {
+          col: 'gender',
+          op: '==',
+          val: 'boy',
+          formattedVal: 'boy',
+        },
+      ]}
+      {...props}
+    />,
+    {
+      useRedux: true,
+    },
+  );
+};
+
+const waitForRender = (overrides: Record<string, any> = {}) =>
+  waitFor(() => setup(overrides));
+
+afterEach(fetchMock.restore);
+
+test('should render', async () => {
+  fetchWithNoData();
+  const { container } = await waitForRender();
+  expect(container).toBeInTheDocument();
+});
+
+test('should render loading indicator', async () => {
+  setup();
+  await waitFor(() =>
+    expect(screen.getByLabelText('Loading')).toBeInTheDocument(),
+  );
+});
+
+test('should render the "No results" components', async () => {
+  fetchWithNoData();
+  setup();
+  expect(await screen.findByText('No Results')).toBeInTheDocument();
+});
diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByChart.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByChart.tsx
new file mode 100644
index 0000000000..1b588c1bfe
--- /dev/null
+++ b/superset-frontend/src/components/Chart/DrillBy/DrillByChart.tsx
@@ -0,0 +1,94 @@
+/**
+ * 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, { useEffect, useState } from 'react';
+import {
+  Behavior,
+  BinaryQueryObjectFilterClause,
+  Column,
+  css,
+  SuperChart,
+} from '@superset-ui/core';
+import { simpleFilterToAdhoc } from 'src/utils/simpleFilterToAdhoc';
+import { getChartDataRequest } from 'src/components/Chart/chartAction';
+import Loading from 'src/components/Loading';
+
+interface DrillByChartProps {
+  column?: Column;
+  filters?: BinaryQueryObjectFilterClause[];
+  formData: { [key: string]: any; viz_type: string };
+  groupbyFieldName?: string;
+}
+
+export default function DrillByChart({
+  column,
+  filters,
+  formData,
+  groupbyFieldName = 'groupby',
+}: DrillByChartProps) {
+  let updatedFormData = formData;
+  let groupbyField: any = [];
+  const [chartDataResult, setChartDataResult] = useState();
+
+  if (groupbyFieldName && column) {
+    groupbyField = Array.isArray(formData[groupbyFieldName])
+      ? [column.column_name]
+      : column.column_name;
+  }
+
+  if (filters) {
+    const adhocFilters = filters.map(filter => simpleFilterToAdhoc(filter));
+    updatedFormData = {
+      ...formData,
+      adhoc_filters: [...formData.adhoc_filters, ...adhocFilters],
+      [groupbyFieldName]: groupbyField,
+    };
+  }
+
+  useEffect(() => {
+    getChartDataRequest({
+      formData: updatedFormData,
+    }).then(({ json }) => {
+      setChartDataResult(json.result);
+    });
+  }, []);
+
+  return (
+    <div
+      css={css`
+        width: 100%;
+        height: 100%;
+      `}
+    >
+      {chartDataResult ? (
+        <SuperChart
+          disableErrorBoundary
+          behaviors={[Behavior.INTERACTIVE_CHART]}
+          chartType={formData.viz_type}
+          enableNoResults
+          formData={updatedFormData}
+          height="100%"
+          queriesData={chartDataResult}
+          width="100%"
+        />
+      ) : (
+        <Loading />
+      )}
+    </div>
+  );
+}
diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx
index 07b00c4be3..2253ff5dea 100644
--- a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx
+++ b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx
@@ -58,13 +58,18 @@ export interface DrillByMenuItemsProps {
   contextMenuY?: number;
   submenuIndex?: number;
   groupbyFieldName?: string;
+  onSelection?: () => void;
+  onClick?: (event: MouseEvent) => void;
 }
+
 export const DrillByMenuItems = ({
   filters,
   groupbyFieldName,
   formData,
   contextMenuY = 0,
   submenuIndex = 0,
+  onSelection = () => {},
+  onClick = () => {},
   ...rest
 }: DrillByMenuItemsProps) => {
   const theme = useTheme();
@@ -73,10 +78,15 @@ export const DrillByMenuItems = ({
   const [showModal, setShowModal] = useState(false);
   const [currentColumn, setCurrentColumn] = useState();
 
-  const openModal = useCallback(column => {
-    setCurrentColumn(column);
-    setShowModal(true);
-  }, []);
+  const openModal = useCallback(
+    (event, column) => {
+      onClick(event);
+      onSelection();
+      setCurrentColumn(column);
+      setShowModal(true);
+    },
+    [onClick, onSelection],
+  );
   const closeModal = useCallback(() => {
     setShowModal(false);
   }, []);
@@ -218,7 +228,7 @@ export const DrillByMenuItems = ({
                   key={`drill-by-item-${column.column_name}`}
                   tooltipText={column.verbose_name || column.column_name}
                   {...rest}
-                  onClick={() => openModal(column)}
+                  onClick={e => openModal(e, column)}
                 >
                   {column.verbose_name || column.column_name}
                 </MenuItemWithTruncation>
@@ -235,6 +245,7 @@ export const DrillByMenuItems = ({
         column={currentColumn}
         filters={filters}
         formData={formData}
+        groupbyFieldName={groupbyFieldName}
         onHideModal={closeModal}
         showModal={showModal}
       />
diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx
index 10d9e1af83..e3e96426f8 100644
--- a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx
+++ b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx
@@ -22,8 +22,14 @@ import userEvent from '@testing-library/user-event';
 import { render, screen } from 'spec/helpers/testing-library';
 import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries';
 import mockState from 'spec/fixtures/mockState';
+import fetchMock from 'fetch-mock';
 import DrillByModal from './DrillByModal';
 
+const CHART_DATA_ENDPOINT =
+  'glob:*api/v1/chart/data?form_data=%7B%22slice_id%22%3A18%7D';
+
+fetchMock.post(CHART_DATA_ENDPOINT, { body: {} }, {});
+
 const { form_data: formData } = chartQueries[sliceId];
 const { slice_name: chartName } = formData;
 const drillByModalState = {
@@ -41,6 +47,7 @@ const drillByModalState = {
 const renderModal = async (state?: object) => {
   const DrillByModalWrapper = () => {
     const [showModal, setShowModal] = useState(false);
+
     return (
       <>
         <button type="button" onClick={() => setShowModal(true)}>
@@ -48,14 +55,12 @@ const renderModal = async (state?: object) => {
         </button>
         <DrillByModal
           formData={formData}
-          filters={[]}
           showModal={showModal}
           onHideModal={() => setShowModal(false)}
         />
       </>
     );
   };
-
   render(<DrillByModalWrapper />, {
     useDnd: true,
     useRedux: true,
@@ -66,6 +71,7 @@ const renderModal = async (state?: object) => {
   userEvent.click(screen.getByRole('button', { name: 'Show modal' }));
   await screen.findByRole('dialog', { name: `Drill by: ${chartName}` });
 };
+afterEach(fetchMock.restore);
 
 test('should render the title', async () => {
   await renderModal(drillByModalState);
diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx
index 527284ebe5..f870cdb01c 100644
--- a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx
+++ b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx
@@ -29,6 +29,7 @@ import Modal from 'src/components/Modal';
 import Button from 'src/components/Button';
 import { useSelector } from 'react-redux';
 import { DashboardLayout, RootState } from 'src/dashboard/types';
+import DrillByChart from './DrillByChart';
 
 interface ModalFooterProps {
   exploreChart: () => void;
@@ -44,7 +45,7 @@ const ModalFooter = ({ exploreChart, closeModal }: ModalFooterProps) => (
       buttonStyle="primary"
       buttonSize="small"
       onClick={closeModal}
-      data-test="close-drillby-modal"
+      data-test="close-drill-by-modal"
     >
       {t('Close')}
     </Button>
@@ -55,14 +56,16 @@ interface DrillByModalProps {
   column?: Column;
   filters?: BinaryQueryObjectFilterClause[];
   formData: { [key: string]: any; viz_type: string };
+  groupbyFieldName?: string;
   onHideModal: () => void;
   showModal: boolean;
 }
 
 export default function DrillByModal({
   column,
-  formData,
   filters,
+  formData,
+  groupbyFieldName,
   onHideModal,
   showModal,
 }: DrillByModalProps) {
@@ -102,7 +105,12 @@ export default function DrillByModal({
       destroyOnClose
       maskClosable={false}
     >
-      {}
+      <DrillByChart
+        column={column}
+        filters={filters}
+        formData={formData}
+        groupbyFieldName={groupbyFieldName}
+      />
     </Modal>
   );
 }
diff --git a/superset-frontend/src/components/Chart/MenuItemWithTruncation.tsx b/superset-frontend/src/components/Chart/MenuItemWithTruncation.tsx
index 24c58d64bc..14e1100ea2 100644
--- a/superset-frontend/src/components/Chart/MenuItemWithTruncation.tsx
+++ b/superset-frontend/src/components/Chart/MenuItemWithTruncation.tsx
@@ -21,11 +21,12 @@ import React, { ReactNode } from 'react';
 import { css, truncationCSS, useCSSTextTruncation } from '@superset-ui/core';
 import { Menu } from 'src/components/Menu';
 import { Tooltip } from 'src/components/Tooltip';
+import type { MenuProps } from 'antd/lib/menu';
 
 export type MenuItemWithTruncationProps = {
   tooltipText: ReactNode;
   children: ReactNode;
-  onClick?: () => void;
+  onClick?: MenuProps['onClick'];
 };
 
 export const MenuItemWithTruncation = ({
diff --git a/superset-frontend/src/explore/controlUtils/getFormDataWithDashboardContext.ts b/superset-frontend/src/explore/controlUtils/getFormDataWithDashboardContext.ts
index d686b70013..5ea6788485 100644
--- a/superset-frontend/src/explore/controlUtils/getFormDataWithDashboardContext.ts
+++ b/superset-frontend/src/explore/controlUtils/getFormDataWithDashboardContext.ts
@@ -22,7 +22,6 @@ import {
   ensureIsArray,
   EXTRA_FORM_DATA_OVERRIDE_EXTRA_KEYS,
   EXTRA_FORM_DATA_OVERRIDE_REGULAR_MAPPINGS,
-  isAdhocColumn,
   isDefined,
   isFreeFormAdhocFilter,
   isSimpleAdhocFilter,
@@ -32,51 +31,7 @@ import {
   QueryObjectFilterClause,
   SimpleAdhocFilter,
 } from '@superset-ui/core';
-import { OPERATOR_ENUM_TO_OPERATOR_TYPE } from '../constants';
-import { translateToSql } from '../components/controls/FilterControl/utils/translateToSQL';
-import {
-  CLAUSES,
-  EXPRESSION_TYPES,
-} from '../components/controls/FilterControl/types';
-
-const simpleFilterToAdhoc = (
-  filterClause: QueryObjectFilterClause,
-  clause: CLAUSES = CLAUSES.WHERE,
-) => {
-  let result: AdhocFilter;
-  if (isAdhocColumn(filterClause.col)) {
-    result = {
-      expressionType: 'SQL',
-      clause,
-      sqlExpression: translateToSql({
-        expressionType: EXPRESSION_TYPES.SIMPLE,
-        subject: `(${filterClause.col.sqlExpression})`,
-        operator: filterClause.op,
-        comparator: 'val' in filterClause ? filterClause.val : undefined,
-      } as SimpleAdhocFilter),
-    };
-  } else {
-    result = {
-      expressionType: 'SIMPLE',
-      clause,
-      operator: filterClause.op,
-      operatorId: Object.entries(OPERATOR_ENUM_TO_OPERATOR_TYPE).find(
-        operatorEntry => operatorEntry[1].operation === filterClause.op,
-      )?.[0],
-      subject: filterClause.col,
-      comparator: 'val' in filterClause ? filterClause.val : undefined,
-    } as SimpleAdhocFilter;
-  }
-  if (filterClause.isExtra) {
-    Object.assign(result, {
-      isExtra: true,
-      filterOptionName: `filter_${Math.random()
-        .toString(36)
-        .substring(2, 15)}_${Math.random().toString(36).substring(2, 15)}`,
-    });
-  }
-  return result;
-};
+import { simpleFilterToAdhoc } from 'src/utils/simpleFilterToAdhoc';
 
 const removeAdhocFilterDuplicates = (filters: AdhocFilter[]) => {
   const isDuplicate = (
diff --git a/superset-frontend/src/utils/simpleFilterToAdhoc.ts b/superset-frontend/src/utils/simpleFilterToAdhoc.ts
new file mode 100644
index 0000000000..c3ce6c95e9
--- /dev/null
+++ b/superset-frontend/src/utils/simpleFilterToAdhoc.ts
@@ -0,0 +1,69 @@
+/**
+ * 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 {
+  AdhocFilter,
+  isAdhocColumn,
+  QueryObjectFilterClause,
+  SimpleAdhocFilter,
+} from '@superset-ui/core';
+import {
+  CLAUSES,
+  EXPRESSION_TYPES,
+} from '../explore/components/controls/FilterControl/types';
+import { OPERATOR_ENUM_TO_OPERATOR_TYPE } from '../explore/constants';
+import { translateToSql } from '../explore/components/controls/FilterControl/utils/translateToSQL';
+
+export const simpleFilterToAdhoc = (
+  filterClause: QueryObjectFilterClause,
+  clause: CLAUSES = CLAUSES.WHERE,
+) => {
+  let result: AdhocFilter;
+  if (isAdhocColumn(filterClause.col)) {
+    result = {
+      expressionType: 'SQL',
+      clause,
+      sqlExpression: translateToSql({
+        expressionType: EXPRESSION_TYPES.SIMPLE,
+        subject: `(${filterClause.col.sqlExpression})`,
+        operator: filterClause.op,
+        comparator: 'val' in filterClause ? filterClause.val : undefined,
+      } as SimpleAdhocFilter),
+    };
+  } else {
+    result = {
+      expressionType: 'SIMPLE',
+      clause,
+      operator: filterClause.op,
+      operatorId: Object.entries(OPERATOR_ENUM_TO_OPERATOR_TYPE).find(
+        operatorEntry => operatorEntry[1].operation === filterClause.op,
+      )?.[0],
+      subject: filterClause.col,
+      comparator: 'val' in filterClause ? filterClause.val : undefined,
+    } as SimpleAdhocFilter;
+  }
+  if (filterClause.isExtra) {
+    Object.assign(result, {
+      isExtra: true,
+      filterOptionName: `filter_${Math.random()
+        .toString(36)
+        .substring(2, 15)}_${Math.random().toString(36).substring(2, 15)}`,
+    });
+  }
+  return result;
+};