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>;
 };
 
 /*

Reply via email to