This is an automated email from the ASF dual-hosted git repository.

diegopucci 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 68e8b00cde feat(dashboard): Support changing filter bar location 
(#22004)
68e8b00cde is described below

commit 68e8b00cdec21db491995567be0aedbe26ea9482
Author: Kamil Gabryjelski <[email protected]>
AuthorDate: Thu Nov 3 15:50:34 2022 +0100

    feat(dashboard): Support changing filter bar location (#22004)
---
 .../src/dashboard/actions/dashboardInfo.ts         |  68 +++++++-
 .../src/dashboard/actions/dashboardState.js        |   2 +-
 superset-frontend/src/dashboard/actions/hydrate.js |   3 +
 .../FilterBarLocationSelect.test.tsx               | 175 +++++++++++++++++++++
 .../FilterBar/FilterBarLocationSelect/index.tsx    |  80 ++++++++++
 .../nativeFilters/FilterBar/Header/index.tsx       |  23 +--
 .../src/dashboard/reducers/dashboardInfo.js        |  10 +-
 superset-frontend/src/dashboard/types.ts           |   6 +
 .../src/dashboard/util/permissionUtils.test.ts     |   2 +-
 .../src/dashboard/util/permissionUtils.ts          |   2 +-
 superset-frontend/src/types/Dashboard.ts           |   2 -
 superset/dashboards/schemas.py                     |   1 +
 12 files changed, 345 insertions(+), 29 deletions(-)

diff --git a/superset-frontend/src/dashboard/actions/dashboardInfo.ts 
b/superset-frontend/src/dashboard/actions/dashboardInfo.ts
index 9a769101cf..19035a2b22 100644
--- a/superset-frontend/src/dashboard/actions/dashboardInfo.ts
+++ b/superset-frontend/src/dashboard/actions/dashboardInfo.ts
@@ -17,9 +17,17 @@
  * under the License.
  */
 import { Dispatch } from 'redux';
-import { makeApi, CategoricalColorNamespace } from '@superset-ui/core';
+import { makeApi, CategoricalColorNamespace, t } from '@superset-ui/core';
 import { isString } from 'lodash';
-import { ChartConfiguration, DashboardInfo } from '../reducers/types';
+import { getClientErrorObject } from 'src/utils/getClientErrorObject';
+import { addDangerToast } from 'src/components/MessageToasts/actions';
+import {
+  DashboardInfo,
+  FilterBarLocation,
+  RootState,
+} from 'src/dashboard/types';
+import { ChartConfiguration } from 'src/dashboard/reducers/types';
+import { onSave } from './dashboardState';
 
 export const DASHBOARD_INFO_UPDATED = 'DASHBOARD_INFO_UPDATED';
 
@@ -111,3 +119,59 @@ export const setChartConfiguration =
       dispatch({ type: SET_CHART_CONFIG_FAIL, chartConfiguration });
     }
   };
+
+export const SET_FILTER_BAR_LOCATION = 'SET_FILTER_BAR_LOCATION';
+export interface SetFilterBarLocation {
+  type: typeof SET_FILTER_BAR_LOCATION;
+  filterBarLocation: FilterBarLocation;
+}
+export function setFilterBarLocation(filterBarLocation: FilterBarLocation) {
+  return { type: SET_FILTER_BAR_LOCATION, filterBarLocation };
+}
+
+export function saveFilterBarLocation(location: FilterBarLocation) {
+  return async (dispatch: Dispatch, getState: () => RootState) => {
+    const { id, metadata } = getState().dashboardInfo;
+    const updateDashboard = makeApi<
+      Partial<DashboardInfo>,
+      { result: Partial<DashboardInfo>; last_modified_time: number }
+    >({
+      method: 'PUT',
+      endpoint: `/api/v1/dashboard/${id}`,
+    });
+    try {
+      const response = await updateDashboard({
+        json_metadata: JSON.stringify({
+          ...metadata,
+          filter_bar_location: location,
+        }),
+      });
+      const updatedDashboard = response.result;
+      const lastModifiedTime = response.last_modified_time;
+      if (updatedDashboard.json_metadata) {
+        const metadata = JSON.parse(updatedDashboard.json_metadata);
+        if (metadata.filter_bar_location) {
+          dispatch(setFilterBarLocation(metadata.filter_bar_location));
+        }
+      }
+      if (lastModifiedTime) {
+        dispatch(onSave(lastModifiedTime));
+      }
+    } catch (errorObject) {
+      const { error, message } = await getClientErrorObject(errorObject);
+      let errorText = t('Sorry, an unknown error occurred.');
+
+      if (error) {
+        errorText = t(
+          'Sorry, there was an error saving this dashboard: %s',
+          error,
+        );
+      }
+      if (typeof message === 'string' && message === 'Forbidden') {
+        errorText = t('You do not have permission to edit this dashboard');
+      }
+      dispatch(addDangerToast(errorText));
+      throw errorObject;
+    }
+  };
+}
diff --git a/superset-frontend/src/dashboard/actions/dashboardState.js 
b/superset-frontend/src/dashboard/actions/dashboardState.js
index 701eab7845..027288cdbc 100644
--- a/superset-frontend/src/dashboard/actions/dashboardState.js
+++ b/superset-frontend/src/dashboard/actions/dashboardState.js
@@ -284,7 +284,7 @@ export function saveDashboardRequest(data, id, saveType) {
     const onUpdateSuccess = response => {
       const updatedDashboard = response.json.result;
       const lastModifiedTime = response.json.last_modified_time;
-      // synching with the backend transformations of the metadata
+      // syncing with the backend transformations of the metadata
       if (updatedDashboard.json_metadata) {
         const metadata = JSON.parse(updatedDashboard.json_metadata);
         dispatch(
diff --git a/superset-frontend/src/dashboard/actions/hydrate.js 
b/superset-frontend/src/dashboard/actions/hydrate.js
index da16e2f6f6..5e3b97247a 100644
--- a/superset-frontend/src/dashboard/actions/hydrate.js
+++ b/superset-frontend/src/dashboard/actions/hydrate.js
@@ -57,6 +57,7 @@ import getNativeFilterConfig from 
'../util/filterboxMigrationHelper';
 import { updateColorSchema } from './dashboardInfo';
 import { getChartIdsInFilterScope } from '../util/getChartIdsInFilterScope';
 import updateComponentParentsList from '../util/updateComponentParentsList';
+import { FilterBarLocation } from '../types';
 
 export const HYDRATE_DASHBOARD = 'HYDRATE_DASHBOARD';
 
@@ -428,6 +429,8 @@ export const hydrateDashboard =
             flash_messages: common?.flash_messages,
             conf: common?.conf,
           },
+          filterBarLocation:
+            metadata.filter_bar_location ?? FilterBarLocation.VERTICAL,
         },
         dataMask,
         dashboardFilters,
diff --git 
a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarLocationSelect/FilterBarLocationSelect.test.tsx
 
b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarLocationSelect/FilterBarLocationSelect.test.tsx
new file mode 100644
index 0000000000..90b640a2c1
--- /dev/null
+++ 
b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarLocationSelect/FilterBarLocationSelect.test.tsx
@@ -0,0 +1,175 @@
+/**
+ * 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 from 'react';
+import fetchMock from 'fetch-mock';
+import { waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render, screen, within } from 'spec/helpers/testing-library';
+import { DashboardInfo, FilterBarLocation } from 'src/dashboard/types';
+import * as mockedMessageActions from 'src/components/MessageToasts/actions';
+import { FilterBarLocationSelect } from './index';
+
+const initialState: { dashboardInfo: DashboardInfo } = {
+  dashboardInfo: {
+    id: 1,
+    userId: '1',
+    metadata: {
+      native_filter_configuration: {},
+      show_native_filters: true,
+      chart_configuration: {},
+      color_scheme: '',
+      color_namespace: '',
+      color_scheme_domain: [],
+      label_colors: {},
+      shared_label_colors: {},
+    },
+    json_metadata: '',
+    dash_edit_perm: true,
+    filterBarLocation: FilterBarLocation.VERTICAL,
+    common: {
+      conf: {},
+      flash_messages: [],
+    },
+  },
+};
+
+const setup = (dashboardInfoOverride: Partial<DashboardInfo> = {}) =>
+  render(<FilterBarLocationSelect />, {
+    useRedux: true,
+    initialState: {
+      ...initialState,
+      dashboardInfo: {
+        ...initialState.dashboardInfo,
+        ...dashboardInfoOverride,
+      },
+    },
+  });
+
+test('Dropdown trigger renders', () => {
+  setup();
+  expect(screen.getByLabelText('gear')).toBeVisible();
+});
+
+test('Popover opens with "Vertical" selected', async () => {
+  setup();
+  userEvent.click(screen.getByLabelText('gear'));
+  expect(await screen.findByText('Vertical (Left)')).toBeInTheDocument();
+  expect(screen.getByText('Horizontal (Top)')).toBeInTheDocument();
+  expect(
+    within(screen.getAllByRole('menuitem')[0]).getByLabelText('check'),
+  ).toBeInTheDocument();
+});
+
+test('Popover opens with "Horizontal" selected', async () => {
+  setup({ filterBarLocation: FilterBarLocation.HORIZONTAL });
+  userEvent.click(screen.getByLabelText('gear'));
+  expect(await screen.findByText('Vertical (Left)')).toBeInTheDocument();
+  expect(screen.getByText('Horizontal (Top)')).toBeInTheDocument();
+  expect(
+    within(screen.getAllByRole('menuitem')[1]).getByLabelText('check'),
+  ).toBeInTheDocument();
+});
+
+test('On selection change, send request and update checked value', async () => 
{
+  fetchMock.reset();
+  fetchMock.put('glob:*/api/v1/dashboard/1', {
+    result: {
+      json_metadata: JSON.stringify({
+        ...initialState.dashboardInfo.metadata,
+        filter_bar_location: 'HORIZONTAL',
+      }),
+    },
+  });
+
+  setup();
+  userEvent.click(screen.getByLabelText('gear'));
+
+  expect(
+    within(screen.getAllByRole('menuitem')[0]).getByLabelText('check'),
+  ).toBeInTheDocument();
+  expect(
+    within(screen.getAllByRole('menuitem')[1]).queryByLabelText('check'),
+  ).not.toBeInTheDocument();
+
+  userEvent.click(await screen.findByText('Horizontal (Top)'));
+
+  // 1st check - checkmark appears immediately after click
+  expect(
+    await within(screen.getAllByRole('menuitem')[1]).findByLabelText('check'),
+  ).toBeInTheDocument();
+  expect(
+    within(screen.getAllByRole('menuitem')[0]).queryByLabelText('check'),
+  ).not.toBeInTheDocument();
+
+  // successful query
+  await waitFor(() =>
+    expect(fetchMock.lastCall()?.[1]?.body).toEqual(
+      JSON.stringify({
+        json_metadata: JSON.stringify({
+          ...initialState.dashboardInfo.metadata,
+          filter_bar_location: 'HORIZONTAL',
+        }),
+      }),
+    ),
+  );
+
+  // 2nd check - checkmark stays after successful query
+  expect(
+    await within(screen.getAllByRole('menuitem')[1]).findByLabelText('check'),
+  ).toBeInTheDocument();
+  expect(
+    within(screen.getAllByRole('menuitem')[0]).queryByLabelText('check'),
+  ).not.toBeInTheDocument();
+
+  fetchMock.reset();
+});
+
+test('On failed request, restore previous selection', async () => {
+  fetchMock.reset();
+  fetchMock.put('glob:*/api/v1/dashboard/1', 400);
+
+  const dangerToastSpy = jest.spyOn(mockedMessageActions, 'addDangerToast');
+
+  setup();
+  userEvent.click(screen.getByLabelText('gear'));
+
+  expect(
+    within(screen.getAllByRole('menuitem')[0]).getByLabelText('check'),
+  ).toBeInTheDocument();
+  expect(
+    within(screen.getAllByRole('menuitem')[1]).queryByLabelText('check'),
+  ).not.toBeInTheDocument();
+
+  userEvent.click(await screen.findByText('Horizontal (Top)'));
+
+  // checkmark gets rolled back to the original selection after successful 
query
+  expect(
+    await within(screen.getAllByRole('menuitem')[0]).findByLabelText('check'),
+  ).toBeInTheDocument();
+  expect(
+    within(screen.getAllByRole('menuitem')[1]).queryByLabelText('check'),
+  ).not.toBeInTheDocument();
+
+  expect(dangerToastSpy).toHaveBeenCalledWith(
+    'Sorry, there was an error saving this dashboard: Unknown Error',
+  );
+
+  fetchMock.reset();
+});
diff --git 
a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarLocationSelect/index.tsx
 
b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarLocationSelect/index.tsx
new file mode 100644
index 0000000000..82d4ba92e6
--- /dev/null
+++ 
b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarLocationSelect/index.tsx
@@ -0,0 +1,80 @@
+/**
+ * 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, { useCallback, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { t, useTheme } from '@superset-ui/core';
+import { MenuProps } from 'src/components/Menu';
+import { FilterBarLocation, RootState } from 'src/dashboard/types';
+import { saveFilterBarLocation } from 'src/dashboard/actions/dashboardInfo';
+import Icons from 'src/components/Icons';
+import DropdownSelectableIcon from 'src/components/DropdownSelectableIcon';
+
+export const FilterBarLocationSelect = () => {
+  const dispatch = useDispatch();
+  const theme = useTheme();
+  const filterBarLocation = useSelector<RootState, FilterBarLocation>(
+    ({ dashboardInfo }) => dashboardInfo.filterBarLocation,
+  );
+  const [selectedFilterBarLocation, setSelectedFilterBarLocation] =
+    useState(filterBarLocation);
+
+  const toggleFilterBarLocation = useCallback(
+    async (
+      selection: Parameters<
+        Required<Pick<MenuProps, 'onSelect'>>['onSelect']
+      >[0],
+    ) => {
+      const selectedKey = selection.key as FilterBarLocation;
+      if (selectedKey !== filterBarLocation) {
+        // set displayed selection in local state for immediate visual 
response after clicking
+        setSelectedFilterBarLocation(selectedKey);
+        try {
+          // save selection in Redux and backend
+          await dispatch(
+            saveFilterBarLocation(selection.key as FilterBarLocation),
+          );
+        } catch {
+          // revert local state in case of error when saving
+          setSelectedFilterBarLocation(filterBarLocation);
+        }
+      }
+    },
+    [dispatch, filterBarLocation],
+  );
+
+  return (
+    <DropdownSelectableIcon
+      onSelect={toggleFilterBarLocation}
+      info={t('Placement of filter bar')}
+      icon={<Icons.Gear name="gear" iconColor={theme.colors.grayscale.base} />}
+      menuItems={[
+        {
+          key: FilterBarLocation.VERTICAL,
+          label: t('Vertical (Left)'),
+        },
+        {
+          key: FilterBarLocation.HORIZONTAL,
+          label: t('Horizontal (Top)'),
+        },
+      ]}
+      selectedKeys={[selectedFilterBarLocation]}
+    />
+  );
+};
diff --git 
a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Header/index.tsx
 
b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Header/index.tsx
index 1f5440d639..a20d6a61f7 100644
--- 
a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Header/index.tsx
+++ 
b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Header/index.tsx
@@ -32,8 +32,8 @@ import { useSelector } from 'react-redux';
 import FilterConfigurationLink from 
'src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink';
 import { useFilters } from 
'src/dashboard/components/nativeFilters/FilterBar/state';
 import { RootState } from 'src/dashboard/types';
-import DropdownSelectableIcon from 'src/components/DropdownSelectableIcon';
 import { getFilterBarTestId } from '..';
+import { FilterBarLocationSelect } from '../FilterBarLocationSelect';
 
 const TitleArea = styled.h4`
   display: flex;
@@ -100,26 +100,7 @@ const Header: FC<HeaderProps> = ({ toggleFiltersBar }) => {
     <Wrapper>
       <TitleArea>
         <span>{t('Filters')}</span>
-        {canSetHorizontalFilterBar && (
-          <DropdownSelectableIcon
-            onSelect={item => console.log('Selected item', item)}
-            info={t('Placement of filter bar')}
-            icon={
-              <Icons.Gear name="gear" iconColor={theme.colors.grayscale.base} 
/>
-            }
-            menuItems={[
-              {
-                key: 'vertical',
-                label: t('Vertical (Left)'),
-              },
-              {
-                key: 'horizontal',
-                label: t('Horizontal (Top)'),
-              },
-            ]}
-            selectedKeys={['vertical']}
-          />
-        )}
+        {canSetHorizontalFilterBar && <FilterBarLocationSelect />}
         <HeaderButton
           {...getFilterBarTestId('collapse-button')}
           buttonStyle="link"
diff --git a/superset-frontend/src/dashboard/reducers/dashboardInfo.js 
b/superset-frontend/src/dashboard/reducers/dashboardInfo.js
index fdd39fae12..8a01e6122c 100644
--- a/superset-frontend/src/dashboard/reducers/dashboardInfo.js
+++ b/superset-frontend/src/dashboard/reducers/dashboardInfo.js
@@ -17,7 +17,10 @@
  * under the License.
  */
 
-import { DASHBOARD_INFO_UPDATED } from '../actions/dashboardInfo';
+import {
+  DASHBOARD_INFO_UPDATED,
+  SET_FILTER_BAR_LOCATION,
+} from '../actions/dashboardInfo';
 import { HYDRATE_DASHBOARD } from '../actions/hydrate';
 
 export default function dashboardStateReducer(state = {}, action) {
@@ -35,6 +38,11 @@ export default function dashboardStateReducer(state = {}, 
action) {
         ...action.data.dashboardInfo,
         // set async api call data
       };
+    case SET_FILTER_BAR_LOCATION:
+      return {
+        ...state,
+        filterBarLocation: action.filterBarLocation,
+      };
     default:
       return state;
   }
diff --git a/superset-frontend/src/dashboard/types.ts 
b/superset-frontend/src/dashboard/types.ts
index 57a317bb83..023120f62f 100644
--- a/superset-frontend/src/dashboard/types.ts
+++ b/superset-frontend/src/dashboard/types.ts
@@ -52,6 +52,11 @@ export type Chart = ChartState & {
   };
 };
 
+export enum FilterBarLocation {
+  VERTICAL = 'VERTICAL',
+  HORIZONTAL = 'HORIZONTAL',
+}
+
 export type ActiveTabs = string[];
 export type DashboardLayout = { [key: string]: LayoutItem };
 export type DashboardLayoutState = { present: DashboardLayout };
@@ -92,6 +97,7 @@ export type DashboardInfo = {
     label_colors: JsonObject;
     shared_label_colors: JsonObject;
   };
+  filterBarLocation: FilterBarLocation;
 };
 
 export type ChartsState = { [key: string]: Chart };
diff --git a/superset-frontend/src/dashboard/util/permissionUtils.test.ts 
b/superset-frontend/src/dashboard/util/permissionUtils.test.ts
index d448db3845..a1aa8c1ca4 100644
--- a/superset-frontend/src/dashboard/util/permissionUtils.test.ts
+++ b/superset-frontend/src/dashboard/util/permissionUtils.test.ts
@@ -20,7 +20,7 @@ import {
   UndefinedUser,
   UserWithPermissionsAndRoles,
 } from 'src/types/bootstrapTypes';
-import Dashboard from 'src/types/Dashboard';
+import { Dashboard } from 'src/types/Dashboard';
 import Owner from 'src/types/Owner';
 import { canUserEditDashboard, isUserAdmin } from './permissionUtils';
 
diff --git a/superset-frontend/src/dashboard/util/permissionUtils.ts 
b/superset-frontend/src/dashboard/util/permissionUtils.ts
index 7348c3526b..4980480cee 100644
--- a/superset-frontend/src/dashboard/util/permissionUtils.ts
+++ b/superset-frontend/src/dashboard/util/permissionUtils.ts
@@ -21,7 +21,7 @@ import {
   UndefinedUser,
   UserWithPermissionsAndRoles,
 } from 'src/types/bootstrapTypes';
-import Dashboard from 'src/types/Dashboard';
+import { Dashboard } from 'src/types/Dashboard';
 import { findPermission } from 'src/utils/findPermission';
 
 // this should really be a config value,
diff --git a/superset-frontend/src/types/Dashboard.ts 
b/superset-frontend/src/types/Dashboard.ts
index 96a92d567f..faecc0bc4a 100644
--- a/superset-frontend/src/types/Dashboard.ts
+++ b/superset-frontend/src/types/Dashboard.ts
@@ -36,5 +36,3 @@ export interface Dashboard {
   owners: Owner[];
   roles: Role[];
 }
-
-export default Dashboard;
diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py
index c8912d1433..e6db0b5688 100644
--- a/superset/dashboards/schemas.py
+++ b/superset/dashboards/schemas.py
@@ -133,6 +133,7 @@ class DashboardJSONMetadataSchema(Schema):
     # used for v0 import/export
     import_time = fields.Integer()
     remote_id = fields.Integer()
+    filter_bar_location = fields.Str(allow_none=True)
 
 
 class UserSchema(Schema):

Reply via email to