feat(native-filters): Hide filters which don't affect any visible charts (#15063)

* feat(native-filters): Hide filters which don't affect any visible charts

* Fix lint errors

* Add comments

* Code review fix

* Fix tests

* Refactor logic in FilterControls
This commit is contained in:
Kamil Gabryjelski 2021-06-11 13:41:08 +02:00 committed by GitHub
parent 5e825cf063
commit 51f0d4fd98
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 111 additions and 60 deletions

View File

@ -50,21 +50,21 @@ jest.mock('src/dashboard/actions/dashboardState');
describe('DashboardBuilder', () => { describe('DashboardBuilder', () => {
let favStarStub; let favStarStub;
let focusedTabStub; let activeTabsStub;
beforeAll(() => { beforeAll(() => {
// this is invoked on mount, so we stub it instead of making a request // this is invoked on mount, so we stub it instead of making a request
favStarStub = sinon favStarStub = sinon
.stub(dashboardStateActions, 'fetchFaveStar') .stub(dashboardStateActions, 'fetchFaveStar')
.returns({ type: 'mock-action' }); .returns({ type: 'mock-action' });
focusedTabStub = sinon activeTabsStub = sinon
.stub(dashboardStateActions, 'setLastFocusedTab') .stub(dashboardStateActions, 'setActiveTabs')
.returns({ type: 'mock-action' }); .returns({ type: 'mock-action' });
}); });
afterAll(() => { afterAll(() => {
favStarStub.restore(); favStarStub.restore();
focusedTabStub.restore(); activeTabsStub.restore();
}); });
function setup(overrideState = {}, overrideStore) { function setup(overrideState = {}, overrideStore) {

View File

@ -344,9 +344,9 @@ export function setDirectPathToChild(path) {
return { type: SET_DIRECT_PATH, path }; return { type: SET_DIRECT_PATH, path };
} }
export const SET_LAST_FOCUSED_TAB = 'SET_LAST_FOCUSED_TAB'; export const SET_ACTIVE_TABS = 'SET_ACTIVE_TABS';
export function setLastFocusedTab(tabId) { export function setActiveTabs(tabIds) {
return { type: SET_LAST_FOCUSED_TAB, tabId }; return { type: SET_ACTIVE_TABS, tabIds };
} }
export const SET_FOCUSED_FILTER_FIELD = 'SET_FOCUSED_FILTER_FIELD'; export const SET_FOCUSED_FILTER_FIELD = 'SET_FOCUSED_FILTER_FIELD';

View File

@ -377,7 +377,7 @@ export const hydrateDashboard = (dashboardData, chartData, datasourcesData) => (
hasUnsavedChanges: false, hasUnsavedChanges: false,
maxUndoHistoryExceeded: false, maxUndoHistoryExceeded: false,
lastModifiedTime: dashboardData.changed_on, lastModifiedTime: dashboardData.changed_on,
lastFocusedTabId: null, activeTabs: [],
}, },
dashboardLayout, dashboardLayout,
}, },

View File

@ -31,11 +31,7 @@ import findTabIndexByComponentId from '../../util/findTabIndexByComponentId';
import getDirectPathToTabIndex from '../../util/getDirectPathToTabIndex'; import getDirectPathToTabIndex from '../../util/getDirectPathToTabIndex';
import getLeafComponentIdFromPath from '../../util/getLeafComponentIdFromPath'; import getLeafComponentIdFromPath from '../../util/getLeafComponentIdFromPath';
import { componentShape } from '../../util/propShapes'; import { componentShape } from '../../util/propShapes';
import { import { NEW_TAB_ID, DASHBOARD_ROOT_ID } from '../../util/constants';
NEW_TAB_ID,
DASHBOARD_ROOT_ID,
DASHBOARD_GRID_ID,
} from '../../util/constants';
import { RENDER_TAB, RENDER_TAB_CONTENT } from './Tab'; import { RENDER_TAB, RENDER_TAB_CONTENT } from './Tab';
import { TAB_TYPE } from '../../util/componentTypes'; import { TAB_TYPE } from '../../util/componentTypes';
@ -50,9 +46,11 @@ const propTypes = {
editMode: PropTypes.bool.isRequired, editMode: PropTypes.bool.isRequired,
renderHoverMenu: PropTypes.bool, renderHoverMenu: PropTypes.bool,
directPathToChild: PropTypes.arrayOf(PropTypes.string), directPathToChild: PropTypes.arrayOf(PropTypes.string),
activeTabs: PropTypes.arrayOf(PropTypes.string),
// actions (from DashboardComponent.jsx) // actions (from DashboardComponent.jsx)
logEvent: PropTypes.func.isRequired, logEvent: PropTypes.func.isRequired,
setActiveTabs: PropTypes.func,
// grid related // grid related
availableColumnCount: PropTypes.number, availableColumnCount: PropTypes.number,
@ -75,6 +73,8 @@ const defaultProps = {
availableColumnCount: 0, availableColumnCount: 0,
columnWidth: 0, columnWidth: 0,
directPathToChild: [], directPathToChild: [],
activeTabs: [],
setActiveTabs() {},
onResizeStart() {}, onResizeStart() {},
onResize() {}, onResize() {},
onResizeStop() {}, onResizeStop() {},
@ -130,6 +130,19 @@ class Tabs extends React.PureComponent {
this.handleDropOnTab = this.handleDropOnTab.bind(this); this.handleDropOnTab = this.handleDropOnTab.bind(this);
} }
componentDidMount() {
this.props.setActiveTabs([...this.props.activeTabs, this.state.activeKey]);
}
componentDidUpdate(prevProps, prevState) {
if (prevState.activeKey !== this.state.activeKey) {
this.props.setActiveTabs([
...this.props.activeTabs.filter(tabId => tabId !== prevState.activeKey),
this.state.activeKey,
]);
}
}
UNSAFE_componentWillReceiveProps(nextProps) { UNSAFE_componentWillReceiveProps(nextProps) {
const maxIndex = Math.max(0, nextProps.component.children.length - 1); const maxIndex = Math.max(0, nextProps.component.children.length - 1);
const currTabsIds = this.props.component.children; const currTabsIds = this.props.component.children;
@ -277,22 +290,11 @@ class Tabs extends React.PureComponent {
isComponentVisible: isCurrentTabVisible, isComponentVisible: isCurrentTabVisible,
editMode, editMode,
nativeFilters, nativeFilters,
dashboardLayout,
lastFocusedTabId,
setLastFocusedTab,
} = this.props; } = this.props;
const { children: tabIds } = tabsComponent; const { children: tabIds } = tabsComponent;
const { tabIndex: selectedTabIndex, activeKey } = this.state; const { tabIndex: selectedTabIndex, activeKey } = this.state;
// 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; let tabsToHighlight;
if (nativeFilters.focusedFilterId) { if (nativeFilters.focusedFilterId) {
tabsToHighlight = tabsToHighlight =
@ -332,7 +334,6 @@ class Tabs extends React.PureComponent {
onEdit={this.handleEdit} onEdit={this.handleEdit}
data-test="nav-list" data-test="nav-list"
type={editMode ? 'editable-card' : 'card'} type={editMode ? 'editable-card' : 'card'}
onTabClick={setLastFocusedTab}
> >
{tabIds.map((tabId, tabIndex) => ( {tabIds.map((tabId, tabIndex) => (
<LineEditableTabs.TabPane <LineEditableTabs.TabPane

View File

@ -19,18 +19,14 @@
import React, { FC, useMemo, useState } from 'react'; import React, { FC, useMemo, useState } from 'react';
import { DataMask, styled, t } from '@superset-ui/core'; import { DataMask, styled, t } from '@superset-ui/core';
import { css } from '@emotion/react'; import { css } from '@emotion/react';
import { useSelector } from 'react-redux';
import * as portals from 'react-reverse-portal'; import * as portals from 'react-reverse-portal';
import { DataMaskStateWithId } from 'src/dataMask/types'; import { DataMaskStateWithId } from 'src/dataMask/types';
import { Collapse } from 'src/common/components'; 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 CascadePopover from '../CascadeFilters/CascadePopover';
import { buildCascadeFiltersTree } from './utils'; import { buildCascadeFiltersTree } from './utils';
import { useFilters } from '../state'; import { useFilters } from '../state';
import { Filter } from '../../types'; import { Filter } from '../../types';
import { CascadeFilter } from '../CascadeFilters/types'; import { useDashboardHasTabs, useSelectFiltersInScope } from '../../state';
import { useDashboardLayout } from '../../state';
const Wrapper = styled.div` const Wrapper = styled.div`
padding: ${({ theme }) => theme.gridUnit * 4}px; padding: ${({ theme }) => theme.gridUnit * 4}px;
@ -52,10 +48,6 @@ const FilterControls: FC<FilterControlsProps> = ({
}) => { }) => {
const [visiblePopoverId, setVisiblePopoverId] = useState<string | null>(null); const [visiblePopoverId, setVisiblePopoverId] = useState<string | null>(null);
const filters = useFilters(); const filters = useFilters();
const dashboardLayout = useDashboardLayout();
const lastFocusedTabId = useSelector<RootState, string | null>(
state => state.dashboardState?.lastFocusedTabId,
);
const filterValues = Object.values<Filter>(filters); const filterValues = Object.values<Filter>(filters);
const portalNodes = React.useMemo(() => { const portalNodes = React.useMemo(() => {
const nodes = new Array(filterValues.length); const nodes = new Array(filterValues.length);
@ -74,23 +66,11 @@ const FilterControls: FC<FilterControlsProps> = ({
}, [filterValues, dataMaskSelected]); }, [filterValues, dataMaskSelected]);
const cascadeFilterIds = new Set(cascadeFilters.map(item => item.id)); const cascadeFilterIds = new Set(cascadeFilters.map(item => item.id));
let filtersInScope: CascadeFilter[] = []; const [filtersInScope, filtersOutOfScope] = useSelectFiltersInScope(
const filtersOutOfScope: CascadeFilter[] = []; cascadeFilters,
const dashboardHasTabs = Object.values(dashboardLayout).some(
element => element.type === TAB_TYPE,
); );
const dashboardHasTabs = useDashboardHasTabs();
const showCollapsePanel = dashboardHasTabs && cascadeFilters.length > 0; 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 ( return (
<Wrapper> <Wrapper>

View File

@ -19,7 +19,9 @@
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Filter, FilterConfiguration } from './types'; import { Filter, FilterConfiguration } from './types';
import { DashboardLayout } from '../../types'; import { ActiveTabs, DashboardLayout, RootState } from '../../types';
import { TAB_TYPE } from '../../util/componentTypes';
import { CascadeFilter } from './FilterBar/CascadeFilters/types';
const defaultFilterConfiguration: Filter[] = []; const defaultFilterConfiguration: Filter[] = [];
@ -52,3 +54,73 @@ export function useDashboardLayout() {
state => state.dashboardLayout?.present, state => state.dashboardLayout?.present,
); );
} }
export function useDashboardHasTabs() {
const dashboardLayout = useDashboardLayout();
return useMemo(
() =>
Object.values(dashboardLayout).some(element => element.type === TAB_TYPE),
[dashboardLayout],
);
}
function useActiveDashboardTabs() {
return useSelector<RootState, ActiveTabs>(
state => state.dashboardState?.activeTabs,
);
}
function useSelectChartTabParents() {
const dashboardLayout = useDashboardLayout();
return (chartId: number) => {
const chartLayoutItem = Object.values(dashboardLayout).find(
layoutItem => layoutItem.meta?.chartId === chartId,
);
return chartLayoutItem?.parents.filter(
(parent: string) => dashboardLayout[parent].type === TAB_TYPE,
);
};
}
function useIsFilterInScope() {
const activeTabs = useActiveDashboardTabs();
const selectChartTabParents = useSelectChartTabParents();
// Filter is in scope if any of it's charts is visible.
// Chart is visible if it's placed in an active tab tree or if it's not attached to any tab.
// Chart is in an active tab tree if all of it's ancestors of type TAB are active
return (filter: CascadeFilter) =>
filter.chartsInScope?.some((chartId: number) => {
const tabParents = selectChartTabParents(chartId);
return (
tabParents?.length === 0 ||
tabParents?.every(tab => activeTabs.includes(tab))
);
});
}
export function useSelectFiltersInScope(cascadeFilters: CascadeFilter[]) {
const dashboardHasTabs = useDashboardHasTabs();
const isFilterInScope = useIsFilterInScope();
return useMemo(() => {
let filtersInScope: CascadeFilter[] = [];
const filtersOutOfScope: CascadeFilter[] = [];
// we check native filters scopes only on dashboards with tabs
if (!dashboardHasTabs) {
filtersInScope = cascadeFilters;
} else {
cascadeFilters.forEach((filter: CascadeFilter) => {
const filterInScope = isFilterInScope(filter);
if (filterInScope) {
filtersInScope.push(filter);
} else {
filtersOutOfScope.push(filter);
}
});
}
return [filtersInScope, filtersOutOfScope];
}, [cascadeFilters, dashboardHasTabs, isFilterInScope]);
}

View File

@ -35,10 +35,7 @@ import {
updateComponents, updateComponents,
handleComponentDrop, handleComponentDrop,
} from '../actions/dashboardLayout'; } from '../actions/dashboardLayout';
import { import { setDirectPathToChild, setActiveTabs } from '../actions/dashboardState';
setDirectPathToChild,
setLastFocusedTab,
} from '../actions/dashboardState';
const propTypes = { const propTypes = {
id: PropTypes.string, id: PropTypes.string,
@ -104,7 +101,7 @@ function mapStateToProps(
redoLength: undoableLayout.future.length, redoLength: undoableLayout.future.length,
filters: getActiveFilters(), filters: getActiveFilters(),
directPathToChild: dashboardState.directPathToChild, directPathToChild: dashboardState.directPathToChild,
lastFocusedTabId: dashboardState.lastFocusedTabId, activeTabs: dashboardState.activeTabs,
directPathLastUpdated: dashboardState.directPathLastUpdated, directPathLastUpdated: dashboardState.directPathLastUpdated,
dashboardId: dashboardInfo.id, dashboardId: dashboardInfo.id,
nativeFilters, nativeFilters,
@ -141,7 +138,7 @@ function mapDispatchToProps(dispatch) {
updateComponents, updateComponents,
handleComponentDrop, handleComponentDrop,
setDirectPathToChild, setDirectPathToChild,
setLastFocusedTab, setActiveTabs,
logEvent, logEvent,
}, },
dispatch, dispatch,

View File

@ -35,7 +35,7 @@ import {
SET_DIRECT_PATH, SET_DIRECT_PATH,
SET_FOCUSED_FILTER_FIELD, SET_FOCUSED_FILTER_FIELD,
UNSET_FOCUSED_FILTER_FIELD, UNSET_FOCUSED_FILTER_FIELD,
SET_LAST_FOCUSED_TAB, SET_ACTIVE_TABS,
} from '../actions/dashboardState'; } from '../actions/dashboardState';
import { HYDRATE_DASHBOARD } from '../actions/hydrate'; import { HYDRATE_DASHBOARD } from '../actions/hydrate';
@ -134,10 +134,10 @@ export default function dashboardStateReducer(state = {}, action) {
directPathLastUpdated: Date.now(), directPathLastUpdated: Date.now(),
}; };
}, },
[SET_LAST_FOCUSED_TAB]() { [SET_ACTIVE_TABS]() {
return { return {
...state, ...state,
lastFocusedTabId: action.tabId, activeTabs: action.tabIds,
}; };
}, },
[SET_FOCUSED_FILTER_FIELD]() { [SET_FOCUSED_FILTER_FIELD]() {

View File

@ -41,12 +41,13 @@ export type Chart = ChartState & {
}; };
}; };
export type ActiveTabs = string[];
export type DashboardLayout = { [key: string]: LayoutItem }; export type DashboardLayout = { [key: string]: LayoutItem };
export type DashboardLayoutState = { present: DashboardLayout }; export type DashboardLayoutState = { present: DashboardLayout };
export type DashboardState = { export type DashboardState = {
editMode: boolean; editMode: boolean;
directPathToChild: string[]; directPathToChild: string[];
lastFocusedTabId: string | null; activeTabs: ActiveTabs;
}; };
export type DashboardInfo = { export type DashboardInfo = {
common: { common: {