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/11 16:39:41 UTC

[GitHub] williaster closed pull request #4989: [dashboard v2] add tests

williaster closed pull request #4989: [dashboard v2] add tests
URL: https://github.com/apache/incubator-superset/pull/4989
 
 
   

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..ad75d50d5d 100644
--- a/superset/assets/package.json
+++ b/superset/assets/package.json
@@ -61,6 +61,7 @@
     "deck.gl": "^5.1.4",
     "deep-equal": "^1.0.1",
     "distributions": "^1.0.0",
+    "dnd-core": "^2.6.0",
     "dompurify": "^1.0.3",
     "fastdom": "^1.0.6",
     "geojson-extent": "^0.3.2",
@@ -105,6 +106,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/spec/helpers/browser.js b/superset/assets/spec/helpers/browser.js
index d465d86473..b30d3c79ed 100644
--- a/superset/assets/spec/helpers/browser.js
+++ b/superset/assets/spec/helpers/browser.js
@@ -10,6 +10,8 @@ const exposedProperties = ['window', 'navigator', 'document'];
 global.jsdom = jsdom.jsdom;
 global.document = global.jsdom('<!doctype html><html><body></body></html>');
 global.window = document.defaultView;
+global.HTMLElement = window.HTMLElement;
+
 Object.keys(document.defaultView).forEach((property) => {
   if (typeof global[property] === 'undefined') {
     exposedProperties.push(property);
@@ -38,5 +40,5 @@ global.sinon.useFakeXMLHttpRequest();
 
 global.window.XMLHttpRequest = global.XMLHttpRequest;
 global.window.location = { href: 'about:blank' };
-global.window.performance = { now: () => (new Date().getTime()) };
+global.window.performance = { now: () => new Date().getTime() };
 global.$ = require('jquery')(global.window);
diff --git a/superset/assets/spec/javascripts/dashboard/.eslintrc b/superset/assets/spec/javascripts/dashboard/.eslintrc
new file mode 100644
index 0000000000..a3f86e3a17
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/.eslintrc
@@ -0,0 +1,33 @@
+{
+  "extends": "prettier",
+  "plugins": ["prettier"],
+  "rules": {
+    "prefer-template": 2,
+    "new-cap": 2,
+    "no-restricted-syntax": 2,
+    "guard-for-in": 2,
+    "prefer-arrow-callback": 2,
+    "func-names": 2,
+    "react/jsx-no-bind": 2,
+    "no-confusing-arrow": 2,
+    "jsx-a11y/no-static-element-interactions": 2,
+    "jsx-a11y/anchor-has-content": 2,
+    "react/require-default-props": 2,
+    "no-plusplus": 2,
+    "no-mixed-operators": 0,
+    "no-continue": 2,
+    "no-bitwise": 2,
+    "no-undef": 2,
+    "no-multi-assign": 2,
+    "no-restricted-properties": 2,
+    "no-prototype-builtins": 2,
+    "jsx-a11y/href-no-hash": 2,
+    "class-methods-use-this": 2,
+    "import/no-named-as-default": 2,
+    "import/prefer-default-export": 2,
+    "react/no-unescaped-entities": 2,
+    "react/no-string-refs": 2,
+    "react/jsx-indent": 0,
+    "prettier/prettier": "error"
+  }
+}
diff --git a/superset/assets/spec/javascripts/dashboard/.prettierrc b/superset/assets/spec/javascripts/dashboard/.prettierrc
new file mode 100644
index 0000000000..a20502b7f0
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/.prettierrc
@@ -0,0 +1,4 @@
+{
+  "singleQuote": true,
+  "trailingComma": "all"
+}
diff --git a/superset/assets/spec/javascripts/dashboard/CodeModal_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/CodeModal_spec.jsx
similarity index 72%
rename from superset/assets/spec/javascripts/dashboard/CodeModal_spec.jsx
rename to superset/assets/spec/javascripts/dashboard/components/CodeModal_spec.jsx
index a93c5573ed..d316dc3d38 100644
--- a/superset/assets/spec/javascripts/dashboard/CodeModal_spec.jsx
+++ b/superset/assets/spec/javascripts/dashboard/components/CodeModal_spec.jsx
@@ -3,16 +3,14 @@ import { mount } from 'enzyme';
 import { describe, it } from 'mocha';
 import { expect } from 'chai';
 
-import CodeModal from '../../../src/dashboard/components/CodeModal';
+import CodeModal from '../../../../src/dashboard/components/CodeModal';
 
 describe('CodeModal', () => {
   const mockedProps = {
     triggerNode: <i className="fa fa-edit" />,
   };
   it('is valid', () => {
-    expect(
-      React.isValidElement(<CodeModal {...mockedProps} />),
-    ).to.equal(true);
+    expect(React.isValidElement(<CodeModal {...mockedProps} />)).to.equal(true);
   });
   it('renders the trigger node', () => {
     const wrapper = mount(<CodeModal {...mockedProps} />);
diff --git a/superset/assets/spec/javascripts/dashboard/CssEditor_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/CssEditor_spec.jsx
similarity index 72%
rename from superset/assets/spec/javascripts/dashboard/CssEditor_spec.jsx
rename to superset/assets/spec/javascripts/dashboard/components/CssEditor_spec.jsx
index c325dc1b78..8c991fa489 100644
--- a/superset/assets/spec/javascripts/dashboard/CssEditor_spec.jsx
+++ b/superset/assets/spec/javascripts/dashboard/components/CssEditor_spec.jsx
@@ -3,16 +3,14 @@ import { mount } from 'enzyme';
 import { describe, it } from 'mocha';
 import { expect } from 'chai';
 
-import CssEditor from '../../../src/dashboard/components/CssEditor';
+import CssEditor from '../../../../src/dashboard/components/CssEditor';
 
 describe('CssEditor', () => {
   const mockedProps = {
     triggerNode: <i className="fa fa-edit" />,
   };
   it('is valid', () => {
-    expect(
-      React.isValidElement(<CssEditor {...mockedProps} />),
-    ).to.equal(true);
+    expect(React.isValidElement(<CssEditor {...mockedProps} />)).to.equal(true);
   });
   it('renders the trigger node', () => {
     const wrapper = mount(<CssEditor {...mockedProps} />);
diff --git a/superset/assets/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx
new file mode 100644
index 0000000000..6b5d051859
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx
@@ -0,0 +1,146 @@
+import { Provider } from 'react-redux';
+import React from 'react';
+import { shallow, mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import ParentSize from '@vx/responsive/build/components/ParentSize';
+import { Sticky, StickyContainer } from 'react-sticky';
+import { TabContainer, TabContent, TabPane } from 'react-bootstrap';
+
+import BuilderComponentPane from '../../../../src/dashboard/components/BuilderComponentPane';
+import DashboardBuilder from '../../../../src/dashboard/components/DashboardBuilder';
+import DashboardComponent from '../../../../src/dashboard/containers/DashboardComponent';
+import DashboardHeader from '../../../../src/dashboard/containers/DashboardHeader';
+import DashboardGrid from '../../../../src/dashboard/containers/DashboardGrid';
+import DragDroppable from '../../../../src/dashboard/components/dnd/DragDroppable';
+
+import {
+  dashboardLayout as undoableDashboardLayout,
+  dashboardLayoutWithTabs as undoableDashboardLayoutWithTabs,
+} from '../fixtures/mockDashboardLayout';
+
+import { mockStore, mockStoreWithTabs } from '../fixtures/mockStore';
+
+const dashboardLayout = undoableDashboardLayout.present;
+const layoutWithTabs = undoableDashboardLayoutWithTabs.present;
+
+describe('DashboardBuilder', () => {
+  const props = {
+    dashboardLayout,
+    deleteTopLevelTabs() {},
+    editMode: false,
+    showBuilderPane: false,
+    handleComponentDrop() {},
+  };
+
+  function setup(overrideProps, useProvider = false, store = mockStore) {
+    const builder = <DashboardBuilder {...props} {...overrideProps} />;
+    return useProvider
+      ? mount(<Provider store={store}>{builder}</Provider>)
+      : shallow(builder);
+  }
+
+  it('should render a StickyContainer with class "dashboard"', () => {
+    const wrapper = setup();
+    const stickyContainer = wrapper.find(StickyContainer);
+    expect(stickyContainer).to.have.length(1);
+    expect(stickyContainer.prop('className')).to.equal('dashboard');
+  });
+
+  it('should add the "dashboard--editing" class if editMode=true', () => {
+    const wrapper = setup({ editMode: true });
+    const stickyContainer = wrapper.find(StickyContainer);
+    expect(stickyContainer.prop('className')).to.equal(
+      'dashboard dashboard--editing',
+    );
+  });
+
+  it('should render a DashboardHeader', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DashboardHeader)).to.have.length(1);
+  });
+
+  it('should render a DragDroppable DashboardHeader if editMode=true and no top-level Tabs exist', () => {
+    const withoutTabs = setup();
+    const withoutTabsEditMode = setup({ editMode: true });
+    const withTabs = setup({
+      dashboardLayout: layoutWithTabs,
+    });
+
+    expect(withoutTabs.find(DragDroppable)).to.have.length(0);
+    expect(withoutTabsEditMode.find(DragDroppable)).to.have.length(1);
+    expect(withTabs.find(DragDroppable)).to.have.length(0);
+  });
+
+  it('should render a Sticky top-level Tabs if the dashboard has tabs', () => {
+    const wrapper = setup(
+      { dashboardLayout: layoutWithTabs },
+      true,
+      mockStoreWithTabs,
+    );
+    const sticky = wrapper.find(Sticky);
+    const dashboardComponent = sticky.find(DashboardComponent);
+
+    const tabChildren = layoutWithTabs.TABS_ID.children;
+    expect(sticky).to.have.length(1);
+    expect(dashboardComponent).to.have.length(1 + tabChildren.length); // tab + tabs
+    expect(dashboardComponent.at(0).prop('id')).to.equal('TABS_ID');
+    tabChildren.forEach((tabId, i) => {
+      expect(dashboardComponent.at(i + 1).prop('id')).to.equal(tabId);
+    });
+  });
+
+  it('should render a TabContainer and TabContent', () => {
+    const wrapper = setup({ dashboardLayout: layoutWithTabs });
+    const parentSize = wrapper.find(ParentSize).dive();
+    expect(parentSize.find(TabContainer)).to.have.length(1);
+    expect(parentSize.find(TabContent)).to.have.length(1);
+  });
+
+  it('should set animation=true, mountOnEnter=true, and unmounOnExit=false on TabContainer for perf', () => {
+    const wrapper = setup({ dashboardLayout: layoutWithTabs });
+    const tabProps = wrapper
+      .find(ParentSize)
+      .dive()
+      .find(TabContainer)
+      .props();
+    expect(tabProps.animation).to.equal(true);
+    expect(tabProps.mountOnEnter).to.equal(true);
+    expect(tabProps.unmountOnExit).to.equal(false);
+  });
+
+  it('should render a TabPane and DashboardGrid for each Tab', () => {
+    const wrapper = setup({ dashboardLayout: layoutWithTabs });
+    const parentSize = wrapper.find(ParentSize).dive();
+
+    const expectedCount = layoutWithTabs.TABS_ID.children.length;
+    expect(parentSize.find(TabPane)).to.have.length(expectedCount);
+    expect(parentSize.find(DashboardGrid)).to.have.length(expectedCount);
+  });
+
+  it('should render a BuilderComponentPane if editMode=showBuilderPane=true', () => {
+    const wrapper = setup();
+    expect(wrapper.find(BuilderComponentPane)).to.have.length(0);
+
+    wrapper.setProps({ ...props, editMode: true, showBuilderPane: true });
+    expect(wrapper.find(BuilderComponentPane)).to.have.length(1);
+  });
+
+  it('should change tabs if a top-level Tab is clicked', () => {
+    const wrapper = setup(
+      { dashboardLayout: layoutWithTabs },
+      true,
+      mockStoreWithTabs,
+    );
+
+    expect(wrapper.find(TabContainer).prop('activeKey')).to.equal(0);
+
+    wrapper
+      .find('.dashboard-component-tabs .nav-tabs a')
+      .at(1)
+      .simulate('click');
+
+    expect(wrapper.find(TabContainer).prop('activeKey')).to.equal(1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/DashboardGrid_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/DashboardGrid_spec.jsx
new file mode 100644
index 0000000000..7e9de51c8c
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/DashboardGrid_spec.jsx
@@ -0,0 +1,82 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import DashboardComponent from '../../../../src/dashboard/containers/DashboardComponent';
+import DashboardGrid from '../../../../src/dashboard/components/DashboardGrid';
+import DragDroppable from '../../../../src/dashboard/components/dnd/DragDroppable';
+import newComponentFactory from '../../../../src/dashboard/util/newComponentFactory';
+
+import { DASHBOARD_GRID_TYPE } from '../../../../src/dashboard/util/componentTypes';
+import { GRID_COLUMN_COUNT } from '../../../../src/dashboard/util/constants';
+
+describe('DashboardGrid', () => {
+  const props = {
+    depth: 1,
+    editMode: false,
+    gridComponent: {
+      ...newComponentFactory(DASHBOARD_GRID_TYPE),
+      children: ['a'],
+    },
+    handleComponentDrop() {},
+    resizeComponent() {},
+    width: 500,
+  };
+
+  function setup(overrideProps) {
+    const wrapper = shallow(<DashboardGrid {...props} {...overrideProps} />);
+    return wrapper;
+  }
+
+  it('should render a div with class "dashboard-grid"', () => {
+    const wrapper = setup();
+    expect(wrapper.find('.dashboard-grid')).to.have.length(1);
+  });
+
+  it('should render one DashboardComponent for each gridComponent child', () => {
+    const wrapper = setup({
+      gridComponent: { ...props.gridComponent, children: ['a', 'b'] },
+    });
+    expect(wrapper.find(DashboardComponent)).to.have.length(2);
+  });
+
+  it('should render two empty DragDroppables targets when editMode=true', () => {
+    const wrapper = setup({ editMode: true });
+    expect(wrapper.find(DragDroppable)).to.have.length(2);
+  });
+
+  it('should render grid column guides when resizing', () => {
+    const wrapper = setup({ editMode: true });
+    expect(wrapper.find('.grid-column-guide')).to.have.length(0);
+
+    wrapper.setState({ isResizing: true });
+
+    expect(wrapper.find('.grid-column-guide')).to.have.length(
+      GRID_COLUMN_COUNT,
+    );
+  });
+
+  it('should render a grid row guide when resizing', () => {
+    const wrapper = setup();
+    expect(wrapper.find('.grid-row-guide')).to.have.length(0);
+    wrapper.setState({ isResizing: true, rowGuideTop: 10 });
+    expect(wrapper.find('.grid-row-guide')).to.have.length(1);
+  });
+
+  it('should call resizeComponent when a child DashboardComponent calls resizeStop', () => {
+    const resizeComponent = sinon.spy();
+    const args = { id: 'id', widthMultiple: 1, heightMultiple: 3 };
+    const wrapper = setup({ resizeComponent });
+    const dashboardComponent = wrapper.find(DashboardComponent).first();
+    dashboardComponent.prop('onResizeStop')(args);
+
+    expect(resizeComponent.callCount).to.equal(1);
+    expect(resizeComponent.getCall(0).args[0]).to.deep.equal({
+      id: 'id',
+      width: 1,
+      height: 3,
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/Dashboard_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx
similarity index 85%
rename from superset/assets/spec/javascripts/dashboard/Dashboard_spec.jsx
rename to superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx
index f4def13307..35fd7276f3 100644
--- a/superset/assets/spec/javascripts/dashboard/Dashboard_spec.jsx
+++ b/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx
@@ -1,3 +1,4 @@
+/* global beforeEach, afterEach */
 /* eslint camelcase: 0 */
 import React from 'react';
 import { shallow } from 'enzyme';
@@ -5,12 +6,19 @@ import { describe, it } from 'mocha';
 import { expect } from 'chai';
 import sinon from 'sinon';
 
-import * as sliceActions from '../../../src/dashboard/actions/sliceEntities';
-import * as dashboardActions from '../../../src/dashboard/actions/dashboardState';
-import * as chartActions from '../../../src/chart/chartAction';
-import Dashboard from '../../../src/dashboard/components/Dashboard';
-import { defaultFilters, dashboardState, dashboardInfo, dashboardLayout,
-  charts, datasources, sliceEntities } from './fixtures';
+import * as sliceActions from '../../../../src/dashboard/actions/sliceEntities';
+import * as dashboardActions from '../../../../src/dashboard/actions/dashboardState';
+import * as chartActions from '../../../../src/chart/chartAction';
+import Dashboard from '../../../../src/dashboard/components/Dashboard';
+import {
+  defaultFilters,
+  dashboardState,
+  dashboardInfo,
+  dashboardLayout,
+  charts,
+  datasources,
+  sliceEntities,
+} from '../fixtures';
 
 describe('Dashboard', () => {
   const mockedProps = {
@@ -34,7 +42,9 @@ describe('Dashboard', () => {
 
   it('should handle metadata default_filters', () => {
     const wrapper = shallow(<Dashboard {...mockedProps} />);
-    expect(wrapper.instance().props.dashboardState.filters).deep.equal(defaultFilters);
+    expect(wrapper.instance().props.dashboardState.filters).deep.equal(
+      defaultFilters,
+    );
   });
 
   describe('getFormDataExtra', () => {
@@ -46,9 +56,18 @@ describe('Dashboard', () => {
     });
 
     it('should carry default_filters', () => {
-      const extraFilters = wrapper.instance().getFormDataExtra(selectedChart).extra_filters;
-      expect(extraFilters[0]).to.deep.equal({ col: 'region', op: 'in', val: [] });
-      expect(extraFilters[1]).to.deep.equal({ col: 'country_name', op: 'in', val: ['United States'] });
+      const extraFilters = wrapper.instance().getFormDataExtra(selectedChart)
+        .extra_filters;
+      expect(extraFilters[0]).to.deep.equal({
+        col: 'region',
+        op: 'in',
+        val: [],
+      });
+      expect(extraFilters[1]).to.deep.equal({
+        col: 'country_name',
+        op: 'in',
+        val: ['United States'],
+      });
     });
 
     it('should carry updated filter', () => {
@@ -62,8 +81,13 @@ describe('Dashboard', () => {
       wrapper.setProps({
         dashboardState: newState,
       });
-      const extraFilters = wrapper.instance().getFormDataExtra(selectedChart).extra_filters;
-      expect(extraFilters[1]).to.deep.equal({ col: 'country_name', op: 'in', val: ['France'] });
+      const extraFilters = wrapper.instance().getFormDataExtra(selectedChart)
+        .extra_filters;
+      expect(extraFilters[1]).to.deep.equal({
+        col: 'country_name',
+        op: 'in',
+        val: ['France'],
+      });
     });
   });
 
diff --git a/superset/assets/spec/javascripts/dashboard/RefreshIntervalModal_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/RefreshIntervalModal_spec.jsx
similarity index 85%
rename from superset/assets/spec/javascripts/dashboard/RefreshIntervalModal_spec.jsx
rename to superset/assets/spec/javascripts/dashboard/components/RefreshIntervalModal_spec.jsx
index 3a2f7000df..564857c088 100644
--- a/superset/assets/spec/javascripts/dashboard/RefreshIntervalModal_spec.jsx
+++ b/superset/assets/spec/javascripts/dashboard/components/RefreshIntervalModal_spec.jsx
@@ -3,7 +3,7 @@ import { mount } from 'enzyme';
 import { describe, it } from 'mocha';
 import { expect } from 'chai';
 
-import RefreshIntervalModal from '../../../src/dashboard/components/RefreshIntervalModal';
+import RefreshIntervalModal from '../../../../src/dashboard/components/RefreshIntervalModal';
 
 describe('RefreshIntervalModal', () => {
   const mockedProps = {
diff --git a/superset/assets/spec/javascripts/dashboard/components/ToastPresenter_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/ToastPresenter_spec.jsx
new file mode 100644
index 0000000000..7545ad6b09
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/ToastPresenter_spec.jsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import mockMessageToasts from '../fixtures/mockMessageToasts';
+import Toast from '../../../../src/dashboard/components/Toast';
+import ToastPresenter from '../../../../src/dashboard/components/ToastPresenter';
+
+describe('ToastPresenter', () => {
+  const props = {
+    toasts: mockMessageToasts,
+    removeToast() {},
+  };
+
+  function setup(overrideProps) {
+    const wrapper = shallow(<ToastPresenter {...props} {...overrideProps} />);
+    return wrapper;
+  }
+
+  it('should render a div with class toast-presenter', () => {
+    const wrapper = setup();
+    expect(wrapper.find('.toast-presenter')).to.have.length(1);
+  });
+
+  it('should render a Toast for each toast object', () => {
+    const wrapper = setup();
+    expect(wrapper.find(Toast)).to.have.length(props.toasts.length);
+  });
+
+  it('should pass removeToast to the Toast component', () => {
+    const removeToast = () => {};
+    const wrapper = setup({ removeToast });
+    expect(
+      wrapper
+        .find(Toast)
+        .first()
+        .prop('onCloseToast'),
+    ).to.equal(removeToast);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/Toast_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/Toast_spec.jsx
new file mode 100644
index 0000000000..6ed0bc5adf
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/Toast_spec.jsx
@@ -0,0 +1,43 @@
+import { Alert } from 'react-bootstrap';
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import mockMessageToasts from '../fixtures/mockMessageToasts';
+import Toast from '../../../../src/dashboard/components/Toast';
+
+describe('Toast', () => {
+  const props = {
+    toast: mockMessageToasts[0],
+    onCloseToast() {},
+  };
+
+  function setup(overrideProps) {
+    const wrapper = shallow(<Toast {...props} {...overrideProps} />);
+    return wrapper;
+  }
+
+  it('should render an Alert', () => {
+    const wrapper = setup();
+    expect(wrapper.find(Alert)).to.have.length(1);
+  });
+
+  it('should render toastText within the alert', () => {
+    const wrapper = setup();
+    const alert = wrapper.find(Alert).dive();
+
+    expect(alert.childAt(1).text()).to.equal(props.toast.text);
+  });
+
+  it('should call onCloseToast upon alert dismissal', done => {
+    const onCloseToast = id => {
+      expect(id).to.equal(props.toast.id);
+      done();
+    };
+    const wrapper = setup({ onCloseToast });
+    const handleClosePress = wrapper.instance().handleClosePress;
+    expect(wrapper.find(Alert).prop('onDismiss')).to.equal(handleClosePress);
+    handleClosePress(); // there is a timeout for onCloseToast to be called
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/dnd/DragDroppable_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/dnd/DragDroppable_spec.jsx
new file mode 100644
index 0000000000..c7e2c2a8d3
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/dnd/DragDroppable_spec.jsx
@@ -0,0 +1,90 @@
+import React from 'react';
+import { shallow, mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import newComponentFactory from '../../../../../src/dashboard/util/newComponentFactory';
+import {
+  CHART_TYPE,
+  ROW_TYPE,
+} from '../../../../../src/dashboard/util/componentTypes';
+import { UnwrappedDragDroppable as DragDroppable } from '../../../../../src/dashboard/components/dnd/DragDroppable';
+
+describe('DragDroppable', () => {
+  const props = {
+    component: newComponentFactory(CHART_TYPE),
+    parentComponent: newComponentFactory(ROW_TYPE),
+    editMode: false,
+    depth: 1,
+    index: 0,
+    isDragging: false,
+    isDraggingOver: false,
+    isDraggingOverShallow: false,
+    droppableRef() {},
+    dragSourceRef() {},
+    dragPreviewRef() {},
+  };
+
+  function setup(overrideProps, shouldMount = false) {
+    const method = shouldMount ? mount : shallow;
+    const wrapper = method(<DragDroppable {...props} {...overrideProps} />);
+    return wrapper;
+  }
+
+  it('should render a div with class dragdroppable', () => {
+    const wrapper = setup();
+    expect(wrapper.find('.dragdroppable')).to.have.length(1);
+  });
+
+  it('should add class dragdroppable--dragging when dragging', () => {
+    const wrapper = setup({ isDragging: true });
+    expect(wrapper.find('.dragdroppable')).to.have.length(1);
+  });
+
+  it('should call its child function', () => {
+    const childrenSpy = sinon.spy();
+    setup({ children: childrenSpy });
+    expect(childrenSpy.callCount).to.equal(1);
+  });
+
+  it('should call its child function with "dragSourceRef" if editMode=true', () => {
+    const children = sinon.spy();
+    const dragSourceRef = () => {};
+    setup({ children, editMode: false, dragSourceRef });
+    setup({ children, editMode: true, dragSourceRef });
+
+    expect(children.getCall(0).args[0].dragSourceRef).to.equal(undefined);
+    expect(children.getCall(1).args[0].dragSourceRef).to.equal(dragSourceRef);
+  });
+
+  it('should call its child function with "dropIndicatorProps" dependent on editMode, isDraggingOver, state.dropIndicator is set', () => {
+    const children = sinon.spy();
+    const wrapper = setup({ children, editMode: false, isDraggingOver: false });
+    wrapper.setState({ dropIndicator: 'nonsense' });
+    wrapper.setProps({ ...props, editMode: true, isDraggingOver: true });
+
+    expect(children.callCount).to.equal(3); // initial + setState + setProps
+    expect(children.getCall(0).args[0].dropIndicatorProps).to.equal(undefined);
+    expect(children.getCall(2).args[0].dropIndicatorProps).to.deep.equal({
+      className: 'drop-indicator',
+    });
+  });
+
+  it('should call props.dragPreviewRef and props.droppableRef on mount', () => {
+    const dragPreviewRef = sinon.spy();
+    const droppableRef = sinon.spy();
+
+    setup({ dragPreviewRef, droppableRef }, true);
+    expect(dragPreviewRef.callCount).to.equal(1);
+    expect(droppableRef.callCount).to.equal(1);
+  });
+
+  it('should set this.mounted dependent on life cycle', () => {
+    const wrapper = setup({}, true);
+    const instance = wrapper.instance();
+    expect(instance.mounted).to.equal(true);
+    wrapper.unmount();
+    expect(instance.mounted).to.equal(false);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/ChartHolder_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/ChartHolder_spec.jsx
new file mode 100644
index 0000000000..821b6371f7
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/ChartHolder_spec.jsx
@@ -0,0 +1,112 @@
+import { Provider } from 'react-redux';
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import Chart from '../../../../../src/dashboard/containers/Chart';
+import ChartHolder from '../../../../../src/dashboard/components/gridComponents/ChartHolder';
+import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
+import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
+import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
+import ResizableContainer from '../../../../../src/dashboard/components/resizable/ResizableContainer';
+
+import { mockStore } from '../../fixtures/mockStore';
+import { sliceId } from '../../fixtures/mockSliceEntities';
+import { dashboardLayout as mockLayout } from '../../fixtures/mockDashboardLayout';
+import WithDragDropContext from '../../helpers/WithDragDropContext';
+
+describe('ChartHolder', () => {
+  const props = {
+    id: String(sliceId),
+    parentId: 'ROW_ID',
+    component: mockLayout.present.CHART_ID,
+    depth: 2,
+    parentComponent: mockLayout.present.ROW_ID,
+    index: 0,
+    editMode: false,
+    availableColumnCount: 12,
+    columnWidth: 50,
+    onResizeStart() {},
+    onResize() {},
+    onResizeStop() {},
+    handleComponentDrop() {},
+    updateComponents() {},
+    deleteComponent() {},
+  };
+
+  function setup(overrideProps) {
+    // We have to wrap provide DragDropContext for the underlying DragDroppable
+    // otherwise we cannot assert on DragDroppable children
+    const wrapper = mount(
+      <Provider store={mockStore}>
+        <WithDragDropContext>
+          <ChartHolder {...props} {...overrideProps} />
+        </WithDragDropContext>
+      </Provider>,
+    );
+    return wrapper;
+  }
+
+  it('should render a DragDroppable', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DragDroppable)).to.have.length(1);
+  });
+
+  it('should render a ResizableContainer', () => {
+    const wrapper = setup();
+    expect(wrapper.find(ResizableContainer)).to.have.length(1);
+  });
+
+  it('should only have an adjustableWidth if its parent is a Row', () => {
+    let wrapper = setup();
+    expect(wrapper.find(ResizableContainer).prop('adjustableWidth')).to.equal(
+      true,
+    );
+
+    wrapper = setup({ ...props, parentComponent: mockLayout.present.CHART_ID });
+    expect(wrapper.find(ResizableContainer).prop('adjustableWidth')).to.equal(
+      false,
+    );
+  });
+
+  it('should pass correct props to ResizableContainer', () => {
+    const wrapper = setup();
+    const resizableProps = wrapper.find(ResizableContainer).props();
+    expect(resizableProps.widthStep).to.equal(props.columnWidth);
+    expect(resizableProps.widthMultiple).to.equal(props.component.meta.width);
+    expect(resizableProps.heightMultiple).to.equal(props.component.meta.height);
+    expect(resizableProps.maxWidthMultiple).to.equal(
+      props.component.meta.width + props.availableColumnCount,
+    );
+  });
+
+  it('should render a div with class "dashboard-component-chart-holder"', () => {
+    const wrapper = setup();
+    expect(wrapper.find('.dashboard-component-chart-holder')).to.have.length(1);
+  });
+
+  it('should render a Chart', () => {
+    const wrapper = setup();
+    expect(wrapper.find(Chart)).to.have.length(1);
+  });
+
+  it('should render a HoverMenu with DeleteComponentButton in editMode', () => {
+    let wrapper = setup();
+    expect(wrapper.find(HoverMenu)).to.have.length(0);
+    expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
+
+    // we cannot set props on the Divider because of the WithDragDropContext wrapper
+    wrapper = setup({ editMode: true });
+    expect(wrapper.find(HoverMenu)).to.have.length(1);
+    expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
+  });
+
+  it('should call deleteComponent when deleted', () => {
+    const deleteComponent = sinon.spy();
+    const wrapper = setup({ editMode: true, deleteComponent });
+    wrapper.find(DeleteComponentButton).simulate('click');
+    expect(deleteComponent.callCount).to.equal(1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx
new file mode 100644
index 0000000000..3bf02f0447
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx
@@ -0,0 +1,86 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import Chart from '../../../../../src/dashboard/components/gridComponents/Chart';
+import SliceHeader from '../../../../../src/dashboard/components/SliceHeader';
+import ChartContainer from '../../../../../src/chart/ChartContainer';
+
+import mockDatasource from '../../fixtures/mockDatasource';
+import sliceEntities, { sliceId } from '../../fixtures/mockSliceEntities';
+import chartQueries, {
+  sliceId as queryId,
+} from '../../fixtures/mockChartQueries';
+
+describe('Chart', () => {
+  const props = {
+    id: sliceId,
+    width: 100,
+    height: 100,
+    updateSliceName() {},
+
+    // from redux
+    chart: chartQueries[queryId],
+    formData: chartQueries[queryId].formData,
+    datasource: mockDatasource[sliceEntities.slices[sliceId].datasource],
+    slice: {
+      ...sliceEntities.slices[sliceId],
+      description_markeddown: 'markdown',
+    },
+    sliceName: sliceEntities.slices[sliceId].slice_name,
+    timeout: 60,
+    filters: {},
+    refreshChart() {},
+    toggleExpandSlice() {},
+    addFilter() {},
+    removeFilter() {},
+    editMode: false,
+    isExpanded: false,
+  };
+
+  function setup(overrideProps) {
+    const wrapper = shallow(<Chart {...props} {...overrideProps} />);
+    return wrapper;
+  }
+
+  it('should render a SliceHeader', () => {
+    const wrapper = setup();
+    expect(wrapper.find(SliceHeader)).to.have.length(1);
+  });
+
+  it('should render a ChartContainer', () => {
+    const wrapper = setup();
+    expect(wrapper.find(ChartContainer)).to.have.length(1);
+  });
+
+  it('should render a description if it has one and isExpanded=true', () => {
+    const wrapper = setup();
+    expect(wrapper.find('.slice_description')).to.have.length(0);
+
+    wrapper.setProps({ ...props, isExpanded: true });
+    expect(wrapper.find('.slice_description')).to.have.length(1);
+  });
+
+  it('should call refreshChart when SliceHeader calls forceRefresh', () => {
+    const refreshChart = sinon.spy();
+    const wrapper = setup({ refreshChart });
+    wrapper.instance().forceRefresh();
+    expect(refreshChart.callCount).to.equal(1);
+  });
+
+  it('should call addFilter when ChartContainer calls addFilter', () => {
+    const addFilter = sinon.spy();
+    const wrapper = setup({ addFilter });
+    wrapper.instance().addFilter();
+    expect(addFilter.callCount).to.equal(1);
+  });
+
+  it('should call removeFilter when ChartContainer calls removeFilter', () => {
+    const removeFilter = sinon.spy();
+    const wrapper = setup({ removeFilter });
+    wrapper.instance().removeFilter();
+    expect(removeFilter.callCount).to.equal(1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Column_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Column_spec.jsx
new file mode 100644
index 0000000000..e97414b65e
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Column_spec.jsx
@@ -0,0 +1,144 @@
+import { Provider } from 'react-redux';
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import BackgroundStyleDropdown from '../../../../../src/dashboard/components/menu/BackgroundStyleDropdown';
+import Column from '../../../../../src/dashboard/components/gridComponents/Column';
+import DashboardComponent from '../../../../../src/dashboard/containers/DashboardComponent';
+import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
+import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
+import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
+import IconButton from '../../../../../src/dashboard/components/IconButton';
+import ResizableContainer from '../../../../../src/dashboard/components/resizable/ResizableContainer';
+import WithPopoverMenu from '../../../../../src/dashboard/components/menu/WithPopoverMenu';
+
+import { mockStore } from '../../fixtures/mockStore';
+import { dashboardLayout as mockLayout } from '../../fixtures/mockDashboardLayout';
+import WithDragDropContext from '../../helpers/WithDragDropContext';
+
+describe('Column', () => {
+  const columnWithoutChildren = {
+    ...mockLayout.present.COLUMN_ID,
+    children: [],
+  };
+  const props = {
+    id: 'COLUMN_ID',
+    parentId: 'ROW_ID',
+    component: mockLayout.present.COLUMN_ID,
+    parentComponent: mockLayout.present.ROW_ID,
+    index: 0,
+    depth: 2,
+    editMode: false,
+    availableColumnCount: 12,
+    minColumnWidth: 2,
+    columnWidth: 50,
+    occupiedColumnCount: 6,
+    onResizeStart() {},
+    onResize() {},
+    onResizeStop() {},
+    handleComponentDrop() {},
+    deleteComponent() {},
+    updateComponents() {},
+  };
+
+  function setup(overrideProps) {
+    // We have to wrap provide DragDropContext for the underlying DragDroppable
+    // otherwise we cannot assert on DragDroppable children
+    const wrapper = mount(
+      <Provider store={mockStore}>
+        <WithDragDropContext>
+          <Column {...props} {...overrideProps} />
+        </WithDragDropContext>
+      </Provider>,
+    );
+    return wrapper;
+  }
+
+  it('should render a DragDroppable', () => {
+    // don't count child DragDroppables
+    const wrapper = setup({ component: columnWithoutChildren });
+    expect(wrapper.find(DragDroppable)).to.have.length(1);
+  });
+
+  it('should render a WithPopoverMenu', () => {
+    // don't count child DragDroppables
+    const wrapper = setup({ component: columnWithoutChildren });
+    expect(wrapper.find(WithPopoverMenu)).to.have.length(1);
+  });
+
+  it('should render a ResizableContainer', () => {
+    // don't count child DragDroppables
+    const wrapper = setup({ component: columnWithoutChildren });
+    expect(wrapper.find(ResizableContainer)).to.have.length(1);
+  });
+
+  it('should render a HoverMenu in editMode', () => {
+    let wrapper = setup({ component: columnWithoutChildren });
+    expect(wrapper.find(HoverMenu)).to.have.length(0);
+
+    // we cannot set props on the Row because of the WithDragDropContext wrapper
+    wrapper = setup({ component: columnWithoutChildren, editMode: true });
+    expect(wrapper.find(HoverMenu)).to.have.length(1);
+  });
+
+  it('should render a DeleteComponentButton in editMode', () => {
+    let wrapper = setup({ component: columnWithoutChildren });
+    expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
+
+    // we cannot set props on the Row because of the WithDragDropContext wrapper
+    wrapper = setup({ component: columnWithoutChildren, editMode: true });
+    expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
+  });
+
+  it('should render a BackgroundStyleDropdown when focused', () => {
+    let wrapper = setup({ component: columnWithoutChildren });
+    expect(wrapper.find(BackgroundStyleDropdown)).to.have.length(0);
+
+    // we cannot set props on the Row because of the WithDragDropContext wrapper
+    wrapper = setup({ component: columnWithoutChildren, editMode: true });
+    wrapper
+      .find(IconButton)
+      .at(1) // first one is delete button
+      .simulate('click');
+
+    expect(wrapper.find(BackgroundStyleDropdown)).to.have.length(1);
+  });
+
+  it('should call deleteComponent when deleted', () => {
+    const deleteComponent = sinon.spy();
+    const wrapper = setup({ editMode: true, deleteComponent });
+    wrapper.find(DeleteComponentButton).simulate('click');
+    expect(deleteComponent.callCount).to.equal(1);
+  });
+
+  it('should pass its own width as availableColumnCount to children', () => {
+    const wrapper = setup();
+    const dashboardComponent = wrapper.find(DashboardComponent).first();
+    expect(dashboardComponent.props().availableColumnCount).to.equal(
+      props.component.meta.width,
+    );
+  });
+
+  it('should pass appropriate dimensions to ResizableContainer', () => {
+    const wrapper = setup({ component: columnWithoutChildren });
+    const columnWidth = columnWithoutChildren.meta.width;
+    const resizableProps = wrapper.find(ResizableContainer).props();
+    expect(resizableProps.adjustableWidth).to.equal(true);
+    expect(resizableProps.adjustableHeight).to.equal(false);
+    expect(resizableProps.widthStep).to.equal(props.columnWidth);
+    expect(resizableProps.widthMultiple).to.equal(columnWidth);
+    expect(resizableProps.minWidthMultiple).to.equal(props.minColumnWidth);
+    expect(resizableProps.maxWidthMultiple).to.equal(
+      props.availableColumnCount + columnWidth,
+    );
+  });
+
+  it('should increment the depth of its children', () => {
+    const wrapper = setup();
+    const dashboardComponent = wrapper.find(DashboardComponent);
+    expect(dashboardComponent.props().depth).to.equal(props.depth + 1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Divider_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Divider_spec.jsx
new file mode 100644
index 0000000000..c8317f8459
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Divider_spec.jsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
+import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
+import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
+import Divider from '../../../../../src/dashboard/components/gridComponents/Divider';
+import newComponentFactory from '../../../../../src/dashboard/util/newComponentFactory';
+import {
+  DIVIDER_TYPE,
+  DASHBOARD_GRID_TYPE,
+} from '../../../../../src/dashboard/util/componentTypes';
+
+import WithDragDropContext from '../../helpers/WithDragDropContext';
+
+describe('Divider', () => {
+  const props = {
+    id: 'id',
+    parentId: 'parentId',
+    component: newComponentFactory(DIVIDER_TYPE),
+    depth: 1,
+    parentComponent: newComponentFactory(DASHBOARD_GRID_TYPE),
+    index: 0,
+    editMode: false,
+    handleComponentDrop() {},
+    deleteComponent() {},
+  };
+
+  function setup(overrideProps) {
+    // We have to wrap provide DragDropContext for the underlying DragDroppable
+    // otherwise we cannot assert on DragDroppable children
+    const wrapper = mount(
+      <WithDragDropContext>
+        <Divider {...props} {...overrideProps} />
+      </WithDragDropContext>,
+    );
+    return wrapper;
+  }
+
+  it('should render a DragDroppable', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DragDroppable)).to.have.length(1);
+  });
+
+  it('should render a div with class "dashboard-component-divider"', () => {
+    const wrapper = setup();
+    expect(wrapper.find('.dashboard-component-divider')).to.have.length(1);
+  });
+
+  it('should render a HoverMenu with DeleteComponentButton in editMode', () => {
+    let wrapper = setup();
+    expect(wrapper.find(HoverMenu)).to.have.length(0);
+    expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
+
+    // we cannot set props on the Divider because of the WithDragDropContext wrapper
+    wrapper = setup({ editMode: true });
+    expect(wrapper.find(HoverMenu)).to.have.length(1);
+    expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
+  });
+
+  it('should call deleteComponent when deleted', () => {
+    const deleteComponent = sinon.spy();
+    const wrapper = setup({ editMode: true, deleteComponent });
+    wrapper.find(DeleteComponentButton).simulate('click');
+    expect(deleteComponent.callCount).to.equal(1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Header_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Header_spec.jsx
new file mode 100644
index 0000000000..1d547756a8
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Header_spec.jsx
@@ -0,0 +1,100 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
+import EditableTitle from '../../../../../src/components/EditableTitle';
+import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
+import WithPopoverMenu from '../../../../../src/dashboard/components/menu/WithPopoverMenu';
+import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
+import Header from '../../../../../src/dashboard/components/gridComponents/Header';
+import newComponentFactory from '../../../../../src/dashboard/util/newComponentFactory';
+import {
+  HEADER_TYPE,
+  DASHBOARD_GRID_TYPE,
+} from '../../../../../src/dashboard/util/componentTypes';
+
+import WithDragDropContext from '../../helpers/WithDragDropContext';
+
+describe('Header', () => {
+  const props = {
+    id: 'id',
+    parentId: 'parentId',
+    component: newComponentFactory(HEADER_TYPE),
+    depth: 1,
+    parentComponent: newComponentFactory(DASHBOARD_GRID_TYPE),
+    index: 0,
+    editMode: false,
+    handleComponentDrop() {},
+    deleteComponent() {},
+    updateComponents() {},
+  };
+
+  function setup(overrideProps) {
+    // We have to wrap provide DragDropContext for the underlying DragDroppable
+    // otherwise we cannot assert on DragDroppable children
+    const wrapper = mount(
+      <WithDragDropContext>
+        <Header {...props} {...overrideProps} />
+      </WithDragDropContext>,
+    );
+    return wrapper;
+  }
+
+  it('should render a DragDroppable', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DragDroppable)).to.have.length(1);
+  });
+
+  it('should render a WithPopoverMenu', () => {
+    const wrapper = setup();
+    expect(wrapper.find(WithPopoverMenu)).to.have.length(1);
+  });
+
+  it('should render a HoverMenu in editMode', () => {
+    let wrapper = setup();
+    expect(wrapper.find(HoverMenu)).to.have.length(0);
+
+    // we cannot set props on the Header because of the WithDragDropContext wrapper
+    wrapper = setup({ editMode: true });
+    expect(wrapper.find(HoverMenu)).to.have.length(1);
+  });
+
+  it('should render an EditableTitle with meta.text', () => {
+    const wrapper = setup();
+    expect(wrapper.find(EditableTitle)).to.have.length(1);
+    expect(wrapper.find('input').prop('value')).to.equal(
+      props.component.meta.text,
+    );
+  });
+
+  it('should call updateComponents when EditableTitle changes', () => {
+    const updateComponents = sinon.spy();
+    const wrapper = setup({ editMode: true, updateComponents });
+    wrapper.find(EditableTitle).prop('onSaveTitle')('New title');
+
+    const headerId = props.component.id;
+    expect(updateComponents.callCount).to.equal(1);
+    expect(updateComponents.getCall(0).args[0][headerId].meta.text).to.equal(
+      'New title',
+    );
+  });
+
+  it('should render a DeleteComponentButton when focused in editMode', () => {
+    const wrapper = setup({ editMode: true });
+    wrapper.find(WithPopoverMenu).simulate('click'); // focus
+
+    expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
+  });
+
+  it('should call deleteComponent when deleted', () => {
+    const deleteComponent = sinon.spy();
+    const wrapper = setup({ editMode: true, deleteComponent });
+    wrapper.find(WithPopoverMenu).simulate('click'); // focus
+    wrapper.find(DeleteComponentButton).simulate('click');
+
+    expect(deleteComponent.callCount).to.equal(1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Row_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Row_spec.jsx
new file mode 100644
index 0000000000..a718ff406a
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Row_spec.jsx
@@ -0,0 +1,120 @@
+import { Provider } from 'react-redux';
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import BackgroundStyleDropdown from '../../../../../src/dashboard/components/menu/BackgroundStyleDropdown';
+import DashboardComponent from '../../../../../src/dashboard/containers/DashboardComponent';
+import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
+import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
+import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
+import IconButton from '../../../../../src/dashboard/components/IconButton';
+import Row from '../../../../../src/dashboard/components/gridComponents/Row';
+import WithPopoverMenu from '../../../../../src/dashboard/components/menu/WithPopoverMenu';
+
+import { mockStore } from '../../fixtures/mockStore';
+import { DASHBOARD_GRID_ID } from '../../../../../src/dashboard/util/constants';
+import { dashboardLayout as mockLayout } from '../../fixtures/mockDashboardLayout';
+import WithDragDropContext from '../../helpers/WithDragDropContext';
+
+describe('Row', () => {
+  const rowWithoutChildren = { ...mockLayout.present.ROW_ID, children: [] };
+  const props = {
+    id: 'ROW_ID',
+    parentId: DASHBOARD_GRID_ID,
+    component: mockLayout.present.ROW_ID,
+    parentComponent: mockLayout.present[DASHBOARD_GRID_ID],
+    index: 0,
+    depth: 2,
+    editMode: false,
+    availableColumnCount: 12,
+    columnWidth: 50,
+    occupiedColumnCount: 6,
+    onResizeStart() {},
+    onResize() {},
+    onResizeStop() {},
+    handleComponentDrop() {},
+    deleteComponent() {},
+    updateComponents() {},
+  };
+
+  function setup(overrideProps) {
+    // We have to wrap provide DragDropContext for the underlying DragDroppable
+    // otherwise we cannot assert on DragDroppable children
+    const wrapper = mount(
+      <Provider store={mockStore}>
+        <WithDragDropContext>
+          <Row {...props} {...overrideProps} />
+        </WithDragDropContext>
+      </Provider>,
+    );
+    return wrapper;
+  }
+
+  it('should render a DragDroppable', () => {
+    // don't count child DragDroppables
+    const wrapper = setup({ component: rowWithoutChildren });
+    expect(wrapper.find(DragDroppable)).to.have.length(1);
+  });
+
+  it('should render a WithPopoverMenu', () => {
+    // don't count child DragDroppables
+    const wrapper = setup({ component: rowWithoutChildren });
+    expect(wrapper.find(WithPopoverMenu)).to.have.length(1);
+  });
+
+  it('should render a HoverMenu in editMode', () => {
+    let wrapper = setup({ component: rowWithoutChildren });
+    expect(wrapper.find(HoverMenu)).to.have.length(0);
+
+    // we cannot set props on the Row because of the WithDragDropContext wrapper
+    wrapper = setup({ component: rowWithoutChildren, editMode: true });
+    expect(wrapper.find(HoverMenu)).to.have.length(1);
+  });
+
+  it('should render a DeleteComponentButton in editMode', () => {
+    let wrapper = setup({ component: rowWithoutChildren });
+    expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
+
+    // we cannot set props on the Row because of the WithDragDropContext wrapper
+    wrapper = setup({ component: rowWithoutChildren, editMode: true });
+    expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
+  });
+
+  it('should render a BackgroundStyleDropdown when focused', () => {
+    let wrapper = setup({ component: rowWithoutChildren });
+    expect(wrapper.find(BackgroundStyleDropdown)).to.have.length(0);
+
+    // we cannot set props on the Row because of the WithDragDropContext wrapper
+    wrapper = setup({ component: rowWithoutChildren, editMode: true });
+    wrapper
+      .find(IconButton)
+      .at(1) // first one is delete button
+      .simulate('click');
+
+    expect(wrapper.find(BackgroundStyleDropdown)).to.have.length(1);
+  });
+
+  it('should call deleteComponent when deleted', () => {
+    const deleteComponent = sinon.spy();
+    const wrapper = setup({ editMode: true, deleteComponent });
+    wrapper.find(DeleteComponentButton).simulate('click');
+    expect(deleteComponent.callCount).to.equal(1);
+  });
+
+  it('should pass appropriate availableColumnCount to children', () => {
+    const wrapper = setup();
+    const dashboardComponent = wrapper.find(DashboardComponent).first();
+    expect(dashboardComponent.props().availableColumnCount).to.equal(
+      props.availableColumnCount - props.occupiedColumnCount,
+    );
+  });
+
+  it('should increment the depth of its children', () => {
+    const wrapper = setup();
+    const dashboardComponent = wrapper.find(DashboardComponent).first();
+    expect(dashboardComponent.props().depth).to.equal(props.depth + 1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Tab_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Tab_spec.jsx
new file mode 100644
index 0000000000..a984565b4c
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Tab_spec.jsx
@@ -0,0 +1,126 @@
+import { Provider } from 'react-redux';
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import DashboardComponent from '../../../../../src/dashboard/containers/DashboardComponent';
+import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
+import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
+import EditableTitle from '../../../../../src/components/EditableTitle';
+import WithPopoverMenu from '../../../../../src/dashboard/components/menu/WithPopoverMenu';
+import Tab, {
+  RENDER_TAB,
+  RENDER_TAB_CONTENT,
+} from '../../../../../src/dashboard/components/gridComponents/Tab';
+import WithDragDropContext from '../../helpers/WithDragDropContext';
+import { dashboardLayoutWithTabs } from '../../fixtures/mockDashboardLayout';
+import { mockStoreWithTabs } from '../../fixtures/mockStore';
+
+describe('Tabs', () => {
+  const props = {
+    id: 'TAB_ID',
+    parentId: 'TABS_ID',
+    component: dashboardLayoutWithTabs.present.TAB_ID,
+    parentComponent: dashboardLayoutWithTabs.present.TABS_ID,
+    index: 0,
+    depth: 1,
+    editMode: false,
+    renderType: RENDER_TAB,
+    onDropOnTab() {},
+    onDeleteTab() {},
+    availableColumnCount: 12,
+    columnWidth: 50,
+    onResizeStart() {},
+    onResize() {},
+    onResizeStop() {},
+    createComponent() {},
+    handleComponentDrop() {},
+    onChangeTab() {},
+    deleteComponent() {},
+    updateComponents() {},
+  };
+
+  function setup(overrideProps) {
+    // We have to wrap provide DragDropContext for the underlying DragDroppable
+    // otherwise we cannot assert on DragDroppable children
+    const wrapper = mount(
+      <Provider store={mockStoreWithTabs}>
+        <WithDragDropContext>
+          <Tab {...props} {...overrideProps} />
+        </WithDragDropContext>
+      </Provider>,
+    );
+    return wrapper;
+  }
+
+  describe('renderType=RENDER_TAB', () => {
+    it('should render a DragDroppable', () => {
+      const wrapper = setup();
+      expect(wrapper.find(DragDroppable)).to.have.length(1);
+    });
+
+    it('should render an EditableTitle with meta.text', () => {
+      const wrapper = setup();
+      const title = wrapper.find(EditableTitle);
+      expect(title).to.have.length(1);
+      expect(title.find('input').prop('value')).to.equal(
+        props.component.meta.text,
+      );
+    });
+
+    it('should call updateComponents when EditableTitle changes', () => {
+      const updateComponents = sinon.spy();
+      const wrapper = setup({ editMode: true, updateComponents });
+      wrapper.find(EditableTitle).prop('onSaveTitle')('New title');
+
+      expect(updateComponents.callCount).to.equal(1);
+      expect(updateComponents.getCall(0).args[0].TAB_ID.meta.text).to.equal(
+        'New title',
+      );
+    });
+
+    it('should render a WithPopoverMenu', () => {
+      const wrapper = setup();
+      expect(wrapper.find(WithPopoverMenu)).to.have.length(1);
+    });
+
+    it('should render a DeleteComponentButton when focused if its not the only tab', () => {
+      let wrapper = setup();
+      wrapper.find(WithPopoverMenu).simulate('click'); // focus
+      expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
+
+      wrapper = setup({ editMode: true });
+      wrapper.find(WithPopoverMenu).simulate('click');
+      expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
+
+      wrapper = setup({
+        editMode: true,
+        parentComponent: {
+          ...props.parentComponent,
+          children: props.parentComponent.children.slice(0, 1),
+        },
+      });
+      wrapper.find(WithPopoverMenu).simulate('click');
+      expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
+    });
+
+    it('should call deleteComponent when deleted', () => {
+      const deleteComponent = sinon.spy();
+      const wrapper = setup({ editMode: true, deleteComponent });
+      wrapper.find(WithPopoverMenu).simulate('click'); // focus
+      wrapper.find(DeleteComponentButton).simulate('click');
+
+      expect(deleteComponent.callCount).to.equal(1);
+    });
+  });
+
+  describe('renderType=RENDER_TAB_CONTENT', () => {
+    it('should render a DashboardComponent', () => {
+      const wrapper = setup({ renderType: RENDER_TAB_CONTENT });
+      // We expect 2 because this Tab has a Row child and the row has a Chart
+      expect(wrapper.find(DashboardComponent)).to.have.length(2);
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Tabs_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Tabs_spec.jsx
new file mode 100644
index 0000000000..d521fe5045
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Tabs_spec.jsx
@@ -0,0 +1,140 @@
+import { Provider } from 'react-redux';
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+import { Tabs as BootstrapTabs, Tab as BootstrapTab } from 'react-bootstrap';
+
+import DashboardComponent from '../../../../../src/dashboard/containers/DashboardComponent';
+import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
+import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
+import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
+import Tabs from '../../../../../src/dashboard/components/gridComponents/Tabs';
+import WithDragDropContext from '../../helpers/WithDragDropContext';
+import { dashboardLayoutWithTabs } from '../../fixtures/mockDashboardLayout';
+import { mockStoreWithTabs } from '../../fixtures/mockStore';
+import { DASHBOARD_ROOT_ID } from '../../../../../src/dashboard/util/constants';
+
+describe('Tabs', () => {
+  const props = {
+    id: 'TABS_ID',
+    parentId: DASHBOARD_ROOT_ID,
+    component: dashboardLayoutWithTabs.present.TABS_ID,
+    parentComponent: dashboardLayoutWithTabs.present[DASHBOARD_ROOT_ID],
+    index: 0,
+    depth: 1,
+    renderTabContent: true,
+    editMode: false,
+    availableColumnCount: 12,
+    columnWidth: 50,
+    onResizeStart() {},
+    onResize() {},
+    onResizeStop() {},
+    createComponent() {},
+    handleComponentDrop() {},
+    onChangeTab() {},
+    deleteComponent() {},
+    updateComponents() {},
+  };
+
+  function setup(overrideProps) {
+    // We have to wrap provide DragDropContext for the underlying DragDroppable
+    // otherwise we cannot assert on DragDroppable children
+    const wrapper = mount(
+      <Provider store={mockStoreWithTabs}>
+        <WithDragDropContext>
+          <Tabs {...props} {...overrideProps} />
+        </WithDragDropContext>
+      </Provider>,
+    );
+    return wrapper;
+  }
+
+  it('should render a DragDroppable', () => {
+    // test just Tabs with no children DragDroppables
+    const wrapper = setup({ component: { ...props.component, children: [] } });
+    expect(wrapper.find(DragDroppable)).to.have.length(1);
+  });
+
+  it('should render BootstrapTabs', () => {
+    const wrapper = setup();
+    expect(wrapper.find(BootstrapTabs)).to.have.length(1);
+  });
+
+  it('should set animation=true, mountOnEnter=true, and unmounOnExit=false on BootstrapTabs for perf', () => {
+    const wrapper = setup();
+    const tabProps = wrapper.find(BootstrapTabs).props();
+    expect(tabProps.animation).to.equal(true);
+    expect(tabProps.mountOnEnter).to.equal(true);
+    expect(tabProps.unmountOnExit).to.equal(false);
+  });
+
+  it('should render a BootstrapTab for each child', () => {
+    const wrapper = setup();
+    expect(wrapper.find(BootstrapTab)).to.have.length(
+      props.component.children.length,
+    );
+  });
+
+  it('should render an extra (+) BootstrapTab in editMode', () => {
+    const wrapper = setup({ editMode: true });
+    expect(wrapper.find(BootstrapTab)).to.have.length(
+      props.component.children.length + 1,
+    );
+  });
+
+  it('should render a DashboardComponent for each child', () => {
+    // note: this does not test Tab content
+    const wrapper = setup({ renderTabContent: false });
+    expect(wrapper.find(DashboardComponent)).to.have.length(
+      props.component.children.length,
+    );
+  });
+
+  it('should call createComponent if the (+) tab is clicked', () => {
+    const createComponent = sinon.spy();
+    const wrapper = setup({ editMode: true, createComponent });
+    wrapper
+      .find('.dashboard-component-tabs .nav-tabs a')
+      .last()
+      .simulate('click');
+
+    expect(createComponent.callCount).to.equal(1);
+  });
+
+  it('should call onChangeTab when a tab is clicked', () => {
+    const onChangeTab = sinon.spy();
+    const wrapper = setup({ editMode: true, onChangeTab });
+    wrapper
+      .find('.dashboard-component-tabs .nav-tabs a')
+      .at(1) // will not call if it is already selected
+      .simulate('click');
+
+    expect(onChangeTab.callCount).to.equal(1);
+  });
+
+  it('should render a HoverMenu in editMode', () => {
+    let wrapper = setup();
+    expect(wrapper.find(HoverMenu)).to.have.length(0);
+
+    wrapper = setup({ editMode: true });
+    expect(wrapper.find(HoverMenu)).to.have.length(1);
+  });
+
+  it('should render a DeleteComponentButton in editMode', () => {
+    let wrapper = setup();
+    expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
+
+    wrapper = setup({ editMode: true });
+    expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
+  });
+
+  it('should call deleteComponent when deleted', () => {
+    const deleteComponent = sinon.spy();
+    const wrapper = setup({ editMode: true, deleteComponent });
+    wrapper.find(DeleteComponentButton).simulate('click');
+
+    expect(deleteComponent.callCount).to.equal(1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/DraggableNewComponent_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/DraggableNewComponent_spec.jsx
new file mode 100644
index 0000000000..4334b37ca4
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/DraggableNewComponent_spec.jsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import DragDroppable from '../../../../../../src/dashboard/components/dnd/DragDroppable';
+import DraggableNewComponent from '../../../../../../src/dashboard/components/gridComponents/new/DraggableNewComponent';
+import WithDragDropContext from '../../../helpers/WithDragDropContext';
+
+import { NEW_COMPONENTS_SOURCE_ID } from '../../../../../../src/dashboard/util/constants';
+import {
+  NEW_COMPONENT_SOURCE_TYPE,
+  CHART_TYPE,
+} from '../../../../../../src/dashboard/util/componentTypes';
+
+describe('DraggableNewComponent', () => {
+  const props = {
+    id: 'id',
+    type: CHART_TYPE,
+    label: 'label!',
+    className: 'a_class',
+  };
+
+  function setup(overrideProps) {
+    // We have to wrap provide DragDropContext for the underlying DragDroppable
+    // otherwise we cannot assert on DragDroppable children
+    const wrapper = mount(
+      <WithDragDropContext>
+        <DraggableNewComponent {...props} {...overrideProps} />
+      </WithDragDropContext>,
+    );
+    return wrapper;
+  }
+
+  it('should render a DragDroppable', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DragDroppable)).to.have.length(1);
+  });
+
+  it('should pass component={ type, id } to DragDroppable', () => {
+    const wrapper = setup();
+    const dragdroppable = wrapper.find(DragDroppable);
+    expect(dragdroppable.prop('component')).to.deep.equal({
+      id: props.id,
+      type: props.type,
+    });
+  });
+
+  it('should pass appropriate parent source and id to DragDroppable', () => {
+    const wrapper = setup();
+    const dragdroppable = wrapper.find(DragDroppable);
+    expect(dragdroppable.prop('parentComponent')).to.deep.equal({
+      id: NEW_COMPONENTS_SOURCE_ID,
+      type: NEW_COMPONENT_SOURCE_TYPE,
+    });
+  });
+
+  it('should render the passed label', () => {
+    const wrapper = setup();
+    expect(wrapper.find('.new-component').text()).to.equal(props.label);
+  });
+
+  it('should add the passed className', () => {
+    const wrapper = setup();
+    const className = `.new-component-placeholder.${props.className}`;
+    expect(wrapper.find(className)).to.have.length(1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewColumn_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewColumn_spec.jsx
new file mode 100644
index 0000000000..cb07cb988a
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewColumn_spec.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import DraggableNewComponent from '../../../../../../src/dashboard/components/gridComponents/new/DraggableNewComponent';
+import NewColumn from '../../../../../../src/dashboard/components/gridComponents/new/NewColumn';
+
+import { NEW_COLUMN_ID } from '../../../../../../src/dashboard/util/constants';
+import { COLUMN_TYPE } from '../../../../../../src/dashboard/util/componentTypes';
+
+describe('NewColumn', () => {
+  function setup() {
+    return shallow(<NewColumn />);
+  }
+
+  it('should render a DraggableNewComponent', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DraggableNewComponent)).to.have.length(1);
+  });
+
+  it('should set appropriate type and id', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DraggableNewComponent).props()).to.include({
+      type: COLUMN_TYPE,
+      id: NEW_COLUMN_ID,
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewDivider_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewDivider_spec.jsx
new file mode 100644
index 0000000000..71703b3900
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewDivider_spec.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import DraggableNewComponent from '../../../../../../src/dashboard/components/gridComponents/new/DraggableNewComponent';
+import NewDivider from '../../../../../../src/dashboard/components/gridComponents/new/NewDivider';
+
+import { NEW_DIVIDER_ID } from '../../../../../../src/dashboard/util/constants';
+import { DIVIDER_TYPE } from '../../../../../../src/dashboard/util/componentTypes';
+
+describe('NewDivider', () => {
+  function setup() {
+    return shallow(<NewDivider />);
+  }
+
+  it('should render a DraggableNewComponent', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DraggableNewComponent)).to.have.length(1);
+  });
+
+  it('should set appropriate type and id', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DraggableNewComponent).props()).to.include({
+      type: DIVIDER_TYPE,
+      id: NEW_DIVIDER_ID,
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewHeader_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewHeader_spec.jsx
new file mode 100644
index 0000000000..a499fe8f6e
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewHeader_spec.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import DraggableNewComponent from '../../../../../../src/dashboard/components/gridComponents/new/DraggableNewComponent';
+import NewHeader from '../../../../../../src/dashboard/components/gridComponents/new/NewHeader';
+
+import { NEW_HEADER_ID } from '../../../../../../src/dashboard/util/constants';
+import { HEADER_TYPE } from '../../../../../../src/dashboard/util/componentTypes';
+
+describe('NewHeader', () => {
+  function setup() {
+    return shallow(<NewHeader />);
+  }
+
+  it('should render a DraggableNewComponent', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DraggableNewComponent)).to.have.length(1);
+  });
+
+  it('should set appropriate type and id', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DraggableNewComponent).props()).to.include({
+      type: HEADER_TYPE,
+      id: NEW_HEADER_ID,
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewRow_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewRow_spec.jsx
new file mode 100644
index 0000000000..e91893d489
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewRow_spec.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import DraggableNewComponent from '../../../../../../src/dashboard/components/gridComponents/new/DraggableNewComponent';
+import NewRow from '../../../../../../src/dashboard/components/gridComponents/new/NewRow';
+
+import { NEW_ROW_ID } from '../../../../../../src/dashboard/util/constants';
+import { ROW_TYPE } from '../../../../../../src/dashboard/util/componentTypes';
+
+describe('NewRow', () => {
+  function setup() {
+    return shallow(<NewRow />);
+  }
+
+  it('should render a DraggableNewComponent', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DraggableNewComponent)).to.have.length(1);
+  });
+
+  it('should set appropriate type and id', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DraggableNewComponent).props()).to.include({
+      type: ROW_TYPE,
+      id: NEW_ROW_ID,
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewTabs_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewTabs_spec.jsx
new file mode 100644
index 0000000000..4e71c8ca42
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewTabs_spec.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import DraggableNewComponent from '../../../../../../src/dashboard/components/gridComponents/new/DraggableNewComponent';
+import NewTabs from '../../../../../../src/dashboard/components/gridComponents/new/NewTabs';
+
+import { NEW_TABS_ID } from '../../../../../../src/dashboard/util/constants';
+import { TABS_TYPE } from '../../../../../../src/dashboard/util/componentTypes';
+
+describe('NewTabs', () => {
+  function setup() {
+    return shallow(<NewTabs />);
+  }
+
+  it('should render a DraggableNewComponent', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DraggableNewComponent)).to.have.length(1);
+  });
+
+  it('should set appropriate type and id', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DraggableNewComponent).props()).to.include({
+      type: TABS_TYPE,
+      id: NEW_TABS_ID,
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/menu/HoverMenu_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/menu/HoverMenu_spec.jsx
new file mode 100644
index 0000000000..1f85085743
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/menu/HoverMenu_spec.jsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
+
+describe('HoverMenu', () => {
+  it('should render a div.hover-menu', () => {
+    const wrapper = shallow(<HoverMenu />);
+    expect(wrapper.find('.hover-menu')).to.have.length(1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/menu/WithPopoverMenu_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/menu/WithPopoverMenu_spec.jsx
new file mode 100644
index 0000000000..d19b6fc6c1
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/menu/WithPopoverMenu_spec.jsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import WithPopoverMenu from '../../../../../src/dashboard/components/menu/WithPopoverMenu';
+
+describe('WithPopoverMenu', () => {
+  const props = {
+    children: <div id="child" />,
+    disableClick: false,
+    menuItems: [<div id="menu1" />, <div id="menu2" />],
+    onChangeFocus() {},
+    shouldFocus: () => true, // needed for mock
+    isFocused: false,
+    editMode: false,
+  };
+
+  function setup(overrideProps) {
+    const wrapper = shallow(<WithPopoverMenu {...props} {...overrideProps} />);
+    return wrapper;
+  }
+
+  it('should render a div with class "with-popover-menu"', () => {
+    const wrapper = setup();
+    expect(wrapper.find('.with-popover-menu')).to.have.length(1);
+  });
+
+  it('should render the passed children', () => {
+    const wrapper = setup();
+    expect(wrapper.find('#child')).to.have.length(1);
+  });
+
+  it('should focus on click in editMode', () => {
+    const wrapper = setup();
+    expect(wrapper.state('isFocused')).to.equal(false);
+
+    wrapper.simulate('click');
+    expect(wrapper.state('isFocused')).to.equal(false);
+
+    wrapper.setProps({ ...props, editMode: true });
+    wrapper.simulate('click');
+    expect(wrapper.state('isFocused')).to.equal(true);
+  });
+
+  it('should render menuItems when focused', () => {
+    const wrapper = setup({ editMode: true });
+    expect(wrapper.find('#menu1')).to.have.length(0);
+    expect(wrapper.find('#menu2')).to.have.length(0);
+
+    wrapper.simulate('click');
+    expect(wrapper.find('#menu1')).to.have.length(1);
+    expect(wrapper.find('#menu2')).to.have.length(1);
+  });
+
+  it('should not focus when disableClick=true', () => {
+    const wrapper = setup({ disableClick: true, editMode: true });
+    expect(wrapper.state('isFocused')).to.equal(false);
+
+    wrapper.simulate('click');
+    expect(wrapper.state('isFocused')).to.equal(false);
+  });
+
+  it('should use the passed shouldFocus func to determine if it should focus', () => {
+    const wrapper = setup({ editMode: true, shouldFocus: () => false });
+    expect(wrapper.state('isFocused')).to.equal(false);
+
+    wrapper.simulate('click');
+    expect(wrapper.state('isFocused')).to.equal(false);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/resizable/ResizableContainer_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/resizable/ResizableContainer_spec.jsx
new file mode 100644
index 0000000000..69fca76f69
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/resizable/ResizableContainer_spec.jsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import Resizable from 're-resizable';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import ResizableContainer from '../../../../../src/dashboard/components/resizable/ResizableContainer';
+
+describe('ResizableContainer', () => {
+  const props = { editMode: false, id: 'id' };
+
+  function setup(propOverrides) {
+    return shallow(<ResizableContainer {...props} {...propOverrides} />);
+  }
+
+  it('should render a Resizable', () => {
+    const wrapper = setup();
+    expect(wrapper.find(Resizable)).to.have.length(1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/resizable/ResizableHandle_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/resizable/ResizableHandle_spec.jsx
new file mode 100644
index 0000000000..0c37855d25
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/resizable/ResizableHandle_spec.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import ResizableHandle from '../../../../../src/dashboard/components/resizable/ResizableHandle';
+
+describe('ResizableHandle', () => {
+  it('should render a right resize handle', () => {
+    const wrapper = shallow(<ResizableHandle.right />);
+    expect(wrapper.find('.resize-handle.resize-handle--right')).to.have.length(
+      1,
+    );
+  });
+
+  it('should render a bottom resize handle', () => {
+    const wrapper = shallow(<ResizableHandle.bottom />);
+    expect(wrapper.find('.resize-handle.resize-handle--bottom')).to.have.length(
+      1,
+    );
+  });
+
+  it('should render a bottomRight resize handle', () => {
+    const wrapper = shallow(<ResizableHandle.bottomRight />);
+    expect(
+      wrapper.find('.resize-handle.resize-handle--bottom-right'),
+    ).to.have.length(1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures.jsx b/superset/assets/spec/javascripts/dashboard/fixtures.jsx
index 1565ccda48..7a12454d39 100644
--- a/superset/assets/spec/javascripts/dashboard/fixtures.jsx
+++ b/superset/assets/spec/javascripts/dashboard/fixtures.jsx
@@ -12,11 +12,13 @@ export const regionFilter = {
   form_data: {
     datasource: '2__table',
     date_filter: false,
-    filters: [{
-      col: 'country_name',
-      op: 'in',
-      val: ['United States', 'France', 'Japan'],
-    }],
+    filters: [
+      {
+        col: 'country_name',
+        op: 'in',
+        val: ['United States', 'France', 'Japan'],
+      },
+    ],
     granularity_sqla: null,
     groupby: ['region', 'country_name'],
     having: '',
@@ -35,7 +37,8 @@ export const regionFilter = {
   },
   slice_id: 256,
   slice_name: 'Region Filters',
-  slice_url: '/superset/explore/table/2/?form_data=%7B%22slice_id%22%3A%20256%7D',
+  slice_url:
+    '/superset/explore/table/2/?form_data=%7B%22slice_id%22%3A%20256%7D',
 };
 export const countryFilter = {
   datasource: null,
@@ -64,7 +67,8 @@ export const countryFilter = {
   },
   slice_id: 257,
   slice_name: 'Country Filters',
-  slice_url: '/superset/explore/table/2/?form_data=%7B%22slice_id%22%3A%20257%7D',
+  slice_url:
+    '/superset/explore/table/2/?form_data=%7B%22slice_id%22%3A%20257%7D',
 };
 export const slice = {
   datasource: null,
@@ -115,7 +119,8 @@ export const slice = {
   },
   slice_id: 248,
   slice_name: 'Filtered Population',
-  slice_url: '/superset/explore/table/2/?form_data=%7B%22slice_id%22%3A%20248%7D',
+  slice_url:
+    '/superset/explore/table/2/?form_data=%7B%22slice_id%22%3A%20248%7D',
 };
 
 const mockDashboardData = {
@@ -152,12 +157,15 @@ const mockDashboardData = {
   standalone_mode: false,
 };
 export const {
-  dashboardState, dashboardInfo,
-  charts, datasources, sliceEntities,
-  dashboardLayout } = getInitialState({
+  dashboardState,
+  dashboardInfo,
+  charts,
+  datasources,
+  sliceEntities,
+  dashboardLayout,
+} = getInitialState({
   common: {},
   dashboard_data: mockDashboardData,
   datasources: {},
   user_id: '1',
 });
-
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockChartQueries.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockChartQueries.js
new file mode 100644
index 0000000000..b5004a1e18
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockChartQueries.js
@@ -0,0 +1,61 @@
+import { datasourceId } from './mockDatasource';
+
+export const sliceId = 18;
+
+export default {
+  [sliceId]: {
+    id: sliceId,
+    chartAlert: null,
+    chartStatus: 'rendered',
+    chartUpdateEndTime: 1525852456388,
+    chartUpdateStartTime: 1525852454838,
+    latestQueryFormData: {},
+    queryRequest: {},
+    queryResponse: {},
+    triggerQuery: false,
+    lastRendered: 0,
+    form_data: {
+      slice_id: sliceId,
+      viz_type: 'pie',
+      row_limit: 50000,
+      metric: 'sum__num',
+      since: '100 years ago',
+      groupby: ['gender'],
+      metrics: ['sum__num'],
+      compare_lag: '10',
+      limit: '25',
+      until: 'now',
+      granularity: 'ds',
+      markup_type: 'markdown',
+      where: '',
+      compare_suffix: 'o10Y',
+      datasource: datasourceId,
+    },
+    formData: {
+      datasource: datasourceId,
+      viz_type: 'pie',
+      slice_id: sliceId,
+      granularity_sqla: null,
+      time_grain_sqla: null,
+      since: '100 years ago',
+      until: 'now',
+      metrics: ['sum__num'],
+      groupby: ['gender'],
+      limit: '25',
+      pie_label_type: 'key',
+      donut: false,
+      show_legend: true,
+      labels_outside: true,
+      color_scheme: 'bnbColors',
+      where: '',
+      having: '',
+      filters: [],
+      row_limit: 50000,
+      metric: 'sum__num',
+      compare_lag: '10',
+      granularity: 'ds',
+      markup_type: 'markdown',
+      compare_suffix: 'o10Y',
+    },
+  },
+};
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardInfo.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardInfo.js
new file mode 100644
index 0000000000..4dd2670722
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardInfo.js
@@ -0,0 +1,12 @@
+export default {
+  id: 1234,
+  slug: 'dashboardSlug',
+  metadata: {},
+  userId: 'mock_user_id',
+  dash_edit_perm: true,
+  dash_save_perm: true,
+  common: {
+    flash_messages: [],
+    conf: { ENABLE_JAVASCRIPT_CONTROLS: false, SUPERSET_WEBSERVER_TIMEOUT: 60 },
+  },
+};
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardLayout.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardLayout.js
new file mode 100644
index 0000000000..865af0a7f9
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardLayout.js
@@ -0,0 +1,140 @@
+import {
+  DASHBOARD_GRID_TYPE,
+  DASHBOARD_HEADER_TYPE,
+  DASHBOARD_ROOT_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+  CHART_TYPE,
+  ROW_TYPE,
+  COLUMN_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+import {
+  DASHBOARD_ROOT_ID,
+  DASHBOARD_HEADER_ID,
+  DASHBOARD_GRID_ID,
+} from '../../../../src/dashboard/util/constants';
+
+import newComponentFactory from '../../../../src/dashboard/util/newComponentFactory';
+
+import { sliceId as chartId } from './mockChartQueries';
+
+export const sliceId = chartId;
+
+export const dashboardLayout = {
+  past: [],
+  present: {
+    [DASHBOARD_ROOT_ID]: {
+      type: DASHBOARD_ROOT_TYPE,
+      id: DASHBOARD_ROOT_ID,
+      children: [DASHBOARD_GRID_ID],
+    },
+
+    [DASHBOARD_GRID_ID]: {
+      type: DASHBOARD_GRID_TYPE,
+      id: DASHBOARD_GRID_ID,
+      children: ['ROW_ID'],
+      meta: {},
+    },
+
+    [DASHBOARD_HEADER_ID]: {
+      type: DASHBOARD_HEADER_TYPE,
+      id: DASHBOARD_HEADER_ID,
+      meta: {
+        text: 'New dashboard',
+      },
+    },
+
+    ROW_ID: {
+      ...newComponentFactory(ROW_TYPE),
+      id: 'ROW_ID',
+      children: ['COLUMN_ID'],
+    },
+
+    COLUMN_ID: {
+      ...newComponentFactory(COLUMN_TYPE),
+      id: 'COLUMN_ID',
+      children: ['CHART_ID'],
+    },
+
+    CHART_ID: {
+      ...newComponentFactory(CHART_TYPE),
+      id: 'CHART_ID',
+      meta: {
+        chartId,
+        width: 3,
+        height: 10,
+        chartName: 'Mock chart name',
+      },
+    },
+  },
+  future: [],
+};
+
+export const dashboardLayoutWithTabs = {
+  past: [],
+  present: {
+    [DASHBOARD_ROOT_ID]: {
+      type: DASHBOARD_ROOT_TYPE,
+      id: DASHBOARD_ROOT_ID,
+      children: ['TABS_ID'],
+    },
+
+    TABS_ID: {
+      id: 'TABS_ID',
+      type: TABS_TYPE,
+      children: ['TAB_ID', 'TAB_ID2'],
+    },
+
+    TAB_ID: {
+      id: 'TAB_ID',
+      type: TAB_TYPE,
+      children: ['ROW_ID'],
+      meta: {
+        text: 'tab1',
+      },
+    },
+
+    TAB_ID2: {
+      id: 'TAB_ID2',
+      type: TAB_TYPE,
+      children: [],
+      meta: {
+        text: 'tab2',
+      },
+    },
+
+    CHART_ID: {
+      ...newComponentFactory(CHART_TYPE),
+      id: 'CHART_ID',
+      meta: {
+        chartId,
+        width: 3,
+        height: 10,
+        chartName: 'Mock chart name',
+      },
+    },
+
+    ROW_ID: {
+      ...newComponentFactory(ROW_TYPE),
+      id: 'ROW_ID',
+      children: ['CHART_ID'],
+    },
+
+    [DASHBOARD_GRID_ID]: {
+      type: DASHBOARD_GRID_TYPE,
+      id: DASHBOARD_GRID_ID,
+      children: [],
+      meta: {},
+    },
+
+    [DASHBOARD_HEADER_ID]: {
+      type: DASHBOARD_HEADER_TYPE,
+      id: DASHBOARD_HEADER_ID,
+      meta: {
+        text: 'New dashboard',
+      },
+    },
+  },
+  future: [],
+};
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardState.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardState.js
new file mode 100644
index 0000000000..9d05344e03
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardState.js
@@ -0,0 +1,13 @@
+import { id as sliceId } from './mockChartQueries';
+
+export default {
+  sliceIds: [sliceId],
+  refresh: false,
+  filters: {},
+  expandedSlices: {},
+  editMode: false,
+  showBuilderPane: false,
+  hasUnsavedChanges: false,
+  maxUndoHistoryExceeded: false,
+  isStarred: true,
+};
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDatasource.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockDatasource.js
new file mode 100644
index 0000000000..1de79158e0
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockDatasource.js
@@ -0,0 +1,206 @@
+export const id = 7;
+export const datasourceId = `${id}__table`;
+
+export default {
+  [datasourceId]: {
+    verbose_map: {
+      count: 'COUNT(*)',
+      __timestamp: 'Time',
+      sum__sum_girls: 'sum__sum_girls',
+      name: 'name',
+      avg__sum_girls: 'avg__sum_girls',
+      gender: 'gender',
+      sum_girls: 'sum_girls',
+      ds: 'ds',
+      sum__sum_boys: 'sum__sum_boys',
+      state: 'state',
+      num: 'num',
+      sum__num: 'sum__num',
+      sum_boys: 'sum_boys',
+      avg__num: 'avg__num',
+      avg__sum_boys: 'avg__sum_boys',
+    },
+    gb_cols: [['gender', 'gender'], ['name', 'name'], ['state', 'state']],
+    metrics: [
+      {
+        expression: 'SUM(birth_names.num)',
+        warning_text: null,
+        verbose_name: 'sum__num',
+        metric_name: 'sum__num',
+        description: null,
+      },
+      {
+        expression: 'AVG(birth_names.num)',
+        warning_text: null,
+        verbose_name: 'avg__num',
+        metric_name: 'avg__num',
+        description: null,
+      },
+      {
+        expression: 'SUM(birth_names.sum_boys)',
+        warning_text: null,
+        verbose_name: 'sum__sum_boys',
+        metric_name: 'sum__sum_boys',
+        description: null,
+      },
+      {
+        expression: 'AVG(birth_names.sum_boys)',
+        warning_text: null,
+        verbose_name: 'avg__sum_boys',
+        metric_name: 'avg__sum_boys',
+        description: null,
+      },
+      {
+        expression: 'SUM(birth_names.sum_girls)',
+        warning_text: null,
+        verbose_name: 'sum__sum_girls',
+        metric_name: 'sum__sum_girls',
+        description: null,
+      },
+      {
+        expression: 'AVG(birth_names.sum_girls)',
+        warning_text: null,
+        verbose_name: 'avg__sum_girls',
+        metric_name: 'avg__sum_girls',
+        description: null,
+      },
+      {
+        expression: 'COUNT(*)',
+        warning_text: null,
+        verbose_name: 'COUNT(*)',
+        metric_name: 'count',
+        description: null,
+      },
+    ],
+    column_formats: {},
+    columns: [
+      {
+        type: 'DATETIME',
+        description: null,
+        filterable: false,
+        verbose_name: null,
+        is_dttm: true,
+        expression: '',
+        groupby: false,
+        column_name: 'ds',
+      },
+      {
+        type: 'VARCHAR(16)',
+        description: null,
+        filterable: true,
+        verbose_name: null,
+        is_dttm: false,
+        expression: '',
+        groupby: true,
+        column_name: 'gender',
+      },
+      {
+        type: 'VARCHAR(255)',
+        description: null,
+        filterable: true,
+        verbose_name: null,
+        is_dttm: false,
+        expression: '',
+        groupby: true,
+        column_name: 'name',
+      },
+      {
+        type: 'BIGINT',
+        description: null,
+        filterable: false,
+        verbose_name: null,
+        is_dttm: false,
+        expression: '',
+        groupby: false,
+        column_name: 'num',
+      },
+      {
+        type: 'VARCHAR(10)',
+        description: null,
+        filterable: true,
+        verbose_name: null,
+        is_dttm: false,
+        expression: '',
+        groupby: true,
+        column_name: 'state',
+      },
+      {
+        type: 'BIGINT',
+        description: null,
+        filterable: false,
+        verbose_name: null,
+        is_dttm: false,
+        expression: '',
+        groupby: false,
+        column_name: 'sum_boys',
+      },
+      {
+        type: 'BIGINT',
+        description: null,
+        filterable: false,
+        verbose_name: null,
+        is_dttm: false,
+        expression: '',
+        groupby: false,
+        column_name: 'sum_girls',
+      },
+    ],
+    id,
+    granularity_sqla: [['ds', 'ds']],
+    name: 'birth_names',
+    database: {
+      allow_multi_schema_metadata_fetch: null,
+      name: 'main',
+      backend: 'sqlite',
+    },
+    time_grain_sqla: [
+      [null, 'Time Column'],
+      ['PT1H', 'hour'],
+      ['P1D', 'day'],
+      ['P1W', 'week'],
+      ['P1M', 'month'],
+    ],
+    filterable_cols: [
+      ['gender', 'gender'],
+      ['name', 'name'],
+      ['state', 'state'],
+    ],
+    all_cols: [
+      ['ds', 'ds'],
+      ['gender', 'gender'],
+      ['name', 'name'],
+      ['num', 'num'],
+      ['state', 'state'],
+      ['sum_boys', 'sum_boys'],
+      ['sum_girls', 'sum_girls'],
+    ],
+    filter_select: true,
+    order_by_choices: [
+      ['["ds", true]', 'ds [asc]'],
+      ['["ds", false]', 'ds [desc]'],
+      ['["gender", true]', 'gender [asc]'],
+      ['["gender", false]', 'gender [desc]'],
+      ['["name", true]', 'name [asc]'],
+      ['["name", false]', 'name [desc]'],
+      ['["num", true]', 'num [asc]'],
+      ['["num", false]', 'num [desc]'],
+      ['["state", true]', 'state [asc]'],
+      ['["state", false]', 'state [desc]'],
+      ['["sum_boys", true]', 'sum_boys [asc]'],
+      ['["sum_boys", false]', 'sum_boys [desc]'],
+      ['["sum_girls", true]', 'sum_girls [asc]'],
+      ['["sum_girls", false]', 'sum_girls [desc]'],
+    ],
+    metrics_combo: [
+      ['count', 'COUNT(*)'],
+      ['avg__num', 'avg__num'],
+      ['avg__sum_boys', 'avg__sum_boys'],
+      ['avg__sum_girls', 'avg__sum_girls'],
+      ['sum__num', 'sum__num'],
+      ['sum__sum_boys', 'sum__sum_boys'],
+      ['sum__sum_girls', 'sum__sum_girls'],
+    ],
+    type: 'table',
+    edit_url: '/tablemodelview/edit/7',
+  },
+};
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockMessageToasts.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockMessageToasts.js
new file mode 100644
index 0000000000..07726a8c73
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockMessageToasts.js
@@ -0,0 +1,9 @@
+import {
+  INFO_TOAST,
+  DANGER_TOAST,
+} from '../../../../src/dashboard/util/constants';
+
+export default [
+  { id: 'info_id', toastType: INFO_TOAST, text: 'info toast' },
+  { id: 'danger_id', toastType: DANGER_TOAST, text: 'danger toast' },
+];
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js
new file mode 100644
index 0000000000..7c43bea534
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js
@@ -0,0 +1,39 @@
+import { sliceId as id } from './mockChartQueries';
+import { datasourceId } from './mockDatasource';
+
+export const sliceId = id;
+
+export default {
+  slices: {
+    [sliceId]: {
+      slice_id: sliceId,
+      slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%2018%7D',
+      slice_name: 'Genders',
+      form_data: {
+        slice_id: sliceId,
+        viz_type: 'pie',
+        row_limit: 50000,
+        metric: 'sum__num',
+        since: '100 years ago',
+        groupby: ['gender'],
+        metrics: ['sum__num'],
+        compare_lag: '10',
+        limit: '25',
+        until: 'now',
+        granularity: 'ds',
+        markup_type: 'markdown',
+        where: '',
+        compare_suffix: 'o10Y',
+        datasource: datasourceId,
+      },
+      edit_url: `/slicemodelview/edit/${sliceId}`,
+      viz_type: 'pie',
+      datasource: datasourceId,
+      description: null,
+      description_markeddown: '',
+    },
+  },
+  isLoading: false,
+  errorMessage: null,
+  lastUpdated: 0,
+};
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockState.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockState.js
new file mode 100644
index 0000000000..655f0bf7b8
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockState.js
@@ -0,0 +1,18 @@
+import chartQueries from './mockChartQueries';
+import { dashboardLayout } from './mockDashboardLayout';
+import dashboardInfo from './mockDashboardInfo';
+import dashboardState from './mockDashboardState';
+import messageToasts from './mockMessageToasts';
+import datasources from './mockDatasource';
+import sliceEntities from './mockSliceEntities';
+
+export default {
+  datasources,
+  sliceEntities,
+  charts: chartQueries,
+  dashboardInfo,
+  dashboardState,
+  dashboardLayout,
+  messageToasts,
+  impressionId: 'mock_impression_id',
+};
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockStore.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockStore.js
new file mode 100644
index 0000000000..97132ac43a
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockStore.js
@@ -0,0 +1,22 @@
+import { createStore, applyMiddleware, compose } from 'redux';
+import thunk from 'redux-thunk';
+
+import rootReducer from '../../../../src/dashboard/reducers/index';
+
+import mockState from './mockState';
+import { dashboardLayoutWithTabs } from './mockDashboardLayout';
+
+export const mockStore = createStore(
+  rootReducer,
+  mockState,
+  compose(applyMiddleware(thunk)),
+);
+
+export const mockStoreWithTabs = createStore(
+  rootReducer,
+  {
+    ...mockState,
+    dashboardLayout: dashboardLayoutWithTabs,
+  },
+  compose(applyMiddleware(thunk)),
+);
diff --git a/superset/assets/spec/javascripts/dashboard/helpers/WithDragDropContext.jsx b/superset/assets/spec/javascripts/dashboard/helpers/WithDragDropContext.jsx
new file mode 100644
index 0000000000..3e892a6c16
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/helpers/WithDragDropContext.jsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import getDragDropManager from '../../../../src/dashboard/util/getDragDropManager';
+
+// A helper component that provides a DragDropContext for components that require it
+class WithDragDropContext extends React.Component {
+  getChildContext() {
+    return {
+      dragDropManager: this.context.dragDropManager || getDragDropManager(),
+    };
+  }
+
+  render() {
+    return this.props.children;
+  }
+}
+
+WithDragDropContext.propTypes = {
+  children: PropTypes.node.isRequired,
+};
+
+WithDragDropContext.childContextTypes = {
+  dragDropManager: PropTypes.object.isRequired,
+};
+
+export default WithDragDropContext;
diff --git a/superset/assets/spec/javascripts/dashboard/util/componentIsResizable_spec.js b/superset/assets/spec/javascripts/dashboard/util/componentIsResizable_spec.js
new file mode 100644
index 0000000000..b49a91f7bb
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/componentIsResizable_spec.js
@@ -0,0 +1,42 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import componentIsResizable from '../../../../src/dashboard/util/componentIsResizable';
+import {
+  CHART_TYPE,
+  COLUMN_TYPE,
+  DASHBOARD_GRID_TYPE,
+  DASHBOARD_ROOT_TYPE,
+  DIVIDER_TYPE,
+  HEADER_TYPE,
+  MARKDOWN_TYPE,
+  ROW_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+const notResizable = [
+  DASHBOARD_GRID_TYPE,
+  DASHBOARD_ROOT_TYPE,
+  DIVIDER_TYPE,
+  HEADER_TYPE,
+  ROW_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+];
+
+const resizable = [COLUMN_TYPE, CHART_TYPE, MARKDOWN_TYPE];
+
+describe('componentIsResizable', () => {
+  resizable.forEach(type => {
+    it(`should return true for ${type}`, () => {
+      expect(componentIsResizable({ type })).to.equal(true);
+    });
+  });
+
+  notResizable.forEach(type => {
+    it(`should return false for ${type}`, () => {
+      expect(componentIsResizable({ type })).to.equal(false);
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/dnd-reorder_spec.js b/superset/assets/spec/javascripts/dashboard/util/dnd-reorder_spec.js
new file mode 100644
index 0000000000..4ff6a52bcc
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/dnd-reorder_spec.js
@@ -0,0 +1,62 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import reorderItem from '../../../../src/dashboard/util/dnd-reorder';
+
+describe('dnd-reorderItem', () => {
+  it('should remove the item from its source entity and add it to its destination entity', () => {
+    const result = reorderItem({
+      entitiesMap: {
+        a: {
+          id: 'a',
+          children: ['x', 'y', 'z'],
+        },
+        b: {
+          id: 'b',
+          children: ['banana'],
+        },
+      },
+      source: { id: 'a', index: 2 },
+      destination: { id: 'b', index: 1 },
+    });
+
+    expect(result.a.children).to.deep.equal(['x', 'y']);
+    expect(result.b.children).to.deep.equal(['banana', 'z']);
+  });
+
+  it('should correctly move elements within the same list', () => {
+    const result = reorderItem({
+      entitiesMap: {
+        a: {
+          id: 'a',
+          children: ['x', 'y', 'z'],
+        },
+      },
+      source: { id: 'a', index: 2 },
+      destination: { id: 'a', index: 0 },
+    });
+
+    expect(result.a.children).to.deep.equal(['z', 'x', 'y']);
+  });
+
+  it('should copy items that do not move into the result', () => {
+    const extraEntity = {};
+    const result = reorderItem({
+      entitiesMap: {
+        a: {
+          id: 'a',
+          children: ['x', 'y', 'z'],
+        },
+        b: {
+          id: 'b',
+          children: ['banana'],
+        },
+        iAmExtra: extraEntity,
+      },
+      source: { id: 'a', index: 2 },
+      destination: { id: 'b', index: 1 },
+    });
+
+    expect(result.iAmExtra === extraEntity).to.equal(true);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/dropOverflowsParent_spec.js b/superset/assets/spec/javascripts/dashboard/util/dropOverflowsParent_spec.js
new file mode 100644
index 0000000000..b153e1ec70
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/dropOverflowsParent_spec.js
@@ -0,0 +1,125 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import dropOverflowsParent from '../../../../src/dashboard/util/dropOverflowsParent';
+import { NEW_COMPONENTS_SOURCE_ID } from '../../../../src/dashboard/util/constants';
+import {
+  CHART_TYPE,
+  COLUMN_TYPE,
+  ROW_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+describe('dropOverflowsParent', () => {
+  it('returns true if a parent does NOT have adequate width for child', () => {
+    const dropResult = {
+      source: { id: '_' },
+      destination: { id: 'a' },
+      dragging: { id: 'z' },
+    };
+
+    const layout = {
+      a: {
+        id: 'a',
+        type: ROW_TYPE,
+        children: ['b', 'b', 'b', 'b'], // width = 4x bs = 12
+      },
+      b: {
+        id: 'b',
+        type: CHART_TYPE,
+        meta: {
+          width: 3,
+        },
+      },
+      z: {
+        id: 'z',
+        type: CHART_TYPE,
+        meta: {
+          width: 2,
+        },
+      },
+    };
+
+    expect(dropOverflowsParent(dropResult, layout)).to.equal(true);
+  });
+
+  it('returns false if a parent DOES not have adequate width for child', () => {
+    const dropResult = {
+      source: { id: '_' },
+      destination: { id: 'a' },
+      dragging: { id: 'z' },
+    };
+
+    const layout = {
+      a: {
+        id: 'a',
+        type: ROW_TYPE,
+        children: ['b', 'b'],
+      },
+      b: {
+        id: 'b',
+        type: CHART_TYPE,
+        meta: {
+          width: 3,
+        },
+      },
+      z: {
+        id: 'z',
+        type: CHART_TYPE,
+        meta: {
+          width: 2,
+        },
+      },
+    };
+
+    expect(dropOverflowsParent(dropResult, layout)).to.equal(false);
+  });
+
+  it('it should base result off of column width (instead of its children) if dropped on column', () => {
+    const dropResult = {
+      source: { id: 'z' },
+      destination: { id: 'a' },
+      dragging: { id: 'b' },
+    };
+
+    const layout = {
+      a: {
+        id: 'a',
+        type: COLUMN_TYPE,
+        meta: { width: 10 },
+      },
+      b: {
+        id: 'b',
+        type: CHART_TYPE,
+        meta: {
+          width: 2,
+        },
+      },
+    };
+
+    expect(dropOverflowsParent(dropResult, layout)).to.equal(false);
+    expect(
+      dropOverflowsParent(dropResult, {
+        ...layout,
+        a: { ...layout.a, meta: { width: 1 } },
+      }),
+    ).to.equal(true);
+  });
+
+  it('should work with new components that are not in the layout', () => {
+    const dropResult = {
+      source: { id: NEW_COMPONENTS_SOURCE_ID },
+      destination: { id: 'a' },
+      dragging: { type: CHART_TYPE },
+    };
+
+    const layout = {
+      a: {
+        id: 'a',
+        type: ROW_TYPE,
+        children: [],
+      },
+    };
+
+    expect(dropOverflowsParent(dropResult, layout)).to.equal(false);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/findParentId_spec.js b/superset/assets/spec/javascripts/dashboard/util/findParentId_spec.js
new file mode 100644
index 0000000000..71c8aece17
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/findParentId_spec.js
@@ -0,0 +1,29 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import findParentId from '../../../../src/dashboard/util/findParentId';
+
+describe('findParentId', () => {
+  const layout = {
+    a: {
+      id: 'a',
+      children: ['b', 'r', 'k'],
+    },
+    b: {
+      id: 'b',
+      children: ['x', 'y', 'z'],
+    },
+    z: {
+      id: 'z',
+      children: [],
+    },
+  };
+  it('should return the correct parentId', () => {
+    expect(findParentId({ childId: 'b', layout })).to.equal('a');
+    expect(findParentId({ childId: 'z', layout })).to.equal('b');
+  });
+
+  it('should return null if the parent cannot be found', () => {
+    expect(findParentId({ childId: 'a', layout })).to.equal(null);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/getChartIdsFromLayout_spec.js b/superset/assets/spec/javascripts/dashboard/util/getChartIdsFromLayout_spec.js
new file mode 100644
index 0000000000..71bbccd59a
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/getChartIdsFromLayout_spec.js
@@ -0,0 +1,41 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import getChartIdsFromLayout from '../../../../src/dashboard/util/getChartIdsFromLayout';
+import {
+  ROW_TYPE,
+  CHART_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+describe('getChartIdsFromLayout', () => {
+  const mockLayout = {
+    a: {
+      id: 'a',
+      type: CHART_TYPE,
+      meta: { chartId: 'A' },
+    },
+    b: {
+      id: 'b',
+      type: CHART_TYPE,
+      meta: { chartId: 'B' },
+    },
+    c: {
+      id: 'c',
+      type: ROW_TYPE,
+      meta: { chartId: 'C' },
+    },
+  };
+
+  it('should return an array of chartIds', () => {
+    const result = getChartIdsFromLayout(mockLayout);
+    expect(Array.isArray(result)).to.equal(true);
+    expect(result.includes('A')).to.equal(true);
+    expect(result.includes('B')).to.equal(true);
+  });
+
+  it('should return ids only from CHART_TYPE components', () => {
+    const result = getChartIdsFromLayout(mockLayout);
+    expect(result.length).to.equal(2);
+    expect(result.includes('C')).to.equal(false);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/getDropPosition_spec.js b/superset/assets/spec/javascripts/dashboard/util/getDropPosition_spec.js
new file mode 100644
index 0000000000..287b7a6f33
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/getDropPosition_spec.js
@@ -0,0 +1,422 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import getDropPosition, {
+  DROP_TOP,
+  DROP_RIGHT,
+  DROP_BOTTOM,
+  DROP_LEFT,
+} from '../../../../src/dashboard/util/getDropPosition';
+
+import {
+  CHART_TYPE,
+  DASHBOARD_GRID_TYPE,
+  DASHBOARD_ROOT_TYPE,
+  HEADER_TYPE,
+  ROW_TYPE,
+  TAB_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+describe('getDropPosition', () => {
+  // helper to easily configure test
+  function getMocks({
+    parentType,
+    componentType,
+    draggingType,
+    depth = 1,
+    hasChildren = false,
+    orientation = 'row',
+    clientOffset = { x: 0, y: 0 },
+    boundingClientRect = {
+      top: 0,
+      right: 0,
+      bottom: 0,
+      left: 0,
+    },
+    isDraggingOverShallow = true,
+  }) {
+    const monitorMock = {
+      getItem: () => ({
+        id: 'id',
+        type: draggingType,
+      }),
+      getClientOffset: () => clientOffset,
+    };
+
+    const ComponentMock = {
+      props: {
+        depth,
+        parentComponent: {
+          type: parentType,
+        },
+        component: {
+          type: componentType,
+          children: hasChildren ? [''] : [],
+        },
+        orientation,
+        isDraggingOverShallow,
+      },
+      ref: {
+        getBoundingClientRect: () => boundingClientRect,
+      },
+    };
+
+    return [monitorMock, ComponentMock];
+  }
+
+  describe('invalid child + invalid sibling', () => {
+    it('should return null', () => {
+      const result = getDropPosition(
+        // TAB is an invalid child + sibling of GRID > ROW
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: TAB_TYPE,
+        }),
+      );
+      expect(result).to.equal(null);
+    });
+  });
+
+  describe('valid child + invalid sibling', () => {
+    it('should return DROP_LEFT if component has NO children, and orientation is "row"', () => {
+      // HEADER is a valid child + invalid sibling of ROOT > GRID
+      const result = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_ROOT_TYPE,
+          componentType: DASHBOARD_GRID_TYPE,
+          draggingType: HEADER_TYPE,
+        }),
+      );
+      expect(result).to.equal(DROP_LEFT);
+    });
+
+    it('should return DROP_RIGHT if component HAS children, and orientation is "row"', () => {
+      const result = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_ROOT_TYPE,
+          componentType: DASHBOARD_GRID_TYPE,
+          draggingType: HEADER_TYPE,
+          hasChildren: true,
+        }),
+      );
+      expect(result).to.equal(DROP_RIGHT);
+    });
+
+    it('should return DROP_TOP if component has NO children, and orientation is "column"', () => {
+      const result = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_ROOT_TYPE,
+          componentType: DASHBOARD_GRID_TYPE,
+          draggingType: HEADER_TYPE,
+          orientation: 'column',
+        }),
+      );
+      expect(result).to.equal(DROP_TOP);
+    });
+
+    it('should return DROP_BOTTOM if component HAS children, and orientation is "column"', () => {
+      const result = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_ROOT_TYPE,
+          componentType: DASHBOARD_GRID_TYPE,
+          draggingType: HEADER_TYPE,
+          orientation: 'column',
+          hasChildren: true,
+        }),
+      );
+      expect(result).to.equal(DROP_BOTTOM);
+    });
+  });
+
+  describe('invalid child + valid sibling', () => {
+    it('should return DROP_TOP if orientation="row" and clientOffset is closer to component top than bottom', () => {
+      const result = getDropPosition(
+        // HEADER is an invalid child but valid sibling of GRID > ROW
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: HEADER_TYPE,
+          clientOffset: { y: 10 },
+          boundingClientRect: {
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      expect(result).to.equal(DROP_TOP);
+    });
+
+    it('should return DROP_BOTTOM if orientation="row" and clientOffset is closer to component bottom than top', () => {
+      const result = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: HEADER_TYPE,
+          clientOffset: { y: 55 },
+          boundingClientRect: {
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      expect(result).to.equal(DROP_BOTTOM);
+    });
+
+    it('should return DROP_LEFT if orientation="column" and clientOffset is closer to component left than right', () => {
+      const result = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: HEADER_TYPE,
+          orientation: 'column',
+          clientOffset: { x: 45 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+          },
+        }),
+      );
+      expect(result).to.equal(DROP_LEFT);
+    });
+
+    it('should return DROP_RIGHT if orientation="column" and clientOffset is closer to component right than left', () => {
+      const result = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: HEADER_TYPE,
+          orientation: 'column',
+          clientOffset: { x: 55 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+          },
+        }),
+      );
+      expect(result).to.equal(DROP_RIGHT);
+    });
+  });
+
+  describe('child + valid sibling (row orientation)', () => {
+    it('should return DROP_LEFT if component has NO children, and clientOffset is NOT near top/bottom sibling boundary', () => {
+      const result = getDropPosition(
+        // CHART is a valid child + sibling of GRID > ROW
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: CHART_TYPE,
+          clientOffset: { x: 10, y: 50 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      expect(result).to.equal(DROP_LEFT);
+    });
+
+    it('should return DROP_RIGHT if component HAS children, and clientOffset is NOT near top/bottom sibling boundary', () => {
+      const result = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: CHART_TYPE,
+          hasChildren: true,
+          clientOffset: { x: 10, y: 50 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      expect(result).to.equal(DROP_RIGHT);
+    });
+
+    it('should return DROP_TOP regardless of component children if clientOffset IS near top sibling boundary', () => {
+      const noChildren = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: CHART_TYPE,
+          clientOffset: { x: 10, y: 2 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      const withChildren = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: CHART_TYPE,
+          hasChildren: true,
+          clientOffset: { x: 10, y: 2 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      expect(noChildren).to.equal(DROP_TOP);
+      expect(withChildren).to.equal(DROP_TOP);
+    });
+
+    it('should return DROP_BOTTOM regardless of component children if clientOffset IS near bottom sibling boundary', () => {
+      const noChildren = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: CHART_TYPE,
+          clientOffset: { x: 10, y: 95 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      const withChildren = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: CHART_TYPE,
+          hasChildren: true,
+          clientOffset: { x: 10, y: 95 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      expect(noChildren).to.equal(DROP_BOTTOM);
+      expect(withChildren).to.equal(DROP_BOTTOM);
+    });
+  });
+
+  describe('child + valid sibling (column orientation)', () => {
+    it('should return DROP_TOP if component has NO children, and clientOffset is NOT near left/right sibling boundary', () => {
+      const result = getDropPosition(
+        // CHART is a valid child + sibling of GRID > ROW
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: CHART_TYPE,
+          orientation: 'column',
+          clientOffset: { x: 50, y: 0 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      expect(result).to.equal(DROP_TOP);
+    });
+
+    it('should return DROP_BOTTOM if component HAS children, and clientOffset is NOT near left/right sibling boundary', () => {
+      const result = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: CHART_TYPE,
+          orientation: 'column',
+          hasChildren: true,
+          clientOffset: { x: 50, y: 0 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      expect(result).to.equal(DROP_BOTTOM);
+    });
+
+    it('should return DROP_LEFT regardless of component children if clientOffset IS near left sibling boundary', () => {
+      const noChildren = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: CHART_TYPE,
+          orientation: 'column',
+          clientOffset: { x: 10, y: 2 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      const withChildren = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: CHART_TYPE,
+          orientation: 'column',
+          hasChildren: true,
+          clientOffset: { x: 10, y: 2 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      expect(noChildren).to.equal(DROP_LEFT);
+      expect(withChildren).to.equal(DROP_LEFT);
+    });
+
+    it('should return DROP_RIGHT regardless of component children if clientOffset IS near right sibling boundary', () => {
+      const noChildren = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: CHART_TYPE,
+          orientation: 'column',
+          clientOffset: { x: 90, y: 95 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      const withChildren = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: CHART_TYPE,
+          orientation: 'column',
+          hasChildren: true,
+          clientOffset: { x: 90, y: 95 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      expect(noChildren).to.equal(DROP_RIGHT);
+      expect(withChildren).to.equal(DROP_RIGHT);
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/isValidChild_spec.js b/superset/assets/spec/javascripts/dashboard/util/isValidChild_spec.js
new file mode 100644
index 0000000000..ec57494717
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/isValidChild_spec.js
@@ -0,0 +1,147 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import isValidChild from '../../../../src/dashboard/util/isValidChild';
+
+import {
+  CHART_TYPE as CHART,
+  COLUMN_TYPE as COLUMN,
+  DASHBOARD_GRID_TYPE as GRID,
+  DASHBOARD_ROOT_TYPE as ROOT,
+  DIVIDER_TYPE as DIVIDER,
+  HEADER_TYPE as HEADER,
+  MARKDOWN_TYPE as MARKDOWN,
+  ROW_TYPE as ROW,
+  TABS_TYPE as TABS,
+  TAB_TYPE as TAB,
+} from '../../../../src/dashboard/util/componentTypes';
+
+const getIndentation = depth =>
+  Array(depth * 3)
+    .fill('')
+    .join('-');
+
+describe('isValidChild', () => {
+  describe('valid calls', () => {
+    // these are representations of nested structures for easy testing
+    //  [ROOT (depth 0) > GRID (depth 1) > HEADER (depth 2)]
+    // every unique parent > child relationship is tested, but because this
+    // test representation WILL result in duplicates, we hash each test
+    // to keep track of which we've run
+    const didTest = {};
+    const validExamples = [
+      [ROOT, GRID, CHART], // chart is valid because it is wrapped in a row
+      [ROOT, GRID, MARKDOWN], // markdown is valid because it is wrapped in a row
+      [ROOT, GRID, COLUMN], // column is valid because it is wrapped in a row
+      [ROOT, GRID, HEADER],
+      [ROOT, GRID, ROW, MARKDOWN],
+      [ROOT, GRID, ROW, CHART],
+
+      [ROOT, GRID, ROW, COLUMN, HEADER],
+      [ROOT, GRID, ROW, COLUMN, DIVIDER],
+      [ROOT, GRID, ROW, COLUMN, CHART],
+      [ROOT, GRID, ROW, COLUMN, MARKDOWN],
+
+      [ROOT, GRID, ROW, COLUMN, ROW, CHART],
+      [ROOT, GRID, ROW, COLUMN, ROW, MARKDOWN],
+
+      [ROOT, GRID, ROW, COLUMN, ROW, COLUMN, CHART],
+      [ROOT, GRID, ROW, COLUMN, ROW, COLUMN, MARKDOWN],
+      [ROOT, GRID, TABS, TAB, ROW, COLUMN, ROW, COLUMN, MARKDOWN],
+
+      // tab equivalents
+      [ROOT, TABS, TAB, CHART],
+      [ROOT, TABS, TAB, MARKDOWN],
+      [ROOT, TABS, TAB, COLUMN],
+      [ROOT, TABS, TAB, HEADER],
+      [ROOT, TABS, TAB, ROW, MARKDOWN],
+      [ROOT, TABS, TAB, ROW, CHART],
+
+      [ROOT, TABS, TAB, ROW, COLUMN, HEADER],
+      [ROOT, TABS, TAB, ROW, COLUMN, DIVIDER],
+      [ROOT, TABS, TAB, ROW, COLUMN, CHART],
+      [ROOT, TABS, TAB, ROW, COLUMN, MARKDOWN],
+
+      [ROOT, TABS, TAB, ROW, COLUMN, ROW, CHART],
+      [ROOT, TABS, TAB, ROW, COLUMN, ROW, MARKDOWN],
+
+      [ROOT, TABS, TAB, ROW, COLUMN, ROW, COLUMN, CHART],
+      [ROOT, TABS, TAB, ROW, COLUMN, ROW, COLUMN, MARKDOWN],
+      [ROOT, TABS, TAB, TABS, TAB, ROW, COLUMN, ROW, COLUMN, MARKDOWN],
+    ];
+
+    validExamples.forEach((example, exampleIdx) => {
+      let childDepth = 0;
+      example.forEach((childType, i) => {
+        const parentDepth = childDepth - 1;
+        const parentType = example[i - 1];
+        const testKey = `${parentType}-${childType}-${parentDepth}`;
+
+        if (i > 0 && !didTest[testKey]) {
+          didTest[testKey] = true;
+
+          it(`(${exampleIdx})${getIndentation(
+            childDepth,
+          )}${parentType} (depth ${parentDepth}) > ${childType} ✅`, () => {
+            expect(
+              isValidChild({
+                parentDepth,
+                parentType,
+                childType,
+              }),
+            ).to.equal(true);
+          });
+        }
+        // see isValidChild.js for why tabs do not increment the depth of their children
+        childDepth += childType !== TABS && childType !== TAB ? 1 : 0;
+      });
+    });
+  });
+
+  describe('invalid calls', () => {
+    // In order to assert that a parent > child hierarchy at a given depth is invalid
+    // we also define some valid hierarchies in doing so. we indicate which
+    // parent > [child] relationships should be asserted as invalid using a nested array
+    const invalidExamples = [
+      [ROOT, [DIVIDER]],
+      [ROOT, [CHART]],
+      [ROOT, [MARKDOWN]],
+      [ROOT, GRID, [TAB]],
+      [ROOT, GRID, TABS, [ROW]],
+      [ROOT, GRID, TABS, TAB, [TABS]],
+      [ROOT, GRID, ROW, [TABS]],
+      [ROOT, GRID, ROW, [TAB]],
+      [ROOT, GRID, ROW, [DIVIDER]],
+      [ROOT, GRID, ROW, COLUMN, [TABS]],
+      [ROOT, GRID, ROW, COLUMN, [TAB]],
+      [ROOT, GRID, ROW, COLUMN, ROW, [DIVIDER]],
+      [ROOT, GRID, ROW, COLUMN, ROW, COLUMN, [ROW]], // too nested
+    ];
+
+    invalidExamples.forEach((example, exampleIdx) => {
+      let childDepth = 0;
+      example.forEach((childType, i) => {
+        const shouldTestChild = Array.isArray(childType);
+
+        if (i > 0 && shouldTestChild) {
+          const parentDepth = childDepth - 1;
+          const parentType = example[i - 1];
+
+          it(`(${exampleIdx})${getIndentation(
+            childDepth,
+          )}${parentType} (depth ${parentDepth}) > ${childType} ❌`, () => {
+            expect(
+              isValidChild({
+                parentDepth,
+                parentType,
+                childType,
+              }),
+            ).to.equal(false);
+          });
+        }
+        // see isValidChild.js for why tabs do not increment the depth of their children
+        childDepth += childType !== TABS && childType !== TAB ? 1 : 0;
+      });
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/newComponentFactory_spec.js b/superset/assets/spec/javascripts/dashboard/util/newComponentFactory_spec.js
new file mode 100644
index 0000000000..f52eba978f
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/newComponentFactory_spec.js
@@ -0,0 +1,51 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import newComponentFactory from '../../../../src/dashboard/util/newComponentFactory';
+
+import {
+  CHART_TYPE,
+  COLUMN_TYPE,
+  DASHBOARD_GRID_TYPE,
+  DASHBOARD_ROOT_TYPE,
+  DIVIDER_TYPE,
+  HEADER_TYPE,
+  MARKDOWN_TYPE,
+  NEW_COMPONENT_SOURCE_TYPE,
+  ROW_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+const types = [
+  CHART_TYPE,
+  COLUMN_TYPE,
+  DASHBOARD_GRID_TYPE,
+  DASHBOARD_ROOT_TYPE,
+  DIVIDER_TYPE,
+  HEADER_TYPE,
+  MARKDOWN_TYPE,
+  NEW_COMPONENT_SOURCE_TYPE,
+  ROW_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+];
+
+describe('newEntityFactory', () => {
+  types.forEach(type => {
+    it(`returns a new ${type}`, () => {
+      const result = newComponentFactory(type);
+
+      expect(result.type).to.equal(type);
+      expect(typeof result.id).to.equal('string');
+      expect(typeof result.meta).to.equal('object');
+      expect(Array.isArray(result.children)).to.equal(true);
+    });
+  });
+
+  it('adds passed meta data to the entity', () => {
+    const banana = 'banana';
+    const result = newComponentFactory(CHART_TYPE, { banana });
+    expect(result.meta.banana).to.equal(banana);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/newEntitiesFromDrop_spec.js b/superset/assets/spec/javascripts/dashboard/util/newEntitiesFromDrop_spec.js
new file mode 100644
index 0000000000..677c329a1f
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/newEntitiesFromDrop_spec.js
@@ -0,0 +1,82 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import newEntitiesFromDrop from '../../../../src/dashboard/util/newEntitiesFromDrop';
+import {
+  CHART_TYPE,
+  DASHBOARD_GRID_TYPE,
+  ROW_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+describe('newEntitiesFromDrop', () => {
+  it('should return a new Entity of appropriate type, and add it to the drop target children', () => {
+    const result = newEntitiesFromDrop({
+      dropResult: {
+        destination: { id: 'a', index: 0 },
+        dragging: { type: CHART_TYPE },
+      },
+      layout: {
+        a: {
+          id: 'a',
+          type: ROW_TYPE,
+          children: [],
+        },
+      },
+    });
+
+    const newId = result.a.children[0];
+    expect(result.a.children.length).to.equal(1);
+    expect(Object.keys(result).length).to.equal(2);
+    expect(result[newId].type).to.equal(CHART_TYPE);
+  });
+
+  it('should create Tab AND Tabs components if the drag entity is Tabs', () => {
+    const result = newEntitiesFromDrop({
+      dropResult: {
+        destination: { id: 'a', index: 0 },
+        dragging: { type: TABS_TYPE },
+      },
+      layout: {
+        a: {
+          id: 'a',
+          type: DASHBOARD_GRID_TYPE,
+          children: [],
+        },
+      },
+    });
+
+    const newTabsId = result.a.children[0];
+    const newTabId = result[newTabsId].children[0];
+
+    expect(result.a.children.length).to.equal(1);
+    expect(Object.keys(result).length).to.equal(3);
+    expect(result[newTabsId].type).to.equal(TABS_TYPE);
+    expect(result[newTabId].type).to.equal(TAB_TYPE);
+  });
+
+  it('should create a Row if the drag entity should be wrapped in a row', () => {
+    const result = newEntitiesFromDrop({
+      dropResult: {
+        destination: { id: 'a', index: 0 },
+        dragging: { type: CHART_TYPE },
+      },
+      layout: {
+        a: {
+          id: 'a',
+          type: DASHBOARD_GRID_TYPE,
+          children: [],
+        },
+      },
+    });
+
+    const newRowId = result.a.children[0];
+    const newChartId = result[newRowId].children[0];
+
+    expect(result.a.children.length).to.equal(1);
+    expect(Object.keys(result).length).to.equal(3);
+    expect(result[newRowId].type).to.equal(ROW_TYPE);
+    expect(result[newChartId].type).to.equal(CHART_TYPE);
+  });
+});
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..ac607c8a35 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];
 
@@ -161,14 +233,42 @@ export function handleComponentDrop(dropResult) {
       ) {
         const parentId = findParentId({
           childId: source.id,
-          components: layout,
+          layout,
         });
         dispatch(deleteComponent(source.id, parentId));
       }
     }
 
-    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/messageToasts.js b/superset/assets/src/dashboard/actions/messageToasts.js
index 367b36f2e7..fde02c4102 100644
--- a/superset/assets/src/dashboard/actions/messageToasts.js
+++ b/superset/assets/src/dashboard/actions/messageToasts.js
@@ -1,3 +1,5 @@
+import shortid from 'shortid';
+
 import {
   INFO_TOAST,
   SUCCESS_TOAST,
@@ -6,11 +8,7 @@ import {
 } from '../util/constants';
 
 function getToastUuid(type) {
-  return `${Math.random()
-    .toString(16)
-    .slice(2)}-${type}-${Math.random()
-    .toString(16)
-    .slice(2)}`;
+  return `${type}-${shortid.generate()}`;
 }
 
 export const ADD_TOAST = 'ADD_TOAST';
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..9a3fc7110b 100644
--- a/superset/assets/src/dashboard/components/DashboardBuilder.jsx
+++ b/superset/assets/src/dashboard/components/DashboardBuilder.jsx
@@ -1,8 +1,12 @@
+/* eslint-env browser */
 import cx from 'classnames';
-import React from 'react';
+// 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 HTML5Backend from 'react-dnd-html5-backend';
-import { DragDropContext } from 'react-dnd';
+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';
@@ -13,12 +17,16 @@ import DashboardComponent from '../containers/DashboardComponent';
 import ToastPresenter from '../containers/ToastPresenter';
 import WithPopoverMenu from './menu/WithPopoverMenu';
 
+import getDragDropManager from '../util/getDragDropManager';
+
 import {
   DASHBOARD_GRID_ID,
   DASHBOARD_ROOT_ID,
   DASHBOARD_ROOT_DEPTH,
 } from '../util/constants';
 
+const TABS_HEIGHT = 47;
+
 const propTypes = {
   // redux
   dashboardLayout: PropTypes.object.isRequired,
@@ -50,33 +58,43 @@ class DashboardBuilder extends React.Component {
     this.handleChangeTab = this.handleChangeTab.bind(this);
   }
 
+  getChildContext() {
+    return {
+      dragDropManager: this.context.dragDropManager || getDragDropManager(),
+    };
+  }
+
   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,43 +117,92 @@ 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>
     );
   }
 }
 
 DashboardBuilder.propTypes = propTypes;
 DashboardBuilder.defaultProps = defaultProps;
+DashboardBuilder.childContextTypes = {
+  dragDropManager: PropTypes.object.isRequired,
+};
 
-export default DragDropContext(HTML5Backend)(DashboardBuilder);
+export default DashboardBuilder;
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/dnd/DragDroppable.jsx b/superset/assets/src/dashboard/components/dnd/DragDroppable.jsx
index bfe4973cf2..ef116ea132 100644
--- a/superset/assets/src/dashboard/components/dnd/DragDroppable.jsx
+++ b/superset/assets/src/dashboard/components/dnd/DragDroppable.jsx
@@ -47,7 +47,8 @@ const defaultProps = {
   useEmptyDragPreview: false,
 };
 
-class DragDroppable extends React.Component {
+// export unwrapped component for testing
+export class UnwrappedDragDroppable extends React.Component {
   constructor(props) {
     super(props);
     this.state = {
@@ -92,6 +93,25 @@ class DragDroppable extends React.Component {
     } = this.props;
 
     const { dropIndicator } = this.state;
+    const dropIndicatorProps =
+      isDraggingOver && dropIndicator
+        ? {
+            className: cx(
+              'drop-indicator',
+              dropIndicator === DROP_TOP && 'drop-indicator--top',
+              dropIndicator === DROP_BOTTOM && 'drop-indicator--bottom',
+              dropIndicator === DROP_LEFT && 'drop-indicator--left',
+              dropIndicator === DROP_RIGHT && 'drop-indicator--right',
+            ),
+          }
+        : null;
+
+    const childProps = editMode
+      ? {
+          dragSourceRef,
+          dropIndicatorProps,
+        }
+      : {};
 
     return (
       <div
@@ -105,33 +125,17 @@ class DragDroppable extends React.Component {
           className,
         )}
       >
-        {children(
-          !editMode
-            ? {}
-            : {
-                dragSourceRef,
-                dropIndicatorProps: isDraggingOver &&
-                  dropIndicator && {
-                    className: cx(
-                      'drop-indicator',
-                      dropIndicator === DROP_TOP && 'drop-indicator--top',
-                      dropIndicator === DROP_BOTTOM && 'drop-indicator--bottom',
-                      dropIndicator === DROP_LEFT && 'drop-indicator--left',
-                      dropIndicator === DROP_RIGHT && 'drop-indicator--right',
-                    ),
-                  },
-              },
-        )}
+        {children(childProps)}
       </div>
     );
   }
 }
 
-DragDroppable.propTypes = propTypes;
-DragDroppable.defaultProps = defaultProps;
+UnwrappedDragDroppable.propTypes = propTypes;
+UnwrappedDragDroppable.defaultProps = defaultProps;
 
 // note that the composition order here determines using
 // component.method() vs decoratedComponentInstance.method() in the drag/drop config
 export default DropTarget(...dropConfig)(
-  DragSource(...dragConfig)(DragDroppable),
+  DragSource(...dragConfig)(UnwrappedDragDroppable),
 );
diff --git a/superset/assets/src/dashboard/components/gridComponents/Chart.jsx b/superset/assets/src/dashboard/components/gridComponents/Chart.jsx
index 54e15366b4..599858cb27 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,
@@ -137,7 +138,7 @@ class Chart extends React.Component {
     return this.props.refreshChart(this.props.chart, true, this.props.timeout);
   }
 
-  removeFilter(args) {
+  removeFilter(...args) {
     this.props.removeFilter(this.props.id, ...args);
   }
 
@@ -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/Header.jsx b/superset/assets/src/dashboard/components/gridComponents/Header.jsx
index 5114a77fb5..683af9e957 100644
--- a/superset/assets/src/dashboard/components/gridComponents/Header.jsx
+++ b/superset/assets/src/dashboard/components/gridComponents/Header.jsx
@@ -107,11 +107,12 @@ class Header extends React.PureComponent {
       >
         {({ dropIndicatorProps, dragSourceRef }) => (
           <div ref={dragSourceRef}>
-            {editMode && (
-              <HoverMenu position="left">
-                <DragHandle position="left" />
-              </HoverMenu>
-            )}
+            {editMode &&
+            depth <= 2 && ( // drag handle looks bad when nested
+                <HoverMenu position="left">
+                  <DragHandle position="left" />
+                </HoverMenu>
+              )}
 
             <WithPopoverMenu
               onChangeFocus={this.handleChangeFocus}
diff --git a/superset/assets/src/dashboard/components/gridComponents/Row.jsx b/superset/assets/src/dashboard/components/gridComponents/Row.jsx
index 91f200d340..28e7042c04 100644
--- a/superset/assets/src/dashboard/components/gridComponents/Row.jsx
+++ b/superset/assets/src/dashboard/components/gridComponents/Row.jsx
@@ -122,12 +122,22 @@ class Row extends React.PureComponent {
             menuItems={[
               <BackgroundStyleDropdown
                 id={`${rowComponent.id}-background`}
-                value={rowComponent.meta.background}
+                value={backgroundStyle.value}
                 onChange={this.handleChangeBackground}
               />,
             ]}
             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..27c34b24d6 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(
@@ -22,7 +22,7 @@ function mapStateToProps(
   ownProps,
 ) {
   const { id } = ownProps;
-  const chart = chartQueries[id];
+  const chart = chartQueries[id] || {};
   const { filters } = dashboardState;
 
   return {
@@ -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/fixtures/emptyDashboardLayout.js b/superset/assets/src/dashboard/fixtures/emptyDashboardLayout.js
index cee948a745..e306288766 100644
--- a/superset/assets/src/dashboard/fixtures/emptyDashboardLayout.js
+++ b/superset/assets/src/dashboard/fixtures/emptyDashboardLayout.js
@@ -1,6 +1,6 @@
 import {
   DASHBOARD_GRID_TYPE,
-  DASHBOARD_HEADER_TYPE,
+  HEADER_TYPE,
   DASHBOARD_ROOT_TYPE,
 } from '../util/componentTypes';
 
@@ -25,7 +25,7 @@ export default {
   },
 
   [DASHBOARD_HEADER_ID]: {
-    type: DASHBOARD_HEADER_TYPE,
+    type: HEADER_TYPE,
     id: DASHBOARD_HEADER_ID,
     meta: {
       text: 'New dashboard',
diff --git a/superset/assets/src/dashboard/reducers/dashboardLayout.js b/superset/assets/src/dashboard/reducers/dashboardLayout.js
index 573a143b38..e35d8a2d0f 100644
--- a/superset/assets/src/dashboard/reducers/dashboardLayout.js
+++ b/superset/assets/src/dashboard/reducers/dashboardLayout.js
@@ -82,7 +82,7 @@ const actionHandlers = {
       payload: { dropResult },
     } = action;
     const { destination, dragging } = dropResult;
-    const newEntities = newEntitiesFromDrop({ dropResult, components: state });
+    const newEntities = newEntitiesFromDrop({ dropResult, layout: state });
 
     // if column is a parent, set any resizable children to have a minimum width so that
     // the chances that they are validly movable to future containers is maximized
@@ -201,7 +201,7 @@ const actionHandlers = {
     }
 
     // create new component
-    const newEntities = newEntitiesFromDrop({ dropResult, components: state });
+    const newEntities = newEntitiesFromDrop({ dropResult, layout: state });
     const newEntitiesArray = Object.values(newEntities);
     const tabComponent = newEntitiesArray.find(
       component => component.type === TAB_TYPE,
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/componentTypes.js b/superset/assets/src/dashboard/util/componentTypes.js
index 286689888f..b773417983 100644
--- a/superset/assets/src/dashboard/util/componentTypes.js
+++ b/superset/assets/src/dashboard/util/componentTypes.js
@@ -1,7 +1,7 @@
 export const CHART_TYPE = 'DASHBOARD_CHART_TYPE';
 export const COLUMN_TYPE = 'DASHBOARD_COLUMN_TYPE';
+export const DASHBOARD_HEADER_TYPE = 'DASHBOARD_HEADER_TYPE';
 export const DASHBOARD_GRID_TYPE = 'DASHBOARD_GRID_TYPE';
-export const DASHBOARD_HEADER_TYPE = 'DASHBOARD_DASHBOARD_HEADER_TYPE';
 export const DASHBOARD_ROOT_TYPE = 'DASHBOARD_ROOT_TYPE';
 export const DIVIDER_TYPE = 'DASHBOARD_DIVIDER_TYPE';
 export const HEADER_TYPE = 'DASHBOARD_HEADER_TYPE';
@@ -14,8 +14,8 @@ export const TAB_TYPE = 'DASHBOARD_TAB_TYPE';
 export default {
   CHART_TYPE,
   COLUMN_TYPE,
-  DASHBOARD_GRID_TYPE,
   DASHBOARD_HEADER_TYPE,
+  DASHBOARD_GRID_TYPE,
   DASHBOARD_ROOT_TYPE,
   DIVIDER_TYPE,
   HEADER_TYPE,
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/dropOverflowsParent.js b/superset/assets/src/dashboard/util/dropOverflowsParent.js
index bc7195f3ea..328d8e3999 100644
--- a/superset/assets/src/dashboard/util/dropOverflowsParent.js
+++ b/superset/assets/src/dashboard/util/dropOverflowsParent.js
@@ -1,14 +1,10 @@
 import { COLUMN_TYPE } from '../util/componentTypes';
-import {
-  GRID_COLUMN_COUNT,
-  NEW_COMPONENTS_SOURCE_ID,
-  GRID_MIN_COLUMN_COUNT,
-} from './constants';
+import { GRID_COLUMN_COUNT, NEW_COMPONENTS_SOURCE_ID } from './constants';
 import findParentId from './findParentId';
 import getChildWidth from './getChildWidth';
 import newComponentFactory from './newComponentFactory';
 
-export default function doesChildOverflowParent(dropResult, components) {
+export default function doesChildOverflowParent(dropResult, layout) {
   const { source, destination, dragging } = dropResult;
 
   // moving a component within a container should never overflow
@@ -17,22 +13,33 @@ export default function doesChildOverflowParent(dropResult, components) {
   }
 
   const isNewComponent = source.id === NEW_COMPONENTS_SOURCE_ID;
-  const grandparentId = findParentId({ childId: destination.id, components });
+  const grandparentId = findParentId({
+    childId: destination.id,
+    layout,
+  });
 
   const child = isNewComponent
     ? newComponentFactory(dragging.type)
-    : components[dragging.id] || {};
-  const parent = components[destination.id] || {};
-  const grandparent = components[grandparentId] || {};
-
-  const grandparentWidth =
-    (grandparent.meta && grandparent.meta.width) || GRID_COLUMN_COUNT;
-  const parentWidth = (parent.meta && parent.meta.width) || grandparentWidth;
-  const parentChildWidth =
-    parent.type === COLUMN_TYPE
-      ? (parent.meta && parent.meta.width) || GRID_MIN_COLUMN_COUNT
-      : getChildWidth({ id: destination.id, components });
+    : layout[dragging.id] || {};
+  const parent = layout[destination.id] || {};
+  const grandparent = layout[grandparentId] || {};
+
   const childWidth = (child.meta && child.meta.width) || 0;
 
-  return parentWidth - parentChildWidth < childWidth;
+  const grandparentCapacity =
+    grandparent.meta && typeof grandparent.meta.width === 'number'
+      ? grandparent.meta.width
+      : GRID_COLUMN_COUNT;
+
+  const parentCapacity =
+    parent.meta && typeof parent.meta.width === 'number'
+      ? parent.meta.width
+      : grandparentCapacity;
+
+  const occupiedParentWidth =
+    parent.type === COLUMN_TYPE
+      ? 0
+      : getChildWidth({ id: destination.id, components: layout });
+
+  return parentCapacity < occupiedParentWidth + childWidth;
 }
diff --git a/superset/assets/src/dashboard/util/findParentId.js b/superset/assets/src/dashboard/util/findParentId.js
index f84b0de19a..c2e285d589 100644
--- a/superset/assets/src/dashboard/util/findParentId.js
+++ b/superset/assets/src/dashboard/util/findParentId.js
@@ -1,10 +1,10 @@
-export default function findParentId({ childId, components = {} }) {
+export default function findParentId({ childId, layout = {} }) {
   let parentId = null;
 
-  const ids = Object.keys(components);
+  const ids = Object.keys(layout);
   for (let i = 0; i < ids.length - 1; i += 1) {
     const id = ids[i];
-    const component = components[id] || {};
+    const component = layout[id] || {};
     if (
       id !== childId &&
       component.children &&
diff --git a/superset/assets/src/dashboard/util/getChartIdsFromLayout.js b/superset/assets/src/dashboard/util/getChartIdsFromLayout.js
index f0963c1942..9aebb611d8 100644
--- a/superset/assets/src/dashboard/util/getChartIdsFromLayout.js
+++ b/superset/assets/src/dashboard/util/getChartIdsFromLayout.js
@@ -1,7 +1,14 @@
+import { CHART_TYPE } from './componentTypes';
+
 export default function getChartIdsFromLayout(layout) {
-  return Object.values(layout).reduce((chartIds, value) => {
-    if (value && value.meta && value.meta.chartId) {
-      chartIds.push(value.meta.chartId);
+  return Object.values(layout).reduce((chartIds, currentComponent) => {
+    if (
+      currentComponent &&
+      currentComponent.type === CHART_TYPE &&
+      currentComponent.meta &&
+      currentComponent.meta.chartId
+    ) {
+      chartIds.push(currentComponent.meta.chartId);
     }
     return chartIds;
   }, []);
diff --git a/superset/assets/src/dashboard/util/getDragDropManager.js b/superset/assets/src/dashboard/util/getDragDropManager.js
new file mode 100644
index 0000000000..1be8ecb1f1
--- /dev/null
+++ b/superset/assets/src/dashboard/util/getDragDropManager.js
@@ -0,0 +1,17 @@
+import { DragDropManager } from 'dnd-core';
+import HTML5Backend from 'react-dnd-html5-backend';
+
+let defaultManager;
+
+// we use this method to ensure that there is a singleton of the DragDropManager
+// within the app this seems to work fine, but in tests multiple are initialized
+// see this issue for more details https://github.com/react-dnd/react-dnd/issues/186
+// @TODO re-evaluate whether this is required when we move to jest
+// the alternative is simply using an HOC like:
+//  DragDropContext(HTML5Backend)(DashboardBuilder);
+export default function getDragDropManager() {
+  if (!defaultManager) {
+    defaultManager = new DragDropManager(HTML5Backend);
+  }
+  return defaultManager;
+}
diff --git a/superset/assets/src/dashboard/util/isValidChild.js b/superset/assets/src/dashboard/util/isValidChild.js
index d789f45b5d..80bf69ea6d 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 = {
@@ -43,6 +44,7 @@ const parentMaxDepthLookup = {
 
   [DASHBOARD_GRID_TYPE]: {
     [CHART_TYPE]: depthOne,
+    [MARKDOWN_TYPE]: depthOne,
     [COLUMN_TYPE]: depthOne,
     [DIVIDER_TYPE]: depthOne,
     [HEADER_TYPE]: depthOne,
@@ -53,7 +55,7 @@ const parentMaxDepthLookup = {
   [ROW_TYPE]: {
     [CHART_TYPE]: depthFour,
     [MARKDOWN_TYPE]: depthFour,
-    [COLUMN_TYPE]: depthTwo,
+    [COLUMN_TYPE]: depthFour,
   },
 
   [TABS_TYPE]: {
@@ -62,18 +64,20 @@ const parentMaxDepthLookup = {
 
   [TAB_TYPE]: {
     [CHART_TYPE]: depthTwo,
+    [MARKDOWN_TYPE]: depthTwo,
     [COLUMN_TYPE]: depthTwo,
     [DIVIDER_TYPE]: depthTwo,
     [HEADER_TYPE]: depthTwo,
     [ROW_TYPE]: depthTwo,
-    [TABS_TYPE]: depthTwo,
+    [TABS_TYPE]: rootDepth, // you cannot drop a Tabs within a Tab
   },
 
   [COLUMN_TYPE]: {
-    [CHART_TYPE]: depthThree,
-    [HEADER_TYPE]: depthThree,
-    [MARKDOWN_TYPE]: depthThree,
+    [CHART_TYPE]: depthFive,
+    [HEADER_TYPE]: depthFive,
+    [MARKDOWN_TYPE]: depthFive,
     [ROW_TYPE]: depthThree,
+    [DIVIDER_TYPE]: depthThree,
   },
 
   // these have no valid children
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/newEntitiesFromDrop.js b/superset/assets/src/dashboard/util/newEntitiesFromDrop.js
index 7fe7f4e80b..8abc9b9985 100644
--- a/superset/assets/src/dashboard/util/newEntitiesFromDrop.js
+++ b/superset/assets/src/dashboard/util/newEntitiesFromDrop.js
@@ -3,14 +3,13 @@ import newComponentFactory from './newComponentFactory';
 
 import { ROW_TYPE, TABS_TYPE, TAB_TYPE } from './componentTypes';
 
-export default function newEntitiesFromDrop({ dropResult, components }) {
+export default function newEntitiesFromDrop({ dropResult, layout }) {
   const { dragging, destination } = dropResult;
 
   const dragType = dragging.type;
-  const dragMeta = dragging.meta;
-  const dropEntity = components[destination.id];
+  const dropEntity = layout[destination.id];
   const dropType = dropEntity.type;
-  let newDropChild = newComponentFactory(dragType, dragMeta);
+  let newDropChild = newComponentFactory(dragType, dragging.meta);
   const wrapChildInRow = shouldWrapChildInRow({
     parentType: dropType,
     childType: dragType,
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/logger.js b/superset/assets/src/logger.js
index c7823fcf2e..65c81b50a7 100644
--- a/superset/assets/src/logger.js
+++ b/superset/assets/src/logger.js
@@ -18,8 +18,10 @@ export const Logger = {
   },
 
   append(eventName, eventBody) {
-    return handlers[eventName].length &&
-      handlers[eventName].forEach(handler => (handler(eventName, eventBody)));
+    return (
+      (handlers[eventName] || {}).length &&
+      handlers[eventName].forEach(handler => handler(eventName, eventBody))
+    );
   },
 
   end(log) {
@@ -28,8 +30,7 @@ export const Logger = {
 
     log.eventNames.forEach((eventName) => {
       if (handlers[eventName].length) {
-        const index = handlers[eventName]
-          .findIndex(handler => (handler === log.addEvent));
+        const index = handlers[eventName].findIndex(handler => handler === log.addEvent);
         handlers[eventName].splice(index, 1);
       }
     });
@@ -51,7 +52,7 @@ export const Logger = {
     }
     let url = '/superset/log/';
     if (requestPrams.length) {
-      url += '?' + requestPrams.map(([k, v]) => (k + '=' + v)).join('&');
+      url += '?' + requestPrams.map(([k, v]) => k + '=' + v).join('&');
     }
     const eventData = {};
     for (const eventName in events) {
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