feat: Pass dashboard context to explore through local storage (#20743)
* feat: Pass dashboard context to explore through local storage * Remove console log * Remove unused local storage keys * Fix lint * Fix link * Fix UT * fix lint * fix prettier * Fix bug * Fix bug with some sample dashboards * Roll back unnecessary change * style fix * Add comments * Fix lint * Address code review comments * Fix
This commit is contained in:
parent
644148b37d
commit
0945d4a2f4
|
|
@ -238,6 +238,7 @@
|
||||||
"exports-loader": "^0.7.0",
|
"exports-loader": "^0.7.0",
|
||||||
"fetch-mock": "^7.7.3",
|
"fetch-mock": "^7.7.3",
|
||||||
"fork-ts-checker-webpack-plugin": "^6.3.3",
|
"fork-ts-checker-webpack-plugin": "^6.3.3",
|
||||||
|
"history": "^4.10.1",
|
||||||
"ignore-styles": "^5.0.1",
|
"ignore-styles": "^5.0.1",
|
||||||
"imports-loader": "^3.0.0",
|
"imports-loader": "^3.0.0",
|
||||||
"jest": "^26.6.3",
|
"jest": "^26.6.3",
|
||||||
|
|
|
||||||
|
|
@ -299,6 +299,7 @@
|
||||||
"exports-loader": "^0.7.0",
|
"exports-loader": "^0.7.0",
|
||||||
"fetch-mock": "^7.7.3",
|
"fetch-mock": "^7.7.3",
|
||||||
"fork-ts-checker-webpack-plugin": "^6.3.3",
|
"fork-ts-checker-webpack-plugin": "^6.3.3",
|
||||||
|
"history": "^4.10.1",
|
||||||
"ignore-styles": "^5.0.1",
|
"ignore-styles": "^5.0.1",
|
||||||
"imports-loader": "^3.0.0",
|
"imports-loader": "^3.0.0",
|
||||||
"jest": "^26.6.3",
|
"jest": "^26.6.3",
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,10 @@ export type Filters = {
|
||||||
[filterId: string]: Filter | Divider;
|
[filterId: string]: Filter | Divider;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PartialFilters = {
|
||||||
|
[filterId: string]: Partial<Filters[keyof Filters]>;
|
||||||
|
};
|
||||||
|
|
||||||
export type NativeFiltersState = {
|
export type NativeFiltersState = {
|
||||||
filters: Filters;
|
filters: Filters;
|
||||||
filterSets: FilterSets;
|
filterSets: FilterSets;
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,7 @@ export const singleNativeFiltersState = {
|
||||||
inverseSelection: false,
|
inverseSelection: false,
|
||||||
allowsMultipleValues: false,
|
allowsMultipleValues: false,
|
||||||
isRequired: false,
|
isRequired: false,
|
||||||
|
chartsInScope: [230],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,10 @@
|
||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import React, { MouseEvent, useEffect, useState, useRef } from 'react';
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
import { css, styled, t } from '@superset-ui/core';
|
import { css, styled, SupersetTheme, t } from '@superset-ui/core';
|
||||||
import { Tooltip } from 'src/components/Tooltip';
|
import { Tooltip } from 'src/components/Tooltip';
|
||||||
import CertifiedBadge from '../CertifiedBadge';
|
import CertifiedBadge from '../CertifiedBadge';
|
||||||
|
|
||||||
|
|
@ -37,7 +38,7 @@ export interface EditableTitleProps {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
certifiedBy?: string;
|
certifiedBy?: string;
|
||||||
certificationDetails?: string;
|
certificationDetails?: string;
|
||||||
onClickTitle?: (event: MouseEvent) => void;
|
url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledCertifiedBadge = styled(CertifiedBadge)`
|
const StyledCertifiedBadge = styled(CertifiedBadge)`
|
||||||
|
|
@ -58,7 +59,7 @@ export default function EditableTitle({
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
certifiedBy,
|
certifiedBy,
|
||||||
certificationDetails,
|
certificationDetails,
|
||||||
onClickTitle,
|
url,
|
||||||
// rest is related to title tooltip
|
// rest is related to title tooltip
|
||||||
...rest
|
...rest
|
||||||
}: EditableTitleProps) {
|
}: EditableTitleProps) {
|
||||||
|
|
@ -218,20 +219,20 @@ export default function EditableTitle({
|
||||||
}
|
}
|
||||||
if (!canEdit) {
|
if (!canEdit) {
|
||||||
// don't actually want an input in this case
|
// don't actually want an input in this case
|
||||||
titleComponent = onClickTitle ? (
|
titleComponent = url ? (
|
||||||
<span
|
<Link
|
||||||
role="button"
|
to={url}
|
||||||
onClick={onClickTitle}
|
|
||||||
tabIndex={0}
|
|
||||||
data-test="editable-title-input"
|
data-test="editable-title-input"
|
||||||
css={css`
|
css={(theme: SupersetTheme) => css`
|
||||||
|
color: ${theme.colors.grayscale.dark1};
|
||||||
|
text-decoration: none;
|
||||||
:hover {
|
:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span data-test="editable-title-input">{value}</span>
|
<span data-test="editable-title-input">{value}</span>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ export const URL_PARAMS = {
|
||||||
},
|
},
|
||||||
sliceId: {
|
sliceId: {
|
||||||
name: 'slice_id',
|
name: 'slice_id',
|
||||||
type: 'string',
|
type: 'number',
|
||||||
},
|
},
|
||||||
datasourceId: {
|
datasourceId: {
|
||||||
name: 'datasource_id',
|
name: 'datasource_id',
|
||||||
|
|
@ -103,6 +103,10 @@ export const URL_PARAMS = {
|
||||||
name: 'save_action',
|
name: 'save_action',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
},
|
},
|
||||||
|
dashboardPageId: {
|
||||||
|
name: 'dashboard_page_id',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const RESERVED_CHART_URL_PARAMS: string[] = [
|
export const RESERVED_CHART_URL_PARAMS: string[] = [
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ import { FeatureFlag, isFeatureEnabled } from '../../featureFlags';
|
||||||
import extractUrlParams from '../util/extractUrlParams';
|
import extractUrlParams from '../util/extractUrlParams';
|
||||||
import getNativeFilterConfig from '../util/filterboxMigrationHelper';
|
import getNativeFilterConfig from '../util/filterboxMigrationHelper';
|
||||||
import { updateColorSchema } from './dashboardInfo';
|
import { updateColorSchema } from './dashboardInfo';
|
||||||
|
import { getChartIdsInFilterScope } from '../util/getChartIdsInFilterScope';
|
||||||
|
import updateComponentParentsList from '../util/updateComponentParentsList';
|
||||||
|
|
||||||
export const HYDRATE_DASHBOARD = 'HYDRATE_DASHBOARD';
|
export const HYDRATE_DASHBOARD = 'HYDRATE_DASHBOARD';
|
||||||
|
|
||||||
|
|
@ -256,6 +258,19 @@ export const hydrateDashboard =
|
||||||
layout[layoutId].meta.sliceName = slice.slice_name;
|
layout[layoutId].meta.sliceName = slice.slice_name;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// make sure that parents tree is built
|
||||||
|
if (
|
||||||
|
Object.values(layout).some(
|
||||||
|
element => element.id !== DASHBOARD_ROOT_ID && !element.parents,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
updateComponentParentsList({
|
||||||
|
currentComponent: layout[DASHBOARD_ROOT_ID],
|
||||||
|
layout,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
buildActiveFilters({
|
buildActiveFilters({
|
||||||
dashboardFilters,
|
dashboardFilters,
|
||||||
components: layout,
|
components: layout,
|
||||||
|
|
@ -333,9 +348,21 @@ export const hydrateDashboard =
|
||||||
rootPath: [DASHBOARD_ROOT_ID],
|
rootPath: [DASHBOARD_ROOT_ID],
|
||||||
excluded: [chartId], // By default it doesn't affects itself
|
excluded: [chartId], // By default it doesn't affects itself
|
||||||
},
|
},
|
||||||
|
chartsInScope: Array.from(sliceIds),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
behaviors.includes(Behavior.INTERACTIVE_CHART) &&
|
||||||
|
!metadata.chart_configuration[chartId].crossFilters?.chartsInScope
|
||||||
|
) {
|
||||||
|
metadata.chart_configuration[chartId].crossFilters.chartsInScope =
|
||||||
|
getChartIdsInFilterScope(
|
||||||
|
metadata.chart_configuration[chartId].crossFilters.scope,
|
||||||
|
charts,
|
||||||
|
dashboardLayout.present,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ import Button from 'src/components/Button';
|
||||||
import { AntdForm } from 'src/components';
|
import { AntdForm } from 'src/components';
|
||||||
import { setChartConfiguration } from 'src/dashboard/actions/dashboardInfo';
|
import { setChartConfiguration } from 'src/dashboard/actions/dashboardInfo';
|
||||||
import { ChartConfiguration } from 'src/dashboard/reducers/types';
|
import { ChartConfiguration } from 'src/dashboard/reducers/types';
|
||||||
|
import { ChartsState, Layout, RootState } from 'src/dashboard/types';
|
||||||
|
import { getChartIdsInFilterScope } from 'src/dashboard/util/getChartIdsInFilterScope';
|
||||||
import CrossFilterScopingForm from './CrossFilterScopingForm';
|
import CrossFilterScopingForm from './CrossFilterScopingForm';
|
||||||
import { CrossFilterScopingFormType } from './types';
|
import { CrossFilterScopingFormType } from './types';
|
||||||
import { StyledForm } from '../nativeFilters/FiltersConfigModal/FiltersConfigModal';
|
import { StyledForm } from '../nativeFilters/FiltersConfigModal/FiltersConfigModal';
|
||||||
|
|
@ -44,14 +46,24 @@ const CrossFilterScopingModal: FC<CrossFilterScopingModalProps> = ({
|
||||||
const chartConfig = useSelector<any, ChartConfiguration>(
|
const chartConfig = useSelector<any, ChartConfiguration>(
|
||||||
({ dashboardInfo }) => dashboardInfo?.metadata?.chart_configuration,
|
({ dashboardInfo }) => dashboardInfo?.metadata?.chart_configuration,
|
||||||
);
|
);
|
||||||
|
const charts = useSelector<RootState, ChartsState>(state => state.charts);
|
||||||
|
const layout = useSelector<RootState, Layout>(
|
||||||
|
state => state.dashboardLayout.present,
|
||||||
|
);
|
||||||
const scope = chartConfig?.[chartId]?.crossFilters?.scope;
|
const scope = chartConfig?.[chartId]?.crossFilters?.scope;
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
|
const chartsInScope = getChartIdsInFilterScope(
|
||||||
|
form.getFieldValue('scope'),
|
||||||
|
charts,
|
||||||
|
layout,
|
||||||
|
);
|
||||||
|
|
||||||
dispatch(
|
dispatch(
|
||||||
setChartConfiguration({
|
setChartConfiguration({
|
||||||
...chartConfig,
|
...chartConfig,
|
||||||
[chartId]: {
|
[chartId]: {
|
||||||
id: chartId,
|
id: chartId,
|
||||||
crossFilters: { scope: form.getFieldValue('scope') },
|
crossFilters: { scope: form.getFieldValue('scope'), chartsInScope },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ import datasources from 'spec/fixtures/mockDatasource';
|
||||||
import {
|
import {
|
||||||
extraFormData,
|
extraFormData,
|
||||||
NATIVE_FILTER_ID,
|
NATIVE_FILTER_ID,
|
||||||
layoutForSingleNativeFilter,
|
|
||||||
singleNativeFiltersState,
|
singleNativeFiltersState,
|
||||||
dataMaskWith1Filter,
|
dataMaskWith1Filter,
|
||||||
} from 'spec/fixtures/mockNativeFilters';
|
} from 'spec/fixtures/mockNativeFilters';
|
||||||
|
|
@ -157,7 +156,7 @@ describe('Dashboard', () => {
|
||||||
...getAllActiveFilters({
|
...getAllActiveFilters({
|
||||||
dataMask: dataMaskWith1Filter,
|
dataMask: dataMaskWith1Filter,
|
||||||
nativeFilters: singleNativeFiltersState.filters,
|
nativeFilters: singleNativeFiltersState.filters,
|
||||||
layout: layoutForSingleNativeFilter,
|
allSliceIds: [227, 229, 230],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ import {
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import { NO_TIME_RANGE, TIME_FILTER_MAP } from 'src/explore/constants';
|
import { NO_TIME_RANGE, TIME_FILTER_MAP } from 'src/explore/constants';
|
||||||
import { getChartIdsInFilterBoxScope } from 'src/dashboard/util/activeDashboardFilters';
|
import { getChartIdsInFilterBoxScope } from 'src/dashboard/util/activeDashboardFilters';
|
||||||
import { CHART_TYPE } from 'src/dashboard/util/componentTypes';
|
|
||||||
import { ChartConfiguration } from 'src/dashboard/reducers/types';
|
import { ChartConfiguration } from 'src/dashboard/reducers/types';
|
||||||
import { Layout } from 'src/dashboard/types';
|
import { Layout } from 'src/dashboard/types';
|
||||||
import { areObjectsEqual } from 'src/reduxUtils';
|
import { areObjectsEqual } from 'src/reduxUtils';
|
||||||
|
|
@ -293,18 +292,9 @@ export const selectNativeIndicatorsForChart = (
|
||||||
let crossFilterIndicators: any = [];
|
let crossFilterIndicators: any = [];
|
||||||
if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) {
|
if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) {
|
||||||
const dashboardLayoutValues = Object.values(dashboardLayout);
|
const dashboardLayoutValues = Object.values(dashboardLayout);
|
||||||
const chartLayoutItem = dashboardLayoutValues.find(
|
|
||||||
layoutItem => layoutItem?.meta?.chartId === chartId,
|
|
||||||
);
|
|
||||||
crossFilterIndicators = Object.values(chartConfiguration)
|
crossFilterIndicators = Object.values(chartConfiguration)
|
||||||
.filter(
|
.filter(chartConfig =>
|
||||||
chartConfig =>
|
chartConfig.crossFilters.chartsInScope.includes(chartId),
|
||||||
!chartConfig.crossFilters.scope.excluded.includes(chartId) &&
|
|
||||||
chartConfig.crossFilters.scope.rootPath.some(
|
|
||||||
elementId =>
|
|
||||||
chartLayoutItem?.type === CHART_TYPE &&
|
|
||||||
chartLayoutItem?.parents?.includes(elementId),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.map(chartConfig => {
|
.map(chartConfig => {
|
||||||
const filterState = dataMask[chartConfig.id]?.filterState;
|
const filterState = dataMask[chartConfig.id]?.filterState;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Router } from 'react-router-dom';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
import { render, screen } from 'spec/helpers/testing-library';
|
import { render, screen } from 'spec/helpers/testing-library';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import SliceHeader from '.';
|
import SliceHeader from '.';
|
||||||
|
|
@ -155,7 +157,6 @@ const createProps = (overrides: any = {}) => ({
|
||||||
forceRefresh: jest.fn(),
|
forceRefresh: jest.fn(),
|
||||||
logExploreChart: jest.fn(),
|
logExploreChart: jest.fn(),
|
||||||
exportCSV: jest.fn(),
|
exportCSV: jest.fn(),
|
||||||
onExploreChart: jest.fn(),
|
|
||||||
formData: { slice_id: 1, datasource: '58__table' },
|
formData: { slice_id: 1, datasource: '58__table' },
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 100,
|
height: 100,
|
||||||
|
|
@ -206,7 +207,7 @@ test('Should render - default props', () => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
delete props.sliceCanEdit;
|
delete props.sliceCanEdit;
|
||||||
|
|
||||||
render(<SliceHeader {...props} />, { useRedux: true });
|
render(<SliceHeader {...props} />, { useRedux: true, useRouter: true });
|
||||||
expect(screen.getByTestId('slice-header')).toBeInTheDocument();
|
expect(screen.getByTestId('slice-header')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -248,7 +249,7 @@ test('Should render default props and "call" actions', () => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
delete props.sliceCanEdit;
|
delete props.sliceCanEdit;
|
||||||
|
|
||||||
render(<SliceHeader {...props} />, { useRedux: true });
|
render(<SliceHeader {...props} />, { useRedux: true, useRouter: true });
|
||||||
userEvent.click(screen.getByTestId('toggleExpandSlice'));
|
userEvent.click(screen.getByTestId('toggleExpandSlice'));
|
||||||
userEvent.click(screen.getByTestId('forceRefresh'));
|
userEvent.click(screen.getByTestId('forceRefresh'));
|
||||||
userEvent.click(screen.getByTestId('exploreChart'));
|
userEvent.click(screen.getByTestId('exploreChart'));
|
||||||
|
|
@ -261,13 +262,21 @@ test('Should render default props and "call" actions', () => {
|
||||||
|
|
||||||
test('Should render title', () => {
|
test('Should render title', () => {
|
||||||
const props = createProps();
|
const props = createProps();
|
||||||
render(<SliceHeader {...props} />, { useRedux: true });
|
render(<SliceHeader {...props} />, { useRedux: true, useRouter: true });
|
||||||
expect(screen.getByText('Vaccine Candidates per Phase')).toBeInTheDocument();
|
expect(screen.getByText('Vaccine Candidates per Phase')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Should render click to edit prompt and run onExploreChart on click', async () => {
|
test('Should render click to edit prompt and run onExploreChart on click', async () => {
|
||||||
const props = createProps();
|
const props = createProps();
|
||||||
render(<SliceHeader {...props} />, { useRedux: true });
|
const history = createMemoryHistory({
|
||||||
|
initialEntries: ['/superset/dashboard/1/'],
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<Router history={history}>
|
||||||
|
<SliceHeader {...props} />
|
||||||
|
</Router>,
|
||||||
|
{ useRedux: true },
|
||||||
|
);
|
||||||
userEvent.hover(screen.getByText('Vaccine Candidates per Phase'));
|
userEvent.hover(screen.getByText('Vaccine Candidates per Phase'));
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText('Click to edit Vaccine Candidates per Phase.'),
|
await screen.findByText('Click to edit Vaccine Candidates per Phase.'),
|
||||||
|
|
@ -277,13 +286,13 @@ test('Should render click to edit prompt and run onExploreChart on click', async
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
||||||
userEvent.click(screen.getByText('Vaccine Candidates per Phase'));
|
userEvent.click(screen.getByText('Vaccine Candidates per Phase'));
|
||||||
expect(props.onExploreChart).toHaveBeenCalled();
|
expect(history.location.pathname).toMatch('/explore');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Display cmd button in tooltip if running on MacOS', async () => {
|
test('Display cmd button in tooltip if running on MacOS', async () => {
|
||||||
jest.spyOn(window.navigator, 'appVersion', 'get').mockReturnValue('Mac');
|
jest.spyOn(window.navigator, 'appVersion', 'get').mockReturnValue('Mac');
|
||||||
const props = createProps();
|
const props = createProps();
|
||||||
render(<SliceHeader {...props} />, { useRedux: true });
|
render(<SliceHeader {...props} />, { useRedux: true, useRouter: true });
|
||||||
userEvent.hover(screen.getByText('Vaccine Candidates per Phase'));
|
userEvent.hover(screen.getByText('Vaccine Candidates per Phase'));
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText('Click to edit Vaccine Candidates per Phase.'),
|
await screen.findByText('Click to edit Vaccine Candidates per Phase.'),
|
||||||
|
|
@ -296,7 +305,7 @@ test('Display cmd button in tooltip if running on MacOS', async () => {
|
||||||
test('Display correct tooltip when DASHBOARD_EDIT_CHART_IN_NEW_TAB is enabled', async () => {
|
test('Display correct tooltip when DASHBOARD_EDIT_CHART_IN_NEW_TAB is enabled', async () => {
|
||||||
window.featureFlags.DASHBOARD_EDIT_CHART_IN_NEW_TAB = true;
|
window.featureFlags.DASHBOARD_EDIT_CHART_IN_NEW_TAB = true;
|
||||||
const props = createProps();
|
const props = createProps();
|
||||||
render(<SliceHeader {...props} />, { useRedux: true });
|
render(<SliceHeader {...props} />, { useRedux: true, useRouter: true });
|
||||||
userEvent.hover(screen.getByText('Vaccine Candidates per Phase'));
|
userEvent.hover(screen.getByText('Vaccine Candidates per Phase'));
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(
|
await screen.findByText(
|
||||||
|
|
@ -307,7 +316,15 @@ test('Display correct tooltip when DASHBOARD_EDIT_CHART_IN_NEW_TAB is enabled',
|
||||||
|
|
||||||
test('Should not render click to edit prompt and run onExploreChart on click if supersetCanExplore=false', () => {
|
test('Should not render click to edit prompt and run onExploreChart on click if supersetCanExplore=false', () => {
|
||||||
const props = createProps({ supersetCanExplore: false });
|
const props = createProps({ supersetCanExplore: false });
|
||||||
render(<SliceHeader {...props} />, { useRedux: true });
|
const history = createMemoryHistory({
|
||||||
|
initialEntries: ['/superset/dashboard/1/'],
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<Router history={history}>
|
||||||
|
<SliceHeader {...props} />
|
||||||
|
</Router>,
|
||||||
|
{ useRedux: true },
|
||||||
|
);
|
||||||
userEvent.hover(screen.getByText('Vaccine Candidates per Phase'));
|
userEvent.hover(screen.getByText('Vaccine Candidates per Phase'));
|
||||||
expect(
|
expect(
|
||||||
screen.queryByText(
|
screen.queryByText(
|
||||||
|
|
@ -316,12 +333,20 @@ test('Should not render click to edit prompt and run onExploreChart on click if
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
|
|
||||||
userEvent.click(screen.getByText('Vaccine Candidates per Phase'));
|
userEvent.click(screen.getByText('Vaccine Candidates per Phase'));
|
||||||
expect(props.onExploreChart).not.toHaveBeenCalled();
|
expect(history.location.pathname).toMatch('/superset/dashboard');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Should not render click to edit prompt and run onExploreChart on click if in edit mode', () => {
|
test('Should not render click to edit prompt and run onExploreChart on click if in edit mode', () => {
|
||||||
const props = createProps({ editMode: true });
|
const props = createProps({ editMode: true });
|
||||||
render(<SliceHeader {...props} />, { useRedux: true });
|
const history = createMemoryHistory({
|
||||||
|
initialEntries: ['/superset/dashboard/1/'],
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<Router history={history}>
|
||||||
|
<SliceHeader {...props} />
|
||||||
|
</Router>,
|
||||||
|
{ useRedux: true },
|
||||||
|
);
|
||||||
userEvent.hover(screen.getByText('Vaccine Candidates per Phase'));
|
userEvent.hover(screen.getByText('Vaccine Candidates per Phase'));
|
||||||
expect(
|
expect(
|
||||||
screen.queryByText(
|
screen.queryByText(
|
||||||
|
|
@ -330,12 +355,12 @@ test('Should not render click to edit prompt and run onExploreChart on click if
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
|
|
||||||
userEvent.click(screen.getByText('Vaccine Candidates per Phase'));
|
userEvent.click(screen.getByText('Vaccine Candidates per Phase'));
|
||||||
expect(props.onExploreChart).not.toHaveBeenCalled();
|
expect(history.location.pathname).toMatch('/superset/dashboard');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Should render "annotationsLoading"', () => {
|
test('Should render "annotationsLoading"', () => {
|
||||||
const props = createProps();
|
const props = createProps();
|
||||||
render(<SliceHeader {...props} />, { useRedux: true });
|
render(<SliceHeader {...props} />, { useRedux: true, useRouter: true });
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('img', {
|
screen.getByRole('img', {
|
||||||
name: 'Annotation layers are still loading.',
|
name: 'Annotation layers are still loading.',
|
||||||
|
|
@ -345,7 +370,7 @@ test('Should render "annotationsLoading"', () => {
|
||||||
|
|
||||||
test('Should render "annotationsError"', () => {
|
test('Should render "annotationsError"', () => {
|
||||||
const props = createProps();
|
const props = createProps();
|
||||||
render(<SliceHeader {...props} />, { useRedux: true });
|
render(<SliceHeader {...props} />, { useRedux: true, useRouter: true });
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('img', {
|
screen.getByRole('img', {
|
||||||
name: 'One ore more annotation layers failed loading.',
|
name: 'One ore more annotation layers failed loading.',
|
||||||
|
|
@ -357,7 +382,7 @@ test('Should not render "annotationsError" and "annotationsLoading"', () => {
|
||||||
const props = createProps();
|
const props = createProps();
|
||||||
props.annotationQuery = {};
|
props.annotationQuery = {};
|
||||||
props.annotationError = {};
|
props.annotationError = {};
|
||||||
render(<SliceHeader {...props} />, { useRedux: true });
|
render(<SliceHeader {...props} />, { useRedux: true, useRouter: true });
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole('img', {
|
screen.queryByRole('img', {
|
||||||
name: 'One ore more annotation layers failed loading.',
|
name: 'One ore more annotation layers failed loading.',
|
||||||
|
|
@ -372,7 +397,7 @@ test('Should not render "annotationsError" and "annotationsLoading"', () => {
|
||||||
|
|
||||||
test('Correct props to "FiltersBadge"', () => {
|
test('Correct props to "FiltersBadge"', () => {
|
||||||
const props = createProps();
|
const props = createProps();
|
||||||
render(<SliceHeader {...props} />, { useRedux: true });
|
render(<SliceHeader {...props} />, { useRedux: true, useRouter: true });
|
||||||
expect(screen.getByTestId('FiltersBadge')).toHaveAttribute(
|
expect(screen.getByTestId('FiltersBadge')).toHaveAttribute(
|
||||||
'data-chart-id',
|
'data-chart-id',
|
||||||
'312',
|
'312',
|
||||||
|
|
@ -381,7 +406,7 @@ test('Correct props to "FiltersBadge"', () => {
|
||||||
|
|
||||||
test('Correct props to "SliceHeaderControls"', () => {
|
test('Correct props to "SliceHeaderControls"', () => {
|
||||||
const props = createProps();
|
const props = createProps();
|
||||||
render(<SliceHeader {...props} />, { useRedux: true });
|
render(<SliceHeader {...props} />, { useRedux: true, useRouter: true });
|
||||||
expect(screen.getByTestId('SliceHeaderControls')).toHaveAttribute(
|
expect(screen.getByTestId('SliceHeaderControls')).toHaveAttribute(
|
||||||
'data-cached-dttm',
|
'data-cached-dttm',
|
||||||
'',
|
'',
|
||||||
|
|
@ -438,7 +463,7 @@ test('Correct props to "SliceHeaderControls"', () => {
|
||||||
|
|
||||||
test('Correct actions to "SliceHeaderControls"', () => {
|
test('Correct actions to "SliceHeaderControls"', () => {
|
||||||
const props = createProps();
|
const props = createProps();
|
||||||
render(<SliceHeader {...props} />, { useRedux: true });
|
render(<SliceHeader {...props} />, { useRedux: true, useRouter: true });
|
||||||
|
|
||||||
expect(props.toggleExpandSlice).toBeCalledTimes(0);
|
expect(props.toggleExpandSlice).toBeCalledTimes(0);
|
||||||
userEvent.click(screen.getByTestId('toggleExpandSlice'));
|
userEvent.click(screen.getByTestId('toggleExpandSlice'));
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
import React, {
|
import React, {
|
||||||
FC,
|
FC,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
|
|
@ -37,6 +38,7 @@ import Icons from 'src/components/Icons';
|
||||||
import { RootState } from 'src/dashboard/types';
|
import { RootState } from 'src/dashboard/types';
|
||||||
import FilterIndicator from 'src/dashboard/components/FiltersBadge/FilterIndicator';
|
import FilterIndicator from 'src/dashboard/components/FiltersBadge/FilterIndicator';
|
||||||
import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip';
|
import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip';
|
||||||
|
import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
|
||||||
import { clearDataMask } from 'src/dataMask/actions';
|
import { clearDataMask } from 'src/dataMask/actions';
|
||||||
|
|
||||||
type SliceHeaderProps = SliceHeaderControlsProps & {
|
type SliceHeaderProps = SliceHeaderControlsProps & {
|
||||||
|
|
@ -68,7 +70,6 @@ const SliceHeader: FC<SliceHeaderProps> = ({
|
||||||
updateSliceName = () => ({}),
|
updateSliceName = () => ({}),
|
||||||
toggleExpandSlice = () => ({}),
|
toggleExpandSlice = () => ({}),
|
||||||
logExploreChart = () => ({}),
|
logExploreChart = () => ({}),
|
||||||
onExploreChart,
|
|
||||||
exportCSV = () => ({}),
|
exportCSV = () => ({}),
|
||||||
editMode = false,
|
editMode = false,
|
||||||
annotationQuery = {},
|
annotationQuery = {},
|
||||||
|
|
@ -97,6 +98,7 @@ const SliceHeader: FC<SliceHeaderProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const uiConfig = useUiConfig();
|
const uiConfig = useUiConfig();
|
||||||
|
const dashboardPageId = useContext(DashboardPageIdContext);
|
||||||
const [headerTooltip, setHeaderTooltip] = useState<ReactNode | null>(null);
|
const [headerTooltip, setHeaderTooltip] = useState<ReactNode | null>(null);
|
||||||
const headerRef = useRef<HTMLDivElement>(null);
|
const headerRef = useRef<HTMLDivElement>(null);
|
||||||
// TODO: change to indicator field after it will be implemented
|
// TODO: change to indicator field after it will be implemented
|
||||||
|
|
@ -112,12 +114,11 @@ const SliceHeader: FC<SliceHeaderProps> = ({
|
||||||
[crossFilterValue],
|
[crossFilterValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClickTitle =
|
const canExplore = !editMode && supersetCanExplore;
|
||||||
!editMode && supersetCanExplore ? onExploreChart : undefined;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const headerElement = headerRef.current;
|
const headerElement = headerRef.current;
|
||||||
if (handleClickTitle) {
|
if (canExplore) {
|
||||||
setHeaderTooltip(getSliceHeaderTooltip(sliceName));
|
setHeaderTooltip(getSliceHeaderTooltip(sliceName));
|
||||||
} else if (
|
} else if (
|
||||||
headerElement &&
|
headerElement &&
|
||||||
|
|
@ -128,7 +129,9 @@ const SliceHeader: FC<SliceHeaderProps> = ({
|
||||||
} else {
|
} else {
|
||||||
setHeaderTooltip(null);
|
setHeaderTooltip(null);
|
||||||
}
|
}
|
||||||
}, [sliceName, width, height, handleClickTitle]);
|
}, [sliceName, width, height, canExplore]);
|
||||||
|
|
||||||
|
const exploreUrl = `/explore/?dashboard_page_id=${dashboardPageId}&slice_id=${slice.slice_id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chart-header" data-test="slice-header" ref={innerRef}>
|
<div className="chart-header" data-test="slice-header" ref={innerRef}>
|
||||||
|
|
@ -145,7 +148,7 @@ const SliceHeader: FC<SliceHeaderProps> = ({
|
||||||
emptyText=""
|
emptyText=""
|
||||||
onSaveTitle={updateSliceName}
|
onSaveTitle={updateSliceName}
|
||||||
showTooltip={false}
|
showTooltip={false}
|
||||||
onClickTitle={handleClickTitle}
|
url={canExplore ? exploreUrl : undefined}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{!!Object.values(annotationQuery).length && (
|
{!!Object.values(annotationQuery).length && (
|
||||||
|
|
@ -206,7 +209,6 @@ const SliceHeader: FC<SliceHeaderProps> = ({
|
||||||
toggleExpandSlice={toggleExpandSlice}
|
toggleExpandSlice={toggleExpandSlice}
|
||||||
forceRefresh={forceRefresh}
|
forceRefresh={forceRefresh}
|
||||||
logExploreChart={logExploreChart}
|
logExploreChart={logExploreChart}
|
||||||
onExploreChart={onExploreChart}
|
|
||||||
exportCSV={exportCSV}
|
exportCSV={exportCSV}
|
||||||
exportFullCSV={exportFullCSV}
|
exportFullCSV={exportFullCSV}
|
||||||
supersetCanExplore={supersetCanExplore}
|
supersetCanExplore={supersetCanExplore}
|
||||||
|
|
@ -222,6 +224,7 @@ const SliceHeader: FC<SliceHeaderProps> = ({
|
||||||
isDescriptionExpanded={isExpanded}
|
isDescriptionExpanded={isExpanded}
|
||||||
chartStatus={chartStatus}
|
chartStatus={chartStatus}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
|
exploreUrl={exploreUrl}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import userEvent from '@testing-library/user-event';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen } from 'spec/helpers/testing-library';
|
import { render, screen } from 'spec/helpers/testing-library';
|
||||||
import { FeatureFlag } from 'src/featureFlags';
|
import { FeatureFlag } from 'src/featureFlags';
|
||||||
import SliceHeaderControls from '.';
|
import SliceHeaderControls, { SliceHeaderControlsProps } from '.';
|
||||||
|
|
||||||
jest.mock('src/components/Dropdown', () => {
|
jest.mock('src/components/Dropdown', () => {
|
||||||
const original = jest.requireActual('src/components/Dropdown');
|
const original = jest.requireActual('src/components/Dropdown');
|
||||||
|
|
@ -36,66 +36,74 @@ jest.mock('src/components/Dropdown', () => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const createProps = (viz_type = 'sunburst') => ({
|
const createProps = (viz_type = 'sunburst') =>
|
||||||
addDangerToast: jest.fn(),
|
({
|
||||||
addSuccessToast: jest.fn(),
|
addDangerToast: jest.fn(),
|
||||||
exploreChart: jest.fn(),
|
addSuccessToast: jest.fn(),
|
||||||
exportCSV: jest.fn(),
|
exploreChart: jest.fn(),
|
||||||
exportFullCSV: jest.fn(),
|
exportCSV: jest.fn(),
|
||||||
forceRefresh: jest.fn(),
|
exportFullCSV: jest.fn(),
|
||||||
handleToggleFullSize: jest.fn(),
|
forceRefresh: jest.fn(),
|
||||||
toggleExpandSlice: jest.fn(),
|
handleToggleFullSize: jest.fn(),
|
||||||
onExploreChart: jest.fn(),
|
toggleExpandSlice: jest.fn(),
|
||||||
slice: {
|
slice: {
|
||||||
slice_id: 371,
|
|
||||||
slice_url: '/explore/?form_data=%7B%22slice_id%22%3A%20371%7D',
|
|
||||||
slice_name: 'Vaccine Candidates per Country & Stage',
|
|
||||||
slice_description: 'Table of vaccine candidates for 100 countries',
|
|
||||||
form_data: {
|
|
||||||
adhoc_filters: [],
|
|
||||||
color_scheme: 'supersetColors',
|
|
||||||
datasource: '58__table',
|
|
||||||
groupby: ['product_category', 'clinical_stage'],
|
|
||||||
linear_color_scheme: 'schemeYlOrBr',
|
|
||||||
metric: 'count',
|
|
||||||
queryFields: {
|
|
||||||
groupby: 'groupby',
|
|
||||||
metric: 'metrics',
|
|
||||||
secondary_metric: 'metrics',
|
|
||||||
},
|
|
||||||
row_limit: 10000,
|
|
||||||
slice_id: 371,
|
slice_id: 371,
|
||||||
time_range: 'No filter',
|
slice_url: '/explore/?form_data=%7B%22slice_id%22%3A%20371%7D',
|
||||||
url_params: {},
|
slice_name: 'Vaccine Candidates per Country & Stage',
|
||||||
|
slice_description: 'Table of vaccine candidates for 100 countries',
|
||||||
|
form_data: {
|
||||||
|
adhoc_filters: [],
|
||||||
|
color_scheme: 'supersetColors',
|
||||||
|
datasource: '58__table',
|
||||||
|
groupby: ['product_category', 'clinical_stage'],
|
||||||
|
linear_color_scheme: 'schemeYlOrBr',
|
||||||
|
metric: 'count',
|
||||||
|
queryFields: {
|
||||||
|
groupby: 'groupby',
|
||||||
|
metric: 'metrics',
|
||||||
|
secondary_metric: 'metrics',
|
||||||
|
},
|
||||||
|
row_limit: 10000,
|
||||||
|
slice_id: 371,
|
||||||
|
time_range: 'No filter',
|
||||||
|
url_params: {},
|
||||||
|
viz_type,
|
||||||
|
},
|
||||||
viz_type,
|
viz_type,
|
||||||
|
datasource: '58__table',
|
||||||
|
description: 'test-description',
|
||||||
|
description_markeddown: '',
|
||||||
|
owners: [],
|
||||||
|
modified: '<span class="no-wrap">22 hours ago</span>',
|
||||||
|
changed_on: 1617143411523,
|
||||||
},
|
},
|
||||||
viz_type,
|
isCached: [false],
|
||||||
datasource: '58__table',
|
isExpanded: false,
|
||||||
description: 'test-description',
|
cachedDttm: [''],
|
||||||
description_markeddown: '',
|
updatedDttm: 1617213803803,
|
||||||
owners: [],
|
supersetCanExplore: true,
|
||||||
modified: '<span class="no-wrap">22 hours ago</span>',
|
supersetCanCSV: true,
|
||||||
changed_on: 1617143411523,
|
sliceCanEdit: false,
|
||||||
},
|
componentId: 'CHART-fYo7IyvKZQ',
|
||||||
isCached: [false],
|
dashboardId: 26,
|
||||||
isExpanded: false,
|
isFullSize: false,
|
||||||
cachedDttm: [''],
|
chartStatus: 'rendered',
|
||||||
updatedDttm: 1617213803803,
|
showControls: true,
|
||||||
supersetCanExplore: true,
|
supersetCanShare: true,
|
||||||
supersetCanCSV: true,
|
formData: { slice_id: 1, datasource: '58__table', viz_type: 'sunburst' },
|
||||||
sliceCanEdit: false,
|
exploreUrl: '/explore',
|
||||||
componentId: 'CHART-fYo7IyvKZQ',
|
} as SliceHeaderControlsProps);
|
||||||
dashboardId: 26,
|
|
||||||
isFullSize: false,
|
const renderWrapper = (overrideProps?: SliceHeaderControlsProps) => {
|
||||||
chartStatus: 'rendered',
|
const props = overrideProps || createProps();
|
||||||
showControls: true,
|
return render(<SliceHeaderControls {...props} />, {
|
||||||
supersetCanShare: true,
|
useRedux: true,
|
||||||
formData: { slice_id: 1, datasource: '58__table', viz_type: 'sunburst' },
|
useRouter: true,
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
test('Should render', () => {
|
test('Should render', () => {
|
||||||
const props = createProps();
|
renderWrapper();
|
||||||
render(<SliceHeaderControls {...props} />, { useRedux: true });
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('button', { name: 'More Options' }),
|
screen.getByRole('button', { name: 'More Options' }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
@ -124,7 +132,7 @@ test('Should render default props', () => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
delete props.sliceCanEdit;
|
delete props.sliceCanEdit;
|
||||||
|
|
||||||
render(<SliceHeaderControls {...props} />, { useRedux: true });
|
renderWrapper(props);
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('menuitem', { name: 'Enter fullscreen' }),
|
screen.getByRole('menuitem', { name: 'Enter fullscreen' }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
@ -150,8 +158,7 @@ test('Should render default props', () => {
|
||||||
|
|
||||||
test('Should "export to CSV"', async () => {
|
test('Should "export to CSV"', async () => {
|
||||||
const props = createProps();
|
const props = createProps();
|
||||||
render(<SliceHeaderControls {...props} />, { useRedux: true });
|
renderWrapper(props);
|
||||||
|
|
||||||
expect(props.exportCSV).toBeCalledTimes(0);
|
expect(props.exportCSV).toBeCalledTimes(0);
|
||||||
userEvent.hover(screen.getByText('Download'));
|
userEvent.hover(screen.getByText('Download'));
|
||||||
userEvent.click(await screen.findByText('Export to .CSV'));
|
userEvent.click(await screen.findByText('Export to .CSV'));
|
||||||
|
|
@ -161,7 +168,7 @@ test('Should "export to CSV"', async () => {
|
||||||
|
|
||||||
test('Should not show "Download" if slice is filter box', () => {
|
test('Should not show "Download" if slice is filter box', () => {
|
||||||
const props = createProps('filter_box');
|
const props = createProps('filter_box');
|
||||||
render(<SliceHeaderControls {...props} />, { useRedux: true });
|
renderWrapper(props);
|
||||||
expect(screen.queryByText('Download')).not.toBeInTheDocument();
|
expect(screen.queryByText('Download')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -171,7 +178,7 @@ test('Export full CSV is under featureflag', async () => {
|
||||||
[FeatureFlag.ALLOW_FULL_CSV_EXPORT]: false,
|
[FeatureFlag.ALLOW_FULL_CSV_EXPORT]: false,
|
||||||
};
|
};
|
||||||
const props = createProps('table');
|
const props = createProps('table');
|
||||||
render(<SliceHeaderControls {...props} />, { useRedux: true });
|
renderWrapper(props);
|
||||||
userEvent.hover(screen.getByText('Download'));
|
userEvent.hover(screen.getByText('Download'));
|
||||||
expect(await screen.findByText('Export to .CSV')).toBeInTheDocument();
|
expect(await screen.findByText('Export to .CSV')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('Export to full .CSV')).not.toBeInTheDocument();
|
expect(screen.queryByText('Export to full .CSV')).not.toBeInTheDocument();
|
||||||
|
|
@ -183,7 +190,7 @@ test('Should "export full CSV"', async () => {
|
||||||
[FeatureFlag.ALLOW_FULL_CSV_EXPORT]: true,
|
[FeatureFlag.ALLOW_FULL_CSV_EXPORT]: true,
|
||||||
};
|
};
|
||||||
const props = createProps('table');
|
const props = createProps('table');
|
||||||
render(<SliceHeaderControls {...props} />, { useRedux: true });
|
renderWrapper(props);
|
||||||
expect(props.exportFullCSV).toBeCalledTimes(0);
|
expect(props.exportFullCSV).toBeCalledTimes(0);
|
||||||
userEvent.hover(screen.getByText('Download'));
|
userEvent.hover(screen.getByText('Download'));
|
||||||
userEvent.click(await screen.findByText('Export to full .CSV'));
|
userEvent.click(await screen.findByText('Export to full .CSV'));
|
||||||
|
|
@ -196,8 +203,7 @@ test('Should not show export full CSV if report is not table', async () => {
|
||||||
global.featureFlags = {
|
global.featureFlags = {
|
||||||
[FeatureFlag.ALLOW_FULL_CSV_EXPORT]: true,
|
[FeatureFlag.ALLOW_FULL_CSV_EXPORT]: true,
|
||||||
};
|
};
|
||||||
const props = createProps();
|
renderWrapper();
|
||||||
render(<SliceHeaderControls {...props} />, { useRedux: true });
|
|
||||||
userEvent.hover(screen.getByText('Download'));
|
userEvent.hover(screen.getByText('Download'));
|
||||||
expect(await screen.findByText('Export to .CSV')).toBeInTheDocument();
|
expect(await screen.findByText('Export to .CSV')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('Export to full .CSV')).not.toBeInTheDocument();
|
expect(screen.queryByText('Export to full .CSV')).not.toBeInTheDocument();
|
||||||
|
|
@ -205,8 +211,7 @@ test('Should not show export full CSV if report is not table', async () => {
|
||||||
|
|
||||||
test('Should "Show chart description"', () => {
|
test('Should "Show chart description"', () => {
|
||||||
const props = createProps();
|
const props = createProps();
|
||||||
render(<SliceHeaderControls {...props} />, { useRedux: true });
|
renderWrapper(props);
|
||||||
|
|
||||||
expect(props.toggleExpandSlice).toBeCalledTimes(0);
|
expect(props.toggleExpandSlice).toBeCalledTimes(0);
|
||||||
userEvent.click(screen.getByText('Show chart description'));
|
userEvent.click(screen.getByText('Show chart description'));
|
||||||
expect(props.toggleExpandSlice).toBeCalledTimes(1);
|
expect(props.toggleExpandSlice).toBeCalledTimes(1);
|
||||||
|
|
@ -215,8 +220,7 @@ test('Should "Show chart description"', () => {
|
||||||
|
|
||||||
test('Should "Force refresh"', () => {
|
test('Should "Force refresh"', () => {
|
||||||
const props = createProps();
|
const props = createProps();
|
||||||
render(<SliceHeaderControls {...props} />, { useRedux: true });
|
renderWrapper(props);
|
||||||
|
|
||||||
expect(props.forceRefresh).toBeCalledTimes(0);
|
expect(props.forceRefresh).toBeCalledTimes(0);
|
||||||
userEvent.click(screen.getByText('Force refresh'));
|
userEvent.click(screen.getByText('Force refresh'));
|
||||||
expect(props.forceRefresh).toBeCalledTimes(1);
|
expect(props.forceRefresh).toBeCalledTimes(1);
|
||||||
|
|
@ -226,7 +230,7 @@ test('Should "Force refresh"', () => {
|
||||||
|
|
||||||
test('Should "Enter fullscreen"', () => {
|
test('Should "Enter fullscreen"', () => {
|
||||||
const props = createProps();
|
const props = createProps();
|
||||||
render(<SliceHeaderControls {...props} />, { useRedux: true });
|
renderWrapper(props);
|
||||||
|
|
||||||
expect(props.handleToggleFullSize).toBeCalledTimes(0);
|
expect(props.handleToggleFullSize).toBeCalledTimes(0);
|
||||||
userEvent.click(screen.getByText('Enter fullscreen'));
|
userEvent.click(screen.getByText('Enter fullscreen'));
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import React, { MouseEvent, Key } from 'react';
|
import React, { MouseEvent, Key } from 'react';
|
||||||
|
import { Link, RouteComponentProps, withRouter } from 'react-router-dom';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import {
|
import {
|
||||||
Behavior,
|
Behavior,
|
||||||
|
|
@ -108,7 +109,7 @@ export interface SliceHeaderControlsProps {
|
||||||
isFullSize?: boolean;
|
isFullSize?: boolean;
|
||||||
isDescriptionExpanded?: boolean;
|
isDescriptionExpanded?: boolean;
|
||||||
formData: QueryFormData;
|
formData: QueryFormData;
|
||||||
onExploreChart: (event: MouseEvent) => void;
|
exploreUrl: string;
|
||||||
|
|
||||||
forceRefresh: (sliceId: number, dashboardId: number) => void;
|
forceRefresh: (sliceId: number, dashboardId: number) => void;
|
||||||
logExploreChart?: (sliceId: number) => void;
|
logExploreChart?: (sliceId: number) => void;
|
||||||
|
|
@ -125,6 +126,8 @@ export interface SliceHeaderControlsProps {
|
||||||
supersetCanCSV?: boolean;
|
supersetCanCSV?: boolean;
|
||||||
sliceCanEdit?: boolean;
|
sliceCanEdit?: boolean;
|
||||||
}
|
}
|
||||||
|
type SliceHeaderControlsPropsWithRouter = SliceHeaderControlsProps &
|
||||||
|
RouteComponentProps;
|
||||||
interface State {
|
interface State {
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
showCrossFilterScopingModal: boolean;
|
showCrossFilterScopingModal: boolean;
|
||||||
|
|
@ -138,10 +141,10 @@ const dropdownIconsStyles = css`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
class SliceHeaderControls extends React.PureComponent<
|
class SliceHeaderControls extends React.PureComponent<
|
||||||
SliceHeaderControlsProps,
|
SliceHeaderControlsPropsWithRouter,
|
||||||
State
|
State
|
||||||
> {
|
> {
|
||||||
constructor(props: SliceHeaderControlsProps) {
|
constructor(props: SliceHeaderControlsPropsWithRouter) {
|
||||||
super(props);
|
super(props);
|
||||||
this.toggleControls = this.toggleControls.bind(this);
|
this.toggleControls = this.toggleControls.bind(this);
|
||||||
this.refreshChart = this.refreshChart.bind(this);
|
this.refreshChart = this.refreshChart.bind(this);
|
||||||
|
|
@ -306,13 +309,14 @@ class SliceHeaderControls extends React.PureComponent<
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{this.props.supersetCanExplore && (
|
{this.props.supersetCanExplore && (
|
||||||
<Menu.Item
|
<Menu.Item key={MENU_KEYS.EXPLORE_CHART}>
|
||||||
key={MENU_KEYS.EXPLORE_CHART}
|
<Link to={this.props.exploreUrl}>
|
||||||
onClick={({ domEvent }) => this.props.onExploreChart(domEvent)}
|
<Tooltip
|
||||||
>
|
title={getSliceHeaderTooltip(this.props.slice.slice_name)}
|
||||||
<Tooltip title={getSliceHeaderTooltip(this.props.slice.slice_name)}>
|
>
|
||||||
{t('Edit chart')}
|
{t('Edit chart')}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -355,7 +359,7 @@ class SliceHeaderControls extends React.PureComponent<
|
||||||
<Button
|
<Button
|
||||||
buttonStyle="secondary"
|
buttonStyle="secondary"
|
||||||
buttonSize="small"
|
buttonSize="small"
|
||||||
onClick={this.props.onExploreChart}
|
onClick={() => this.props.history.push(this.props.exploreUrl)}
|
||||||
>
|
>
|
||||||
{t('Edit chart')}
|
{t('Edit chart')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -463,4 +467,4 @@ class SliceHeaderControls extends React.PureComponent<
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SliceHeaderControls;
|
export default withRouter(SliceHeaderControls);
|
||||||
|
|
|
||||||
|
|
@ -211,10 +211,14 @@ const publishDataMask = debounce(
|
||||||
|
|
||||||
// pathname could be updated somewhere else through window.history
|
// pathname could be updated somewhere else through window.history
|
||||||
// keep react router history in sync with window history
|
// keep react router history in sync with window history
|
||||||
history.location.pathname = window.location.pathname;
|
// replace params only when current page is /superset/dashboard
|
||||||
history.replace({
|
// this prevents a race condition between updating filters and navigating to Explore
|
||||||
search: newParams.toString(),
|
if (window.location.pathname.includes('/superset/dashboard')) {
|
||||||
});
|
history.location.pathname = window.location.pathname;
|
||||||
|
history.replace({
|
||||||
|
search: newParams.toString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
SLOW_DEBOUNCE,
|
SLOW_DEBOUNCE,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import {
|
||||||
getChartMetadataRegistry,
|
getChartMetadataRegistry,
|
||||||
QueryFormData,
|
QueryFormData,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import { Charts, DashboardLayout } from 'src/dashboard/types';
|
import { DashboardLayout } from 'src/dashboard/types';
|
||||||
import extractUrlParams from 'src/dashboard/util/extractUrlParams';
|
import extractUrlParams from 'src/dashboard/util/extractUrlParams';
|
||||||
import { isFeatureEnabled } from 'src/featureFlags';
|
import { isFeatureEnabled } from 'src/featureFlags';
|
||||||
import { CHART_TYPE, TAB_TYPE } from '../../util/componentTypes';
|
import { CHART_TYPE, TAB_TYPE } from '../../util/componentTypes';
|
||||||
|
|
@ -122,7 +122,6 @@ export function isCrossFilter(vizType: string) {
|
||||||
|
|
||||||
export function getExtraFormData(
|
export function getExtraFormData(
|
||||||
dataMask: DataMaskStateWithId,
|
dataMask: DataMaskStateWithId,
|
||||||
charts: Charts,
|
|
||||||
filterIdsAppliedOnChart: string[],
|
filterIdsAppliedOnChart: string[],
|
||||||
): ExtraFormData {
|
): ExtraFormData {
|
||||||
let extraFormData: ExtraFormData = {};
|
let extraFormData: ExtraFormData = {};
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,6 @@ function mapStateToProps(
|
||||||
charts: chartQueries,
|
charts: chartQueries,
|
||||||
dashboardInfo,
|
dashboardInfo,
|
||||||
dashboardState,
|
dashboardState,
|
||||||
dashboardLayout,
|
|
||||||
dataMask,
|
dataMask,
|
||||||
datasources,
|
datasources,
|
||||||
sliceEntities,
|
sliceEntities,
|
||||||
|
|
@ -65,16 +64,15 @@ function mapStateToProps(
|
||||||
const sharedLabelColors = dashboardInfo?.metadata?.shared_label_colors || {};
|
const sharedLabelColors = dashboardInfo?.metadata?.shared_label_colors || {};
|
||||||
// note: this method caches filters if possible to prevent render cascades
|
// note: this method caches filters if possible to prevent render cascades
|
||||||
const formData = getFormDataWithExtraFilters({
|
const formData = getFormDataWithExtraFilters({
|
||||||
layout: dashboardLayout.present,
|
|
||||||
chart,
|
chart,
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
chartConfiguration: dashboardInfo.metadata?.chart_configuration,
|
chartConfiguration: dashboardInfo.metadata?.chart_configuration,
|
||||||
charts: chartQueries,
|
charts: chartQueries,
|
||||||
filters: getAppliedFilterValues(id),
|
filters: getAppliedFilterValues(id),
|
||||||
colorScheme,
|
colorScheme,
|
||||||
colorNamespace,
|
colorNamespace,
|
||||||
sliceId: id,
|
sliceId: id,
|
||||||
nativeFilters,
|
nativeFilters: nativeFilters?.filters,
|
||||||
|
allSliceIds: dashboardState.sliceIds,
|
||||||
dataMask,
|
dataMask,
|
||||||
extraControls,
|
extraControls,
|
||||||
labelColors,
|
labelColors,
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ function mapStateToProps(state: RootState) {
|
||||||
chartConfiguration: dashboardInfo.metadata?.chart_configuration,
|
chartConfiguration: dashboardInfo.metadata?.chart_configuration,
|
||||||
nativeFilters: nativeFilters.filters,
|
nativeFilters: nativeFilters.filters,
|
||||||
dataMask,
|
dataMask,
|
||||||
layout: dashboardLayout.present,
|
allSliceIds: dashboardState.sliceIds,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
chartConfiguration: dashboardInfo.metadata?.chart_configuration,
|
chartConfiguration: dashboardInfo.metadata?.chart_configuration,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import React, { FC, useRef, useEffect, useState } from 'react';
|
import React, { FC, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
CategoricalColorNamespace,
|
CategoricalColorNamespace,
|
||||||
FeatureFlag,
|
FeatureFlag,
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
t,
|
t,
|
||||||
useTheme,
|
useTheme,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
|
import pick from 'lodash/pick';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { Global } from '@emotion/react';
|
import { Global } from '@emotion/react';
|
||||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||||
|
|
@ -44,8 +45,8 @@ import { addWarningToast } from 'src/components/MessageToasts/actions';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getItem,
|
getItem,
|
||||||
setItem,
|
|
||||||
LocalStorageKeys,
|
LocalStorageKeys,
|
||||||
|
setItem,
|
||||||
} from 'src/utils/localStorageHelpers';
|
} from 'src/utils/localStorageHelpers';
|
||||||
import {
|
import {
|
||||||
FILTER_BOX_MIGRATION_STATES,
|
FILTER_BOX_MIGRATION_STATES,
|
||||||
|
|
@ -61,11 +62,17 @@ import {
|
||||||
getPermalinkValue,
|
getPermalinkValue,
|
||||||
} from 'src/dashboard/components/nativeFilters/FilterBar/keyValue';
|
} from 'src/dashboard/components/nativeFilters/FilterBar/keyValue';
|
||||||
import { filterCardPopoverStyle } from 'src/dashboard/styles';
|
import { filterCardPopoverStyle } from 'src/dashboard/styles';
|
||||||
|
import { DashboardContextForExplore } from 'src/types/DashboardContextForExplore';
|
||||||
|
import shortid from 'shortid';
|
||||||
|
import { RootState } from '../types';
|
||||||
|
import { getActiveFilters } from '../util/activeDashboardFilters';
|
||||||
|
|
||||||
export const MigrationContext = React.createContext(
|
export const MigrationContext = React.createContext(
|
||||||
FILTER_BOX_MIGRATION_STATES.NOOP,
|
FILTER_BOX_MIGRATION_STATES.NOOP,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const DashboardPageIdContext = React.createContext('');
|
||||||
|
|
||||||
setupPlugins();
|
setupPlugins();
|
||||||
const DashboardContainer = React.lazy(
|
const DashboardContainer = React.lazy(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -82,12 +89,76 @@ type PageProps = {
|
||||||
idOrSlug: string;
|
idOrSlug: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDashboardContextLocalStorage = () => {
|
||||||
|
const dashboardsContexts = getItem(
|
||||||
|
LocalStorageKeys.dashboard__explore_context,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
// A new dashboard tab id is generated on each dashboard page opening.
|
||||||
|
// We mark ids as redundant when user leaves the dashboard, because they won't be reused.
|
||||||
|
// Then we remove redundant dashboard contexts from local storage in order not to clutter it
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(dashboardsContexts).filter(
|
||||||
|
([, value]) => !value.isRedundant,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDashboardTabLocalStorage = (
|
||||||
|
dashboardPageId: string,
|
||||||
|
dashboardContext: DashboardContextForExplore,
|
||||||
|
) => {
|
||||||
|
const dashboardsContexts = getDashboardContextLocalStorage();
|
||||||
|
setItem(LocalStorageKeys.dashboard__explore_context, {
|
||||||
|
...dashboardsContexts,
|
||||||
|
[dashboardPageId]: dashboardContext,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const useSyncDashboardStateWithLocalStorage = () => {
|
||||||
|
const dashboardPageId = useMemo(() => shortid.generate(), []);
|
||||||
|
const dashboardContextForExplore = useSelector<
|
||||||
|
RootState,
|
||||||
|
DashboardContextForExplore
|
||||||
|
>(({ dashboardInfo, dashboardState, nativeFilters, dataMask }) => ({
|
||||||
|
labelColors: dashboardInfo.metadata?.label_colors || {},
|
||||||
|
sharedLabelColors: dashboardInfo.metadata?.shared_label_colors || {},
|
||||||
|
colorScheme: dashboardState?.colorScheme,
|
||||||
|
chartConfiguration: dashboardInfo.metadata?.chart_configuration || {},
|
||||||
|
nativeFilters: Object.entries(nativeFilters.filters).reduce(
|
||||||
|
(acc, [key, filterValue]) => ({
|
||||||
|
...acc,
|
||||||
|
[key]: pick(filterValue, ['chartsInScope']),
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
dataMask,
|
||||||
|
dashboardId: dashboardInfo.id,
|
||||||
|
filterBoxFilters: getActiveFilters(),
|
||||||
|
dashboardPageId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateDashboardTabLocalStorage(dashboardPageId, dashboardContextForExplore);
|
||||||
|
return () => {
|
||||||
|
// mark tab id as redundant when dashboard unmounts - case when user opens
|
||||||
|
// Explore in the same tab
|
||||||
|
updateDashboardTabLocalStorage(dashboardPageId, {
|
||||||
|
...dashboardContextForExplore,
|
||||||
|
isRedundant: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [dashboardContextForExplore, dashboardPageId]);
|
||||||
|
return dashboardPageId;
|
||||||
|
};
|
||||||
|
|
||||||
export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const user = useSelector<any, UserWithPermissionsAndRoles>(
|
const user = useSelector<any, UserWithPermissionsAndRoles>(
|
||||||
state => state.user,
|
state => state.user,
|
||||||
);
|
);
|
||||||
|
const dashboardPageId = useSyncDashboardStateWithLocalStorage();
|
||||||
const { addDangerToast } = useToasts();
|
const { addDangerToast } = useToasts();
|
||||||
const { result: dashboard, error: dashboardApiError } =
|
const { result: dashboard, error: dashboardApiError } =
|
||||||
useDashboard(idOrSlug);
|
useDashboard(idOrSlug);
|
||||||
|
|
@ -113,6 +184,25 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||||
migrationStateParam || FILTER_BOX_MIGRATION_STATES.NOOP,
|
migrationStateParam || FILTER_BOX_MIGRATION_STATES.NOOP,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// mark tab id as redundant when user closes browser tab - a new id will be
|
||||||
|
// generated next time user opens a dashboard and the old one won't be reused
|
||||||
|
const handleTabClose = () => {
|
||||||
|
const dashboardsContexts = getDashboardContextLocalStorage();
|
||||||
|
setItem(LocalStorageKeys.dashboard__explore_context, {
|
||||||
|
...dashboardsContexts,
|
||||||
|
[dashboardPageId]: {
|
||||||
|
...dashboardsContexts[dashboardPageId],
|
||||||
|
isRedundant: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener('beforeunload', handleTabClose);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeunload', handleTabClose);
|
||||||
|
};
|
||||||
|
}, [dashboardPageId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setDatasetsStatus(status));
|
dispatch(setDatasetsStatus(status));
|
||||||
}, [dispatch, status]);
|
}, [dispatch, status]);
|
||||||
|
|
@ -295,7 +385,9 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MigrationContext.Provider value={filterboxMigrationState}>
|
<MigrationContext.Provider value={filterboxMigrationState}>
|
||||||
<DashboardContainer />
|
<DashboardPageIdContext.Provider value={dashboardPageId}>
|
||||||
|
<DashboardContainer />
|
||||||
|
</DashboardPageIdContext.Provider>
|
||||||
</MigrationContext.Provider>
|
</MigrationContext.Provider>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ export type ChartConfiguration = {
|
||||||
id: number;
|
id: number;
|
||||||
crossFilters: {
|
crossFilters: {
|
||||||
scope: NativeFilterScope;
|
scope: NativeFilterScope;
|
||||||
|
chartsInScope: number[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ export type DashboardState = {
|
||||||
isRefreshing: boolean;
|
isRefreshing: boolean;
|
||||||
isFiltersRefreshing: boolean;
|
isFiltersRefreshing: boolean;
|
||||||
hasUnsavedChanges: boolean;
|
hasUnsavedChanges: boolean;
|
||||||
|
colorScheme: string;
|
||||||
|
sliceIds: number[];
|
||||||
};
|
};
|
||||||
export type DashboardInfo = {
|
export type DashboardInfo = {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -79,6 +81,8 @@ export type DashboardInfo = {
|
||||||
native_filter_configuration: JsonObject;
|
native_filter_configuration: JsonObject;
|
||||||
show_native_filters: boolean;
|
show_native_filters: boolean;
|
||||||
chart_configuration: JsonObject;
|
chart_configuration: JsonObject;
|
||||||
|
label_colors: JsonObject;
|
||||||
|
shared_label_colors: JsonObject;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,61 +18,11 @@
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
DataMaskStateWithId,
|
DataMaskStateWithId,
|
||||||
Filters,
|
PartialFilters,
|
||||||
JsonObject,
|
JsonObject,
|
||||||
NativeFilterScope,
|
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import { CHART_TYPE } from './componentTypes';
|
import { ActiveFilters } from '../types';
|
||||||
import { ActiveFilters, Layout, LayoutItem } from '../types';
|
|
||||||
import { ChartConfiguration } from '../reducers/types';
|
import { ChartConfiguration } from '../reducers/types';
|
||||||
import { DASHBOARD_ROOT_ID } from './constants';
|
|
||||||
|
|
||||||
// Looking for affected chart scopes and values
|
|
||||||
export const findAffectedCharts = ({
|
|
||||||
child,
|
|
||||||
layout,
|
|
||||||
scope,
|
|
||||||
activeFilters,
|
|
||||||
filterId,
|
|
||||||
extraFormData,
|
|
||||||
}: {
|
|
||||||
child: string;
|
|
||||||
layout: { [key: string]: LayoutItem };
|
|
||||||
scope: NativeFilterScope;
|
|
||||||
activeFilters: ActiveFilters;
|
|
||||||
filterId: string;
|
|
||||||
extraFormData: any;
|
|
||||||
}) => {
|
|
||||||
const chartId = layout[child]?.meta?.chartId;
|
|
||||||
if (layout[child].type === CHART_TYPE) {
|
|
||||||
// Ignore excluded charts
|
|
||||||
if (scope.excluded.includes(chartId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!activeFilters[filterId]) {
|
|
||||||
// Small mutation but simplify logic
|
|
||||||
// eslint-disable-next-line no-param-reassign
|
|
||||||
activeFilters[filterId] = {
|
|
||||||
scope: [],
|
|
||||||
values: extraFormData,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Add not excluded chart scopes(to know what charts refresh) and values(refresh only if its value changed)
|
|
||||||
activeFilters[filterId].scope.push(chartId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// If child is not chart, recursive iterate over its children
|
|
||||||
layout[child].children.forEach((child: string) =>
|
|
||||||
findAffectedCharts({
|
|
||||||
child,
|
|
||||||
layout,
|
|
||||||
scope,
|
|
||||||
activeFilters,
|
|
||||||
filterId,
|
|
||||||
extraFormData,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getRelevantDataMask = (
|
export const getRelevantDataMask = (
|
||||||
dataMask: DataMaskStateWithId,
|
dataMask: DataMaskStateWithId,
|
||||||
|
|
@ -89,36 +39,27 @@ export const getAllActiveFilters = ({
|
||||||
chartConfiguration,
|
chartConfiguration,
|
||||||
nativeFilters,
|
nativeFilters,
|
||||||
dataMask,
|
dataMask,
|
||||||
layout,
|
allSliceIds,
|
||||||
}: {
|
}: {
|
||||||
chartConfiguration: ChartConfiguration;
|
chartConfiguration: ChartConfiguration;
|
||||||
dataMask: DataMaskStateWithId;
|
dataMask: DataMaskStateWithId;
|
||||||
nativeFilters: Filters;
|
nativeFilters: PartialFilters;
|
||||||
layout: Layout;
|
allSliceIds: number[];
|
||||||
}): ActiveFilters => {
|
}): ActiveFilters => {
|
||||||
const activeFilters = {};
|
const activeFilters = {};
|
||||||
|
|
||||||
// Combine native filters with cross filters, because they have similar logic
|
// Combine native filters with cross filters, because they have similar logic
|
||||||
Object.values(dataMask).forEach(({ id: filterId, extraFormData }) => {
|
Object.values(dataMask).forEach(({ id: filterId, extraFormData }) => {
|
||||||
const scope = nativeFilters?.[filterId]?.scope ??
|
const scope =
|
||||||
chartConfiguration?.[filterId]?.crossFilters?.scope ?? {
|
nativeFilters?.[filterId]?.chartsInScope ??
|
||||||
rootPath: [DASHBOARD_ROOT_ID],
|
chartConfiguration?.[filterId]?.crossFilters?.chartsInScope ??
|
||||||
excluded: [filterId],
|
allSliceIds ??
|
||||||
};
|
[];
|
||||||
// Iterate over all roots to find all affected charts
|
// Iterate over all roots to find all affected charts
|
||||||
scope.rootPath.forEach((layoutItemId: string | number) => {
|
activeFilters[filterId] = {
|
||||||
layout[layoutItemId]?.children?.forEach((child: string) => {
|
scope,
|
||||||
// Need exclude from affected charts, charts that located in scope `excluded`
|
values: extraFormData,
|
||||||
findAffectedCharts({
|
};
|
||||||
child,
|
|
||||||
layout,
|
|
||||||
scope,
|
|
||||||
activeFilters,
|
|
||||||
filterId,
|
|
||||||
extraFormData,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
return activeFilters;
|
return activeFilters;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -45,10 +45,10 @@ export function isFilterBox(chartId) {
|
||||||
// this function is to find all filter values applied to a chart,
|
// this function is to find all filter values applied to a chart,
|
||||||
// it goes through all active filters and their scopes.
|
// it goes through all active filters and their scopes.
|
||||||
// return: { [column]: array of selected values }
|
// return: { [column]: array of selected values }
|
||||||
export function getAppliedFilterValues(chartId) {
|
export function getAppliedFilterValues(chartId, filters) {
|
||||||
// use cached data if possible
|
// use cached data if possible
|
||||||
if (!(chartId in appliedFilterValuesByChart)) {
|
if (!(chartId in appliedFilterValuesByChart)) {
|
||||||
const applicableFilters = Object.entries(activeFilters).filter(
|
const applicableFilters = Object.entries(filters || activeFilters).filter(
|
||||||
([, { scope: chartIds }]) => chartIds.includes(chartId),
|
([, { scope: chartIds }]) => chartIds.includes(chartId),
|
||||||
);
|
);
|
||||||
appliedFilterValuesByChart[chartId] = flow(
|
appliedFilterValuesByChart[chartId] = flow(
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,9 @@ import {
|
||||||
DataMaskStateWithId,
|
DataMaskStateWithId,
|
||||||
DataRecordFilters,
|
DataRecordFilters,
|
||||||
JsonObject,
|
JsonObject,
|
||||||
NativeFiltersState,
|
PartialFilters,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import { ChartQueryPayload, Charts, LayoutItem } from 'src/dashboard/types';
|
import { ChartQueryPayload } from 'src/dashboard/types';
|
||||||
import { getExtraFormData } from 'src/dashboard/components/nativeFilters/utils';
|
import { getExtraFormData } from 'src/dashboard/components/nativeFilters/utils';
|
||||||
import { areObjectsEqual } from 'src/reduxUtils';
|
import { areObjectsEqual } from 'src/reduxUtils';
|
||||||
import getEffectiveExtraFilters from './getEffectiveExtraFilters';
|
import getEffectiveExtraFilters from './getEffectiveExtraFilters';
|
||||||
|
|
@ -37,17 +37,16 @@ const cachedFormdataByChart = {};
|
||||||
export interface GetFormDataWithExtraFiltersArguments {
|
export interface GetFormDataWithExtraFiltersArguments {
|
||||||
chartConfiguration: ChartConfiguration;
|
chartConfiguration: ChartConfiguration;
|
||||||
chart: ChartQueryPayload;
|
chart: ChartQueryPayload;
|
||||||
charts: Charts;
|
|
||||||
filters: DataRecordFilters;
|
filters: DataRecordFilters;
|
||||||
layout: { [key: string]: LayoutItem };
|
|
||||||
colorScheme?: string;
|
colorScheme?: string;
|
||||||
colorNamespace?: string;
|
colorNamespace?: string;
|
||||||
sliceId: number;
|
sliceId: number;
|
||||||
dataMask: DataMaskStateWithId;
|
dataMask: DataMaskStateWithId;
|
||||||
nativeFilters: NativeFiltersState;
|
nativeFilters: PartialFilters;
|
||||||
extraControls: Record<string, string | boolean | null>;
|
extraControls: Record<string, string | boolean | null>;
|
||||||
labelColors?: Record<string, string>;
|
labelColors?: Record<string, string>;
|
||||||
sharedLabelColors?: Record<string, string>;
|
sharedLabelColors?: Record<string, string>;
|
||||||
|
allSliceIds: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// this function merge chart's formData with dashboard filters value,
|
// this function merge chart's formData with dashboard filters value,
|
||||||
|
|
@ -55,18 +54,17 @@ export interface GetFormDataWithExtraFiltersArguments {
|
||||||
// filters param only contains those applicable to this chart.
|
// filters param only contains those applicable to this chart.
|
||||||
export default function getFormDataWithExtraFilters({
|
export default function getFormDataWithExtraFilters({
|
||||||
chart,
|
chart,
|
||||||
charts,
|
|
||||||
filters,
|
filters,
|
||||||
nativeFilters,
|
nativeFilters,
|
||||||
chartConfiguration,
|
chartConfiguration,
|
||||||
colorScheme,
|
colorScheme,
|
||||||
colorNamespace,
|
colorNamespace,
|
||||||
sliceId,
|
sliceId,
|
||||||
layout,
|
|
||||||
dataMask,
|
dataMask,
|
||||||
extraControls,
|
extraControls,
|
||||||
labelColors,
|
labelColors,
|
||||||
sharedLabelColors,
|
sharedLabelColors,
|
||||||
|
allSliceIds,
|
||||||
}: GetFormDataWithExtraFiltersArguments) {
|
}: GetFormDataWithExtraFiltersArguments) {
|
||||||
// if dashboard metadata + filters have not changed, use cache if possible
|
// if dashboard metadata + filters have not changed, use cache if possible
|
||||||
const cachedFormData = cachedFormdataByChart[sliceId];
|
const cachedFormData = cachedFormdataByChart[sliceId];
|
||||||
|
|
@ -99,19 +97,15 @@ export default function getFormDataWithExtraFilters({
|
||||||
const activeFilters = getAllActiveFilters({
|
const activeFilters = getAllActiveFilters({
|
||||||
chartConfiguration,
|
chartConfiguration,
|
||||||
dataMask,
|
dataMask,
|
||||||
layout,
|
nativeFilters,
|
||||||
nativeFilters: nativeFilters.filters,
|
allSliceIds,
|
||||||
});
|
});
|
||||||
const filterIdsAppliedOnChart = Object.entries(activeFilters)
|
const filterIdsAppliedOnChart = Object.entries(activeFilters)
|
||||||
.filter(([, { scope }]) => scope.includes(chart.id))
|
.filter(([, { scope }]) => scope.includes(chart.id))
|
||||||
.map(([filterId]) => filterId);
|
.map(([filterId]) => filterId);
|
||||||
if (filterIdsAppliedOnChart.length) {
|
if (filterIdsAppliedOnChart.length) {
|
||||||
extraData = {
|
extraData = {
|
||||||
extra_form_data: getExtraFormData(
|
extra_form_data: getExtraFormData(dataMask, filterIdsAppliedOnChart),
|
||||||
dataMask,
|
|
||||||
charts,
|
|
||||||
filterIdsAppliedOnChart,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,19 +50,13 @@ describe('getFormDataWithExtraFilters', () => {
|
||||||
};
|
};
|
||||||
const mockArgs: GetFormDataWithExtraFiltersArguments = {
|
const mockArgs: GetFormDataWithExtraFiltersArguments = {
|
||||||
chartConfiguration: {},
|
chartConfiguration: {},
|
||||||
charts: {
|
|
||||||
[chartId as number]: mockChart,
|
|
||||||
},
|
|
||||||
chart: mockChart,
|
chart: mockChart,
|
||||||
filters: {
|
filters: {
|
||||||
region: ['Spain'],
|
region: ['Spain'],
|
||||||
color: ['pink', 'purple'],
|
color: ['pink', 'purple'],
|
||||||
},
|
},
|
||||||
sliceId: chartId,
|
sliceId: chartId,
|
||||||
nativeFilters: {
|
nativeFilters: {},
|
||||||
filters: {},
|
|
||||||
filterSets: {},
|
|
||||||
},
|
|
||||||
dataMask: {
|
dataMask: {
|
||||||
[filterId]: {
|
[filterId]: {
|
||||||
id: filterId,
|
id: filterId,
|
||||||
|
|
@ -71,10 +65,10 @@ describe('getFormDataWithExtraFilters', () => {
|
||||||
ownState: {},
|
ownState: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
layout: {},
|
|
||||||
extraControls: {
|
extraControls: {
|
||||||
stack: 'Stacked',
|
stack: 'Stacked',
|
||||||
},
|
},
|
||||||
|
allSliceIds: [chartId],
|
||||||
};
|
};
|
||||||
|
|
||||||
it('should include filters from the passed filters', () => {
|
it('should include filters from the passed filters', () => {
|
||||||
|
|
|
||||||
|
|
@ -19,17 +19,21 @@
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { makeApi, t, isDefined, JsonObject } from '@superset-ui/core';
|
import { isDefined, JsonObject, makeApi, t } from '@superset-ui/core';
|
||||||
import Loading from 'src/components/Loading';
|
import Loading from 'src/components/Loading';
|
||||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||||
import { getUrlParam } from 'src/utils/urlUtils';
|
import { getUrlParam } from 'src/utils/urlUtils';
|
||||||
import { URL_PARAMS } from 'src/constants';
|
import { URL_PARAMS } from 'src/constants';
|
||||||
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
|
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
|
||||||
|
import getFormDataWithExtraFilters from 'src/dashboard/util/charts/getFormDataWithExtraFilters';
|
||||||
|
import { getAppliedFilterValues } from 'src/dashboard/util/activeDashboardFilters';
|
||||||
import { getParsedExploreURLParams } from './exploreUtils/getParsedExploreURLParams';
|
import { getParsedExploreURLParams } from './exploreUtils/getParsedExploreURLParams';
|
||||||
import { hydrateExplore } from './actions/hydrateExplore';
|
import { hydrateExplore } from './actions/hydrateExplore';
|
||||||
import ExploreViewContainer from './components/ExploreViewContainer';
|
import ExploreViewContainer from './components/ExploreViewContainer';
|
||||||
import { ExploreResponsePayload } from './types';
|
import { ExploreResponsePayload } from './types';
|
||||||
import { fallbackExploreInitialData } from './fixtures';
|
import { fallbackExploreInitialData } from './fixtures';
|
||||||
|
import { getItem, LocalStorageKeys } from '../utils/localStorageHelpers';
|
||||||
|
import { getFormDataWithDashboardContext } from './controlUtils/getFormDataWithDashboardContext';
|
||||||
|
|
||||||
const isResult = (rv: JsonObject): rv is ExploreResponsePayload =>
|
const isResult = (rv: JsonObject): rv is ExploreResponsePayload =>
|
||||||
rv?.result?.form_data &&
|
rv?.result?.form_data &&
|
||||||
|
|
@ -57,6 +61,43 @@ const fetchExploreData = async (exploreUrlParams: URLSearchParams) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDashboardContextFormData = () => {
|
||||||
|
const dashboardPageId = getUrlParam(URL_PARAMS.dashboardPageId);
|
||||||
|
const sliceId = getUrlParam(URL_PARAMS.sliceId) || 0;
|
||||||
|
let dashboardContextWithFilters = {};
|
||||||
|
if (dashboardPageId) {
|
||||||
|
const {
|
||||||
|
labelColors,
|
||||||
|
sharedLabelColors,
|
||||||
|
colorScheme,
|
||||||
|
chartConfiguration,
|
||||||
|
nativeFilters,
|
||||||
|
filterBoxFilters,
|
||||||
|
dataMask,
|
||||||
|
dashboardId,
|
||||||
|
} =
|
||||||
|
getItem(LocalStorageKeys.dashboard__explore_context, {})[
|
||||||
|
dashboardPageId
|
||||||
|
] || {};
|
||||||
|
dashboardContextWithFilters = getFormDataWithExtraFilters({
|
||||||
|
chart: { id: sliceId },
|
||||||
|
filters: getAppliedFilterValues(sliceId, filterBoxFilters),
|
||||||
|
nativeFilters,
|
||||||
|
chartConfiguration,
|
||||||
|
colorScheme,
|
||||||
|
dataMask,
|
||||||
|
labelColors,
|
||||||
|
sharedLabelColors,
|
||||||
|
sliceId,
|
||||||
|
allSliceIds: [sliceId],
|
||||||
|
extraControls: {},
|
||||||
|
});
|
||||||
|
Object.assign(dashboardContextWithFilters, { dashboardId });
|
||||||
|
return dashboardContextWithFilters;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
export default function ExplorePage() {
|
export default function ExplorePage() {
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
const isExploreInitialized = useRef(false);
|
const isExploreInitialized = useRef(false);
|
||||||
|
|
@ -66,10 +107,20 @@ export default function ExplorePage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const exploreUrlParams = getParsedExploreURLParams(location);
|
const exploreUrlParams = getParsedExploreURLParams(location);
|
||||||
const isSaveAction = !!getUrlParam(URL_PARAMS.saveAction);
|
const isSaveAction = !!getUrlParam(URL_PARAMS.saveAction);
|
||||||
|
const dashboardContextFormData = getDashboardContextFormData();
|
||||||
if (!isExploreInitialized.current || isSaveAction) {
|
if (!isExploreInitialized.current || isSaveAction) {
|
||||||
fetchExploreData(exploreUrlParams)
|
fetchExploreData(exploreUrlParams)
|
||||||
.then(({ result }) => {
|
.then(({ result }) => {
|
||||||
dispatch(hydrateExplore(result));
|
const formData = getFormDataWithDashboardContext(
|
||||||
|
result.form_data,
|
||||||
|
dashboardContextFormData,
|
||||||
|
);
|
||||||
|
dispatch(
|
||||||
|
hydrateExplore({
|
||||||
|
...result,
|
||||||
|
form_data: formData,
|
||||||
|
}),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
dispatch(hydrateExplore(fallbackExploreInitialData));
|
dispatch(hydrateExplore(fallbackExploreInitialData));
|
||||||
|
|
|
||||||
|
|
@ -212,7 +212,7 @@ class SaveModal extends React.Component<SaveModalProps, SaveModalState> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(this.props.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
searchParams.set('save_action', this.state.action);
|
searchParams.set('save_action', this.state.action);
|
||||||
searchParams.delete('form_data_key');
|
searchParams.delete('form_data_key');
|
||||||
if (this.state.action === 'saveas') {
|
if (this.state.action === 'saveas') {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
/**
|
||||||
|
* 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 { JsonObject } from '@superset-ui/core';
|
||||||
|
import { getFormDataWithDashboardContext } from './getFormDataWithDashboardContext';
|
||||||
|
|
||||||
|
const getExploreFormData = (overrides: JsonObject = {}) => ({
|
||||||
|
adhoc_filters: [
|
||||||
|
{
|
||||||
|
clause: 'WHERE' as const,
|
||||||
|
expressionType: 'SIMPLE' as const,
|
||||||
|
operator: 'IN' as const,
|
||||||
|
subject: 'gender',
|
||||||
|
comparator: ['boys'],
|
||||||
|
filterOptionName: '123',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
applied_time_extras: {},
|
||||||
|
color_scheme: 'supersetColors',
|
||||||
|
datasource: '2__table',
|
||||||
|
granularity_sqla: 'ds',
|
||||||
|
groupby: ['gender'],
|
||||||
|
metric: {
|
||||||
|
aggregate: 'SUM',
|
||||||
|
column: {
|
||||||
|
column_name: 'num',
|
||||||
|
type: 'BIGINT',
|
||||||
|
},
|
||||||
|
expressionType: 'SIMPLE',
|
||||||
|
label: 'Births',
|
||||||
|
},
|
||||||
|
slice_id: 46,
|
||||||
|
time_range: '100 years ago : now',
|
||||||
|
viz_type: 'pie',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getDashboardFormData = (overrides: JsonObject = {}) => ({
|
||||||
|
label_colors: {
|
||||||
|
Girls: '#FF69B4',
|
||||||
|
Boys: '#ADD8E6',
|
||||||
|
girl: '#FF69B4',
|
||||||
|
boy: '#ADD8E6',
|
||||||
|
},
|
||||||
|
shared_label_colors: {
|
||||||
|
boy: '#ADD8E6',
|
||||||
|
girl: '#FF69B4',
|
||||||
|
},
|
||||||
|
color_scheme: 'd3Category20b',
|
||||||
|
extra_filters: [
|
||||||
|
{
|
||||||
|
col: '__time_range',
|
||||||
|
op: '==',
|
||||||
|
val: 'No filter',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
col: '__time_grain',
|
||||||
|
op: '==',
|
||||||
|
val: 'P1D',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
col: '__time_col',
|
||||||
|
op: '==',
|
||||||
|
val: 'ds',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
extra_form_data: {
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
col: 'name',
|
||||||
|
op: 'IN',
|
||||||
|
val: ['Aaron'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
col: 'num_boys',
|
||||||
|
op: '<=',
|
||||||
|
val: 10000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
granularity_sqla: 'ds',
|
||||||
|
time_range: 'Last month',
|
||||||
|
time_grain_sqla: 'PT1S',
|
||||||
|
},
|
||||||
|
dashboardId: 2,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getExpectedResultFormData = (overrides: JsonObject = {}) => ({
|
||||||
|
adhoc_filters: [
|
||||||
|
{
|
||||||
|
clause: 'WHERE',
|
||||||
|
expressionType: 'SIMPLE',
|
||||||
|
operator: 'IN',
|
||||||
|
subject: 'gender',
|
||||||
|
comparator: ['boys'],
|
||||||
|
filterOptionName: '123',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clause: 'WHERE',
|
||||||
|
expressionType: 'SIMPLE',
|
||||||
|
operator: 'IN',
|
||||||
|
subject: 'name',
|
||||||
|
comparator: ['Aaron'],
|
||||||
|
isExtra: true,
|
||||||
|
filterOptionName: expect.any(String),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clause: 'WHERE',
|
||||||
|
expressionType: 'SIMPLE',
|
||||||
|
operator: '<=',
|
||||||
|
subject: 'num_boys',
|
||||||
|
comparator: 10000,
|
||||||
|
isExtra: true,
|
||||||
|
filterOptionName: expect.any(String),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
applied_time_extras: {
|
||||||
|
__time_grain: 'P1D',
|
||||||
|
__time_col: 'ds',
|
||||||
|
},
|
||||||
|
color_scheme: 'd3Category20b',
|
||||||
|
datasource: '2__table',
|
||||||
|
granularity_sqla: 'ds',
|
||||||
|
groupby: ['gender'],
|
||||||
|
metric: {
|
||||||
|
aggregate: 'SUM',
|
||||||
|
column: {
|
||||||
|
column_name: 'num',
|
||||||
|
type: 'BIGINT',
|
||||||
|
},
|
||||||
|
expressionType: 'SIMPLE',
|
||||||
|
label: 'Births',
|
||||||
|
},
|
||||||
|
slice_id: 46,
|
||||||
|
time_range: 'Last month',
|
||||||
|
viz_type: 'pie',
|
||||||
|
label_colors: {
|
||||||
|
Girls: '#FF69B4',
|
||||||
|
Boys: '#ADD8E6',
|
||||||
|
girl: '#FF69B4',
|
||||||
|
boy: '#ADD8E6',
|
||||||
|
},
|
||||||
|
shared_label_colors: {
|
||||||
|
boy: '#ADD8E6',
|
||||||
|
girl: '#FF69B4',
|
||||||
|
},
|
||||||
|
extra_filters: [
|
||||||
|
{
|
||||||
|
col: '__time_range',
|
||||||
|
op: '==',
|
||||||
|
val: 'No filter',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
col: '__time_grain',
|
||||||
|
op: '==',
|
||||||
|
val: 'P1D',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
col: '__time_col',
|
||||||
|
op: '==',
|
||||||
|
val: 'ds',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
extra_form_data: {
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
col: 'name',
|
||||||
|
op: 'IN',
|
||||||
|
val: ['Aaron'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
col: 'num_boys',
|
||||||
|
op: '<=',
|
||||||
|
val: 10000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
granularity_sqla: 'ds',
|
||||||
|
time_range: 'Last month',
|
||||||
|
time_grain_sqla: 'PT1S',
|
||||||
|
},
|
||||||
|
dashboardId: 2,
|
||||||
|
time_grain_sqla: 'PT1S',
|
||||||
|
granularity: 'ds',
|
||||||
|
extras: {
|
||||||
|
time_grain_sqla: 'PT1S',
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges dashboard context form data with explore form data', () => {
|
||||||
|
const fullFormData = getFormDataWithDashboardContext(
|
||||||
|
getExploreFormData(),
|
||||||
|
getDashboardFormData(),
|
||||||
|
);
|
||||||
|
expect(fullFormData).toEqual(getExpectedResultFormData());
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
/**
|
||||||
|
* 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 isEqual from 'lodash/isEqual';
|
||||||
|
import {
|
||||||
|
EXTRA_FORM_DATA_OVERRIDE_REGULAR_MAPPINGS,
|
||||||
|
EXTRA_FORM_DATA_OVERRIDE_EXTRA_KEYS,
|
||||||
|
isDefined,
|
||||||
|
JsonObject,
|
||||||
|
ensureIsArray,
|
||||||
|
QueryObjectFilterClause,
|
||||||
|
SimpleAdhocFilter,
|
||||||
|
QueryFormData,
|
||||||
|
} from '@superset-ui/core';
|
||||||
|
import { NO_TIME_RANGE } from '../constants';
|
||||||
|
|
||||||
|
const simpleFilterToAdhoc = (
|
||||||
|
filterClause: QueryObjectFilterClause,
|
||||||
|
clause = 'where',
|
||||||
|
) => {
|
||||||
|
const result = {
|
||||||
|
clause: clause.toUpperCase(),
|
||||||
|
expressionType: 'SIMPLE',
|
||||||
|
operator: filterClause.op,
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAdhocFilterDuplicates = (filters: SimpleAdhocFilter[]) => {
|
||||||
|
const isDuplicate = (
|
||||||
|
adhocFilter: SimpleAdhocFilter,
|
||||||
|
existingFilters: SimpleAdhocFilter[],
|
||||||
|
) =>
|
||||||
|
existingFilters.some(
|
||||||
|
(existingFilter: SimpleAdhocFilter) =>
|
||||||
|
existingFilter.operator === adhocFilter.operator &&
|
||||||
|
existingFilter.subject === adhocFilter.subject &&
|
||||||
|
((!('comparator' in existingFilter) &&
|
||||||
|
!('comparator' in adhocFilter)) ||
|
||||||
|
('comparator' in existingFilter &&
|
||||||
|
'comparator' in adhocFilter &&
|
||||||
|
isEqual(existingFilter.comparator, adhocFilter.comparator))),
|
||||||
|
);
|
||||||
|
|
||||||
|
return filters.reduce((acc, filter) => {
|
||||||
|
if (!isDuplicate(filter, acc)) {
|
||||||
|
acc.push(filter);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [] as SimpleAdhocFilter[]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeFilterBoxToFormData = (
|
||||||
|
exploreFormData: QueryFormData,
|
||||||
|
dashboardFormData: JsonObject,
|
||||||
|
) => {
|
||||||
|
const dateColumns = {
|
||||||
|
__time_range: 'time_range',
|
||||||
|
__time_col: 'granularity_sqla',
|
||||||
|
__time_grain: 'time_grain_sqla',
|
||||||
|
__granularity: 'granularity',
|
||||||
|
};
|
||||||
|
const appliedTimeExtras = {};
|
||||||
|
|
||||||
|
const filterBoxData: JsonObject = {};
|
||||||
|
ensureIsArray(dashboardFormData.extra_filters).forEach(filter => {
|
||||||
|
if (dateColumns[filter.col]) {
|
||||||
|
if (filter.val !== NO_TIME_RANGE) {
|
||||||
|
filterBoxData[dateColumns[filter.col]] = filter.val;
|
||||||
|
appliedTimeExtras[filter.col] = filter.val;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const adhocFilter = simpleFilterToAdhoc({
|
||||||
|
...(filter as QueryObjectFilterClause),
|
||||||
|
isExtra: true,
|
||||||
|
});
|
||||||
|
filterBoxData.adhoc_filters = [
|
||||||
|
...ensureIsArray(filterBoxData.adhoc_filters),
|
||||||
|
adhocFilter,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
filterBoxData.applied_time_extras = appliedTimeExtras;
|
||||||
|
return filterBoxData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeNativeFiltersToFormData = (
|
||||||
|
exploreFormData: QueryFormData,
|
||||||
|
dashboardFormData: JsonObject,
|
||||||
|
) => {
|
||||||
|
const nativeFiltersData: JsonObject = {};
|
||||||
|
const extraFormData = dashboardFormData.extra_form_data || {};
|
||||||
|
|
||||||
|
Object.entries(EXTRA_FORM_DATA_OVERRIDE_REGULAR_MAPPINGS).forEach(
|
||||||
|
([srcKey, targetKey]) => {
|
||||||
|
const val = extraFormData[srcKey];
|
||||||
|
if (isDefined(val)) {
|
||||||
|
nativeFiltersData[targetKey] = val;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if ('time_grain_sqla' in extraFormData) {
|
||||||
|
nativeFiltersData.time_grain_sqla = extraFormData.time_grain_sqla;
|
||||||
|
}
|
||||||
|
if ('granularity_sqla' in extraFormData) {
|
||||||
|
nativeFiltersData.granularity_sqla = extraFormData.granularity_sqla;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extras = dashboardFormData.extras || {};
|
||||||
|
EXTRA_FORM_DATA_OVERRIDE_EXTRA_KEYS.forEach(key => {
|
||||||
|
const val = extraFormData[key];
|
||||||
|
if (isDefined(val)) {
|
||||||
|
extras[key] = val;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (Object.keys(extras).length) {
|
||||||
|
nativeFiltersData.extras = extras;
|
||||||
|
}
|
||||||
|
|
||||||
|
nativeFiltersData.adhoc_filters = ensureIsArray(
|
||||||
|
extraFormData.adhoc_filters,
|
||||||
|
).map(filter => ({
|
||||||
|
...filter,
|
||||||
|
isExtra: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const appendFilters = ensureIsArray(extraFormData.filters).map(extraFilter =>
|
||||||
|
simpleFilterToAdhoc({ ...extraFilter, isExtra: true }),
|
||||||
|
);
|
||||||
|
Object.keys(exploreFormData).forEach(key => {
|
||||||
|
if (key.match(/adhoc_filter.*/)) {
|
||||||
|
nativeFiltersData[key] = [
|
||||||
|
...ensureIsArray(nativeFiltersData[key]),
|
||||||
|
...appendFilters,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return nativeFiltersData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFormDataWithDashboardContext = (
|
||||||
|
exploreFormData: QueryFormData,
|
||||||
|
dashboardContextFormData: JsonObject,
|
||||||
|
) => {
|
||||||
|
const filterBoxData = mergeFilterBoxToFormData(
|
||||||
|
exploreFormData,
|
||||||
|
dashboardContextFormData,
|
||||||
|
);
|
||||||
|
const nativeFiltersData = mergeNativeFiltersToFormData(
|
||||||
|
exploreFormData,
|
||||||
|
dashboardContextFormData,
|
||||||
|
);
|
||||||
|
const adhocFilters = removeAdhocFilterDuplicates([
|
||||||
|
...ensureIsArray(exploreFormData.adhoc_filters),
|
||||||
|
...ensureIsArray(filterBoxData.adhoc_filters),
|
||||||
|
...ensureIsArray(nativeFiltersData.adhoc_filters),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
...exploreFormData,
|
||||||
|
...dashboardContextFormData,
|
||||||
|
...filterBoxData,
|
||||||
|
...nativeFiltersData,
|
||||||
|
adhoc_filters: adhocFilters,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
DataMaskStateWithId,
|
||||||
|
DataRecordValue,
|
||||||
|
PartialFilters,
|
||||||
|
} from '@superset-ui/core';
|
||||||
|
import { ChartConfiguration } from 'src/dashboard/reducers/types';
|
||||||
|
|
||||||
|
export interface DashboardContextForExplore {
|
||||||
|
labelColors: Record<string, string>;
|
||||||
|
sharedLabelColors: Record<string, string>;
|
||||||
|
colorScheme: string;
|
||||||
|
chartConfiguration: ChartConfiguration;
|
||||||
|
nativeFilters: PartialFilters;
|
||||||
|
dataMask: DataMaskStateWithId;
|
||||||
|
dashboardId: number;
|
||||||
|
filterBoxFilters:
|
||||||
|
| {
|
||||||
|
[key: string]: {
|
||||||
|
scope: number[];
|
||||||
|
values: DataRecordValue[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| {};
|
||||||
|
isRedundant?: boolean;
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
|
|
||||||
import { TableTabTypes } from 'src/views/CRUD/types';
|
import { TableTabTypes } from 'src/views/CRUD/types';
|
||||||
import { SetTabType } from 'src/views/CRUD/welcome/ActivityTable';
|
import { SetTabType } from 'src/views/CRUD/welcome/ActivityTable';
|
||||||
|
import { DashboardContextForExplore } from 'src/types/DashboardContextForExplore';
|
||||||
|
|
||||||
export enum LocalStorageKeys {
|
export enum LocalStorageKeys {
|
||||||
/**
|
/**
|
||||||
|
|
@ -52,6 +53,7 @@ export enum LocalStorageKeys {
|
||||||
sqllab__is_autocomplete_enabled = 'sqllab__is_autocomplete_enabled',
|
sqllab__is_autocomplete_enabled = 'sqllab__is_autocomplete_enabled',
|
||||||
explore__data_table_original_formatted_time_columns = 'explore__data_table_original_formatted_time_columns',
|
explore__data_table_original_formatted_time_columns = 'explore__data_table_original_formatted_time_columns',
|
||||||
dashboard__custom_filter_bar_widths = 'dashboard__custom_filter_bar_widths',
|
dashboard__custom_filter_bar_widths = 'dashboard__custom_filter_bar_widths',
|
||||||
|
dashboard__explore_context = 'dashboard__explore_context',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LocalStorageValues = {
|
export type LocalStorageValues = {
|
||||||
|
|
@ -68,6 +70,7 @@ export type LocalStorageValues = {
|
||||||
sqllab__is_autocomplete_enabled: boolean;
|
sqllab__is_autocomplete_enabled: boolean;
|
||||||
explore__data_table_original_formatted_time_columns: Record<string, string[]>;
|
explore__data_table_original_formatted_time_columns: Record<string, string[]>;
|
||||||
dashboard__custom_filter_bar_widths: Record<string, number>;
|
dashboard__custom_filter_bar_widths: Record<string, number>;
|
||||||
|
dashboard__explore_context: Record<string, DashboardContextForExplore>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue