diff --git a/superset/assets/spec/javascripts/dashboard/util/getFilterScopeFromNodesTree_spec.js b/superset/assets/spec/javascripts/dashboard/util/getFilterScopeFromNodesTree_spec.js new file mode 100644 index 000000000000..067e193a712c --- /dev/null +++ b/superset/assets/spec/javascripts/dashboard/util/getFilterScopeFromNodesTree_spec.js @@ -0,0 +1,216 @@ +/** + * 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 getFilterScopeFromNodesTree from '../../../../src/dashboard/util/getFilterScopeFromNodesTree'; + +describe('getFilterScopeFromNodesTree', () => { + it('should return empty scope', () => { + const nodes = []; + expect( + getFilterScopeFromNodesTree({ + filterKey: '107_region', + nodes, + checkedChartIds: [], + }), + ).toEqual({}); + }); + + it('should return scope for simple grid', () => { + const nodes = [ + { + label: 'All dashboard', + type: 'ROOT', + value: 'ROOT_ID', + children: [ + { + value: 104, + label: 'Life Expectancy VS Rural %', + type: 'CHART', + }, + { value: 105, label: 'Rural Breakdown', type: 'CHART' }, + { + value: 106, + label: "World's Pop Growth", + type: 'CHART', + }, + { + label: 'Time Filter', + showCheckbox: false, + type: 'CHART', + value: 108, + }, + ], + }, + ]; + const checkedChartIds = [104, 106]; + expect( + getFilterScopeFromNodesTree({ + filterKey: '108___time_range', + nodes, + checkedChartIds, + }), + ).toEqual({ + scope: ['ROOT_ID'], + immune: [105], + }); + }); + + describe('should return scope for tabbed dashboard', () => { + const nodes = [ + { + label: 'All dashboard', + type: 'ROOT', + value: 'ROOT_ID', + children: [ + { + label: 'Tab 1', + type: 'TAB', + value: 'TAB-Rb5aaqKWgG', + children: [ + { + label: 'Geo Filters', + showCheckbox: false, + type: 'CHART', + value: 107, + }, + { + label: "World's Pop Growth", + showCheckbox: true, + type: 'CHART', + value: 106, + }, + ], + }, + { + label: 'Tab 2', + type: 'TAB', + value: 'TAB-w5Fp904Rs', + children: [ + { + label: 'Time Filter', + showCheckbox: true, + type: 'CHART', + value: 108, + }, + { + label: 'Life Expectancy VS Rural %', + showCheckbox: true, + type: 'CHART', + value: 104, + }, + { + label: 'Row Tab 1', + type: 'TAB', + value: 'TAB-E4mJaZ-uQM', + children: [ + { + value: 105, + label: 'Rural Breakdown', + type: 'CHART', + showCheckbox: true, + }, + { + value: 103, + label: '% Rural', + type: 'CHART', + showCheckbox: true, + }, + ], + }, + { + value: 'TAB-rLYu-Cryu', + label: 'New Tab', + type: 'TAB', + children: [ + { + value: 102, + label: 'Most Populated Countries', + type: 'CHART', + showCheckbox: true, + }, + { + value: 101, + label: "World's Population", + type: 'CHART', + showCheckbox: true, + }, + ], + }, + ], + }, + ], + }, + ]; + + it('root level tab scope', () => { + const checkedChartIds = [106]; + expect( + getFilterScopeFromNodesTree({ + filterKey: '107_region', + nodes, + checkedChartIds, + }), + ).toEqual({ + scope: ['TAB-Rb5aaqKWgG'], + immune: [], + }); + }); + + it('global scope', () => { + const checkedChartIds = [106, 104, 101, 102, 103, 105]; + expect( + getFilterScopeFromNodesTree({ + filterKey: '107_country_name', + nodes, + checkedChartIds, + }), + ).toEqual({ + scope: ['ROOT_ID'], + immune: [108], + }); + }); + + it('row level tab scope', () => { + const checkedChartIds = [103, 105]; + expect( + getFilterScopeFromNodesTree({ + filterKey: '108___time_range', + nodes, + checkedChartIds, + }), + ).toEqual({ + scope: ['TAB-E4mJaZ-uQM'], + immune: [], + }); + }); + + it('mixed row level and root level scope', () => { + const checkedChartIds = [103, 105, 106]; + expect( + getFilterScopeFromNodesTree({ + filterKey: '107_region', + nodes, + checkedChartIds, + }), + ).toEqual({ + scope: ['TAB-Rb5aaqKWgG', 'TAB-E4mJaZ-uQM'], + immune: [], + }); + }); + }); +}); diff --git a/superset/assets/src/dashboard/util/getFilterScopeFromNodesTree.js b/superset/assets/src/dashboard/util/getFilterScopeFromNodesTree.js new file mode 100644 index 000000000000..2eb97a411709 --- /dev/null +++ b/superset/assets/src/dashboard/util/getFilterScopeFromNodesTree.js @@ -0,0 +1,126 @@ +/** + * 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 { flow, keyBy, mapValues } from 'lodash/fp'; +import { flatMap, isEmpty } from 'lodash'; + +import { CHART_TYPE, TAB_TYPE } from './componentTypes'; +import { getChartIdAndColumnFromFilterKey } from './getDashboardFilterKey'; + +function getTabChildrenScope({ + tabScopes, + parentNodeValue, + forceAggregate = false, +}) { + // if all sub-tabs are in scope, or forceAggregate = true + // aggregate scope to parentNodeValue + if ( + forceAggregate || + Object.entries(tabScopes).every( + ([key, { scope }]) => scope && scope.length && key === scope[0], + ) + ) { + return { + scope: [parentNodeValue], + immune: flatMap(Object.values(tabScopes), ({ immune }) => immune), + }; + } + + const componentsInScope = Object.values(tabScopes).filter( + ({ scope }) => scope && scope.length, + ); + return { + scope: flatMap(componentsInScope, ({ scope }) => scope), + immune: flatMap(componentsInScope, ({ immune }) => immune), + }; +} + +function traverse({ currentNode = {}, filterId, checkedChartIds = [] }) { + if (!currentNode) { + return {}; + } + + const { value: currentValue, children } = currentNode; + const chartChildren = children.filter(({ type }) => type === CHART_TYPE); + const tabChildren = children.filter(({ type }) => type === TAB_TYPE); + + const chartsImmune = chartChildren + .filter( + ({ value }) => filterId !== value && !checkedChartIds.includes(value), + ) + .map(({ value }) => value); + const tabScopes = flow( + keyBy(child => child.value), + mapValues(child => + traverse({ + currentNode: child, + filterId, + checkedChartIds, + }), + ), + )(tabChildren); + + // if any chart type child is in scope, + // no matter has tab children or not, current node should be scope + if ( + !isEmpty(chartChildren) && + chartChildren.some(({ value }) => checkedChartIds.includes(value)) + ) { + if (isEmpty(tabChildren)) { + return { scope: [currentValue], immune: chartsImmune }; + } + + const { scope, immune } = getTabChildrenScope({ + tabScopes, + parentNodeValue: currentValue, + forceAggregate: true, + }); + return { + scope, + immune: chartsImmune.concat(immune), + }; + } + + // has tab children but only some sub-tab in scope + if (tabChildren.length) { + return getTabChildrenScope({ tabScopes, parentNodeValue: currentValue }); + } + + // no tab children and no chart children in scope + return { + scope: [], + immune: chartsImmune, + }; +} + +export default function getFilterScopeFromNodesTree({ + filterKey, + nodes = [], + checkedChartIds = [], +}) { + if (nodes.length) { + const { chartId } = getChartIdAndColumnFromFilterKey(filterKey); + return traverse({ + currentNode: nodes[0], + filterId: chartId, + checkedChartIds, + }); + } + + return {}; +}