feat: drill by display chart (#23524)

This commit is contained in:
Lily Kuang 2023-04-04 04:34:59 -07:00 committed by GitHub
parent 95db6c04aa
commit 4452a65095
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 292 additions and 57 deletions

View File

@ -33,6 +33,7 @@ export default {
triggerQuery: false,
lastRendered: 0,
form_data: {
adhoc_filters: [],
datasource: datasourceId,
viz_type: 'pie',
slice_id: sliceId,

View File

@ -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();
});

View File

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

View File

@ -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}
/>

View File

@ -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);

View File

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

View File

@ -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 = ({

View File

@ -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 = (

View File

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