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

tai pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 0eee678  feat(listviews): SIP-34 Bulk Select (#10298)
0eee678 is described below

commit 0eee6785a812cfa65276cd2bfbef08da8ff357f3
Author: ʈᵃᵢ <tdupree...@gmail.com>
AuthorDate: Thu Jul 16 16:07:49 2020 -0700

    feat(listviews): SIP-34 Bulk Select (#10298)
---
 superset-frontend/jest.config.js                   |   1 +
 .../waitForComponentToPaint.ts}                    |  29 ++-
 .../components/ListView/ListView_spec.jsx          |  70 ++++++--
 .../dashboard/components/CodeModal_spec.jsx        |   6 +-
 .../views/datasetList/DatasetList_spec.jsx         |  81 ++++++++-
 .../src/SqlLab/components/RunQueryActionButton.tsx |   4 +-
 .../src/components/{Button.jsx => Button.tsx}      |  78 +++++---
 .../src/components/ListView/ListView.tsx           | 132 +++++++++-----
 .../src/components/ListView/ListViewStyles.less    |   9 +
 .../src/components/ListView/TableCollection.tsx    |   6 +-
 superset-frontend/src/components/ListView/utils.ts |   2 +
 superset-frontend/src/components/Menu/Menu.jsx     |   3 +-
 superset-frontend/src/components/Menu/SubMenu.tsx  | 113 +++++-------
 .../src/views/chartList/ChartList.tsx              |  56 +++---
 .../src/views/dashboardList/DashboardList.tsx      |  65 ++++---
 .../{DatasetModal.tsx => AddDatasetModal.tsx}      |  16 +-
 .../src/views/datasetList/DatasetList.tsx          | 199 +++++++++++++--------
 superset-frontend/webpack.config.js                |   1 +
 18 files changed, 570 insertions(+), 301 deletions(-)

diff --git a/superset-frontend/jest.config.js b/superset-frontend/jest.config.js
index ea190d1..18b05cc 100644
--- a/superset-frontend/jest.config.js
+++ b/superset-frontend/jest.config.js
@@ -23,6 +23,7 @@ module.exports = {
     '\\.(gif|ttf|eot)$': '<rootDir>/spec/__mocks__/fileMock.js',
     '\\.svg$': '<rootDir>/spec/__mocks__/svgrMock.js',
     '^src/(.*)$': '<rootDir>/src/$1',
+    '^spec/(.*)$': '<rootDir>/spec/$1',
   },
   setupFilesAfterEnv: ['<rootDir>/spec/helpers/shim.js'],
   testURL: 'http://localhost',
diff --git 
a/superset-frontend/spec/javascripts/dashboard/components/CodeModal_spec.jsx 
b/superset-frontend/spec/helpers/waitForComponentToPaint.ts
similarity index 58%
copy from 
superset-frontend/spec/javascripts/dashboard/components/CodeModal_spec.jsx
copy to superset-frontend/spec/helpers/waitForComponentToPaint.ts
index 297492e..2e57a80 100644
--- a/superset-frontend/spec/javascripts/dashboard/components/CodeModal_spec.jsx
+++ b/superset-frontend/spec/helpers/waitForComponentToPaint.ts
@@ -16,20 +16,19 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import React from 'react';
-import { mount } from 'enzyme';
+import { ReactWrapper } from 'enzyme';
+import { act } from 'react-dom/test-utils';
 
-import CodeModal from 'src/dashboard/components/CodeModal';
-
-describe('CodeModal', () => {
-  const mockedProps = {
-    triggerNode: <i className="fa fa-edit" />,
-  };
-  it('is valid', () => {
-    expect(React.isValidElement(<CodeModal {...mockedProps} />)).toBe(true);
-  });
-  it('renders the trigger node', () => {
-    const wrapper = mount(<CodeModal {...mockedProps} />);
-    expect(wrapper.find('.fa-edit')).toHaveLength(1);
+// taken from: https://github.com/enzymejs/enzyme/issues/2073
+// There is currently and issue with enzyme and react-16's hooks
+// that results in a race condition between tests and react hook updates.
+// This function ensures tests run after all react updates are done.
+export default async function waitForComponentToPaint<P = {}>(
+  wrapper: ReactWrapper<P>,
+  amount = 0,
+) {
+  await act(async () => {
+    await new Promise(resolve => setTimeout(resolve, amount));
+    wrapper.update();
   });
-});
+}
diff --git 
a/superset-frontend/spec/javascripts/components/ListView/ListView_spec.jsx 
b/superset-frontend/spec/javascripts/components/ListView/ListView_spec.jsx
index bb7b2d8..6f62e4e 100644
--- a/superset-frontend/spec/javascripts/components/ListView/ListView_spec.jsx
+++ b/superset-frontend/spec/javascripts/components/ListView/ListView_spec.jsx
@@ -22,13 +22,17 @@ import { act } from 'react-dom/test-utils';
 import { MenuItem } from 'react-bootstrap';
 import Select from 'src/components/Select';
 import { QueryParamProvider } from 'use-query-params';
+import { supersetTheme, ThemeProvider } from '@superset-ui/style';
 
 import ListView from 'src/components/ListView/ListView';
 import ListViewFilters from 'src/components/ListView/Filters';
 import ListViewPagination from 'src/components/ListView/Pagination';
 import Pagination from 'src/components/Pagination';
+import Button from 'src/components/Button';
 import { areArraysShallowEqual } from 'src/reduxUtils';
-import { supersetTheme, ThemeProvider } from '@superset-ui/style';
+
+import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
+import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox';
 
 function makeMockLocation(query) {
   const queryStr = encodeURIComponent(query);
@@ -72,8 +76,15 @@ const mockedProps = {
   pageSize: 1,
   fetchData: jest.fn(() => []),
   loading: false,
+  bulkSelectEnabled: true,
+  disableBulkSelect: jest.fn(),
   bulkActions: [
-    { key: 'something', name: 'do something', onSelect: jest.fn() },
+    {
+      key: 'something',
+      name: 'do something',
+      style: 'danger',
+      onSelect: jest.fn(),
+    },
   ],
 };
 
@@ -89,7 +100,10 @@ const factory = (props = mockedProps) =>
   );
 
 describe('ListView', () => {
-  const wrapper = factory();
+  let wrapper = beforeAll(async () => {
+    wrapper = factory();
+    await waitForComponentToPaint(wrapper);
+  });
 
   afterEach(() => {
     mockedProps.fetchData.mockClear();
@@ -227,18 +241,17 @@ Array [
       wrapper.find('input[id="0"]').at(0).prop('onChange')({
         target: { value: 'on' },
       });
+    });
+    wrapper.update();
 
+    act(() => {
       wrapper
-        .find('.dropdown-toggle')
-        .children('button')
-        .at(1)
+        .find('[data-test="bulk-select-controls"]')
+        .find(Button)
         .props()
         .onClick();
     });
-    wrapper.update();
-    const bulkActionsProps = wrapper.find(MenuItem).last().props();
 
-    bulkActionsProps.onSelect(bulkActionsProps.eventKey);
     expect(mockedProps.bulkActions[0].onSelect.mock.calls[0])
       .toMatchInlineSnapshot(`
                                     Array [
@@ -257,18 +270,17 @@ Array [
       wrapper.find('input[id="header-toggle-all"]').at(0).prop('onChange')({
         target: { value: 'on' },
       });
+    });
+    wrapper.update();
 
+    act(() => {
       wrapper
-        .find('.dropdown-toggle')
-        .children('button')
-        .at(1)
+        .find('[data-test="bulk-select-controls"]')
+        .find(Button)
         .props()
         .onClick();
     });
-    wrapper.update();
-    const bulkActionsProps = wrapper.find(MenuItem).last().props();
 
-    bulkActionsProps.onSelect(bulkActionsProps.eventKey);
     expect(mockedProps.bulkActions[0].onSelect.mock.calls[0])
       .toMatchInlineSnapshot(`
                         Array [
@@ -286,6 +298,34 @@ Array [
                 `);
   });
 
+  it('allows deselecting all', async () => {
+    act(() => {
+      wrapper.find('[data-test="bulk-select-deselect-all"]').props().onClick();
+    });
+    await waitForComponentToPaint(wrapper);
+    wrapper.update();
+    wrapper.find(IndeterminateCheckbox).forEach(input => {
+      expect(input.props().checked).toBe(false);
+    });
+  });
+
+  it('allows disabling bulkSelect', () => {
+    wrapper
+      .find('[data-test="bulk-select-controls"]')
+      .at(0)
+      .props()
+      .onDismiss();
+    expect(mockedProps.disableBulkSelect).toHaveBeenCalled();
+  });
+
+  it('disables bulk select based on prop', async () => {
+    const wrapper2 = factory({ ...mockedProps, bulkSelectEnabled: false });
+    await waitForComponentToPaint(wrapper2);
+    expect(wrapper2.find('[data-test="bulk-select-controls"]').exists()).toBe(
+      false,
+    );
+  });
+
   it('Throws an exception if filter missing in columns', () => {
     expect.assertions(1);
     const props = {
diff --git 
a/superset-frontend/spec/javascripts/dashboard/components/CodeModal_spec.jsx 
b/superset-frontend/spec/javascripts/dashboard/components/CodeModal_spec.jsx
index 297492e..1589dd3 100644
--- a/superset-frontend/spec/javascripts/dashboard/components/CodeModal_spec.jsx
+++ b/superset-frontend/spec/javascripts/dashboard/components/CodeModal_spec.jsx
@@ -18,6 +18,7 @@
  */
 import React from 'react';
 import { mount } from 'enzyme';
+import { supersetTheme, ThemeProvider } from '@superset-ui/style';
 
 import CodeModal from 'src/dashboard/components/CodeModal';
 
@@ -29,7 +30,10 @@ describe('CodeModal', () => {
     expect(React.isValidElement(<CodeModal {...mockedProps} />)).toBe(true);
   });
   it('renders the trigger node', () => {
-    const wrapper = mount(<CodeModal {...mockedProps} />);
+    const wrapper = mount(<CodeModal {...mockedProps} />, {
+      wrappingComponent: ThemeProvider,
+      wrappingComponentProps: { theme: supersetTheme },
+    });
     expect(wrapper.find('.fa-edit')).toHaveLength(1);
   });
 });
diff --git 
a/superset-frontend/spec/javascripts/views/datasetList/DatasetList_spec.jsx 
b/superset-frontend/spec/javascripts/views/datasetList/DatasetList_spec.jsx
index 665b2bf..1ceb527 100644
--- a/superset-frontend/spec/javascripts/views/datasetList/DatasetList_spec.jsx
+++ b/superset-frontend/spec/javascripts/views/datasetList/DatasetList_spec.jsx
@@ -21,10 +21,14 @@ import { mount } from 'enzyme';
 import thunk from 'redux-thunk';
 import configureStore from 'redux-mock-store';
 import fetchMock from 'fetch-mock';
+import { supersetTheme, ThemeProvider } from '@superset-ui/style';
 
 import DatasetList from 'src/views/datasetList/DatasetList';
 import ListView from 'src/components/ListView/ListView';
-import { supersetTheme, ThemeProvider } from '@superset-ui/style';
+import Button from 'src/components/Button';
+import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox';
+import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
+import { act } from 'react-dom/test-utils';
 
 // store needed for withToasts(datasetTable)
 const mockStore = configureStore([thunk]);
@@ -37,7 +41,7 @@ const datasetsEndpoint = 'glob:*/api/v1/dataset/?*';
 
 const mockdatasets = [...new Array(3)].map((_, i) => ({
   changed_by_name: 'user',
-  kind: ['physical', 'virtual'][Math.floor(Math.random() * 2)],
+  kind: i === 0 ? 'virtual' : 'physical', // ensure there is 1 virtual
   changed_by_url: 'changed_by_url',
   changed_by: 'user',
   changed_on: new Date().toISOString(),
@@ -49,7 +53,7 @@ const mockdatasets = [...new Array(3)].map((_, i) => ({
 }));
 
 fetchMock.get(datasetsInfoEndpoint, {
-  permissions: ['can_list', 'can_edit'],
+  permissions: ['can_list', 'can_edit', 'can_add', 'can_delete'],
   filters: {
     database: [],
     schema: [],
@@ -69,13 +73,24 @@ fetchMock.get(databaseEndpoint, {
   result: [],
 });
 
-describe('DatasetList', () => {
-  const mockedProps = {};
-  const wrapper = mount(<DatasetList {...mockedProps} />, {
+async function mountAndWait(props) {
+  const mounted = mount(<DatasetList {...props} />, {
     context: { store },
     wrappingComponent: ThemeProvider,
     wrappingComponentProps: { theme: supersetTheme },
   });
+  await waitForComponentToPaint(mounted);
+
+  return mounted;
+}
+
+describe('DatasetList', () => {
+  const mockedProps = {};
+  let wrapper;
+
+  beforeAll(async () => {
+    wrapper = await mountAndWait(mockedProps);
+  });
 
   it('renders', () => {
     expect(wrapper.find(DatasetList)).toHaveLength(1);
@@ -96,11 +111,63 @@ describe('DatasetList', () => {
   });
 
   it('fetches data', () => {
-    // wrapper.update();
     const callsD = fetchMock.calls(/dataset\/\?q/);
     expect(callsD).toHaveLength(1);
     expect(callsD[0][0]).toMatchInlineSnapshot(
       
`"http://localhost/api/v1/dataset/?q=(order_column:changed_on,order_direction:desc,page:0,page_size:25)"`,
     );
   });
+
+  it('shows/hides bulk actions when bulk actions is clicked', async () => {
+    await waitForComponentToPaint(wrapper);
+    const button = wrapper.find(Button).at(0);
+    act(() => {
+      button.props().onClick();
+    });
+    await waitForComponentToPaint(wrapper);
+    expect(wrapper.find(IndeterminateCheckbox)).toHaveLength(
+      mockdatasets.length + 1, // 1 for each row and 1 for select all
+    );
+  });
+
+  it('renders different bulk selected copy depending on type of row selected', 
async () => {
+    // None selected
+    const checkedEvent = { target: { checked: true } };
+    const uncheckedEvent = { target: { checked: false } };
+    expect(
+      wrapper.find('[data-test="bulk-select-copy"]').text(),
+    ).toMatchInlineSnapshot(`"0 Selected"`);
+
+    // Vitual Selected
+    act(() => {
+      wrapper.find(IndeterminateCheckbox).at(1).props().onChange(checkedEvent);
+    });
+    await waitForComponentToPaint(wrapper);
+    expect(
+      wrapper.find('[data-test="bulk-select-copy"]').text(),
+    ).toMatchInlineSnapshot(`"1 Selected (Virtual)"`);
+
+    // Physical Selected
+    act(() => {
+      wrapper
+        .find(IndeterminateCheckbox)
+        .at(1)
+        .props()
+        .onChange(uncheckedEvent);
+      wrapper.find(IndeterminateCheckbox).at(2).props().onChange(checkedEvent);
+    });
+    await waitForComponentToPaint(wrapper);
+    expect(
+      wrapper.find('[data-test="bulk-select-copy"]').text(),
+    ).toMatchInlineSnapshot(`"1 Selected (Physical)"`);
+
+    // All Selected
+    act(() => {
+      wrapper.find(IndeterminateCheckbox).at(0).props().onChange(checkedEvent);
+    });
+    await waitForComponentToPaint(wrapper);
+    expect(
+      wrapper.find('[data-test="bulk-select-copy"]').text(),
+    ).toMatchInlineSnapshot(`"3 Selected (2 Physical, 1 Virtual)"`);
+  });
 });
diff --git a/superset-frontend/src/SqlLab/components/RunQueryActionButton.tsx 
b/superset-frontend/src/SqlLab/components/RunQueryActionButton.tsx
index f1b4c5c..84a8782 100644
--- a/superset-frontend/src/SqlLab/components/RunQueryActionButton.tsx
+++ b/superset-frontend/src/SqlLab/components/RunQueryActionButton.tsx
@@ -19,7 +19,7 @@
 import React from 'react';
 import { t } from '@superset-ui/translation';
 
-import Button from '../../components/Button';
+import Button, { ButtonProps } from '../../components/Button';
 
 const NO_OP = () => undefined;
 
@@ -47,7 +47,7 @@ const RunQueryActionButton = ({
   const shouldShowStopBtn =
     !!queryState && ['running', 'pending'].indexOf(queryState) > -1;
 
-  const commonBtnProps = {
+  const commonBtnProps: ButtonProps = {
     bsSize: 'small',
     bsStyle: btnStyle,
     disabled: !dbId,
diff --git a/superset-frontend/src/components/Button.jsx 
b/superset-frontend/src/components/Button.tsx
similarity index 50%
rename from superset-frontend/src/components/Button.jsx
rename to superset-frontend/src/components/Button.tsx
index 0be1c6b..6861fc5 100644
--- a/superset-frontend/src/components/Button.jsx
+++ b/superset-frontend/src/components/Button.tsx
@@ -17,41 +17,76 @@
  * under the License.
  */
 import React from 'react';
-import PropTypes from 'prop-types';
 import { kebabCase } from 'lodash';
 import {
   Button as BootstrapButton,
   Tooltip,
   OverlayTrigger,
 } from 'react-bootstrap';
+import styled from '@superset-ui/style';
 
-const propTypes = {
-  children: PropTypes.node,
-  className: PropTypes.string,
-  tooltip: PropTypes.node,
-  placement: PropTypes.string,
-  onClick: PropTypes.func,
-  disabled: PropTypes.bool,
-  bsSize: PropTypes.string,
-  bsStyle: PropTypes.string,
-  btnStyles: PropTypes.string,
-};
-const defaultProps = {
-  bsSize: 'sm',
-  placement: 'top',
-};
+export type OnClickHandler = React.MouseEventHandler<BootstrapButton>;
+
+export interface ButtonProps {
+  className?: string;
+  tooltip?: string;
+  placement?: string;
+  onClick?: OnClickHandler;
+  disabled?: boolean;
+  bsStyle?: string;
+  btnStyles?: string;
+  bsSize?: BootstrapButton.ButtonProps['bsSize'];
+  style?: BootstrapButton.ButtonProps['style'];
+  children?: React.ReactNode;
+}
 
 const BUTTON_WRAPPER_STYLE = { display: 'inline-block', cursor: 'not-allowed' 
};
 
-export default function Button(props) {
-  const buttonProps = { ...props };
+const SupersetButton = styled(BootstrapButton)`
+  &.supersetButton {
+    border-radius: ${({ theme }) => theme.borderRadius}px;
+    border: none;
+    color: ${({ theme }) => theme.colors.secondary.light5};
+    font-size: ${({ theme }) => theme.typography.sizes.s};
+    font-weight: ${({ theme }) => theme.typography.weights.bold};
+    min-width: ${({ theme }) => theme.gridUnit * 36}px;
+    min-height: ${({ theme }) => theme.gridUnit * 8}px;
+    text-transform: uppercase;
+    margin-left: ${({ theme }) => theme.gridUnit * 4}px;
+    &:first-of-type {
+      margin-left: 0;
+    }
+
+    i {
+      padding: 0 ${({ theme }) => theme.gridUnit * 2}px 0 0;
+    }
+
+    &.primary {
+      background-color: ${({ theme }) => theme.colors.primary.base};
+    }
+    &.secondary {
+      color: ${({ theme }) => theme.colors.primary.base};
+      background-color: ${({ theme }) => theme.colors.primary.light4};
+    }
+    &.danger {
+      background-color: ${({ theme }) => theme.colors.error.base};
+    }
+  }
+`;
+
+export default function Button(props: ButtonProps) {
+  const buttonProps = {
+    ...props,
+    bsSize: props.bsSize || 'sm',
+    placement: props.placement || 'top',
+  };
   const tooltip = props.tooltip;
   const placement = props.placement;
   delete buttonProps.tooltip;
   delete buttonProps.placement;
 
   let button = (
-    <BootstrapButton {...buttonProps}>{props.children}</BootstrapButton>
+    <SupersetButton {...buttonProps}>{props.children}</SupersetButton>
   );
   if (tooltip) {
     if (props.disabled) {
@@ -60,7 +95,7 @@ export default function Button(props) {
       buttonProps.style = { pointerEvents: 'none' };
       button = (
         <div style={BUTTON_WRAPPER_STYLE}>
-          <BootstrapButton {...buttonProps}>{props.children}</BootstrapButton>
+          <SupersetButton {...buttonProps}>{props.children}</SupersetButton>
         </div>
       );
     }
@@ -77,6 +112,3 @@ export default function Button(props) {
   }
   return button;
 }
-
-Button.propTypes = propTypes;
-Button.defaultProps = defaultProps;
diff --git a/superset-frontend/src/components/ListView/ListView.tsx 
b/superset-frontend/src/components/ListView/ListView.tsx
index 6f98c84..dc420cf 100644
--- a/superset-frontend/src/components/ListView/ListView.tsx
+++ b/superset-frontend/src/components/ListView/ListView.tsx
@@ -18,7 +18,10 @@
  */
 import { t } from '@superset-ui/translation';
 import React, { FunctionComponent } from 'react';
-import { Col, DropdownButton, MenuItem, Row } from 'react-bootstrap';
+import { Col, Row, Alert } from 'react-bootstrap';
+import styled from '@superset-ui/style';
+import cx from 'classnames';
+import Button from 'src/components/Button';
 import Loading from 'src/components/Loading';
 import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox';
 import TableCollection from './TableCollection';
@@ -30,7 +33,7 @@ import { ListViewError, useListViewState } from './utils';
 
 import './ListViewStyles.less';
 
-interface Props {
+export interface ListViewProps {
   columns: any[];
   data: any[];
   count: number;
@@ -42,12 +45,50 @@ interface Props {
   filters?: Filters;
   bulkActions?: Array<{
     key: string;
-    name: React.ReactNode | string;
+    name: React.ReactNode;
     onSelect: (rows: any[]) => any;
+    type?: 'primary' | 'secondary' | 'danger';
   }>;
   isSIP34FilterUIEnabled?: boolean;
+  bulkSelectEnabled?: boolean;
+  disableBulkSelect?: () => void;
+  renderBulkSelectCopy?: (selects: any[]) => React.ReactNode;
 }
 
+const BulkSelectWrapper = styled(Alert)`
+  border-radius: 0;
+  margin-bottom: 0;
+  padding-top: 0;
+  padding-bottom: 0;
+  padding-right: 36px;
+  color: #3d3d3d;
+  background-color: ${({ theme }) => theme.colors.primary.light4};
+
+  .selectedCopy {
+    display: inline-block;
+    padding: 16px 0;
+  }
+
+  .deselect-all {
+    color: #1985a0;
+    margin-left: 16px;
+  }
+
+  .divider {
+    margin: -8px 0 -8px 16px;
+    width: 1px;
+    height: 32px;
+    box-shadow: inset -1px 0px 0px #dadada;
+    display: inline-flex;
+    vertical-align: middle;
+    position: relative;
+  }
+
+  .close {
+    margin: 16px 0;
+  }
+`;
+
 const bulkSelectColumnConfig = {
   Cell: ({ row }: any) => (
     <IndeterminateCheckbox {...row.getToggleRowSelectedProps()} id={row.id} />
@@ -62,7 +103,7 @@ const bulkSelectColumnConfig = {
   size: 'sm',
 };
 
-const ListView: FunctionComponent<Props> = ({
+const ListView: FunctionComponent<ListViewProps> = ({
   columns,
   data,
   count,
@@ -74,6 +115,9 @@ const ListView: FunctionComponent<Props> = ({
   filters = [],
   bulkActions = [],
   isSIP34FilterUIEnabled = false,
+  bulkSelectEnabled = false,
+  disableBulkSelect = () => {},
+  renderBulkSelectCopy = selected => t('%s Selected', selected.length),
 }) => {
   const {
     getTableProps,
@@ -90,10 +134,11 @@ const ListView: FunctionComponent<Props> = ({
     applyFilters,
     filtersApplied,
     selectedFlatRows,
+    toggleAllRowsSelected,
     state: { pageIndex, pageSize, internalFilters },
   } = useListViewState({
     bulkSelectColumnConfig,
-    bulkSelectMode: Boolean(bulkActions.length),
+    bulkSelectMode: bulkSelectEnabled && Boolean(bulkActions.length),
     columns,
     count,
     data,
@@ -155,6 +200,47 @@ const ListView: FunctionComponent<Props> = ({
           )}
         </div>
         <div className="body">
+          {bulkSelectEnabled && (
+            <BulkSelectWrapper
+              data-test="bulk-select-controls"
+              bsStyle="info"
+              onDismiss={disableBulkSelect}
+            >
+              <div className="selectedCopy" data-test="bulk-select-copy">
+                {renderBulkSelectCopy(selectedFlatRows)}
+              </div>
+              {Boolean(selectedFlatRows.length) && (
+                <>
+                  <span
+                    data-test="bulk-select-deselect-all"
+                    role="button"
+                    tabIndex={0}
+                    className="deselect-all"
+                    onClick={() => toggleAllRowsSelected(false)}
+                  >
+                    {t('Deselect All')}
+                  </span>
+                  <div className="divider" />
+                  {bulkActions.map(action => (
+                    <Button
+                      data-test="bulk-select-action"
+                      key={action.key}
+                      className={cx('supersetButton', {
+                        danger: action.type === 'danger',
+                        primary: action.type === 'primary',
+                        secondary: action.type === 'secondary',
+                      })}
+                      onClick={() =>
+                        action.onSelect(selectedFlatRows.map(r => r.original))
+                      }
+                    >
+                      {action.name}
+                    </Button>
+                  ))}
+                </>
+              )}
+            </BulkSelectWrapper>
+          )}
           <TableCollection
             getTableProps={getTableProps}
             getTableBodyProps={getTableBodyProps}
@@ -167,42 +253,6 @@ const ListView: FunctionComponent<Props> = ({
         <div className="footer">
           <Row>
             <Col>
-              <div className="form-actions-container">
-                <div className="btn-group">
-                  {bulkActions.length > 0 && (
-                    <DropdownButton
-                      id="bulk-actions"
-                      bsSize="small"
-                      bsStyle="default"
-                      noCaret
-                      title={
-                        <>
-                          {t('Actions')} <span className="caret" />
-                        </>
-                      }
-                    >
-                      {bulkActions.map(action => (
-                        // @ts-ignore
-                        <MenuItem
-                          key={action.key}
-                          eventKey={selectedFlatRows}
-                          // @ts-ignore
-                          onSelect={(selectedRows: typeof selectedFlatRows) => 
{
-                            action.onSelect(
-                              selectedRows.map((r: any) => r.original),
-                            );
-                          }}
-                        >
-                          {action.name}
-                        </MenuItem>
-                      ))}
-                    </DropdownButton>
-                  )}
-                </div>
-              </div>
-            </Col>
-
-            <Col>
               <span className="row-count-container">
                 showing{' '}
                 <strong>
diff --git a/superset-frontend/src/components/ListView/ListViewStyles.less 
b/superset-frontend/src/components/ListView/ListViewStyles.less
index 4711226..f6e3f6f 100644
--- a/superset-frontend/src/components/ListView/ListViewStyles.less
+++ b/superset-frontend/src/components/ListView/ListViewStyles.less
@@ -104,8 +104,17 @@
     }
 
     .table-row {
+      .actions {
+        opacity: 0;
+      }
+
       &:hover {
         background-color: @brand-secondary-light5;
+
+        .actions {
+          opacity: 1;
+          transition: opacity ease-in @timing-normal;
+        }
       }
     }
 
diff --git a/superset-frontend/src/components/ListView/TableCollection.tsx 
b/superset-frontend/src/components/ListView/TableCollection.tsx
index 0efb30b..42bb720 100644
--- a/superset-frontend/src/components/ListView/TableCollection.tsx
+++ b/superset-frontend/src/components/ListView/TableCollection.tsx
@@ -126,13 +126,9 @@ export default function TableCollection({
           return (
             <tr
               {...row.getRowProps()}
-              className={cx({
+              className={cx('table-row', {
                 'table-row-selected': row.isSelected,
               })}
-              onMouseEnter={() => row.setState && row.setState({ hover: true 
})}
-              onMouseLeave={() =>
-                row.setState && row.setState({ hover: false })
-              }
             >
               {row.cells.map(cell => {
                 if (cell.column.hidden) return null;
diff --git a/superset-frontend/src/components/ListView/utils.ts 
b/superset-frontend/src/components/ListView/utils.ts
index c77d05b..f3a6975 100644
--- a/superset-frontend/src/components/ListView/utils.ts
+++ b/superset-frontend/src/components/ListView/utils.ts
@@ -165,6 +165,7 @@ export function useListViewState({
     gotoPage,
     setAllFilters,
     selectedFlatRows,
+    toggleAllRowsSelected,
     state: { pageIndex, pageSize, sortBy, filters },
   } = useTable(
     {
@@ -271,6 +272,7 @@ export function useListViewState({
     setAllFilters,
     setInternalFilters,
     state: { pageIndex, pageSize, sortBy, filters, internalFilters },
+    toggleAllRowsSelected,
     updateInternalFilter,
     applyFilterValue,
   };
diff --git a/superset-frontend/src/components/Menu/Menu.jsx 
b/superset-frontend/src/components/Menu/Menu.jsx
index 18e7759..ccfd94a 100644
--- a/superset-frontend/src/components/Menu/Menu.jsx
+++ b/superset-frontend/src/components/Menu/Menu.jsx
@@ -34,7 +34,8 @@ const propTypes = {
       path: PropTypes.string.isRequired,
       icon: PropTypes.string.isRequired,
       alt: PropTypes.string.isRequired,
-      width: PropTypes.string.isRequired,
+      width: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
+        .isRequired,
     }).isRequired,
     navbar_right: PropTypes.shape({
       bug_report_url: PropTypes.string,
diff --git a/superset-frontend/src/components/Menu/SubMenu.tsx 
b/superset-frontend/src/components/Menu/SubMenu.tsx
index 5b45ae1..e34fde2 100644
--- a/superset-frontend/src/components/Menu/SubMenu.tsx
+++ b/superset-frontend/src/components/Menu/SubMenu.tsx
@@ -16,40 +16,30 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import React, { useState } from 'react';
+import React from 'react';
 import styled from '@superset-ui/style';
-import DatasetModal from 'src/views/datasetList/DatasetModal';
-import { Button, Nav, Navbar, MenuItem } from 'react-bootstrap';
+import { Nav, Navbar, MenuItem } from 'react-bootstrap';
+import Button, { OnClickHandler } from 'src/components/Button';
 
 const StyledHeader = styled.header`
   margin-top: -20px;
   .navbar-header .navbar-brand {
     font-weight: ${({ theme }) => theme.typography.weights.bold};
   }
-
   .navbar-right {
-    .btn-default {
-      background-color: ${({ theme }) => theme.colors.primary.base};
-      border-radius: 4px;
-      border: none;
-      color: ${({ theme }) => theme.colors.secondary.light5};
-      font-size: ${({ theme }) => theme.typography.sizes.s};
-      font-weight: ${({ theme }) => theme.typography.weights.bold};
-      margin: 8px 43px;
-      padding: 8px 51px 8px 43px;
-      text-transform: uppercase;
-      i {
-        padding: 4px ${({ theme }) => theme.typography.sizes.xs};
-      }
+    .supersetButton {
+      margin: ${({ theme }) =>
+        `${theme.gridUnit * 2}px ${theme.gridUnit * 4}px ${
+          theme.gridUnit * 2
+        }px 0`};
     }
   }
-
   .navbar-nav {
     li {
       a {
         font-size: ${({ theme }) => theme.typography.sizes.s};
-        padding: 8px;
-        margin: 8px;
+        padding: ${({ theme }) => theme.gridUnit * 2}px;
+        margin: ${({ theme }) => theme.gridUnit * 2}px;
         color: ${({ theme }) => theme.colors.secondary.dark1};
       }
     }
@@ -63,70 +53,63 @@ const StyledHeader = styled.header`
   }
 `;
 
-interface SubMenuProps {
-  canCreate?: boolean;
-  childs?: Array<{ label: string; name: string; url: string }>;
-  createButton?: { name: string; url: string | null };
-  fetchData?: () => void;
+type MenuChild = {
+  label: string;
   name: string;
-}
-
-const SubMenu = ({
-  canCreate,
-  childs,
-  createButton,
-  fetchData,
-  name,
-}: SubMenuProps) => {
-  const [isModalOpen, setIsModalOpen] = useState(false);
-  const [selectedMenu, setSelectedMenu] = useState<string | undefined>(
-    childs?.[0]?.label,
-  );
-
-  const onOpen = () => {
-    setIsModalOpen(true);
-  };
+  url: string;
+};
 
-  const onClose = () => {
-    setIsModalOpen(false);
+export interface SubMenuProps {
+  primaryButton?: {
+    name: React.ReactNode;
+    onClick: OnClickHandler;
   };
-
-  const handleClick = (item: string) => () => {
-    setSelectedMenu(item);
+  secondaryButton?: {
+    name: React.ReactNode;
+    onClick: OnClickHandler;
   };
+  name: string;
+  children?: MenuChild[];
+  activeChild?: MenuChild['name'];
+}
 
+const SubMenu: React.FunctionComponent<SubMenuProps> = props => {
   return (
     <StyledHeader>
       <Navbar inverse fluid role="navigation">
         <Navbar.Header>
-          <Navbar.Brand>{name}</Navbar.Brand>
+          <Navbar.Brand>{props.name}</Navbar.Brand>
         </Navbar.Header>
-        <DatasetModal
-          fetchData={fetchData}
-          onHide={onClose}
-          show={isModalOpen}
-        />
         <Nav>
-          {childs &&
-            childs.map(child => (
+          {props.children &&
+            props.children.map(child => (
               <MenuItem
-                active={child.label === selectedMenu}
-                eventKey={`${child.name}`}
-                href={child.url}
+                active={child.name === props.activeChild}
                 key={`${child.label}`}
-                onClick={handleClick(child.label)}
+                href={child.url}
               >
                 {child.label}
               </MenuItem>
             ))}
         </Nav>
-        {canCreate && createButton && (
-          <Nav className="navbar-right">
-            <Button onClick={onOpen}>
-              <i className="fa fa-plus" /> {createButton.name}
+        <Nav className="navbar-right">
+          {props.secondaryButton && (
+            <Button
+              className="supersetButton secondary"
+              onClick={props.secondaryButton.onClick}
+            >
+              {props.secondaryButton.name}
             </Button>
-          </Nav>
-        )}
+          )}
+          {props.primaryButton && (
+            <Button
+              className="supersetButton primary"
+              onClick={props.primaryButton.onClick}
+            >
+              {props.primaryButton.name}
+            </Button>
+          )}
+        </Nav>
       </Navbar>
     </StyledHeader>
   );
diff --git a/superset-frontend/src/views/chartList/ChartList.tsx 
b/superset-frontend/src/views/chartList/ChartList.tsx
index 5f96eef..20b43e2 100644
--- a/superset-frontend/src/views/chartList/ChartList.tsx
+++ b/superset-frontend/src/views/chartList/ChartList.tsx
@@ -26,7 +26,7 @@ import rison from 'rison';
 import { Panel } from 'react-bootstrap';
 import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
 import SubMenu from 'src/components/Menu/SubMenu';
-import ListView from 'src/components/ListView/ListView';
+import ListView, { ListViewProps } from 'src/components/ListView/ListView';
 import {
   FetchDataConfig,
   FilterOperatorMap,
@@ -45,12 +45,13 @@ interface Props {
 }
 
 interface State {
-  charts: any[];
+  bulkSelectEnabled: boolean;
   chartCount: number;
-  loading: boolean;
+  charts: any[];
   filterOperators: FilterOperatorMap;
   filters: Filters;
   lastFetchDataConfig: FetchDataConfig | null;
+  loading: boolean;
   permissions: string[];
   // for now we need to use the Slice type defined in PropertiesModal.
   // In future it would be better to have a unified Chart entity.
@@ -63,6 +64,7 @@ class ChartList extends React.PureComponent<Props, State> {
   };
 
   state: State = {
+    bulkSelectEnabled: false,
     chartCount: 0,
     charts: [],
     filterOperators: {},
@@ -174,7 +176,7 @@ class ChartList extends React.PureComponent<Props, State> {
       disableSortBy: true,
     },
     {
-      Cell: ({ row: { state, original } }: any) => {
+      Cell: ({ row: { original } }: any) => {
         const handleDelete = () => this.handleChartDelete(original);
         const openEditModal = () => this.openChartEditModal(original);
         if (!this.canEdit && !this.canDelete) {
@@ -182,9 +184,7 @@ class ChartList extends React.PureComponent<Props, State> {
         }
 
         return (
-          <span
-            className={`actions ${state && state.hover ? '' : 'invisible'}`}
-          >
+          <span className="actions">
             {this.canDelete && (
               <ConfirmStatusChange
                 title={t('Please Confirm')}
@@ -235,6 +235,10 @@ class ChartList extends React.PureComponent<Props, State> {
     return this.state.permissions.some(p => p === perm);
   };
 
+  toggleBulkSelect = () => {
+    this.setState({ bulkSelectEnabled: !this.state.bulkSelectEnabled });
+  };
+
   openChartEditModal = (chart: Chart) => {
     this.setState({
       sliceCurrentlyEditing: {
@@ -509,6 +513,7 @@ class ChartList extends React.PureComponent<Props, State> {
 
   render() {
     const {
+      bulkSelectEnabled,
       charts,
       chartCount,
       loading,
@@ -517,7 +522,17 @@ class ChartList extends React.PureComponent<Props, State> {
     } = this.state;
     return (
       <>
-        <SubMenu name={t('Charts')} />
+        <SubMenu
+          name={t('Charts')}
+          secondaryButton={
+            this.canDelete
+              ? {
+                  name: t('Bulk Select'),
+                  onClick: this.toggleBulkSelect,
+                }
+              : undefined
+          }
+        />
         {sliceCurrentlyEditing && (
           <PropertiesModal
             show
@@ -534,18 +549,17 @@ class ChartList extends React.PureComponent<Props, State> 
{
           onConfirm={this.handleBulkChartDelete}
         >
           {confirmDelete => {
-            const bulkActions = [];
-            if (this.canDelete) {
-              bulkActions.push({
-                key: 'delete',
-                name: (
-                  <>
-                    <i className="fa fa-trash" /> {t('Delete')}
-                  </>
-                ),
-                onSelect: confirmDelete,
-              });
-            }
+            const bulkActions: ListViewProps['bulkActions'] = this.canDelete
+              ? [
+                  {
+                    key: 'delete',
+                    name: t('Delete'),
+                    onSelect: confirmDelete,
+                    type: 'danger',
+                  },
+                ]
+              : [];
+
             return (
               <ListView
                 className="chart-list-view"
@@ -558,6 +572,8 @@ class ChartList extends React.PureComponent<Props, State> {
                 initialSort={this.initialSort}
                 filters={filters}
                 bulkActions={bulkActions}
+                bulkSelectEnabled={bulkSelectEnabled}
+                disableBulkSelect={this.toggleBulkSelect}
                 isSIP34FilterUIEnabled={this.isSIP34FilterUIEnabled}
               />
             );
diff --git a/superset-frontend/src/views/dashboardList/DashboardList.tsx 
b/superset-frontend/src/views/dashboardList/DashboardList.tsx
index 09c3cc5..af6cf29 100644
--- a/superset-frontend/src/views/dashboardList/DashboardList.tsx
+++ b/superset-frontend/src/views/dashboardList/DashboardList.tsx
@@ -25,7 +25,7 @@ import rison from 'rison';
 import { Panel } from 'react-bootstrap';
 import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
 import SubMenu from 'src/components/Menu/SubMenu';
-import ListView from 'src/components/ListView/ListView';
+import ListView, { ListViewProps } from 'src/components/ListView/ListView';
 import ExpandableList from 'src/components/ExpandableList';
 import {
   FetchDataConfig,
@@ -44,23 +44,24 @@ interface Props {
 }
 
 interface State {
-  dashboards: any[];
+  bulkSelectEnabled: boolean;
   dashboardCount: number;
-  loading: boolean;
+  dashboards: any[];
+  dashboardToEdit: Dashboard | null;
   filterOperators: FilterOperatorMap;
   filters: Filters;
-  permissions: string[];
   lastFetchDataConfig: FetchDataConfig | null;
-  dashboardToEdit: Dashboard | null;
+  loading: boolean;
+  permissions: string[];
 }
 
 interface Dashboard {
-  id: number;
-  changed_by: string;
   changed_by_name: string;
   changed_by_url: string;
   changed_on_delta_humanized: string;
+  changed_by: string;
   dashboard_title: string;
+  id: number;
   published: boolean;
   url: string;
 }
@@ -71,14 +72,15 @@ class DashboardList extends React.PureComponent<Props, 
State> {
   };
 
   state: State = {
+    bulkSelectEnabled: false,
     dashboardCount: 0,
     dashboards: [],
+    dashboardToEdit: null,
     filterOperators: {},
     filters: [],
     lastFetchDataConfig: null,
     loading: true,
     permissions: [],
-    dashboardToEdit: null,
   };
 
   componentDidMount() {
@@ -192,7 +194,7 @@ class DashboardList extends React.PureComponent<Props, 
State> {
       disableSortBy: true,
     },
     {
-      Cell: ({ row: { state, original } }: any) => {
+      Cell: ({ row: { original } }: any) => {
         const handleDelete = () => this.handleDashboardDelete(original);
         const handleEdit = () => this.openDashboardEditModal(original);
         const handleExport = () => this.handleBulkDashboardExport([original]);
@@ -200,9 +202,7 @@ class DashboardList extends React.PureComponent<Props, 
State> {
           return null;
         }
         return (
-          <span
-            className={`actions ${state && state.hover ? '' : 'invisible'}`}
-          >
+          <span className="actions">
             {this.canDelete && (
               <ConfirmStatusChange
                 title={t('Please Confirm')}
@@ -255,6 +255,10 @@ class DashboardList extends React.PureComponent<Props, 
State> {
     },
   ];
 
+  toggleBulkSelect = () => {
+    this.setState({ bulkSelectEnabled: !this.state.bulkSelectEnabled });
+  };
+
   hasPerm = (perm: string) => {
     if (!this.state.permissions.length) {
       return false;
@@ -500,15 +504,26 @@ class DashboardList extends React.PureComponent<Props, 
State> {
 
   render() {
     const {
-      dashboards,
+      bulkSelectEnabled,
       dashboardCount,
-      loading,
-      filters,
+      dashboards,
       dashboardToEdit,
+      filters,
+      loading,
     } = this.state;
     return (
       <>
-        <SubMenu name={t('Dashboards')} />
+        <SubMenu
+          name={t('Dashboards')}
+          secondaryButton={
+            this.canDelete || this.canExport
+              ? {
+                  name: t('Bulk Select'),
+                  onClick: this.toggleBulkSelect,
+                }
+              : undefined
+          }
+        />
         <ConfirmStatusChange
           title={t('Please confirm')}
           description={t(
@@ -517,26 +532,20 @@ class DashboardList extends React.PureComponent<Props, 
State> {
           onConfirm={this.handleBulkDashboardDelete}
         >
           {confirmDelete => {
-            const bulkActions = [];
+            const bulkActions: ListViewProps['bulkActions'] = [];
             if (this.canDelete) {
               bulkActions.push({
                 key: 'delete',
-                name: (
-                  <>
-                    <i className="fa fa-trash" /> {t('Delete')}
-                  </>
-                ),
+                name: t('Delete'),
+                type: 'danger',
                 onSelect: confirmDelete,
               });
             }
             if (this.canExport) {
               bulkActions.push({
                 key: 'export',
-                name: (
-                  <>
-                    <i className="fa fa-database" /> {t('Export')}
-                  </>
-                ),
+                name: t('Export'),
+                type: 'primary',
                 onSelect: this.handleBulkDashboardExport,
               });
             }
@@ -561,6 +570,8 @@ class DashboardList extends React.PureComponent<Props, 
State> {
                   initialSort={this.initialSort}
                   filters={filters}
                   bulkActions={bulkActions}
+                  bulkSelectEnabled={bulkSelectEnabled}
+                  disableBulkSelect={this.toggleBulkSelect}
                   isSIP34FilterUIEnabled={this.isSIP34FilterUIEnabled}
                 />
               </>
diff --git a/superset-frontend/src/views/datasetList/DatasetModal.tsx 
b/superset-frontend/src/views/datasetList/AddDatasetModal.tsx
similarity index 92%
rename from superset-frontend/src/views/datasetList/DatasetModal.tsx
rename to superset-frontend/src/views/datasetList/AddDatasetModal.tsx
index 5dd50f2..c13969c 100644
--- a/superset-frontend/src/views/datasetList/DatasetModal.tsx
+++ b/superset-frontend/src/views/datasetList/AddDatasetModal.tsx
@@ -26,10 +26,16 @@ import Modal from 'src/components/Modal';
 import TableSelector from 'src/components/TableSelector';
 import withToasts from '../../messageToasts/enhancers/withToasts';
 
+type DatasetAddObject = {
+  id: number;
+  databse: number;
+  schema: string;
+  table_name: string;
+};
 interface DatasetModalProps {
   addDangerToast: (msg: string) => void;
   addSuccessToast: (msg: string) => void;
-  fetchData?: () => void;
+  onDatasetAdd?: (dataset: DatasetAddObject) => void;
   onHide: () => void;
   show: boolean;
 }
@@ -48,7 +54,7 @@ const TableSelectorContainer = styled.div`
 const DatasetModal: FunctionComponent<DatasetModalProps> = ({
   addDangerToast,
   addSuccessToast,
-  fetchData,
+  onDatasetAdd,
   onHide,
   show,
 }) => {
@@ -82,9 +88,9 @@ const DatasetModal: FunctionComponent<DatasetModalProps> = ({
       }),
       headers: { 'Content-Type': 'application/json' },
     })
-      .then(() => {
-        if (fetchData) {
-          fetchData();
+      .then(({ json = {} }) => {
+        if (onDatasetAdd) {
+          onDatasetAdd({ id: json.id, ...json.result });
         }
         addSuccessToast(t('The dataset has been saved'));
         onHide();
diff --git a/superset-frontend/src/views/datasetList/DatasetList.tsx 
b/superset-frontend/src/views/datasetList/DatasetList.tsx
index e6c6248..a144c20 100644
--- a/superset-frontend/src/views/datasetList/DatasetList.tsx
+++ b/superset-frontend/src/views/datasetList/DatasetList.tsx
@@ -26,13 +26,11 @@ import React, {
   useState,
 } from 'react';
 import rison from 'rison';
-// @ts-ignore
-import { Panel } from 'react-bootstrap';
 import { SHORT_DATE, SHORT_TIME } from 'src/utils/common';
 import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
 import DeleteModal from 'src/components/DeleteModal';
-import ListView from 'src/components/ListView/ListView';
-import SubMenu from 'src/components/Menu/SubMenu';
+import ListView, { ListViewProps } from 'src/components/ListView/ListView';
+import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu';
 import AvatarIcon from 'src/components/AvatarIcon';
 import {
   FetchDataConfig,
@@ -42,6 +40,7 @@ import {
 import withToasts from 'src/messageToasts/enhancers/withToasts';
 import TooltipWrapper from 'src/components/TooltipWrapper';
 import Icon from 'src/components/Icon';
+import AddDatasetModal from './AddDatasetModal';
 
 const PAGE_SIZE = 25;
 
@@ -52,15 +51,10 @@ type Owner = {
   username: string;
 };
 
-interface DatasetListProps {
-  addDangerToast: (msg: string) => void;
-  addSuccessToast: (msg: string) => void;
-}
-
-interface Dataset {
-  changed_by: string;
+type Dataset = {
   changed_by_name: string;
   changed_by_url: string;
+  changed_by: string;
   changed_on: string;
   databse_name: string;
   explore_url: string;
@@ -68,6 +62,11 @@ interface Dataset {
   owners: Array<Owner>;
   schema: string;
   table_name: string;
+};
+
+interface DatasetListProps {
+  addDangerToast: (msg: string) => void;
+  addSuccessToast: (msg: string) => void;
 }
 
 const DatasetList: FunctionComponent<DatasetListProps> = ({
@@ -93,6 +92,11 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
   >([]);
   const [permissions, setPermissions] = useState<string[]>([]);
 
+  const [datasetAddModalOpen, setDatasetAddModalOpen] = useState<boolean>(
+    false,
+  );
+  const [bulkSelectEnabled, setBulkSelectEnabled] = useState<boolean>(false);
+
   const updateFilters = (filterOperators: FilterOperatorMap) => {
     const convertFilter = ({
       name: label,
@@ -187,9 +191,9 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
     return Boolean(permissions.find(p => p === perm));
   };
 
-  const canEdit = () => hasPerm('can_edit');
-  const canDelete = () => hasPerm('can_delete');
-  const canCreate = () => hasPerm('can_add');
+  const canEdit = hasPerm('can_edit');
+  const canDelete = hasPerm('can_delete');
+  const canCreate = hasPerm('can_add');
 
   const initialSort = [{ id: 'changed_on', desc: true }];
 
@@ -349,16 +353,14 @@ const DatasetList: FunctionComponent<DatasetListProps> = 
({
       disableSortBy: true,
     },
     {
-      Cell: ({ row: { state, original } }: any) => {
+      Cell: ({ row: { original } }: any) => {
         const handleEdit = () => handleDatasetEdit(original);
         const handleDelete = () => openDatasetDeleteModal(original);
-        if (!canEdit() && !canDelete()) {
+        if (!canEdit && !canDelete) {
           return null;
         }
         return (
-          <span
-            className={`actions ${state && state.hover ? '' : 'invisible'}`}
-          >
+          <span className="actions">
             <TooltipWrapper
               label="explore-action"
               tooltip={t('Explore')}
@@ -390,7 +392,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
               </TooltipWrapper>
             )}
 
-            {canEdit() && (
+            {canEdit && (
               <TooltipWrapper
                 label="edit-action"
                 tooltip={t('Edit')}
@@ -415,17 +417,14 @@ const DatasetList: FunctionComponent<DatasetListProps> = 
({
     },
   ];
 
-  const menu = {
+  const menuData: SubMenuProps = {
+    activeChild: 'Datasets',
     name: t('Data'),
-    createButton: {
-      name: t('Dataset'),
-      url: '/tablemodelview/add',
-    },
-    childs: [
+    children: [
       {
         name: 'Datasets',
         label: t('Datasets'),
-        url: '/tablemodelview/list/?_flt_1_is_sqllab_view=y',
+        url: '/tablemodelview/list/',
       },
       { name: 'Databases', label: t('Databases'), url: '/databaseview/list/' },
       {
@@ -436,6 +435,25 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
     ],
   };
 
+  if (canCreate) {
+    menuData.primaryButton = {
+      name: (
+        <>
+          {' '}
+          <i className="fa fa-plus" /> {t('Dataset')}{' '}
+        </>
+      ),
+      onClick: () => setDatasetAddModalOpen(true),
+    };
+  }
+
+  if (canDelete) {
+    menuData.secondaryButton = {
+      name: t('Bulk Select'),
+      onClick: () => setBulkSelectEnabled(!bulkSelectEnabled),
+    };
+  }
+
   const closeDatasetDeleteModal = () => {
     setDatasetCurrentlyDeleting(null);
   };
@@ -519,11 +537,28 @@ const DatasetList: FunctionComponent<DatasetListProps> = 
({
 
   return (
     <>
-      <SubMenu
-        {...menu}
-        canCreate={canCreate()}
-        fetchData={() => lastFetchDataConfig && fetchData(lastFetchDataConfig)}
+      <SubMenu {...menuData} />
+      <AddDatasetModal
+        show={datasetAddModalOpen}
+        onHide={() => setDatasetAddModalOpen(false)}
+        onDatasetAdd={() => {
+          if (lastFetchDataConfig) fetchData(lastFetchDataConfig);
+        }}
       />
+      {datasetCurrentlyDeleting && (
+        <DeleteModal
+          description={t(
+            'The dataset %s is linked to %s charts that appear on %s 
dashboards. Are you sure you want to continue? Deleting the dataset will break 
those objects.',
+            datasetCurrentlyDeleting.table_name,
+            datasetCurrentlyDeleting.chart_count,
+            datasetCurrentlyDeleting.dashboard_count,
+          )}
+          onConfirm={() => handleDatasetDelete(datasetCurrentlyDeleting)}
+          onHide={closeDatasetDeleteModal}
+          open
+          title={t('Delete Dataset?')}
+        />
+      )}
       <ConfirmStatusChange
         title={t('Please confirm')}
         description={t(
@@ -532,50 +567,66 @@ const DatasetList: FunctionComponent<DatasetListProps> = 
({
         onConfirm={handleBulkDatasetDelete}
       >
         {confirmDelete => {
-          const bulkActions = [];
-          if (canDelete()) {
-            bulkActions.push({
-              key: 'delete',
-              name: (
-                <>
-                  <i className="fa fa-trash" /> {t('Delete')}
-                </>
-              ),
-              onSelect: confirmDelete,
-            });
-          }
+          const bulkActions: ListViewProps['bulkActions'] = canDelete
+            ? [
+                {
+                  key: 'delete',
+                  name: t('Delete'),
+                  onSelect: confirmDelete,
+                  type: 'danger',
+                },
+              ]
+            : [];
+
           return (
-            <>
-              {datasetCurrentlyDeleting && (
-                <DeleteModal
-                  description={t(
-                    `The dataset ${datasetCurrentlyDeleting.table_name} is 
linked to 
-                  ${datasetCurrentlyDeleting.chart_count} charts that appear 
on 
-                  ${datasetCurrentlyDeleting.dashboard_count} dashboards. 
-                  Are you sure you want to continue? Deleting the dataset will 
break 
-                  those objects.`,
-                  )}
-                  onConfirm={() =>
-                    handleDatasetDelete(datasetCurrentlyDeleting)
-                  }
-                  onHide={closeDatasetDeleteModal}
-                  open
-                  title={t('Delete Dataset?')}
-                />
-              )}
-              <ListView
-                className="dataset-list-view"
-                columns={columns}
-                data={datasets}
-                count={datasetCount}
-                pageSize={PAGE_SIZE}
-                fetchData={fetchData}
-                loading={loading}
-                initialSort={initialSort}
-                filters={currentFilters}
-                bulkActions={bulkActions}
-              />
-            </>
+            <ListView
+              className="dataset-list-view"
+              columns={columns}
+              data={datasets}
+              count={datasetCount}
+              pageSize={PAGE_SIZE}
+              fetchData={fetchData}
+              loading={loading}
+              initialSort={initialSort}
+              filters={currentFilters}
+              bulkActions={bulkActions}
+              bulkSelectEnabled={bulkSelectEnabled}
+              disableBulkSelect={() => setBulkSelectEnabled(false)}
+              renderBulkSelectCopy={selected => {
+                const { virtualCount, physicalCount } = selected.reduce(
+                  (acc, e) => {
+                    if (e.original.kind === 'physical') acc.physicalCount += 1;
+                    else if (e.original.kind === 'virtual')
+                      acc.virtualCount += 1;
+                    return acc;
+                  },
+                  { virtualCount: 0, physicalCount: 0 },
+                );
+
+                if (!selected.length) {
+                  return t('0 Selected');
+                } else if (virtualCount && !physicalCount) {
+                  return t(
+                    '%s Selected (Virtual)',
+                    selected.length,
+                    virtualCount,
+                  );
+                } else if (physicalCount && !virtualCount) {
+                  return t(
+                    '%s Selected (Physical)',
+                    selected.length,
+                    physicalCount,
+                  );
+                }
+
+                return t(
+                  '%s Selected (%s Physical, %s Virtual)',
+                  selected.length,
+                  physicalCount,
+                  virtualCount,
+                );
+              }}
+            />
           );
         }}
       </ConfirmStatusChange>
diff --git a/superset-frontend/webpack.config.js 
b/superset-frontend/webpack.config.js
index d6295d3..2bfc2ef 100644
--- a/superset-frontend/webpack.config.js
+++ b/superset-frontend/webpack.config.js
@@ -214,6 +214,7 @@ const config = {
       'react-dom': '@hot-loader/react-dom',
       stylesheets: path.resolve(APP_DIR, './stylesheets'),
       images: path.resolve(APP_DIR, './images'),
+      spec: path.resolve(APP_DIR, './spec'),
     },
     extensions: ['.ts', '.tsx', '.js', '.jsx'],
     symlinks: false,

Reply via email to