diff --git a/superset-frontend/spec/fixtures/mockChartQueries.js b/superset-frontend/spec/fixtures/mockChartQueries.js index dc29d71ab..5d5afc483 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 000000000..d9d85566d --- /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 = {}) => { + const props = { + column: { column_name: 'state' }, + formData: { ...chart.form_data, viz_type: 'pie' }, + groupbyFieldName: 'groupby', + ...overrides, + }; + return render( + , + { + useRedux: true, + }, + ); +}; + +const waitForRender = (overrides: Record = {}) => + 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 000000000..1b588c1bf --- /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 ( +
+ {chartDataResult ? ( + + ) : ( + + )} +
+ ); +} diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx index 07b00c4be..2253ff5de 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} @@ -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 10d9e1af8..e3e96426f 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 ( <> setShowModal(false)} /> ); }; - render(, { 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 527284ebe..f870cdb01 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')} @@ -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} > - {} + ); } diff --git a/superset-frontend/src/components/Chart/MenuItemWithTruncation.tsx b/superset-frontend/src/components/Chart/MenuItemWithTruncation.tsx index 24c58d64b..14e1100ea 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 d686b7001..5ea678848 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 000000000..c3ce6c95e --- /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; +};