diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard_list/filter.test.ts b/superset-frontend/cypress-base/cypress/integration/dashboard_list/filter.test.ts index 61bdd7c6d92..caff4c03f67 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard_list/filter.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/dashboard_list/filter.test.ts @@ -61,7 +61,6 @@ describe('dashboard filters card view', () => { cy.get('.Select__menu').contains('Published').click({ timeout: 5000 }); cy.get('[data-test="styled-card"]').should('have.length', 2); cy.get('[data-test="styled-card"]') - .first() .contains('USA Births Names') .should('be.visible'); cy.get('.Select__control').eq(1).click(); @@ -107,13 +106,12 @@ describe('dashboard filters list view', () => { cy.get('[data-test="table-row"]').should('not.exist'); }); - xit('should filter by published correctly', () => { + it('should filter by published correctly', () => { // filter by published cy.get('.Select__control').eq(2).click(); cy.get('.Select__menu').contains('Published').click(); cy.get('[data-test="table-row"]').should('have.length', 2); cy.get('[data-test="table-row"]') - .first() .contains('USA Births Names') .should('be.visible'); cy.get('.Select__control').eq(2).click(); diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 42509818315..2af60b3d646 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -102,6 +102,7 @@ "react-markdown": "^4.3.1", "react-redux": "^7.2.0", "react-resize-detector": "^6.0.1-rc.1", + "react-reverse-portal": "^2.0.1", "react-router-dom": "^5.1.2", "react-search-input": "^0.11.3", "react-select": "^3.1.0", @@ -44737,6 +44738,15 @@ "resize-observer-polyfill": "^1.5.1" } }, + "node_modules/react-reverse-portal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/react-reverse-portal/-/react-reverse-portal-2.0.1.tgz", + "integrity": "sha512-sj/D9nSHspqV8i8hWkTSZ5Ohnrqk2A5fkDKw4Xe/zV4OfF1UYwmbzrxLdmNRdKkWgQwnXIxaa2E3FC7QYdZAeA==", + "peerDependencies": { + "react": "^16.0.0", + "react-dom": "^16.0.0" + } + }, "node_modules/react-router": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.1.2.tgz", @@ -92328,6 +92338,12 @@ "resize-observer-polyfill": "^1.5.1" } }, + "react-reverse-portal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/react-reverse-portal/-/react-reverse-portal-2.0.1.tgz", + "integrity": "sha512-sj/D9nSHspqV8i8hWkTSZ5Ohnrqk2A5fkDKw4Xe/zV4OfF1UYwmbzrxLdmNRdKkWgQwnXIxaa2E3FC7QYdZAeA==", + "requires": {} + }, "react-router": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.1.2.tgz", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index db48f72f0a9..4a53a359708 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -154,6 +154,7 @@ "react-markdown": "^4.3.1", "react-redux": "^7.2.0", "react-resize-detector": "^6.0.1-rc.1", + "react-reverse-portal": "^2.0.1", "react-router-dom": "^5.1.2", "react-search-input": "^0.11.3", "react-select": "^3.1.0", diff --git a/superset-frontend/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx b/superset-frontend/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx index 66eaf20fdbb..20f63a6fcab 100644 --- a/superset-frontend/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx +++ b/superset-frontend/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx @@ -50,16 +50,21 @@ jest.mock('src/dashboard/actions/dashboardState'); describe('DashboardBuilder', () => { let favStarStub; + let focusedTabStub; beforeAll(() => { // this is invoked on mount, so we stub it instead of making a request favStarStub = sinon .stub(dashboardStateActions, 'fetchFaveStar') .returns({ type: 'mock-action' }); + focusedTabStub = sinon + .stub(dashboardStateActions, 'setLastFocusedTab') + .returns({ type: 'mock-action' }); }); afterAll(() => { favStarStub.restore(); + focusedTabStub.restore(); }); function setup(overrideState = {}, overrideStore) { diff --git a/superset-frontend/spec/javascripts/dashboard/components/gridComponents/ChartHolder_spec.jsx b/superset-frontend/spec/javascripts/dashboard/components/gridComponents/ChartHolder_spec.jsx index efc1d8ca91a..2a1d0bc98b2 100644 --- a/superset-frontend/spec/javascripts/dashboard/components/gridComponents/ChartHolder_spec.jsx +++ b/superset-frontend/spec/javascripts/dashboard/components/gridComponents/ChartHolder_spec.jsx @@ -36,6 +36,7 @@ import { sliceId } from 'spec/fixtures/mockChartQueries'; import dashboardInfo from 'spec/fixtures/mockDashboardInfo'; import { dashboardLayout as mockLayout } from 'spec/fixtures/mockDashboardLayout'; import { sliceEntitiesForChart } from 'spec/fixtures/mockSliceEntities'; +import { nativeFiltersInfo } from '../../fixtures/mockNativeFilters'; describe('ChartHolder', () => { const props = { @@ -55,6 +56,7 @@ describe('ChartHolder', () => { handleComponentDrop() {}, updateComponents() {}, deleteComponent() {}, + nativeFilters: nativeFiltersInfo.filters, }; function setup(overrideProps) { diff --git a/superset-frontend/spec/javascripts/dashboard/components/gridComponents/Tabs_spec.jsx b/superset-frontend/spec/javascripts/dashboard/components/gridComponents/Tabs_spec.jsx index 4b2dbd9a531..2f15359c7f1 100644 --- a/superset-frontend/spec/javascripts/dashboard/components/gridComponents/Tabs_spec.jsx +++ b/superset-frontend/spec/javascripts/dashboard/components/gridComponents/Tabs_spec.jsx @@ -33,8 +33,10 @@ import HoverMenu from 'src/dashboard/components/menu/HoverMenu'; import DragDroppable from 'src/dashboard/components/dnd/DragDroppable'; import Tabs from 'src/dashboard/components/gridComponents/Tabs'; import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants'; +import emptyDashboardLayout from 'src/dashboard/fixtures/emptyDashboardLayout'; import { dashboardLayoutWithTabs } from 'spec/fixtures/mockDashboardLayout'; import { mockStoreWithTabs } from 'spec/fixtures/mockStore'; +import { nativeFilters } from 'spec/fixtures/mockNativeFilters'; describe('Tabs', () => { fetchMock.post('glob:*/r/shortner/', {}); @@ -59,6 +61,8 @@ describe('Tabs', () => { deleteComponent() {}, updateComponents() {}, logEvent() {}, + dashboardLayout: emptyDashboardLayout, + nativeFilters: nativeFilters.filters, }; function setup(overrideProps) { diff --git a/superset-frontend/src/dashboard/actions/dashboardState.js b/superset-frontend/src/dashboard/actions/dashboardState.js index ac2ead0d743..d0b39e2ff14 100644 --- a/superset-frontend/src/dashboard/actions/dashboardState.js +++ b/superset-frontend/src/dashboard/actions/dashboardState.js @@ -344,6 +344,11 @@ export function setDirectPathToChild(path) { return { type: SET_DIRECT_PATH, path }; } +export const SET_LAST_FOCUSED_TAB = 'SET_LAST_FOCUSED_TAB'; +export function setLastFocusedTab(tabId) { + return { type: SET_LAST_FOCUSED_TAB, tabId }; +} + export const SET_FOCUSED_FILTER_FIELD = 'SET_FOCUSED_FILTER_FIELD'; export function setFocusedFilterField(chartId, column) { return { type: SET_FOCUSED_FILTER_FIELD, chartId, column }; diff --git a/superset-frontend/src/dashboard/actions/hydrate.js b/superset-frontend/src/dashboard/actions/hydrate.js index d86ae8c78e8..62751033ad6 100644 --- a/superset-frontend/src/dashboard/actions/hydrate.js +++ b/superset-frontend/src/dashboard/actions/hydrate.js @@ -377,6 +377,7 @@ export const hydrateDashboard = (dashboardData, chartData, datasourcesData) => ( hasUnsavedChanges: false, maxUndoHistoryExceeded: false, lastModifiedTime: dashboardData.changed_on, + lastFocusedTabId: null, }, dashboardLayout, }, diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx index 9c58d853e4c..00c5d73ae5b 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx @@ -21,7 +21,7 @@ import { ParentSize } from '@vx/responsive'; import Tabs from 'src/components/Tabs'; import React, { FC, useEffect, useState } from 'react'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import DashboardGrid from 'src/dashboard/containers/DashboardGrid'; import getLeafComponentIdFromPath from 'src/dashboard/util/getLeafComponentIdFromPath'; import { DashboardLayout, LayoutItem, RootState } from 'src/dashboard/types'; @@ -30,6 +30,10 @@ import { DASHBOARD_ROOT_DEPTH, } from 'src/dashboard/util/constants'; import { getRootLevelTabIndex } from './utils'; +import { Filters } from '../../reducers/types'; +import { getChartIdsInFilterScope } from '../../util/activeDashboardFilters'; +import { findTabsWithChartsInScope } from '../nativeFilters/utils'; +import { setFilterConfiguration } from '../../actions/nativeFilters'; type DashboardContainerProps = { topLevelTabs?: LayoutItem; @@ -39,6 +43,9 @@ const DashboardContainer: FC = ({ topLevelTabs }) => { const dashboardLayout = useSelector( state => state.dashboardLayout.present, ); + const nativeFilters = useSelector( + state => state.nativeFilters.filters, + ); const directPathToChild = useSelector( state => state.dashboardState.directPathToChild, ); @@ -46,10 +53,37 @@ const DashboardContainer: FC = ({ topLevelTabs }) => { getRootLevelTabIndex(dashboardLayout, directPathToChild), ); + const dispatch = useDispatch(); + useEffect(() => { setTabIndex(getRootLevelTabIndex(dashboardLayout, directPathToChild)); }, [getLeafComponentIdFromPath(directPathToChild)]); + // recalculate charts and tabs in scopes of native filters only when a scope or dashboard layout changes + const nativeFiltersValues = Object.values(nativeFilters); + const scopes = nativeFiltersValues.map(filter => filter.scope); + useEffect(() => { + nativeFiltersValues.forEach(filter => { + const filterScope = filter.scope; + const chartsInScope = getChartIdsInFilterScope({ + filterScope: { + scope: filterScope.rootPath, + // @ts-ignore + immune: filterScope.excluded, + }, + }); + const tabsInScope = findTabsWithChartsInScope( + dashboardLayout, + chartsInScope, + ); + Object.assign(filter, { + chartsInScope, + tabsInScope: Array.from(tabsInScope), + }); + }); + dispatch(setFilterConfiguration(nativeFiltersValues)); + }, [JSON.stringify(scopes), JSON.stringify(dashboardLayout)]); + const childIds: string[] = topLevelTabs ? topLevelTabs.children : [DASHBOARD_GRID_ID]; diff --git a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.jsx b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.jsx index f4868d0e6a0..102acae84aa 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.jsx @@ -85,37 +85,46 @@ const defaultProps = { * If ChartHolder were a function component, this could be implemented as a hook instead. */ const FilterFocusHighlight = React.forwardRef( - ({ chartId, focusedFilterScope, ...otherProps }, ref) => { + ({ chartId, focusedFilterScope, nativeFilters, ...otherProps }, ref) => { const theme = useTheme(); - if (!focusedFilterScope) return
; + const focusedNativeFilterId = nativeFilters.focusedFilterId; + if (!(focusedFilterScope || focusedNativeFilterId)) + return
; // we use local styles here instead of a conditionally-applied class, // because adding any conditional class to this container // causes performance issues in Chrome. // default to the "de-emphasized" state - let styles = { opacity: 0.3, pointerEvents: 'none' }; + const unfocusedChartStyles = { opacity: 0.3, pointerEvents: 'none' }; + const focusedChartStyles = { + borderColor: theme.colors.primary.light2, + opacity: 1, + boxShadow: `0px 0px ${theme.gridUnit * 2}px ${ + theme.colors.primary.light2 + }`, + pointerEvents: 'auto', + }; - if ( + if (focusedNativeFilterId) { + if ( + nativeFilters.filters[focusedNativeFilterId].chartsInScope.includes( + chartId, + ) + ) { + return
; + } + } else if ( chartId === focusedFilterScope.chartId || getChartIdsInFilterScope({ filterScope: focusedFilterScope.scope, }).includes(chartId) ) { - // apply the "highlighted" state if this chart - // contains a filter being focused, or is in scope of a focused filter. - styles = { - borderColor: theme.colors.primary.light2, - opacity: 1, - boxShadow: `0px 0px ${theme.gridUnit * 2}px ${ - theme.colors.primary.light2 - }`, - pointerEvents: 'auto', - }; + return
; } // inline styles are used here due to a performance issue when adding/changing a class, which causes a reflow - return
; + return
; }, ); @@ -233,6 +242,7 @@ class ChartHolder extends React.Component { isComponentVisible, dashboardId, focusedFilterScope, + nativeFilters, } = this.props; // inherit the size of parent columns @@ -291,6 +301,7 @@ class ChartHolder extends React.Component { { editMode: false, isComponentVisible: true, dashboardId: 123, + nativeFilters: nativeFiltersInfo.filters, }; const renderWrapper = (props = defaultProps, state = mockState) => diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx b/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx index bdf60f8f423..c41abf83a4b 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx @@ -31,40 +31,13 @@ import findTabIndexByComponentId from '../../util/findTabIndexByComponentId'; import getDirectPathToTabIndex from '../../util/getDirectPathToTabIndex'; import getLeafComponentIdFromPath from '../../util/getLeafComponentIdFromPath'; import { componentShape } from '../../util/propShapes'; -import { NEW_TAB_ID, DASHBOARD_ROOT_ID } from '../../util/constants'; +import { + NEW_TAB_ID, + DASHBOARD_ROOT_ID, + DASHBOARD_GRID_ID, +} from '../../util/constants'; import { RENDER_TAB, RENDER_TAB_CONTENT } from './Tab'; -import { CHART_TYPE, TAB_TYPE } from '../../util/componentTypes'; -import { getChartIdsInFilterScope } from '../../util/activeDashboardFilters'; - -const findTabsWithChartsInScope = ( - dashboardLayout, - chartsInScope, - childId, - tabId, - tabsToHighlight, -) => { - if ( - dashboardLayout[childId].type === CHART_TYPE && - chartsInScope.includes(dashboardLayout[childId].meta.chartId) - ) { - tabsToHighlight.add(tabId); - } - if ( - dashboardLayout[childId].children.length === 0 || - (dashboardLayout[childId].type === TAB_TYPE && tabsToHighlight.has(childId)) - ) { - return; - } - dashboardLayout[childId].children.forEach(subChildId => - findTabsWithChartsInScope( - dashboardLayout, - chartsInScope, - subChildId, - tabId, - tabsToHighlight, - ), - ); -}; +import { TAB_TYPE } from '../../util/componentTypes'; const propTypes = { id: PropTypes.string.isRequired, @@ -299,29 +272,27 @@ class Tabs extends React.PureComponent { renderHoverMenu, isComponentVisible: isCurrentTabVisible, editMode, - focusedFilterScope, + nativeFilters, dashboardLayout, + lastFocusedTabId, + setLastFocusedTab, } = this.props; const { children: tabIds } = tabsComponent; const { tabIndex: selectedTabIndex, activeKey } = this.state; - const tabsToHighlight = new Set(); - if (focusedFilterScope) { - const chartsInScope = getChartIdsInFilterScope({ - filterScope: focusedFilterScope.scope, - }); - tabIds.forEach(tabId => { - if (!tabsToHighlight.has(tabId)) { - findTabsWithChartsInScope( - dashboardLayout, - chartsInScope, - tabId, - tabId, - tabsToHighlight, - ); - } - }); + // On dashboards with top level tabs, set initial focus to the active top level tab + const dashboardRoot = dashboardLayout[DASHBOARD_ROOT_ID]; + const rootChildId = dashboardRoot.children[0]; + const isTopLevelTabs = rootChildId !== DASHBOARD_GRID_ID; + if (isTopLevelTabs && !lastFocusedTabId) { + setLastFocusedTab(activeKey); + } + + let tabsToHighlight; + if (nativeFilters.focusedFilterId) { + tabsToHighlight = + nativeFilters.filters[nativeFilters.focusedFilterId].tabsInScope; } return ( {tabIds.map((tabId, tabIndex) => ( } diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tabs.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/Tabs.test.tsx index f35dcd4df10..49ec405f25b 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Tabs.test.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Tabs.test.tsx @@ -20,11 +20,12 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { render, screen, waitFor } from 'spec/helpers/testing-library'; +import { nativeFiltersInfo } from 'spec/javascripts/dashboard/fixtures/mockNativeFilters'; import DashboardComponent from 'src/dashboard/containers/DashboardComponent'; import DragDroppable from 'src/dashboard/components/dnd/DragDroppable'; import DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton'; import getLeafComponentIdFromPath from 'src/dashboard/util/getLeafComponentIdFromPath'; - +import emptyDashboardLayout from 'src/dashboard/fixtures/emptyDashboardLayout'; import Tabs from './Tabs'; jest.mock('src/dashboard/containers/DashboardComponent', () => @@ -110,6 +111,8 @@ const createProps = () => ({ onChangeTab: jest.fn(), deleteComponent: jest.fn(), updateComponents: jest.fn(), + dashboardLayout: emptyDashboardLayout, + nativeFilters: nativeFiltersInfo.filters, }); beforeEach(() => { diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx index 7b630f8ff6f..ab8233c1fc0 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx @@ -17,12 +17,20 @@ * under the License. */ import React, { FC, useMemo, useState } from 'react'; +import { DataMask, styled, t } from '@superset-ui/core'; +import { css } from '@emotion/react'; +import { useSelector } from 'react-redux'; +import * as portals from 'react-reverse-portal'; import { DataMaskState } from 'src/dataMask/types'; -import { DataMask, styled } from '@superset-ui/core'; +import { Collapse } from 'src/common/components'; +import { TAB_TYPE } from 'src/dashboard/util/componentTypes'; +import { RootState } from 'src/dashboard/types'; import CascadePopover from '../CascadeFilters/CascadePopover'; import { buildCascadeFiltersTree } from './utils'; import { useFilters } from '../state'; import { Filter } from '../../types'; +import { CascadeFilter } from '../CascadeFilters/types'; +import { useDashboardLayout } from '../../state'; const Wrapper = styled.div` padding: ${({ theme }) => theme.gridUnit * 4}px; @@ -44,7 +52,18 @@ const FilterControls: FC = ({ }) => { const [visiblePopoverId, setVisiblePopoverId] = useState(null); const filters = useFilters(); + const dashboardLayout = useDashboardLayout(); + const lastFocusedTabId = useSelector( + state => state.dashboardState?.lastFocusedTabId, + ); const filterValues = Object.values(filters); + const portalNodes = React.useMemo(() => { + const nodes = new Array(filterValues.length); + for (let i = 0; i < filterValues.length; i += 1) { + nodes[i] = portals.createHtmlPortalNode(); + } + return nodes; + }, [filterValues.length]); const cascadeFilters = useMemo(() => { const filtersWithValue = filterValues.map(filter => ({ @@ -54,21 +73,86 @@ const FilterControls: FC = ({ return buildCascadeFiltersTree(filtersWithValue); }, [filterValues, dataMaskSelected]); + let filtersInScope: CascadeFilter[] = []; + const filtersOutOfScope: CascadeFilter[] = []; + const dashboardHasTabs = Object.values(dashboardLayout).some( + element => element.type === TAB_TYPE, + ); + const showCollapsePanel = dashboardHasTabs && cascadeFilters.length > 0; + if (!lastFocusedTabId || !dashboardHasTabs) { + filtersInScope = cascadeFilters; + } else { + cascadeFilters.forEach((filter, index) => { + if (cascadeFilters[index].tabsInScope?.includes(lastFocusedTabId)) { + filtersInScope.push(filter); + } else { + filtersOutOfScope.push(filter); + } + }); + } + return ( - {cascadeFilters.map(filter => ( - - setVisiblePopoverId(visible ? filter.id : null) - } - filter={filter} - onFilterSelectionChange={onFilterSelectionChange} - directPathToChild={directPathToChild} - /> + {portalNodes.map((node, index) => ( + + + setVisiblePopoverId(visible ? cascadeFilters[index].id : null) + } + filter={cascadeFilters[index]} + onFilterSelectionChange={onFilterSelectionChange} + directPathToChild={directPathToChild} + /> + ))} + {filtersInScope.map(filter => { + const index = cascadeFilters.findIndex(f => f.id === filter.id); + return ; + })} + {showCollapsePanel && ( + css` + &.ant-collapse { + margin-top: ${filtersInScope.length > 0 + ? theme.gridUnit * 6 + : 0}px; + & > .ant-collapse-item { + & > .ant-collapse-header { + padding-left: 0; + padding-bottom: ${theme.gridUnit * 2}px; + + & > .ant-collapse-arrow { + right: ${theme.gridUnit}px; + } + } + + & .ant-collapse-content-box { + padding: ${theme.gridUnit * 4}px 0 0; + } + } + } + `} + > + + {filtersOutOfScope.map(filter => { + const index = cascadeFilters.findIndex(f => f.id === filter.id); + return ; + })} + + + )} ); }; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/state.ts b/superset-frontend/src/dashboard/components/nativeFilters/state.ts index 83003d67c9e..027bf2ac968 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/state.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/state.ts @@ -19,6 +19,7 @@ import { useSelector } from 'react-redux'; import { useMemo } from 'react'; import { Filter, FilterConfiguration } from './types'; +import { DashboardLayout } from '../../types'; const defaultFilterConfiguration: Filter[] = []; @@ -45,3 +46,9 @@ export function useFilterConfigMap() { [filterConfig], ); } + +export function useDashboardLayout() { + return useSelector( + state => state.dashboardLayout?.present, + ); +} diff --git a/superset-frontend/src/dashboard/components/nativeFilters/types.ts b/superset-frontend/src/dashboard/components/nativeFilters/types.ts index 07e347c437b..ac772dcd734 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/types.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/types.ts @@ -56,6 +56,8 @@ export interface Filter { sortMetric?: string | null; adhoc_filters?: AdhocFilter[]; time_range?: string; + tabsInScope?: string[]; + chartsInScope?: number[]; } export type FilterConfiguration = Filter[]; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/utils.ts index d85684ca091..24264cb4e72 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/utils.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/utils.ts @@ -26,12 +26,14 @@ import { AdhocFilter, FeatureFlag, } from '@superset-ui/core'; -import { Charts } from 'src/dashboard/types'; +import { Charts, DashboardLayout } from 'src/dashboard/types'; import { RefObject } from 'react'; import { DataMaskStateWithId } from 'src/dataMask/types'; import extractUrlParams from 'src/dashboard/util/extractUrlParams'; import { isFeatureEnabled } from 'src/featureFlags'; import { Filter } from './types'; +import { CHART_TYPE, TAB_TYPE } from '../../util/componentTypes'; +import { DASHBOARD_GRID_ID, DASHBOARD_ROOT_ID } from '../../util/constants'; export const getFormData = ({ datasetId, @@ -142,3 +144,73 @@ export function nativeFilterGate(behaviors: Behavior[]): boolean { behaviors.includes(Behavior.INTERACTIVE_CHART)) ); } + +const isComponentATab = ( + dashboardLayout: DashboardLayout, + componentId: string, +) => dashboardLayout[componentId].type === TAB_TYPE; + +const findTabsWithChartsInScopeHelper = ( + dashboardLayout: DashboardLayout, + chartsInScope: number[], + componentId: string, + tabIds: string[], + tabsToHighlight: Set, +) => { + if ( + dashboardLayout[componentId].type === CHART_TYPE && + chartsInScope.includes(dashboardLayout[componentId].meta.chartId) + ) { + tabIds.forEach(tabsToHighlight.add, tabsToHighlight); + } + if ( + dashboardLayout[componentId].children.length === 0 || + (isComponentATab(dashboardLayout, componentId) && + tabsToHighlight.has(componentId)) + ) { + return; + } + dashboardLayout[componentId].children.forEach(childId => + findTabsWithChartsInScopeHelper( + dashboardLayout, + chartsInScope, + childId, + isComponentATab(dashboardLayout, childId) ? [...tabIds, childId] : tabIds, + tabsToHighlight, + ), + ); +}; + +export const findTabsWithChartsInScope = ( + dashboardLayout: DashboardLayout, + chartsInScope: number[], +) => { + const dashboardRoot = dashboardLayout[DASHBOARD_ROOT_ID]; + const rootChildId = dashboardRoot.children[0]; + const hasTopLevelTabs = rootChildId !== DASHBOARD_GRID_ID; + const tabsInScope = new Set(); + if (hasTopLevelTabs) { + dashboardLayout[rootChildId].children?.forEach(tabId => + findTabsWithChartsInScopeHelper( + dashboardLayout, + chartsInScope, + tabId, + [tabId], + tabsInScope, + ), + ); + } else { + Object.values(dashboardLayout) + .filter(element => element.type === TAB_TYPE) + .forEach(element => + findTabsWithChartsInScopeHelper( + dashboardLayout, + chartsInScope, + element.id, + [element.id], + tabsInScope, + ), + ); + } + return tabsInScope; +}; diff --git a/superset-frontend/src/dashboard/containers/DashboardComponent.jsx b/superset-frontend/src/dashboard/containers/DashboardComponent.jsx index 42f408cd0d9..387e67c9262 100644 --- a/superset-frontend/src/dashboard/containers/DashboardComponent.jsx +++ b/superset-frontend/src/dashboard/containers/DashboardComponent.jsx @@ -35,7 +35,10 @@ import { updateComponents, handleComponentDrop, } from '../actions/dashboardLayout'; -import { setDirectPathToChild } from '../actions/dashboardState'; +import { + setDirectPathToChild, + setLastFocusedTab, +} from '../actions/dashboardState'; const propTypes = { id: PropTypes.string, @@ -79,19 +82,6 @@ function selectFocusedFilterScope(dashboardState, dashboardFilters) { }; } -function selectFocusedNativeFilterScope(nativeFilters) { - if (!nativeFilters.focusedFilterId) return null; - const id = nativeFilters.focusedFilterId; - const focusedFilterScope = nativeFilters.filters[id].scope; - return { - chartId: id, - scope: { - scope: focusedFilterScope.rootPath, - immune: focusedFilterScope.excluded, - }, - }; -} - function mapStateToProps( { dashboardLayout: undoableLayout, @@ -114,11 +104,14 @@ function mapStateToProps( redoLength: undoableLayout.future.length, filters: getActiveFilters(), directPathToChild: dashboardState.directPathToChild, + lastFocusedTabId: dashboardState.lastFocusedTabId, directPathLastUpdated: dashboardState.directPathLastUpdated, dashboardId: dashboardInfo.id, - focusedFilterScope: - selectFocusedFilterScope(dashboardState, dashboardFilters) || - selectFocusedNativeFilterScope(nativeFilters), + nativeFilters, + focusedFilterScope: selectFocusedFilterScope( + dashboardState, + dashboardFilters, + ), }; // rows and columns need more data about their child dimensions @@ -148,6 +141,7 @@ function mapDispatchToProps(dispatch) { updateComponents, handleComponentDrop, setDirectPathToChild, + setLastFocusedTab, logEvent, }, dispatch, diff --git a/superset-frontend/src/dashboard/reducers/dashboardState.js b/superset-frontend/src/dashboard/reducers/dashboardState.js index 6f162084708..842916e1cc3 100644 --- a/superset-frontend/src/dashboard/reducers/dashboardState.js +++ b/superset-frontend/src/dashboard/reducers/dashboardState.js @@ -35,6 +35,7 @@ import { SET_DIRECT_PATH, SET_FOCUSED_FILTER_FIELD, UNSET_FOCUSED_FILTER_FIELD, + SET_LAST_FOCUSED_TAB, } from '../actions/dashboardState'; import { HYDRATE_DASHBOARD } from '../actions/hydrate'; @@ -133,6 +134,12 @@ export default function dashboardStateReducer(state = {}, action) { directPathLastUpdated: Date.now(), }; }, + [SET_LAST_FOCUSED_TAB]() { + return { + ...state, + lastFocusedTabId: action.tabId, + }; + }, [SET_FOCUSED_FILTER_FIELD]() { return { ...state, diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts index a37cc0f9387..9a15b1da7ef 100644 --- a/superset-frontend/src/dashboard/types.ts +++ b/superset-frontend/src/dashboard/types.ts @@ -43,7 +43,11 @@ export type Chart = ChartState & { export type DashboardLayout = { [key: string]: LayoutItem }; export type DashboardLayoutState = { present: DashboardLayout }; -export type DashboardState = { editMode: boolean; directPathToChild: string[] }; +export type DashboardState = { + editMode: boolean; + directPathToChild: string[]; + lastFocusedTabId: string | null; +}; export type DashboardInfo = { common: { flash_messages: string[];