feat(cross-filters): Add scoping for cross filters (#13625)
* feat: cross filter modal * refactor: add charts metadata * refactor: add charts metadata * feat: cross filters scoping * fix: fix CR notes * test: fix test * lint: fix lint
This commit is contained in:
parent
aa0cd64940
commit
0e0c99b2fb
|
|
@ -39,7 +39,7 @@ import dashboardInfo from 'spec/fixtures/mockDashboardInfo';
|
|||
import { dashboardLayout } from 'spec/fixtures/mockDashboardLayout';
|
||||
import dashboardState from 'spec/fixtures/mockDashboardState';
|
||||
import { sliceEntitiesForChart as sliceEntities } from 'spec/fixtures/mockSliceEntities';
|
||||
import { getActiveNativeFilters } from 'src/dashboard/util/activeDashboardNativeFilters';
|
||||
import { getAllActiveFilters } from 'src/dashboard/util/activeAllDashboardFilters';
|
||||
|
||||
describe('Dashboard', () => {
|
||||
const props = {
|
||||
|
|
@ -154,9 +154,9 @@ describe('Dashboard', () => {
|
|||
wrapper.setProps({
|
||||
activeFilters: {
|
||||
...OVERRIDE_FILTERS,
|
||||
...getActiveNativeFilters({
|
||||
...getAllActiveFilters({
|
||||
dataMask: dataMaskWith1Filter,
|
||||
filters: singleNativeFiltersState.filters,
|
||||
nativeFilters: singleNativeFiltersState.filters,
|
||||
layout: layoutForSingleNativeFilter,
|
||||
}),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ describe('getFormDataWithExtraFilters', () => {
|
|||
},
|
||||
};
|
||||
const mockArgs: GetFormDataWithExtraFiltersArguments = {
|
||||
chartConfiguration: {},
|
||||
charts: {
|
||||
[chartId]: mockChart,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* 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 { Dispatch } from 'redux';
|
||||
import { makeApi } from '@superset-ui/core';
|
||||
import { ChartConfiguration, DashboardInfo } from '../reducers/types';
|
||||
|
||||
export const DASHBOARD_INFO_UPDATED = 'DASHBOARD_INFO_UPDATED';
|
||||
|
||||
// updates partially changed dashboard info
|
||||
export function dashboardInfoChanged(newInfo: { metadata: any }) {
|
||||
return { type: DASHBOARD_INFO_UPDATED, newInfo };
|
||||
}
|
||||
export const SET_CHART_CONFIG_BEGIN = 'SET_CHART_CONFIG_BEGIN';
|
||||
export interface SetChartConfigBegin {
|
||||
type: typeof SET_CHART_CONFIG_BEGIN;
|
||||
chartConfiguration: ChartConfiguration;
|
||||
}
|
||||
export const SET_CHART_CONFIG_COMPLETE = 'SET_CHART_CONFIG_COMPLETE';
|
||||
export interface SetChartConfigComplete {
|
||||
type: typeof SET_CHART_CONFIG_COMPLETE;
|
||||
chartConfiguration: ChartConfiguration;
|
||||
}
|
||||
export const SET_CHART_CONFIG_FAIL = 'SET_CHART_CONFIG_FAIL';
|
||||
export interface SetChartConfigFail {
|
||||
type: typeof SET_CHART_CONFIG_FAIL;
|
||||
chartConfiguration: ChartConfiguration;
|
||||
}
|
||||
export const setChartConfiguration = (
|
||||
chartConfiguration: ChartConfiguration,
|
||||
) => async (dispatch: Dispatch, getState: () => any) => {
|
||||
dispatch({
|
||||
type: SET_CHART_CONFIG_BEGIN,
|
||||
chartConfiguration,
|
||||
});
|
||||
const { id, metadata } = getState().dashboardInfo;
|
||||
|
||||
// TODO extract this out when makeApi supports url parameters
|
||||
const updateDashboard = makeApi<
|
||||
Partial<DashboardInfo>,
|
||||
{ result: DashboardInfo }
|
||||
>({
|
||||
method: 'PUT',
|
||||
endpoint: `/api/v1/dashboard/${id}`,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await updateDashboard({
|
||||
json_metadata: JSON.stringify({
|
||||
...metadata,
|
||||
chart_configuration: chartConfiguration,
|
||||
}),
|
||||
});
|
||||
dispatch(
|
||||
dashboardInfoChanged({
|
||||
metadata: JSON.parse(response.result.json_metadata),
|
||||
}),
|
||||
);
|
||||
dispatch({
|
||||
type: SET_CHART_CONFIG_COMPLETE,
|
||||
chartConfiguration,
|
||||
});
|
||||
} catch (err) {
|
||||
dispatch({ type: SET_CHART_CONFIG_FAIL, chartConfiguration });
|
||||
}
|
||||
};
|
||||
|
|
@ -26,7 +26,7 @@ import {
|
|||
SET_DATA_MASK_FOR_FILTER_CONFIG_FAIL,
|
||||
} from 'src/dataMask/actions';
|
||||
import { dashboardInfoChanged } from './dashboardInfo';
|
||||
import { FilterSet } from '../reducers/types';
|
||||
import { DashboardInfo, FilterSet } from '../reducers/types';
|
||||
|
||||
export const SET_FILTER_CONFIG_BEGIN = 'SET_FILTER_CONFIG_BEGIN';
|
||||
export interface SetFilterConfigBegin {
|
||||
|
|
@ -60,11 +60,6 @@ export interface SetFilterSetsConfigFail {
|
|||
filterSetsConfig: FilterSet[];
|
||||
}
|
||||
|
||||
interface DashboardInfo {
|
||||
id: number;
|
||||
json_metadata: string;
|
||||
}
|
||||
|
||||
export const setFilterConfiguration = (
|
||||
filterConfig: FilterConfiguration,
|
||||
) => async (dispatch: Dispatch, getState: () => any) => {
|
||||
|
|
@ -87,7 +82,7 @@ export const setFilterConfiguration = (
|
|||
const response = await updateDashboard({
|
||||
json_metadata: JSON.stringify({
|
||||
...metadata,
|
||||
filter_configuration: filterConfig,
|
||||
native_filter_configuration: filterConfig,
|
||||
}),
|
||||
});
|
||||
dispatch(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React, { FC } from 'react';
|
||||
import { FormInstance } from 'antd/lib/form';
|
||||
import FilterScope from '../nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/FilterScope';
|
||||
import { setCrossFilterFieldValues } from './utils';
|
||||
import { Scope } from '../nativeFilters/types';
|
||||
import { useForceUpdate } from '../nativeFilters/FiltersConfigModal/FiltersConfigForm/utils';
|
||||
import { CrossFilterScopingFormType } from './types';
|
||||
|
||||
type CrossFilterScopingFormProps = {
|
||||
scope: Scope;
|
||||
form: FormInstance<CrossFilterScopingFormType>;
|
||||
};
|
||||
|
||||
const CrossFilterScopingForm: FC<CrossFilterScopingFormProps> = ({
|
||||
form,
|
||||
scope,
|
||||
}) => {
|
||||
const forceUpdate = useForceUpdate();
|
||||
const formScope = form.getFieldValue('scope');
|
||||
const formScoping = form.getFieldValue('scoping');
|
||||
return (
|
||||
<FilterScope
|
||||
updateFormValues={(values: any) => {
|
||||
setCrossFilterFieldValues(form, {
|
||||
...values,
|
||||
});
|
||||
}}
|
||||
scope={scope}
|
||||
formScope={formScope}
|
||||
forceUpdate={forceUpdate}
|
||||
formScoping={formScoping}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CrossFilterScopingForm;
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* 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 { t } from '@superset-ui/core';
|
||||
import React, { FC } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { StyledModal } from 'src/common/components/Modal';
|
||||
import Button from 'src/components/Button';
|
||||
import { Form } from 'src/common/components';
|
||||
import { setChartConfiguration } from 'src/dashboard/actions/dashboardInfo';
|
||||
import { ChartConfiguration } from 'src/dashboard/reducers/types';
|
||||
import CrossFilterScopingForm from './CrossFilterScopingForm';
|
||||
import { CrossFilterScopingFormType } from './types';
|
||||
import { StyledForm } from '../nativeFilters/FiltersConfigModal/FiltersConfigModal';
|
||||
|
||||
type CrossFilterScopingModalProps = {
|
||||
chartId: number;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const CrossFilterScopingModal: FC<CrossFilterScopingModalProps> = ({
|
||||
isOpen,
|
||||
chartId,
|
||||
onClose,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const [form] = Form.useForm<CrossFilterScopingFormType>();
|
||||
const chartConfig = useSelector<any, ChartConfiguration>(
|
||||
({ dashboardInfo }) => dashboardInfo?.metadata?.chart_configuration,
|
||||
);
|
||||
const scope = chartConfig?.[chartId]?.crossFilters?.scope;
|
||||
const handleSave = () => {
|
||||
dispatch(
|
||||
setChartConfiguration({
|
||||
...chartConfig,
|
||||
[chartId]: { crossFilters: { scope: form.getFieldValue('scope') } },
|
||||
}),
|
||||
);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
visible={isOpen}
|
||||
maskClosable={false}
|
||||
title={t('Cross Filter Scoping')}
|
||||
width="55%"
|
||||
destroyOnClose
|
||||
onCancel={onClose}
|
||||
onOk={handleSave}
|
||||
centered
|
||||
data-test="cross-filter-scoping-modal"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
key="cancel"
|
||||
buttonStyle="secondary"
|
||||
data-test="cross-filter-scoping-modal-cancel-button"
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
key="submit"
|
||||
buttonStyle="primary"
|
||||
onClick={handleSave}
|
||||
data-test="cross-filter-scoping-modal-save-button"
|
||||
>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StyledForm preserve={false} form={form} layout="vertical">
|
||||
<CrossFilterScopingForm form={form} scope={scope} />
|
||||
</StyledForm>
|
||||
</StyledModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CrossFilterScopingModal;
|
||||
|
|
@ -17,9 +17,8 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
export const DASHBOARD_INFO_UPDATED = 'DASHBOARD_INFO_UPDATED';
|
||||
import { Scope } from '../nativeFilters/types';
|
||||
|
||||
// updates partially changed dashboard info
|
||||
export function dashboardInfoChanged(newInfo) {
|
||||
return { type: DASHBOARD_INFO_UPDATED, newInfo };
|
||||
}
|
||||
export type CrossFilterScopingFormType = {
|
||||
scope: Scope;
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* 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 { FormInstance } from 'antd/lib/form';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const setCrossFilterFieldValues = (
|
||||
form: FormInstance,
|
||||
values: object,
|
||||
) => {
|
||||
form.setFieldsValue({
|
||||
...values,
|
||||
});
|
||||
};
|
||||
|
|
@ -207,7 +207,10 @@ class Dashboard extends React.PureComponent {
|
|||
this.appliedOwnDataCharts,
|
||||
);
|
||||
[...allKeys].forEach(filterKey => {
|
||||
if (!currFilterKeys.includes(filterKey)) {
|
||||
if (
|
||||
!currFilterKeys.includes(filterKey) &&
|
||||
appliedFilterKeys.includes(filterKey)
|
||||
) {
|
||||
// filterKey is removed?
|
||||
affectedChartIds.push(...appliedFilters[filterKey].scope);
|
||||
} else if (!appliedFilterKeys.includes(filterKey)) {
|
||||
|
|
|
|||
|
|
@ -19,12 +19,19 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import moment from 'moment';
|
||||
import { styled, t } from '@superset-ui/core';
|
||||
import {
|
||||
Behavior,
|
||||
getChartMetadataRegistry,
|
||||
styled,
|
||||
t,
|
||||
} from '@superset-ui/core';
|
||||
import { Menu, NoAnimationDropdown } from 'src/common/components';
|
||||
import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems';
|
||||
import downloadAsImage from '../../utils/downloadAsImage';
|
||||
import getDashboardUrl from '../util/getDashboardUrl';
|
||||
import { getActiveFilters } from '../util/activeDashboardFilters';
|
||||
import { FeatureFlag, isFeatureEnabled } from '../../featureFlags';
|
||||
import CrossFilterScopingModal from './CrossFilterScopingModal/CrossFilterScopingModal';
|
||||
|
||||
const propTypes = {
|
||||
slice: PropTypes.object.isRequired,
|
||||
|
|
@ -59,6 +66,7 @@ const defaultProps = {
|
|||
};
|
||||
|
||||
const MENU_KEYS = {
|
||||
CROSS_FILTER_SCOPING: 'cross_filter_scoping',
|
||||
FORCE_REFRESH: 'force_refresh',
|
||||
TOGGLE_CHART_DESCRIPTION: 'toggle_chart_description',
|
||||
EXPLORE_CHART: 'explore_chart',
|
||||
|
|
@ -111,6 +119,7 @@ class SliceHeaderControls extends React.PureComponent {
|
|||
|
||||
this.state = {
|
||||
showControls: false,
|
||||
showCrossFilterScopingModal: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -134,6 +143,9 @@ class SliceHeaderControls extends React.PureComponent {
|
|||
case MENU_KEYS.FORCE_REFRESH:
|
||||
this.refreshChart();
|
||||
break;
|
||||
case MENU_KEYS.CROSS_FILTER_SCOPING:
|
||||
this.setState({ showCrossFilterScopingModal: true });
|
||||
break;
|
||||
case MENU_KEYS.TOGGLE_CHART_DESCRIPTION:
|
||||
this.props.toggleExpandSlice(this.props.slice.slice_id);
|
||||
break;
|
||||
|
|
@ -177,6 +189,14 @@ class SliceHeaderControls extends React.PureComponent {
|
|||
addDangerToast,
|
||||
isFullSize,
|
||||
} = this.props;
|
||||
const crossFilterItems = getChartMetadataRegistry().items;
|
||||
const isCrossFilter = Object.entries(crossFilterItems)
|
||||
// @ts-ignore
|
||||
.filter(([, { value }]) =>
|
||||
value.behaviors?.includes(Behavior.CROSS_FILTER),
|
||||
)
|
||||
.find(([key]) => key === slice.viz_type);
|
||||
|
||||
const cachedWhen = cachedDttm.map(itemCachedDttm =>
|
||||
moment.utc(itemCachedDttm).fromNow(),
|
||||
);
|
||||
|
|
@ -255,25 +275,38 @@ class SliceHeaderControls extends React.PureComponent {
|
|||
{this.props.supersetCanCSV && (
|
||||
<Menu.Item key={MENU_KEYS.EXPORT_CSV}>{t('Export CSV')}</Menu.Item>
|
||||
)}
|
||||
{isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS) &&
|
||||
isCrossFilter && (
|
||||
<Menu.Item key={MENU_KEYS.CROSS_FILTER_SCOPING}>
|
||||
{t('Cross-filter scoping')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<NoAnimationDropdown
|
||||
overlay={menu}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
dropdownAlign={{
|
||||
offset: [-40, 4],
|
||||
}}
|
||||
getPopupContainer={triggerNode =>
|
||||
triggerNode.closest(SCREENSHOT_NODE_SELECTOR)
|
||||
}
|
||||
>
|
||||
<span id={`slice_${slice.slice_id}-controls`} role="button">
|
||||
<VerticalDotsTrigger />
|
||||
</span>
|
||||
</NoAnimationDropdown>
|
||||
<>
|
||||
<CrossFilterScopingModal
|
||||
chartId={slice.slice_id}
|
||||
isOpen={this.state.showCrossFilterScopingModal}
|
||||
onClose={() => this.setState({ showCrossFilterScopingModal: false })}
|
||||
/>
|
||||
<NoAnimationDropdown
|
||||
overlay={menu}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
dropdownAlign={{
|
||||
offset: [-40, 4],
|
||||
}}
|
||||
getPopupContainer={triggerNode =>
|
||||
triggerNode.closest(SCREENSHOT_NODE_SELECTOR)
|
||||
}
|
||||
>
|
||||
<span id={`slice_${slice.slice_id}-controls`} role="button">
|
||||
<VerticalDotsTrigger />
|
||||
</span>
|
||||
</NoAnimationDropdown>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import React, { FC } from 'react';
|
|||
import { Checkbox } from 'src/common/components';
|
||||
import { FormInstance } from 'antd/lib/form';
|
||||
import { getChartControlPanelRegistry } from '@superset-ui/core';
|
||||
import { getControlItems, setFilterFieldValues } from './utils';
|
||||
import { getControlItems, setNativeFilterFieldValues } from './utils';
|
||||
import { NativeFiltersForm, NativeFiltersFormItem } from '../types';
|
||||
import { StyledCheckboxFormItem } from './FiltersConfigForm';
|
||||
import { Filter } from '../../types';
|
||||
|
|
@ -71,7 +71,7 @@ const ControlItems: FC<ControlItemsProps> = ({
|
|||
if (!controlItem.config.resetConfig) {
|
||||
return;
|
||||
}
|
||||
setFilterFieldValues(form, filterId, {
|
||||
setNativeFilterFieldValues(form, filterId, {
|
||||
defaultValue: null,
|
||||
});
|
||||
forceUpdate();
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
import React, { FC } from 'react';
|
||||
import { t, SuperChart, Behavior } from '@superset-ui/core';
|
||||
import { FormInstance } from 'antd/lib/form';
|
||||
import { setFilterFieldValues } from './utils';
|
||||
import { setNativeFilterFieldValues } from './utils';
|
||||
import { StyledFormItem, StyledLabel } from './FiltersConfigForm';
|
||||
import { Filter } from '../../types';
|
||||
import { NativeFiltersForm } from '../types';
|
||||
|
|
@ -68,7 +68,7 @@ const DefaultValue: FC<DefaultValueProps> = ({
|
|||
chartType={formFilter?.filterType}
|
||||
hooks={{
|
||||
setDataMask: ({ nativeFilters }) => {
|
||||
setFilterFieldValues(form, filterId, {
|
||||
setNativeFilterFieldValues(form, filterId, {
|
||||
defaultValue: nativeFilters?.currentState?.value,
|
||||
});
|
||||
forceUpdate();
|
||||
|
|
|
|||
|
|
@ -20,52 +20,57 @@
|
|||
import React, { FC } from 'react';
|
||||
import { t, styled } from '@superset-ui/core';
|
||||
import { Radio } from 'src/common/components/Radio';
|
||||
import { Form, Typography, Space, FormInstance } from 'src/common/components';
|
||||
import { NativeFiltersForm } from '../../types';
|
||||
import { Filter } from '../../../types';
|
||||
import { Form, Typography } from 'src/common/components';
|
||||
import { Scope } from '../../../types';
|
||||
import { Scoping } from './types';
|
||||
import ScopingTree from './ScopingTree';
|
||||
import { setFilterFieldValues, useForceUpdate } from '../utils';
|
||||
import { getDefaultScopeValue, isScopingAll } from './utils';
|
||||
|
||||
type FilterScopeProps = {
|
||||
filterId: string;
|
||||
filterToEdit?: Filter;
|
||||
form: FormInstance<NativeFiltersForm>;
|
||||
pathToFormValue?: string[];
|
||||
updateFormValues: (values: any) => void;
|
||||
formScope?: Scope;
|
||||
forceUpdate: Function;
|
||||
scope?: Scope;
|
||||
formScoping?: Scoping;
|
||||
};
|
||||
|
||||
const Wrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
& > * {
|
||||
margin-bottom: ${({ theme }) => theme.gridUnit}px;
|
||||
}
|
||||
`;
|
||||
|
||||
const CleanFormItem = styled(Form.Item)`
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
const FilterScope: FC<FilterScopeProps> = ({
|
||||
filterId,
|
||||
filterToEdit,
|
||||
form,
|
||||
pathToFormValue = [],
|
||||
formScoping,
|
||||
formScope,
|
||||
forceUpdate,
|
||||
scope,
|
||||
updateFormValues,
|
||||
}) => {
|
||||
const formFilter = form.getFieldValue('filters')?.[filterId];
|
||||
const initialScope = filterToEdit?.scope || getDefaultScopeValue();
|
||||
|
||||
const scoping = isScopingAll(initialScope) ? Scoping.all : Scoping.specific;
|
||||
|
||||
const forceUpdate = useForceUpdate();
|
||||
const initialScope = scope || getDefaultScopeValue();
|
||||
const initialScoping = isScopingAll(initialScope)
|
||||
? Scoping.all
|
||||
: Scoping.specific;
|
||||
|
||||
return (
|
||||
<Space direction="vertical">
|
||||
<CleanFormItem
|
||||
name={['filters', filterId, 'scope']}
|
||||
hidden
|
||||
initialValue={initialScope}
|
||||
/>
|
||||
<Wrapper>
|
||||
<Typography.Title level={5}>{t('Scoping')}</Typography.Title>
|
||||
<CleanFormItem
|
||||
name={['filters', filterId, 'scoping']}
|
||||
initialValue={scoping}
|
||||
name={[...pathToFormValue, 'scoping']}
|
||||
initialValue={initialScoping}
|
||||
>
|
||||
<Radio.Group
|
||||
onChange={({ target: { value } }) => {
|
||||
if (value === Scoping.all) {
|
||||
setFilterFieldValues(form, filterId, {
|
||||
updateFormValues({
|
||||
scope: getDefaultScopeValue(),
|
||||
});
|
||||
}
|
||||
|
|
@ -79,18 +84,24 @@ const FilterScope: FC<FilterScopeProps> = ({
|
|||
</Radio.Group>
|
||||
</CleanFormItem>
|
||||
<Typography.Text type="secondary">
|
||||
{formFilter?.scoping === Scoping.specific
|
||||
{(formScoping ?? initialScoping) === Scoping.specific
|
||||
? t('Only selected panels will be affected by this filter')
|
||||
: t('All panels with this column will be affected by this filter')}
|
||||
</Typography.Text>
|
||||
{formFilter?.scoping === Scoping.specific && (
|
||||
{(formScoping ?? initialScoping) === Scoping.specific && (
|
||||
<ScopingTree
|
||||
updateFormValues={updateFormValues}
|
||||
initialScope={initialScope}
|
||||
form={form}
|
||||
filterId={filterId}
|
||||
formScope={formScope}
|
||||
forceUpdate={forceUpdate}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
<CleanFormItem
|
||||
name={[...pathToFormValue, 'scope']}
|
||||
hidden
|
||||
initialValue={initialScope}
|
||||
/>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -18,31 +18,29 @@
|
|||
*/
|
||||
|
||||
import React, { FC, useMemo, useState } from 'react';
|
||||
import { FormInstance, Tree } from 'src/common/components';
|
||||
import { Tree } from 'src/common/components';
|
||||
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
|
||||
import { useFilterScopeTree } from './state';
|
||||
import { setFilterFieldValues, useForceUpdate } from '../utils';
|
||||
import { findFilterScope, getTreeCheckedItems } from './utils';
|
||||
import { NativeFiltersForm } from '../../types';
|
||||
import { Scope } from '../../../types';
|
||||
|
||||
type ScopingTreeProps = {
|
||||
form: FormInstance<NativeFiltersForm>;
|
||||
filterId: string;
|
||||
forceUpdate: Function;
|
||||
updateFormValues: (values: any) => void;
|
||||
formScope?: Scope;
|
||||
initialScope: Scope;
|
||||
};
|
||||
|
||||
const ScopingTree: FC<ScopingTreeProps> = ({
|
||||
form,
|
||||
filterId,
|
||||
formScope,
|
||||
initialScope,
|
||||
forceUpdate,
|
||||
updateFormValues,
|
||||
}) => {
|
||||
const [expandedKeys, setExpandedKeys] = useState<string[]>([
|
||||
DASHBOARD_ROOT_ID,
|
||||
]);
|
||||
|
||||
const formFilter = form.getFieldValue('filters')[filterId];
|
||||
|
||||
const { treeData, layout } = useFilterScopeTree();
|
||||
const [autoExpandParent, setAutoExpandParent] = useState<boolean>(true);
|
||||
|
||||
|
|
@ -50,18 +48,16 @@ const ScopingTree: FC<ScopingTreeProps> = ({
|
|||
setExpandedKeys(expandedKeys);
|
||||
setAutoExpandParent(false);
|
||||
};
|
||||
const forceUpdate = useForceUpdate();
|
||||
const handleCheck = (checkedKeys: string[]) => {
|
||||
forceUpdate();
|
||||
setFilterFieldValues(form, filterId, {
|
||||
updateFormValues({
|
||||
scope: findFilterScope(checkedKeys, layout),
|
||||
});
|
||||
};
|
||||
|
||||
const checkedKeys = useMemo(
|
||||
() =>
|
||||
getTreeCheckedItems({ ...(formFilter.scope || initialScope) }, layout),
|
||||
[formFilter.scope, initialScope, layout],
|
||||
() => getTreeCheckedItems({ ...(formScope || initialScope) }, layout),
|
||||
[formScope, initialScope, layout],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import { ColumnSelect } from './ColumnSelect';
|
|||
import { NativeFiltersForm } from '../types';
|
||||
import {
|
||||
datasetToSelectOption,
|
||||
setFilterFieldValues,
|
||||
setNativeFilterFieldValues,
|
||||
useForceUpdate,
|
||||
} from './utils';
|
||||
import { useBackendFormUpdate } from './state';
|
||||
|
|
@ -171,7 +171,7 @@ export const FiltersConfigForm: React.FC<FiltersConfigFormProps> = ({
|
|||
label: nativeFilterItems[filterType]?.value.name,
|
||||
}))}
|
||||
onChange={({ value }: { value: string }) => {
|
||||
setFilterFieldValues(form, filterId, {
|
||||
setNativeFilterFieldValues(form, filterId, {
|
||||
filterType: value,
|
||||
defaultValue: null,
|
||||
});
|
||||
|
|
@ -202,7 +202,7 @@ export const FiltersConfigForm: React.FC<FiltersConfigFormProps> = ({
|
|||
// We need reset column when dataset changed
|
||||
const datasetId = formFilter?.dataset?.value;
|
||||
if (datasetId && e?.value !== datasetId) {
|
||||
setFilterFieldValues(form, filterId, {
|
||||
setNativeFilterFieldValues(form, filterId, {
|
||||
column: null,
|
||||
});
|
||||
}
|
||||
|
|
@ -283,9 +283,14 @@ export const FiltersConfigForm: React.FC<FiltersConfigFormProps> = ({
|
|||
forceUpdate={forceUpdate}
|
||||
/>
|
||||
<FilterScope
|
||||
filterId={filterId}
|
||||
filterToEdit={filterToEdit}
|
||||
form={form}
|
||||
updateFormValues={(values: any) =>
|
||||
setNativeFilterFieldValues(form, filterId, values)
|
||||
}
|
||||
pathToFormValue={['filters', filterId]}
|
||||
forceUpdate={forceUpdate}
|
||||
scope={filterToEdit?.scope}
|
||||
formScope={formFilter?.scope}
|
||||
formScoping={formFilter?.scoping}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { useEffect } from 'react';
|
|||
import { FormInstance } from 'antd/lib/form';
|
||||
import { getChartDataRequest } from 'src/chart/chartAction';
|
||||
import { NativeFiltersForm } from '../types';
|
||||
import { setFilterFieldValues, useForceUpdate } from './utils';
|
||||
import { setNativeFilterFieldValues, useForceUpdate } from './utils';
|
||||
import { Filter } from '../../types';
|
||||
import { getFormData } from '../../utils';
|
||||
|
||||
|
|
@ -44,7 +44,7 @@ export const useBackendFormUpdate = (
|
|||
// No need to check data set change because it cascading update column
|
||||
// So check that column exists is enough
|
||||
if (hasColumn && !formFilter?.column) {
|
||||
setFilterFieldValues(form, filterId, {
|
||||
setNativeFilterFieldValues(form, filterId, {
|
||||
defaultValueQueriesData: [],
|
||||
defaultValue: resolvedDefaultValue,
|
||||
});
|
||||
|
|
@ -73,7 +73,7 @@ export const useBackendFormUpdate = (
|
|||
) {
|
||||
resolvedDefaultValue = filterToEdit?.defaultValue;
|
||||
}
|
||||
setFilterFieldValues(form, filterId, {
|
||||
setNativeFilterFieldValues(form, filterId, {
|
||||
defaultValueQueriesData: response.result,
|
||||
defaultValue: resolvedDefaultValue,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export const useForceUpdate = () => {
|
|||
return React.useCallback(() => updateState({}), []);
|
||||
};
|
||||
|
||||
export const setFilterFieldValues = (
|
||||
export const setNativeFilterFieldValues = (
|
||||
form: FormInstance,
|
||||
filterId: string,
|
||||
values: object,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ const defaultFilterConfiguration: Filter[] = [];
|
|||
export function useFilterConfiguration() {
|
||||
return useSelector<any, FilterConfiguration>(
|
||||
state =>
|
||||
state.dashboardInfo?.metadata?.filter_configuration ||
|
||||
state.dashboardInfo?.metadata?.native_filter_configuration ||
|
||||
defaultFilterConfiguration,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,9 +25,8 @@ import {
|
|||
} from '@superset-ui/core';
|
||||
import { Charts } from 'src/dashboard/types';
|
||||
import { RefObject } from 'react';
|
||||
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
|
||||
import { DataMaskStateWithId } from 'src/dataMask/types';
|
||||
import { Filter } from './types';
|
||||
import { DataMaskStateWithId } from '../../../dataMask/types';
|
||||
|
||||
export const getFormData = ({
|
||||
datasetId,
|
||||
|
|
@ -123,18 +122,10 @@ export function getExtraFormData(
|
|||
): ExtraFormData {
|
||||
let extraFormData: ExtraFormData = {};
|
||||
filterIdsAppliedOnChart.forEach(key => {
|
||||
const singleDataMask = dataMask.nativeFilters[key] || {};
|
||||
const singleDataMask =
|
||||
dataMask.nativeFilters[key] ?? dataMask.crossFilters[key] ?? {};
|
||||
const { extraFormData: newExtra = {} } = singleDataMask;
|
||||
extraFormData = mergeExtraFormData(extraFormData, newExtra);
|
||||
});
|
||||
if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) {
|
||||
Object.entries(charts).forEach(([key, chart]) => {
|
||||
if (isCrossFilter(chart?.formData?.viz_type)) {
|
||||
const singleDataMask = dataMask.crossFilters[key] || {};
|
||||
const { extraFormData: newExtra = {} } = singleDataMask;
|
||||
extraFormData = mergeExtraFormData(extraFormData, newExtra);
|
||||
}
|
||||
});
|
||||
}
|
||||
return extraFormData;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ function mapStateToProps(
|
|||
const formData = getFormDataWithExtraFilters({
|
||||
layout: dashboardLayout.present,
|
||||
chart,
|
||||
// eslint-disable-next-line camelcase
|
||||
chartConfiguration: dashboardInfo.metadata?.chart_configuration,
|
||||
charts: chartQueries,
|
||||
filters: getAppliedFilterValues(id),
|
||||
colorScheme,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import {
|
|||
import { triggerQuery } from '../../chart/chartAction';
|
||||
import { logEvent } from '../../logger/actions';
|
||||
import { getActiveFilters } from '../util/activeDashboardFilters';
|
||||
import { getActiveNativeFilters } from '../util/activeDashboardNativeFilters';
|
||||
import { getAllActiveFilters } from '../util/activeAllDashboardFilters';
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const {
|
||||
|
|
@ -58,8 +58,10 @@ function mapStateToProps(state) {
|
|||
// When user start interacting with dashboard, it will be user picked values from all filter_box
|
||||
activeFilters: {
|
||||
...getActiveFilters(),
|
||||
...getActiveNativeFilters({
|
||||
filters: nativeFilters.filters,
|
||||
...getAllActiveFilters({
|
||||
// eslint-disable-next-line camelcase
|
||||
chartConfiguration: dashboardInfo.metadata?.chart_configuration,
|
||||
nativeFilters: nativeFilters.filters,
|
||||
dataMask,
|
||||
layout: dashboardLayout.present,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -259,7 +259,7 @@ export default function getInitialState(bootstrapData) {
|
|||
}
|
||||
|
||||
const nativeFilters = getInitialNativeFilterState({
|
||||
filterConfig: dashboard.metadata.filter_configuration || [],
|
||||
filterConfig: dashboard.metadata.native_filter_configuration || [],
|
||||
filterSetsConfig: dashboard.metadata.filter_sets_configuration || [],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -19,11 +19,24 @@
|
|||
|
||||
import componentTypes from 'src/dashboard/util/componentTypes';
|
||||
import { DataMaskStateWithId } from 'src/dataMask/types';
|
||||
import { Filter } from '../components/nativeFilters/types';
|
||||
import { Filter, Scope } from '../components/nativeFilters/types';
|
||||
|
||||
export enum Scoping {
|
||||
all,
|
||||
specific,
|
||||
All = 'All',
|
||||
Specific = 'Specific',
|
||||
}
|
||||
|
||||
export type ChartConfiguration = {
|
||||
[chartId: string]: {
|
||||
crossFilters: {
|
||||
scope: Scope;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export interface DashboardInfo {
|
||||
id: number;
|
||||
json_metadata: string;
|
||||
}
|
||||
|
||||
/** Chart state of redux */
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
import { CHART_TYPE } from './componentTypes';
|
||||
import { Scope } from '../components/nativeFilters/types';
|
||||
import { ActiveFilters, LayoutItem } from '../types';
|
||||
import { Filters } from '../reducers/types';
|
||||
import { ChartConfiguration, Filters } from '../reducers/types';
|
||||
import { DASHBOARD_ROOT_ID } from './constants';
|
||||
import { DataMaskStateWithId } from '../../dataMask/types';
|
||||
|
||||
|
|
@ -28,14 +28,14 @@ export const findAffectedCharts = ({
|
|||
child,
|
||||
layout,
|
||||
scope,
|
||||
activeNativeFilters,
|
||||
activeFilters,
|
||||
filterId,
|
||||
extraFormData,
|
||||
}: {
|
||||
child: string;
|
||||
layout: { [key: string]: LayoutItem };
|
||||
scope: Scope;
|
||||
activeNativeFilters: ActiveFilters;
|
||||
activeFilters: ActiveFilters;
|
||||
filterId: string;
|
||||
extraFormData: any;
|
||||
}) => {
|
||||
|
|
@ -45,17 +45,17 @@ export const findAffectedCharts = ({
|
|||
if (scope.excluded.includes(chartId)) {
|
||||
return;
|
||||
}
|
||||
if (!activeNativeFilters[filterId]) {
|
||||
if (!activeFilters[filterId]) {
|
||||
// Small mutation but simplify logic
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
activeNativeFilters[filterId] = {
|
||||
activeFilters[filterId] = {
|
||||
scope: [],
|
||||
values: [],
|
||||
};
|
||||
}
|
||||
// Add not excluded chart scopes(to know what charts refresh) and values(refresh only if its value changed)
|
||||
activeNativeFilters[filterId].scope.push(chartId);
|
||||
activeNativeFilters[filterId].values.push(extraFormData);
|
||||
activeFilters[filterId].scope.push(chartId);
|
||||
activeFilters[filterId].values.push(extraFormData);
|
||||
return;
|
||||
}
|
||||
// If child is not chart, recursive iterate over its children
|
||||
|
|
@ -64,35 +64,36 @@ export const findAffectedCharts = ({
|
|||
child,
|
||||
layout,
|
||||
scope,
|
||||
activeNativeFilters,
|
||||
activeFilters,
|
||||
filterId,
|
||||
extraFormData,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const getActiveNativeFilters = ({
|
||||
filters,
|
||||
export const getAllActiveFilters = ({
|
||||
chartConfiguration,
|
||||
nativeFilters,
|
||||
dataMask,
|
||||
layout,
|
||||
}: {
|
||||
chartConfiguration: ChartConfiguration;
|
||||
dataMask: DataMaskStateWithId;
|
||||
filters: Filters;
|
||||
nativeFilters: Filters;
|
||||
layout: { [key: string]: LayoutItem };
|
||||
}): ActiveFilters => {
|
||||
const activeNativeFilters = {};
|
||||
if (!dataMask?.nativeFilters) {
|
||||
return activeNativeFilters;
|
||||
}
|
||||
const activeFilters = {};
|
||||
|
||||
// Combine native filters with cross filters, because they have similar logic
|
||||
Object.values({
|
||||
...dataMask.nativeFilters,
|
||||
...dataMask.crossFilters,
|
||||
}).forEach(({ id: filterId, extraFormData }) => {
|
||||
// TODO: for a case of a cross filters (should be updated will be added scope there)
|
||||
const scope = filters?.[filterId]?.scope ?? {
|
||||
rootPath: [DASHBOARD_ROOT_ID],
|
||||
excluded: [],
|
||||
};
|
||||
const scope = nativeFilters?.[filterId]?.scope ??
|
||||
chartConfiguration?.[filterId]?.crossFilters?.scope ?? {
|
||||
rootPath: [DASHBOARD_ROOT_ID],
|
||||
excluded: [],
|
||||
};
|
||||
// Iterate over all roots to find all affected charts
|
||||
scope.rootPath.forEach(layoutItemId => {
|
||||
layout[layoutItemId].children.forEach((child: string) => {
|
||||
|
|
@ -101,12 +102,12 @@ export const getActiveNativeFilters = ({
|
|||
child,
|
||||
layout,
|
||||
scope,
|
||||
activeNativeFilters,
|
||||
activeFilters,
|
||||
filterId,
|
||||
extraFormData,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
return activeNativeFilters;
|
||||
return activeFilters;
|
||||
};
|
||||
|
|
@ -29,8 +29,8 @@ import {
|
|||
} from 'src/dashboard/components/nativeFilters/utils';
|
||||
import { DataMaskStateWithId } from 'src/dataMask/types';
|
||||
import getEffectiveExtraFilters from './getEffectiveExtraFilters';
|
||||
import { getActiveNativeFilters } from '../activeDashboardNativeFilters';
|
||||
import { NativeFiltersState } from '../../reducers/types';
|
||||
import { ChartConfiguration, NativeFiltersState } from '../../reducers/types';
|
||||
import { getAllActiveFilters } from '../activeAllDashboardFilters';
|
||||
|
||||
// We cache formData objects so that our connected container components don't always trigger
|
||||
// render cascades. we cannot leverage the reselect library because our cache size is >1
|
||||
|
|
@ -38,6 +38,7 @@ const cachedFiltersByChart = {};
|
|||
const cachedFormdataByChart = {};
|
||||
|
||||
export interface GetFormDataWithExtraFiltersArguments {
|
||||
chartConfiguration: ChartConfiguration;
|
||||
chart: ChartQueryPayload;
|
||||
charts: Charts;
|
||||
filters: DataRecordFilters;
|
||||
|
|
@ -57,6 +58,7 @@ export default function getFormDataWithExtraFilters({
|
|||
charts,
|
||||
filters,
|
||||
nativeFilters,
|
||||
chartConfiguration,
|
||||
colorScheme,
|
||||
colorNamespace,
|
||||
sliceId,
|
||||
|
|
@ -81,12 +83,13 @@ export default function getFormDataWithExtraFilters({
|
|||
}
|
||||
|
||||
let extraData: { extra_form_data?: JsonObject } = {};
|
||||
const activeNativeFilters = getActiveNativeFilters({
|
||||
const activeFilters = getAllActiveFilters({
|
||||
chartConfiguration,
|
||||
dataMask,
|
||||
layout,
|
||||
filters: nativeFilters.filters,
|
||||
nativeFilters: nativeFilters.filters,
|
||||
});
|
||||
const filterIdsAppliedOnChart = Object.entries(activeNativeFilters)
|
||||
const filterIdsAppliedOnChart = Object.entries(activeFilters)
|
||||
.filter(([, { scope }]) => scope.includes(chart.id))
|
||||
.map(([filterId]) => filterId);
|
||||
if (filterIdsAppliedOnChart.length) {
|
||||
|
|
|
|||
|
|
@ -106,8 +106,10 @@ def validate_json_metadata(value: Union[bytes, bytearray, str]) -> None:
|
|||
|
||||
|
||||
class DashboardJSONMetadataSchema(Schema):
|
||||
# filter_configuration is for dashboard-native filters
|
||||
filter_configuration = fields.List(fields.Dict(), allow_none=True)
|
||||
# native_filter_configuration is for dashboard-native filters
|
||||
native_filter_configuration = fields.List(fields.Dict(), allow_none=True)
|
||||
# chart_configuration is for dashboard-native filters
|
||||
chart_configuration = fields.List(fields.Dict(), allow_none=True)
|
||||
# filter_sets_configuration is for dashboard-native filters
|
||||
filter_sets_configuration = fields.List(fields.Dict(), allow_none=True)
|
||||
timed_refresh_immune_slices = fields.List(fields.Integer())
|
||||
|
|
|
|||
Loading…
Reference in New Issue