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:
simcha90 2021-03-17 14:00:32 +02:00 committed by GitHub
parent aa0cd64940
commit 0e0c99b2fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 457 additions and 139 deletions

View File

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

View File

@ -41,6 +41,7 @@ describe('getFormDataWithExtraFilters', () => {
},
};
const mockArgs: GetFormDataWithExtraFiltersArguments = {
chartConfiguration: {},
charts: {
[chartId]: mockChart,
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,7 +26,7 @@ export const useForceUpdate = () => {
return React.useCallback(() => updateState({}), []);
};
export const setFilterFieldValues = (
export const setNativeFilterFieldValues = (
form: FormInstance,
filterId: string,
values: object,

View File

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

View File

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

View File

@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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