You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@superset.apache.org by GitBox <gi...@apache.org> on 2018/05/08 18:33:16 UTC

[GitHub] williaster closed pull request #4893: add sticky to sidepane

williaster closed pull request #4893: add sticky to sidepane
URL: https://github.com/apache/incubator-superset/pull/4893
 
 
   

This is a PR merged from a forked repository.
As GitHub hides the original diff on merge, it is displayed below for
the sake of provenance:

As this is a foreign pull request (from a fork), the diff is supplied
below (as it won't show otherwise due to GitHub magic):

diff --git a/superset/assets/package.json b/superset/assets/package.json
index de4093647f..576920a6a6 100644
--- a/superset/assets/package.json
+++ b/superset/assets/package.json
@@ -105,6 +105,7 @@
     "react-select-fast-filter-options": "^0.2.1",
     "react-sortable-hoc": "^0.6.7",
     "react-split-pane": "^0.1.66",
+    "react-sticky": "^6.0.2",
     "react-syntax-highlighter": "^5.7.0",
     "react-virtualized": "9.3.0",
     "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 a0ecb78ab8..e6c44478b6 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 5a04de5430..c64ea0dde5 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 d80ec831a8..10c0a26316 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 6922753bac..37781f9043 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 e5bc74c0cc..b42650ef55 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 06b4f7f699..07b6c3378e 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 2d85ebf1cd..369ed46622 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 79eb35d6c9..7f929480be 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 3e6fc0cba8..77503bb1fc 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 242102e123..21b01dbbbb 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 07b904b376..4f05d2c7d3 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: ',
-          )}</ br>${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 37ce21fa9f..05c4270f4c 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 bcdaedf207..0c572d803b 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 ee1f261e9c..5326e0f67c 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,53 +64,44 @@ 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) : '';
 
     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 94cab4227c..91fc0558b3 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 54e15366b4..4742d71bfb 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 a68423061f..bc9f430158 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 a71d732533..7249034e69 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 91f200d340..3119a08841 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 d73bc0cb72..63619c1574 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 585041f3cb..813961d228 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 8a87fca1af..2a047ac573 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 470176bf88..61627d21fc 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 9af0e81f8c..bcf2ace219 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 650313e0af..29071cb18f 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 2b3431ad75..fe7e7bb84e 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 0000000000..e3d931dc51
--- /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 7b5a17a907..2d44399827 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 d0b4d7b247..ba24b36bff 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 b78c273334..45e36ee647 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 bdf342ba38..d45da4f7d5 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 dc366a1bb2..73914fba52 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 5fcb44282d..2f26d95441 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 8b93164c85..940310336c 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 7df5675f96..382417eb00 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 f67c151007..02039b49b1 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 03c804bfc1..8d8c8be8c3 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 835b62bfd2..0a10c61c22 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 a12ac97fd5..9d09ac7017 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 77edb0675a..4f624015ef 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 848949b8ca..d69006c788 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 254af23a4b..8f53f99a8a 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 f35614c269..d682687623 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 f04b50e381..f3f6061c4d 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 d789f45b5d..a885c31561 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 4e2de37e43..8d259afa4b 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 73a10b02a9..c8e198180f 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 68a7a8ac5f..34fc0c00cc 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 bf87287c78..162145892a 100644
--- a/superset/assets/src/visualizations/nvd3_vis.js
+++ b/superset/assets/src/visualizations/nvd3_vis.js
@@ -458,6 +458,8 @@ export default function nvd3Vis(slice, payload) {
       customizeToolTip(chart, xAxisFormatter, [yAxisFormatter1, yAxisFormatter2]);
       chart.showLegend(width > BREAKPOINTS.small);
     }
+    // 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 d75655141f..0e8ffad469 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 acedd779b9..fde5be7ba1 100755
--- a/superset/views/core.py
+++ b/superset/views/core.py
@@ -1554,10 +1554,16 @@ def copy_dash(self, dashboard_id):
                 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
@@ -1580,6 +1586,7 @@ def save_dash(self, dashboard_id):
                 .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()
@@ -1591,15 +1598,30 @@ def _set_dash_metadata(dashboard, data):
         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')


 

----------------------------------------------------------------
This is an automated message from the Apache Git Service.
To respond to the message, please log on GitHub and use the
URL above to go to the specific comment.
 
For queries about this service, please contact Infrastructure at:
users@infra.apache.org


With regards,
Apache Git Services

---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org