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 ( + + + ); +}; + +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; + }; +};