diff --git a/superset-frontend/spec/javascripts/dashboard/components/nativeFilters/ScopingTree_spec.tsx b/superset-frontend/spec/javascripts/dashboard/components/nativeFilters/ScopingTree_spec.tsx
index 818ed011d41..e26305855d0 100644
--- a/superset-frontend/spec/javascripts/dashboard/components/nativeFilters/ScopingTree_spec.tsx
+++ b/superset-frontend/spec/javascripts/dashboard/components/nativeFilters/ScopingTree_spec.tsx
@@ -21,20 +21,40 @@ import { Provider } from 'react-redux';
import ScopingTree from 'src/dashboard/components/nativeFilters/ScopingTree';
import { styledMount as mount } from 'spec/helpers/theming';
import { mockStore } from 'spec/fixtures/mockStore';
+import { FormInstance } from 'src/common/components';
+import { NativeFiltersForm } from 'src/dashboard/components/nativeFilters/types';
+import { getDefaultScopeValue } from 'src/dashboard/components/nativeFilters/FilterScope';
describe('ScopingTree', () => {
- const mock = jest.fn();
+ const filterId = '1';
+ const form = {
+ getFieldValue: () => ({
+ [filterId]: {
+ scope: getDefaultScopeValue(),
+ },
+ }),
+ };
const wrapper = mount(
-
+ }
+ />
,
);
it('is valid', () => {
- const mock = () => null;
- expect(React.isValidElement()).toBe(
- true,
- );
+ expect(
+ React.isValidElement(
+ }
+ />,
+ ),
+ ).toBe(true);
});
+
it('renders a tree', () => {
expect(wrapper.find('TreeNode')).toExist();
});
diff --git a/superset-frontend/src/common/components/index.tsx b/superset-frontend/src/common/components/index.tsx
index e193a2a2893..5c66af5d44f 100644
--- a/superset-frontend/src/common/components/index.tsx
+++ b/superset-frontend/src/common/components/index.tsx
@@ -47,6 +47,7 @@ export {
Slider,
Radio,
Row,
+ Space,
Select,
Skeleton,
Switch,
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigForm.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigForm.tsx
index 461250d5d54..ef701fe3dd9 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigForm.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigForm.tsx
@@ -24,7 +24,6 @@ import {
Checkbox,
Form,
Input,
- Radio,
Typography,
} from 'src/common/components';
import { Select } from 'src/components/Select/SupersetStyledSelect';
@@ -34,8 +33,8 @@ import SupersetResourceSelect, {
import { addDangerToast } from 'src/messageToasts/actions';
import { ClientErrorObject } from 'src/utils/getClientErrorObject';
import { ColumnSelect } from './ColumnSelect';
-import ScopingTree from './ScopingTree';
-import { Filter, NativeFiltersForm, Scoping } from './types';
+import { Filter, NativeFiltersForm } from './types';
+import FilterScope from './FilterScope';
type DatasetSelectValue = {
value: number;
@@ -47,10 +46,6 @@ const datasetToSelectOption = (item: any): DatasetSelectValue => ({
label: item.table_name,
});
-const ScopingTreeNote = styled.div`
- margin-bottom: ${({ theme }) => theme.gridUnit * 2}px;
-`;
-
const RemovedContent = styled.div`
display: flex;
flex-direction: column;
@@ -103,9 +98,6 @@ export const FilterConfigForm: React.FC = ({
form,
parentFilters,
}) => {
- const [advancedScopingOpen, setAdvancedScopingOpen] = useState(
- Scoping.all,
- );
const [dataset, setDataset] = useState | undefined>();
const onDatasetSelectError = useCallback(
@@ -119,13 +111,6 @@ export const FilterConfigForm: React.FC = ({
[],
);
- const setFilterScope = useCallback(
- value => {
- form.setFields([{ name: ['filters', filterId, 'scope'], value }]);
- },
- [form, filterId],
- );
-
if (removed) {
return (
@@ -190,14 +175,6 @@ export const FilterConfigForm: React.FC = ({
datasetId={dataset?.value}
/>
-
- {t('Default Value')}}
- initialValue={filterToEdit?.defaultValue}
- >
-
-
{t('Parent Filter')}}
@@ -244,36 +221,11 @@ export const FilterConfigForm: React.FC = ({
>
{t('Required')}
- {t('Scoping')}
-
- {
- setAdvancedScopingOpen(value as Scoping);
- }}
- >
- {t('Apply to all panels')}
-
- {t('Apply to specific panels')}
-
-
-
- <>
-
-
- {advancedScopingOpen === Scoping.specific
- ? t('Only selected panels will be affected by this filter')
- : t(
- 'All panels with this column will be affected by this filter',
- )}
-
-
- {advancedScopingOpen === Scoping.specific && (
-
- )}
- >
+
>
);
};
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal.tsx
index 40a644a0e8a..f93e5425203 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal.tsx
@@ -25,7 +25,6 @@ import { Form } from 'src/common/components';
import { StyledModal } from 'src/common/components/Modal';
import Button from 'src/components/Button';
import { LineEditableTabs } from 'src/common/components/Tabs';
-import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
import { usePrevious } from 'src/common/hooks/usePrevious';
import ErrorBoundary from 'src/components/ErrorBoundary';
import { useFilterConfigMap, useFilterConfiguration } from './state';
@@ -396,10 +395,7 @@ export function FilterConfigModal({
cascadeParentIds: formInputs.parentFilter
? [formInputs.parentFilter.value]
: [],
- scope: {
- rootPath: [DASHBOARD_ROOT_ID],
- excluded: [],
- },
+ scope: formInputs.scope,
inverseSelection: !!formInputs.inverseSelection,
isInstant: !!formInputs.isInstant,
allowsMultipleValues: !!formInputs.allowsMultipleValues,
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterScope.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterScope.tsx
new file mode 100644
index 00000000000..bd8eccb75de
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterScope.tsx
@@ -0,0 +1,105 @@
+/**
+ * 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 { t, styled } from '@superset-ui/core';
+import {
+ Form,
+ Radio,
+ Typography,
+ Space,
+ FormInstance,
+} from '../../../common/components';
+import { Filter, NativeFiltersForm, Scoping } from './types';
+import ScopingTree from './ScopingTree';
+import { DASHBOARD_ROOT_ID } from '../../util/constants';
+import { isScopingAll, setFilterFieldValues, useForceUpdate } from './utils';
+
+type FilterScopeProps = {
+ filterId: string;
+ filterToEdit?: Filter;
+ form: FormInstance;
+};
+
+export const getDefaultScopeValue = () => ({
+ rootPath: [DASHBOARD_ROOT_ID],
+ excluded: [],
+});
+
+const CleanFormItem = styled(Form.Item)`
+ margin-bottom: 0;
+`;
+
+const FilterScope: FC = ({
+ filterId,
+ filterToEdit,
+ form,
+}) => {
+ const formFilter = form.getFieldValue('filters')?.[filterId];
+ const initialScope = filterToEdit?.scope || getDefaultScopeValue();
+
+ const scoping = isScopingAll(initialScope) ? Scoping.all : Scoping.specific;
+
+ const forceUpdate = useForceUpdate();
+
+ return (
+
+
+ {t('Scoping')}
+
+ {
+ if (value === Scoping.all) {
+ setFilterFieldValues(form, filterId, {
+ scope: getDefaultScopeValue(),
+ });
+ }
+ forceUpdate();
+ }}
+ >
+ {t('Apply to all panels')}
+
+ {t('Apply to specific panels')}
+
+
+
+
+ {formFilter?.scoping === Scoping.specific
+ ? t('Only selected panels will be affected by this filter')
+ : t('All panels with this column will be affected by this filter')}
+
+ {formFilter?.scoping === Scoping.specific && (
+
+ )}
+
+ );
+};
+
+export default FilterScope;
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/ScopingTree.tsx b/superset-frontend/src/dashboard/components/nativeFilters/ScopingTree.tsx
index 24b0de73ece..467159c6365 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/ScopingTree.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/ScopingTree.tsx
@@ -17,44 +17,64 @@
* under the License.
*/
-import React, { FC, useState } from 'react';
-import { Tree } from 'src/common/components';
+import React, { FC, useMemo, useState } from 'react';
+import { FormInstance, Tree } from 'src/common/components';
import { useFilterScopeTree } from './state';
import { DASHBOARD_ROOT_ID } from '../../util/constants';
-import { findFilterScope } from './utils';
+import {
+ findFilterScope,
+ getTreeCheckedItems,
+ setFilterFieldValues,
+ useForceUpdate,
+} from './utils';
+import { NativeFiltersForm, Scope } from './types';
type ScopingTreeProps = {
- setFilterScope: Function;
+ form: FormInstance;
+ filterId: string;
+ initialScope: Scope;
};
-const ScopingTree: FC = ({ setFilterScope }) => {
+const ScopingTree: FC = ({
+ form,
+ filterId,
+ initialScope,
+}) => {
const [expandedKeys, setExpandedKeys] = useState([
DASHBOARD_ROOT_ID,
]);
+ const formFilter = form.getFieldValue('filters')[filterId];
+
const { treeData, layout } = useFilterScopeTree();
-
const [autoExpandParent, setAutoExpandParent] = useState(true);
- const [checkedKeys, setCheckedKeys] = useState([]);
- const onExpand = (expandedKeys: string[]) => {
+ const handleExpand = (expandedKeys: string[]) => {
setExpandedKeys(expandedKeys);
setAutoExpandParent(false);
};
-
- const onCheck = (checkedKeys: string[]) => {
- setCheckedKeys(checkedKeys);
- setFilterScope(findFilterScope(checkedKeys, layout));
+ const forceUpdate = useForceUpdate();
+ const handleCheck = (checkedKeys: string[]) => {
+ forceUpdate();
+ setFilterFieldValues(form, filterId, {
+ scope: findFilterScope(checkedKeys, layout),
+ });
};
+ const checkedKeys = useMemo(
+ () =>
+ getTreeCheckedItems({ ...(formFilter.scope || initialScope) }, layout),
+ [formFilter.scope, initialScope, layout],
+ );
+
return (
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/state.ts b/superset-frontend/src/dashboard/components/nativeFilters/state.ts
index 37b7198ceef..a6476fe8b12 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/state.ts
+++ b/superset-frontend/src/dashboard/components/nativeFilters/state.ts
@@ -23,7 +23,10 @@ import { getInitialFilterState } from 'src/dashboard/reducers/nativeFilters';
import { ExtraFormData, t } from '@superset-ui/core';
import { Charts, Layout, RootState } from 'src/dashboard/types';
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
-import { DASHBOARD_ROOT_TYPE } from 'src/dashboard/util/componentTypes';
+import {
+ CHART_TYPE,
+ DASHBOARD_ROOT_TYPE,
+} from 'src/dashboard/util/componentTypes';
import {
Filter,
FilterConfiguration,
@@ -83,14 +86,29 @@ export function useFilterScopeTree(): {
);
const charts = useSelector(({ charts }) => charts);
-
const tree = {
children: [],
key: DASHBOARD_ROOT_ID,
type: DASHBOARD_ROOT_TYPE,
title: t('All Panels'),
};
- buildTree(layout[DASHBOARD_ROOT_ID], tree, layout, charts);
+
+ // We need to get only nodes that have charts as children or grandchildren
+ const validNodes = useMemo(
+ () =>
+ Object.values(layout).reduce((acc, cur) => {
+ if (cur?.type === CHART_TYPE) {
+ return [...new Set([...acc, ...cur?.parents, cur.id])];
+ }
+ return acc;
+ }, []),
+ [layout],
+ );
+
+ useMemo(() => {
+ buildTree(layout[DASHBOARD_ROOT_ID], tree, layout, charts, validNodes);
+ }, [charts, layout, tree]);
+
return { treeData: [tree], layout };
}
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/types.ts b/superset-frontend/src/dashboard/components/nativeFilters/types.ts
index 221c4063d16..311c308cfda 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/types.ts
+++ b/superset-frontend/src/dashboard/components/nativeFilters/types.ts
@@ -23,8 +23,10 @@ export enum Scoping {
specific,
}
+// Using to pass setState React callbacks directly to And components
+export type AntCallback = (value1?: any, value2?: any) => void;
+
interface NativeFiltersFormItem {
- scoping: Scoping;
scope: Scope;
name: string;
dataset: {
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/utils.ts
index e5a3ab8d6bb..7f89aee46a7 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/utils.ts
+++ b/superset-frontend/src/dashboard/components/nativeFilters/utils.ts
@@ -21,9 +21,10 @@ import { Charts, Layout, LayoutItem } from 'src/dashboard/types';
import {
CHART_TYPE,
DASHBOARD_ROOT_TYPE,
- TABS_TYPE,
TAB_TYPE,
} from 'src/dashboard/util/componentTypes';
+import { FormInstance } from 'antd/lib/form';
+import React from 'react';
import {
CascadeFilter,
Filter,
@@ -31,12 +32,15 @@ import {
Scope,
TreeItem,
} from './types';
+import { DASHBOARD_ROOT_ID } from '../../util/constants';
+
+export const useForceUpdate = () => {
+ const [, updateState] = React.useState({});
+ return React.useCallback(() => updateState({}), []);
+};
export const isShowTypeInTree = ({ type, meta }: LayoutItem, charts?: Charts) =>
- (type === TABS_TYPE ||
- type === TAB_TYPE ||
- type === CHART_TYPE ||
- type === DASHBOARD_ROOT_TYPE) &&
+ (type === TAB_TYPE || type === CHART_TYPE || type === DASHBOARD_ROOT_TYPE) &&
(!charts || charts[meta?.chartId]?.formData?.viz_type !== 'filter_box');
export const buildTree = (
@@ -44,9 +48,14 @@ export const buildTree = (
treeItem: TreeItem,
layout: Layout,
charts: Charts,
+ validNodes: string[],
) => {
let itemToPass: TreeItem = treeItem;
- if (isShowTypeInTree(node, charts) && node.type !== DASHBOARD_ROOT_TYPE) {
+ if (
+ isShowTypeInTree(node, charts) &&
+ node.type !== DASHBOARD_ROOT_TYPE &&
+ validNodes.includes(node.id)
+ ) {
const currentTreeItem = {
key: node.id,
title: node.meta.sliceName || node.meta.text || node.id.toString(),
@@ -56,10 +65,52 @@ export const buildTree = (
itemToPass = currentTreeItem;
}
node.children.forEach(child =>
- buildTree(layout[child], itemToPass, layout, charts),
+ buildTree(layout[child], itemToPass, layout, charts, validNodes),
);
};
+const addInvisibleParents = (layout: Layout, item: string) => [
+ ...(layout[item]?.children || []),
+ ...Object.values(layout)
+ .filter(
+ val =>
+ val.parents &&
+ val.parents[val.parents.length - 1] === item &&
+ !isShowTypeInTree(layout[val.parents[val.parents.length - 1]]),
+ )
+ .map(({ id }) => id),
+];
+
+// Generate checked options for Ant tree from redux scope
+const checkTreeItem = (
+ checkedItems: string[],
+ layout: Layout,
+ items: string[],
+ excluded: number[],
+) => {
+ items.forEach(item => {
+ checkTreeItem(
+ checkedItems,
+ layout,
+ addInvisibleParents(layout, item),
+ excluded,
+ );
+ if (
+ layout[item]?.type === CHART_TYPE &&
+ !excluded.includes(layout[item]?.meta.chartId)
+ ) {
+ checkedItems.push(item);
+ }
+ });
+};
+
+export const getTreeCheckedItems = (scope: Scope, layout: Layout) => {
+ const checkedItems: string[] = [];
+ checkTreeItem(checkedItems, layout, [...scope.rootPath], [...scope.excluded]);
+ return [...new Set(checkedItems)];
+};
+
+// Looking for first common parent for selected charts/tabs/tab
export const findFilterScope = (
checkedKeys: string[],
layout: Layout,
@@ -70,11 +121,16 @@ export const findFilterScope = (
excluded: [],
};
}
- const checkedItemParents = checkedKeys.map(key =>
- (layout[key].parents || []).filter(parent =>
- isShowTypeInTree(layout[parent]),
- ),
- );
+
+ // Get arrays of parents for selected charts
+ const checkedItemParents = checkedKeys
+ .filter(item => layout[item]?.type === CHART_TYPE)
+ .map(key => {
+ const parents = [DASHBOARD_ROOT_ID, ...(layout[key]?.parents || [])];
+ return parents.filter(parent => isShowTypeInTree(layout[parent]));
+ });
+ // Sort arrays of parents to get first shortest array of parents,
+ // that means on it's level of parents located common parent, from this place parents start be different
checkedItemParents.sort((p1, p2) => p1.length - p2.length);
const rootPath = checkedItemParents.map(
parents => parents[checkedItemParents[0].length - 1],
@@ -83,11 +139,14 @@ export const findFilterScope = (
const excluded: number[] = [];
const isExcluded = (parent: string, item: string) =>
rootPath.includes(parent) && !checkedKeys.includes(item);
-
+ // looking for charts to be excluded: iterate over all charts
+ // and looking for charts that have one of their parents in `rootPath` and not in selected items
Object.entries(layout).forEach(([key, value]) => {
if (
value.type === CHART_TYPE &&
- value.parents?.find(parent => isExcluded(parent, key))
+ [DASHBOARD_ROOT_ID, ...value.parents]?.find(parent =>
+ isExcluded(parent, key),
+ )
) {
excluded.push(value.meta.chartId);
}
@@ -178,3 +237,23 @@ export function buildCascadeFiltersTree(filters: Filter[]): CascadeFilter[] {
.filter(filter => !filter.cascadeParentIds?.length)
.map(getCascadeFilter);
}
+
+export const setFilterFieldValues = (
+ form: FormInstance,
+ filterId: string,
+ values: object,
+) => {
+ const formFilters = form.getFieldValue('filters');
+ form.setFieldsValue({
+ filters: {
+ ...formFilters,
+ [filterId]: {
+ ...formFilters[filterId],
+ ...values,
+ },
+ },
+ });
+};
+
+export const isScopingAll = (scope: Scope) =>
+ !scope || (scope.rootPath[0] === DASHBOARD_ROOT_ID && !scope.excluded.length);
diff --git a/superset-frontend/src/dashboard/reducers/types.ts b/superset-frontend/src/dashboard/reducers/types.ts
new file mode 100644
index 00000000000..f3f7c214574
--- /dev/null
+++ b/superset-frontend/src/dashboard/reducers/types.ts
@@ -0,0 +1,66 @@
+/**
+ * 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 componentTypes from 'src/dashboard/util/componentTypes';
+
+export enum Scoping {
+ all,
+ specific,
+}
+
+/** Chart state of redux */
+export type Chart = {
+ id: number;
+ slice_id: string;
+ formData: {
+ viz_type: string;
+ };
+};
+
+/** Root state of redux */
+export type RootState = {
+ charts: { [key: string]: Chart };
+ dashboardLayout: { present: { [key: string]: LayoutItem } };
+ dashboardFilters: {};
+};
+
+/** State of dashboardLayout in redux */
+export type Layout = { [key: string]: LayoutItem };
+
+/** State of charts in redux */
+export type Charts = { [key: number]: Chart };
+
+type ComponentTypesKeys = keyof typeof componentTypes;
+export type ComponentType = typeof componentTypes[ComponentTypesKeys];
+
+/** State of dashboardLayout item in redux */
+export type LayoutItem = {
+ children: string[];
+ parents: string[];
+ type: ComponentType;
+ id: string;
+ meta: {
+ chartId: number;
+ height: number;
+ sliceName?: string;
+ text?: string;
+ uuid: string;
+ width: number;
+ };
+};