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:
parent
6c3bfe80dc
commit
aa92c1e7eb
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
|
|
@ -50,7 +50,7 @@ const Contents = styled.div`
|
|||
|
||||
export interface SVBProps {
|
||||
topOffset: number;
|
||||
width: number;
|
||||
width?: number;
|
||||
filtersOpen: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -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;
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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`
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue