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;
+};