This is an automated email from the ASF dual-hosted git repository.
yjc pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 1debacaaca feat(dashboard): Make FilterBar width resizable (#20778)
1debacaaca is described below
commit 1debacaaca156c6d63606f3c4aabce8adf13a837
Author: Just[in]Do it! <[email protected]>
AuthorDate: Wed Jul 20 09:56:55 2022 -0700
feat(dashboard): Make FilterBar width resizable (#20778)
* Add Resizable panel in DashboardBuilder to adjust the width for
FiltersPanel
* store the adjusted width for individual dashboard in localStorage to
memorize the state
* migrate DashboardBuilder test code by testing-library and jest
---
superset-frontend/spec/helpers/setup.ts | 2 +
superset-frontend/spec/helpers/testing-library.tsx | 24 +-
.../DashboardBuilder/DashboardBuilder.test.jsx | 196 ---------------
.../DashboardBuilder/DashboardBuilder.test.tsx | 280 +++++++++++++++++++++
.../DashboardBuilder/DashboardBuilder.tsx | 80 ++++--
.../useStoredFilterBarWidth.test.ts | 85 +++++++
.../DashboardBuilder/useStoredFilterBarWidth.ts | 51 ++++
.../src/dashboard/components/Header/index.jsx | 1 +
.../FilterBar/ActionButtons/ActionButtons.test.tsx | 26 ++
.../FilterBar/ActionButtons/index.tsx | 10 +-
.../components/nativeFilters/FilterBar/index.tsx | 1 +
superset-frontend/src/dashboard/constants.ts | 1 +
superset-frontend/src/utils/localStorageHelpers.ts | 2 +
13 files changed, 536 insertions(+), 223 deletions(-)
diff --git a/superset-frontend/spec/helpers/setup.ts
b/superset-frontend/spec/helpers/setup.ts
index c2c991f956..bd2961e23c 100644
--- a/superset-frontend/spec/helpers/setup.ts
+++ b/superset-frontend/spec/helpers/setup.ts
@@ -19,9 +19,11 @@
import 'jest-enzyme';
import './shim';
import { configure as configureTestingLibrary } from '@testing-library/react';
+import { matchers } from '@emotion/jest';
configureTestingLibrary({
testIdAttribute: 'data-test',
});
document.body.innerHTML = '<div id="app" data-bootstrap="{}"></div>';
+expect.extend(matchers);
diff --git a/superset-frontend/spec/helpers/testing-library.tsx
b/superset-frontend/spec/helpers/testing-library.tsx
index 56489cce84..b0e04902c2 100644
--- a/superset-frontend/spec/helpers/testing-library.tsx
+++ b/superset-frontend/spec/helpers/testing-library.tsx
@@ -22,7 +22,13 @@ import { render, RenderOptions } from
'@testing-library/react';
import { ThemeProvider, supersetTheme } from '@superset-ui/core';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
-import { combineReducers, createStore, applyMiddleware, compose } from 'redux';
+import {
+ combineReducers,
+ createStore,
+ applyMiddleware,
+ compose,
+ Store,
+} from 'redux';
import thunk from 'redux-thunk';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
@@ -36,6 +42,7 @@ type Options = Omit<RenderOptions, 'queries'> & {
useRouter?: boolean;
initialState?: {};
reducers?: {};
+ store?: Store;
};
function createWrapper(options?: Options) {
@@ -46,6 +53,7 @@ function createWrapper(options?: Options) {
useRouter,
initialState,
reducers,
+ store,
} = options || {};
return ({ children }: { children?: ReactNode }) => {
@@ -58,13 +66,15 @@ function createWrapper(options?: Options) {
}
if (useRedux) {
- const store = createStore(
- combineReducers(reducers || reducerIndex),
- initialState || {},
- compose(applyMiddleware(thunk)),
- );
+ const mockStore =
+ store ??
+ createStore(
+ combineReducers(reducers || reducerIndex),
+ initialState || {},
+ compose(applyMiddleware(thunk)),
+ );
- result = <Provider store={store}>{result}</Provider>;
+ result = <Provider store={mockStore}>{result}</Provider>;
}
if (useQueryParams) {
diff --git
a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.jsx
b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.jsx
deleted file mode 100644
index 937012a9f8..0000000000
---
a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.jsx
+++ /dev/null
@@ -1,196 +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 { Provider } from 'react-redux';
-import React from 'react';
-import { mount } from 'enzyme';
-import sinon from 'sinon';
-import fetchMock from 'fetch-mock';
-import { ParentSize } from '@vx/responsive';
-import { supersetTheme, ThemeProvider } from '@superset-ui/core';
-import Tabs from 'src/components/Tabs';
-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/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 { mockStoreWithTabs, storeWithState } from 'spec/fixtures/mockStore';
-import mockState from 'spec/fixtures/mockState';
-import {
- DASHBOARD_ROOT_ID,
- DASHBOARD_GRID_ID,
-} from 'src/dashboard/util/constants';
-
-fetchMock.get('glob:*/csstemplateasyncmodelview/api/read', {});
-
-jest.mock('src/dashboard/actions/dashboardState');
-
-describe('DashboardBuilder', () => {
- let favStarStub;
- let activeTabsStub;
-
- beforeAll(() => {
- // this is invoked on mount, so we stub it instead of making a request
- favStarStub = sinon
- .stub(dashboardStateActions, 'fetchFaveStar')
- .returns({ type: 'mock-action' });
- activeTabsStub = sinon
- .stub(dashboardStateActions, 'setActiveTabs')
- .returns({ type: 'mock-action' });
- });
-
- afterAll(() => {
- favStarStub.restore();
- activeTabsStub.restore();
- });
-
- 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"', () => {
- const wrapper = setup();
- const stickyContainer = wrapper.find('[data-test="dashboard-content"]');
- expect(stickyContainer).toHaveLength(1);
- expect(stickyContainer.prop('className')).toBe('dashboard');
- });
-
- it('should add the "dashboard--editing" class if editMode=true', () => {
- const wrapper = setup({ dashboardState: { editMode: true } });
- const stickyContainer = wrapper.find('[data-test="dashboard-content"]');
- expect(stickyContainer.prop('className')).toBe(
- 'dashboard dashboard--editing',
- );
- });
-
- it('should render a DragDroppable DashboardHeader', () => {
- 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: undoableDashboardLayoutWithTabs },
- mockStoreWithTabs,
- );
-
- const sticky = wrapper.find('[data-test="top-level-tabs"]');
- const dashboardComponent = sticky.find(DashboardComponent);
-
- const tabChildren =
- undoableDashboardLayoutWithTabs.present.TABS_ID.children;
- expect(dashboardComponent).toHaveLength(1 + tabChildren.length); // tab +
tabs
- expect(dashboardComponent.at(0).prop('id')).toBe('TABS_ID');
- tabChildren.forEach((tabId, i) => {
- expect(dashboardComponent.at(i + 1).prop('id')).toBe(tabId);
- });
- });
-
- it('should render one Tabs and two TabPane', () => {
- const wrapper = setup({ dashboardLayout: undoableDashboardLayoutWithTabs
});
- const parentSize = wrapper.find(ParentSize);
- expect(parentSize.find(Tabs)).toHaveLength(1);
- expect(parentSize.find(Tabs.TabPane)).toHaveLength(2);
- });
-
- 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(Tabs.TabPane)).toHaveLength(expectedCount);
- expect(
- parentSize.find(Tabs.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(Tabs.TabPane)).toHaveLength(expectedCount);
- expect(
- parentSize.find(Tabs.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({ dashboardState: { editMode: true } });
- expect(wrapper.find(BuilderComponentPane)).toExist();
- });
-
- it('should change redux state if a top-level Tab is clicked', () => {
- dashboardStateActions.setDirectPathToChild = jest.fn(arg0 => ({
- type: 'type',
- arg0,
- }));
- const wrapper = setup({
- ...mockStoreWithTabs,
- dashboardLayout: undoableDashboardLayoutWithTabs,
- });
-
- expect(wrapper.find(Tabs).at(1).prop('activeKey')).toBe(DASHBOARD_GRID_ID);
-
- wrapper
- .find('.dashboard-component-tabs .ant-tabs .ant-tabs-tab')
- .at(1)
- .simulate('click');
-
- expect(dashboardStateActions.setDirectPathToChild).toHaveBeenCalledWith([
- 'ROOT_ID',
- 'TABS_ID',
- 'TAB_ID2',
- ]);
- });
-});
diff --git
a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx
b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx
new file mode 100644
index 0000000000..b64a961092
--- /dev/null
+++
b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.test.tsx
@@ -0,0 +1,280 @@
+/**
+ * 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 { Store } from 'redux';
+import React from 'react';
+import fetchMock from 'fetch-mock';
+import { render } from 'spec/helpers/testing-library';
+import { fireEvent, within } from '@testing-library/react';
+import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
+import DashboardBuilder from
'src/dashboard/components/DashboardBuilder/DashboardBuilder';
+import useStoredFilterBarWidth from
'src/dashboard/components/DashboardBuilder/useStoredFilterBarWidth';
+import {
+ fetchFaveStar,
+ setActiveTabs,
+ setDirectPathToChild,
+} from 'src/dashboard/actions/dashboardState';
+import {
+ dashboardLayout as undoableDashboardLayout,
+ dashboardLayoutWithTabs as undoableDashboardLayoutWithTabs,
+} from 'spec/fixtures/mockDashboardLayout';
+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', () => ({
+ ...jest.requireActual('src/dashboard/actions/dashboardState'),
+ fetchFaveStar: jest.fn(),
+ setActiveTabs: jest.fn(),
+ setDirectPathToChild: jest.fn(),
+}));
+jest.mock('src/featureFlags');
+jest.mock('src/dashboard/components/DashboardBuilder/useStoredFilterBarWidth');
+
+// mock following dependant components to fix the prop warnings
+jest.mock('src/components/Icons/Icon', () => () => (
+ <div data-test="mock-icon" />
+));
+jest.mock('src/components/Select/WindowedSelect', () => () => (
+ <div data-test="mock-windowed-select" />
+));
+jest.mock('src/components/Select', () => () => (
+ <div data-test="mock-deprecated-select" />
+));
+jest.mock('src/components/Select/Select', () => () => (
+ <div data-test="mock-select" />
+));
+jest.mock('src/components/Select/AsyncSelect', () => () => (
+ <div data-test="mock-async-select" />
+));
+jest.mock('src/dashboard/components/Header/HeaderActionsDropdown', () => () =>
(
+ <div data-test="mock-header-actions-dropdown" />
+));
+jest.mock('src/components/PageHeaderWithActions', () => ({
+ PageHeaderWithActions: () => (
+ <div data-test="mock-page-header-with-actions" />
+ ),
+}));
+jest.mock(
+
'src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal',
+ () => () => <div data-test="mock-filters-config-modal" />,
+);
+jest.mock('src/dashboard/components/BuilderComponentPane', () => () => (
+ <div data-test="mock-builder-component-pane" />
+));
+jest.mock('src/dashboard/components/nativeFilters/FilterBar', () => () => (
+ <div data-test="mock-filter-bar" />
+));
+jest.mock('src/dashboard/containers/DashboardGrid', () => () => (
+ <div data-test="mock-dashboard-grid" />
+));
+
+describe('DashboardBuilder', () => {
+ let favStarStub: jest.Mock;
+ let activeTabsStub: jest.Mock;
+
+ beforeAll(() => {
+ // this is invoked on mount, so we stub it instead of making a request
+ favStarStub = (fetchFaveStar as jest.Mock).mockReturnValue({
+ type: 'mock-action',
+ });
+ activeTabsStub = (setActiveTabs as jest.Mock).mockReturnValue({
+ type: 'mock-action',
+ });
+ (useStoredFilterBarWidth as jest.Mock).mockImplementation(() => [
+ 100,
+ jest.fn(),
+ ]);
+ (isFeatureEnabled as jest.Mock).mockImplementation(() => false);
+ });
+
+ afterAll(() => {
+ favStarStub.mockReset();
+ activeTabsStub.mockReset();
+ (useStoredFilterBarWidth as jest.Mock).mockReset();
+ });
+
+ function setup(overrideState = {}, overrideStore?: Store) {
+ return render(<DashboardBuilder />, {
+ useRedux: true,
+ store: storeWithState({
+ ...mockState,
+ dashboardLayout: undoableDashboardLayout,
+ ...overrideState,
+ }),
+ useDnd: true,
+ });
+ }
+
+ it('should render a StickyContainer with class "dashboard"', () => {
+ const { getByTestId } = setup();
+ const stickyContainer = getByTestId('dashboard-content');
+ expect(stickyContainer).toHaveClass('dashboard');
+ });
+
+ it('should add the "dashboard--editing" class if editMode=true', () => {
+ const { getByTestId } = setup({
+ dashboardState: { ...mockState.dashboardState, editMode: true },
+ });
+ const stickyContainer = getByTestId('dashboard-content');
+ expect(stickyContainer).toHaveClass('dashboard dashboard--editing');
+ });
+
+ it('should render a DragDroppable DashboardHeader', () => {
+ const { queryByTestId } = setup();
+ const header = queryByTestId('dashboard-header-container');
+ expect(header).toBeTruthy();
+ });
+
+ it('should render a Sticky top-level Tabs if the dashboard has tabs', async
() => {
+ const { findAllByTestId } = setup(
+ { dashboardLayout: undoableDashboardLayoutWithTabs },
+ mockStoreWithTabs,
+ );
+ const sticky = await findAllByTestId('nav-list');
+
+ expect(sticky.length).toBe(1);
+ expect(sticky[0]).toHaveAttribute('id', 'TABS_ID');
+
+ const dashboardTabComponents = within(sticky[0]).getAllByRole('tab');
+ const tabChildren =
+ undoableDashboardLayoutWithTabs.present.TABS_ID.children;
+ expect(dashboardTabComponents.length).toBe(tabChildren.length);
+ tabChildren.forEach((tabId, i) => {
+ const idMatcher = new RegExp(`${tabId}$`);
+ expect(dashboardTabComponents[i]).toHaveAttribute(
+ 'id',
+ expect.stringMatching(idMatcher),
+ );
+ });
+ });
+
+ it('should render one Tabs and two TabPane', async () => {
+ const { findAllByRole } = setup({
+ dashboardLayout: undoableDashboardLayoutWithTabs,
+ });
+ const tabs = await findAllByRole('tablist');
+ expect(tabs.length).toBe(1);
+ const tabPanels = await findAllByRole('tabpanel');
+ expect(tabPanels.length).toBe(2);
+ });
+
+ it('should render a TabPane and DashboardGrid for first Tab', async () => {
+ const { findByTestId } = setup({
+ dashboardLayout: undoableDashboardLayoutWithTabs,
+ });
+ const parentSize = await findByTestId('grid-container');
+ const expectedCount =
+ undoableDashboardLayoutWithTabs.present.TABS_ID.children.length;
+ const tabPanels = within(parentSize).getAllByRole('tabpanel', {
+ // to include invisiable tab panels
+ hidden: true,
+ });
+ expect(tabPanels.length).toBe(expectedCount);
+ expect(
+ within(tabPanels[0]).getAllByTestId('mock-dashboard-grid').length,
+ ).toBe(1);
+ });
+
+ it('should render a TabPane and DashboardGrid for second Tab', async () => {
+ const { findByTestId } = setup({
+ dashboardLayout: undoableDashboardLayoutWithTabs,
+ dashboardState: {
+ ...mockState.dashboardState,
+ directPathToChild: [DASHBOARD_ROOT_ID, 'TABS_ID', 'TAB_ID2'],
+ },
+ });
+ const parentSize = await findByTestId('grid-container');
+ const expectedCount =
+ undoableDashboardLayoutWithTabs.present.TABS_ID.children.length;
+ const tabPanels = within(parentSize).getAllByRole('tabpanel', {
+ // to include invisiable tab panels
+ hidden: true,
+ });
+ expect(tabPanels.length).toBe(expectedCount);
+ expect(
+ within(tabPanels[1]).getAllByTestId('mock-dashboard-grid').length,
+ ).toBe(1);
+ });
+
+ it('should render a BuilderComponentPane if editMode=false and user selects
"Insert Components" pane', () => {
+ const { queryAllByTestId } = setup();
+ const builderComponents = queryAllByTestId('mock-builder-component-pane');
+ expect(builderComponents.length).toBe(0);
+ });
+
+ it('should render a BuilderComponentPane if editMode=true and user selects
"Insert Components" pane', () => {
+ const { queryAllByTestId } = setup({ dashboardState: { editMode: true } });
+ const builderComponents = queryAllByTestId('mock-builder-component-pane');
+ expect(builderComponents.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('should change redux state if a top-level Tab is clicked', async () => {
+ (setDirectPathToChild as jest.Mock).mockImplementation(arg0 => ({
+ type: 'type',
+ arg0,
+ }));
+ const { findByRole } = setup(
+ {
+ dashboardLayout: undoableDashboardLayoutWithTabs,
+ },
+ mockStoreWithTabs,
+ );
+ const tabList = await findByRole('tablist');
+ const tabs = within(tabList).getAllByRole('tab');
+ expect(setDirectPathToChild).toHaveBeenCalledTimes(0);
+ fireEvent.click(tabs[1]);
+ expect(setDirectPathToChild).toHaveBeenCalledWith([
+ 'ROOT_ID',
+ 'TABS_ID',
+ 'TAB_ID2',
+ ]);
+ (setDirectPathToChild as jest.Mock).mockReset();
+ });
+
+ describe('when nativeFiltersEnabled', () => {
+ beforeEach(() => {
+ (isFeatureEnabled as jest.Mock).mockImplementation(
+ flag => flag === FeatureFlag.DASHBOARD_NATIVE_FILTERS,
+ );
+ });
+ afterEach(() => {
+ (isFeatureEnabled as jest.Mock).mockReset();
+ });
+
+ it('should set FilterBar width by useStoredFilterBarWidth', () => {
+ const expectedValue = 200;
+ const setter = jest.fn();
+ (useStoredFilterBarWidth as jest.Mock).mockImplementation(() => [
+ expectedValue,
+ setter,
+ ]);
+ const { getByTestId } = setup({
+ dashboardInfo: {
+ ...mockState.dashboardInfo,
+ dash_edit_perm: true,
+ metadata: { show_native_filters: true },
+ },
+ });
+ const filterbar = getByTestId('dashboard-filters-panel');
+ expect(filterbar).toHaveStyleRule('width', `${expectedValue}px`);
+ });
+ });
+});
diff --git
a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx
b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx
index 8352482ed8..7ff6432d79 100644
---
a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx
+++
b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx
@@ -26,6 +26,7 @@ import React, {
useMemo,
useRef,
} from 'react';
+import { Resizable } from 're-resizable';
import { JsonObject, styled, css, t } from '@superset-ui/core';
import { Global } from '@emotion/react';
import { useDispatch, useSelector } from 'react-redux';
@@ -68,10 +69,12 @@ import {
FILTER_BAR_TABS_HEIGHT,
MAIN_HEADER_HEIGHT,
OPEN_FILTER_BAR_WIDTH,
+ OPEN_FILTER_BAR_MAX_WIDTH,
} from 'src/dashboard/constants';
import { shouldFocusTabs, getRootLevelTabsComponent } from './utils';
import DashboardContainer from './DashboardContainer';
import { useNativeFilters } from './state';
+import useStoredFilterBarWidth from './useStoredFilterBarWidth';
type DashboardBuilderProps = {};
@@ -125,10 +128,11 @@ const StyledDiv = styled.div`
`;
// @z-index-above-dashboard-charts + 1 = 11
-const FiltersPanel = styled.div`
+const FiltersPanel = styled.div<{ width: number }>`
grid-column: 1;
grid-row: 1 / span 2;
z-index: 11;
+ width: ${({ width }) => width}px;
`;
const StickyPanel = styled.div<{ width: number }>`
@@ -215,10 +219,34 @@ const StyledDashboardContent = styled.div<{
}
`;
+const ResizableFilterBarWrapper = styled.div`
+ position: absolute;
+
+ :hover .filterbar-resizer::after {
+ background-color: ${({ theme }) => theme.colors.primary.base};
+ }
+
+ .filterbar-resizer {
+ // @z-index-above-sticky-header (100) + 1 = 101
+ z-index: 101;
+ }
+
+ .filterbar-resizer::after {
+ display: block;
+ content: '';
+ width: 1px;
+ height: 100%;
+ margin: 0 auto;
+ }
+`;
+
const DashboardBuilder: FC<DashboardBuilderProps> = () => {
const dispatch = useDispatch();
const uiConfig = useUiConfig();
+ const dashboardId = useSelector<RootState, string>(
+ ({ dashboardInfo }) => `${dashboardInfo.id}`,
+ );
const dashboardLayout = useSelector<RootState, DashboardLayout>(
state => state.dashboardLayout.present,
);
@@ -298,8 +326,11 @@ const DashboardBuilder: FC<DashboardBuilderProps> = () => {
nativeFiltersEnabled,
} = useNativeFilters();
+ const [adjustedFilterBarWidth, setAdjustedFilterBarWidth] =
+ useStoredFilterBarWidth(dashboardId);
+
const filterBarWidth = dashboardFiltersOpen
- ? OPEN_FILTER_BAR_WIDTH
+ ? adjustedFilterBarWidth
: CLOSED_FILTER_BAR_WIDTH;
const [containerRef, isSticky] = useElementOnScreen<HTMLDivElement>({
@@ -392,20 +423,37 @@ const DashboardBuilder: FC<DashboardBuilderProps> = () =>
{
return (
<StyledDiv>
{nativeFiltersEnabled && !editMode && (
- <FiltersPanel>
- <StickyPanel ref={containerRef} width={filterBarWidth}>
- <ErrorBoundary>
- <FilterBar
- filtersOpen={dashboardFiltersOpen}
- toggleFiltersBar={toggleDashboardFiltersOpen}
- directPathToChild={directPathToChild}
- width={filterBarWidth}
- height={filterBarHeight}
- offset={filterBarOffset}
- />
- </ErrorBoundary>
- </StickyPanel>
- </FiltersPanel>
+ <>
+ <ResizableFilterBarWrapper>
+ <Resizable
+ enable={{ right: dashboardFiltersOpen }}
+ handleClasses={{ right: 'filterbar-resizer' }}
+ size={{ width: filterBarWidth, height: '100vh' }}
+ minWidth={OPEN_FILTER_BAR_WIDTH}
+ maxWidth={OPEN_FILTER_BAR_MAX_WIDTH}
+ onResizeStop={(e, direction, ref, d) =>
+ setAdjustedFilterBarWidth(filterBarWidth + d.width)
+ }
+ />
+ </ResizableFilterBarWrapper>
+ <FiltersPanel
+ width={filterBarWidth}
+ data-test="dashboard-filters-panel"
+ >
+ <StickyPanel ref={containerRef} width={filterBarWidth}>
+ <ErrorBoundary>
+ <FilterBar
+ filtersOpen={dashboardFiltersOpen}
+ toggleFiltersBar={toggleDashboardFiltersOpen}
+ directPathToChild={directPathToChild}
+ width={filterBarWidth}
+ height={filterBarHeight}
+ offset={filterBarOffset}
+ />
+ </ErrorBoundary>
+ </StickyPanel>
+ </FiltersPanel>
+ </>
)}
<StyledHeader ref={headerRef}>
{/* @ts-ignore */}
diff --git
a/superset-frontend/src/dashboard/components/DashboardBuilder/useStoredFilterBarWidth.test.ts
b/superset-frontend/src/dashboard/components/DashboardBuilder/useStoredFilterBarWidth.test.ts
new file mode 100644
index 0000000000..1a9da6658a
--- /dev/null
+++
b/superset-frontend/src/dashboard/components/DashboardBuilder/useStoredFilterBarWidth.test.ts
@@ -0,0 +1,85 @@
+/**
+ * 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 { renderHook, act } from '@testing-library/react-hooks';
+import {
+ LocalStorageKeys,
+ setItem,
+ getItem,
+} from 'src/utils/localStorageHelpers';
+import { OPEN_FILTER_BAR_WIDTH } from 'src/dashboard/constants';
+import useStoredFilterBarWidth from './useStoredFilterBarWidth';
+
+describe('useStoredFilterBarWidth', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ afterAll(() => {
+ localStorage.clear();
+ });
+
+ it('returns a default filterBar width by OPEN_FILTER_BAR_WIDTH', () => {
+ const dashboardId = '123';
+ const { result } = renderHook(() => useStoredFilterBarWidth(dashboardId));
+ const [actualWidth] = result.current;
+
+ expect(actualWidth).toEqual(OPEN_FILTER_BAR_WIDTH);
+ });
+
+ it('returns a stored filterBar width from localStorage', () => {
+ const dashboardId = '123';
+ const expectedWidth = 378;
+ setItem(LocalStorageKeys.dashboard__custom_filter_bar_widths, {
+ [dashboardId]: expectedWidth,
+ '456': 250,
+ });
+ const { result } = renderHook(() => useStoredFilterBarWidth(dashboardId));
+ const [actualWidth] = result.current;
+
+ expect(actualWidth).toEqual(expectedWidth);
+ expect(actualWidth).not.toEqual(250);
+ });
+
+ it('returns a setter for filterBar width that stores the state in
localStorage together', () => {
+ const dashboardId = '123';
+ const expectedWidth = 378;
+ const otherDashboardId = '456';
+ const otherDashboardWidth = 253;
+ setItem(LocalStorageKeys.dashboard__custom_filter_bar_widths, {
+ [dashboardId]: 300,
+ [otherDashboardId]: otherDashboardWidth,
+ });
+ const { result } = renderHook(() => useStoredFilterBarWidth(dashboardId));
+ const [prevWidth, setter] = result.current;
+
+ expect(prevWidth).toEqual(300);
+
+ act(() => setter(expectedWidth));
+
+ const updatedWidth = result.current[0];
+ const widthsMap = getItem(
+ LocalStorageKeys.dashboard__custom_filter_bar_widths,
+ {},
+ );
+ expect(widthsMap[dashboardId]).toEqual(expectedWidth);
+ expect(widthsMap[otherDashboardId]).toEqual(otherDashboardWidth);
+ expect(updatedWidth).toEqual(expectedWidth);
+ expect(updatedWidth).not.toEqual(250);
+ });
+});
diff --git
a/superset-frontend/src/dashboard/components/DashboardBuilder/useStoredFilterBarWidth.ts
b/superset-frontend/src/dashboard/components/DashboardBuilder/useStoredFilterBarWidth.ts
new file mode 100644
index 0000000000..0cbdc74182
--- /dev/null
+++
b/superset-frontend/src/dashboard/components/DashboardBuilder/useStoredFilterBarWidth.ts
@@ -0,0 +1,51 @@
+/**
+ * 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 { useEffect, useRef, useState } from 'react';
+import {
+ LocalStorageKeys,
+ setItem,
+ getItem,
+} from 'src/utils/localStorageHelpers';
+import { OPEN_FILTER_BAR_WIDTH } from 'src/dashboard/constants';
+
+export default function useStoredFilterBarWidth(dashboardId: string) {
+ const widthsMapRef = useRef<Record<string, number>>();
+ const [filterBarWidth, setFilterBarWidth] = useState<number>(
+ OPEN_FILTER_BAR_WIDTH,
+ );
+
+ useEffect(() => {
+ widthsMapRef.current =
+ widthsMapRef.current ??
+ getItem(LocalStorageKeys.dashboard__custom_filter_bar_widths, {});
+ if (widthsMapRef.current[dashboardId]) {
+ setFilterBarWidth(widthsMapRef.current[dashboardId]);
+ }
+ }, [dashboardId]);
+
+ function setStoredFilterBarWidth(updatedWidth: number) {
+ setFilterBarWidth(updatedWidth);
+ setItem(LocalStorageKeys.dashboard__custom_filter_bar_widths, {
+ ...widthsMapRef.current,
+ [dashboardId]: updatedWidth,
+ });
+ }
+
+ return [filterBarWidth, setStoredFilterBarWidth] as const;
+}
diff --git a/superset-frontend/src/dashboard/components/Header/index.jsx
b/superset-frontend/src/dashboard/components/Header/index.jsx
index 8601cece15..0699304b2c 100644
--- a/superset-frontend/src/dashboard/components/Header/index.jsx
+++ b/superset-frontend/src/dashboard/components/Header/index.jsx
@@ -486,6 +486,7 @@ class Header extends React.PureComponent {
return (
<div
css={headerContainerStyle}
+ data-test="dashboard-header-container"
data-test-id={dashboardInfo.id}
className="dashboard-header-container"
>
diff --git
a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/ActionButtons/ActionButtons.test.tsx
b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/ActionButtons/ActionButtons.test.tsx
index 77aede5be3..3c3f838c4a 100644
---
a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/ActionButtons/ActionButtons.test.tsx
+++
b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/ActionButtons/ActionButtons.test.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
import React from 'react';
+import { OPEN_FILTER_BAR_WIDTH } from 'src/dashboard/constants';
import userEvent from '@testing-library/user-event';
import { render, screen } from 'spec/helpers/testing-library';
import { ActionButtons } from './index';
@@ -77,3 +78,28 @@ test('should apply', () => {
userEvent.click(applyBtn);
expect(mockedProps.onApply).toHaveBeenCalled();
});
+
+describe('custom width', () => {
+ it('sets its default width with OPEN_FILTER_BAR_WIDTH', () => {
+ const mockedProps = createProps();
+ render(<ActionButtons {...mockedProps} />, { useRedux: true });
+ const container = screen.getByTestId('filterbar-action-buttons');
+ expect(container).toHaveStyleRule(
+ 'width',
+ `${OPEN_FILTER_BAR_WIDTH - 1}px`,
+ );
+ });
+
+ it('sets custom width', () => {
+ const mockedProps = createProps();
+ const expectedWidth = 423;
+ const { getByTestId } = render(
+ <ActionButtons {...mockedProps} width={expectedWidth} />,
+ {
+ useRedux: true,
+ },
+ );
+ const container = getByTestId('filterbar-action-buttons');
+ expect(container).toHaveStyleRule('width', `${expectedWidth - 1}px`);
+ });
+});
diff --git
a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/ActionButtons/index.tsx
b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/ActionButtons/index.tsx
index d94885b1c9..b53f497189 100644
---
a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/ActionButtons/index.tsx
+++
b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/ActionButtons/index.tsx
@@ -31,6 +31,7 @@ import { rgba } from 'emotion-rgba';
import { getFilterBarTestId } from '../index';
interface ActionButtonsProps {
+ width?: number;
onApply: () => void;
onClearAll: () => void;
dataMaskSelected: DataMaskState;
@@ -38,8 +39,8 @@ interface ActionButtonsProps {
isApplyDisabled: boolean;
}
-const ActionButtonsContainer = styled.div`
- ${({ theme }) => css`
+const ActionButtonsContainer = styled.div<{ width: number }>`
+ ${({ theme, width }) => css`
display: flex;
flex-direction: column;
align-items: center;
@@ -48,7 +49,7 @@ const ActionButtonsContainer = styled.div`
z-index: 100;
// filter bar width minus 1px for border
- width: ${OPEN_FILTER_BAR_WIDTH - 1}px;
+ width: ${width - 1}px;
bottom: 0;
padding: ${theme.gridUnit * 4}px;
@@ -85,6 +86,7 @@ const ActionButtonsContainer = styled.div`
`;
export const ActionButtons = ({
+ width = OPEN_FILTER_BAR_WIDTH,
onApply,
onClearAll,
dataMaskApplied,
@@ -103,7 +105,7 @@ export const ActionButtons = ({
);
return (
- <ActionButtonsContainer>
+ <ActionButtonsContainer data-test="filterbar-action-buttons" width={width}>
<Button
disabled={isApplyDisabled}
buttonStyle="primary"
diff --git
a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx
b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx
index 309d75dac9..ee7d0fa6a1 100644
---
a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx
+++
b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx
@@ -493,6 +493,7 @@ const FilterBar: React.FC<FiltersBarProps> = ({
</div>
)}
<ActionButtons
+ width={width}
onApply={handleApply}
onClearAll={handleClearAll}
dataMaskSelected={dataMaskSelected}
diff --git a/superset-frontend/src/dashboard/constants.ts
b/superset-frontend/src/dashboard/constants.ts
index 30d2cca719..dc1a28ea66 100644
--- a/superset-frontend/src/dashboard/constants.ts
+++ b/superset-frontend/src/dashboard/constants.ts
@@ -37,6 +37,7 @@ export const PLACEHOLDER_DATASOURCE: Dataset = {
export const MAIN_HEADER_HEIGHT = 53;
export const CLOSED_FILTER_BAR_WIDTH = 32;
export const OPEN_FILTER_BAR_WIDTH = 260;
+export const OPEN_FILTER_BAR_MAX_WIDTH = 550;
export const FILTER_BAR_HEADER_HEIGHT = 80;
export const FILTER_BAR_TABS_HEIGHT = 46;
export const BUILDER_SIDEPANEL_WIDTH = 374;
diff --git a/superset-frontend/src/utils/localStorageHelpers.ts
b/superset-frontend/src/utils/localStorageHelpers.ts
index 28de043200..686a9a2498 100644
--- a/superset-frontend/src/utils/localStorageHelpers.ts
+++ b/superset-frontend/src/utils/localStorageHelpers.ts
@@ -51,6 +51,7 @@ export enum LocalStorageKeys {
*/
sqllab__is_autocomplete_enabled = 'sqllab__is_autocomplete_enabled',
explore__data_table_original_formatted_time_columns =
'explore__data_table_original_formatted_time_columns',
+ dashboard__custom_filter_bar_widths = 'dashboard__custom_filter_bar_widths',
}
export type LocalStorageValues = {
@@ -66,6 +67,7 @@ export type LocalStorageValues = {
homepage_activity_filter: SetTabType | null;
sqllab__is_autocomplete_enabled: boolean;
explore__data_table_original_formatted_time_columns: Record<string,
string[]>;
+ dashboard__custom_filter_bar_widths: Record<string, number>;
};
/*