feat: Drill by open in Explore (#23575)

This commit is contained in:
Kamil Gabryjelski 2023-04-05 11:20:45 +02:00 committed by GitHub
parent 9d2f43d312
commit 117360cd57
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 171 additions and 117 deletions

View File

@ -27,14 +27,17 @@ import {
} from './Operator'; } from './Operator';
import { TimeGranularity } from '../../time-format'; import { TimeGranularity } from '../../time-format';
interface BaseSimpleAdhocFilter { interface BaseAdhocFilter {
expressionType: 'SIMPLE';
clause: 'WHERE' | 'HAVING'; clause: 'WHERE' | 'HAVING';
subject: string;
timeGrain?: TimeGranularity; timeGrain?: TimeGranularity;
isExtra?: boolean; isExtra?: boolean;
} }
interface BaseSimpleAdhocFilter extends BaseAdhocFilter {
expressionType: 'SIMPLE';
subject: string;
}
export type UnaryAdhocFilter = BaseSimpleAdhocFilter & { export type UnaryAdhocFilter = BaseSimpleAdhocFilter & {
operator: UnaryOperator; operator: UnaryOperator;
}; };
@ -54,9 +57,8 @@ export type SimpleAdhocFilter =
| BinaryAdhocFilter | BinaryAdhocFilter
| SetAdhocFilter; | SetAdhocFilter;
export interface FreeFormAdhocFilter { export interface FreeFormAdhocFilter extends BaseAdhocFilter {
expressionType: 'SQL'; expressionType: 'SQL';
clause: 'WHERE' | 'HAVING';
sqlExpression: string; sqlExpression: string;
} }

View File

@ -40,30 +40,10 @@ const fetchWithNoData = () => {
}); });
}; };
const setup = (overrides: Record<string, any> = {}) => { const setup = (overrides: Record<string, any> = {}) =>
const props = { render(<DrillByChart formData={{ ...chart.form_data, ...overrides }} />, {
column: { column_name: 'state' }, useRedux: true,
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> = {}) => const waitForRender = (overrides: Record<string, any> = {}) =>
waitFor(() => setup(overrides)); waitFor(() => setup(overrides));

View File

@ -17,52 +17,20 @@
* under the License. * under the License.
*/ */
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import { BaseFormData, Behavior, css, SuperChart } from '@superset-ui/core';
Behavior,
BinaryQueryObjectFilterClause,
Column,
css,
SuperChart,
} from '@superset-ui/core';
import { simpleFilterToAdhoc } from 'src/utils/simpleFilterToAdhoc';
import { getChartDataRequest } from 'src/components/Chart/chartAction'; import { getChartDataRequest } from 'src/components/Chart/chartAction';
import Loading from 'src/components/Loading'; import Loading from 'src/components/Loading';
interface DrillByChartProps { interface DrillByChartProps {
column?: Column; formData: BaseFormData & { [key: string]: any };
filters?: BinaryQueryObjectFilterClause[];
formData: { [key: string]: any; viz_type: string };
groupbyFieldName?: string;
} }
export default function DrillByChart({ export default function DrillByChart({ formData }: DrillByChartProps) {
column,
filters,
formData,
groupbyFieldName = 'groupby',
}: DrillByChartProps) {
let updatedFormData = formData;
let groupbyField: any = [];
const [chartDataResult, setChartDataResult] = useState(); const [chartDataResult, setChartDataResult] = useState();
if (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(() => { useEffect(() => {
getChartDataRequest({ getChartDataRequest({
formData: updatedFormData, formData,
}).then(({ json }) => { }).then(({ json }) => {
setChartDataResult(json.result); setChartDataResult(json.result);
}); });
@ -81,7 +49,7 @@ export default function DrillByChart({
behaviors={[Behavior.INTERACTIVE_CHART]} behaviors={[Behavior.INTERACTIVE_CHART]}
chartType={formData.viz_type} chartType={formData.viz_type}
enableNoResults enableNoResults
formData={updatedFormData} formData={formData}
queriesData={chartDataResult} queriesData={chartDataResult}
height="100%" height="100%"
width="100%" width="100%"

View File

@ -18,30 +18,35 @@
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
import fetchMock from 'fetch-mock';
import { omit, isUndefined, omitBy } from 'lodash';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/react';
import { render, screen } from 'spec/helpers/testing-library'; import { render, screen } from 'spec/helpers/testing-library';
import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries'; import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries';
import mockState from 'spec/fixtures/mockState'; import mockState from 'spec/fixtures/mockState';
import fetchMock from 'fetch-mock'; import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
import DrillByModal from './DrillByModal'; import DrillByModal from './DrillByModal';
const CHART_DATA_ENDPOINT = const CHART_DATA_ENDPOINT = 'glob:*/api/v1/chart/data*';
'glob:*api/v1/chart/data?form_data=%7B%22slice_id%22%3A18%7D'; const FORM_DATA_KEY_ENDPOINT = 'glob:*/api/v1/explore/form_data';
fetchMock.post(CHART_DATA_ENDPOINT, { body: {} }, {});
const { form_data: formData } = chartQueries[sliceId]; const { form_data: formData } = chartQueries[sliceId];
const { slice_name: chartName } = formData; const { slice_name: chartName } = formData;
const drillByModalState = { const drillByModalState = {
...mockState, ...mockState,
dashboardLayout: { dashboardLayout: {
CHART_ID: { past: [],
id: 'CHART_ID', present: {
meta: { CHART_ID: {
chartId: formData.slice_id, id: 'CHART_ID',
sliceName: chartName, meta: {
chartId: formData.slice_id,
sliceName: chartName,
},
}, },
}, },
future: [],
}, },
}; };
const dataset = { const dataset = {
@ -56,12 +61,13 @@ const dataset = {
}, },
], ],
}; };
const renderModal = async (state?: object) => {
const renderModal = async () => {
const DrillByModalWrapper = () => { const DrillByModalWrapper = () => {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
return ( return (
<> <DashboardPageIdContext.Provider value="1">
<button type="button" onClick={() => setShowModal(true)}> <button type="button" onClick={() => setShowModal(true)}>
Show modal Show modal
</button> </button>
@ -71,23 +77,29 @@ const renderModal = async (state?: object) => {
onHideModal={() => setShowModal(false)} onHideModal={() => setShowModal(false)}
dataset={dataset} dataset={dataset}
/> />
</> </DashboardPageIdContext.Provider>
); );
}; };
render(<DrillByModalWrapper />, { render(<DrillByModalWrapper />, {
useDnd: true, useDnd: true,
useRedux: true, useRedux: true,
useRouter: true, useRouter: true,
initialState: state, initialState: drillByModalState,
}); });
userEvent.click(screen.getByRole('button', { name: 'Show modal' })); userEvent.click(screen.getByRole('button', { name: 'Show modal' }));
await screen.findByRole('dialog', { name: `Drill by: ${chartName}` }); await screen.findByRole('dialog', { name: `Drill by: ${chartName}` });
}; };
beforeEach(() => {
fetchMock
.post(CHART_DATA_ENDPOINT, { body: {} }, {})
.post(FORM_DATA_KEY_ENDPOINT, { key: '123' });
});
afterEach(fetchMock.restore); afterEach(fetchMock.restore);
test('should render the title', async () => { test('should render the title', async () => {
await renderModal(drillByModalState); await renderModal();
expect(screen.getByText(`Drill by: ${chartName}`)).toBeInTheDocument(); expect(screen.getByText(`Drill by: ${chartName}`)).toBeInTheDocument();
}); });
@ -105,3 +117,30 @@ test('should close the modal', async () => {
userEvent.click(screen.getAllByRole('button', { name: 'Close' })[1]); userEvent.click(screen.getAllByRole('button', { name: 'Close' })[1]);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
}); });
test('should generate Explore url', async () => {
await renderModal();
await waitFor(() => fetchMock.called(FORM_DATA_KEY_ENDPOINT));
const expectedRequestPayload = {
form_data: {
...omitBy(
omit(formData, ['slice_id', 'slice_name', 'dashboards']),
isUndefined,
),
slice_id: 0,
},
datasource_id: Number(formData.datasource.split('__')[0]),
datasource_type: formData.datasource.split('__')[1],
};
const parsedRequestPayload = JSON.parse(
fetchMock.lastCall()?.[1]?.body as string,
);
parsedRequestPayload.form_data = JSON.parse(parsedRequestPayload.form_data);
expect(parsedRequestPayload).toEqual(expectedRequestPayload);
expect(
await screen.findByRole('link', { name: 'Edit chart' }),
).toHaveAttribute('href', '/explore/?form_data_key=123&dashboard_page_id=1');
});

View File

@ -17,47 +17,79 @@
* under the License. * under the License.
*/ */
import React from 'react'; import React, { useContext, useEffect, useMemo, useState } from 'react';
import { import {
BaseFormData,
BinaryQueryObjectFilterClause, BinaryQueryObjectFilterClause,
Column, Column,
css, css,
ensureIsArray,
t, t,
useTheme, useTheme,
} from '@superset-ui/core'; } from '@superset-ui/core';
import { useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import Modal from 'src/components/Modal'; import Modal from 'src/components/Modal';
import Button from 'src/components/Button'; import Button from 'src/components/Button';
import { useSelector } from 'react-redux';
import { DashboardLayout, RootState } from 'src/dashboard/types'; import { DashboardLayout, RootState } from 'src/dashboard/types';
import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
import { postFormData } from 'src/explore/exploreUtils/formData';
import { noOp } from 'src/utils/common';
import { simpleFilterToAdhoc } from 'src/utils/simpleFilterToAdhoc';
import { useDatasetMetadataBar } from 'src/features/datasets/metadataBar/useDatasetMetadataBar'; import { useDatasetMetadataBar } from 'src/features/datasets/metadataBar/useDatasetMetadataBar';
import { Dataset } from '../types'; import { Dataset } from '../types';
import DrillByChart from './DrillByChart'; import DrillByChart from './DrillByChart';
interface ModalFooterProps { interface ModalFooterProps {
exploreChart: () => void; formData: BaseFormData;
closeModal?: () => void; closeModal?: () => void;
} }
const ModalFooter = ({ exploreChart, closeModal }: ModalFooterProps) => ( const ModalFooter = ({ formData, closeModal }: ModalFooterProps) => {
<> const [url, setUrl] = useState('');
<Button buttonStyle="secondary" buttonSize="small" onClick={exploreChart}> const dashboardPageId = useContext(DashboardPageIdContext);
{t('Edit chart')} const [datasource_id, datasource_type] = formData.datasource.split('__');
</Button> useEffect(() => {
<Button postFormData(Number(datasource_id), datasource_type, formData, 0)
buttonStyle="primary" .then(key => {
buttonSize="small" setUrl(
onClick={closeModal} `/explore/?form_data_key=${key}&dashboard_page_id=${dashboardPageId}`,
data-test="close-drill-by-modal" );
> })
{t('Close')} .catch(e => {
</Button> console.log(e);
</> });
); }, [dashboardPageId, datasource_id, datasource_type, formData]);
return (
<>
<Button buttonStyle="secondary" buttonSize="small" onClick={noOp}>
<Link
css={css`
&:hover {
text-decoration: none;
}
`}
to={url}
>
{t('Edit chart')}
</Link>
</Button>
<Button
buttonStyle="primary"
buttonSize="small"
onClick={closeModal}
data-test="close-drill-by-modal"
>
{t('Close')}
</Button>
</>
);
};
interface DrillByModalProps { interface DrillByModalProps {
column?: Column; column?: Column;
filters?: BinaryQueryObjectFilterClause[]; filters?: BinaryQueryObjectFilterClause[];
formData: { [key: string]: any; viz_type: string }; formData: BaseFormData & { [key: string]: any };
groupbyFieldName?: string; groupbyFieldName?: string;
onHideModal: () => void; onHideModal: () => void;
showModal: boolean; showModal: boolean;
@ -68,7 +100,7 @@ export default function DrillByModal({
column, column,
filters, filters,
formData, formData,
groupbyFieldName, groupbyFieldName = 'groupby',
onHideModal, onHideModal,
showModal, showModal,
dataset, dataset,
@ -82,7 +114,32 @@ export default function DrillByModal({
); );
const chartName = const chartName =
chartLayoutItem?.meta.sliceNameOverride || chartLayoutItem?.meta.sliceName; chartLayoutItem?.meta.sliceNameOverride || chartLayoutItem?.meta.sliceName;
const exploreChart = () => {};
const updatedFormData = useMemo(() => {
let updatedFormData = { ...formData };
if (column) {
updatedFormData[groupbyFieldName] = Array.isArray(
formData[groupbyFieldName],
)
? [column.column_name]
: column.column_name;
}
if (filters) {
const adhocFilters = filters.map(filter => simpleFilterToAdhoc(filter));
updatedFormData = {
...updatedFormData,
adhoc_filters: [
...ensureIsArray(formData.adhoc_filters),
...adhocFilters,
],
};
}
updatedFormData.slice_id = 0;
delete updatedFormData.slice_name;
delete updatedFormData.dashboards;
return updatedFormData;
}, [column, filters, formData, groupbyFieldName]);
const { metadataBar } = useDatasetMetadataBar({ dataset }); const { metadataBar } = useDatasetMetadataBar({ dataset });
return ( return (
@ -95,7 +152,7 @@ export default function DrillByModal({
show={showModal} show={showModal}
onHide={onHideModal ?? (() => null)} onHide={onHideModal ?? (() => null)}
title={t('Drill by: %s', chartName)} title={t('Drill by: %s', chartName)}
footer={<ModalFooter exploreChart={exploreChart} />} footer={<ModalFooter formData={updatedFormData} />}
responsive responsive
resizable resizable
resizableConfig={{ resizableConfig={{
@ -118,12 +175,7 @@ export default function DrillByModal({
`} `}
> >
{metadataBar} {metadataBar}
<DrillByChart <DrillByChart formData={updatedFormData} />
column={column}
filters={filters}
formData={formData}
groupbyFieldName={groupbyFieldName}
/>
</div> </div>
</Modal> </Modal>
); );

View File

@ -33,6 +33,17 @@ import {
} from '@superset-ui/core'; } from '@superset-ui/core';
import { simpleFilterToAdhoc } from 'src/utils/simpleFilterToAdhoc'; import { simpleFilterToAdhoc } from 'src/utils/simpleFilterToAdhoc';
const removeExtraFieldForNewCharts = (
filters: AdhocFilter[],
isNewChart: boolean,
) =>
filters.map(filter => {
if (filter.isExtra) {
return { ...filter, isExtra: !isNewChart };
}
return filter;
});
const removeAdhocFilterDuplicates = (filters: AdhocFilter[]) => { const removeAdhocFilterDuplicates = (filters: AdhocFilter[]) => {
const isDuplicate = ( const isDuplicate = (
adhocFilter: AdhocFilter, adhocFilter: AdhocFilter,
@ -103,7 +114,6 @@ const mergeNativeFiltersToFormData = (
) => { ) => {
const nativeFiltersData: JsonObject = {}; const nativeFiltersData: JsonObject = {};
const extraFormData = dashboardFormData.extra_form_data || {}; const extraFormData = dashboardFormData.extra_form_data || {};
Object.entries(EXTRA_FORM_DATA_OVERRIDE_REGULAR_MAPPINGS).forEach( Object.entries(EXTRA_FORM_DATA_OVERRIDE_REGULAR_MAPPINGS).forEach(
([srcKey, targetKey]) => { ([srcKey, targetKey]) => {
const val = extraFormData[srcKey]; const val = extraFormData[srcKey];
@ -193,13 +203,16 @@ export const getFormDataWithDashboardContext = (
.reduce( .reduce(
(acc, key) => ({ (acc, key) => ({
...acc, ...acc,
[key]: applyTimeRangeFilters( [key]: removeExtraFieldForNewCharts(
dashboardContextFormData, applyTimeRangeFilters(
removeAdhocFilterDuplicates([ dashboardContextFormData,
...ensureIsArray(exploreFormData[key]), removeAdhocFilterDuplicates([
...ensureIsArray(filterBoxData[key]), ...ensureIsArray(exploreFormData[key]),
...ensureIsArray(nativeFiltersData[key]), ...ensureIsArray(filterBoxData[key]),
]), ...ensureIsArray(nativeFiltersData[key]),
]),
),
exploreFormData.slice_id === 0,
), ),
}), }),
{}, {},