feat: drill by display chart (#23524)
This commit is contained in:
parent
95db6c04aa
commit
4452a65095
|
|
@ -33,6 +33,7 @@ export default {
|
|||
triggerQuery: false,
|
||||
lastRendered: 0,
|
||||
form_data: {
|
||||
adhoc_filters: [],
|
||||
datasource: datasourceId,
|
||||
viz_type: 'pie',
|
||||
slice_id: sliceId,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
Loading…
Reference in New Issue