superset-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From ccwilli...@apache.org
Subject [incubator-superset] 08/26: add sticky tabs + sidepane, better tabs perf, better container hierarchy, better chart header (#4893)
Date Fri, 22 Jun 2018 00:54:23 GMT
This is an automated email from the ASF dual-hosted git repository.

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

commit ff057d86bd5205de8e5f8230bd68e14da9854a88
Author: Grace Guo <grace.guo@airbnb.com>
AuthorDate: Tue May 8 11:33:14 2018 -0700

    add sticky tabs + sidepane, better tabs perf, better container hierarchy, better chart header (#4893)
    
    * dashboard header, slice header UI improvement
    
    * add slider and sticky
    
    * dashboard header, slice header UI improvement
    
    * make builder pane floating
    
    * [dashboard builder] add sticky top-level tabs, refactor for performant tabs
    
    * [dashboard builder] visually distinct containers, icons for undo-redo, fix some isValidChild bugs
    
    * [dashboard builder] better undo redo <> save changes state, notify upon reaching undo limit
    
    * [dashboard builder] hook up edit + create component actions to saved-state pop.
    
    * [dashboard builder] visual refinement, refactor Dashboard header content and updates into layout for undo-redo, refactor save dashboard modal to use toasts instead of notify.
    
    * [dashboard builder] refactor chart name update logic to use layout for undo redo, save slice name changes on dashboard save
    
    * add slider and sticky
    
    * [dashboard builder] fix layout converter slice_id + chartId type casting, don't change grid size upon edit (perf)
    
    * [dashboard builder] don't set version key in getInitialState
    
    * [dashboard builder] make top level tabs addition/removal undoable, fix double sticky tabs + side panel.
    
    * [dashboard builder] fix sticky tabs offset bug
    
    * [dashboard builder] fix drag preview width, css polish, fix rebase issue
    
    * [dashboard builder] fix side pane labels and hove z-index
---
 superset/assets/package.json                       |   1 +
 .../{dashboard => }/components/ActionMenuItem.jsx  |   2 +-
 .../src/dashboard/actions/dashboardLayout.js       | 118 +++++++++++-
 .../assets/src/dashboard/actions/dashboardState.js |  43 ++++-
 .../assets/src/dashboard/actions/sliceEntities.js  |  35 ----
 .../dashboard/components/BuilderComponentPane.jsx  | 114 ++++++++----
 .../assets/src/dashboard/components/Controls.jsx   |  52 ++----
 .../assets/src/dashboard/components/Dashboard.jsx  |   7 +-
 .../src/dashboard/components/DashboardBuilder.jsx  | 134 ++++++++++----
 .../src/dashboard/components/DashboardGrid.jsx     | 204 +++++++++++----------
 .../assets/src/dashboard/components/Header.jsx     |  58 ++++--
 .../assets/src/dashboard/components/SaveModal.jsx  |  32 ++--
 .../assets/src/dashboard/components/SliceAdder.jsx |   6 +-
 .../src/dashboard/components/SliceHeader.jsx       |  26 +--
 .../dashboard/components/SliceHeaderControls.jsx   |  86 +++++----
 .../components/dnd/AddSliceDragPreview.jsx         |  17 +-
 .../dashboard/components/gridComponents/Chart.jsx  |  70 +++----
 .../components/gridComponents/ChartHolder.jsx      |  19 +-
 .../dashboard/components/gridComponents/Column.jsx |  25 ++-
 .../dashboard/components/gridComponents/Row.jsx    |  23 +--
 .../dashboard/components/gridComponents/Tab.jsx    |   2 +-
 .../dashboard/components/gridComponents/Tabs.jsx   |  42 ++---
 .../dashboard/components/menu/WithPopoverMenu.jsx  |   5 +-
 superset/assets/src/dashboard/containers/Chart.jsx |   4 +-
 .../assets/src/dashboard/containers/Dashboard.jsx  |   2 -
 .../dashboard/containers/DashboardComponent.jsx    |   8 +-
 .../src/dashboard/containers/DashboardHeader.jsx   |  38 ++--
 .../assets/src/dashboard/containers/SliceAdder.js  |  28 +++
 .../src/dashboard/reducers/dashboardState.js       |  11 +-
 .../src/dashboard/reducers/getInitialState.js      |  44 ++++-
 .../dashboard/reducers/undoableDashboardLayout.js  |   5 +-
 .../dashboard/stylesheets/builder-sidepane.less    |  78 +++++---
 .../dashboard/stylesheets/components/chart.less    |   5 -
 .../dashboard/stylesheets/components/column.less   |   6 +-
 .../dashboard/stylesheets/components/header.less   |  10 +-
 .../src/dashboard/stylesheets/components/row.less  |   6 +-
 .../src/dashboard/stylesheets/components/tabs.less |  13 +-
 .../src/dashboard/stylesheets/dashboard.less       |  98 +++++++---
 superset/assets/src/dashboard/stylesheets/dnd.less |   4 +-
 .../assets/src/dashboard/stylesheets/grid.less     |   9 +-
 .../src/dashboard/stylesheets/hover-menu.less      |  53 +++++-
 .../src/dashboard/stylesheets/popover-menu.less    |  28 +--
 .../src/dashboard/stylesheets/variables.less       |   4 +
 superset/assets/src/dashboard/util/constants.js    |   4 +
 .../src/dashboard/util/dashboardLayoutConverter.js |  63 ++-----
 superset/assets/src/dashboard/util/isValidChild.js |   9 +-
 .../src/dashboard/util/newComponentFactory.js      |   1 -
 superset/assets/src/dashboard/util/propShapes.jsx  |   1 -
 superset/assets/src/theme.js                       |   1 -
 superset/assets/src/visualizations/nvd3_vis.js     |   2 +
 superset/assets/stylesheets/superset.less          |   6 +-
 superset/views/core.py                             |  34 +++-
 52 files changed, 1058 insertions(+), 638 deletions(-)

diff --git a/superset/assets/package.json b/superset/assets/package.json
index ab440f0..6f3b20a 100644
--- a/superset/assets/package.json
+++ b/superset/assets/package.json
@@ -107,6 +107,7 @@
     "react-select-fast-filter-options": "^0.2.1",
     "react-sortable-hoc": "^0.8.3",
     "react-split-pane": "^0.1.66",
+    "react-sticky": "^6.0.2",
     "react-syntax-highlighter": "^7.0.4",
     "react-virtualized": "9.19.1",
     "react-virtualized-select": "^2.4.0",
diff --git a/superset/assets/src/dashboard/components/ActionMenuItem.jsx b/superset/assets/src/components/ActionMenuItem.jsx
similarity index 94%
rename from superset/assets/src/dashboard/components/ActionMenuItem.jsx
rename to superset/assets/src/components/ActionMenuItem.jsx
index a0ecb78..e6c4447 100644
--- a/superset/assets/src/dashboard/components/ActionMenuItem.jsx
+++ b/superset/assets/src/components/ActionMenuItem.jsx
@@ -2,7 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { MenuItem } from 'react-bootstrap';
 
-import InfoTooltipWithTrigger from '../../components/InfoTooltipWithTrigger';
+import InfoTooltipWithTrigger from './InfoTooltipWithTrigger';
 
 export function MenuItemContent({ faIcon, text, tooltip, children }) {
   return (
diff --git a/superset/assets/src/dashboard/actions/dashboardLayout.js b/superset/assets/src/dashboard/actions/dashboardLayout.js
index 5a04de5..c64ea0d 100644
--- a/superset/assets/src/dashboard/actions/dashboardLayout.js
+++ b/superset/assets/src/dashboard/actions/dashboardLayout.js
@@ -1,3 +1,5 @@
+import { ActionCreators as UndoActionCreators } from 'redux-undo';
+
 import { addInfoToast } from './messageToasts';
 import { setUnsavedChanges } from './dashboardState';
 import { CHART_TYPE, MARKDOWN_TYPE, TABS_TYPE } from '../util/componentTypes';
@@ -5,13 +7,14 @@ import {
   DASHBOARD_ROOT_ID,
   NEW_COMPONENTS_SOURCE_ID,
   GRID_MIN_COLUMN_COUNT,
+  DASHBOARD_HEADER_ID,
 } from '../util/constants';
 import dropOverflowsParent from '../util/dropOverflowsParent';
 import findParentId from '../util/findParentId';
 
 // Component CRUD -------------------------------------------------------------
 export const UPDATE_COMPONENTS = 'UPDATE_COMPONENTS';
-export function updateComponents(nextComponents) {
+function updateLayoutComponents(nextComponents) {
   return {
     type: UPDATE_COMPONENTS,
     payload: {
@@ -20,8 +23,34 @@ export function updateComponents(nextComponents) {
   };
 }
 
+export function updateComponents(nextComponents) {
+  return (dispatch, getState) => {
+    dispatch(updateLayoutComponents(nextComponents));
+
+    if (!getState().dashboardState.hasUnsavedChanges) {
+      dispatch(setUnsavedChanges(true));
+    }
+  };
+}
+
+export function updateDashboardTitle(text) {
+  return (dispatch, getState) => {
+    const { dashboardLayout } = getState();
+    dispatch(
+      updateComponents({
+        [DASHBOARD_HEADER_ID]: {
+          ...dashboardLayout.present[DASHBOARD_HEADER_ID],
+          meta: {
+            text,
+          },
+        },
+      }),
+    );
+  };
+}
+
 export const DELETE_COMPONENT = 'DELETE_COMPONENT';
-export function deleteComponent(id, parentId) {
+function deleteLayoutComponent(id, parentId) {
   return {
     type: DELETE_COMPONENT,
     payload: {
@@ -31,8 +60,18 @@ export function deleteComponent(id, parentId) {
   };
 }
 
+export function deleteComponent(id, parentId) {
+  return (dispatch, getState) => {
+    dispatch(deleteLayoutComponent(id, parentId));
+
+    if (!getState().dashboardState.hasUnsavedChanges) {
+      dispatch(setUnsavedChanges(true));
+    }
+  };
+}
+
 export const CREATE_COMPONENT = 'CREATE_COMPONENT';
-export function createComponent(dropResult) {
+function createLayoutComponent(dropResult) {
   return {
     type: CREATE_COMPONENT,
     payload: {
@@ -41,9 +80,19 @@ export function createComponent(dropResult) {
   };
 }
 
+export function createComponent(dropResult) {
+  return (dispatch, getState) => {
+    dispatch(createLayoutComponent(dropResult));
+
+    if (!getState().dashboardState.hasUnsavedChanges) {
+      dispatch(setUnsavedChanges(true));
+    }
+  };
+}
+
 // Tabs -----------------------------------------------------------------------
 export const CREATE_TOP_LEVEL_TABS = 'CREATE_TOP_LEVEL_TABS';
-export function createTopLevelTabs(dropResult) {
+function createTopLevelTabsAction(dropResult) {
   return {
     type: CREATE_TOP_LEVEL_TABS,
     payload: {
@@ -52,19 +101,39 @@ export function createTopLevelTabs(dropResult) {
   };
 }
 
+export function createTopLevelTabs(dropResult) {
+  return (dispatch, getState) => {
+    dispatch(createTopLevelTabsAction(dropResult));
+
+    if (!getState().dashboardState.hasUnsavedChanges) {
+      dispatch(setUnsavedChanges(true));
+    }
+  };
+}
+
 export const DELETE_TOP_LEVEL_TABS = 'DELETE_TOP_LEVEL_TABS';
-export function deleteTopLevelTabs() {
+function deleteTopLevelTabsAction() {
   return {
     type: DELETE_TOP_LEVEL_TABS,
     payload: {},
   };
 }
 
+export function deleteTopLevelTabs(dropResult) {
+  return (dispatch, getState) => {
+    dispatch(deleteTopLevelTabsAction(dropResult));
+
+    if (!getState().dashboardState.hasUnsavedChanges) {
+      dispatch(setUnsavedChanges(true));
+    }
+  };
+}
+
 // Resize ---------------------------------------------------------------------
 export const RESIZE_COMPONENT = 'RESIZE_COMPONENT';
 export function resizeComponent({ id, width, height }) {
   return (dispatch, getState) => {
-    const { dashboardLayout: undoableLayout } = getState();
+    const { dashboardLayout: undoableLayout, dashboardState } = getState();
     const { present: dashboard } = undoableLayout;
     const component = dashboard[id];
     const widthChanged = width && component.meta.width !== width;
@@ -99,7 +168,9 @@ export function resizeComponent({ id, width, height }) {
       });
 
       dispatch(updateComponents(updatedComponents));
-      dispatch(setUnsavedChanges(true));
+      if (!dashboardState.hasUnsavedChanges) {
+        dispatch(setUnsavedChanges(true));
+      }
     }
   };
 }
@@ -149,9 +220,10 @@ export function handleComponentDrop(dropResult) {
       dispatch(moveComponent(dropResult));
     }
 
+    const { dashboardLayout: undoableLayout, dashboardState } = getState();
+
     // if we moved a Tab and the parent Tabs no longer has children, delete it.
     if (!isNewComponent) {
-      const { dashboardLayout: undoableLayout } = getState();
       const { present: layout } = undoableLayout;
       const sourceComponent = layout[source.id];
 
@@ -167,8 +239,36 @@ export function handleComponentDrop(dropResult) {
       }
     }
 
-    dispatch(setUnsavedChanges(true));
+    if (!dashboardState.hasUnsavedChanges) {
+      dispatch(setUnsavedChanges(true));
+    }
 
     return null;
   };
 }
+
+// Undo redo ------------------------------------------------------------------
+export function undoLayoutAction() {
+  return (dispatch, getState) => {
+    dispatch(UndoActionCreators.undo());
+
+    const { dashboardLayout, dashboardState } = getState();
+
+    if (
+      dashboardLayout.past.length === 0 &&
+      !dashboardState.maxUndoHistoryExceeded
+    ) {
+      dispatch(setUnsavedChanges(false));
+    }
+  };
+}
+
+export function redoLayoutAction() {
+  return (dispatch, getState) => {
+    dispatch(UndoActionCreators.redo());
+
+    if (!getState().dashboardState.hasUnsavedChanges) {
+      dispatch(setUnsavedChanges(true));
+    }
+  };
+}
diff --git a/superset/assets/src/dashboard/actions/dashboardState.js b/superset/assets/src/dashboard/actions/dashboardState.js
index d80ec83..10c0a26 100644
--- a/superset/assets/src/dashboard/actions/dashboardState.js
+++ b/superset/assets/src/dashboard/actions/dashboardState.js
@@ -1,10 +1,12 @@
 /* eslint camelcase: 0 */
 import $ from 'jquery';
+import { ActionCreators as UndoActionCreators } from 'redux-undo';
 
 import { addChart, removeChart, refreshChart } from '../../chart/chartAction';
 import { chart as initChart } from '../../chart/chartReducer';
 import { fetchDatasourceMetadata } from '../../dashboard/actions/datasources';
 import { applyDefaultFormData } from '../../explore/stores/store';
+import { addWarningToast } from './messageToasts';
 
 export const SET_UNSAVED_CHANGES = 'SET_UNSAVED_CHANGES';
 export function setUnsavedChanges(hasUnsavedChanges) {
@@ -21,11 +23,6 @@ export function removeFilter(sliceId, col, vals, refresh = true) {
   return { type: REMOVE_FILTER, sliceId, col, vals, refresh };
 }
 
-export const UPDATE_DASHBOARD_TITLE = 'UPDATE_DASHBOARD_TITLE';
-export function updateDashboardTitle(title) {
-  return { type: UPDATE_DASHBOARD_TITLE, title };
-}
-
 export const ADD_SLICE = 'ADD_SLICE';
 export function addSlice(slice) {
   return { type: ADD_SLICE, slice };
@@ -84,6 +81,14 @@ export function onSave() {
   return { type: ON_SAVE };
 }
 
+export function saveDashboard() {
+  return dispatch => {
+    dispatch(onSave());
+    // clear layout undo history
+    dispatch(UndoActionCreators.clearHistory());
+  };
+}
+
 export function fetchCharts(chartList = [], force = false, interval = 0) {
   return (dispatch, getState) => {
     const timeout = getState().dashboardInfo.common.conf
@@ -168,9 +173,31 @@ export function addSliceToDashboard(id) {
   };
 }
 
-export function removeSliceFromDashboard(chart) {
+export function removeSliceFromDashboard(id) {
   return dispatch => {
-    dispatch(removeSlice(chart.id));
-    dispatch(removeChart(chart.id));
+    dispatch(removeSlice(id));
+    dispatch(removeChart(id));
+  };
+}
+
+// Undo history ---------------------------------------------------------------
+export const SET_MAX_UNDO_HISTORY_EXCEEDED = 'SET_MAX_UNDO_HISTORY_EXCEEDED';
+export function setMaxUndoHistoryExceeded(maxUndoHistoryExceeded = true) {
+  return {
+    type: SET_MAX_UNDO_HISTORY_EXCEEDED,
+    payload: { maxUndoHistoryExceeded },
+  };
+}
+
+export function maxUndoHistoryToast() {
+  return (dispatch, getState) => {
+    const { dashboardLayout } = getState();
+    const historyLength = dashboardLayout.past.length;
+
+    return dispatch(
+      addWarningToast(
+        `You have used all ${historyLength} undo slots and will not be able to fully undo subsequent actions. You may save your current state to reset the history.`,
+      ),
+    );
   };
 }
diff --git a/superset/assets/src/dashboard/actions/sliceEntities.js b/superset/assets/src/dashboard/actions/sliceEntities.js
index 6922753..37781f9 100644
--- a/superset/assets/src/dashboard/actions/sliceEntities.js
+++ b/superset/assets/src/dashboard/actions/sliceEntities.js
@@ -1,41 +1,6 @@
 /* eslint camelcase: 0 */
-/* global notify */
 import $ from 'jquery';
 
-export const UPDATE_SLICE_NAME = 'UPDATE_SLICE_NAME';
-export function updateSliceName(key, sliceName) {
-  return { type: UPDATE_SLICE_NAME, key, sliceName };
-}
-
-export function saveSliceName(slice, sliceName) {
-  const oldName = slice.slice_name;
-  return dispatch => {
-    const sliceParams = {};
-    sliceParams.slice_id = slice.slice_id;
-    sliceParams.action = 'overwrite';
-    sliceParams.slice_name = sliceName;
-
-    const url = `${slice.slice_url}&${Object.keys(sliceParams)
-      .map(key => `${key}=${sliceParams[key]}`)
-      .join('&')}`;
-    const key = slice.slice_id;
-    return $.ajax({
-      url,
-      type: 'POST',
-      success: () => {
-        dispatch(updateSliceName(key, sliceName));
-        notify.success('This slice name was saved successfully.');
-      },
-      error: () => {
-        // if server-side reject the overwrite action,
-        // revert to old state
-        dispatch(updateSliceName(key, oldName));
-        notify.error("You don't have the rights to alter this slice");
-      },
-    });
-  };
-}
-
 export const SET_ALL_SLICES = 'SET_ALL_SLICES';
 export function setAllSlices(slices) {
   return { type: SET_ALL_SLICES, slices };
diff --git a/superset/assets/src/dashboard/components/BuilderComponentPane.jsx b/superset/assets/src/dashboard/components/BuilderComponentPane.jsx
index e5bc74c..b42650e 100644
--- a/superset/assets/src/dashboard/components/BuilderComponentPane.jsx
+++ b/superset/assets/src/dashboard/components/BuilderComponentPane.jsx
@@ -1,70 +1,106 @@
+/* eslint-env browser */
+import PropTypes from 'prop-types';
 import React from 'react';
 import cx from 'classnames';
+import { StickyContainer, Sticky } from 'react-sticky';
 
 import NewColumn from './gridComponents/new/NewColumn';
 import NewDivider from './gridComponents/new/NewDivider';
 import NewHeader from './gridComponents/new/NewHeader';
 import NewRow from './gridComponents/new/NewRow';
 import NewTabs from './gridComponents/new/NewTabs';
-import SliceAdderContainer from '../containers/SliceAdder';
+import SliceAdder from '../containers/SliceAdder';
+import { t } from '../../locales';
+
+const propTypes = {
+  topOffset: PropTypes.number,
+};
+
+const defaultProps = {
+  topOffset: 0,
+};
 
 class BuilderComponentPane extends React.PureComponent {
   constructor(props) {
     super(props);
     this.state = {
-      showSlices: false,
+      slideDirection: 'slide-out',
     };
 
-    this.openSlicesPane = this.showSlices.bind(this, true);
-    this.closeSlicesPane = this.showSlices.bind(this, false);
+    this.openSlicesPane = this.slide.bind(this, 'slide-in');
+    this.closeSlicesPane = this.slide.bind(this, 'slide-out');
   }
 
-  showSlices(show) {
+  slide(direction) {
     this.setState({
-      showSlices: show,
+      slideDirection: direction,
     });
   }
 
   render() {
+    const { topOffset } = this.props;
     return (
-      <div className="dashboard-builder-sidepane">
-        <div className="dashboard-builder-sidepane-header">
-          Insert components
-          {this.state.showSlices && (
-            <i
-              className="fa fa-times close trigger"
-              onClick={this.closeSlicesPane}
-              role="none"
-            />
-          )}
-        </div>
+      <StickyContainer className="dashboard-builder-sidepane">
+        <Sticky topOffset={-topOffset}>
+          {({ style, calculatedHeight, isSticky }) => (
+            <div
+              className="viewport"
+              style={isSticky ? { ...style, top: topOffset } : null}
+            >
+              <div
+                className={cx('slider-container', this.state.slideDirection)}
+              >
+                <div className="component-layer slide-content">
+                  <div className="dashboard-builder-sidepane-header">
+                    {t('Saved components')}
+                  </div>
+                  <div
+                    className="new-component static"
+                    role="none"
+                    onClick={this.openSlicesPane}
+                  >
+                    <div className="new-component-placeholder fa fa-area-chart" />
+                    <div className="new-component-label">
+                      {t('Charts & filters')}
+                    </div>
 
-        <div className="component-layer">
-          <div
-            className="dragdroppable dragdroppable-row"
-            onClick={this.openSlicesPane}
-            role="none"
-          >
-            <div className="new-component static">
-              <div className="new-component-placeholder fa fa-area-chart" />
-              Chart
-              <i className="fa fa-arrow-right open trigger" />
-            </div>
-          </div>
+                    <i className="fa fa-arrow-right trigger" />
+                  </div>
 
-          <NewHeader />
-          <NewDivider />
-          <NewTabs />
-          <NewRow />
-          <NewColumn />
-        </div>
+                  <div className="dashboard-builder-sidepane-header">
+                    {t('Containers')}
+                  </div>
+                  <NewTabs />
+                  <NewRow />
+                  <NewColumn />
 
-        <div className={cx('slices-layer', this.state.showSlices && 'show')}>
-          <SliceAdderContainer />
-        </div>
-      </div>
+                  <div className="dashboard-builder-sidepane-header">
+                    {t('More components')}
+                  </div>
+                  <NewHeader />
+                  <NewDivider />
+                </div>
+                <div className="slices-layer slide-content">
+                  <div
+                    className="dashboard-builder-sidepane-header"
+                    onClick={this.closeSlicesPane}
+                    role="none"
+                  >
+                    <i className="fa fa-arrow-left trigger" />
+                    {t('All components')}
+                  </div>
+                  <SliceAdder height={calculatedHeight} />
+                </div>
+              </div>
+            </div>
+          )}
+        </Sticky>
+      </StickyContainer>
     );
   }
 }
 
+BuilderComponentPane.propTypes = propTypes;
+BuilderComponentPane.defaultProps = defaultProps;
+
 export default BuilderComponentPane;
diff --git a/superset/assets/src/dashboard/components/Controls.jsx b/superset/assets/src/dashboard/components/Controls.jsx
index 06b4f7f..07b6c33 100644
--- a/superset/assets/src/dashboard/components/Controls.jsx
+++ b/superset/assets/src/dashboard/components/Controls.jsx
@@ -2,11 +2,10 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import $ from 'jquery';
-import { DropdownButton } from 'react-bootstrap';
+import { DropdownButton, MenuItem } from 'react-bootstrap';
 
 import RefreshIntervalModal from './RefreshIntervalModal';
 import SaveModal from './SaveModal';
-import { ActionMenuItem, MenuItemContent } from './ActionMenuItem';
 import { t } from '../../locales';
 
 function updateDom(css) {
@@ -28,6 +27,8 @@ function updateDom(css) {
 }
 
 const propTypes = {
+  addSuccessToast: PropTypes.func.isRequired,
+  addDangerToast: PropTypes.func.isRequired,
   dashboardInfo: PropTypes.object.isRequired,
   dashboardTitle: PropTypes.string.isRequired,
   layout: PropTypes.object.isRequired,
@@ -100,23 +101,18 @@ class Controls extends React.PureComponent {
           id="bg-nested-dropdown"
           pullRight
         >
-          <ActionMenuItem
-            text={t('Force Refresh')}
-            tooltip={t('Force refresh the whole dashboard')}
-            onClick={forceRefreshAllCharts}
-          />
+          <MenuItem onClick={forceRefreshAllCharts}>
+            {t('Force refresh dashboard')}
+          </MenuItem>
           <RefreshIntervalModal
             onChange={refreshInterval =>
               startPeriodicRender(refreshInterval * 1000)
             }
-            triggerNode={
-              <MenuItemContent
-                text={t('Set autorefresh')}
-                tooltip={t('Set the auto-refresh interval for this session')}
-              />
-            }
+            triggerNode={<span>{t('Set auto-refresh interval')}</span>}
           />
           <SaveModal
+            addSuccessToast={this.props.addSuccessToast}
+            addDangerToast={this.props.addDangerToast}
             dashboardId={this.props.dashboardInfo.id}
             dashboardTitle={dashboardTitle}
             layout={layout}
@@ -124,33 +120,19 @@ class Controls extends React.PureComponent {
             expandedSlices={expandedSlices}
             onSave={onSave}
             css={this.state.css}
-            triggerNode={
-              <MenuItemContent
-                text={editMode ? t('Save') : t('Save as')}
-                tooltip={t('Save the dashboard')}
-              />
-            }
+            triggerNode={<span>{editMode ? t('Save') : t('Save as')}</span>}
             isMenuItem
           />
           {editMode && (
-            <ActionMenuItem
-              text={t('Edit properties')}
-              tooltip={t("Edit the dashboards's properties")}
-              onClick={() => {
-                window.location = `/dashboardmodelview/edit/${
-                  this.props.dashboardInfo.id
-                }`;
-              }}
-            />
+            <MenuItem
+              target="_blank"
+              href={`/dashboardmodelview/edit/${this.props.dashboardInfo.id}`}
+            >
+              {t('Edit dashboard metadata')}
+            </MenuItem>
           )}
           {editMode && (
-            <ActionMenuItem
-              text={t('Email')}
-              tooltip={t('Email a link to this dashboard')}
-              onClick={() => {
-                window.location = emailLink;
-              }}
-            />
+            <MenuItem href={emailLink}>{t('Email dashboard link')}</MenuItem>
           )}
         </DropdownButton>
       </span>
diff --git a/superset/assets/src/dashboard/components/Dashboard.jsx b/superset/assets/src/dashboard/components/Dashboard.jsx
index 2d85ebf..369ed46 100644
--- a/superset/assets/src/dashboard/components/Dashboard.jsx
+++ b/superset/assets/src/dashboard/components/Dashboard.jsx
@@ -27,7 +27,6 @@ import '../stylesheets/index.less';
 const propTypes = {
   actions: PropTypes.shape({
     addSliceToDashboard: PropTypes.func.isRequired,
-    onChange: PropTypes.func.isRequired,
     removeSliceFromDashboard: PropTypes.func.isRequired,
     runQuery: PropTypes.func.isRequired,
   }).isRequired,
@@ -98,16 +97,12 @@ class Dashboard extends React.PureComponent {
         key => currentChartIds.indexOf(key) === -1,
       );
       this.props.actions.addSliceToDashboard(newChartId);
-      this.props.actions.onChange();
     } else if (currentChartIds.length > nextChartIds.length) {
       // remove chart
       const removedChartId = currentChartIds.find(
         key => nextChartIds.indexOf(key) === -1,
       );
-      this.props.actions.removeSliceFromDashboard(
-        this.props.charts[removedChartId],
-      );
-      this.props.actions.onChange();
+      this.props.actions.removeSliceFromDashboard(removedChartId);
     }
   }
 
diff --git a/superset/assets/src/dashboard/components/DashboardBuilder.jsx b/superset/assets/src/dashboard/components/DashboardBuilder.jsx
index 79eb35d..7f92948 100644
--- a/superset/assets/src/dashboard/components/DashboardBuilder.jsx
+++ b/superset/assets/src/dashboard/components/DashboardBuilder.jsx
@@ -1,8 +1,14 @@
+/* eslint-env browser */
 import cx from 'classnames';
-import React from 'react';
-import PropTypes from 'prop-types';
-import HTML5Backend from 'react-dnd-html5-backend';
 import { DragDropContext } from 'react-dnd';
+import HTML5Backend from 'react-dnd-html5-backend';
+// ParentSize uses resize observer so the dashboard will update size
+// when its container size changes, due to e.g., builder side panel opening
+import ParentSize from '@vx/responsive/build/components/ParentSize';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Sticky, StickyContainer } from 'react-sticky';
+import { TabContainer, TabContent, TabPane } from 'react-bootstrap';
 
 import BuilderComponentPane from './BuilderComponentPane';
 import DashboardHeader from '../containers/DashboardHeader';
@@ -19,6 +25,8 @@ import {
   DASHBOARD_ROOT_DEPTH,
 } from '../util/constants';
 
+const TABS_HEIGHT = 47;
+
 const propTypes = {
   // redux
   dashboardLayout: PropTypes.object.isRequired,
@@ -52,31 +60,35 @@ class DashboardBuilder extends React.Component {
 
   handleChangeTab({ tabIndex }) {
     this.setState(() => ({ tabIndex }));
+    setTimeout(() => {
+      if (window)
+        window.scrollTo({
+          top: 0,
+          behavior: 'smooth',
+        });
+    }, 100);
   }
 
   render() {
-    const { tabIndex } = this.state;
     const {
       handleComponentDrop,
       dashboardLayout,
       deleteTopLevelTabs,
       editMode,
     } = this.props;
+
+    const { tabIndex } = this.state;
     const dashboardRoot = dashboardLayout[DASHBOARD_ROOT_ID];
     const rootChildId = dashboardRoot.children[0];
     const topLevelTabs =
       rootChildId !== DASHBOARD_GRID_ID && dashboardLayout[rootChildId];
 
-    const gridComponentId = topLevelTabs
-      ? topLevelTabs.children[
-          Math.min(topLevelTabs.children.length - 1, tabIndex)
-        ]
-      : DASHBOARD_GRID_ID;
-
-    const gridComponent = dashboardLayout[gridComponentId];
+    const childIds = topLevelTabs ? topLevelTabs.children : [DASHBOARD_GRID_ID];
 
     return (
-      <div className={cx('dashboard', editMode && 'dashboard--editing')}>
+      <StickyContainer
+        className={cx('dashboard', editMode && 'dashboard--editing')}
+      >
         {topLevelTabs || !editMode ? ( // you cannot drop on/displace tabs if they already exist
           <DashboardHeader />
         ) : (
@@ -99,38 +111,84 @@ class DashboardBuilder extends React.Component {
         )}
 
         {topLevelTabs && (
-          <WithPopoverMenu
-            shouldFocus={DashboardBuilder.shouldFocusTabs}
-            menuItems={[
-              <IconButton
-                className="fa fa-level-down"
-                label="Collapse tab content"
-                onClick={deleteTopLevelTabs}
-              />,
-            ]}
-            editMode={editMode}
-          >
-            <DashboardComponent
-              id={topLevelTabs.id}
-              parentId={DASHBOARD_ROOT_ID}
-              depth={DASHBOARD_ROOT_DEPTH + 1}
-              index={0}
-              renderTabContent={false}
-              onChangeTab={this.handleChangeTab}
-            />
-          </WithPopoverMenu>
+          <Sticky topOffset={50}>
+            {({ style }) => (
+              <WithPopoverMenu
+                shouldFocus={DashboardBuilder.shouldFocusTabs}
+                menuItems={[
+                  <IconButton
+                    className="fa fa-level-down"
+                    label="Collapse tab content"
+                    onClick={deleteTopLevelTabs}
+                  />,
+                ]}
+                editMode={editMode}
+                style={{ zIndex: 100, ...style }}
+              >
+                <DashboardComponent
+                  id={topLevelTabs.id}
+                  parentId={DASHBOARD_ROOT_ID}
+                  depth={DASHBOARD_ROOT_DEPTH + 1}
+                  index={0}
+                  renderTabContent={false}
+                  onChangeTab={this.handleChangeTab}
+                />
+              </WithPopoverMenu>
+            )}
+          </Sticky>
         )}
 
         <div className="dashboard-content">
-          <DashboardGrid
-            gridComponent={gridComponent}
-            depth={DASHBOARD_ROOT_DEPTH + 1}
-          />
+          <div className="grid-container">
+            <ParentSize>
+              {({ width }) => (
+                /*
+                  We use a TabContainer irrespective of whether top-level tabs exist to maintain
+                  a consistent React component tree. This avoids expensive mounts/unmounts of
+                  the entire dashboard upon adding/removing top-level tabs, which would otherwise
+                  happen because of React's diffing algorithm
+                */
+                <TabContainer
+                  id={DASHBOARD_GRID_ID}
+                  activeKey={tabIndex}
+                  onSelect={this.handleChangeTab}
+                  // these are important for performant loading of tabs. also, there is a
+                  // react-bootstrap bug where mountOnEnter has no effect unless animation=true
+                  animation
+                  mountOnEnter
+                  unmountOnExit={false}
+                >
+                  <TabContent>
+                    {childIds.map((id, index) => (
+                      // Matching the key of the first TabPane irrespective of topLevelTabs
+                      // lets us keep the same React component tree when !!topLevelTabs changes.
+                      // This avoids expensive mounts/unmounts of the entire dashboard.
+                      <TabPane
+                        key={index === 0 ? DASHBOARD_GRID_ID : id}
+                        eventKey={index}
+                      >
+                        <DashboardGrid
+                          gridComponent={dashboardLayout[id]}
+                          depth={DASHBOARD_ROOT_DEPTH + 1}
+                          width={width}
+                        />
+                      </TabPane>
+                    ))}
+                  </TabContent>
+                </TabContainer>
+              )}
+            </ParentSize>
+          </div>
+
           {this.props.editMode &&
-            this.props.showBuilderPane && <BuilderComponentPane />}
+            this.props.showBuilderPane && (
+              <BuilderComponentPane
+                topOffset={topLevelTabs ? TABS_HEIGHT : 0}
+              />
+            )}
         </div>
         <ToastPresenter />
-      </div>
+      </StickyContainer>
     );
   }
 }
diff --git a/superset/assets/src/dashboard/components/DashboardGrid.jsx b/superset/assets/src/dashboard/components/DashboardGrid.jsx
index 3e6fc0c..77503bb 100644
--- a/superset/assets/src/dashboard/components/DashboardGrid.jsx
+++ b/superset/assets/src/dashboard/components/DashboardGrid.jsx
@@ -1,8 +1,5 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-// ParentSize uses resize observer so the dashboard will update size
-// when its container size changes, due to e.g., builder side panel opening
-import ParentSize from '@vx/responsive/build/components/ParentSize';
 
 import { componentShape } from '../util/propShapes';
 import DashboardComponent from '../containers/DashboardComponent';
@@ -16,6 +13,7 @@ const propTypes = {
   gridComponent: componentShape.isRequired,
   handleComponentDrop: PropTypes.func.isRequired,
   resizeComponent: PropTypes.func.isRequired,
+  width: PropTypes.number.isRequired,
 };
 
 const defaultProps = {};
@@ -28,6 +26,7 @@ class DashboardGrid extends React.PureComponent {
       rowGuideTop: null,
     };
 
+    this.handleTopDropTargetDrop = this.handleTopDropTargetDrop.bind(this);
     this.handleResizeStart = this.handleResizeStart.bind(this);
     this.handleResize = this.handleResize.bind(this);
     this.handleResizeStop = this.handleResizeStop.bind(this);
@@ -77,100 +76,117 @@ class DashboardGrid extends React.PureComponent {
     }));
   }
 
+  handleTopDropTargetDrop(dropResult) {
+    if (dropResult) {
+      this.props.handleComponentDrop({
+        ...dropResult,
+        destination: {
+          ...dropResult.destination,
+          // force appending as the first child if top drop target
+          index: 0,
+        },
+      });
+    }
+  }
+
   render() {
-    const { gridComponent, handleComponentDrop, depth, editMode } = this.props;
+    const {
+      gridComponent,
+      handleComponentDrop,
+      depth,
+      editMode,
+      width,
+    } = this.props;
+
+    const columnPlusGutterWidth =
+      (width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT;
+
+    const columnWidth = columnPlusGutterWidth - GRID_GUTTER_SIZE;
     const { isResizing, rowGuideTop } = this.state;
 
-    return (
-      <div className="grid-container" ref={this.setGridRef}>
-        <ParentSize>
-          {({ width }) => {
-            const columnPlusGutterWidth =
-              (width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT;
-            const columnWidth = columnPlusGutterWidth - GRID_GUTTER_SIZE;
-            return width < 50 ? null : (
-              <div className="grid-content">
-                {editMode && (
-                  <DragDroppable
-                    component={gridComponent}
-                    depth={depth}
-                    parentComponent={null}
-                    index={0}
-                    orientation="column"
-                    onDrop={handleComponentDrop}
-                    editMode
-                  >
-                    {({ dropIndicatorProps }) =>
-                      dropIndicatorProps && (
-                        <div className="drop-indicator drop-indicator--bottom" />
-                      )
-                    }
-                  </DragDroppable>
-                )}
-
-                {gridComponent.children.map((id, index) => (
-                  <DashboardComponent
-                    key={id}
-                    id={id}
-                    parentId={gridComponent.id}
-                    depth={depth + 1}
-                    index={index}
-                    availableColumnCount={GRID_COLUMN_COUNT}
-                    columnWidth={columnWidth}
-                    onResizeStart={this.handleResizeStart}
-                    onResize={this.handleResize}
-                    onResizeStop={this.handleResizeStop}
-                  />
-                ))}
-
-                {/* render an empty drop target */}
-                {editMode && (
-                  <DragDroppable
-                    component={gridComponent}
-                    depth={depth}
-                    parentComponent={null}
-                    index={gridComponent.children.length}
-                    orientation="column"
-                    onDrop={handleComponentDrop}
-                    className="empty-grid-droptarget"
-                    editMode
-                  >
-                    {({ dropIndicatorProps }) =>
-                      dropIndicatorProps && (
-                        <div className="drop-indicator drop-indicator--top" />
-                      )
-                    }
-                  </DragDroppable>
-                )}
-
-                {isResizing &&
-                  Array(GRID_COLUMN_COUNT)
-                    .fill(null)
-                    .map((_, i) => (
-                      <div
-                        key={`grid-column-${i}`}
-                        className="grid-column-guide"
-                        style={{
-                          left: i * GRID_GUTTER_SIZE + i * columnWidth,
-                          width: columnWidth,
-                        }}
-                      />
-                    ))}
-
-                {isResizing &&
-                  rowGuideTop && (
-                    <div
-                      className="grid-row-guide"
-                      style={{
-                        top: rowGuideTop,
-                        width,
-                      }}
-                    />
-                  )}
-              </div>
-            );
-          }}
-        </ParentSize>
+    return width < 100 ? null : (
+      <div className="dashboard-grid" ref={this.setGridRef}>
+        <div className="grid-content">
+          {/* empty drop target makes top droppable */}
+          {editMode && (
+            <DragDroppable
+              component={gridComponent}
+              depth={depth}
+              parentComponent={null}
+              index={0}
+              orientation="column"
+              onDrop={this.handleTopDropTargetDrop}
+              className="empty-grid-droptarget--top"
+              editMode
+            >
+              {({ dropIndicatorProps }) =>
+                dropIndicatorProps && (
+                  <div className="drop-indicator drop-indicator--bottom" />
+                )
+              }
+            </DragDroppable>
+          )}
+
+          {gridComponent.children.map((id, index) => (
+            <DashboardComponent
+              key={id}
+              id={id}
+              parentId={gridComponent.id}
+              depth={depth + 1}
+              index={index}
+              availableColumnCount={GRID_COLUMN_COUNT}
+              columnWidth={columnWidth}
+              onResizeStart={this.handleResizeStart}
+              onResize={this.handleResize}
+              onResizeStop={this.handleResizeStop}
+            />
+          ))}
+
+          {/* empty drop target makes bottom droppable */}
+          {editMode && (
+            <DragDroppable
+              component={gridComponent}
+              depth={depth}
+              parentComponent={null}
+              index={gridComponent.children.length}
+              orientation="column"
+              onDrop={handleComponentDrop}
+              className="empty-grid-droptarget--bottom"
+              editMode
+            >
+              {({ dropIndicatorProps }) =>
+                dropIndicatorProps && (
+                  <div className="drop-indicator drop-indicator--top" />
+                )
+              }
+            </DragDroppable>
+          )}
+
+          {isResizing &&
+            Array(GRID_COLUMN_COUNT)
+              .fill(null)
+              .map((_, i) => (
+                <div
+                  key={`grid-column-${i}`}
+                  className="grid-column-guide"
+                  style={{
+                    left: i * GRID_GUTTER_SIZE + i * columnWidth,
+                    width: columnWidth,
+                  }}
+                />
+              ))}
+
+          {isResizing &&
+            rowGuideTop && (
+              <div
+                className="grid-row-guide"
+                style={{
+                  top: rowGuideTop,
+                  width,
+                }}
+              />
+            )}
+        </div>
       </div>
     );
   }
diff --git a/superset/assets/src/dashboard/components/Header.jsx b/superset/assets/src/dashboard/components/Header.jsx
index 242102e..21b01db 100644
--- a/superset/assets/src/dashboard/components/Header.jsx
+++ b/superset/assets/src/dashboard/components/Header.jsx
@@ -6,12 +6,14 @@ import Controls from './Controls';
 import EditableTitle from '../../components/EditableTitle';
 import Button from '../../components/Button';
 import FaveStar from '../../components/FaveStar';
-// import InfoTooltipWithTrigger from '../../components/InfoTooltipWithTrigger';
 import SaveModal from './SaveModal';
 import { chartPropShape } from '../util/propShapes';
 import { t } from '../../locales';
+import { UNDO_LIMIT } from '../util/constants';
 
 const propTypes = {
+  addSuccessToast: PropTypes.func.isRequired,
+  addDangerToast: PropTypes.func.isRequired,
   dashboardInfo: PropTypes.object.isRequired,
   dashboardTitle: PropTypes.string.isRequired,
   charts: PropTypes.objectOf(chartPropShape).isRequired,
@@ -31,23 +33,45 @@ const propTypes = {
   showBuilderPane: PropTypes.bool.isRequired,
   toggleBuilderPane: PropTypes.func.isRequired,
   hasUnsavedChanges: PropTypes.bool.isRequired,
+  maxUndoHistoryExceeded: PropTypes.bool.isRequired,
 
   // redux
   onUndo: PropTypes.func.isRequired,
   onRedo: PropTypes.func.isRequired,
-  canUndo: PropTypes.bool.isRequired,
-  canRedo: PropTypes.bool.isRequired,
+  undoLength: PropTypes.number.isRequired,
+  redoLength: PropTypes.number.isRequired,
+  setMaxUndoHistoryExceeded: PropTypes.func.isRequired,
+  maxUndoHistoryToast: PropTypes.func.isRequired,
 };
 
 class Header extends React.PureComponent {
   constructor(props) {
     super(props);
+    this.state = {
+      didNotifyMaxUndoHistoryToast: false,
+    };
 
     this.handleChangeText = this.handleChangeText.bind(this);
     this.toggleEditMode = this.toggleEditMode.bind(this);
     this.forceRefresh = this.forceRefresh.bind(this);
   }
 
+  componentWillReceiveProps(nextProps) {
+    if (
+      UNDO_LIMIT - nextProps.undoLength <= 0 &&
+      !this.state.didNotifyMaxUndoHistoryToast
+    ) {
+      this.setState(() => ({ didNotifyMaxUndoHistoryToast: true }));
+      this.props.maxUndoHistoryToast();
+    }
+    if (
+      nextProps.undoLength > UNDO_LIMIT &&
+      !this.props.maxUndoHistoryExceeded
+    ) {
+      this.props.setMaxUndoHistoryExceeded();
+    }
+  }
+
   forceRefresh() {
     return this.props.fetchCharts(Object.values(this.props.charts), true);
   }
@@ -72,8 +96,8 @@ class Header extends React.PureComponent {
       expandedSlices,
       onUndo,
       onRedo,
-      canUndo,
-      canRedo,
+      undoLength,
+      redoLength,
       onChange,
       onSave,
       editMode,
@@ -91,9 +115,9 @@ class Header extends React.PureComponent {
             title={dashboardTitle}
             canEdit={this.props.dashboardInfo.dash_save_perm && editMode}
             onSaveTitle={this.handleChangeText}
-            showTooltip={editMode}
+            showTooltip={false}
           />
-          <span className="favstar m-r-5">
+          <span className="favstar m-l-5">
             <FaveStar
               itemId={this.props.dashboardInfo.id}
               fetchFaveStar={this.props.fetchFaveStar}
@@ -106,14 +130,22 @@ class Header extends React.PureComponent {
           {userCanEdit && (
             <ButtonGroup>
               {editMode && (
-                <Button bsSize="small" onClick={onUndo} disabled={!canUndo}>
-                  Undo
+                <Button
+                  bsSize="small"
+                  onClick={onUndo}
+                  disabled={undoLength < 1}
+                >
+                  <div title="Undo" className="undo-action fa fa-reply" />
                 </Button>
               )}
 
               {editMode && (
-                <Button bsSize="small" onClick={onRedo} disabled={!canRedo}>
-                  Redo
+                <Button
+                  bsSize="small"
+                  onClick={onRedo}
+                  disabled={redoLength < 1}
+                >
+                  <div title="Redo" className="redo-action fa fa-share" />
                 </Button>
               )}
 
@@ -135,6 +167,8 @@ class Header extends React.PureComponent {
                 </Button>
               ) : (
                 <SaveModal
+                  addSuccessToast={this.props.addSuccessToast}
+                  addDangerToast={this.props.addDangerToast}
                   dashboardId={this.props.dashboardInfo.id}
                   dashboardTitle={dashboardTitle}
                   layout={layout}
@@ -154,6 +188,8 @@ class Header extends React.PureComponent {
           )}
 
           <Controls
+            addSuccessToast={this.props.addSuccessToast}
+            addDangerToast={this.props.addDangerToast}
             dashboardInfo={this.props.dashboardInfo}
             dashboardTitle={dashboardTitle}
             layout={layout}
diff --git a/superset/assets/src/dashboard/components/SaveModal.jsx b/superset/assets/src/dashboard/components/SaveModal.jsx
index 41c6364..1d287d6 100644
--- a/superset/assets/src/dashboard/components/SaveModal.jsx
+++ b/superset/assets/src/dashboard/components/SaveModal.jsx
@@ -1,4 +1,4 @@
-/* global notify, window */
+/* eslint-env browser */
 import React from 'react';
 import PropTypes from 'prop-types';
 import $ from 'jquery';
@@ -10,6 +10,8 @@ import { t } from '../../locales';
 import Checkbox from '../../components/Checkbox';
 
 const propTypes = {
+  addSuccessToast: PropTypes.func.isRequired,
+  addDangerToast: PropTypes.func.isRequired,
   dashboardId: PropTypes.number.isRequired,
   dashboardTitle: PropTypes.string.isRequired,
   expandedSlices: PropTypes.object.isRequired,
@@ -61,31 +63,31 @@ class SaveModal extends React.PureComponent {
     });
   }
 
+  // @TODO this should all be moved to actions
   saveDashboardRequest(data, url, saveType) {
-    const saveModal = this.modal;
-    const onSaveDashboard = this.props.onSave;
     $.ajax({
       type: 'POST',
       url,
       data: {
         data: JSON.stringify(data),
       },
-      success(resp) {
-        saveModal.close();
-        onSaveDashboard();
+      success: resp => {
+        this.modal.close();
+        this.props.onSave();
         if (saveType === 'newDashboard') {
           window.location = `/superset/dashboard/${resp.id}/`;
         } else {
-          notify.success(t('This dashboard was saved successfully.'));
+          this.props.addSuccessToast(
+            t('This dashboard was saved successfully.'),
+          );
         }
       },
-      error(error) {
-        saveModal.close();
+      error: error => {
+        this.modal.close();
         const errorMsg = getAjaxErrorMsg(error);
-        notify.error(
-          `${t(
-            'Sorry, there was an error saving this dashboard: ',
-          )} ${errorMsg}`,
+        this.props.addDangerToast(
+          `${t('Sorry, there was an error saving this dashboard: ')}
+          ${errorMsg}`,
         );
       },
     });
@@ -115,7 +117,9 @@ class SaveModal extends React.PureComponent {
       this.saveDashboardRequest(data, url, saveType);
     } else if (saveType === 'newDashboard') {
       if (!newDashName) {
-        notify.error('You must pick a name for the new dashboard');
+        this.props.addDangerToast(
+          t('You must pick a name for the new dashboard'),
+        );
       } else {
         data.dashboard_title = newDashName;
         url = `/superset/copy_dash/${dashboardId}/`;
diff --git a/superset/assets/src/dashboard/components/SliceAdder.jsx b/superset/assets/src/dashboard/components/SliceAdder.jsx
index 37ce21f..05c4270 100644
--- a/superset/assets/src/dashboard/components/SliceAdder.jsx
+++ b/superset/assets/src/dashboard/components/SliceAdder.jsx
@@ -1,3 +1,4 @@
+/* eslint-env browser */
 import React from 'react';
 import PropTypes from 'prop-types';
 import { DropdownButton, MenuItem } from 'react-bootstrap';
@@ -20,12 +21,14 @@ const propTypes = {
   userId: PropTypes.string.isRequired,
   selectedSliceIds: PropTypes.object,
   editMode: PropTypes.bool,
+  height: PropTypes.number,
 };
 
 const defaultProps = {
   selectedSliceIds: new Set(),
   editMode: false,
   errorMessage: '',
+  height: window.innerHeight,
 };
 
 const KEYS_TO_FILTERS = ['slice_name', 'viz_type', 'datasource_name'];
@@ -179,6 +182,7 @@ class SliceAdder extends React.Component {
           </DropdownButton>
 
           <SearchInput
+            className="search-input"
             onChange={this.searchUpdated}
             onKeyPress={this.handleKeyPress}
           />
@@ -198,7 +202,7 @@ class SliceAdder extends React.Component {
           this.state.filteredSlices.length > 0 && (
             <List
               width={376}
-              height={500}
+              height={this.props.height}
               rowCount={this.state.filteredSlices.length}
               rowHeight={136}
               rowRenderer={this.rowRenderer}
diff --git a/superset/assets/src/dashboard/components/SliceHeader.jsx b/superset/assets/src/dashboard/components/SliceHeader.jsx
index bcdaedf..0c572d8 100644
--- a/superset/assets/src/dashboard/components/SliceHeader.jsx
+++ b/superset/assets/src/dashboard/components/SliceHeader.jsx
@@ -20,6 +20,7 @@ const propTypes = {
   editMode: PropTypes.bool,
   annotationQuery: PropTypes.object,
   annotationError: PropTypes.object,
+  sliceName: PropTypes.string,
 };
 
 const defaultProps = {
@@ -36,21 +37,10 @@ const defaultProps = {
   cachedDttm: null,
   isCached: false,
   isExpanded: false,
+  sliceName: '',
 };
 
 class SliceHeader extends React.PureComponent {
-  constructor(props) {
-    super(props);
-
-    this.onSaveTitle = this.onSaveTitle.bind(this);
-  }
-
-  onSaveTitle(newTitle) {
-    if (this.props.updateSliceName) {
-      this.props.updateSliceName(this.props.slice.slice_id, newTitle);
-    }
-  }
-
   render() {
     const {
       slice,
@@ -62,6 +52,7 @@ class SliceHeader extends React.PureComponent {
       exploreChart,
       exportCSV,
       innerRef,
+      sliceName,
     } = this.props;
 
     const annoationsLoading = t('Annotation layers are still loading.');
@@ -71,13 +62,10 @@ class SliceHeader extends React.PureComponent {
       <div className="chart-header" ref={innerRef}>
         <div className="header">
           <EditableTitle
-            title={slice.slice_name}
-            canEdit={!!this.props.updateSliceName && this.props.editMode}
-            onSaveTitle={this.onSaveTitle}
-            noPermitTooltip={
-              "You don't have the rights to alter this dashboard."
-            }
-            showTooltip={!!this.props.updateSliceName && this.props.editMode}
+            title={sliceName}
+            canEdit={this.props.editMode}
+            onSaveTitle={this.props.updateSliceName}
+            showTooltip={this.props.editMode}
           />
           {!!Object.values(this.props.annotationQuery).length && (
             <TooltipWrapper
diff --git a/superset/assets/src/dashboard/components/SliceHeaderControls.jsx b/superset/assets/src/dashboard/components/SliceHeaderControls.jsx
index 0dae6f8..e793bc2 100644
--- a/superset/assets/src/dashboard/components/SliceHeaderControls.jsx
+++ b/superset/assets/src/dashboard/components/SliceHeaderControls.jsx
@@ -2,9 +2,8 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import cx from 'classnames';
 import moment from 'moment';
-import { DropdownButton } from 'react-bootstrap';
+import { Dropdown, MenuItem } from 'react-bootstrap';
 
-import { ActionMenuItem } from './ActionMenuItem';
 import { t } from '../../locales';
 
 const propTypes = {
@@ -28,6 +27,14 @@ const defaultProps = {
   isExpanded: false,
 };
 
+const VerticalDotsTrigger = () => (
+  <div className="vertical-dots-container">
+    <span className="dot" />
+    <span className="dot" />
+    <span className="dot" />
+  </div>
+);
+
 class SliceHeaderControls extends React.PureComponent {
   constructor(props) {
     super(props);
@@ -57,56 +64,47 @@ class SliceHeaderControls extends React.PureComponent {
     const slice = this.props.slice;
     const isCached = this.props.isCached;
     const cachedWhen = moment.utc(this.props.cachedDttm).fromNow();
-    const refreshTooltip = isCached
-      ? t('Served from data cached %s . Click to force refresh.', cachedWhen)
-      : t('Force refresh data');
+    const refreshTooltip = isCached ? t('Cached %s', cachedWhen) : '';
 
     // @TODO account for
     //  dashboard.dashboard.superset_can_explore
     //  dashboard.dashboard.slice_can_edit
     return (
-      <DropdownButton
-        title=""
+      <Dropdown
         id={`slice_${slice.slice_id}-controls`}
-        className={cx('slice-header-controls-trigger', 'fa fa-ellipsis-v', {
-          'is-cached': isCached,
-        })}
+        className={cx(isCached && 'is-cached')}
         pullRight
-        noCaret
       >
-        <ActionMenuItem
-          text={t('Force refresh data')}
-          tooltip={refreshTooltip}
-          onClick={this.props.forceRefresh}
-        />
-
-        {slice.description && (
-          <ActionMenuItem
-            text={t('Toggle chart description')}
-            tooltip={t('Toggle chart description')}
-            onClick={this.toggleExpandSlice}
-          />
-        )}
-
-        <ActionMenuItem
-          text={t('Edit chart')}
-          tooltip={t("Edit the chart's properties")}
-          href={slice.edit_url}
-          target="_blank"
-        />
-
-        <ActionMenuItem
-          text={t('Export CSV')}
-          tooltip={t('Export CSV')}
-          onClick={this.exportCSV}
-        />
-
-        <ActionMenuItem
-          text={t('Explore chart')}
-          tooltip={t('Explore chart')}
-          onClick={this.exploreChart}
-        />
-      </DropdownButton>
+        <Dropdown.Toggle className="slice-header-controls-trigger" noCaret>
+          <VerticalDotsTrigger />
+        </Dropdown.Toggle>
+
+        <Dropdown.Menu>
+          <MenuItem onClick={this.props.forceRefresh}>
+            {isCached && <span className="dot" />}
+            {t('Force refresh')}
+            {isCached && (
+              <div className="refresh-tooltip">{refreshTooltip}</div>
+            )}
+          </MenuItem>
+
+          <MenuItem divider />
+
+          {slice.description && (
+            <MenuItem onClick={this.toggleExpandSlice}>
+              {t('Toggle chart description')}
+            </MenuItem>
+          )}
+
+          <MenuItem href={slice.edit_url} target="_blank">
+            {t('Edit chart metadata')}
+          </MenuItem>
+
+          <MenuItem onClick={this.exportCSV}>{t('Export CSV')}</MenuItem>
+
+          <MenuItem onClick={this.exploreChart}>{t('Explore chart')}</MenuItem>
+        </Dropdown.Menu>
+      </Dropdown>
     );
   }
 }
diff --git a/superset/assets/src/dashboard/components/dnd/AddSliceDragPreview.jsx b/superset/assets/src/dashboard/components/dnd/AddSliceDragPreview.jsx
index 94cab42..91fc055 100644
--- a/superset/assets/src/dashboard/components/dnd/AddSliceDragPreview.jsx
+++ b/superset/assets/src/dashboard/components/dnd/AddSliceDragPreview.jsx
@@ -9,6 +9,16 @@ import {
   CHART_TYPE,
 } from '../../util/componentTypes';
 
+const staticCardStyles = {
+  position: 'fixed',
+  background: 'white',
+  pointerEvents: 'none',
+  top: 0,
+  left: 0,
+  zIndex: 100,
+  width: 376 - 2 * 16,
+};
+
 const propTypes = {
   dragItem: PropTypes.shape({
     index: PropTypes.number.isRequired,
@@ -41,12 +51,7 @@ function AddSliceDragPreview({ dragItem, slices, isDragging, currentOffset }) {
   return !shouldRender ? null : (
     <AddSliceCard
       style={{
-        position: 'fixed',
-        background: 'white',
-        pointerEvents: 'none',
-        top: 0,
-        left: 0,
-        zIndex: 100,
+        ...staticCardStyles,
         transform: `translate(${currentOffset.x}px, ${currentOffset.y}px)`,
       }}
       sliceName={slice.slice_name}
diff --git a/superset/assets/src/dashboard/components/gridComponents/Chart.jsx b/superset/assets/src/dashboard/components/gridComponents/Chart.jsx
index 54e1536..4742d71 100644
--- a/superset/assets/src/dashboard/components/gridComponents/Chart.jsx
+++ b/superset/assets/src/dashboard/components/gridComponents/Chart.jsx
@@ -13,16 +13,17 @@ const propTypes = {
   id: PropTypes.number.isRequired,
   width: PropTypes.number.isRequired,
   height: PropTypes.number.isRequired,
+  updateSliceName: PropTypes.func.isRequired,
 
   // from redux
   chart: PropTypes.shape(chartPropType).isRequired,
   formData: PropTypes.object.isRequired,
   datasource: PropTypes.object.isRequired,
   slice: slicePropShape.isRequired,
+  sliceName: PropTypes.string.isRequired,
   timeout: PropTypes.number.isRequired,
   filters: PropTypes.object.isRequired,
   refreshChart: PropTypes.func.isRequired,
-  saveSliceName: PropTypes.func.isRequired,
   toggleExpandSlice: PropTypes.func.isRequired,
   addFilter: PropTypes.func.isRequired,
   removeFilter: PropTypes.func.isRequired,
@@ -150,6 +151,8 @@ class Chart extends React.Component {
       isExpanded,
       editMode,
       formData,
+      updateSliceName,
+      sliceName,
       toggleExpandSlice,
       timeout,
     } = this.props;
@@ -161,25 +164,21 @@ class Chart extends React.Component {
     const isOverflowable = OVERFLOWABLE_VIZ_TYPES.has(slice && slice.viz_type);
 
     return (
-      <div
-        className={cx(
-          'dashboard-chart',
-          isOverflowable && 'dashboard-chart--overflowable',
-        )}
-      >
+      <div>
         <SliceHeader
           innerRef={this.setHeaderRef}
           slice={slice}
           isExpanded={!!isExpanded}
           isCached={isCached}
           cachedDttm={cachedDttm}
-          updateSliceName={this.updateSliceName}
           toggleExpandSlice={toggleExpandSlice}
           forceRefresh={this.forceRefresh}
           editMode={editMode}
           annotationQuery={chart.annotationQuery}
           exploreChart={this.exploreChart}
           exportCSV={this.exportCSV}
+          updateSliceName={updateSliceName}
+          sliceName={sliceName}
         />
 
         {/*
@@ -199,30 +198,37 @@ class Chart extends React.Component {
             />
           )}
 
-        <ChartContainer
-          containerId={`slice-container-${id}`}
-          chartId={id}
-          datasource={datasource}
-          formData={formData}
-          headerHeight={this.getHeaderHeight()}
-          height={this.getChartHeight()}
-          width={width}
-          timeout={timeout}
-          vizType={slice.viz_type}
-          addFilter={this.addFilter}
-          getFilters={this.getFilters}
-          removeFilter={this.removeFilter}
-          annotationData={chart.annotationData}
-          chartAlert={chart.chartAlert}
-          chartStatus={chart.chartStatus}
-          chartUpdateEndTime={chart.chartUpdateEndTime}
-          chartUpdateStartTime={chart.chartUpdateStartTime}
-          latestQueryFormData={chart.latestQueryFormData}
-          lastRendered={chart.lastRendered}
-          queryResponse={chart.queryResponse}
-          queryRequest={chart.queryRequest}
-          triggerQuery={chart.triggerQuery}
-        />
+        <div
+          className={cx(
+            'dashboard-chart',
+            isOverflowable && 'dashboard-chart--overflowable',
+          )}
+        >
+          <ChartContainer
+            containerId={`slice-container-${id}`}
+            chartId={id}
+            datasource={datasource}
+            formData={formData}
+            headerHeight={this.getHeaderHeight()}
+            height={this.getChartHeight()}
+            width={width}
+            timeout={timeout}
+            vizType={slice.viz_type}
+            addFilter={this.addFilter}
+            getFilters={this.getFilters}
+            removeFilter={this.removeFilter}
+            annotationData={chart.annotationData}
+            chartAlert={chart.chartAlert}
+            chartStatus={chart.chartStatus}
+            chartUpdateEndTime={chart.chartUpdateEndTime}
+            chartUpdateStartTime={chart.chartUpdateStartTime}
+            latestQueryFormData={chart.latestQueryFormData}
+            lastRendered={chart.lastRendered}
+            queryResponse={chart.queryResponse}
+            queryRequest={chart.queryRequest}
+            triggerQuery={chart.triggerQuery}
+          />
+        </div>
       </div>
     );
   }
diff --git a/superset/assets/src/dashboard/components/gridComponents/ChartHolder.jsx b/superset/assets/src/dashboard/components/gridComponents/ChartHolder.jsx
index a684230..bc9f430 100644
--- a/superset/assets/src/dashboard/components/gridComponents/ChartHolder.jsx
+++ b/superset/assets/src/dashboard/components/gridComponents/ChartHolder.jsx
@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
 import Chart from '../../containers/Chart';
 import DeleteComponentButton from '../DeleteComponentButton';
 import DragDroppable from '../dnd/DragDroppable';
-import DragHandle from '../dnd/DragHandle';
 import HoverMenu from '../menu/HoverMenu';
 import ResizableContainer from '../resizable/ResizableContainer';
 import { componentShape } from '../../util/propShapes';
@@ -35,6 +34,7 @@ const propTypes = {
 
   // dnd
   deleteComponent: PropTypes.func.isRequired,
+  updateComponents: PropTypes.func.isRequired,
   handleComponentDrop: PropTypes.func.isRequired,
 };
 
@@ -49,6 +49,7 @@ class ChartHolder extends React.Component {
 
     this.handleChangeFocus = this.handleChangeFocus.bind(this);
     this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+    this.handleUpdateSliceName = this.handleUpdateSliceName.bind(this);
   }
 
   handleChangeFocus(nextFocus) {
@@ -60,6 +61,19 @@ class ChartHolder extends React.Component {
     deleteComponent(id, parentId);
   }
 
+  handleUpdateSliceName(nextName) {
+    const { component, updateComponents } = this.props;
+    updateComponents({
+      [component.id]: {
+        ...component,
+        meta: {
+          ...component.meta,
+          chartName: nextName,
+        },
+      },
+    });
+  }
+
   render() {
     const { isFocused } = this.state;
 
@@ -119,10 +133,11 @@ class ChartHolder extends React.Component {
                 id={component.meta.chartId}
                 width={widthMultiple * columnWidth}
                 height={component.meta.height * GRID_BASE_UNIT - CHART_MARGIN}
+                sliceName={component.meta.chartName}
+                updateSliceName={this.handleUpdateSliceName}
               />
               {editMode && (
                 <HoverMenu position="top">
-                  <DragHandle position="top" />
                   <DeleteComponentButton
                     onDelete={this.handleDeleteComponent}
                   />
diff --git a/superset/assets/src/dashboard/components/gridComponents/Column.jsx b/superset/assets/src/dashboard/components/gridComponents/Column.jsx
index a71d732..7249034 100644
--- a/superset/assets/src/dashboard/components/gridComponents/Column.jsx
+++ b/superset/assets/src/dashboard/components/gridComponents/Column.jsx
@@ -142,6 +142,18 @@ class Column extends React.PureComponent {
               ]}
               editMode={editMode}
             >
+              {editMode && (
+                <HoverMenu innerRef={dragSourceRef} position="top">
+                  <DragHandle position="top" />
+                  <DeleteComponentButton
+                    onDelete={this.handleDeleteComponent}
+                  />
+                  <IconButton
+                    onClick={this.handleChangeFocus}
+                    className="fa fa-cog"
+                  />
+                </HoverMenu>
+              )}
               <div
                 className={cx(
                   'grid-column',
@@ -149,19 +161,6 @@ class Column extends React.PureComponent {
                   backgroundStyle.className,
                 )}
               >
-                {editMode && (
-                  <HoverMenu innerRef={dragSourceRef} position="top">
-                    <DragHandle position="top" />
-                    <DeleteComponentButton
-                      onDelete={this.handleDeleteComponent}
-                    />
-                    <IconButton
-                      onClick={this.handleChangeFocus}
-                      className="fa fa-cog"
-                    />
-                  </HoverMenu>
-                )}
-
                 {columnItems.map((componentId, itemIndex) => (
                   <DashboardComponent
                     key={componentId}
diff --git a/superset/assets/src/dashboard/components/gridComponents/Row.jsx b/superset/assets/src/dashboard/components/gridComponents/Row.jsx
index 91f200d..3119a08 100644
--- a/superset/assets/src/dashboard/components/gridComponents/Row.jsx
+++ b/superset/assets/src/dashboard/components/gridComponents/Row.jsx
@@ -128,6 +128,16 @@ class Row extends React.PureComponent {
             ]}
             editMode={editMode}
           >
+            {editMode && (
+              <HoverMenu innerRef={dragSourceRef} position="left">
+                <DragHandle position="left" />
+                <DeleteComponentButton onDelete={this.handleDeleteComponent} />
+                <IconButton
+                  onClick={this.handleChangeFocus}
+                  className="fa fa-cog"
+                />
+              </HoverMenu>
+            )}
             <div
               className={cx(
                 'grid-row',
@@ -135,19 +145,6 @@ class Row extends React.PureComponent {
                 backgroundStyle.className,
               )}
             >
-              {editMode && (
-                <HoverMenu innerRef={dragSourceRef} position="left">
-                  <DragHandle position="left" />
-                  <DeleteComponentButton
-                    onDelete={this.handleDeleteComponent}
-                  />
-                  <IconButton
-                    onClick={this.handleChangeFocus}
-                    className="fa fa-cog"
-                  />
-                </HoverMenu>
-              )}
-
               {rowItems.map((componentId, itemIndex) => (
                 <DashboardComponent
                   key={componentId}
diff --git a/superset/assets/src/dashboard/components/gridComponents/Tab.jsx b/superset/assets/src/dashboard/components/gridComponents/Tab.jsx
index d73bc0c..63619c1 100644
--- a/superset/assets/src/dashboard/components/gridComponents/Tab.jsx
+++ b/superset/assets/src/dashboard/components/gridComponents/Tab.jsx
@@ -136,7 +136,7 @@ export default class Tab extends React.PureComponent {
         // disable drag drop of top-level Tab's to prevent invalid nesting of a child in
         // itself, e.g. if a top-level Tab has a Tabs child, dragging the Tab into the Tabs would
         // reusult in circular children
-        disableDragDrop={isFocused || depth === DASHBOARD_ROOT_DEPTH + 1}
+        disableDragDrop={depth === DASHBOARD_ROOT_DEPTH + 1}
         editMode={editMode}
       >
         {({ dropIndicatorProps, dragSourceRef }) => (
diff --git a/superset/assets/src/dashboard/components/gridComponents/Tabs.jsx b/superset/assets/src/dashboard/components/gridComponents/Tabs.jsx
index 585041f..813961d 100644
--- a/superset/assets/src/dashboard/components/gridComponents/Tabs.jsx
+++ b/superset/assets/src/dashboard/components/gridComponents/Tabs.jsx
@@ -164,7 +164,11 @@ class Tabs extends React.PureComponent {
               id={tabsComponent.id}
               activeKey={selectedTabIndex}
               onSelect={this.handleClickTab}
-              animation={false}
+              // these are important for performant loading of tabs. also, there is a
+              // react-bootstrap bug where mountOnEnter has no effect unless animation=true
+              animation
+              mountOnEnter
+              unmountOnExit={false}
             >
               {tabIds.map((tabId, tabIndex) => (
                 // react-bootstrap doesn't render a Tab if we move this to its own Tab.jsx so we
@@ -187,27 +191,21 @@ class Tabs extends React.PureComponent {
                     />
                   }
                 >
-                  {/*
-                    react-bootstrap renders all children with display:none, so we don't
-                    render potentially-expensive charts (this also enables lazy loading
-                    their content)
-                  */}
-                  {tabIndex === selectedTabIndex &&
-                    renderTabContent && (
-                      <DashboardComponent
-                        id={tabId}
-                        parentId={tabsComponent.id}
-                        depth={depth} // see isValidChild.js for why tabs don't increment child depth
-                        index={tabIndex}
-                        renderType={RENDER_TAB_CONTENT}
-                        availableColumnCount={availableColumnCount}
-                        columnWidth={columnWidth}
-                        onResizeStart={onResizeStart}
-                        onResize={onResize}
-                        onResizeStop={onResizeStop}
-                        onDropOnTab={this.handleDropOnTab}
-                      />
-                    )}
+                  {renderTabContent && (
+                    <DashboardComponent
+                      id={tabId}
+                      parentId={tabsComponent.id}
+                      depth={depth} // see isValidChild.js for why tabs don't increment child depth
+                      index={tabIndex}
+                      renderType={RENDER_TAB_CONTENT}
+                      availableColumnCount={availableColumnCount}
+                      columnWidth={columnWidth}
+                      onResizeStart={onResizeStart}
+                      onResize={onResize}
+                      onResizeStop={onResizeStop}
+                      onDropOnTab={this.handleDropOnTab}
+                    />
+                  )}
                 </BootstrapTab>
               ))}
 
diff --git a/superset/assets/src/dashboard/components/menu/WithPopoverMenu.jsx b/superset/assets/src/dashboard/components/menu/WithPopoverMenu.jsx
index 8a87fca..2a047ac 100644
--- a/superset/assets/src/dashboard/components/menu/WithPopoverMenu.jsx
+++ b/superset/assets/src/dashboard/components/menu/WithPopoverMenu.jsx
@@ -10,6 +10,7 @@ const propTypes = {
   isFocused: PropTypes.bool,
   shouldFocus: PropTypes.func,
   editMode: PropTypes.bool.isRequired,
+  style: PropTypes.object,
 };
 
 const defaultProps = {
@@ -20,6 +21,7 @@ const defaultProps = {
   menuItems: [],
   isFocused: false,
   shouldFocus: (event, container) => container.contains(event.target),
+  style: null,
 };
 
 class WithPopoverMenu extends React.PureComponent {
@@ -84,7 +86,7 @@ class WithPopoverMenu extends React.PureComponent {
   }
 
   render() {
-    const { children, menuItems, editMode } = this.props;
+    const { children, menuItems, editMode, style } = this.props;
     const { isFocused } = this.state;
 
     return (
@@ -96,6 +98,7 @@ class WithPopoverMenu extends React.PureComponent {
           'with-popover-menu',
           editMode && isFocused && 'with-popover-menu--focused',
         )}
+        style={style}
       >
         {children}
         {editMode &&
diff --git a/superset/assets/src/dashboard/containers/Chart.jsx b/superset/assets/src/dashboard/containers/Chart.jsx
index 470176b..61627d2 100644
--- a/superset/assets/src/dashboard/containers/Chart.jsx
+++ b/superset/assets/src/dashboard/containers/Chart.jsx
@@ -8,7 +8,7 @@ import {
 } from '../actions/dashboardState';
 import { refreshChart } from '../../chart/chartAction';
 import getFormDataWithExtraFilters from '../util/charts/getFormDataWithExtraFilters';
-import { saveSliceName } from '../actions/sliceEntities';
+import { updateComponents } from '../actions/dashboardLayout';
 import Chart from '../components/gridComponents/Chart';
 
 function mapStateToProps(
@@ -46,7 +46,7 @@ function mapStateToProps(
 function mapDispatchToProps(dispatch) {
   return bindActionCreators(
     {
-      saveSliceName,
+      updateComponents,
       toggleExpandSlice,
       addFilter,
       refreshChart,
diff --git a/superset/assets/src/dashboard/containers/Dashboard.jsx b/superset/assets/src/dashboard/containers/Dashboard.jsx
index 9af0e81..bcf2ace 100644
--- a/superset/assets/src/dashboard/containers/Dashboard.jsx
+++ b/superset/assets/src/dashboard/containers/Dashboard.jsx
@@ -4,7 +4,6 @@ import { connect } from 'react-redux';
 import {
   addSliceToDashboard,
   removeSliceFromDashboard,
-  onChange,
 } from '../actions/dashboardState';
 import { runQuery } from '../../chart/chartAction';
 import Dashboard from '../components/Dashboard';
@@ -37,7 +36,6 @@ function mapDispatchToProps(dispatch) {
     actions: bindActionCreators(
       {
         addSliceToDashboard,
-        onChange,
         removeSliceFromDashboard,
         runQuery,
       },
diff --git a/superset/assets/src/dashboard/containers/DashboardComponent.jsx b/superset/assets/src/dashboard/containers/DashboardComponent.jsx
index 650313e..29071cb 100644
--- a/superset/assets/src/dashboard/containers/DashboardComponent.jsx
+++ b/superset/assets/src/dashboard/containers/DashboardComponent.jsx
@@ -26,13 +26,7 @@ const propTypes = {
 };
 
 function mapStateToProps(
-  {
-    dashboardLayout: undoableLayout,
-    dashboardState,
-    sliceEntities,
-    charts,
-    datasources,
-  },
+  { dashboardLayout: undoableLayout, dashboardState },
   ownProps,
 ) {
   const dashboardLayout = undoableLayout.present;
diff --git a/superset/assets/src/dashboard/containers/DashboardHeader.jsx b/superset/assets/src/dashboard/containers/DashboardHeader.jsx
index 2b3431a..fe7e7bb 100644
--- a/superset/assets/src/dashboard/containers/DashboardHeader.jsx
+++ b/superset/assets/src/dashboard/containers/DashboardHeader.jsx
@@ -1,8 +1,8 @@
-import { ActionCreators as UndoActionCreators } from 'redux-undo';
 import { bindActionCreators } from 'redux';
 import { connect } from 'react-redux';
 
 import DashboardHeader from '../components/Header';
+
 import {
   setEditMode,
   toggleBuilderPane,
@@ -10,11 +10,21 @@ import {
   saveFaveStar,
   fetchCharts,
   startPeriodicRender,
-  updateDashboardTitle,
   onChange,
-  onSave,
+  saveDashboard,
+  setMaxUndoHistoryExceeded,
+  maxUndoHistoryToast,
 } from '../actions/dashboardState';
-import { handleComponentDrop } from '../actions/dashboardLayout';
+
+import {
+  undoLayoutAction,
+  redoLayoutAction,
+  updateDashboardTitle,
+} from '../actions/dashboardLayout';
+
+import { addSuccessToast, addDangerToast } from '../actions/messageToasts';
+
+import { DASHBOARD_HEADER_ID } from '../util/constants';
 
 function mapStateToProps({
   dashboardLayout: undoableLayout,
@@ -24,16 +34,19 @@ function mapStateToProps({
 }) {
   return {
     dashboardInfo,
-    canUndo: undoableLayout.past.length > 0,
-    canRedo: undoableLayout.future.length > 0,
+    undoLength: undoableLayout.past.length,
+    redoLength: undoableLayout.future.length,
     layout: undoableLayout.present,
     filters: dashboard.filters,
-    dashboardTitle: dashboard.title,
+    dashboardTitle: (
+      (undoableLayout.present[DASHBOARD_HEADER_ID] || {}).meta || {}
+    ).text,
     expandedSlices: dashboard.expandedSlices,
     charts,
     userId: dashboardInfo.userId,
     isStarred: !!dashboard.isStarred,
     hasUnsavedChanges: !!dashboard.hasUnsavedChanges,
+    maxUndoHistoryExceeded: !!dashboard.maxUndoHistoryExceeded,
     editMode: !!dashboard.editMode,
     showBuilderPane: !!dashboard.showBuilderPane,
   };
@@ -42,9 +55,10 @@ function mapStateToProps({
 function mapDispatchToProps(dispatch) {
   return bindActionCreators(
     {
-      handleComponentDrop,
-      onUndo: UndoActionCreators.undo,
-      onRedo: UndoActionCreators.redo,
+      addSuccessToast,
+      addDangerToast,
+      onUndo: undoLayoutAction,
+      onRedo: redoLayoutAction,
       setEditMode,
       toggleBuilderPane,
       fetchFaveStar,
@@ -53,7 +67,9 @@ function mapDispatchToProps(dispatch) {
       startPeriodicRender,
       updateDashboardTitle,
       onChange,
-      onSave,
+      onSave: saveDashboard,
+      setMaxUndoHistoryExceeded,
+      maxUndoHistoryToast,
     },
     dispatch,
   );
diff --git a/superset/assets/src/dashboard/containers/SliceAdder.js b/superset/assets/src/dashboard/containers/SliceAdder.js
new file mode 100644
index 0000000..e3d931d
--- /dev/null
+++ b/superset/assets/src/dashboard/containers/SliceAdder.js
@@ -0,0 +1,28 @@
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+
+import { fetchAllSlices } from '../actions/sliceEntities';
+import SliceAdder from '../components/SliceAdder';
+
+function mapStateToProps({ sliceEntities, dashboardInfo, dashboardState }) {
+  return {
+    userId: dashboardInfo.userId,
+    selectedSliceIds: dashboardState.sliceIds,
+    slices: sliceEntities.slices,
+    isLoading: sliceEntities.isLoading,
+    errorMessage: sliceEntities.errorMessage,
+    lastUpdated: sliceEntities.lastUpdated,
+    editMode: dashboardState.editMode,
+  };
+}
+
+function mapDispatchToProps(dispatch) {
+  return bindActionCreators(
+    {
+      fetchAllSlices,
+    },
+    dispatch,
+  );
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(SliceAdder);
diff --git a/superset/assets/src/dashboard/reducers/dashboardState.js b/superset/assets/src/dashboard/reducers/dashboardState.js
index 7b5a17a..2d44399 100644
--- a/superset/assets/src/dashboard/reducers/dashboardState.js
+++ b/superset/assets/src/dashboard/reducers/dashboardState.js
@@ -9,6 +9,7 @@ import {
   REMOVE_SLICE,
   REMOVE_FILTER,
   SET_EDIT_MODE,
+  SET_MAX_UNDO_HISTORY_EXCEEDED,
   SET_UNSAVED_CHANGES,
   TOGGLE_BUILDER_PANE,
   TOGGLE_EXPAND_SLICE,
@@ -55,6 +56,10 @@ export default function dashboardStateReducer(state = {}, action) {
     [SET_EDIT_MODE]() {
       return { ...state, editMode: action.editMode };
     },
+    [SET_MAX_UNDO_HISTORY_EXCEEDED]() {
+      const { maxUndoHistoryExceeded = true } = action.payload;
+      return { ...state, maxUndoHistoryExceeded };
+    },
     [TOGGLE_BUILDER_PANE]() {
       return { ...state, showBuilderPane: !state.showBuilderPane };
     },
@@ -72,7 +77,11 @@ export default function dashboardStateReducer(state = {}, action) {
       return { ...state, hasUnsavedChanges: true };
     },
     [ON_SAVE]() {
-      return { ...state, hasUnsavedChanges: false };
+      return {
+        ...state,
+        hasUnsavedChanges: false,
+        maxUndoHistoryExceeded: false,
+      };
     },
 
     // filters
diff --git a/superset/assets/src/dashboard/reducers/getInitialState.js b/superset/assets/src/dashboard/reducers/getInitialState.js
index d0b4d7b..ba24b36 100644
--- a/superset/assets/src/dashboard/reducers/getInitialState.js
+++ b/superset/assets/src/dashboard/reducers/getInitialState.js
@@ -7,7 +7,8 @@ import { getParam } from '../../modules/utils';
 import { applyDefaultFormData } from '../../explore/stores/store';
 import { getColorFromScheme } from '../../modules/colors';
 import layoutConverter from '../util/dashboardLayoutConverter';
-import { DASHBOARD_ROOT_ID } from '../util/constants';
+import { DASHBOARD_VERSION_KEY, DASHBOARD_HEADER_ID } from '../util/constants';
+import { DASHBOARD_HEADER_TYPE, CHART_TYPE } from '../util/componentTypes';
 
 export default function(bootstrapData) {
   const { user_id, datasources, common } = bootstrapData;
@@ -35,22 +36,39 @@ export default function(bootstrapData) {
   }
 
   // dashboard layout
-  const positionJson = dashboard.position_json;
-  let layout;
-  if (!positionJson || !positionJson[DASHBOARD_ROOT_ID]) {
-    layout = layoutConverter(dashboard);
-  } else {
-    layout = positionJson;
-  }
+  const { position_json: positionJson } = dashboard;
+
+  const layout =
+    !positionJson || positionJson[DASHBOARD_VERSION_KEY] !== 'v2'
+      ? layoutConverter(dashboard)
+      : positionJson;
+
+  // store the header as a layout component so we can undo/redo changes
+  layout[DASHBOARD_HEADER_ID] = {
+    id: DASHBOARD_HEADER_ID,
+    type: DASHBOARD_HEADER_TYPE,
+    meta: {
+      text: dashboard.dashboard_title,
+    },
+  };
 
   const dashboardLayout = {
     past: [],
     present: layout,
     future: [],
   };
+
   delete dashboard.position_json;
   delete dashboard.css;
 
+  // creat a lookup to sync layout names with slice names
+  const chartIdToLayoutId = {};
+  Object.values(layout).forEach(layoutComponent => {
+    if (layoutComponent.type === CHART_TYPE) {
+      chartIdToLayoutId[layoutComponent.meta.chartId] = layoutComponent.id;
+    }
+  });
+
   const chartQueries = {};
   const slices = {};
   const sliceIds = new Set();
@@ -76,6 +94,14 @@ export default function(bootstrapData) {
     };
 
     sliceIds.add(key);
+
+    // sync layout names with current slice names in case a slice was edited
+    // in explore since the layout was updated. name updates go through layout for undo/redo
+    // functionality and python updates slice names based on layout upon dashboard save
+    const layoutId = chartIdToLayoutId[key];
+    if (layoutId && layout[layoutId]) {
+      layout[layoutId].meta.chartName = slice.slice_name;
+    }
   });
 
   return {
@@ -99,7 +125,6 @@ export default function(bootstrapData) {
       common,
     },
     dashboardState: {
-      title: dashboard.dashboard_title,
       sliceIds,
       refresh: false,
       filters,
@@ -107,6 +132,7 @@ export default function(bootstrapData) {
       editMode: false,
       showBuilderPane: false,
       hasUnsavedChanges: false,
+      maxUndoHistoryExceeded: false,
     },
     dashboardLayout,
     messageToasts: [],
diff --git a/superset/assets/src/dashboard/reducers/undoableDashboardLayout.js b/superset/assets/src/dashboard/reducers/undoableDashboardLayout.js
index b78c273..45e36ee 100644
--- a/superset/assets/src/dashboard/reducers/undoableDashboardLayout.js
+++ b/superset/assets/src/dashboard/reducers/undoableDashboardLayout.js
@@ -1,4 +1,5 @@
 import undoable, { includeAction } from 'redux-undo';
+import { UNDO_LIMIT } from '../util/constants';
 import {
   UPDATE_COMPONENTS,
   DELETE_COMPONENT,
@@ -13,7 +14,9 @@ import {
 import dashboardLayout from './dashboardLayout';
 
 export default undoable(dashboardLayout, {
-  limit: 15,
+  // +1 because length of history seems max out at limit - 1
+  // +1 again so we can detect if we've exceeded the limit
+  limit: UNDO_LIMIT + 2,
   filter: includeAction([
     UPDATE_COMPONENTS,
     DELETE_COMPONENT,
diff --git a/superset/assets/src/dashboard/stylesheets/builder-sidepane.less b/superset/assets/src/dashboard/stylesheets/builder-sidepane.less
index bdf342b..d45da4f 100644
--- a/superset/assets/src/dashboard/stylesheets/builder-sidepane.less
+++ b/superset/assets/src/dashboard/stylesheets/builder-sidepane.less
@@ -1,53 +1,67 @@
 .dashboard-builder-sidepane {
-  background: white;
-  flex: 0 0 376px;
-  border: 1px solid @gray-light;
+  flex: 0 0 @builder-pane-width;
   z-index: 10;
   position: relative;
+  box-shadow: -4px 0 4px 0 rgba(0, 0, 0, 0.1);
 
   .dashboard-builder-sidepane-header {
     font-size: 15px;
     font-weight: 700;
+    border-top: 1px solid @gray-light;
     border-bottom: 1px solid @gray-light;
-    padding: 14px;
+    padding: 16px;
   }
 
   .trigger {
-    height: 25px;
+    height: 18px;
     width: 25px;
-    color: @gray;
-    position: relative;
+    color: @almost-black;
+    opacity: 1;
+  }
+
+  .viewport {
+    position: absolute;
+    transform: none !important;
+    background: white;
+    overflow: hidden;
+    width: @builder-pane-width;
+    height: 100%;
+  }
+
+  .slider-container {
+    position: absolute;
+    background: white;
+    width: @builder-pane-width * 2;
+    height: 100%;
+    display: flex;
+    transition: all 0.5s ease;
+
+    &.slide-in {
+      left: -@builder-pane-width;
+    }
 
-    &.close {
-      top: 3px;
+    &.slide-out {
+      left: 0;
     }
 
-    &.open {
-      position: absolute;
-      right: 14px;
+    .slide-content {
+      width: @builder-pane-width;
     }
   }
 
+  .component-layer .new-component.static,
+  .slices-layer .dashboard-builder-sidepane-header {
+    cursor: pointer;
+  }
+
   .component-layer {
     .new-component.static {
       cursor: pointer;
     }
   }
 
-  .slices-layer {
-    position: absolute;
-    width: 2px;
-    top: 51px;
-    right: 0;
-    background: white;
-    transition-property: width;
-    transition-duration: 1s;
-    transition-timing-function: ease;
-    overflow: hidden;
-
-    &.show {
-      width: 374px;
-    }
+  .new-component-label {
+    flex-grow: 1;
   }
 
   .chart-card-container {
@@ -89,21 +103,27 @@
       display: flex;
       padding: 16px;
 
+      /* the input is wrapped in a div */
+      .search-input {
+        flex-grow: 1;
+        margin-left: 16px;
+      }
+
       .dropdown.btn-group button,
       input {
         font-size: 14px;
         line-height: 16px;
         padding: 7px 12px;
         height: 32px;
+        border: 1px solid @gray-light;
       }
 
       input {
-        margin-left: 16px;
-        width: 169px;
-        border: 1px solid @gray;
+        width: 100%;
 
         &:focus {
           outline: none;
+          border-color: @gray;
         }
       }
     }
diff --git a/superset/assets/src/dashboard/stylesheets/components/chart.less b/superset/assets/src/dashboard/stylesheets/components/chart.less
index dc366a1..73914fb 100644
--- a/superset/assets/src/dashboard/stylesheets/components/chart.less
+++ b/superset/assets/src/dashboard/stylesheets/components/chart.less
@@ -62,8 +62,3 @@
   /* disable chart interactions in edit mode */
   pointer-events: none;
 }
-
-.dashboard-chart .chart-header {
-  font-size: 16px;
-  font-weight: bold;
-}
diff --git a/superset/assets/src/dashboard/stylesheets/components/column.less b/superset/assets/src/dashboard/stylesheets/components/column.less
index 5fcb442..2f26d95 100644
--- a/superset/assets/src/dashboard/stylesheets/components/column.less
+++ b/superset/assets/src/dashboard/stylesheets/components/column.less
@@ -23,15 +23,11 @@
 .dashboard--editing
   .resizable-container.resizable-container--resizing:hover
   > .grid-column:after,
-.dashboard--editing .grid-column:hover:after {
+.dashboard--editing .hover-menu:hover + .grid-column:after {
   border: 1px dashed @gray-light;
   box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.1);
 }
 
-.grid-column > .hover-menu--top {
-  top: -20px;
-}
-
 .grid-column--empty {
   min-height: 72px;
 }
diff --git a/superset/assets/src/dashboard/stylesheets/components/header.less b/superset/assets/src/dashboard/stylesheets/components/header.less
index 8b93164..9403103 100644
--- a/superset/assets/src/dashboard/stylesheets/components/header.less
+++ b/superset/assets/src/dashboard/stylesheets/components/header.less
@@ -1,6 +1,6 @@
 .dashboard-component-header {
   width: 100%;
-  line-height: 1em;
+  line-height: 1.1;
   font-weight: 700;
   padding: 16px 0;
   color: @almost-black;
@@ -15,7 +15,13 @@
   margin-right: 8px;
 }
 
-.dragdroppable-row .dashboard-component-header {
+.dashboard-header .undo-action,
+.dashboard-header .redo-action {
+  line-height: 18px;
+  font-size: 12px;
+}
+
+.dashboard--editing .dragdroppable-row .dashboard-component-header {
   cursor: move;
 }
 
diff --git a/superset/assets/src/dashboard/stylesheets/components/row.less b/superset/assets/src/dashboard/stylesheets/components/row.less
index 7df5675..382417e 100644
--- a/superset/assets/src/dashboard/stylesheets/components/row.less
+++ b/superset/assets/src/dashboard/stylesheets/components/row.less
@@ -14,7 +14,8 @@
 }
 
 /* hover indicator */
-.dashboard--editing .grid-row:after {
+.dashboard--editing .grid-row:after,
+.dashboard--editing .dashboard-component-tabs > .hover-menu:hover + div:after {
   border: 1px dashed transparent;
   content: '';
   position: absolute;
@@ -29,7 +30,8 @@
 .dashboard--editing
   .resizable-container.resizable-container--resizing:hover
   > .grid-row:after,
-.dashboard--editing .grid-row:hover:after {
+.dashboard--editing .hover-menu:hover + .grid-row:after,
+.dashboard--editing .dashboard-component-tabs > .hover-menu:hover + div:after {
   border: 1px dashed @gray-light;
   box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.1);
 }
diff --git a/superset/assets/src/dashboard/stylesheets/components/tabs.less b/superset/assets/src/dashboard/stylesheets/components/tabs.less
index f67c151..02039b4 100644
--- a/superset/assets/src/dashboard/stylesheets/components/tabs.less
+++ b/superset/assets/src/dashboard/stylesheets/components/tabs.less
@@ -30,12 +30,12 @@
 }
 
 .dashboard-component-tabs .nav-tabs > li.active > a:after {
-  content: "";
+  content: '';
   position: absolute;
   height: 3px;
   width: 100%;
   bottom: 0;
-  background: linear-gradient(to right, #E32464, #2C2261);
+  background: linear-gradient(to right, #e32464, #2c2261);
 }
 
 .dashboard-component-tabs .nav-tabs > li > a:hover {
@@ -53,9 +53,10 @@
   cursor: move;
 }
 
+/* These expande the outline border + drop indicator for tabs */
 .dashboard-component-tabs .nav-tabs > li .drop-indicator {
   top: -12px !important;
-  height: ~"calc(100% + 24px)" !important;
+  height: ~'calc(100% + 24px)' !important;
 }
 
 .dashboard-component-tabs .nav-tabs > li .drop-indicator--left {
@@ -69,7 +70,7 @@
 .dashboard-component-tabs .nav-tabs > li .drop-indicator--top,
 .dashboard-component-tabs .nav-tabs > li .drop-indicator--bottom {
   left: -12px !important;
-  width: ~"calc(100% + 24px)" !important; /* escape for .less */
+  width: ~'calc(100% + 24px)' !important; /* escape for .less */
   opacity: 0.4;
 }
 
@@ -78,3 +79,7 @@
   font-size: 14px;
   margin-top: 3px;
 }
+
+.dashboard-component-tabs li .editable-title input[type='button'] {
+  cursor: pointer;
+}
diff --git a/superset/assets/src/dashboard/stylesheets/dashboard.less b/superset/assets/src/dashboard/stylesheets/dashboard.less
index 03c804b..8d8c8be 100644
--- a/superset/assets/src/dashboard/stylesheets/dashboard.less
+++ b/superset/assets/src/dashboard/stylesheets/dashboard.less
@@ -1,52 +1,94 @@
-// @import './less/cosmo/variables.less';
-
 .dashboard .chart-header {
   position: relative;
+  font-size: 16px;
+  font-weight: bold;
 
   .dropdown.btn-group {
     position: absolute;
     right: 0;
   }
 
+  .dropdown-toggle.btn.btn-default {
+    background: none;
+    border: none;
+    box-shadow: none;
+  }
+
   .dropdown-menu.dropdown-menu-right {
-    right: 7px;
-    top: -3px;
+    top: 20px;
   }
-}
 
-.slice-header-controls-trigger {
-  border: 0;
-  padding: 0 0 0 20px;
-  background: none;
-  outline: none;
-  box-shadow: none;
-  color: #263238;
-
-  &.is-cached {
-    color: red;
+  .divider {
+    margin: 5px 0;
   }
 
-  &:hover,
-  &:focus {
-    background: none;
-    cursor: pointer;
+  .fa-circle {
+    position: absolute;
+    left: 7px;
+    top: 18px;
+    font-size: 4px;
+    color: @pink;
   }
 
-  .controls-container.dropdown-menu {
-    top: 0;
-    left: unset;
-    right: 10px;
+  .refresh-tooltip {
+    display: block;
+    height: 16px;
+    margin: 3px 0;
+    color: @gray;
+  }
+}
 
-    &.is-open {
-      display: block;
-    }
+.dashboard .chart-header,
+.dashboard .dashboard-header {
+  .dropdown-menu {
+    padding: 9px 0;
+  }
 
-    & li {
-      white-space: nowrap;
+  .dropdown-menu li a {
+    padding: 3px 16px;
+    color: @almost-black;
+    line-height: 16px;
+    font-size: 14px;
+    letter-spacing: 0.4px;
+
+    &:hover,
+    &:focus {
+      background: @menu-hover;
+      color: @almost-black;
     }
   }
 }
 
+.slice-header-controls-trigger {
+  padding: 0 16px;
+  position: absolute;
+  top: 0;
+  right: -22px;
+
+  &:hover {
+    cursor: pointer;
+  }
+}
+
+.dot {
+  height: 4px;
+  width: 4px;
+  background-color: @gray;
+  border-radius: 50%;
+  margin: 2px 0;
+  display: inline-block;
+
+  .is-cached & {
+    background-color: @pink;
+    margin-right: 6px;
+  }
+
+  .vertical-dots-container & {
+    display: block;
+  }
+}
+
+
 .modal img.loading {
   width: 50px;
   margin: 0;
diff --git a/superset/assets/src/dashboard/stylesheets/dnd.less b/superset/assets/src/dashboard/stylesheets/dnd.less
index 835b62b..0a10c61 100644
--- a/superset/assets/src/dashboard/stylesheets/dnd.less
+++ b/superset/assets/src/dashboard/stylesheets/dnd.less
@@ -65,11 +65,11 @@
   float: left;
   height: 2px;
   margin: 1px;
-  width: 2px
+  width: 2px;
 }
 
 .drag-handle-dot:after {
-  content: "";
+  content: '';
   background: #aaa;
   float: left;
   height: 2px;
diff --git a/superset/assets/src/dashboard/stylesheets/grid.less b/superset/assets/src/dashboard/stylesheets/grid.less
index a12ac97..9d09ac7 100644
--- a/superset/assets/src/dashboard/stylesheets/grid.less
+++ b/superset/assets/src/dashboard/stylesheets/grid.less
@@ -20,11 +20,16 @@
 }
 
 /* gutters between rows */
-.grid-content > div:not(:only-child):not(:last-child):not(.empty-grid-droptarget) {
+.grid-content
+  > div:not(:only-child):not(:last-child):not(.empty-grid-droptarget--bottom):not(.empty-grid-droptarget--top) {
   margin-bottom: 16px;
 }
 
-.empty-grid-droptarget {
+.grid-content > .empty-grid-droptarget--top {
+  height: 24px;
+  margin-top: -24px;
+}
+.empty-grid-droptarget--bottom {
   width: 100%;
   height: 100%;
 }
diff --git a/superset/assets/src/dashboard/stylesheets/hover-menu.less b/superset/assets/src/dashboard/stylesheets/hover-menu.less
index 77edb06..4f62401 100644
--- a/superset/assets/src/dashboard/stylesheets/hover-menu.less
+++ b/superset/assets/src/dashboard/stylesheets/hover-menu.less
@@ -1,14 +1,16 @@
 .hover-menu {
   opacity: 0;
   position: absolute;
-  z-index: 2;
+  z-index: 10;
+  font-size: 14px;
 }
 
 .hover-menu--left {
   width: 24px;
-  height: 100%;
-  top: 0;
+  top: 50%;
+  transform: translate(0, -50%);
   left: -24px;
+  padding: 8px 0;
   display: flex;
   flex-direction: column;
   justify-content: center;
@@ -19,21 +21,52 @@
   margin-bottom: 12px;
 }
 
-.dragdroppable-row .dragdroppable-row .hover-menu--left {
-  left: 1px;
-}
-
 .hover-menu--top {
-  width: 100%;
   height: 24px;
-  top: 0;
-  left: 0;
+  top: -24px;
+  left: 50%;
+  transform: translate(-50%);
+  padding: 0 8px;
   display: flex;
   flex-direction: row;
   justify-content: center;
   align-items: center;
 }
 
+/* Special cases */
+
+/* A row within a column has inset hover menu */
+.dragdroppable-column .dragdroppable-row .hover-menu--left {
+  left: -12px;
+  background: white;
+  border: 1px solid @gray-light;
+}
+
+/* A column within a column or tabs has inset hover menu */
+.dragdroppable-column .dragdroppable-column .hover-menu--top,
+.dashboard-component-tabs .dragdroppable-column .hover-menu--top {
+  top: -12px;
+  background: white;
+  border: 1px solid @gray-light;
+}
+
+/* move Tabs hover menu to top near actual Tabs */
+.dashboard-component-tabs > .hover-menu--left {
+  top: 0;
+  transform: unset;
+  background: transparent;
+}
+
+/* push Chart actions to upper right */
+.dragdroppable-column .dashboard-component-chart-holder > .hover-menu--top {
+  right: 8px;
+  top: 8px;
+  background: transparent;
+  border: none;
+  transform: unset;
+  left: unset;
+}
+
 .hover-menu--top > :nth-child(n):not(:only-child):not(:last-child) {
   margin-right: 12px;
 }
diff --git a/superset/assets/src/dashboard/stylesheets/popover-menu.less b/superset/assets/src/dashboard/stylesheets/popover-menu.less
index 848949b..d69006c 100644
--- a/superset/assets/src/dashboard/stylesheets/popover-menu.less
+++ b/superset/assets/src/dashboard/stylesheets/popover-menu.less
@@ -3,13 +3,14 @@
   outline: none;
 }
 
-.grid-row.grid-row--empty .with-popover-menu { /* drop indicator doesn't show up without this */
+.grid-row.grid-row--empty .with-popover-menu {
+  /* drop indicator doesn't show up without this */
   width: 100%;
   height: 100%;
 }
 
 .with-popover-menu--focused:after {
-  content: "";
+  content: '';
   position: absolute;
   top: 1;
   left: -1;
@@ -34,15 +35,15 @@
   box-shadow: 0 1px 2px 1px rgba(0, 0, 0, 0.2);
   font-size: 14px;
   cursor: default;
-  z-index: 10;
+  z-index: 1000;
 }
 
 /* the focus menu doesn't account for parent padding */
 .dashboard-component-tabs li .with-popover-menu--focused:after {
   top: -12px;
-  left: -2px;
-  width: ~"calc(100% + 4px)"; /* escape for .less */
-  height: ~"calc(100% + 28px)";
+  left: -8px;
+  width: ~'calc(100% + 16px)'; /* escape for .less */
+  height: ~'calc(100% + 28px)';
 }
 
 .dashboard-component-tabs li .popover-menu {
@@ -57,7 +58,7 @@
 
 /* vertical spacer after each menu item */
 .popover-menu .menu-item:not(:only-child):not(:last-child):after {
-  content: "";
+  content: '';
   width: 1;
   height: 100%;
   background: @gray-light;
@@ -86,12 +87,12 @@
   background: @gray-light;
 }
 
-.popover-dropdown .caret { /* without this the caret doesn't take up full width / is clipped */
+.popover-dropdown .caret {
+  /* without this the caret doesn't take up full width / is clipped */
   width: auto;
   border-top-color: transparent;
 }
 
-
 .hover-dropdown li.dropdown-item.active a,
 .popover-menu li.dropdown-item.active a {
   background: white;
@@ -105,7 +106,7 @@
 }
 
 .background-style-option:before {
-  content: "";
+  content: '';
   width: 1em;
   height: 1em;
   margin-right: 8px;
@@ -124,7 +125,10 @@
 }
 
 .background-style-option.background--transparent:before {
-  background-image: linear-gradient(45deg, @gray 25%, transparent 25%), linear-gradient(-45deg, @gray 25%, transparent 25%), linear-gradient(45deg, transparent 75%, @gray 75%), linear-gradient(-45deg, transparent 75%, @gray 75%);
+  background-image: linear-gradient(45deg, @gray 25%, transparent 25%),
+    linear-gradient(-45deg, @gray 25%, transparent 25%),
+    linear-gradient(45deg, transparent 75%, @gray 75%),
+    linear-gradient(-45deg, transparent 75%, @gray 75%);
   background-size: 8px 8px;
-  background-position: 0 0, 0 4px, 4px -4px, -4px 0px
+  background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
 }
diff --git a/superset/assets/src/dashboard/stylesheets/variables.less b/superset/assets/src/dashboard/stylesheets/variables.less
index 254af23..8f53f99 100644
--- a/superset/assets/src/dashboard/stylesheets/variables.less
+++ b/superset/assets/src/dashboard/stylesheets/variables.less
@@ -5,6 +5,10 @@
 @gray: #879399;
 @gray-light: #CFD8DC;
 @gray-bg: #f5f5f5;
+@menu-hover: #F2F3F5;
+
+/* builder component pane */
+@builder-pane-width: 374px;
 
 /* toasts */
 @pink: #E32364;
diff --git a/superset/assets/src/dashboard/util/constants.js b/superset/assets/src/dashboard/util/constants.js
index f35614c..d682687 100644
--- a/superset/assets/src/dashboard/util/constants.js
+++ b/superset/assets/src/dashboard/util/constants.js
@@ -2,6 +2,7 @@
 export const DASHBOARD_GRID_ID = 'DASHBOARD_GRID_ID';
 export const DASHBOARD_HEADER_ID = 'DASHBOARD_HEADER_ID';
 export const DASHBOARD_ROOT_ID = 'DASHBOARD_ROOT_ID';
+export const DASHBOARD_VERSION_KEY = 'DASHBOARD_VERSION_KEY';
 
 export const NEW_COMPONENTS_SOURCE_ID = 'NEW_COMPONENTS_SOURCE_ID';
 export const NEW_CHART_ID = 'NEW_CHART_ID';
@@ -37,3 +38,6 @@ export const INFO_TOAST = 'INFO_TOAST';
 export const SUCCESS_TOAST = 'SUCCESS_TOAST';
 export const WARNING_TOAST = 'WARNING_TOAST';
 export const DANGER_TOAST = 'DANGER_TOAST';
+
+// undo-redo
+export const UNDO_LIMIT = 50;
diff --git a/superset/assets/src/dashboard/util/dashboardLayoutConverter.js b/superset/assets/src/dashboard/util/dashboardLayoutConverter.js
index f04b50e..f3f6061 100644
--- a/superset/assets/src/dashboard/util/dashboardLayoutConverter.js
+++ b/superset/assets/src/dashboard/util/dashboardLayoutConverter.js
@@ -5,14 +5,14 @@ import {
   ROW_TYPE,
   COLUMN_TYPE,
   CHART_TYPE,
-  DASHBOARD_HEADER_TYPE,
   DASHBOARD_ROOT_TYPE,
   DASHBOARD_GRID_TYPE,
 } from './componentTypes';
+
 import {
   DASHBOARD_GRID_ID,
-  DASHBOARD_HEADER_ID,
   DASHBOARD_ROOT_ID,
+  DASHBOARD_VERSION_KEY,
 } from './constants';
 
 const MAX_RECURSIVE_LEVEL = 6;
@@ -55,7 +55,6 @@ function getBoundary(positions) {
 
 function getRowContainer() {
   return {
-    version: 'v2',
     type: ROW_TYPE,
     id: `DASHBOARD_ROW_TYPE-${generateId()}`,
     children: [],
@@ -67,7 +66,6 @@ function getRowContainer() {
 
 function getColContainer() {
   return {
-    version: 'v2',
     type: COLUMN_TYPE,
     id: `DASHBOARD_COLUMN_TYPE-${generateId()}`,
     children: [],
@@ -78,24 +76,19 @@ function getColContainer() {
 }
 
 function getChartHolder(item) {
-  const { row, col, size_x, size_y, slice_id } = item;
-  const converted = {
-    row: Math.round(row / GRID_RATIO),
-    col: Math.floor((col - 1) / GRID_RATIO) + 1,
-    size_x: Math.max(1, Math.floor(size_x / GRID_RATIO)),
-    size_y: Math.max(1, Math.round(size_y / GRID_RATIO)),
-    slice_id,
-  };
+  const { size_x, size_y, slice_id } = item;
+
+  const width = Math.max(1, Math.floor(size_x / GRID_RATIO));
+  const height = Math.max(1, Math.round(size_y / GRID_RATIO));
 
   return {
-    version: 'v2',
     type: CHART_TYPE,
     id: `DASHBOARD_CHART_TYPE-${generateId()}`,
     children: [],
     meta: {
-      width: converted.size_x,
-      height: Math.round(converted.size_y * 100 / ROW_HEIGHT),
-      chartId: slice_id,
+      width,
+      height: Math.round(height * 100 / ROW_HEIGHT),
+      chartId: parseInt(slice_id, 10),
     },
   };
 }
@@ -111,21 +104,6 @@ function getChildrenSum(items, attr, layout) {
   );
 }
 
-// function getChildrenMax(items, attr, layout) {
-//   return Math.max.apply(null, items.map((childId) => {
-//     const child = layout[childId];
-//     if (child.type === ROW_TYPE && attr === 'width') {
-//       // rows don't have widths themselves
-//       return getChildrenSum(child.children, attr, layout);
-//     } else if (child.type === COLUMN_TYPE && attr === 'height') {
-//       // columns don't have heights themselves
-//       return getChildrenSum(child.children, attr, layout);
-//     }
-//
-//     return child.meta[attr];
-//   }));
-// }
-
 function sortByRowId(item1, item2) {
   return item1.row - item2.row;
 }
@@ -289,10 +267,10 @@ export default function(dashboard) {
 
   // position data clean up. some dashboard didn't have position_json
   let { position_json } = dashboard;
-  const posDict = {};
+  const positionDict = {};
   if (Array.isArray(position_json)) {
     position_json.forEach(position => {
-      posDict[position.slice_id] = position;
+      positionDict[position.slice_id] = position;
     });
   } else {
     position_json = [];
@@ -303,25 +281,25 @@ export default function(dashboard) {
     Math.max.apply(null, position_json.map(pos => pos.row + pos.size_y)),
   );
   let newSliceCounter = 0;
-  dashboard.slices.forEach(slice => {
-    const sliceId = slice.slice_id;
-    let pos = posDict[sliceId];
-    if (!pos) {
+  dashboard.slices.forEach(({ slice_id }) => {
+    let position = positionDict[slice_id];
+    if (!position) {
       // append new slices to dashboard bottom, 3 slices per row
-      pos = {
+      position = {
         col: (newSliceCounter % 3) * 16 + 1,
         row: lastRowId + Math.floor(newSliceCounter / 3) * 16,
         size_x: 16,
         size_y: 16,
-        slice_id: String(sliceId),
+        slice_id,
       };
       newSliceCounter += 1;
     }
 
-    positions.push(pos);
+    positions.push(position);
   });
 
   const root = {
+    [DASHBOARD_VERSION_KEY]: 'v2',
     [DASHBOARD_ROOT_ID]: {
       type: DASHBOARD_ROOT_TYPE,
       id: DASHBOARD_ROOT_ID,
@@ -332,11 +310,8 @@ export default function(dashboard) {
       id: DASHBOARD_GRID_ID,
       children: [],
     },
-    [DASHBOARD_HEADER_ID]: {
-      type: DASHBOARD_HEADER_TYPE,
-      id: DASHBOARD_HEADER_ID,
-    },
   };
+
   doConvert(positions, 0, root[DASHBOARD_GRID_ID], root);
 
   // remove row's width/height and col's height
diff --git a/superset/assets/src/dashboard/util/isValidChild.js b/superset/assets/src/dashboard/util/isValidChild.js
index d789f45..a885c31 100644
--- a/superset/assets/src/dashboard/util/isValidChild.js
+++ b/superset/assets/src/dashboard/util/isValidChild.js
@@ -33,6 +33,7 @@ const depthOne = rootDepth + 1;
 const depthTwo = rootDepth + 2;
 const depthThree = rootDepth + 3;
 const depthFour = rootDepth + 4;
+const depthFive = rootDepth + 5;
 
 // when moving components around the depth of child is irrelevant, note these are parent depths
 const parentMaxDepthLookup = {
@@ -53,7 +54,7 @@ const parentMaxDepthLookup = {
   [ROW_TYPE]: {
     [CHART_TYPE]: depthFour,
     [MARKDOWN_TYPE]: depthFour,
-    [COLUMN_TYPE]: depthTwo,
+    [COLUMN_TYPE]: depthFour,
   },
 
   [TABS_TYPE]: {
@@ -70,9 +71,9 @@ const parentMaxDepthLookup = {
   },
 
   [COLUMN_TYPE]: {
-    [CHART_TYPE]: depthThree,
-    [HEADER_TYPE]: depthThree,
-    [MARKDOWN_TYPE]: depthThree,
+    [CHART_TYPE]: depthFive,
+    [HEADER_TYPE]: depthFive,
+    [MARKDOWN_TYPE]: depthFive,
     [ROW_TYPE]: depthThree,
   },
 
diff --git a/superset/assets/src/dashboard/util/newComponentFactory.js b/superset/assets/src/dashboard/util/newComponentFactory.js
index 4e2de37..8d259af 100644
--- a/superset/assets/src/dashboard/util/newComponentFactory.js
+++ b/superset/assets/src/dashboard/util/newComponentFactory.js
@@ -34,7 +34,6 @@ function uuid(type) {
 
 export default function entityFactory(type, meta) {
   return {
-    version: 'v0',
     type,
     id: uuid(type),
     children: [],
diff --git a/superset/assets/src/dashboard/util/propShapes.jsx b/superset/assets/src/dashboard/util/propShapes.jsx
index 73a10b0..c8e1981 100644
--- a/superset/assets/src/dashboard/util/propShapes.jsx
+++ b/superset/assets/src/dashboard/util/propShapes.jsx
@@ -66,7 +66,6 @@ export const slicePropShape = PropTypes.shape({
 });
 
 export const dashboardStatePropShape = PropTypes.shape({
-  title: PropTypes.string.isRequired,
   sliceIds: PropTypes.object.isRequired,
   refresh: PropTypes.bool.isRequired,
   filters: PropTypes.object,
diff --git a/superset/assets/src/theme.js b/superset/assets/src/theme.js
index 68a7a8a..34fc0c0 100644
--- a/superset/assets/src/theme.js
+++ b/superset/assets/src/theme.js
@@ -1,3 +1,2 @@
-import '../stylesheets/less/index.less';
 import '../stylesheets/react-select/select.less';
 import '../stylesheets/superset.less';
diff --git a/superset/assets/src/visualizations/nvd3_vis.js b/superset/assets/src/visualizations/nvd3_vis.js
index e94e740..4619d4d 100644
--- a/superset/assets/src/visualizations/nvd3_vis.js
+++ b/superset/assets/src/visualizations/nvd3_vis.js
@@ -490,6 +490,8 @@ export default function nvd3Vis(slice, payload) {
         chart.showLegend(fd.show_legend);
       }
     }
+    // This is needed for correct chart dimensions if a chart is rendered in a hidden container
+    chart.width(width);
     chart.height(height);
     slice.container.css('height', height + 'px');
 
diff --git a/superset/assets/stylesheets/superset.less b/superset/assets/stylesheets/superset.less
index d756551..0e8ffad 100644
--- a/superset/assets/stylesheets/superset.less
+++ b/superset/assets/stylesheets/superset.less
@@ -114,7 +114,6 @@ span.title-block {
 }
 
 .nvtooltip {
-    //position: relative !important;
     z-index: 888;
     transition: opacity 0ms linear;
     -moz-transition: opacity 0ms linear;
@@ -238,13 +237,14 @@ table.table-no-hover tr:hover {
   line-height: inherit;
   white-space: normal;
   text-align: left;
+  cursor: initial;
 }
 
-.editable-title.editable-title--editable {
+.editable-title.editable-title--editable input[type="button"] {
   cursor: pointer;
 }
 
-.editable-title.editable-title--editing {
+.editable-title.editable-title--editing input[type="button"] {
   cursor: text;
 }
 
diff --git a/superset/views/core.py b/superset/views/core.py
index c086a6d..26356c5 100755
--- a/superset/views/core.py
+++ b/superset/views/core.py
@@ -1559,10 +1559,16 @@ class Superset(BaseSupersetView):
                 session.add(new_slice)
                 session.flush()
                 new_slice.dashboards.append(dash)
-                old_to_new_sliceids['{}'.format(slc.id)] =\
-                    '{}'.format(new_slice.id)
-            for d in data['positions']:
-                d['slice_id'] = old_to_new_sliceids[d['slice_id']]
+                old_to_new_sliceids[slc.id] = new_slice.id
+
+            # update chartId of layout entities
+            for value in data['positions'].values():
+                if isinstance(value, dict) and value.get('meta') \
+                    and value.get('meta').get('chartId'):
+
+                    old_id = value.get('meta').get('chartId')
+                    new_id = old_to_new_sliceids[old_id]
+                    value['meta']['chartId'] = new_id
         else:
             dash.slices = original_dash.slices
         dash.params = original_dash.params
@@ -1585,6 +1591,7 @@ class Superset(BaseSupersetView):
                 .filter_by(id=dashboard_id).first())
         check_ownership(dash, raise_if_false=True)
         data = json.loads(request.form.get('data'))
+        original_slice_names = {(slc.id): slc.slice_name for slc in dash.slices}
         self._set_dash_metadata(dash, data)
         session.merge(dash)
         session.commit()
@@ -1596,15 +1603,30 @@ class Superset(BaseSupersetView):
         positions = data['positions']
         # find slices in the position data
         slice_ids = []
+        slice_id_to_name = {}
         for value in positions.values():
-            if value.get('meta') and value.get('meta').get('chartId'):
-                slice_ids.append(int(value.get('meta').get('chartId')))
+            if isinstance(value, dict) and value.get('meta') \
+                and value.get('meta').get('chartId'):
+
+                slice_id = value.get('meta').get('chartId')
+                slice_ids.append(slice_id)
+                slice_id_to_name[slice_id] = value.get('meta').get('chartName')
+
         session = db.session()
         Slice = models.Slice  # noqa
         current_slices = session.query(Slice).filter(
             Slice.id.in_(slice_ids)).all()
 
         dashboard.slices = current_slices
+
+        # update slice names. this assumes user has permissions to update the slice
+        for slc in dashboard.slices:
+            new_name = slice_id_to_name[slc.id]
+            if slc.slice_name != new_name:
+                slc.slice_name = new_name
+                session.merge(slc)
+                session.flush()
+
         dashboard.position_json = json.dumps(positions, indent=4, sort_keys=True)
         md = dashboard.params_dict
         dashboard.css = data.get('css')


Mime
View raw message