diff --git a/superset-frontend/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx b/superset-frontend/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx index 2b3d04dc6..57c066484 100644 --- a/superset-frontend/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx +++ b/superset-frontend/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx @@ -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 = ; - return useProvider - ? mount( - - {builder} - , - { - wrappingComponent: ThemeProvider, - wrappingComponentProps: { theme: supersetTheme }, - }, - ) - : shallow(builder); + function setup(overrideState = {}, overrideStore) { + const store = + overrideStore ?? + storeWithState({ + ...mockState, + dashboardLayout: undoableDashboardLayout, + ...overrideState, + }); + return mount( + + + + + , + { + 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', + ]); }); }); diff --git a/superset-frontend/spec/javascripts/dashboard/components/Dashboard_spec.jsx b/superset-frontend/spec/javascripts/dashboard/components/Dashboard_spec.jsx index c9c13b1a0..61730c5e9 100644 --- a/superset-frontend/spec/javascripts/dashboard/components/Dashboard_spec.jsx +++ b/superset-frontend/spec/javascripts/dashboard/components/Dashboard_spec.jsx @@ -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'; diff --git a/superset-frontend/src/dashboard/components/Dashboard.jsx b/superset-frontend/src/dashboard/components/Dashboard.jsx index a373d33e6..b504a8d20 100644 --- a/superset-frontend/src/dashboard/components/Dashboard.jsx +++ b/superset-frontend/src/dashboard/components/Dashboard.jsx @@ -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, diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder.jsx b/superset-frontend/src/dashboard/components/DashboardBuilder.jsx deleted file mode 100644 index 308ecfa8e..000000000 --- a/superset-frontend/src/dashboard/components/DashboardBuilder.jsx +++ /dev/null @@ -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 ( - - - {({ style }) => ( - - {({ dropIndicatorProps }) => ( -
- {!hideDashboardHeader && } - {dropIndicatorProps &&
} - {topLevelTabs && ( - , - ]} - editMode={editMode} - > - - - )} -
- )} - - )} - - - {isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS) && !editMode && ( - - - - - - )} -
- - {({ 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 - */ - - - {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. - - - - ))} - - - )} - -
- {editMode && ( - - )} -
- - - ); - } -} - -DashboardBuilder.propTypes = propTypes; -DashboardBuilder.defaultProps = defaultProps; -DashboardBuilder.childContextTypes = { - dragDropManager: PropTypes.object.isRequired, -}; - -export default DashboardBuilder; diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx new file mode 100644 index 000000000..17a2362d0 --- /dev/null +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx @@ -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 = () => { + const dispatch = useDispatch(); + const dashboardLayout = useSelector( + state => state.dashboardLayout.present, + ); + const editMode = useSelector( + state => state.dashboardState.editMode, + ); + const directPathToChild = useSelector( + state => state.dashboardState.directPathToChild, + ); + + const filters = useFilters(); + const filterValues = Object.values(filters); + + const [dashboardFiltersOpen, setDashboardFiltersOpen] = useState(true); + + const toggleDashboardFiltersOpen = (visible?: boolean) => { + setDashboardFiltersOpen(visible ?? !dashboardFiltersOpen); + }; + + const handleChangeTab = ({ + pathToTabIndex, + }: SyntheticEvent & { 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 ( + + + {({ style }) => ( + // @ts-ignore + dispatch(handleComponentDrop)} + editMode={editMode} + // you cannot drop on/displace tabs if they already exist + disableDragdrop={!!topLevelTabs} + style={{ + zIndex: 100, + ...style, + }} + > + {({ dropIndicatorProps }: { dropIndicatorProps: JsonObject }) => ( +
+ {!hideDashboardHeader && } + {dropIndicatorProps &&
} + {topLevelTabs && ( + , + ]} + editMode={editMode} + > + {/* + // @ts-ignore */} + + + )} +
+ )} + + )} + + + {isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS) && !editMode && ( + + + + + + )} + + {editMode && } + + + + ); +}; + +export default DashboardBuilder; diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx new file mode 100644 index 000000000..cd0120937 --- /dev/null +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx @@ -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) => void; +}; + +const DashboardContainer: FC = ({ + topLevelTabs, + handleChangeTab, +}) => { + const dashboardLayout = useSelector( + state => state.dashboardLayout.present, + ); + const directPathToChild = useSelector( + 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 ( +
+ + {({ 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 + */ + + + {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. + + + + ))} + + + )} + +
+ ); +}; + +export default DashboardContainer; diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/utils.ts b/superset-frontend/src/dashboard/components/DashboardBuilder/utils.ts new file mode 100644 index 000000000..999adf62a --- /dev/null +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/utils.ts @@ -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, + }), + ); diff --git a/superset-frontend/src/dashboard/components/StickyVerticalBar.tsx b/superset-frontend/src/dashboard/components/StickyVerticalBar.tsx index afb5a71a2..80e76282e 100644 --- a/superset-frontend/src/dashboard/components/StickyVerticalBar.tsx +++ b/superset-frontend/src/dashboard/components/StickyVerticalBar.tsx @@ -50,7 +50,7 @@ const Contents = styled.div` export interface SVBProps { topOffset: number; - width: number; + width?: number; filtersOpen: boolean; } diff --git a/superset-frontend/src/dashboard/components/dnd/DragDroppable.jsx b/superset-frontend/src/dashboard/components/dnd/DragDroppable.jsx index 55991462a..36be1833e 100644 --- a/superset-frontend/src/dashboard/components/dnd/DragDroppable.jsx +++ b/superset-frontend/src/dashboard/components/dnd/DragDroppable.jsx @@ -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', diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CascadeFilterControl.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CascadeFilters/CascadeFilterControl.tsx similarity index 95% rename from superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CascadeFilterControl.tsx rename to superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CascadeFilters/CascadeFilterControl.tsx index a0e6d673a..3b5ce92b6 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CascadeFilterControl.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CascadeFilters/CascadeFilterControl.tsx @@ -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 { diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CascadePopover.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CascadeFilters/CascadePopover.tsx similarity index 98% rename from superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CascadePopover.tsx rename to superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CascadeFilters/CascadePopover.tsx index 430003bb1..5792ece91 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CascadePopover.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CascadeFilters/CascadePopover.tsx @@ -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; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CascadeFilters/types.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CascadeFilters/types.ts new file mode 100644 index 000000000..435404669 --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CascadeFilters/types.ts @@ -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[]; +} diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.tsx index 2e3814760..ef30ab807 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.tsx @@ -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 = ({ filtersOpen, toggleFiltersBar, @@ -193,90 +158,17 @@ const FilterBar: React.FC = ({ 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(filters); const dataMaskApplied = useDataMask(); - const canEdit = useSelector( - ({ dashboardInfo }) => dashboardInfo.dash_edit_perm, - ); - const [visiblePopoverId, setVisiblePopoverId] = useState(null); - const [isInitialized, setIsInitialized] = useState(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 & Partial, dataMask: Partial, @@ -295,36 +187,29 @@ const FilterBar: React.FC = ({ }); }; - 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 = () => ( - - {cascadeFilters.map(filter => ( - - setVisiblePopoverId(visible ? filter.id : null) - } - filter={filter} - onFilterSelectionChange={handleFilterSelectionChange} - directPathToChild={directPathToChild} - /> - ))} - + useFilterUpdates( + dataMaskSelected, + setDataMaskSelected, + setLastAppliedFilterData, ); const isApplyDisabled = @@ -340,38 +225,14 @@ const FilterBar: React.FC = ({ - - {t('Filters')} - {canEdit && ( - - - - )} - toggleFiltersBar(false)} /> - - - - - +
{isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS_SET) ? ( = ({ filterSetId={editFilterSetId} /> )} - {getFilterControls()} + = ({ ) : ( - getFilterControls() + )} diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControl.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx similarity index 100% rename from superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControl.tsx rename to superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx new file mode 100644 index 000000000..424e58721 --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx @@ -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 = ({ + directPathToChild, + dataMaskSelected, + onFilterSelectionChange, +}) => { + const [visiblePopoverId, setVisiblePopoverId] = useState(null); + const filters = useFilters(); + const filterValues = Object.values(filters); + + const cascadeFilters = useMemo(() => { + const filtersWithValue = filterValues.map(filter => ({ + ...filter, + currentValue: dataMaskSelected[filter.id]?.currentState?.value, + })); + return buildCascadeFiltersTree(filtersWithValue); + }, [filterValues, dataMaskSelected]); + + return ( + + {cascadeFilters.map(filter => ( + + setVisiblePopoverId(visible ? filter.id : null) + } + filter={filter} + onFilterSelectionChange={onFilterSelectionChange} + directPathToChild={directPathToChild} + /> + ))} + + ); +}; + +export default FilterControls; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterValue.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx similarity index 98% rename from superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterValue.tsx rename to superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx index 9b5fb9207..6e7a4eaca 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterValue.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx @@ -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` diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/state.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/state.ts new file mode 100644 index 000000000..0a60c2c02 --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/state.ts @@ -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( + 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; +} diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/types.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/types.ts similarity index 89% rename from superset-frontend/src/dashboard/components/nativeFilters/FilterBar/types.ts rename to superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/types.ts index 896aa47a5..67e50d562 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/types.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/types.ts @@ -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[]; -} diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/utils.ts new file mode 100644 index 000000000..19b50ddf6 --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/utils.ts @@ -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); +} diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Header.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Header.tsx new file mode 100644 index 000000000..0f2282a99 --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Header.tsx @@ -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 = ({ + onApply, + isApplyDisabled, + dataMaskSelected, + dataMaskApplied, + setDataMaskSelected, + toggleFiltersBar, +}) => { + const filters = useFilters(); + const filterValues = Object.values(filters); + const canEdit = useSelector( + ({ 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 ( + <> + + {t('Filters')} + {canEdit && ( + + + + )} + toggleFiltersBar(false)} /> + + + + + + + ); +}; + +export default Header; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/state.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/state.ts index db51f30c5..6f3d10263 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/state.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/state.ts @@ -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( @@ -36,18 +38,63 @@ export const useFilters = () => export const useDataMask = () => useSelector(state => state.dataMask.nativeFilters); -export function useCascadingFilters(id: string) { - const { filters } = useSelector( - 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(false); + const filters = useFilters(); + const filterValues = Object.values(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, + ]); +}; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.ts index 71681a23b..dc7097a50 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.ts @@ -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); -} diff --git a/superset-frontend/src/dashboard/containers/DashboardBuilder.jsx b/superset-frontend/src/dashboard/containers/DashboardBuilder.jsx deleted file mode 100644 index dba43d189..000000000 --- a/superset-frontend/src/dashboard/containers/DashboardBuilder.jsx +++ /dev/null @@ -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); diff --git a/superset-frontend/src/dashboard/containers/DashboardComponent.jsx b/superset-frontend/src/dashboard/containers/DashboardComponent.jsx index e8331b270..4d5e14bc0 100644 --- a/superset-frontend/src/dashboard/containers/DashboardComponent.jsx +++ b/superset-frontend/src/dashboard/containers/DashboardComponent.jsx @@ -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, diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts index 696eda40b..e74fb43c2 100644 --- a/superset-frontend/src/dashboard/types.ts +++ b/superset-frontend/src/dashboard/types.ts @@ -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; };