refactor(native-filters): refactor filter bar (#13723)

* fix: fix removeing native filter

* fix: fix native-cross filters

* fix: fix native-cross filters

* fix: fix native-cross filters

* fix: fix function declaration

* refactor: before pull

* refactor: refactor filter bar

* chore: add lic

* lint: fix lint
This commit is contained in:
simcha90 2021-03-22 17:03:44 +02:00 committed by GitHub
parent 6c3bfe80dc
commit aa92c1e7eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 932 additions and 734 deletions

View File

@ -18,7 +18,7 @@
*/
import { Provider } from 'react-redux';
import React from 'react';
import { shallow, mount } from 'enzyme';
import { mount } from 'enzyme';
import sinon from 'sinon';
import fetchMock from 'fetch-mock';
import { ParentSize } from '@vx/responsive';
@ -27,26 +27,24 @@ import { Sticky, StickyContainer } from 'react-sticky';
import { TabContainer, TabContent, TabPane } from 'react-bootstrap';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import BuilderComponentPane from 'src/dashboard/components/BuilderComponentPane';
import DashboardBuilder from 'src/dashboard/components/DashboardBuilder';
import DashboardBuilder from 'src/dashboard/components/DashboardBuilder/DashboardBuilder';
import DashboardComponent from 'src/dashboard/containers/DashboardComponent';
import DashboardHeader from 'src/dashboard/containers/DashboardHeader';
import DashboardGrid from 'src/dashboard/containers/DashboardGrid';
import * as dashboardStateActions from 'src/dashboard/actions/dashboardState';
import {
dashboardLayout as undoableDashboardLayout,
dashboardLayoutWithTabs as undoableDashboardLayoutWithTabs,
} from 'spec/fixtures/mockDashboardLayout';
import { mockStore, mockStoreWithTabs } from 'spec/fixtures/mockStore';
const dashboardLayout = undoableDashboardLayout.present;
const layoutWithTabs = undoableDashboardLayoutWithTabs.present;
import { mockStoreWithTabs, storeWithState } from 'spec/fixtures/mockStore';
import mockState from 'spec/fixtures/mockState';
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
fetchMock.get('glob:*/csstemplateasyncmodelview/api/read', {});
jest.mock('src/dashboard/actions/dashboardState');
describe('DashboardBuilder', () => {
let favStarStub;
@ -61,31 +59,25 @@ describe('DashboardBuilder', () => {
favStarStub.restore();
});
const props = {
dashboardLayout,
deleteTopLevelTabs() {},
editMode: false,
showBuilderPane() {},
setColorSchemeAndUnsavedChanges() {},
colorScheme: undefined,
handleComponentDrop() {},
setDirectPathToChild: sinon.spy(),
setMountedTab() {},
};
function setup(overrideProps, useProvider = false, store = mockStore) {
const builder = <DashboardBuilder {...props} {...overrideProps} />;
return useProvider
? mount(
<Provider store={store}>
<DndProvider backend={HTML5Backend}>{builder}</DndProvider>
</Provider>,
{
wrappingComponent: ThemeProvider,
wrappingComponentProps: { theme: supersetTheme },
},
)
: shallow(builder);
function setup(overrideState = {}, overrideStore) {
const store =
overrideStore ??
storeWithState({
...mockState,
dashboardLayout: undoableDashboardLayout,
...overrideState,
});
return mount(
<Provider store={store}>
<DndProvider backend={HTML5Backend}>
<DashboardBuilder />
</DndProvider>
</Provider>,
{
wrappingComponent: ThemeProvider,
wrappingComponentProps: { theme: supersetTheme },
},
);
}
it('should render a StickyContainer with class "dashboard"', () => {
@ -96,28 +88,28 @@ describe('DashboardBuilder', () => {
});
it('should add the "dashboard--editing" class if editMode=true', () => {
const wrapper = setup({ editMode: true });
const stickyContainer = wrapper.find(StickyContainer);
const wrapper = setup({ dashboardState: { editMode: true } });
const stickyContainer = wrapper.find(StickyContainer).first();
expect(stickyContainer.prop('className')).toBe(
'dashboard dashboard--editing',
);
});
it('should render a DragDroppable DashboardHeader', () => {
const wrapper = setup(null, true);
const wrapper = setup();
expect(wrapper.find(DashboardHeader)).toExist();
});
it('should render a Sticky top-level Tabs if the dashboard has tabs', () => {
const wrapper = setup(
{ dashboardLayout: layoutWithTabs },
true,
{ dashboardLayout: undoableDashboardLayoutWithTabs },
mockStoreWithTabs,
);
const sticky = wrapper.find(Sticky);
const dashboardComponent = sticky.find(DashboardComponent);
const tabChildren = layoutWithTabs.TABS_ID.children;
const tabChildren =
undoableDashboardLayoutWithTabs.present.TABS_ID.children;
expect(sticky).toHaveLength(1);
expect(dashboardComponent).toHaveLength(1 + tabChildren.length); // tab + tabs
expect(dashboardComponent.at(0).prop('id')).toBe('TABS_ID');
@ -127,57 +119,65 @@ describe('DashboardBuilder', () => {
});
it('should render a TabContainer and TabContent', () => {
const wrapper = setup({ dashboardLayout: layoutWithTabs });
const parentSize = wrapper.find(ParentSize).dive();
const wrapper = setup({ dashboardLayout: undoableDashboardLayoutWithTabs });
const parentSize = wrapper.find(ParentSize);
expect(parentSize.find(TabContainer)).toHaveLength(1);
expect(parentSize.find(TabContent)).toHaveLength(1);
});
it('should set animation=true, mountOnEnter=true, and unmounOnExit=false on TabContainer for perf', () => {
const wrapper = setup({ dashboardLayout: layoutWithTabs });
const tabProps = wrapper.find(ParentSize).dive().find(TabContainer).props();
const wrapper = setup({ dashboardLayout: undoableDashboardLayoutWithTabs });
const tabProps = wrapper.find(ParentSize).find(TabContainer).props();
expect(tabProps.animation).toBe(true);
expect(tabProps.mountOnEnter).toBe(true);
expect(tabProps.unmountOnExit).toBe(false);
});
it('should render a TabPane and DashboardGrid for each Tab', () => {
const wrapper = setup({ dashboardLayout: layoutWithTabs });
const parentSize = wrapper.find(ParentSize).dive();
const expectedCount = layoutWithTabs.TABS_ID.children.length;
it('should render a TabPane and DashboardGrid for first Tab', () => {
const wrapper = setup({ dashboardLayout: undoableDashboardLayoutWithTabs });
const parentSize = wrapper.find(ParentSize);
const expectedCount =
undoableDashboardLayoutWithTabs.present.TABS_ID.children.length;
expect(parentSize.find(TabPane)).toHaveLength(expectedCount);
expect(parentSize.find(DashboardGrid)).toHaveLength(expectedCount);
expect(parentSize.find(TabPane).first().find(DashboardGrid)).toHaveLength(
1,
);
});
it('should render a TabPane and DashboardGrid for second Tab', () => {
const wrapper = setup({
dashboardLayout: undoableDashboardLayoutWithTabs,
dashboardState: {
...mockState,
directPathToChild: [DASHBOARD_ROOT_ID, 'TABS_ID', 'TAB_ID2'],
},
});
const parentSize = wrapper.find(ParentSize);
const expectedCount =
undoableDashboardLayoutWithTabs.present.TABS_ID.children.length;
expect(parentSize.find(TabPane)).toHaveLength(expectedCount);
expect(parentSize.find(TabPane).at(1).find(DashboardGrid)).toHaveLength(1);
});
it('should render a BuilderComponentPane if editMode=false and user selects "Insert Components" pane', () => {
const wrapper = setup();
expect(wrapper.find(BuilderComponentPane)).not.toExist();
});
it('should render a BuilderComponentPane if editMode=true and user selects "Insert Components" pane', () => {
const wrapper = setup();
expect(wrapper.find(BuilderComponentPane)).not.toExist();
wrapper.setProps({
...props,
editMode: true,
});
expect(wrapper.find(BuilderComponentPane)).toExist();
});
it('should render a BuilderComponentPane if editMode=true and user selects "Colors" pane', () => {
const wrapper = setup();
expect(wrapper.find(BuilderComponentPane)).not.toExist();
wrapper.setProps({
...props,
editMode: true,
});
const wrapper = setup({ dashboardState: { editMode: true } });
expect(wrapper.find(BuilderComponentPane)).toExist();
});
it('should change redux state if a top-level Tab is clicked', () => {
const wrapper = setup(
{ dashboardLayout: layoutWithTabs },
true,
mockStoreWithTabs,
);
dashboardStateActions.setDirectPathToChild = jest.fn(arg0 => ({
type: 'type',
arg0,
}));
const wrapper = setup({
...mockStoreWithTabs,
dashboardLayout: undoableDashboardLayoutWithTabs,
});
expect(wrapper.find(TabContainer).prop('activeKey')).toBe(0);
@ -186,6 +186,10 @@ describe('DashboardBuilder', () => {
.at(1)
.simulate('click');
expect(props.setDirectPathToChild.callCount).toBe(1);
expect(dashboardStateActions.setDirectPathToChild).toHaveBeenCalledWith([
'ROOT_ID',
'TABS_ID',
'TAB_ID2',
]);
});
});

View File

@ -21,7 +21,7 @@ import { shallow } from 'enzyme';
import sinon from 'sinon';
import Dashboard from 'src/dashboard/components/Dashboard';
import DashboardBuilder from 'src/dashboard/containers/DashboardBuilder';
import DashboardBuilder from 'src/dashboard/components/DashboardBuilder/DashboardBuilder';
import { CHART_TYPE } from 'src/dashboard/util/componentTypes';
import newComponentFactory from 'src/dashboard/util/newComponentFactory';

View File

@ -24,7 +24,7 @@ import { PluginContext } from 'src/components/DynamicPlugins';
import Loading from 'src/components/Loading';
import getChartIdsFromLayout from '../util/getChartIdsFromLayout';
import getLayoutComponentFromChartId from '../util/getLayoutComponentFromChartId';
import DashboardBuilder from '../containers/DashboardBuilder';
import DashboardBuilder from './DashboardBuilder/DashboardBuilder';
import {
chartPropShape,
slicePropShape,

View File

@ -1,371 +0,0 @@
/**
* 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.
*/
/* eslint-env browser */
import cx from 'classnames';
// ParentSize uses resize observer so the dashboard will update size
// when its container size changes, due to e.g., builder side panel opening
import { ParentSize } from '@vx/responsive';
import PropTypes from 'prop-types';
import React from 'react';
import { Sticky, StickyContainer } from 'react-sticky';
import { TabContainer, TabContent, TabPane } from 'react-bootstrap';
import { styled } from '@superset-ui/core';
import ErrorBoundary from 'src/components/ErrorBoundary';
import BuilderComponentPane from 'src/dashboard/components/BuilderComponentPane';
import DashboardHeader from 'src/dashboard/containers/DashboardHeader';
import DashboardGrid from 'src/dashboard/containers/DashboardGrid';
import IconButton from 'src/dashboard/components/IconButton';
import DragDroppable from 'src/dashboard/components/dnd/DragDroppable';
import DashboardComponent from 'src/dashboard/containers/DashboardComponent';
import ToastPresenter from 'src/messageToasts/containers/ToastPresenter';
import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu';
import findTabIndexByComponentId from 'src/dashboard/util/findTabIndexByComponentId';
import getDirectPathToTabIndex from 'src/dashboard/util/getDirectPathToTabIndex';
import getLeafComponentIdFromPath from 'src/dashboard/util/getLeafComponentIdFromPath';
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
import { URL_PARAMS } from 'src/constants';
import {
DASHBOARD_GRID_ID,
DASHBOARD_ROOT_ID,
DASHBOARD_ROOT_DEPTH,
DashboardStandaloneMode,
} from '../util/constants';
import FilterBar from './nativeFilters/FilterBar/FilterBar';
import { StickyVerticalBar } from './StickyVerticalBar';
import { getUrlParam } from '../../utils/urlUtils';
const TABS_HEIGHT = 47;
const HEADER_HEIGHT = 67;
const propTypes = {
// redux
dashboardLayout: PropTypes.object.isRequired,
deleteTopLevelTabs: PropTypes.func.isRequired,
editMode: PropTypes.bool.isRequired,
showBuilderPane: PropTypes.func,
colorScheme: PropTypes.string,
setColorSchemeAndUnsavedChanges: PropTypes.func.isRequired,
handleComponentDrop: PropTypes.func.isRequired,
directPathToChild: PropTypes.arrayOf(PropTypes.string),
focusedFilterField: PropTypes.object,
setDirectPathToChild: PropTypes.func.isRequired,
setMountedTab: PropTypes.func.isRequired,
};
const defaultProps = {
showBuilderPane: false,
directPathToChild: [],
colorScheme: undefined,
};
const StyledDashboardContent = styled.div`
display: flex;
flex-direction: row;
flex-wrap: nowrap;
height: auto;
flex-grow: 1;
.grid-container .dashboard-component-tabs {
box-shadow: none;
padding-left: 0;
}
.grid-container {
/* without this, the grid will not get smaller upon toggling the builder panel on */
min-width: 0;
width: 100%;
flex-grow: 1;
position: relative;
margin: ${({ theme }) => theme.gridUnit * 6}px
${({ theme }) => theme.gridUnit * 8}px
${({ theme }) => theme.gridUnit * 6}px
${({ theme, dashboardFiltersOpen }) => {
if (dashboardFiltersOpen) return theme.gridUnit * 8;
return 0;
}}px;
}
.dashboard-component-chart-holder {
// transitionable traits to show filter relevance
transition: opacity ${({ theme }) => theme.transitionTiming}s,
border-color ${({ theme }) => theme.transitionTiming}s,
box-shadow ${({ theme }) => theme.transitionTiming}s;
border: 0px solid transparent;
}
`;
class DashboardBuilder extends React.Component {
static shouldFocusTabs(event, container) {
// don't focus the tabs when we click on a tab
return (
event.target.className === 'ant-tabs-nav-wrap' ||
(/icon-button/.test(event.target.className) &&
container.contains(event.target))
);
}
static getRootLevelTabIndex(dashboardLayout, directPathToChild) {
return Math.max(
0,
findTabIndexByComponentId({
currentComponent: DashboardBuilder.getRootLevelTabsComponent(
dashboardLayout,
),
directPathToChild,
}),
);
}
static getRootLevelTabsComponent(dashboardLayout) {
const dashboardRoot = dashboardLayout[DASHBOARD_ROOT_ID];
const rootChildId = dashboardRoot.children[0];
return rootChildId === DASHBOARD_GRID_ID
? dashboardLayout[DASHBOARD_ROOT_ID]
: dashboardLayout[rootChildId];
}
constructor(props) {
super(props);
const { dashboardLayout, directPathToChild } = props;
const tabIndex = DashboardBuilder.getRootLevelTabIndex(
dashboardLayout,
directPathToChild,
);
this.state = {
tabIndex,
dashboardFiltersOpen: true,
};
this.handleChangeTab = this.handleChangeTab.bind(this);
this.handleDeleteTopLevelTabs = this.handleDeleteTopLevelTabs.bind(this);
this.toggleDashboardFiltersOpen = this.toggleDashboardFiltersOpen.bind(
this,
);
}
UNSAFE_componentWillReceiveProps(nextProps) {
const nextFocusComponent = getLeafComponentIdFromPath(
nextProps.directPathToChild,
);
const currentFocusComponent = getLeafComponentIdFromPath(
this.props.directPathToChild,
);
if (nextFocusComponent !== currentFocusComponent) {
const { dashboardLayout, directPathToChild } = nextProps;
const nextTabIndex = DashboardBuilder.getRootLevelTabIndex(
dashboardLayout,
directPathToChild,
);
this.setState(() => ({ tabIndex: nextTabIndex }));
}
}
toggleDashboardFiltersOpen(visible) {
if (visible === undefined) {
this.setState(state => ({
...state,
dashboardFiltersOpen: !state.dashboardFiltersOpen,
}));
} else {
this.setState(state => ({
...state,
dashboardFiltersOpen: visible,
}));
}
}
handleChangeTab({ pathToTabIndex }) {
this.props.setDirectPathToChild(pathToTabIndex);
}
handleDeleteTopLevelTabs() {
this.props.deleteTopLevelTabs();
const { dashboardLayout } = this.props;
const firstTab = getDirectPathToTabIndex(
DashboardBuilder.getRootLevelTabsComponent(dashboardLayout),
0,
);
this.props.setDirectPathToChild(firstTab);
}
render() {
const {
handleComponentDrop,
dashboardLayout,
editMode,
showBuilderPane,
setColorSchemeAndUnsavedChanges,
colorScheme,
directPathToChild,
} = this.props;
const { tabIndex } = this.state;
const dashboardRoot = dashboardLayout[DASHBOARD_ROOT_ID];
const rootChildId = dashboardRoot.children[0];
const topLevelTabs =
rootChildId !== DASHBOARD_GRID_ID && dashboardLayout[rootChildId];
const childIds = topLevelTabs ? topLevelTabs.children : [DASHBOARD_GRID_ID];
const hideDashboardHeader =
getUrlParam(URL_PARAMS.standalone, 'number') ===
DashboardStandaloneMode.HIDE_NAV_AND_TITLE;
const barTopOffset =
(hideDashboardHeader ? 0 : HEADER_HEIGHT) +
(topLevelTabs ? TABS_HEIGHT : 0);
return (
<StickyContainer
className={cx('dashboard', editMode && 'dashboard--editing')}
>
<Sticky>
{({ style }) => (
<DragDroppable
component={dashboardRoot}
parentComponent={null}
depth={DASHBOARD_ROOT_DEPTH}
index={0}
orientation="column"
onDrop={handleComponentDrop}
editMode={editMode}
// you cannot drop on/displace tabs if they already exist
disableDragdrop={!!topLevelTabs}
style={{
zIndex: 100,
...style,
}}
>
{({ dropIndicatorProps }) => (
<div>
{!hideDashboardHeader && <DashboardHeader />}
{dropIndicatorProps && <div {...dropIndicatorProps} />}
{topLevelTabs && (
<WithPopoverMenu
shouldFocus={DashboardBuilder.shouldFocusTabs}
menuItems={[
<IconButton
className="fa fa-level-down"
label="Collapse tab content"
onClick={this.handleDeleteTopLevelTabs}
/>,
]}
editMode={editMode}
>
<DashboardComponent
id={topLevelTabs.id}
parentId={DASHBOARD_ROOT_ID}
depth={DASHBOARD_ROOT_DEPTH + 1}
index={0}
renderTabContent={false}
renderHoverMenu={false}
onChangeTab={this.handleChangeTab}
/>
</WithPopoverMenu>
)}
</div>
)}
</DragDroppable>
)}
</Sticky>
<StyledDashboardContent
className="dashboard-content"
dashboardFiltersOpen={this.state.dashboardFiltersOpen}
>
{isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS) && !editMode && (
<StickyVerticalBar
filtersOpen={this.state.dashboardFiltersOpen}
topOffset={barTopOffset}
>
<ErrorBoundary>
<FilterBar
filtersOpen={this.state.dashboardFiltersOpen}
toggleFiltersBar={this.toggleDashboardFiltersOpen}
directPathToChild={directPathToChild}
/>
</ErrorBoundary>
</StickyVerticalBar>
)}
<div className="grid-container" data-test="grid-container">
<ParentSize>
{({ width }) => (
/*
We use a TabContainer irrespective of whether top-level tabs exist to maintain
a consistent React component tree. This avoids expensive mounts/unmounts of
the entire dashboard upon adding/removing top-level tabs, which would otherwise
happen because of React's diffing algorithm
*/
<TabContainer
id={DASHBOARD_GRID_ID}
activeKey={Math.min(tabIndex, childIds.length - 1)}
onSelect={this.handleChangeTab}
animation
mountOnEnter
unmountOnExit={false}
>
<TabContent>
{childIds.map((id, index) => (
// Matching the key of the first TabPane irrespective of topLevelTabs
// lets us keep the same React component tree when !!topLevelTabs changes.
// This avoids expensive mounts/unmounts of the entire dashboard.
<TabPane
key={index === 0 ? DASHBOARD_GRID_ID : id}
eventKey={index}
>
<DashboardGrid
gridComponent={dashboardLayout[id]}
// see isValidChild for why tabs do not increment the depth of their children
depth={DASHBOARD_ROOT_DEPTH + 1} // (topLevelTabs ? 0 : 1)}
width={width}
isComponentVisible={index === tabIndex}
/>
</TabPane>
))}
</TabContent>
</TabContainer>
)}
</ParentSize>
</div>
{editMode && (
<BuilderComponentPane
topOffset={barTopOffset}
showBuilderPane={showBuilderPane}
setColorSchemeAndUnsavedChanges={setColorSchemeAndUnsavedChanges}
colorScheme={colorScheme}
/>
)}
</StyledDashboardContent>
<ToastPresenter />
</StickyContainer>
);
}
}
DashboardBuilder.propTypes = propTypes;
DashboardBuilder.defaultProps = defaultProps;
DashboardBuilder.childContextTypes = {
dragDropManager: PropTypes.object.isRequired,
};
export default DashboardBuilder;

View File

@ -0,0 +1,243 @@
/**
* 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.
*/
/* eslint-env browser */
import cx from 'classnames';
import React, { FC, SyntheticEvent, useEffect, useState } from 'react';
import { Sticky, StickyContainer } from 'react-sticky';
import { TabContainer } from 'react-bootstrap';
import { JsonObject, styled } from '@superset-ui/core';
import ErrorBoundary from 'src/components/ErrorBoundary';
import BuilderComponentPane from 'src/dashboard/components/BuilderComponentPane';
import DashboardHeader from 'src/dashboard/containers/DashboardHeader';
import IconButton from 'src/dashboard/components/IconButton';
import DragDroppable from 'src/dashboard/components/dnd/DragDroppable';
import DashboardComponent from 'src/dashboard/containers/DashboardComponent';
import ToastPresenter from 'src/messageToasts/containers/ToastPresenter';
import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu';
import getDirectPathToTabIndex from 'src/dashboard/util/getDirectPathToTabIndex';
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
import { URL_PARAMS } from 'src/constants';
import { useDispatch, useSelector } from 'react-redux';
import { getUrlParam } from 'src/utils/urlUtils';
import { DashboardLayout, RootState } from 'src/dashboard/types';
import { setDirectPathToChild } from 'src/dashboard/actions/dashboardState';
import {
deleteTopLevelTabs,
handleComponentDrop,
} from 'src/dashboard/actions/dashboardLayout';
import {
DASHBOARD_GRID_ID,
DASHBOARD_ROOT_ID,
DASHBOARD_ROOT_DEPTH,
DashboardStandaloneMode,
} from 'src/dashboard/util/constants';
import FilterBar from '../nativeFilters/FilterBar/FilterBar';
import { StickyVerticalBar } from '../StickyVerticalBar';
import { shouldFocusTabs, getRootLevelTabsComponent } from './utils';
import { useFilters } from '../nativeFilters/FilterBar/state';
import { Filter } from '../nativeFilters/types';
import DashboardContainer from './DashboardContainer';
const TABS_HEIGHT = 47;
const HEADER_HEIGHT = 67;
type DashboardBuilderProps = {};
const StyledDashboardContent = styled.div<{ dashboardFiltersOpen: boolean }>`
display: flex;
flex-direction: row;
flex-wrap: nowrap;
height: auto;
flex-grow: 1;
.grid-container .dashboard-component-tabs {
box-shadow: none;
padding-left: 0;
}
.grid-container {
/* without this, the grid will not get smaller upon toggling the builder panel on */
min-width: 0;
width: 100%;
flex-grow: 1;
position: relative;
margin: ${({ theme }) => theme.gridUnit * 6}px
${({ theme }) => theme.gridUnit * 8}px
${({ theme }) => theme.gridUnit * 6}px
${({ theme, dashboardFiltersOpen }) => {
if (dashboardFiltersOpen) return theme.gridUnit * 8;
return 0;
}}px;
}
.dashboard-component-chart-holder {
// transitionable traits to show filter relevance
transition: opacity ${({ theme }) => theme.transitionTiming}s,
border-color ${({ theme }) => theme.transitionTiming}s,
box-shadow ${({ theme }) => theme.transitionTiming}s;
border: 0 solid transparent;
}
`;
const DashboardBuilder: FC<DashboardBuilderProps> = () => {
const dispatch = useDispatch();
const dashboardLayout = useSelector<RootState, DashboardLayout>(
state => state.dashboardLayout.present,
);
const editMode = useSelector<RootState, boolean>(
state => state.dashboardState.editMode,
);
const directPathToChild = useSelector<RootState, string[]>(
state => state.dashboardState.directPathToChild,
);
const filters = useFilters();
const filterValues = Object.values<Filter>(filters);
const [dashboardFiltersOpen, setDashboardFiltersOpen] = useState(true);
const toggleDashboardFiltersOpen = (visible?: boolean) => {
setDashboardFiltersOpen(visible ?? !dashboardFiltersOpen);
};
const handleChangeTab = ({
pathToTabIndex,
}: SyntheticEvent<TabContainer, Event> & { pathToTabIndex: string[] }) => {
dispatch(setDirectPathToChild(pathToTabIndex));
};
const handleDeleteTopLevelTabs = () => {
dispatch(deleteTopLevelTabs());
const firstTab = getDirectPathToTabIndex(
getRootLevelTabsComponent(dashboardLayout),
0,
);
dispatch(setDirectPathToChild(firstTab));
};
const dashboardRoot = dashboardLayout[DASHBOARD_ROOT_ID];
const rootChildId = dashboardRoot.children[0];
const topLevelTabs =
rootChildId !== DASHBOARD_GRID_ID
? dashboardLayout[rootChildId]
: undefined;
const hideDashboardHeader =
getUrlParam(URL_PARAMS.standalone, 'number') ===
DashboardStandaloneMode.HIDE_NAV_AND_TITLE;
const barTopOffset =
(hideDashboardHeader ? 0 : HEADER_HEIGHT) +
(topLevelTabs ? TABS_HEIGHT : 0);
useEffect(() => {
if (filterValues.length === 0 && dashboardFiltersOpen) {
toggleDashboardFiltersOpen(false);
}
}, [filterValues.length]);
return (
<StickyContainer
className={cx('dashboard', editMode && 'dashboard--editing')}
>
<Sticky>
{({ style }) => (
// @ts-ignore
<DragDroppable
component={dashboardRoot}
parentComponent={null}
depth={DASHBOARD_ROOT_DEPTH}
index={0}
orientation="column"
onDrop={() => dispatch(handleComponentDrop)}
editMode={editMode}
// you cannot drop on/displace tabs if they already exist
disableDragdrop={!!topLevelTabs}
style={{
zIndex: 100,
...style,
}}
>
{({ dropIndicatorProps }: { dropIndicatorProps: JsonObject }) => (
<div>
{!hideDashboardHeader && <DashboardHeader />}
{dropIndicatorProps && <div {...dropIndicatorProps} />}
{topLevelTabs && (
<WithPopoverMenu
shouldFocus={shouldFocusTabs}
menuItems={[
<IconButton
className="fa fa-level-down"
label="Collapse tab content"
onClick={handleDeleteTopLevelTabs}
/>,
]}
editMode={editMode}
>
{/*
// @ts-ignore */}
<DashboardComponent
id={topLevelTabs?.id}
parentId={DASHBOARD_ROOT_ID}
depth={DASHBOARD_ROOT_DEPTH + 1}
index={0}
renderTabContent={false}
renderHoverMenu={false}
onChangeTab={handleChangeTab}
/>
</WithPopoverMenu>
)}
</div>
)}
</DragDroppable>
)}
</Sticky>
<StyledDashboardContent
className="dashboard-content"
dashboardFiltersOpen={dashboardFiltersOpen}
>
{isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS) && !editMode && (
<StickyVerticalBar
filtersOpen={dashboardFiltersOpen}
topOffset={barTopOffset}
>
<ErrorBoundary>
<FilterBar
filtersOpen={dashboardFiltersOpen}
toggleFiltersBar={toggleDashboardFiltersOpen}
directPathToChild={directPathToChild}
/>
</ErrorBoundary>
</StickyVerticalBar>
)}
<DashboardContainer
topLevelTabs={topLevelTabs}
handleChangeTab={handleChangeTab}
/>
{editMode && <BuilderComponentPane topOffset={barTopOffset} />}
</StyledDashboardContent>
<ToastPresenter />
</StickyContainer>
);
};
export default DashboardBuilder;

View File

@ -0,0 +1,106 @@
/**
* 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.
*/
// ParentSize uses resize observer so the dashboard will update size
// when its container size changes, due to e.g., builder side panel opening
import { ParentSize } from '@vx/responsive';
import { TabContainer, TabContent, TabPane } from 'react-bootstrap';
import React, { FC, SyntheticEvent, useEffect, useState } from 'react';
import { 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';
import {
DASHBOARD_GRID_ID,
DASHBOARD_ROOT_DEPTH,
} from 'src/dashboard/util/constants';
import { getRootLevelTabIndex } from './utils';
type DashboardContainerProps = {
topLevelTabs?: LayoutItem;
handleChangeTab: (event: SyntheticEvent<TabContainer, Event>) => void;
};
const DashboardContainer: FC<DashboardContainerProps> = ({
topLevelTabs,
handleChangeTab,
}) => {
const dashboardLayout = useSelector<RootState, DashboardLayout>(
state => state.dashboardLayout.present,
);
const directPathToChild = useSelector<RootState, string[]>(
state => state.dashboardState.directPathToChild,
);
const [tabIndex, setTabIndex] = useState(
getRootLevelTabIndex(dashboardLayout, directPathToChild),
);
useEffect(() => {
setTabIndex(getRootLevelTabIndex(dashboardLayout, directPathToChild));
}, [getLeafComponentIdFromPath(directPathToChild)]);
const childIds: string[] = topLevelTabs
? topLevelTabs.children
: [DASHBOARD_GRID_ID];
return (
<div className="grid-container" data-test="grid-container">
<ParentSize>
{({ width }) => (
/*
We use a TabContainer irrespective of whether top-level tabs exist to maintain
a consistent React component tree. This avoids expensive mounts/unmounts of
the entire dashboard upon adding/removing top-level tabs, which would otherwise
happen because of React's diffing algorithm
*/
<TabContainer
id={DASHBOARD_GRID_ID}
activeKey={Math.min(tabIndex, childIds.length - 1)}
onSelect={handleChangeTab}
// @ts-ignore
animation
mountOnEnter
unmountOnExit={false}
>
<TabContent>
{childIds.map((id, index) => (
// Matching the key of the first TabPane irrespective of topLevelTabs
// lets us keep the same React component tree when !!topLevelTabs changes.
// This avoids expensive mounts/unmounts of the entire dashboard.
<TabPane
key={index === 0 ? DASHBOARD_GRID_ID : id}
eventKey={index}
>
<DashboardGrid
gridComponent={dashboardLayout[id]}
// see isValidChild for why tabs do not increment the depth of their children
depth={DASHBOARD_ROOT_DEPTH + 1} // (topLevelTabs ? 0 : 1)}
width={width}
isComponentVisible={index === tabIndex}
/>
</TabPane>
))}
</TabContent>
</TabContainer>
)}
</ParentSize>
</div>
);
};
export default DashboardContainer;

View File

@ -0,0 +1,53 @@
/**
* 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 {
DASHBOARD_GRID_ID,
DASHBOARD_ROOT_ID,
} from 'src/dashboard/util/constants';
import { DashboardLayout } from 'src/dashboard/types';
import findTabIndexByComponentId from 'src/dashboard/util/findTabIndexByComponentId';
export const getRootLevelTabsComponent = (dashboardLayout: DashboardLayout) => {
const dashboardRoot = dashboardLayout[DASHBOARD_ROOT_ID];
const rootChildId = dashboardRoot.children[0];
return rootChildId === DASHBOARD_GRID_ID
? dashboardLayout[DASHBOARD_ROOT_ID]
: dashboardLayout[rootChildId];
};
export const shouldFocusTabs = (
event: { target: { className: string } },
container: { contains: (arg0: any) => any },
) =>
// don't focus the tabs when we click on a tab
event.target.className === 'ant-tabs-nav-wrap' ||
(/icon-button/.test(event.target.className) &&
container.contains(event.target));
export const getRootLevelTabIndex = (
dashboardLayout: DashboardLayout,
directPathToChild: string[],
): number =>
Math.max(
0,
findTabIndexByComponentId({
currentComponent: getRootLevelTabsComponent(dashboardLayout),
directPathToChild,
}),
);

View File

@ -50,7 +50,7 @@ const Contents = styled.div`
export interface SVBProps {
topOffset: number;
width: number;
width?: number;
filtersOpen: boolean;
}

View File

@ -37,7 +37,7 @@ const propTypes = {
component: componentShape.isRequired,
parentComponent: componentShape,
depth: PropTypes.number.isRequired,
disableDragDrop: PropTypes.bool,
disableDragdrop: PropTypes.bool,
orientation: PropTypes.oneOf(['row', 'column']),
index: PropTypes.number.isRequired,
style: PropTypes.object,
@ -58,7 +58,7 @@ const defaultProps = {
className: null,
style: null,
parentComponent: null,
disableDragDrop: false,
disableDragdrop: false,
children() {},
onDrop() {},
orientation: 'row',

View File

@ -19,8 +19,8 @@
import React from 'react';
import { styled, DataMask } from '@superset-ui/core';
import Icon from 'src/components/Icon';
import FilterControl from './FilterControl';
import { Filter } from '../types';
import FilterControl from '../FilterControls/FilterControl';
import { Filter } from '../../types';
import { CascadeFilter } from './types';
interface CascadeFilterControlProps {

View File

@ -24,10 +24,10 @@ import { Pill } from 'src/dashboard/components/FiltersBadge/Styles';
import { useSelector } from 'react-redux';
import { getInitialMask } from 'src/dataMask/reducer';
import { MaskWithId } from 'src/dataMask/types';
import FilterControl from './FilterControl';
import FilterControl from '../FilterControls/FilterControl';
import CascadeFilterControl from './CascadeFilterControl';
import { CascadeFilter } from './types';
import { Filter } from '../types';
import { Filter } from '../../types';
interface CascadePopoverProps {
filter: CascadeFilter;

View File

@ -0,0 +1,24 @@
/**
* 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 { Filter } from '../../types';
export interface CascadeFilter extends Filter {
cascadeChildren: CascadeFilter[];
}

View File

@ -19,25 +19,29 @@
/* eslint-disable no-param-reassign */
import { HandlerFunction, styled, t } from '@superset-ui/core';
import React, { useState, useEffect, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import React, { useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import cx from 'classnames';
import Button from 'src/components/Button';
import Icon from 'src/components/Icon';
import { Tabs } from 'src/common/components';
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
import { updateDataMask } from 'src/dataMask/actions';
import { DataMaskUnit, DataMaskState } from 'src/dataMask/types';
import { DataMaskState, DataMaskUnit } from 'src/dataMask/types';
import { useImmer } from 'use-immer';
import { getInitialMask } from 'src/dataMask/reducer';
import { areObjectsEqual } from 'src/reduxUtils';
import FilterConfigurationLink from './FilterConfigurationLink';
import { Filter } from '../types';
import { buildCascadeFiltersTree, mapParentFiltersToChildren } from './utils';
import CascadePopover from './CascadePopover';
import { mapParentFiltersToChildren, TabIds } from './utils';
import FilterSets from './FilterSets/FilterSets';
import { useDataMask, useFilters, useFilterSets } from './state';
import {
useDataMask,
useFilters,
useFilterSets,
useFiltersInitialisation,
useFilterUpdates,
} from './state';
import EditSection from './FilterSets/EditSection';
import Header from './Header';
import FilterControls from './FilterControls/FilterControls';
const barWidth = `250px`;
@ -122,18 +126,6 @@ const StyledCollapseIcon = styled(Icon)`
margin-bottom: ${({ theme }) => theme.gridUnit * 3}px;
`;
const TitleArea = styled.h4`
display: flex;
flex-direction: row;
justify-content: space-between;
margin: 0;
padding: ${({ theme }) => theme.gridUnit * 2}px;
& > span {
flex-grow: 1;
}
`;
const StyledTabs = styled(Tabs)`
& .ant-tabs-nav-list {
width: 100%;
@ -146,39 +138,12 @@ const StyledTabs = styled(Tabs)`
}
`;
const ActionButtons = styled.div`
display: grid;
flex-direction: row;
justify-content: center;
align-items: center;
grid-gap: 10px;
grid-template-columns: 1fr 1fr;
${({ theme }) =>
`padding: 0 ${theme.gridUnit * 2}px ${theme.gridUnit * 2}px`};
.btn {
flex: 1;
}
`;
const FilterControls = styled.div`
padding: ${({ theme }) => theme.gridUnit * 4}px;
&:hover {
cursor: pointer;
}
`;
interface FiltersBarProps {
filtersOpen: boolean;
toggleFiltersBar: any;
directPathToChild?: string[];
}
enum TabIds {
AllFilters = 'allFilters',
FilterSets = 'filterSets',
}
const FilterBar: React.FC<FiltersBarProps> = ({
filtersOpen,
toggleFiltersBar,
@ -193,90 +158,17 @@ const FilterBar: React.FC<FiltersBarProps> = ({
const dispatch = useDispatch();
const filterSets = useFilterSets();
const filterSetFilterValues = Object.values(filterSets);
const [isFilterSetChanged, setIsFilterSetChanged] = useState(false);
const [tab, setTab] = useState(TabIds.AllFilters);
const filters = useFilters();
const filterValues = Object.values<Filter>(filters);
const dataMaskApplied = useDataMask();
const canEdit = useSelector<any, boolean>(
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
);
const [visiblePopoverId, setVisiblePopoverId] = useState<string | null>(null);
const [isInitialized, setIsInitialized] = useState<boolean>(false);
const handleApply = () => {
const filterIds = Object.keys(dataMaskSelected);
filterIds.forEach(filterId => {
if (dataMaskSelected[filterId]) {
dispatch(
updateDataMask(filterId, {
nativeFilters: dataMaskSelected[filterId],
}),
);
}
});
setLastAppliedFilterData(() => dataMaskSelected);
};
useEffect(() => {
if (isInitialized) {
return;
}
const areFiltersInitialized = filterValues.every(filterValue =>
areObjectsEqual(
filterValue?.defaultValue,
dataMaskSelected[filterValue?.id]?.currentState?.value,
),
);
if (areFiltersInitialized) {
handleApply();
setIsInitialized(true);
}
}, [filterValues, dataMaskSelected, isInitialized]);
useEffect(() => {
if (filterValues.length === 0 && filtersOpen) {
toggleFiltersBar(false);
}
}, [filterValues.length]);
useEffect(() => {
// Remove deleted filters from local state
Object.keys(dataMaskSelected).forEach(selectedId => {
if (!filters[selectedId]) {
setDataMaskSelected(draft => {
delete draft[selectedId];
});
}
});
Object.keys(dataMaskApplied).forEach(appliedId => {
if (!filters[appliedId]) {
setLastAppliedFilterData(draft => {
delete draft[appliedId];
});
}
});
}, [
dataMaskApplied,
dataMaskSelected,
filters,
setDataMaskSelected,
setLastAppliedFilterData,
]);
const [isFilterSetChanged, setIsFilterSetChanged] = useState(false);
const cascadeChildren = useMemo(
() => mapParentFiltersToChildren(filterValues),
[filterValues],
);
const cascadeFilters = useMemo(() => {
const filtersWithValue = filterValues.map(filter => ({
...filter,
currentValue: dataMaskSelected[filter.id]?.currentState?.value,
}));
return buildCascadeFiltersTree(filtersWithValue);
}, [filterValues, dataMaskSelected]);
const handleFilterSelectionChange = (
filter: Pick<Filter, 'id'> & Partial<Filter>,
dataMask: Partial<DataMaskState>,
@ -295,36 +187,29 @@ const FilterBar: React.FC<FiltersBarProps> = ({
});
};
const handleClearAll = () => {
filterValues.forEach(filter => {
setDataMaskSelected(draft => {
draft[filter.id] = getInitialMask(filter.id);
});
const handleApply = () => {
const filterIds = Object.keys(dataMaskSelected);
filterIds.forEach(filterId => {
if (dataMaskSelected[filterId]) {
dispatch(
updateDataMask(filterId, {
nativeFilters: dataMaskSelected[filterId],
}),
);
}
});
setLastAppliedFilterData(() => dataMaskSelected);
};
const isClearAllDisabled = Object.values(dataMaskApplied).every(
filter =>
dataMaskSelected[filter.id]?.currentState?.value === null ||
(!dataMaskSelected[filter.id] && filter.currentState?.value === null),
const { isInitialized } = useFiltersInitialisation(
dataMaskSelected,
handleApply,
);
const getFilterControls = () => (
<FilterControls>
{cascadeFilters.map(filter => (
<CascadePopover
data-test="cascade-filters-control"
key={filter.id}
visible={visiblePopoverId === filter.id}
onVisibleChange={visible =>
setVisiblePopoverId(visible ? filter.id : null)
}
filter={filter}
onFilterSelectionChange={handleFilterSelectionChange}
directPathToChild={directPathToChild}
/>
))}
</FilterControls>
useFilterUpdates(
dataMaskSelected,
setDataMaskSelected,
setLastAppliedFilterData,
);
const isApplyDisabled =
@ -340,38 +225,14 @@ const FilterBar: React.FC<FiltersBarProps> = ({
<Icon name="filter" />
</CollapsedBar>
<Bar className={cx({ open: filtersOpen })}>
<TitleArea>
<span>{t('Filters')}</span>
{canEdit && (
<FilterConfigurationLink
createNewOnOpen={filterValues.length === 0}
>
<Icon name="edit" data-test="create-filter" />
</FilterConfigurationLink>
)}
<Icon name="expand" onClick={() => toggleFiltersBar(false)} />
</TitleArea>
<ActionButtons>
<Button
disabled={isClearAllDisabled}
buttonStyle="tertiary"
buttonSize="small"
onClick={handleClearAll}
data-test="filter-reset-button"
>
{t('Clear all')}
</Button>
<Button
disabled={isApplyDisabled}
buttonStyle="primary"
htmlType="submit"
buttonSize="small"
onClick={handleApply}
data-test="filter-apply-button"
>
{t('Apply')}
</Button>
</ActionButtons>
<Header
toggleFiltersBar={toggleFiltersBar}
onApply={handleApply}
setDataMaskSelected={setDataMaskSelected}
isApplyDisabled={isApplyDisabled}
dataMaskSelected={dataMaskSelected}
dataMaskApplied={dataMaskApplied}
/>
{isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS_SET) ? (
<StyledTabs
centered
@ -391,7 +252,11 @@ const FilterBar: React.FC<FiltersBarProps> = ({
filterSetId={editFilterSetId}
/>
)}
{getFilterControls()}
<FilterControls
dataMaskSelected={dataMaskSelected}
directPathToChild={directPathToChild}
onFilterSelectionChange={handleFilterSelectionChange}
/>
</Tabs.TabPane>
<Tabs.TabPane
disabled={!!editFilterSetId}
@ -408,7 +273,11 @@ const FilterBar: React.FC<FiltersBarProps> = ({
</Tabs.TabPane>
</StyledTabs>
) : (
getFilterControls()
<FilterControls
dataMaskSelected={dataMaskSelected}
directPathToChild={directPathToChild}
onFilterSelectionChange={handleFilterSelectionChange}
/>
)}
</Bar>
</BarWrapper>

View File

@ -0,0 +1,76 @@
/**
* 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, useMemo, useState } from 'react';
import { DataMaskUnit } from 'src/dataMask/types';
import { DataMask, styled } from '@superset-ui/core';
import CascadePopover from '../CascadeFilters/CascadePopover';
import { buildCascadeFiltersTree } from './utils';
import { useFilters } from '../state';
import { Filter } from '../../types';
const Wrapper = styled.div`
padding: ${({ theme }) => theme.gridUnit * 4}px;
&:hover {
cursor: pointer;
}
`;
type FilterControlsProps = {
directPathToChild?: string[];
dataMaskSelected: DataMaskUnit;
onFilterSelectionChange: (filter: Filter, dataMask: DataMask) => void;
};
const FilterControls: FC<FilterControlsProps> = ({
directPathToChild,
dataMaskSelected,
onFilterSelectionChange,
}) => {
const [visiblePopoverId, setVisiblePopoverId] = useState<string | null>(null);
const filters = useFilters();
const filterValues = Object.values<Filter>(filters);
const cascadeFilters = useMemo(() => {
const filtersWithValue = filterValues.map(filter => ({
...filter,
currentValue: dataMaskSelected[filter.id]?.currentState?.value,
}));
return buildCascadeFiltersTree(filtersWithValue);
}, [filterValues, dataMaskSelected]);
return (
<Wrapper>
{cascadeFilters.map(filter => (
<CascadePopover
data-test="cascade-filters-control"
key={filter.id}
visible={visiblePopoverId === filter.id}
onVisibleChange={visible =>
setVisiblePopoverId(visible ? filter.id : null)
}
filter={filter}
onFilterSelectionChange={onFilterSelectionChange}
directPathToChild={directPathToChild}
/>
))}
</Wrapper>
);
};
export default FilterControls;

View File

@ -30,7 +30,7 @@ import { getChartDataRequest } from 'src/chart/chartAction';
import Loading from 'src/components/Loading';
import BasicErrorAlert from 'src/components/ErrorMessage/BasicErrorAlert';
import { FilterProps } from './types';
import { getFormData } from '../utils';
import { getFormData } from '../../utils';
import { useCascadingFilters } from './state';
const StyledLoadingBox = styled.div`

View File

@ -0,0 +1,39 @@
/**
* 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 { useSelector } from 'react-redux';
import { NativeFiltersState } from 'src/dashboard/reducers/types';
import { mergeExtraFormData } from '../../utils';
import { useDataMask } from '../state';
// eslint-disable-next-line import/prefer-default-export
export function useCascadingFilters(id: string) {
const { filters } = useSelector<any, NativeFiltersState>(
state => state.nativeFilters,
);
const filter = filters[id];
const cascadeParentIds: string[] = filter?.cascadeParentIds ?? [];
let cascadedFilters = {};
const nativeFilters = useDataMask();
cascadeParentIds.forEach(parentId => {
const parentState = nativeFilters[parentId] || {};
const { extraFormData: parentExtra = {} } = parentState;
cascadedFilters = mergeExtraFormData(cascadedFilters, parentExtra);
});
return cascadedFilters;
}

View File

@ -18,7 +18,7 @@
*/
import React from 'react';
import { DataMask } from '@superset-ui/core';
import { Filter } from '../types';
import { Filter } from '../../types';
export interface FilterProps {
filter: Filter;
@ -26,7 +26,3 @@ export interface FilterProps {
directPathToChild?: string[];
onFilterSelectionChange: (filter: Filter, dataMask: DataMask) => void;
}
export interface CascadeFilter extends Filter {
cascadeChildren: CascadeFilter[];
}

View File

@ -0,0 +1,38 @@
/**
* 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 { Filter } from '../../types';
import { CascadeFilter } from '../CascadeFilters/types';
import { mapParentFiltersToChildren } from '../utils';
// eslint-disable-next-line import/prefer-default-export
export function buildCascadeFiltersTree(filters: Filter[]): CascadeFilter[] {
const cascadeChildren = mapParentFiltersToChildren(filters);
const getCascadeFilter = (filter: Filter): CascadeFilter => {
const children = cascadeChildren[filter.id] || [];
return {
...filter,
cascadeChildren: children.map(getCascadeFilter),
};
};
return filters
.filter(filter => !filter.cascadeParentIds?.length)
.map(getCascadeFilter);
}

View File

@ -0,0 +1,131 @@
/**
* 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.
*/
/* eslint-disable no-param-reassign */
import { styled, t } from '@superset-ui/core';
import React, { FC } from 'react';
import Icon from 'src/components/Icon';
import Button from 'src/components/Button';
import { useSelector } from 'react-redux';
import { getInitialMask } from 'src/dataMask/reducer';
import { DataMaskUnit, DataMaskUnitWithId } from 'src/dataMask/types';
import FilterConfigurationLink from './FilterConfigurationLink';
import { useFilters } from './state';
import { Filter } from '../types';
const TitleArea = styled.h4`
display: flex;
flex-direction: row;
justify-content: space-between;
margin: 0;
padding: ${({ theme }) => theme.gridUnit * 2}px;
& > span {
flex-grow: 1;
}
`;
const ActionButtons = styled.div`
display: grid;
flex-direction: row;
justify-content: center;
align-items: center;
grid-gap: 10px;
grid-template-columns: 1fr 1fr;
${({ theme }) =>
`padding: 0 ${theme.gridUnit * 2}px ${theme.gridUnit * 2}px`};
.btn {
flex: 1;
}
`;
type HeaderProps = {
toggleFiltersBar: (arg0: boolean) => void;
onApply: () => void;
setDataMaskSelected: (arg0: (draft: DataMaskUnit) => void) => void;
dataMaskSelected: DataMaskUnit;
dataMaskApplied: DataMaskUnitWithId;
isApplyDisabled: boolean;
};
const Header: FC<HeaderProps> = ({
onApply,
isApplyDisabled,
dataMaskSelected,
dataMaskApplied,
setDataMaskSelected,
toggleFiltersBar,
}) => {
const filters = useFilters();
const filterValues = Object.values<Filter>(filters);
const canEdit = useSelector<any, boolean>(
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
);
const handleClearAll = () => {
filterValues.forEach(filter => {
setDataMaskSelected(draft => {
draft[filter.id] = getInitialMask(filter.id);
});
});
};
const isClearAllDisabled = Object.values(dataMaskApplied).every(
filter =>
dataMaskSelected[filter.id]?.currentState?.value === null ||
(!dataMaskSelected[filter.id] && filter.currentState?.value === null),
);
return (
<>
<TitleArea>
<span>{t('Filters')}</span>
{canEdit && (
<FilterConfigurationLink createNewOnOpen={filterValues.length === 0}>
<Icon name="edit" data-test="create-filter" />
</FilterConfigurationLink>
)}
<Icon name="expand" onClick={() => toggleFiltersBar(false)} />
</TitleArea>
<ActionButtons>
<Button
disabled={isClearAllDisabled}
buttonStyle="tertiary"
buttonSize="small"
onClick={handleClearAll}
data-test="filter-reset-button"
>
{t('Clear all')}
</Button>
<Button
disabled={isApplyDisabled}
buttonStyle="primary"
htmlType="submit"
buttonSize="small"
onClick={onApply}
data-test="filter-apply-button"
>
{t('Apply')}
</Button>
</ActionButtons>
</>
);
};
export default Header;

View File

@ -16,14 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable no-param-reassign */
import { useSelector } from 'react-redux';
import {
Filters,
FilterSets as FilterSetsType,
NativeFiltersState,
} from 'src/dashboard/reducers/types';
import { DataMaskUnitWithId } from 'src/dataMask/types';
import { mergeExtraFormData } from '../utils';
import { DataMaskUnit, DataMaskUnitWithId } from 'src/dataMask/types';
import { useEffect, useState } from 'react';
import { areObjectsEqual } from 'src/reduxUtils';
import { Filter } from '../types';
export const useFilterSets = () =>
useSelector<any, FilterSetsType>(
@ -36,18 +38,63 @@ export const useFilters = () =>
export const useDataMask = () =>
useSelector<any, DataMaskUnitWithId>(state => state.dataMask.nativeFilters);
export function useCascadingFilters(id: string) {
const { filters } = useSelector<any, NativeFiltersState>(
state => state.nativeFilters,
);
const filter = filters[id];
const cascadeParentIds: string[] = filter?.cascadeParentIds ?? [];
let cascadedFilters = {};
const nativeFilters = useDataMask();
cascadeParentIds.forEach(parentId => {
const parentState = nativeFilters[parentId] || {};
const { extraFormData: parentExtra = {} } = parentState;
cascadedFilters = mergeExtraFormData(cascadedFilters, parentExtra);
});
return cascadedFilters;
}
export const useFiltersInitialisation = (
dataMaskSelected: DataMaskUnit,
handleApply: () => void,
) => {
const [isInitialized, setIsInitialized] = useState<boolean>(false);
const filters = useFilters();
const filterValues = Object.values<Filter>(filters);
useEffect(() => {
if (isInitialized) {
return;
}
const areFiltersInitialized = filterValues.every(filterValue =>
areObjectsEqual(
filterValue?.defaultValue,
dataMaskSelected[filterValue?.id]?.currentState?.value,
),
);
if (areFiltersInitialized) {
handleApply();
setIsInitialized(true);
}
}, [filterValues, dataMaskSelected, isInitialized]);
return {
isInitialized,
};
};
export const useFilterUpdates = (
dataMaskSelected: DataMaskUnit,
setDataMaskSelected: (arg0: (arg0: DataMaskUnit) => void) => void,
setLastAppliedFilterData: (arg0: (arg0: DataMaskUnit) => void) => void,
) => {
const filters = useFilters();
const dataMaskApplied = useDataMask();
useEffect(() => {
// Remove deleted filters from local state
Object.keys(dataMaskSelected).forEach(selectedId => {
if (!filters[selectedId]) {
setDataMaskSelected(draft => {
delete draft[selectedId];
});
}
});
Object.keys(dataMaskApplied).forEach(appliedId => {
if (!filters[appliedId]) {
setLastAppliedFilterData(draft => {
delete draft[appliedId];
});
}
});
}, [
dataMaskApplied,
dataMaskSelected,
filters,
setDataMaskSelected,
setLastAppliedFilterData,
]);
};

View File

@ -18,7 +18,11 @@
*/
import { Filter } from '../types';
import { CascadeFilter } from './types';
export enum TabIds {
AllFilters = 'allFilters',
FilterSets = 'filterSets',
}
export function mapParentFiltersToChildren(
filters: Filter[],
@ -35,19 +39,3 @@ export function mapParentFiltersToChildren(
});
return cascadeChildren;
}
export function buildCascadeFiltersTree(filters: Filter[]): CascadeFilter[] {
const cascadeChildren = mapParentFiltersToChildren(filters);
const getCascadeFilter = (filter: Filter): CascadeFilter => {
const children = cascadeChildren[filter.id] || [];
return {
...filter,
cascadeChildren: children.map(getCascadeFilter),
};
};
return filters
.filter(filter => !filter.cascadeParentIds?.length)
.map(getCascadeFilter);
}

View File

@ -1,57 +0,0 @@
/**
* 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 { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import DashboardBuilder from '../components/DashboardBuilder';
import {
setColorSchemeAndUnsavedChanges,
showBuilderPane,
setDirectPathToChild,
} from '../actions/dashboardState';
import {
deleteTopLevelTabs,
handleComponentDrop,
} from '../actions/dashboardLayout';
function mapStateToProps({ dashboardLayout: undoableLayout, dashboardState }) {
return {
dashboardLayout: undoableLayout.present,
editMode: dashboardState.editMode,
showBuilderPane: dashboardState.showBuilderPane,
directPathToChild: dashboardState.directPathToChild,
colorScheme: dashboardState.colorScheme,
focusedFilterField: dashboardState.focusedFilterField,
};
}
function mapDispatchToProps(dispatch) {
return bindActionCreators(
{
deleteTopLevelTabs,
handleComponentDrop,
showBuilderPane,
setColorSchemeAndUnsavedChanges,
setDirectPathToChild,
},
dispatch,
);
}
export default connect(mapStateToProps, mapDispatchToProps)(DashboardBuilder);

View File

@ -38,6 +38,13 @@ import {
import { setDirectPathToChild } from '../actions/dashboardState';
const propTypes = {
id: PropTypes.string,
parentId: PropTypes.string,
depth: PropTypes.number,
index: PropTypes.number,
renderHoverMenu: PropTypes.bool,
renderTabContent: PropTypes.bool,
onChangeTab: PropTypes.func,
component: componentShape.isRequired,
parentComponent: componentShape.isRequired,
createComponent: PropTypes.func.isRequired,

View File

@ -40,11 +40,16 @@ export type Chart = {
};
};
export type DashboardLayout = { [key: string]: LayoutItem };
export type DashboardLayoutState = { present: DashboardLayout };
export type DashboardState = { editMode: boolean; directPathToChild: string[] };
/** Root state of redux */
export type RootState = {
charts: { [key: string]: Chart };
dashboardLayout: { present: { [key: string]: LayoutItem } };
dashboardLayout: DashboardLayoutState;
dashboardFilters: {};
dashboardState: DashboardState;
dataMask: DataMaskStateWithId;
};