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/24 00:27:36 UTC

[GitHub] williaster closed pull request #4993: [dashboard v2] tests!

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

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/images/loading.gif b/superset/assets/images/loading.gif
index 01ae3939c4..ae5cbddd91 100644
Binary files a/superset/assets/images/loading.gif and b/superset/assets/images/loading.gif differ
diff --git a/superset/assets/package.json b/superset/assets/package.json
index c4911b3101..fc6f7d080b 100644
--- a/superset/assets/package.json
+++ b/superset/assets/package.json
@@ -43,6 +43,7 @@
   "dependencies": {
     "@data-ui/event-flow": "^0.0.8",
     "@data-ui/sparkline": "^0.0.49",
+    "@vx/responsive": "0.0.153",
     "babel-register": "^6.24.1",
     "bootstrap": "^3.3.6",
     "bootstrap-slider": "^10.0.0",
@@ -60,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",
@@ -82,26 +84,30 @@
     "parse-iso-duration": "^1.0.0",
     "po2json": "^0.4.5",
     "prop-types": "^15.6.0",
+    "re-resizable": "^4.3.1",
     "react": "^15.6.2",
-    "react-ace": "^5.0.1",
+    "react-ace": "^5.10.0",
     "react-addons-css-transition-group": "^15.6.0",
     "react-addons-shallow-compare": "^15.4.2",
     "react-alert": "^2.3.0",
     "react-bootstrap": "^0.31.5",
     "react-bootstrap-slider": "2.0.1",
-    "react-bootstrap-table": "^4.0.2",
     "react-color": "^2.13.8",
     "react-datetime": "2.9.0",
+    "react-dnd": "^2.5.4",
+    "react-dnd-html5-backend": "^2.5.4",
     "react-dom": "^15.6.2",
     "react-gravatar": "^2.6.1",
-    "react-grid-layout": "0.16.5",
     "react-map-gl": "^3.0.4",
+    "react-markdown": "^3.3.0",
     "react-redux": "^5.0.2",
     "react-resizable": "^1.3.3",
+    "react-search-input": "^0.11.3",
     "react-select": "1.2.1",
     "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",
@@ -109,6 +115,7 @@
     "redux": "^3.5.2",
     "redux-localstorage": "^0.4.1",
     "redux-thunk": "^2.1.0",
+    "redux-undo": "^1.0.0-beta9-9-7",
     "shortid": "^2.2.6",
     "sprintf-js": "^1.1.1",
     "srcdoc-polyfill": "^1.0.0",
@@ -131,8 +138,10 @@
     "enzyme": "^2.0.0",
     "eslint": "^4.19.0",
     "eslint-config-airbnb": "^15.0.1",
+    "eslint-config-prettier": "^2.9.0",
     "eslint-plugin-import": "^2.2.0",
     "eslint-plugin-jsx-a11y": "^5.1.1",
+    "eslint-plugin-prettier": "^2.6.0",
     "eslint-plugin-react": "^7.0.1",
     "exports-loader": "^0.6.3",
     "extract-text-webpack-plugin": "3.0.0",
@@ -147,6 +156,7 @@
     "less-loader": "^4.0.3",
     "mocha": "^3.2.0",
     "npm-check-updates": "^2.14.0",
+    "prettier": "^1.12.1",
     "react-addons-test-utils": "^15.6.2",
     "react-test-renderer": "^15.6.2",
     "redux-mock-store": "^1.2.3",
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/chart/Chart_spec.jsx b/superset/assets/spec/javascripts/chart/Chart_spec.jsx
index b766d9f8f4..29a2941870 100644
--- a/superset/assets/spec/javascripts/chart/Chart_spec.jsx
+++ b/superset/assets/spec/javascripts/chart/Chart_spec.jsx
@@ -20,7 +20,7 @@ describe('Chart', () => {
   };
   const mockedProps = {
     ...chart,
-    chartKey: 'slice_223',
+    id: 223,
     containerId: 'slice-container-223',
     datasource: {},
     formData: {},
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/Dashboard_spec.jsx b/superset/assets/spec/javascripts/dashboard/Dashboard_spec.jsx
deleted file mode 100644
index c6e94d87d9..0000000000
--- a/superset/assets/spec/javascripts/dashboard/Dashboard_spec.jsx
+++ /dev/null
@@ -1,182 +0,0 @@
-import React from 'react';
-import { shallow } from 'enzyme';
-import { describe, it } from 'mocha';
-import { expect } from 'chai';
-import sinon from 'sinon';
-
-import * as dashboardActions from '../../../src/dashboard/actions';
-import * as chartActions from '../../../src/chart/chartAction';
-import Dashboard from '../../../src/dashboard/components/Dashboard';
-import { defaultFilters, dashboard, charts } from './fixtures';
-
-describe('Dashboard', () => {
-  const mockedProps = {
-    actions: { ...chartActions, ...dashboardActions },
-    initMessages: [],
-    dashboard: dashboard.dashboard,
-    slices: charts,
-    filters: dashboard.filters,
-    datasources: dashboard.datasources,
-    refresh: false,
-    timeout: 60,
-    isStarred: false,
-    userId: dashboard.userId,
-  };
-
-  it('should render', () => {
-    const wrapper = shallow(<Dashboard {...mockedProps} />);
-    expect(wrapper.find('#dashboard-container')).to.have.length(1);
-    expect(wrapper.instance().getAllSlices()).to.have.length(3);
-  });
-
-  it('should handle metadata default_filters', () => {
-    const wrapper = shallow(<Dashboard {...mockedProps} />);
-    expect(wrapper.instance().props.filters).deep.equal(defaultFilters);
-  });
-
-  describe('getFormDataExtra', () => {
-    let wrapper;
-    let selectedSlice;
-    beforeEach(() => {
-      wrapper = shallow(<Dashboard {...mockedProps} />);
-      selectedSlice = wrapper.instance().props.dashboard.slices[1];
-    });
-
-    it('should carry default_filters', () => {
-      const extraFilters = wrapper.instance().getFormDataExtra(selectedSlice).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', () => {
-      wrapper.setProps({
-        filters: {
-          256: { region: [] },
-          257: { country_name: ['France'] },
-        },
-      });
-      const extraFilters = wrapper.instance().getFormDataExtra(selectedSlice).extra_filters;
-      expect(extraFilters[1]).to.deep.equal({ col: 'country_name', op: 'in', val: ['France'] });
-    });
-  });
-
-  describe('refreshExcept', () => {
-    let wrapper;
-    let spy;
-    beforeEach(() => {
-      wrapper = shallow(<Dashboard {...mockedProps} />);
-      spy = sinon.spy(wrapper.instance(), 'fetchSlices');
-    });
-    afterEach(() => {
-      spy.restore();
-    });
-
-    it('should not refresh filter slice', () => {
-      const filterKey = Object.keys(defaultFilters)[1];
-      wrapper.instance().refreshExcept(filterKey);
-      expect(spy.callCount).to.equal(1);
-      expect(spy.getCall(0).args[0].length).to.equal(1);
-    });
-
-    it('should refresh all slices', () => {
-      wrapper.instance().refreshExcept();
-      expect(spy.callCount).to.equal(1);
-      expect(spy.getCall(0).args[0].length).to.equal(3);
-    });
-  });
-
-  describe('componentDidUpdate', () => {
-    let wrapper;
-    let refreshExceptSpy;
-    let fetchSlicesStub;
-    let prevProp;
-    beforeEach(() => {
-      wrapper = shallow(<Dashboard {...mockedProps} />);
-      prevProp = wrapper.instance().props;
-      refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
-      fetchSlicesStub = sinon.stub(wrapper.instance(), 'fetchSlices');
-    });
-    afterEach(() => {
-      fetchSlicesStub.restore();
-      refreshExceptSpy.restore();
-    });
-
-    describe('should check if filter has change', () => {
-      beforeEach(() => {
-        refreshExceptSpy.reset();
-      });
-      it('no change', () => {
-        wrapper.setProps({
-          refresh: true,
-          filters: {
-            256: { region: [] },
-            257: { country_name: ['United States'] },
-          },
-        });
-        wrapper.instance().componentDidUpdate(prevProp);
-        expect(refreshExceptSpy.callCount).to.equal(0);
-      });
-
-      it('remove filter', () => {
-        wrapper.setProps({
-          refresh: true,
-          filters: {
-            256: { region: [] },
-          },
-        });
-        wrapper.instance().componentDidUpdate(prevProp);
-        expect(refreshExceptSpy.callCount).to.equal(1);
-      });
-
-      it('change filter', () => {
-        wrapper.setProps({
-          refresh: true,
-          filters: {
-            256: { region: [] },
-            257: { country_name: ['Canada'] },
-          },
-        });
-        wrapper.instance().componentDidUpdate(prevProp);
-        expect(refreshExceptSpy.callCount).to.equal(1);
-      });
-
-      it('add filter', () => {
-        wrapper.setProps({
-          refresh: true,
-          filters: {
-            256: { region: [] },
-            257: { country_name: ['Canada'] },
-            258: { another_filter: ['new'] },
-          },
-        });
-        wrapper.instance().componentDidUpdate(prevProp);
-        expect(refreshExceptSpy.callCount).to.equal(1);
-      });
-    });
-
-    it('should refresh if refresh flag is true', () => {
-      wrapper.setProps({
-        refresh: true,
-        filters: {
-          256: { region: ['Asian'] },
-        },
-      });
-      wrapper.instance().componentDidUpdate(prevProp);
-      const fetchArgs = fetchSlicesStub.lastCall.args[0];
-      expect(fetchArgs).to.have.length(2);
-    });
-
-    it('should not refresh filter_immune_slices', () => {
-      wrapper.setProps({
-        refresh: true,
-        filters: {
-          256: { region: [] },
-          257: { country_name: ['Canada'] },
-        },
-      });
-      wrapper.instance().componentDidUpdate(prevProp);
-      const fetchArgs = fetchSlicesStub.lastCall.args[0];
-      expect(fetchArgs).to.have.length(1);
-    });
-  });
-});
diff --git a/superset/assets/spec/javascripts/dashboard/actions/dashboardLayout_spec.js b/superset/assets/spec/javascripts/dashboard/actions/dashboardLayout_spec.js
new file mode 100644
index 0000000000..0c4fe1297c
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/actions/dashboardLayout_spec.js
@@ -0,0 +1,442 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import { ActionCreators as UndoActionCreators } from 'redux-undo';
+
+import {
+  UPDATE_COMPONENTS,
+  updateComponents,
+  DELETE_COMPONENT,
+  deleteComponent,
+  CREATE_COMPONENT,
+  CREATE_TOP_LEVEL_TABS,
+  createTopLevelTabs,
+  DELETE_TOP_LEVEL_TABS,
+  deleteTopLevelTabs,
+  resizeComponent,
+  MOVE_COMPONENT,
+  handleComponentDrop,
+  updateDashboardTitle,
+  undoLayoutAction,
+  redoLayoutAction,
+} from '../../../../src/dashboard/actions/dashboardLayout';
+
+import { setUnsavedChanges } from '../../../../src/dashboard/actions/dashboardState';
+import { addInfoToast } from '../../../../src/dashboard/actions/messageToasts';
+
+import {
+  DASHBOARD_GRID_TYPE,
+  ROW_TYPE,
+  CHART_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+import {
+  DASHBOARD_HEADER_ID,
+  DASHBOARD_GRID_ID,
+  DASHBOARD_ROOT_ID,
+  NEW_COMPONENTS_SOURCE_ID,
+  NEW_ROW_ID,
+} from '../../../../src/dashboard/util/constants';
+
+describe('dashboardLayout actions', () => {
+  const mockState = {
+    dashboardState: {},
+    dashboardInfo: {},
+    dashboardLayout: {
+      past: [],
+      present: {},
+      future: {},
+    },
+  };
+
+  function setup(stateOverrides) {
+    const state = { ...mockState, ...stateOverrides };
+    const getState = sinon.spy(() => state);
+    const dispatch = sinon.spy();
+
+    return { getState, dispatch, state };
+  }
+
+  describe('updateComponents', () => {
+    it('should dispatch an updateLayout action', () => {
+      const { getState, dispatch } = setup({
+        dashboardState: { hasUnsavedChanges: true },
+      });
+      const nextComponents = { 1: {} };
+      const thunk = updateComponents(nextComponents);
+      thunk(dispatch, getState);
+      expect(dispatch.callCount).to.equal(1);
+      expect(dispatch.getCall(0).args[0]).to.deep.equal({
+        type: UPDATE_COMPONENTS,
+        payload: { nextComponents },
+      });
+    });
+
+    it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => {
+      const { getState, dispatch } = setup({
+        dashboardState: { hasUnsavedChanges: false },
+      });
+      const nextComponents = { 1: {} };
+      const thunk = updateComponents(nextComponents);
+      thunk(dispatch, getState);
+      expect(dispatch.callCount).to.equal(2);
+      expect(dispatch.getCall(1).args[0]).to.deep.equal(
+        setUnsavedChanges(true),
+      );
+    });
+  });
+
+  describe('deleteComponents', () => {
+    it('should dispatch an deleteComponent action', () => {
+      const { getState, dispatch } = setup({
+        dashboardState: { hasUnsavedChanges: true },
+      });
+      const thunk = deleteComponent('id', 'parentId');
+      thunk(dispatch, getState);
+      expect(dispatch.callCount).to.equal(1);
+      expect(dispatch.getCall(0).args[0]).to.deep.equal({
+        type: DELETE_COMPONENT,
+        payload: { id: 'id', parentId: 'parentId' },
+      });
+    });
+
+    it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => {
+      const { getState, dispatch } = setup({
+        dashboardState: { hasUnsavedChanges: false },
+      });
+      const thunk = deleteComponent('id', 'parentId');
+      thunk(dispatch, getState);
+      expect(dispatch.callCount).to.equal(2);
+      expect(dispatch.getCall(1).args[0]).to.deep.equal(
+        setUnsavedChanges(true),
+      );
+    });
+  });
+
+  describe('updateDashboardTitle', () => {
+    it('should dispatch an updateComponent action for the header component', () => {
+      const { getState, dispatch } = setup();
+      const thunk1 = updateDashboardTitle('new text');
+      thunk1(dispatch, getState);
+
+      const thunk2 = dispatch.getCall(0).args[0];
+      thunk2(dispatch, getState);
+
+      expect(dispatch.getCall(1).args[0]).to.deep.equal({
+        type: UPDATE_COMPONENTS,
+        payload: {
+          nextComponents: {
+            [DASHBOARD_HEADER_ID]: {
+              meta: { text: 'new text' },
+            },
+          },
+        },
+      });
+    });
+  });
+
+  // describe('createComponent', () => {});
+  describe('createTopLevelTabs', () => {
+    it('should dispatch a createTopLevelTabs action', () => {
+      const { getState, dispatch } = setup({
+        dashboardState: { hasUnsavedChanges: true },
+      });
+      const dropResult = {};
+      const thunk = createTopLevelTabs(dropResult);
+      thunk(dispatch, getState);
+      expect(dispatch.callCount).to.equal(1);
+      expect(dispatch.getCall(0).args[0]).to.deep.equal({
+        type: CREATE_TOP_LEVEL_TABS,
+        payload: { dropResult },
+      });
+    });
+
+    it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => {
+      const { getState, dispatch } = setup({
+        dashboardState: { hasUnsavedChanges: false },
+      });
+      const dropResult = {};
+      const thunk = createTopLevelTabs(dropResult);
+      thunk(dispatch, getState);
+      expect(dispatch.callCount).to.equal(2);
+      expect(dispatch.getCall(1).args[0]).to.deep.equal(
+        setUnsavedChanges(true),
+      );
+    });
+  });
+
+  describe('deleteTopLevelTabs', () => {
+    it('should dispatch a deleteTopLevelTabs action', () => {
+      const { getState, dispatch } = setup({
+        dashboardState: { hasUnsavedChanges: true },
+      });
+      const dropResult = {};
+      const thunk = deleteTopLevelTabs(dropResult);
+      thunk(dispatch, getState);
+      expect(dispatch.callCount).to.equal(1);
+      expect(dispatch.getCall(0).args[0]).to.deep.equal({
+        type: DELETE_TOP_LEVEL_TABS,
+        payload: {},
+      });
+    });
+
+    it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => {
+      const { getState, dispatch } = setup({
+        dashboardState: { hasUnsavedChanges: false },
+      });
+      const dropResult = {};
+      const thunk = deleteTopLevelTabs(dropResult);
+      thunk(dispatch, getState);
+      expect(dispatch.callCount).to.equal(2);
+      expect(dispatch.getCall(1).args[0]).to.deep.equal(
+        setUnsavedChanges(true),
+      );
+    });
+  });
+
+  describe('resizeComponent', () => {
+    const dashboardLayout = {
+      ...mockState.dashboardLayout,
+      present: {
+        1: {
+          id: 1,
+          children: [],
+          meta: {
+            width: 1,
+            height: 1,
+          },
+        },
+      },
+    };
+
+    it('should update the size of the component', () => {
+      const { getState, dispatch } = setup({
+        dashboardState: { hasUnsavedChanges: true },
+        dashboardLayout,
+      });
+
+      const thunk1 = resizeComponent({ id: 1, width: 10, height: 3 });
+      thunk1(dispatch, getState);
+
+      const thunk2 = dispatch.getCall(0).args[0];
+      thunk2(dispatch, getState);
+
+      expect(dispatch.callCount).to.equal(2);
+      expect(dispatch.getCall(1).args[0]).to.deep.equal({
+        type: UPDATE_COMPONENTS,
+        payload: {
+          nextComponents: {
+            1: {
+              id: 1,
+              children: [],
+              meta: {
+                width: 10,
+                height: 3,
+              },
+            },
+          },
+        },
+      });
+    });
+
+    it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => {
+      const { getState, dispatch } = setup({
+        dashboardState: { hasUnsavedChanges: false },
+        dashboardLayout,
+      });
+      const thunk1 = resizeComponent({ id: 1, width: 10, height: 3 });
+      thunk1(dispatch, getState);
+
+      const thunk2 = dispatch.getCall(0).args[0];
+      thunk2(dispatch, getState);
+
+      expect(dispatch.callCount).to.equal(3);
+    });
+  });
+
+  describe('handleComponentDrop', () => {
+    it('should create a component if it is new', () => {
+      const { getState, dispatch } = setup();
+      const dropResult = {
+        source: { id: NEW_COMPONENTS_SOURCE_ID },
+        destination: { id: DASHBOARD_GRID_ID, type: DASHBOARD_GRID_TYPE },
+        dragging: { id: NEW_ROW_ID, type: ROW_TYPE },
+      };
+
+      const thunk1 = handleComponentDrop(dropResult);
+      thunk1(dispatch, getState);
+
+      const thunk2 = dispatch.getCall(0).args[0];
+      thunk2(dispatch, getState);
+
+      expect(dispatch.getCall(1).args[0]).to.deep.equal({
+        type: CREATE_COMPONENT,
+        payload: {
+          dropResult,
+        },
+      });
+    });
+
+    it('should move a component if the component is not new', () => {
+      const { getState, dispatch } = setup({
+        dashboardLayout: { present: { id: { type: ROW_TYPE } } },
+      });
+      const dropResult = {
+        source: { id: 'id', index: 0, type: ROW_TYPE },
+        destination: { id: DASHBOARD_GRID_ID, type: DASHBOARD_GRID_TYPE },
+        dragging: { id: NEW_ROW_ID, type: ROW_TYPE },
+      };
+
+      const thunk = handleComponentDrop(dropResult);
+      thunk(dispatch, getState);
+
+      expect(dispatch.getCall(0).args[0]).to.deep.equal({
+        type: MOVE_COMPONENT,
+        payload: {
+          dropResult,
+        },
+      });
+    });
+
+    it('should dispatch a toast if the drop overflows the destination', () => {
+      const { getState, dispatch } = setup({
+        dashboardLayout: {
+          present: {
+            source: { type: ROW_TYPE, meta: { width: 0 } },
+            destination: { type: ROW_TYPE, meta: { width: 0 } },
+            dragging: { type: CHART_TYPE, meta: { width: 100 } },
+          },
+        },
+      });
+      const dropResult = {
+        source: { id: 'source', type: ROW_TYPE },
+        destination: { id: 'destination', type: ROW_TYPE },
+        dragging: { id: 'dragging', type: CHART_TYPE },
+      };
+
+      const thunk = handleComponentDrop(dropResult);
+      thunk(dispatch, getState);
+      expect(dispatch.getCall(0).args[0].type).to.deep.equal(
+        addInfoToast('').type,
+      );
+    });
+
+    it('should delete the parent Tabs if the moved Tab was the only child', () => {
+      const { getState, dispatch } = setup({
+        dashboardLayout: {
+          present: {
+            parentId: { id: 'parentId', children: ['tabsId'] },
+            tabsId: { id: 'tabsId', type: TABS_TYPE, children: [] },
+            [DASHBOARD_GRID_ID]: {
+              id: DASHBOARD_GRID_ID,
+              type: DASHBOARD_GRID_TYPE,
+            },
+            tabId: { id: 'tabId', type: TAB_TYPE },
+          },
+        },
+      });
+
+      const dropResult = {
+        source: { id: 'tabsId', type: TABS_TYPE },
+        destination: { id: DASHBOARD_GRID_ID, type: DASHBOARD_GRID_TYPE },
+        dragging: { id: 'tabId', type: TAB_TYPE },
+      };
+
+      const moveThunk = handleComponentDrop(dropResult);
+      moveThunk(dispatch, getState);
+
+      // first call is move action which is not a thunk
+      const deleteThunk = dispatch.getCall(1).args[0];
+      deleteThunk(dispatch, getState);
+
+      expect(dispatch.getCall(2).args[0]).to.deep.equal({
+        type: DELETE_COMPONENT,
+        payload: {
+          id: 'tabsId',
+          parentId: 'parentId',
+        },
+      });
+    });
+
+    it('should create top-level tabs if dropped on root', () => {
+      const { getState, dispatch } = setup();
+      const dropResult = {
+        source: { id: NEW_COMPONENTS_SOURCE_ID },
+        destination: { id: DASHBOARD_ROOT_ID },
+        dragging: { id: NEW_ROW_ID, type: ROW_TYPE },
+      };
+
+      const thunk1 = handleComponentDrop(dropResult);
+      thunk1(dispatch, getState);
+
+      const thunk2 = dispatch.getCall(0).args[0];
+      thunk2(dispatch, getState);
+
+      expect(dispatch.getCall(1).args[0]).to.deep.equal({
+        type: CREATE_TOP_LEVEL_TABS,
+        payload: {
+          dropResult,
+        },
+      });
+    });
+  });
+
+  describe('undoLayoutAction', () => {
+    it('should dispatch a redux-undo .undo() action ', () => {
+      const { getState, dispatch } = setup({
+        dashboardLayout: { past: ['non-empty'] },
+      });
+      const thunk = undoLayoutAction();
+      thunk(dispatch, getState);
+
+      expect(dispatch.callCount).to.equal(1);
+      expect(dispatch.getCall(0).args[0]).to.deep.equal(
+        UndoActionCreators.undo(),
+      );
+    });
+
+    it('should dispatch a setUnsavedChanges(false) action history length is zero', () => {
+      const { getState, dispatch } = setup({
+        dashboardLayout: { past: [] },
+      });
+      const thunk = undoLayoutAction();
+      thunk(dispatch, getState);
+
+      expect(dispatch.callCount).to.equal(2);
+      expect(dispatch.getCall(1).args[0]).to.deep.equal(
+        setUnsavedChanges(false),
+      );
+    });
+  });
+
+  describe('redoLayoutAction', () => {
+    it('should dispatch a redux-undo .redo() action ', () => {
+      const { getState, dispatch } = setup({
+        dashboardState: { hasUnsavedChanges: true },
+      });
+      const thunk = redoLayoutAction();
+      thunk(dispatch, getState);
+
+      expect(dispatch.callCount).to.equal(1);
+      expect(dispatch.getCall(0).args[0]).to.deep.equal(
+        UndoActionCreators.redo(),
+      );
+    });
+
+    it('should dispatch a setUnsavedChanges(true) action if hasUnsavedChanges=false', () => {
+      const { getState, dispatch } = setup({
+        dashboardState: { hasUnsavedChanges: false },
+      });
+      const thunk = redoLayoutAction();
+      thunk(dispatch, getState);
+
+      expect(dispatch.callCount).to.equal(2);
+      expect(dispatch.getCall(1).args[0]).to.deep.equal(
+        setUnsavedChanges(true),
+      );
+    });
+  });
+});
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/components/Dashboard_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx
new file mode 100644
index 0000000000..545b890dea
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx
@@ -0,0 +1,249 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import Dashboard from '../../../../src/dashboard/components/Dashboard';
+import DashboardBuilder from '../../../../src/dashboard/containers/DashboardBuilder';
+
+// mock data
+import chartQueries, { sliceId as chartId } from '../fixtures/mockChartQueries';
+import datasources from '../fixtures/mockDatasource';
+import dashboardInfo from '../fixtures/mockDashboardInfo';
+import { dashboardLayout } from '../fixtures/mockDashboardLayout';
+import dashboardState from '../fixtures/mockDashboardState';
+import sliceEntities from '../fixtures/mockSliceEntities';
+
+import { CHART_TYPE } from '../../../../src/dashboard/util/componentTypes';
+import newComponentFactory from '../../../../src/dashboard/util/newComponentFactory';
+
+describe('Dashboard', () => {
+  const props = {
+    actions: {
+      addSliceToDashboard() {},
+      removeSliceFromDashboard() {},
+      runQuery() {},
+    },
+    initMessages: [],
+    dashboardState,
+    dashboardInfo,
+    charts: chartQueries,
+    slices: sliceEntities.slices,
+    datasources,
+    layout: dashboardLayout.present,
+    timeout: 60,
+    userId: dashboardInfo.userId,
+    impressionId: 'id',
+  };
+
+  function setup(overrideProps) {
+    const wrapper = shallow(<Dashboard {...props} {...overrideProps} />);
+    return wrapper;
+  }
+
+  it('should render a DashboardBuilder', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DashboardBuilder)).to.have.length(1);
+  });
+
+  describe('refreshExcept', () => {
+    const overrideCharts = {
+      ...chartQueries,
+      1001: {
+        ...chartQueries[chartId],
+        id: 1001,
+      },
+    };
+
+    const overrideSlices = {
+      ...props.slices,
+      1001: {
+        ...props.slices[chartId],
+        slice_id: 1001,
+      },
+    };
+
+    it('should call runQuery for all non-exempt slices', () => {
+      const wrapper = setup({ charts: overrideCharts, slices: overrideSlices });
+      const spy = sinon.spy(props.actions, 'runQuery');
+      wrapper.instance().refreshExcept('1001');
+      spy.restore();
+      expect(spy.callCount).to.equal(Object.keys(overrideCharts).length - 1);
+    });
+
+    it('should not call runQuery for filter_immune_slices', () => {
+      const wrapper = setup({
+        charts: overrideCharts,
+        dashboardInfo: {
+          ...dashboardInfo,
+          metadata: {
+            ...dashboardInfo.metadata,
+            filter_immune_slices: Object.keys(overrideCharts).map(id =>
+              Number(id),
+            ),
+          },
+        },
+      });
+      const spy = sinon.spy(props.actions, 'runQuery');
+      wrapper.instance().refreshExcept();
+      spy.restore();
+      expect(spy.callCount).to.equal(0);
+    });
+  });
+
+  describe('componentWillReceiveProps', () => {
+    const layoutWithExtraChart = {
+      ...props.layout,
+      1001: newComponentFactory(CHART_TYPE, { chartId: 1001 }),
+    };
+
+    it('should call addSliceToDashboard if a new slice is added to the layout', () => {
+      const wrapper = setup();
+      const spy = sinon.spy(props.actions, 'addSliceToDashboard');
+      wrapper.instance().componentWillReceiveProps({
+        ...props,
+        layout: layoutWithExtraChart,
+      });
+      spy.restore();
+      expect(spy.callCount).to.equal(1);
+    });
+
+    it('should call removeSliceFromDashboard if a slice is removed from the layout', () => {
+      const wrapper = setup({ layout: layoutWithExtraChart });
+      const spy = sinon.spy(props.actions, 'removeSliceFromDashboard');
+      const nextLayout = { ...layoutWithExtraChart };
+      delete nextLayout[1001];
+
+      wrapper.instance().componentWillReceiveProps({
+        ...props,
+        layout: nextLayout,
+      });
+      spy.restore();
+      expect(spy.callCount).to.equal(1);
+    });
+  });
+
+  describe('componentDidUpdate', () => {
+    const overrideDashboardState = {
+      ...dashboardState,
+      filters: {
+        1: { region: [] },
+        2: { country_name: ['USA'] },
+      },
+      refresh: true,
+    };
+
+    it('should not call refresh when there is no change', () => {
+      const wrapper = setup({ dashboardState: overrideDashboardState });
+      const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
+      const prevProps = wrapper.instance().props;
+      wrapper.setProps({
+        dashboardState: {
+          ...overrideDashboardState,
+        },
+      });
+      wrapper.instance().componentDidUpdate(prevProps);
+      refreshExceptSpy.restore();
+      expect(refreshExceptSpy.callCount).to.equal(0);
+    });
+
+    it('should call refresh if a filter is added', () => {
+      const wrapper = setup({ dashboardState: overrideDashboardState });
+      const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
+      const prevProps = wrapper.instance().props;
+      wrapper.setProps({
+        dashboardState: {
+          ...overrideDashboardState,
+          filters: {
+            ...overrideDashboardState.filters,
+            3: { another_filter: ['please'] },
+          },
+        },
+      });
+      wrapper.instance().componentDidUpdate(prevProps);
+      refreshExceptSpy.restore();
+      expect(refreshExceptSpy.callCount).to.equal(1);
+    });
+
+    it('should call refresh if a filter is removed', () => {
+      const wrapper = setup({ dashboardState: overrideDashboardState });
+      const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
+      const prevProps = wrapper.instance().props;
+      wrapper.setProps({
+        dashboardState: {
+          ...overrideDashboardState,
+          filters: {},
+        },
+      });
+      wrapper.instance().componentDidUpdate(prevProps);
+      refreshExceptSpy.restore();
+      expect(refreshExceptSpy.callCount).to.equal(1);
+    });
+
+    it('should call refresh if a filter is changed', () => {
+      const wrapper = setup({ dashboardState: overrideDashboardState });
+      const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
+      const prevProps = wrapper.instance().props;
+      wrapper.setProps({
+        dashboardState: {
+          ...overrideDashboardState,
+          filters: {
+            ...overrideDashboardState.filters,
+            2: { country_name: ['Canada'] },
+          },
+        },
+      });
+      wrapper.instance().componentDidUpdate(prevProps);
+      refreshExceptSpy.restore();
+      expect(refreshExceptSpy.callCount).to.equal(1);
+    });
+
+    it('should not call refresh if filters change and refresh is false', () => {
+      const wrapper = setup({ dashboardState: overrideDashboardState });
+      const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
+      const prevProps = wrapper.instance().props;
+      wrapper.setProps({
+        dashboardState: {
+          ...overrideDashboardState,
+          filters: {
+            ...overrideDashboardState.filters,
+            2: { country_name: ['Canada'] },
+          },
+          refresh: false,
+        },
+      });
+      wrapper.instance().componentDidUpdate(prevProps);
+      refreshExceptSpy.restore();
+      expect(refreshExceptSpy.callCount).to.equal(0);
+    });
+
+    it('should not refresh filter_immune_slices', () => {
+      const wrapper = setup({
+        dashboardState: overrideDashboardState,
+        dashboardInfo: {
+          ...dashboardInfo,
+          metadata: {
+            ...dashboardInfo.metadata,
+            filter_immune_slices: [chartId],
+          },
+        },
+      });
+      const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
+      const prevProps = wrapper.instance().props;
+      wrapper.setProps({
+        dashboardState: {
+          ...overrideDashboardState,
+          filters: {
+            ...overrideDashboardState.filters,
+            2: { country_name: ['Canada'] },
+          },
+          refresh: false,
+        },
+      });
+      wrapper.instance().componentDidUpdate(prevProps);
+      refreshExceptSpy.restore();
+      expect(refreshExceptSpy.callCount).to.equal(0);
+    });
+  });
+});
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..05756f467b
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx
@@ -0,0 +1,92 @@
+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);
+  });
+
+  it('should return props.filters when its getFilters method is called', () => {
+    const filters = { column: ['value'] };
+    const wrapper = setup({ filters });
+    expect(wrapper.instance().getFilters()).to.equal(filters);
+  });
+});
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..5add770a8c
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/menu/WithPopoverMenu_spec.jsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+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 371b02c02c..7a12454d39 100644
--- a/superset/assets/spec/javascripts/dashboard/fixtures.jsx
+++ b/superset/assets/spec/javascripts/dashboard/fixtures.jsx
@@ -1,4 +1,4 @@
-import { getInitialState } from '../../../src/dashboard/reducers';
+import getInitialState from '../../../src/dashboard/reducers/getInitialState';
 
 export const defaultFilters = {
   256: { region: [] },
@@ -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,10 +119,10 @@ 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 datasources = {};
 const mockDashboardData = {
   css: '',
   dash_edit_perm: true,
@@ -152,10 +156,16 @@ const mockDashboardData = {
   slices: [regionFilter, slice, countryFilter],
   standalone_mode: false,
 };
-export const { dashboard, charts } = getInitialState({
+export const {
+  dashboardState,
+  dashboardInfo,
+  charts,
+  datasources,
+  sliceEntities,
+  dashboardLayout,
+} = getInitialState({
   common: {},
   dashboard_data: mockDashboardData,
-  datasources,
+  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/reducers/dashboardLayout_spec.js b/superset/assets/spec/javascripts/dashboard/reducers/dashboardLayout_spec.js
new file mode 100644
index 0000000000..cbe1729a4a
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/reducers/dashboardLayout_spec.js
@@ -0,0 +1,443 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import layoutReducer from '../../../../src/dashboard/reducers/dashboardLayout';
+
+import {
+  UPDATE_COMPONENTS,
+  DELETE_COMPONENT,
+  CREATE_COMPONENT,
+  MOVE_COMPONENT,
+  CREATE_TOP_LEVEL_TABS,
+  DELETE_TOP_LEVEL_TABS,
+} from '../../../../src/dashboard/actions/dashboardLayout';
+
+import {
+  CHART_TYPE,
+  COLUMN_TYPE,
+  DASHBOARD_GRID_TYPE,
+  DASHBOARD_ROOT_TYPE,
+  ROW_TYPE,
+  TAB_TYPE,
+  TABS_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+import {
+  DASHBOARD_ROOT_ID,
+  DASHBOARD_GRID_ID,
+  GRID_MIN_COLUMN_COUNT,
+  NEW_COMPONENTS_SOURCE_ID,
+  NEW_TABS_ID,
+  NEW_ROW_ID,
+} from '../../../../src/dashboard/util/constants';
+
+describe('dashboardLayout reducer', () => {
+  it('should return initial state for unrecognized actions', () => {
+    expect(layoutReducer(undefined, {})).to.deep.equal({});
+  });
+
+  it('should delete a component, remove its reference in its parent, and recursively all of its children', () => {
+    expect(
+      layoutReducer(
+        {
+          toDelete: {
+            id: 'toDelete',
+            children: ['child1'],
+          },
+          child1: {
+            id: 'child1',
+            children: ['child2'],
+          },
+          child2: {
+            id: 'child2',
+            children: [],
+          },
+          parentId: {
+            id: 'parentId',
+            children: ['toDelete', 'anotherId'],
+          },
+        },
+        {
+          type: DELETE_COMPONENT,
+          payload: { id: 'toDelete', parentId: 'parentId' },
+        },
+      ),
+    ).to.deep.equal({
+      parentId: {
+        id: 'parentId',
+        children: ['anotherId'],
+      },
+    });
+  });
+
+  it('should update components', () => {
+    expect(
+      layoutReducer(
+        {
+          update: {
+            id: 'update',
+            children: [],
+          },
+          update2: {
+            id: 'update2',
+            children: [],
+          },
+          dontUpdate: {
+            id: 'dontUpdate',
+            something: 'something',
+            children: ['abcd'],
+          },
+        },
+        {
+          type: UPDATE_COMPONENTS,
+          payload: {
+            nextComponents: {
+              update: {
+                id: 'update',
+                newField: 'newField',
+              },
+              update2: {
+                id: 'update2',
+                newField: 'newField',
+              },
+            },
+          },
+        },
+      ),
+    ).to.deep.equal({
+      update: {
+        id: 'update',
+        newField: 'newField',
+      },
+      update2: {
+        id: 'update2',
+        newField: 'newField',
+      },
+      dontUpdate: {
+        id: 'dontUpdate',
+        something: 'something',
+        children: ['abcd'],
+      },
+    });
+  });
+
+  it('should move a component', () => {
+    const layout = {
+      source: {
+        id: 'source',
+        type: ROW_TYPE,
+        children: ['dontMove', 'toMove'],
+      },
+      destination: {
+        id: 'destination',
+        type: ROW_TYPE,
+        children: ['anotherChild'],
+      },
+      toMove: {
+        id: 'toMove',
+        type: CHART_TYPE,
+        children: [],
+      },
+    };
+
+    const dropResult = {
+      source: { id: 'source', type: ROW_TYPE, index: 1 },
+      destination: { id: 'destination', type: ROW_TYPE, index: 0 },
+      dragging: { id: 'toMove', type: CHART_TYPE },
+    };
+
+    expect(
+      layoutReducer(layout, {
+        type: MOVE_COMPONENT,
+        payload: { dropResult },
+      }),
+    ).to.deep.equal({
+      source: {
+        id: 'source',
+        type: ROW_TYPE,
+        children: ['dontMove'],
+      },
+      destination: {
+        id: 'destination',
+        type: ROW_TYPE,
+        children: ['toMove', 'anotherChild'],
+      },
+      toMove: {
+        id: 'toMove',
+        type: CHART_TYPE,
+        children: [],
+      },
+    });
+  });
+
+  it('should set the width of a moved component with column type parent to the minimum width', () => {
+    const layout = {
+      source: {
+        id: 'source',
+        type: ROW_TYPE,
+        children: ['dontMove', 'toMove'],
+      },
+      destination: {
+        id: 'destination',
+        type: COLUMN_TYPE,
+        children: [],
+        meta: { width: 100 },
+      },
+      toMove: {
+        id: 'toMove',
+        type: CHART_TYPE,
+        children: [],
+        meta: { width: 1001 },
+      },
+    };
+
+    const dropResult = {
+      source: { id: 'source', type: ROW_TYPE, index: 1 },
+      destination: { id: 'destination', type: COLUMN_TYPE, index: 0 },
+      dragging: { id: 'toMove', type: CHART_TYPE },
+    };
+
+    const result = layoutReducer(layout, {
+      type: MOVE_COMPONENT,
+      payload: { dropResult },
+    });
+
+    expect(result.toMove.meta.width).to.equal(GRID_MIN_COLUMN_COUNT);
+  });
+
+  it('should wrap a moved component in a row if need be', () => {
+    const layout = {
+      source: {
+        id: 'source',
+        type: ROW_TYPE,
+        children: ['dontMove', 'toMove'],
+      },
+      destination: {
+        id: 'destination',
+        type: DASHBOARD_GRID_TYPE,
+        children: [],
+      },
+      toMove: {
+        id: 'toMove',
+        type: CHART_TYPE,
+        children: [],
+      },
+    };
+
+    const dropResult = {
+      source: { id: 'source', type: ROW_TYPE, index: 1 },
+      destination: { id: 'destination', type: DASHBOARD_GRID_TYPE, index: 0 },
+      dragging: { id: 'toMove', type: CHART_TYPE },
+    };
+
+    const result = layoutReducer(layout, {
+      type: MOVE_COMPONENT,
+      payload: { dropResult },
+    });
+
+    const newRow = Object.values(result).find(
+      component =>
+        ['source', 'destination', 'toMove'].indexOf(component.id) === -1,
+    );
+
+    expect(newRow.children[0]).to.equal('toMove');
+    expect(result.destination.children[0]).to.equal(newRow.id);
+    expect(Object.keys(result)).to.have.length(4);
+  });
+
+  it('should add top-level tabs from a new tabs component, moving grid children to new tab', () => {
+    const layout = {
+      [DASHBOARD_ROOT_ID]: {
+        id: DASHBOARD_ROOT_ID,
+        children: [DASHBOARD_GRID_ID],
+      },
+      [DASHBOARD_GRID_ID]: {
+        id: DASHBOARD_GRID_ID,
+        children: ['child'],
+      },
+      child: {
+        id: 'child',
+        children: [],
+      },
+    };
+
+    const dropResult = {
+      source: { id: NEW_COMPONENTS_SOURCE_ID, type: '' },
+      destination: {
+        id: DASHBOARD_ROOT_ID,
+        type: DASHBOARD_ROOT_TYPE,
+        index: 0,
+      },
+      dragging: { id: NEW_TABS_ID, type: TABS_TYPE },
+    };
+
+    const result = layoutReducer(layout, {
+      type: CREATE_TOP_LEVEL_TABS,
+      payload: { dropResult },
+    });
+
+    const tabComponent = Object.values(result).find(
+      component => component.type === TAB_TYPE,
+    );
+
+    const tabsComponent = Object.values(result).find(
+      component => component.type === TABS_TYPE,
+    );
+
+    expect(Object.keys(result)).to.have.length(5); // initial + Tabs + Tab
+    expect(result[DASHBOARD_ROOT_ID].children[0]).to.equal(tabsComponent.id);
+    expect(result[tabsComponent.id].children[0]).to.equal(tabComponent.id);
+    expect(result[tabComponent.id].children[0]).to.equal('child');
+    expect(result[DASHBOARD_GRID_ID].children).to.have.length(0);
+  });
+
+  it('should add top-level tabs from an existing tabs component, moving grid children to new tab', () => {
+    const layout = {
+      [DASHBOARD_ROOT_ID]: {
+        id: DASHBOARD_ROOT_ID,
+        children: [DASHBOARD_GRID_ID],
+      },
+      [DASHBOARD_GRID_ID]: {
+        id: DASHBOARD_GRID_ID,
+        children: ['child', 'tabs', 'child2'],
+      },
+      child: {
+        id: 'child',
+        children: [],
+      },
+      child2: {
+        id: 'child2',
+        children: [],
+      },
+      tabs: {
+        id: 'tabs',
+        type: TABS_TYPE,
+        children: ['tab'],
+      },
+      tab: {
+        id: 'tab',
+        type: TAB_TYPE,
+        children: [],
+      },
+    };
+
+    const dropResult = {
+      source: { id: DASHBOARD_GRID_ID, type: DASHBOARD_GRID_TYPE, index: 1 },
+      destination: {
+        id: DASHBOARD_ROOT_ID,
+        type: DASHBOARD_ROOT_TYPE,
+        index: 0,
+      },
+      dragging: { id: 'tabs', type: TABS_TYPE },
+    };
+
+    const result = layoutReducer(layout, {
+      type: CREATE_TOP_LEVEL_TABS,
+      payload: { dropResult },
+    });
+
+    expect(Object.keys(result)).to.have.length(Object.keys(layout).length);
+    expect(result[DASHBOARD_ROOT_ID].children[0]).to.equal('tabs');
+    expect(result.tabs.children[0]).to.equal('tab');
+    expect(result.tab.children).to.deep.equal(['child', 'child2']);
+    expect(result[DASHBOARD_GRID_ID].children).to.have.length(0);
+  });
+
+  it('should remove top-level tabs, moving children to the grid', () => {
+    const layout = {
+      [DASHBOARD_ROOT_ID]: {
+        id: DASHBOARD_ROOT_ID,
+        children: ['tabs'],
+      },
+      [DASHBOARD_GRID_ID]: {
+        id: DASHBOARD_GRID_ID,
+        children: [],
+      },
+      child: {
+        id: 'child',
+        children: [],
+      },
+      child2: {
+        id: 'child2',
+        children: [],
+      },
+      tabs: {
+        id: 'tabs',
+        type: TABS_TYPE,
+        children: ['tab'],
+      },
+      tab: {
+        id: 'tab',
+        type: TAB_TYPE,
+        children: ['child', 'child2'],
+      },
+    };
+
+    const dropResult = {
+      source: { id: DASHBOARD_GRID_ID, type: DASHBOARD_GRID_TYPE, index: 1 },
+      destination: {
+        id: DASHBOARD_ROOT_ID,
+        type: DASHBOARD_ROOT_TYPE,
+        index: 0,
+      },
+      dragging: { id: 'tabs', type: TABS_TYPE },
+    };
+
+    const result = layoutReducer(layout, {
+      type: DELETE_TOP_LEVEL_TABS,
+      payload: { dropResult },
+    });
+
+    expect(result).to.deep.equal({
+      [DASHBOARD_ROOT_ID]: {
+        id: DASHBOARD_ROOT_ID,
+        children: [DASHBOARD_GRID_ID],
+      },
+      [DASHBOARD_GRID_ID]: {
+        id: DASHBOARD_GRID_ID,
+        children: ['child', 'child2'],
+      },
+      child: {
+        id: 'child',
+        children: [],
+      },
+      child2: {
+        id: 'child2',
+        children: [],
+      },
+    });
+  });
+
+  it('should create a component', () => {
+    const layout = {
+      [DASHBOARD_ROOT_ID]: {
+        id: DASHBOARD_ROOT_ID,
+        children: [DASHBOARD_GRID_ID],
+      },
+      [DASHBOARD_GRID_ID]: {
+        id: DASHBOARD_GRID_ID,
+        children: ['child'],
+      },
+      child: { id: 'child' },
+    };
+
+    const dropResult = {
+      source: { id: NEW_COMPONENTS_SOURCE_ID, type: '' },
+      destination: {
+        id: DASHBOARD_GRID_ID,
+        type: DASHBOARD_GRID_TYPE,
+        index: 1,
+      },
+      dragging: { id: NEW_ROW_ID, type: ROW_TYPE },
+    };
+
+    const result = layoutReducer(layout, {
+      type: CREATE_COMPONENT,
+      payload: { dropResult },
+    });
+
+    const newId = result[DASHBOARD_GRID_ID].children[1];
+    expect(result[DASHBOARD_GRID_ID].children).to.have.length(2);
+    expect(result[newId].type).to.equal(ROW_TYPE);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/reducers/dashboardState_spec.js b/superset/assets/spec/javascripts/dashboard/reducers/dashboardState_spec.js
new file mode 100644
index 0000000000..078019dfa5
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/reducers/dashboardState_spec.js
@@ -0,0 +1,239 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import {
+  ADD_SLICE,
+  ADD_FILTER,
+  ON_CHANGE,
+  ON_SAVE,
+  REMOVE_SLICE,
+  REMOVE_FILTER,
+  SET_EDIT_MODE,
+  SET_MAX_UNDO_HISTORY_EXCEEDED,
+  SET_UNSAVED_CHANGES,
+  TOGGLE_BUILDER_PANE,
+  TOGGLE_EXPAND_SLICE,
+  TOGGLE_FAVE_STAR,
+} from '../../../../src/dashboard/actions/dashboardState';
+
+import dashboardStateReducer from '../../../../src/dashboard/reducers/dashboardState';
+
+describe('dashboardState reducer', () => {
+  it('should return initial state', () => {
+    expect(dashboardStateReducer(undefined, {})).to.deep.equal({});
+  });
+
+  it('should add a slice', () => {
+    expect(
+      dashboardStateReducer(
+        { sliceIds: [1] },
+        { type: ADD_SLICE, slice: { slice_id: 2 } },
+      ),
+    ).to.deep.equal({ sliceIds: [1, 2] });
+  });
+
+  it('should remove a slice', () => {
+    expect(
+      dashboardStateReducer(
+        { sliceIds: [1, 2], filters: {} },
+        { type: REMOVE_SLICE, sliceId: 2 },
+      ),
+    ).to.deep.equal({ sliceIds: [1], refresh: false, filters: {} });
+  });
+
+  it('should reset filters if a removed slice is a filter', () => {
+    expect(
+      dashboardStateReducer(
+        { sliceIds: [1, 2], filters: { 2: {}, 1: {} } },
+        { type: REMOVE_SLICE, sliceId: 2 },
+      ),
+    ).to.deep.equal({ sliceIds: [1], filters: { 1: {} }, refresh: true });
+  });
+
+  it('should toggle fav star', () => {
+    expect(
+      dashboardStateReducer(
+        { isStarred: false },
+        { type: TOGGLE_FAVE_STAR, isStarred: true },
+      ),
+    ).to.deep.equal({ isStarred: true });
+  });
+
+  it('should toggle edit mode', () => {
+    expect(
+      dashboardStateReducer(
+        { editMode: false },
+        { type: SET_EDIT_MODE, editMode: true },
+      ),
+    ).to.deep.equal({ editMode: true, showBuilderPane: true });
+  });
+
+  it('should toggle builder pane', () => {
+    expect(
+      dashboardStateReducer(
+        { showBuilderPane: false },
+        { type: TOGGLE_BUILDER_PANE },
+      ),
+    ).to.deep.equal({ showBuilderPane: true });
+
+    expect(
+      dashboardStateReducer(
+        { showBuilderPane: true },
+        { type: TOGGLE_BUILDER_PANE },
+      ),
+    ).to.deep.equal({ showBuilderPane: false });
+  });
+
+  it('should toggle expanded slices', () => {
+    expect(
+      dashboardStateReducer(
+        { expandedSlices: { 1: true, 2: false } },
+        { type: TOGGLE_EXPAND_SLICE, sliceId: 1 },
+      ),
+    ).to.deep.equal({ expandedSlices: { 2: false } });
+
+    expect(
+      dashboardStateReducer(
+        { expandedSlices: { 1: true, 2: false } },
+        { type: TOGGLE_EXPAND_SLICE, sliceId: 2 },
+      ),
+    ).to.deep.equal({ expandedSlices: { 1: true, 2: true } });
+  });
+
+  it('should set hasUnsavedChanges', () => {
+    expect(dashboardStateReducer({}, { type: ON_CHANGE })).to.deep.equal({
+      hasUnsavedChanges: true,
+    });
+
+    expect(
+      dashboardStateReducer(
+        {},
+        { type: SET_UNSAVED_CHANGES, payload: { hasUnsavedChanges: false } },
+      ),
+    ).to.deep.equal({
+      hasUnsavedChanges: false,
+    });
+  });
+
+  it('should set maxUndoHistoryExceeded', () => {
+    expect(
+      dashboardStateReducer(
+        {},
+        {
+          type: SET_MAX_UNDO_HISTORY_EXCEEDED,
+          payload: { maxUndoHistoryExceeded: true },
+        },
+      ),
+    ).to.deep.equal({
+      maxUndoHistoryExceeded: true,
+    });
+  });
+
+  it('should set unsaved changes and max undo history to false on save', () => {
+    expect(
+      dashboardStateReducer({ hasUnsavedChanges: true }, { type: ON_SAVE }),
+    ).to.deep.equal({
+      hasUnsavedChanges: false,
+      maxUndoHistoryExceeded: false,
+    });
+  });
+
+  describe('add filter', () => {
+    it('should add a new filter if it does not exist', () => {
+      expect(
+        dashboardStateReducer(
+          {
+            filters: {},
+            sliceIds: [1],
+          },
+          {
+            type: ADD_FILTER,
+            chart: { id: 1, formData: { groupby: 'column' } },
+            col: 'column',
+            vals: ['b', 'a'],
+            refresh: true,
+            merge: true,
+          },
+        ),
+      ).to.deep.equal({
+        filters: { 1: { column: ['b', 'a'] } },
+        refresh: true,
+        sliceIds: [1],
+      });
+    });
+
+    it('should overwrite a filter if merge is false', () => {
+      expect(
+        dashboardStateReducer(
+          {
+            filters: {
+              1: { column: ['z'] },
+            },
+            sliceIds: [1],
+          },
+          {
+            type: ADD_FILTER,
+            chart: { id: 1, formData: { groupby: 'column' } },
+            col: 'column',
+            vals: ['b', 'a'],
+            refresh: true,
+            merge: false,
+          },
+        ),
+      ).to.deep.equal({
+        filters: { 1: { column: ['b', 'a'] } },
+        refresh: true,
+        sliceIds: [1],
+      });
+    });
+
+    it('should merge a filter if merge is true', () => {
+      expect(
+        dashboardStateReducer(
+          {
+            filters: {
+              1: { column: ['z'] },
+            },
+            sliceIds: [1],
+          },
+          {
+            type: ADD_FILTER,
+            chart: { id: 1, formData: { groupby: 'column' } },
+            col: 'column',
+            vals: ['b', 'a'],
+            refresh: true,
+            merge: true,
+          },
+        ),
+      ).to.deep.equal({
+        filters: { 1: { column: ['z', 'b', 'a'] } },
+        refresh: true,
+        sliceIds: [1],
+      });
+    });
+  });
+
+  it('should remove a filter', () => {
+    expect(
+      dashboardStateReducer(
+        {
+          filters: {
+            1: {
+              column: ['a', 'b', 'c'],
+            },
+          },
+        },
+        {
+          type: REMOVE_FILTER,
+          sliceId: 1,
+          col: 'column',
+          vals: ['b', 'a'], // these are removed
+          refresh: true,
+        },
+      ),
+    ).to.deep.equal({
+      filters: { 1: { column: ['c'] } },
+      refresh: true,
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/reducers/messageToasts_spec.js b/superset/assets/spec/javascripts/dashboard/reducers/messageToasts_spec.js
new file mode 100644
index 0000000000..5280312bb6
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/reducers/messageToasts_spec.js
@@ -0,0 +1,32 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import {
+  ADD_TOAST,
+  REMOVE_TOAST,
+} from '../../../../src/dashboard/actions/messageToasts';
+import messageToastsReducer from '../../../../src/dashboard/reducers/messageToasts';
+
+describe('messageToasts reducer', () => {
+  it('should return initial state', () => {
+    expect(messageToastsReducer(undefined, {})).to.deep.equal([]);
+  });
+
+  it('should add a toast', () => {
+    expect(
+      messageToastsReducer([], {
+        type: ADD_TOAST,
+        payload: { text: 'test', id: 'id', type: 'test_type' },
+      }),
+    ).to.deep.equal([{ text: 'test', id: 'id', type: 'test_type' }]);
+  });
+
+  it('should add a toast', () => {
+    expect(
+      messageToastsReducer([{ id: 'id' }, { id: 'id2' }], {
+        type: REMOVE_TOAST,
+        payload: { id: 'id' },
+      }),
+    ).to.deep.equal([{ id: 'id2' }]);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/reducers/sliceEntities_spec.js b/superset/assets/spec/javascripts/dashboard/reducers/sliceEntities_spec.js
new file mode 100644
index 0000000000..7e3bb76bc1
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/reducers/sliceEntities_spec.js
@@ -0,0 +1,51 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import {
+  FETCH_ALL_SLICES_FAILED,
+  FETCH_ALL_SLICES_STARTED,
+  SET_ALL_SLICES,
+} from '../../../../src/dashboard/actions/sliceEntities';
+
+import sliceEntitiesReducer from '../../../../src/dashboard/reducers/sliceEntities';
+
+describe('sliceEntities reducer', () => {
+  it('should return initial state', () => {
+    expect(sliceEntitiesReducer({}, {})).to.deep.equal({});
+  });
+
+  it('should set loading when fetching slices', () => {
+    expect(
+      sliceEntitiesReducer(
+        { isLoading: false },
+        { type: FETCH_ALL_SLICES_STARTED },
+      ).isLoading,
+    ).to.equal(true);
+  });
+
+  it('should set slices', () => {
+    const result = sliceEntitiesReducer(
+      { slices: { a: {} } },
+      { type: SET_ALL_SLICES, slices: { 1: {}, 2: {} } },
+    );
+
+    expect(result.slices).to.deep.equal({
+      1: {},
+      2: {},
+      a: {},
+    });
+    expect(result.isLoading).to.equal(false);
+  });
+
+  it('should set an error on error', () => {
+    const result = sliceEntitiesReducer(
+      {},
+      {
+        type: FETCH_ALL_SLICES_FAILED,
+        error: { responseJSON: { message: 'errorrr' } },
+      },
+    );
+    expect(result.isLoading).to.equal(false);
+    expect(result.errorMessage.indexOf('errorrr')).to.be.above(-1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/reducers_spec.js b/superset/assets/spec/javascripts/dashboard/reducers_spec.js
deleted file mode 100644
index 6421fec83c..0000000000
--- a/superset/assets/spec/javascripts/dashboard/reducers_spec.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import { describe, it } from 'mocha';
-import { expect } from 'chai';
-
-import { dashboard as reducers } from '../../../src/dashboard/reducers';
-import * as actions from '../../../src/dashboard/actions';
-import { defaultFilters, dashboard as initState } from './fixtures';
-
-describe('Dashboard reducers', () => {
-  it('should remove slice', () => {
-    const action = {
-      type: actions.REMOVE_SLICE,
-      slice: initState.dashboard.slices[1],
-    };
-    expect(initState.dashboard.slices).to.have.length(3);
-
-    const { dashboard, filters, refresh } = reducers(initState, action);
-    expect(dashboard.slices).to.have.length(2);
-    expect(filters).to.deep.equal(defaultFilters);
-    expect(refresh).to.equal(false);
-  });
-
-  it('should remove filter slice', () => {
-    const action = {
-      type: actions.REMOVE_SLICE,
-      slice: initState.dashboard.slices[0],
-    };
-    const initFilters = Object.keys(initState.filters);
-    expect(initFilters).to.have.length(2);
-
-    const { dashboard, filters, refresh } = reducers(initState, action);
-    expect(dashboard.slices).to.have.length(2);
-    expect(Object.keys(filters)).to.have.length(1);
-    expect(refresh).to.equal(true);
-  });
-});
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/getFormDataWithExtraFilters_spec.js b/superset/assets/spec/javascripts/dashboard/util/getFormDataWithExtraFilters_spec.js
new file mode 100644
index 0000000000..82ec00ecf4
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/getFormDataWithExtraFilters_spec.js
@@ -0,0 +1,67 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import getFormDataWithExtraFilters from '../../../../src/dashboard/util/charts/getFormDataWithExtraFilters';
+
+describe('getFormDataWithExtraFilters', () => {
+  const chartId = 'chartId';
+  const mockArgs = {
+    chart: {
+      id: chartId,
+      formData: {
+        filters: [
+          {
+            col: 'country_name',
+            op: 'in',
+            val: ['United States'],
+          },
+        ],
+      },
+    },
+    dashboardMetadata: {
+      filter_immune_slices: [],
+      filter_immune_slice_fields: {},
+    },
+    filters: {
+      filterId: {
+        region: ['Spain'],
+        color: ['pink', 'purple'],
+      },
+    },
+    sliceId: chartId,
+  };
+
+  it('should include filters from the slice', () => {
+    const result = getFormDataWithExtraFilters(mockArgs);
+    expect(result.extra_filters[0]).to.deep.equal(
+      mockArgs.chart.formData.filters[0],
+    );
+  });
+
+  it('should include filters from the passed filters', () => {
+    const result = getFormDataWithExtraFilters(mockArgs);
+    expect(result.extra_filters).to.have.length(3);
+  });
+
+  it('should not add additional filters if the slice is immune to them', () => {
+    const result = getFormDataWithExtraFilters({
+      ...mockArgs,
+      dashboardMetadata: {
+        filter_immune_slices: [chartId],
+      },
+    });
+    expect(result.extra_filters).to.have.length(1);
+  });
+
+  it('should not add additional filters for fields to which the slice is immune', () => {
+    const result = getFormDataWithExtraFilters({
+      ...mockArgs,
+      dashboardMetadata: {
+        filter_immune_slice_fields: {
+          [chartId]: ['region'],
+        },
+      },
+    });
+    expect(result.extra_filters).to.have.length(2);
+  });
+});
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/chart/Chart.jsx b/superset/assets/src/chart/Chart.jsx
index b69ee3ae3b..a76891cc77 100644
--- a/superset/assets/src/chart/Chart.jsx
+++ b/superset/assets/src/chart/Chart.jsx
@@ -17,7 +17,7 @@ import './chart.css';
 const propTypes = {
   annotationData: PropTypes.object,
   actions: PropTypes.object,
-  chartKey: PropTypes.string.isRequired,
+  chartId: PropTypes.number.isRequired,
   containerId: PropTypes.string.isRequired,
   datasource: PropTypes.object.isRequired,
   formData: PropTypes.object.isRequired,
@@ -42,7 +42,6 @@ const propTypes = {
   // dashboard callbacks
   addFilter: PropTypes.func,
   getFilters: PropTypes.func,
-  clearFilter: PropTypes.func,
   removeFilter: PropTypes.func,
   onQuery: PropTypes.func,
   onDismissRefreshOverlay: PropTypes.func,
@@ -51,7 +50,6 @@ const propTypes = {
 const defaultProps = {
   addFilter: () => ({}),
   getFilters: () => ({}),
-  clearFilter: () => ({}),
   removeFilter: () => ({}),
 };
 
@@ -67,7 +65,6 @@ class Chart extends React.PureComponent {
     this.datasource = props.datasource;
     this.addFilter = this.addFilter.bind(this);
     this.getFilters = this.getFilters.bind(this);
-    this.clearFilter = this.clearFilter.bind(this);
     this.removeFilter = this.removeFilter.bind(this);
     this.headerHeight = this.headerHeight.bind(this);
     this.height = this.height.bind(this);
@@ -76,10 +73,16 @@ class Chart extends React.PureComponent {
 
   componentDidMount() {
     if (this.props.triggerQuery) {
-      this.props.actions.runQuery(this.props.formData, false,
+      const { formData } = this.props;
+      this.props.actions.runQuery(
+        formData,
+        false,
         this.props.timeout,
-        this.props.chartKey,
+        this.props.chartId,
       );
+    } else {
+      // when drag/dropping in a dashboard, a chart may be unmounted/remounted but still have data
+      this.renderViz();
     }
   }
 
@@ -93,14 +96,15 @@ class Chart extends React.PureComponent {
 
   componentDidUpdate(prevProps) {
     if (
-        this.props.queryResponse &&
-        ['success', 'rendered'].indexOf(this.props.chartStatus) > -1 &&
-        !this.props.queryResponse.error && (
+      this.props.queryResponse &&
+      ['success', 'rendered'].indexOf(this.props.chartStatus) > -1 &&
+      !this.props.queryResponse.error && (
         prevProps.annotationData !== this.props.annotationData ||
         prevProps.queryResponse !== this.props.queryResponse ||
         prevProps.height !== this.props.height ||
         prevProps.width !== this.props.width ||
-        prevProps.lastRendered !== this.props.lastRendered)
+        prevProps.lastRendered !== this.props.lastRendered
+      )
     ) {
       this.renderViz();
     }
@@ -118,10 +122,6 @@ class Chart extends React.PureComponent {
     this.props.addFilter(col, vals, merge, refresh);
   }
 
-  clearFilter() {
-    this.props.clearFilter();
-  }
-
   removeFilter(col, vals, refresh = true) {
     this.props.removeFilter(col, vals, refresh);
   }
@@ -131,7 +131,8 @@ class Chart extends React.PureComponent {
   }
 
   width() {
-    return this.props.width || this.container.el.offsetWidth;
+    return this.props.width ||
+      (this.container && this.container.el && this.container.el.offsetWidth);
   }
 
   headerHeight() {
@@ -139,7 +140,8 @@ class Chart extends React.PureComponent {
   }
 
   height() {
-    return this.props.height || this.container.el.offsetHeight;
+    return this.props.height
+      || (this.container && this.container.el && this.container.el.offsetHeight);
   }
 
   d3format(col, number) {
@@ -150,7 +152,7 @@ class Chart extends React.PureComponent {
   }
 
   error(e) {
-    this.props.actions.chartRenderingFailed(e, this.props.chartKey);
+    this.props.actions.chartRenderingFailed(e, this.props.chartId);
   }
 
   verboseMetricName(metric) {
@@ -167,7 +169,6 @@ class Chart extends React.PureComponent {
 
   renderTooltip() {
     if (this.state.tooltip) {
-      /* eslint-disable react/no-danger */
       return (
         <Tooltip
           className="chart-tooltip"
@@ -177,52 +178,55 @@ class Chart extends React.PureComponent {
           positionLeft={this.state.tooltip.x + 30}
           arrowOffsetTop={10}
         >
-          <div dangerouslySetInnerHTML={{ __html: this.state.tooltip.content }} />
+          <div // eslint-disable-next-line react/no-danger
+            dangerouslySetInnerHTML={{ __html: this.state.tooltip.content }}
+          />
         </Tooltip>
       );
-      /* eslint-enable react/no-danger */
     }
     return null;
   }
 
   renderViz() {
-    const viz = visMap[this.props.vizType];
-    const fd = this.props.formData;
-    const qr = this.props.queryResponse;
+    const { vizType, formData, queryResponse, setControlValue, chartId, chartStatus } = this.props;
+    const visRenderer = visMap[vizType];
     const renderStart = Logger.getTimestamp();
     try {
       // Executing user-defined data mutator function
-      if (fd.js_data) {
-        qr.data = sandboxedEval(fd.js_data)(qr.data);
+      if (formData.js_data) {
+        queryResponse.data = sandboxedEval(formData.js_data)(queryResponse.data);
+      }
+      visRenderer(this, queryResponse, setControlValue);
+      if (chartStatus !== 'rendered') {
+        this.props.actions.chartRenderingSucceeded(chartId);
       }
-      // [re]rendering the visualization
-      viz(this, qr, this.props.setControlValue);
       Logger.append(LOG_ACTIONS_RENDER_EVENT, {
-        label: this.props.chartKey,
-        vis_type: this.props.vizType,
+        label: 'slice_' + chartId,
+        vis_type: vizType,
         start_offset: renderStart,
         duration: Logger.getTimestamp() - renderStart,
       });
-      this.props.actions.chartRenderingSucceeded(this.props.chartKey);
+      this.props.actions.chartRenderingSucceeded(chartId);
     } catch (e) {
-      this.props.actions.chartRenderingFailed(e, this.props.chartKey);
+      console.error(e); // eslint-disable-line no-console
+      this.props.actions.chartRenderingFailed(e, chartId);
     }
   }
 
   render() {
     const isLoading = this.props.chartStatus === 'loading';
+
+    // this allows <Loading /> to be positioned in the middle of the chart
+    const containerStyles = isLoading ? { height: this.height(), width: this.width() } : null;
     return (
-      <div className={`token col-md-12 ${isLoading ? 'is-loading' : ''}`}>
+      <div className={`chart-container ${isLoading ? 'is-loading' : ''}`} style={containerStyles}>
         {this.renderTooltip()}
-        {isLoading &&
-          <Loading size={25} />
-        }
+        {isLoading && <Loading size={75} />}
         {this.props.chartAlert &&
-        <StackTraceMessage
-          message={this.props.chartAlert}
-          queryResponse={this.props.queryResponse}
-        />
-        }
+          <StackTraceMessage
+            message={this.props.chartAlert}
+            queryResponse={this.props.queryResponse}
+          />}
 
         {!isLoading &&
           !this.props.chartAlert &&
@@ -234,8 +238,8 @@ class Chart extends React.PureComponent {
             width={this.width()}
             onQuery={this.props.onQuery}
             onDismiss={this.props.onDismissRefreshOverlay}
-          />
-        }
+          />}
+
         {!isLoading && !this.props.chartAlert &&
           <ChartBody
             containerId={this.containerId}
diff --git a/superset/assets/src/chart/ChartContainer.jsx b/superset/assets/src/chart/ChartContainer.jsx
index b731412fc5..b66fe5d017 100644
--- a/superset/assets/src/chart/ChartContainer.jsx
+++ b/superset/assets/src/chart/ChartContainer.jsx
@@ -1,29 +1,13 @@
 import { connect } from 'react-redux';
 import { bindActionCreators } from 'redux';
 
-import * as Actions from './chartAction';
+import * as actions from './chartAction';
 import Chart from './Chart';
 
-function mapStateToProps({ charts }, ownProps) {
-  const chart = charts[ownProps.chartKey];
-  return {
-    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,
-  };
-}
-
 function mapDispatchToProps(dispatch) {
   return {
-    actions: bindActionCreators(Actions, dispatch),
+    actions: bindActionCreators(actions, dispatch),
   };
 }
 
-export default connect(mapStateToProps, mapDispatchToProps)(Chart);
+export default connect(null, mapDispatchToProps)(Chart);
diff --git a/superset/assets/src/chart/chartAction.js b/superset/assets/src/chart/chartAction.js
index a2f01165d6..4f9a4d3ab4 100644
--- a/superset/assets/src/chart/chartAction.js
+++ b/superset/assets/src/chart/chartAction.js
@@ -112,6 +112,11 @@ export function updateQueryFormData(value, key) {
   return { type: UPDATE_QUERY_FORM_DATA, value, key };
 }
 
+export const ADD_CHART = 'ADD_CHART';
+export function addChart(chart, key) {
+  return { type: ADD_CHART, chart, key };
+}
+
 export const RUN_QUERY = 'RUN_QUERY';
 export function runQuery(formData, force = false, timeout = 60, key) {
   return (dispatch) => {
@@ -134,7 +139,7 @@ export function runQuery(formData, force = false, timeout = 60, key) {
       .then(() => queryRequest)
       .then((queryResponse) => {
         Logger.append(LOG_ACTIONS_LOAD_EVENT, {
-          label: key,
+          label: 'slice_' + key,
           is_cached: queryResponse.is_cached,
           row_count: queryResponse.rowcount,
           datasource: formData.datasource,
@@ -185,3 +190,9 @@ export function runQuery(formData, force = false, timeout = 60, key) {
     ]);
   };
 }
+
+export function refreshChart(chart, force, timeout) {
+  return dispatch => (
+    dispatch(runQuery(chart.latestQueryFormData, force, timeout, chart.id))
+  );
+}
diff --git a/superset/assets/src/chart/chartReducer.js b/superset/assets/src/chart/chartReducer.js
index f68a5b80ee..ea8de8b54d 100644
--- a/superset/assets/src/chart/chartReducer.js
+++ b/superset/assets/src/chart/chartReducer.js
@@ -1,25 +1,10 @@
 /* eslint camelcase: 0 */
-import PropTypes from 'prop-types';
-
 import { now } from '../modules/dates';
 import * as actions from './chartAction';
 import { t } from '../locales';
 
-export const chartPropType = {
-  chartKey: PropTypes.string.isRequired,
-  chartAlert: PropTypes.string,
-  chartStatus: PropTypes.string,
-  chartUpdateEndTime: PropTypes.number,
-  chartUpdateStartTime: PropTypes.number,
-  latestQueryFormData: PropTypes.object,
-  queryRequest: PropTypes.object,
-  queryResponse: PropTypes.object,
-  triggerQuery: PropTypes.bool,
-  lastRendered: PropTypes.number,
-};
-
 export const chart = {
-  chartKey: '',
+  id: 0,
   chartAlert: null,
   chartStatus: 'loading',
   chartUpdateEndTime: null,
@@ -33,6 +18,12 @@ export const chart = {
 
 export default function chartReducer(charts = {}, action) {
   const actionHandlers = {
+    [actions.ADD_CHART]() {
+      return {
+        ...chart,
+        ...action.chart,
+      };
+    },
     [actions.CHART_UPDATE_SUCCEEDED](state) {
       return { ...state,
         chartStatus: 'success',
@@ -70,12 +61,12 @@ export default function chartReducer(charts = {}, action) {
       return { ...state,
         chartStatus: 'failed',
         chartAlert: (
-            `${t('Query timeout')} - ` +
-            t(`visualization queries are set to timeout at ${action.timeout} seconds. `) +
-            t('Perhaps your data has grown, your database is under unusual load, ' +
-                'or you are simply querying a data source that is too large ' +
-                'to be processed within the timeout range. ' +
-                'If that is the case, we recommend that you summarize your data further.')),
+          `${t('Query timeout')} - ` +
+          t(`visualization queries are set to timeout at ${action.timeout} seconds. `) +
+          t('Perhaps your data has grown, your database is under unusual load, ' +
+            'or you are simply querying a data source that is too large ' +
+            'to be processed within the timeout range. ' +
+            'If that is the case, we recommend that you summarize your data further.')),
       };
     },
     [actions.CHART_UPDATE_FAILED](state) {
@@ -151,7 +142,10 @@ export default function chartReducer(charts = {}, action) {
   }
 
   if (action.type in actionHandlers) {
-    return { ...charts, [action.key]: actionHandlers[action.type](charts[action.key], action) };
+    return {
+      ...charts,
+      [action.key]: actionHandlers[action.type](charts[action.key], action),
+    };
   }
 
   return charts;
diff --git a/superset/assets/src/components/ActionMenuItem.jsx b/superset/assets/src/components/ActionMenuItem.jsx
new file mode 100644
index 0000000000..e6c44478b6
--- /dev/null
+++ b/superset/assets/src/components/ActionMenuItem.jsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { MenuItem } from 'react-bootstrap';
+
+import InfoTooltipWithTrigger from './InfoTooltipWithTrigger';
+
+export function MenuItemContent({ faIcon, text, tooltip, children }) {
+  return (
+    <span>
+      {faIcon && <i className={`fa fa-${faIcon}`}>&nbsp;</i>}
+      {text} {''}
+      <InfoTooltipWithTrigger
+        tooltip={tooltip}
+        label={faIcon ? `dash-${faIcon}` : ''}
+        placement="top"
+      />
+      {children}
+    </span>
+  );
+}
+
+MenuItemContent.propTypes = {
+  faIcon: PropTypes.string,
+  text: PropTypes.string,
+  tooltip: PropTypes.string,
+  children: PropTypes.node,
+};
+
+MenuItemContent.defaultProps = {
+  faIcon: '',
+  text: '',
+  tooltip: null,
+  children: null,
+};
+
+export function ActionMenuItem({
+  onClick,
+  href,
+  target,
+  text,
+  tooltip,
+  children,
+  faIcon,
+}) {
+  return (
+    <MenuItem onClick={onClick} href={href} target={target}>
+      <MenuItemContent faIcon={faIcon} text={text} tooltip={tooltip}>
+        {children}
+      </MenuItemContent>
+    </MenuItem>
+  );
+}
+
+ActionMenuItem.propTypes = {
+  onClick: PropTypes.func,
+  href: PropTypes.string,
+  target: PropTypes.string,
+  ...MenuItemContent.propTypes,
+};
+
+ActionMenuItem.defaultProps = {
+  onClick() {},
+  href: null,
+  target: null,
+};
diff --git a/superset/assets/src/components/EditableTitle.jsx b/superset/assets/src/components/EditableTitle.jsx
index b773340846..45fea1dcb0 100644
--- a/superset/assets/src/components/EditableTitle.jsx
+++ b/superset/assets/src/components/EditableTitle.jsx
@@ -1,5 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import cx from 'classnames';
 import TooltipWrapper from './TooltipWrapper';
 import { t } from '../locales';
 
@@ -27,8 +28,10 @@ class EditableTitle extends React.PureComponent {
     this.handleClick = this.handleClick.bind(this);
     this.handleBlur = this.handleBlur.bind(this);
     this.handleChange = this.handleChange.bind(this);
+    this.handleKeyUp = this.handleKeyUp.bind(this);
     this.handleKeyPress = this.handleKeyPress.bind(this);
   }
+
   componentWillReceiveProps(nextProps) {
     if (nextProps.title !== this.state.title) {
       this.setState({
@@ -37,8 +40,9 @@ class EditableTitle extends React.PureComponent {
       });
     }
   }
+
   handleClick() {
-    if (!this.props.canEdit) {
+    if (!this.props.canEdit || this.state.isEditing) {
       return;
     }
 
@@ -46,6 +50,7 @@ class EditableTitle extends React.PureComponent {
       isEditing: true,
     });
   }
+
   handleBlur() {
     if (!this.props.canEdit) {
       return;
@@ -67,9 +72,31 @@ class EditableTitle extends React.PureComponent {
       this.setState({
         lastTitle: this.state.title,
       });
+    }
+
+    if (this.props.title !== this.state.title) {
       this.props.onSaveTitle(this.state.title);
     }
   }
+
+  handleKeyUp(ev) {
+    // this entire method exists to support using EditableTitle as the title of a
+    // react-bootstrap Tab, as a workaround for this line in react-bootstrap https://goo.gl/ZVLmv4
+    //
+    // tl;dr when a Tab EditableTitle is being edited, typically the Tab it's within has been
+    // clicked and is focused/active. for accessibility, when focused the Tab <a /> intercepts
+    // the ' ' key (among others, including all arrows) and onChange() doesn't fire. somehow
+    // keydown is still called so we can detect this and manually add a ' ' to the current title
+    if (ev.key === ' ') {
+      let title = ev.target.value;
+      const titleLength = (title || '').length;
+      if (title && title[titleLength - 1] !== ' ') {
+        title = `${title} `;
+        this.setState(() => ({ title }));
+      }
+    }
+  }
+
   handleChange(ev) {
     if (!this.props.canEdit) {
       return;
@@ -79,6 +106,7 @@ class EditableTitle extends React.PureComponent {
       title: ev.target.value,
     });
   }
+
   handleKeyPress(ev) {
     if (ev.key === 'Enter') {
       ev.preventDefault();
@@ -86,12 +114,14 @@ class EditableTitle extends React.PureComponent {
       this.handleBlur();
     }
   }
+
   render() {
-    let input = (
+    let content = (
       <input
         required
         type={this.state.isEditing ? 'text' : 'button'}
         value={this.state.title}
+        onKeyUp={this.handleKeyUp}
         onChange={this.handleChange}
         onBlur={this.handleBlur}
         onClick={this.handleClick}
@@ -99,18 +129,26 @@ class EditableTitle extends React.PureComponent {
       />
     );
     if (this.props.showTooltip) {
-      input = (
+      content = (
         <TooltipWrapper
           label="title"
           tooltip={this.props.canEdit ? t('click to edit title') :
               this.props.noPermitTooltip || t('You don\'t have the rights to alter this title.')}
         >
-          {input}
+          {content}
         </TooltipWrapper>
       );
     }
     return (
-      <span className="editable-title">{input}</span>
+      <span
+        className={cx(
+          'editable-title',
+          this.props.canEdit && 'editable-title--editable',
+          this.state.isEditing && 'editable-title--editing',
+        )}
+      >
+        {content}
+      </span>
     );
   }
 }
diff --git a/superset/assets/src/components/Loading.jsx b/superset/assets/src/components/Loading.jsx
index 416e770295..810c5819cb 100644
--- a/superset/assets/src/components/Loading.jsx
+++ b/superset/assets/src/components/Loading.jsx
@@ -20,6 +20,9 @@ export default function Loading(props) {
         padding: 0,
         margin: 0,
         position: 'absolute',
+        left: '50%',
+        top: '50%',
+        transform: 'translate(-50%, -60%)',
       }}
     />
   );
diff --git a/superset/assets/src/dashboard/.eslintrc b/superset/assets/src/dashboard/.eslintrc
new file mode 100644
index 0000000000..a3f86e3a17
--- /dev/null
+++ b/superset/assets/src/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/src/dashboard/.prettierrc b/superset/assets/src/dashboard/.prettierrc
new file mode 100644
index 0000000000..a20502b7f0
--- /dev/null
+++ b/superset/assets/src/dashboard/.prettierrc
@@ -0,0 +1,4 @@
+{
+  "singleQuote": true,
+  "trailingComma": "all"
+}
diff --git a/superset/assets/src/dashboard/actions.js b/superset/assets/src/dashboard/actions.js
deleted file mode 100644
index c7f1a6aa26..0000000000
--- a/superset/assets/src/dashboard/actions.js
+++ /dev/null
@@ -1,127 +0,0 @@
-/* global notify */
-import $ from 'jquery';
-import { getExploreUrlAndPayload } from '../explore/exploreUtils';
-
-export const ADD_FILTER = 'ADD_FILTER';
-export function addFilter(sliceId, col, vals, merge = true, refresh = true) {
-  return { type: ADD_FILTER, sliceId, col, vals, merge, refresh };
-}
-
-export const CLEAR_FILTER = 'CLEAR_FILTER';
-export function clearFilter(sliceId) {
-  return { type: CLEAR_FILTER, sliceId };
-}
-
-export const REMOVE_FILTER = 'REMOVE_FILTER';
-export function removeFilter(sliceId, col, vals, refresh = true) {
-  return { type: REMOVE_FILTER, sliceId, col, vals, refresh };
-}
-
-export const UPDATE_DASHBOARD_LAYOUT = 'UPDATE_DASHBOARD_LAYOUT';
-export function updateDashboardLayout(layout) {
-  return { type: UPDATE_DASHBOARD_LAYOUT, layout };
-}
-
-export const UPDATE_DASHBOARD_TITLE = 'UPDATE_DASHBOARD_TITLE';
-export function updateDashboardTitle(title) {
-  return { type: UPDATE_DASHBOARD_TITLE, title };
-}
-
-export function addSlicesToDashboard(dashboardId, sliceIds) {
-  return () => (
-    $.ajax({
-      type: 'POST',
-      url: `/superset/add_slices/${dashboardId}/`,
-      data: {
-        data: JSON.stringify({ slice_ids: sliceIds }),
-      },
-    })
-      .done(() => {
-        // Refresh page to allow for slices to re-render
-        window.location.reload();
-      })
-  );
-}
-
-export const REMOVE_SLICE = 'REMOVE_SLICE';
-export function removeSlice(slice) {
-  return { type: REMOVE_SLICE, slice };
-}
-
-export const UPDATE_SLICE_NAME = 'UPDATE_SLICE_NAME';
-export function updateSliceName(slice, sliceName) {
-  return { type: UPDATE_SLICE_NAME, slice, sliceName };
-}
-export function saveSlice(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, payload } = getExploreUrlAndPayload({
-      formData: slice.form_data,
-      endpointType: 'base',
-      force: false,
-      curUrl: null,
-      requestParams: sliceParams,
-    });
-    return $.ajax({
-      url,
-      type: 'POST',
-      data: {
-        form_data: JSON.stringify(payload),
-      },
-      success: () => {
-        dispatch(updateSliceName(slice, sliceName));
-        notify.success('This slice name was saved successfully.');
-      },
-      error: () => {
-        // if server-side reject the overwrite action,
-        // revert to old state
-        dispatch(updateSliceName(slice, oldName));
-        notify.error("You don't have the rights to alter this slice");
-      },
-    });
-  };
-}
-
-const FAVESTAR_BASE_URL = '/superset/favstar/Dashboard';
-export const TOGGLE_FAVE_STAR = 'TOGGLE_FAVE_STAR';
-export function toggleFaveStar(isStarred) {
-  return { type: TOGGLE_FAVE_STAR, isStarred };
-}
-
-export const FETCH_FAVE_STAR = 'FETCH_FAVE_STAR';
-export function fetchFaveStar(id) {
-  return function (dispatch) {
-    const url = `${FAVESTAR_BASE_URL}/${id}/count`;
-    return $.get(url)
-      .done((data) => {
-        if (data.count > 0) {
-          dispatch(toggleFaveStar(true));
-        }
-      });
-  };
-}
-
-export const SAVE_FAVE_STAR = 'SAVE_FAVE_STAR';
-export function saveFaveStar(id, isStarred) {
-  return function (dispatch) {
-    const urlSuffix = isStarred ? 'unselect' : 'select';
-    const url = `${FAVESTAR_BASE_URL}/${id}/${urlSuffix}/`;
-    $.get(url);
-    dispatch(toggleFaveStar(!isStarred));
-  };
-}
-
-export const TOGGLE_EXPAND_SLICE = 'TOGGLE_EXPAND_SLICE';
-export function toggleExpandSlice(slice, isExpanded) {
-  return { type: TOGGLE_EXPAND_SLICE, slice, isExpanded };
-}
-
-export const SET_EDIT_MODE = 'SET_EDIT_MODE';
-export function setEditMode(editMode) {
-  return { type: SET_EDIT_MODE, editMode };
-}
diff --git a/superset/assets/src/dashboard/actions/dashboardLayout.js b/superset/assets/src/dashboard/actions/dashboardLayout.js
new file mode 100644
index 0000000000..d210ee64f3
--- /dev/null
+++ b/superset/assets/src/dashboard/actions/dashboardLayout.js
@@ -0,0 +1,221 @@
+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';
+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';
+
+// this is a helper that takes an action as input and dispatches
+// an additional setUnsavedChanges(true) action after the dispatch in the case
+// that dashboardState.hasUnsavedChanges is false.
+function setUnsavedChangesAfterAction(action) {
+  return (...args) => (dispatch, getState) => {
+    const result = action(...args);
+    if (typeof result === 'function') {
+      dispatch(result(dispatch, getState));
+    } else {
+      dispatch(result);
+    }
+
+    if (!getState().dashboardState.hasUnsavedChanges) {
+      dispatch(setUnsavedChanges(true));
+    }
+  };
+}
+
+// Component CRUD -------------------------------------------------------------
+export const UPDATE_COMPONENTS = 'UPDATE_COMPONENTS';
+
+export const updateComponents = setUnsavedChangesAfterAction(
+  nextComponents => ({
+    type: UPDATE_COMPONENTS,
+    payload: {
+      nextComponents,
+    },
+  }),
+);
+
+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 const deleteComponent = setUnsavedChangesAfterAction((id, parentId) => ({
+  type: DELETE_COMPONENT,
+  payload: {
+    id,
+    parentId,
+  },
+}));
+
+export const CREATE_COMPONENT = 'CREATE_COMPONENT';
+export const createComponent = setUnsavedChangesAfterAction(dropResult => ({
+  type: CREATE_COMPONENT,
+  payload: {
+    dropResult,
+  },
+}));
+
+// Tabs -----------------------------------------------------------------------
+export const CREATE_TOP_LEVEL_TABS = 'CREATE_TOP_LEVEL_TABS';
+export const createTopLevelTabs = setUnsavedChangesAfterAction(dropResult => ({
+  type: CREATE_TOP_LEVEL_TABS,
+  payload: {
+    dropResult,
+  },
+}));
+
+export const DELETE_TOP_LEVEL_TABS = 'DELETE_TOP_LEVEL_TABS';
+export const deleteTopLevelTabs = setUnsavedChangesAfterAction(() => ({
+  type: DELETE_TOP_LEVEL_TABS,
+  payload: {},
+}));
+
+// Resize ---------------------------------------------------------------------
+export const RESIZE_COMPONENT = 'RESIZE_COMPONENT';
+export function resizeComponent({ id, width, height }) {
+  return (dispatch, getState) => {
+    const { dashboardLayout: undoableLayout } = getState();
+    const { present: dashboard } = undoableLayout;
+    const component = dashboard[id];
+    const widthChanged = width && component.meta.width !== width;
+    const heightChanged = height && component.meta.height !== height;
+    if (component && (widthChanged || heightChanged)) {
+      // update the size of this component
+      const updatedComponents = {
+        [id]: {
+          ...component,
+          meta: {
+            ...component.meta,
+            width: width || component.meta.width,
+            height: height || component.meta.height,
+          },
+        },
+      };
+
+      // set any resizable children to have a minimum width so that
+      // the chances that they are validly movable to future containers is maximized
+      component.children.forEach(childId => {
+        const child = dashboard[childId];
+        if ([CHART_TYPE, MARKDOWN_TYPE].includes(child.type)) {
+          updatedComponents[childId] = {
+            ...child,
+            meta: {
+              ...child.meta,
+              width: GRID_MIN_COLUMN_COUNT,
+              height: height || child.meta.height,
+            },
+          };
+        }
+      });
+
+      dispatch(updateComponents(updatedComponents));
+    }
+  };
+}
+
+// Drag and drop --------------------------------------------------------------
+export const MOVE_COMPONENT = 'MOVE_COMPONENT';
+function moveComponent(dropResult) {
+  return {
+    type: MOVE_COMPONENT,
+    payload: {
+      dropResult,
+    },
+  };
+}
+
+export const HANDLE_COMPONENT_DROP = 'HANDLE_COMPONENT_DROP';
+export function handleComponentDrop(dropResult) {
+  return (dispatch, getState) => {
+    const overflowsParent = dropOverflowsParent(
+      dropResult,
+      getState().dashboardLayout.present,
+    );
+
+    if (overflowsParent) {
+      return dispatch(
+        addInfoToast(
+          `Parent does not have enough space for this component.
+         Try decreasing its width or add it to a new row.`,
+        ),
+      );
+    }
+
+    const { source, destination } = dropResult;
+    const droppedOnRoot = destination && destination.id === DASHBOARD_ROOT_ID;
+    const isNewComponent = source.id === NEW_COMPONENTS_SOURCE_ID;
+
+    if (droppedOnRoot) {
+      dispatch(createTopLevelTabs(dropResult));
+    } else if (destination && isNewComponent) {
+      dispatch(createComponent(dropResult));
+    } else if (
+      destination &&
+      source &&
+      !// ensure it has moved
+      (destination.id === source.id && destination.index === source.index)
+    ) {
+      dispatch(moveComponent(dropResult));
+    }
+
+    const { dashboardLayout: undoableLayout } = getState();
+
+    // if we moved a Tab and the parent Tabs no longer has children, delete it.
+    if (!isNewComponent) {
+      const { present: layout } = undoableLayout;
+      const sourceComponent = layout[source.id];
+      if (
+        sourceComponent.type === TABS_TYPE &&
+        sourceComponent.children.length === 0
+      ) {
+        const parentId = findParentId({
+          childId: source.id,
+          layout,
+        });
+        dispatch(deleteComponent(source.id, parentId));
+      }
+    }
+
+    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 const redoLayoutAction = setUnsavedChangesAfterAction(
+  UndoActionCreators.redo,
+);
diff --git a/superset/assets/src/dashboard/actions/dashboardState.js b/superset/assets/src/dashboard/actions/dashboardState.js
new file mode 100644
index 0000000000..42f68ad381
--- /dev/null
+++ b/superset/assets/src/dashboard/actions/dashboardState.js
@@ -0,0 +1,242 @@
+/* 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 { getAjaxErrorMsg } from '../../modules/utils';
+import { SAVE_TYPE_OVERWRITE } from '../util/constants';
+import { t } from '../../locales';
+
+import {
+  addSuccessToast,
+  addWarningToast,
+  addDangerToast,
+} from './messageToasts';
+
+export const SET_UNSAVED_CHANGES = 'SET_UNSAVED_CHANGES';
+export function setUnsavedChanges(hasUnsavedChanges) {
+  return { type: SET_UNSAVED_CHANGES, payload: { hasUnsavedChanges } };
+}
+
+export const ADD_FILTER = 'ADD_FILTER';
+export function addFilter(chart, col, vals, merge = true, refresh = true) {
+  return { type: ADD_FILTER, chart, col, vals, merge, refresh };
+}
+
+export const REMOVE_FILTER = 'REMOVE_FILTER';
+export function removeFilter(sliceId, col, vals, refresh = true) {
+  return { type: REMOVE_FILTER, sliceId, col, vals, refresh };
+}
+
+export const ADD_SLICE = 'ADD_SLICE';
+export function addSlice(slice) {
+  return { type: ADD_SLICE, slice };
+}
+
+export const REMOVE_SLICE = 'REMOVE_SLICE';
+export function removeSlice(sliceId) {
+  return { type: REMOVE_SLICE, sliceId };
+}
+
+const FAVESTAR_BASE_URL = '/superset/favstar/Dashboard';
+export const TOGGLE_FAVE_STAR = 'TOGGLE_FAVE_STAR';
+export function toggleFaveStar(isStarred) {
+  return { type: TOGGLE_FAVE_STAR, isStarred };
+}
+
+export const FETCH_FAVE_STAR = 'FETCH_FAVE_STAR';
+export function fetchFaveStar(id) {
+  return function fetchFaveStarThunk(dispatch) {
+    const url = `${FAVESTAR_BASE_URL}/${id}/count`;
+    return $.get(url).done(data => {
+      if (data.count > 0) {
+        dispatch(toggleFaveStar(true));
+      }
+    });
+  };
+}
+
+export const SAVE_FAVE_STAR = 'SAVE_FAVE_STAR';
+export function saveFaveStar(id, isStarred) {
+  return function saveFaveStarThunk(dispatch) {
+    const urlSuffix = isStarred ? 'unselect' : 'select';
+    const url = `${FAVESTAR_BASE_URL}/${id}/${urlSuffix}/`;
+    $.get(url);
+    dispatch(toggleFaveStar(!isStarred));
+  };
+}
+
+export const TOGGLE_EXPAND_SLICE = 'TOGGLE_EXPAND_SLICE';
+export function toggleExpandSlice(sliceId) {
+  return { type: TOGGLE_EXPAND_SLICE, sliceId };
+}
+
+export const UPDATE_CSS = 'UPDATE_CSS';
+export function updateCss(css) {
+  return { type: UPDATE_CSS, css };
+}
+
+export const SET_EDIT_MODE = 'SET_EDIT_MODE';
+export function setEditMode(editMode) {
+  return { type: SET_EDIT_MODE, editMode };
+}
+
+export const ON_CHANGE = 'ON_CHANGE';
+export function onChange() {
+  return { type: ON_CHANGE };
+}
+
+export const ON_SAVE = 'ON_SAVE';
+export function onSave() {
+  return { type: ON_SAVE };
+}
+
+export function saveDashboardRequestSuccess() {
+  return dispatch => {
+    dispatch(onSave());
+    // clear layout undo history
+    dispatch(UndoActionCreators.clearHistory());
+  };
+}
+
+export function saveDashboardRequest(data, id, saveType) {
+  const path = saveType === SAVE_TYPE_OVERWRITE ? 'save_dash' : 'copy_dash';
+  const url = `/superset/${path}/${id}/`;
+  return dispatch =>
+    $.ajax({
+      type: 'POST',
+      url,
+      data: {
+        data: JSON.stringify(data),
+      },
+      success: () => {
+        dispatch(saveDashboardRequestSuccess());
+        dispatch(addSuccessToast(t('This dashboard was saved successfully.')));
+      },
+      error: error => {
+        const errorMsg = getAjaxErrorMsg(error);
+        dispatch(
+          addDangerToast(
+            `${t('Sorry, there was an error saving this dashboard: ')}
+          ${errorMsg}`,
+          ),
+        );
+      },
+    });
+}
+
+export function fetchCharts(chartList = [], force = false, interval = 0) {
+  return (dispatch, getState) => {
+    const timeout = getState().dashboardInfo.common.conf
+      .SUPERSET_WEBSERVER_TIMEOUT;
+    if (!interval) {
+      chartList.forEach(chart => dispatch(refreshChart(chart, force, timeout)));
+      return;
+    }
+
+    const { metadata: meta } = getState().dashboardInfo;
+    const refreshTime = Math.max(interval, meta.stagger_time || 5000); // default 5 seconds
+    if (typeof meta.stagger_refresh !== 'boolean') {
+      meta.stagger_refresh =
+        meta.stagger_refresh === undefined
+          ? true
+          : meta.stagger_refresh === 'true';
+    }
+    const delay = meta.stagger_refresh
+      ? refreshTime / (chartList.length - 1)
+      : 0;
+    chartList.forEach((chart, i) => {
+      setTimeout(
+        () => dispatch(refreshChart(chart, force, timeout)),
+        delay * i,
+      );
+    });
+  };
+}
+
+let refreshTimer = null;
+export function startPeriodicRender(interval) {
+  const stopPeriodicRender = () => {
+    if (refreshTimer) {
+      clearTimeout(refreshTimer);
+      refreshTimer = null;
+    }
+  };
+
+  return (dispatch, getState) => {
+    stopPeriodicRender();
+
+    const { metadata } = getState().dashboardInfo;
+    const immune = metadata.timed_refresh_immune_slices || [];
+    const refreshAll = () => {
+      const affected = Object.values(getState().charts).filter(
+        chart => immune.indexOf(chart.id) === -1,
+      );
+      return dispatch(fetchCharts(affected, true, interval * 0.2));
+    };
+    const fetchAndRender = () => {
+      refreshAll();
+      if (interval > 0) {
+        refreshTimer = setTimeout(fetchAndRender, interval);
+      }
+    };
+
+    fetchAndRender();
+  };
+}
+
+export const TOGGLE_BUILDER_PANE = 'TOGGLE_BUILDER_PANE';
+export function toggleBuilderPane() {
+  return { type: TOGGLE_BUILDER_PANE };
+}
+
+export function addSliceToDashboard(id) {
+  return (dispatch, getState) => {
+    const { sliceEntities } = getState();
+    const selectedSlice = sliceEntities.slices[id];
+    const form_data = selectedSlice.form_data;
+    const newChart = {
+      ...initChart,
+      id,
+      form_data,
+      formData: applyDefaultFormData(form_data),
+    };
+
+    return Promise.all([
+      dispatch(addChart(newChart, id)),
+      dispatch(fetchDatasourceMetadata(form_data.datasource)),
+    ]).then(() => dispatch(addSlice(selectedSlice)));
+  };
+}
+
+export function removeSliceFromDashboard(id) {
+  return dispatch => {
+    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/datasources.js b/superset/assets/src/dashboard/actions/datasources.js
new file mode 100644
index 0000000000..d97296e9e8
--- /dev/null
+++ b/superset/assets/src/dashboard/actions/datasources.js
@@ -0,0 +1,36 @@
+import $ from 'jquery';
+
+export const SET_DATASOURCE = 'SET_DATASOURCE';
+export function setDatasource(datasource, key) {
+  return { type: SET_DATASOURCE, datasource, key };
+}
+
+export const FETCH_DATASOURCE_STARTED = 'FETCH_DATASOURCE_STARTED';
+export function fetchDatasourceStarted(key) {
+  return { type: FETCH_DATASOURCE_STARTED, key };
+}
+
+export const FETCH_DATASOURCE_FAILED = 'FETCH_DATASOURCE_FAILED';
+export function fetchDatasourceFailed(error, key) {
+  return { type: FETCH_DATASOURCE_FAILED, error, key };
+}
+
+export function fetchDatasourceMetadata(key) {
+  return (dispatch, getState) => {
+    const { datasources } = getState();
+    const datasource = datasources[key];
+
+    if (datasource) {
+      return dispatch(setDatasource(datasource, key));
+    }
+
+    const url = `/superset/fetch_datasource_metadata?datasourceKey=${key}`;
+    return $.ajax({
+      type: 'GET',
+      url,
+      success: data => dispatch(setDatasource(data, key)),
+      error: error =>
+        dispatch(fetchDatasourceFailed(error.responseJSON.error, key)),
+    });
+  };
+}
diff --git a/superset/assets/src/dashboard/actions/messageToasts.js b/superset/assets/src/dashboard/actions/messageToasts.js
new file mode 100644
index 0000000000..fde02c4102
--- /dev/null
+++ b/superset/assets/src/dashboard/actions/messageToasts.js
@@ -0,0 +1,55 @@
+import shortid from 'shortid';
+
+import {
+  INFO_TOAST,
+  SUCCESS_TOAST,
+  WARNING_TOAST,
+  DANGER_TOAST,
+} from '../util/constants';
+
+function getToastUuid(type) {
+  return `${type}-${shortid.generate()}`;
+}
+
+export const ADD_TOAST = 'ADD_TOAST';
+export function addToast({ toastType, text }) {
+  return {
+    type: ADD_TOAST,
+    payload: {
+      id: getToastUuid(toastType),
+      toastType,
+      text,
+    },
+  };
+}
+
+export const REMOVE_TOAST = 'REMOVE_TOAST';
+export function removeToast(id) {
+  return {
+    type: REMOVE_TOAST,
+    payload: {
+      id,
+    },
+  };
+}
+
+// Different types of toasts
+export const ADD_INFO_TOAST = 'ADD_INFO_TOAST';
+export function addInfoToast(text) {
+  return dispatch => dispatch(addToast({ text, toastType: INFO_TOAST }));
+}
+
+export const ADD_SUCCESS_TOAST = 'ADD_SUCCESS_TOAST';
+export function addSuccessToast(text) {
+  return dispatch => dispatch(addToast({ text, toastType: SUCCESS_TOAST }));
+}
+
+export const ADD_WARNING_TOAST = 'ADD_WARNING_TOAST';
+export function addWarningToast(text) {
+  return dispatch => dispatch(addToast({ text, toastType: WARNING_TOAST }));
+}
+
+export const ADD_DANGER_TOAST = 'ADD_DANGER_TOAST';
+export function addDangerToast(text) {
+  return dispatch => dispatch(addToast({ text, toastType: DANGER_TOAST }));
+}
diff --git a/superset/assets/src/dashboard/actions/sliceEntities.js b/superset/assets/src/dashboard/actions/sliceEntities.js
new file mode 100644
index 0000000000..b635ea05f5
--- /dev/null
+++ b/superset/assets/src/dashboard/actions/sliceEntities.js
@@ -0,0 +1,71 @@
+/* eslint camelcase: 0 */
+import $ from 'jquery';
+
+import { getDatasourceParameter } from '../../modules/utils';
+
+export const SET_ALL_SLICES = 'SET_ALL_SLICES';
+export function setAllSlices(slices) {
+  return { type: SET_ALL_SLICES, slices };
+}
+
+export const FETCH_ALL_SLICES_STARTED = 'FETCH_ALL_SLICES_STARTED';
+export function fetchAllSlicesStarted() {
+  return { type: FETCH_ALL_SLICES_STARTED };
+}
+
+export const FETCH_ALL_SLICES_FAILED = 'FETCH_ALL_SLICES_FAILED';
+export function fetchAllSlicesFailed(error) {
+  return { type: FETCH_ALL_SLICES_FAILED, error };
+}
+
+export function fetchAllSlices(userId) {
+  return (dispatch, getState) => {
+    const { sliceEntities } = getState();
+    if (sliceEntities.lastUpdated === 0) {
+      dispatch(fetchAllSlicesStarted());
+
+      const uri = `/sliceaddview/api/read?_flt_0_created_by=${userId}`;
+      return $.ajax({
+        url: uri,
+        type: 'GET',
+        success: response => {
+          const slices = {};
+          response.result.forEach(slice => {
+            let form_data = JSON.parse(slice.params);
+            let datasource = form_data.datasource;
+            if (!datasource) {
+              datasource = getDatasourceParameter(
+                slice.datasource_id,
+                slice.datasource_type,
+              );
+              form_data = {
+                ...form_data,
+                datasource,
+              };
+            }
+            if (['markup', 'separator'].indexOf(slice.viz_type) === -1) {
+              slices[slice.id] = {
+                slice_id: slice.id,
+                slice_url: slice.slice_url,
+                slice_name: slice.slice_name,
+                edit_url: slice.edit_url,
+                form_data,
+                datasource_name: slice.datasource_name_text,
+                datasource_link: slice.datasource_link,
+                changed_on: new Date(slice.changed_on).getTime(),
+                description: slice.description,
+                description_markdown: slice.description_markeddown,
+                viz_type: slice.viz_type,
+                modified: slice.modified,
+              };
+            }
+          });
+          return dispatch(setAllSlices(slices));
+        },
+        error: error => dispatch(fetchAllSlicesFailed(error)),
+      });
+    }
+
+    return dispatch(setAllSlices(sliceEntities.slices));
+  };
+}
diff --git a/superset/assets/src/dashboard/components/AddSliceCard.jsx b/superset/assets/src/dashboard/components/AddSliceCard.jsx
new file mode 100644
index 0000000000..7fd9ba4caa
--- /dev/null
+++ b/superset/assets/src/dashboard/components/AddSliceCard.jsx
@@ -0,0 +1,59 @@
+import cx from 'classnames';
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const propTypes = {
+  datasourceLink: PropTypes.string,
+  innerRef: PropTypes.func,
+  isSelected: PropTypes.bool,
+  lastModified: PropTypes.string.isRequired,
+  sliceName: PropTypes.string.isRequired,
+  style: PropTypes.object,
+  visType: PropTypes.string.isRequired,
+};
+
+const defaultProps = {
+  datasourceLink: '—',
+  innerRef: null,
+  isSelected: false,
+  style: null,
+};
+
+function AddSliceCard({
+  datasourceLink,
+  innerRef,
+  isSelected,
+  lastModified,
+  sliceName,
+  style,
+  visType,
+}) {
+  return (
+    <div ref={innerRef} className="chart-card-container" style={style}>
+      <div className={cx('chart-card', isSelected && 'is-selected')}>
+        <div className="card-title">{sliceName}</div>
+        <div className="card-body">
+          <div className="item">
+            <span>Modified </span>
+            <span>{lastModified}</span>
+          </div>
+          <div className="item">
+            <span>Visualization </span>
+            <span>{visType}</span>
+          </div>
+          <div className="item">
+            <span>Data source </span>
+            <span // eslint-disable-next-line react/no-danger
+              dangerouslySetInnerHTML={{ __html: datasourceLink }}
+            />
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+AddSliceCard.propTypes = propTypes;
+AddSliceCard.defaultProps = defaultProps;
+
+export default AddSliceCard;
diff --git a/superset/assets/src/dashboard/components/BuilderComponentPane.jsx b/superset/assets/src/dashboard/components/BuilderComponentPane.jsx
new file mode 100644
index 0000000000..c35a63756c
--- /dev/null
+++ b/superset/assets/src/dashboard/components/BuilderComponentPane.jsx
@@ -0,0 +1,108 @@
+/* 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 NewMarkdown from './gridComponents/new/NewMarkdown';
+import SliceAdder from '../containers/SliceAdder';
+import { t } from '../../locales';
+
+const propTypes = {
+  topOffset: PropTypes.number,
+  toggleBuilderPane: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+  topOffset: 0,
+};
+
+class BuilderComponentPane extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {
+      slideDirection: 'slide-out',
+    };
+
+    this.openSlicesPane = this.slide.bind(this, 'slide-in');
+    this.closeSlicesPane = this.slide.bind(this, 'slide-out');
+  }
+
+  slide(direction) {
+    this.setState({
+      slideDirection: direction,
+    });
+  }
+
+  render() {
+    const { topOffset } = this.props;
+    return (
+      <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">
+                    <span>{t('Insert')}</span>
+                    <i
+                      className="fa fa-times trigger"
+                      onClick={this.props.toggleBuilderPane}
+                      role="none"
+                    />
+                  </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>
+
+                    <i className="fa fa-arrow-right trigger" />
+                  </div>
+
+                  <NewTabs />
+                  <NewRow />
+                  <NewColumn />
+
+                  <NewHeader />
+                  <NewMarkdown />
+                  <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" />
+                    <span>{t('All components')}</span>
+                  </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/CodeModal.jsx b/superset/assets/src/dashboard/components/CodeModal.jsx
index 0e84ad1bab..cc0c9f2a41 100644
--- a/superset/assets/src/dashboard/components/CodeModal.jsx
+++ b/superset/assets/src/dashboard/components/CodeModal.jsx
@@ -12,13 +12,16 @@ const propTypes = {
 
 const defaultProps = {
   codeCallback: () => {},
+  code: '',
 };
 
 export default class CodeModal extends React.PureComponent {
   constructor(props) {
     super(props);
     this.state = { code: props.code };
+    this.beforeOpen = this.beforeOpen.bind(this);
   }
+
   beforeOpen() {
     let code = this.props.code;
     if (!code && this.props.codeCallback) {
@@ -26,18 +29,17 @@ export default class CodeModal extends React.PureComponent {
     }
     this.setState({ code });
   }
+
   render() {
     return (
       <ModalTrigger
         triggerNode={this.props.triggerNode}
         isButton
-        beforeOpen={this.beforeOpen.bind(this)}
+        beforeOpen={this.beforeOpen}
         modalTitle={t('Active Dashboard Filters')}
         modalBody={
           <div className="CodeModal">
-            <pre>
-              {this.state.code}
-            </pre>
+            <pre>{this.state.code}</pre>
           </div>
         }
       />
diff --git a/superset/assets/src/dashboard/components/Controls.jsx b/superset/assets/src/dashboard/components/Controls.jsx
index 00cb6d56dc..9d54b09e5a 100644
--- a/superset/assets/src/dashboard/components/Controls.jsx
+++ b/superset/assets/src/dashboard/components/Controls.jsx
@@ -1,76 +1,65 @@
+/* global window */
 import React from 'react';
 import PropTypes from 'prop-types';
+import $ from 'jquery';
 import { DropdownButton, MenuItem } from 'react-bootstrap';
 
 import CssEditor from './CssEditor';
 import RefreshIntervalModal from './RefreshIntervalModal';
-import SaveModal from './SaveModal';
-import SliceAdder from './SliceAdder';
 import { t } from '../../locales';
-import InfoTooltipWithTrigger from '../../components/InfoTooltipWithTrigger';
 
-const $ = window.$ = require('jquery');
+function updateDom(css) {
+  const className = 'CssEditor-css';
+  const head = document.head || document.getElementsByTagName('head')[0];
+  let style = document.querySelector(`.${className}`);
+
+  if (!style) {
+    style = document.createElement('style');
+    style.className = className;
+    style.type = 'text/css';
+    head.appendChild(style);
+  }
+  if (style.styleSheet) {
+    style.styleSheet.cssText = css;
+  } else {
+    style.innerHTML = css;
+  }
+}
 
 const propTypes = {
-  dashboard: PropTypes.object.isRequired,
-  filters: PropTypes.object.isRequired,
+  addSuccessToast: PropTypes.func.isRequired,
+  addDangerToast: PropTypes.func.isRequired,
+  dashboardInfo: PropTypes.object.isRequired,
+  dashboardTitle: PropTypes.string.isRequired,
+  css: PropTypes.string.isRequired,
   slices: PropTypes.array,
-  userId: PropTypes.string.isRequired,
-  addSlicesToDashboard: PropTypes.func,
-  onSave: PropTypes.func,
-  onChange: PropTypes.func,
-  renderSlices: PropTypes.func,
-  serialize: PropTypes.func,
-  startPeriodicRender: PropTypes.func,
+  onChange: PropTypes.func.isRequired,
+  updateCss: PropTypes.func.isRequired,
+  forceRefreshAllCharts: PropTypes.func.isRequired,
+  startPeriodicRender: PropTypes.func.isRequired,
   editMode: PropTypes.bool,
 };
 
-function MenuItemContent({ faIcon, text, tooltip, children }) {
-  return (
-    <span>
-      <i className={`fa fa-${faIcon}`} /> {text} {''}
-      <InfoTooltipWithTrigger
-        tooltip={tooltip}
-        label={`dash-${faIcon}`}
-        placement="top"
-      />
-      {children}
-    </span>
-  );
-}
-MenuItemContent.propTypes = {
-  faIcon: PropTypes.string.isRequired,
-  text: PropTypes.string,
-  tooltip: PropTypes.string,
-  children: PropTypes.node,
-};
-
-function ActionMenuItem(props) {
-  return (
-    <MenuItem onClick={props.onClick}>
-      <MenuItemContent {...props} />
-    </MenuItem>
-  );
-}
-ActionMenuItem.propTypes = {
-  onClick: PropTypes.func,
+const defaultProps = {
+  editMode: false,
+  slices: [],
 };
 
 class Controls extends React.PureComponent {
   constructor(props) {
     super(props);
     this.state = {
-      css: props.dashboard.css || '',
+      css: props.css,
       cssTemplates: [],
     };
-    this.refresh = this.refresh.bind(this);
-    this.toggleModal = this.toggleModal.bind(this);
-    this.updateDom = this.updateDom.bind(this);
+
+    this.changeCss = this.changeCss.bind(this);
   }
+
   componentWillMount() {
-    this.updateDom(this.state.css);
+    updateDom(this.state.css);
 
-    $.get('/csstemplateasyncmodelview/api/read', (data) => {
+    $.get('/csstemplateasyncmodelview/api/read', data => {
       const cssTemplates = data.result.map(row => ({
         value: row.template_name,
         css: row.css,
@@ -79,134 +68,71 @@ class Controls extends React.PureComponent {
       this.setState({ cssTemplates });
     });
   }
-  refresh() {
-    // Force refresh all slices
-    this.props.renderSlices(true);
-  }
-  toggleModal(modal) {
-    let currentModal;
-    if (modal !== this.state.currentModal) {
-      currentModal = modal;
-    }
-    this.setState({ currentModal });
-  }
+
   changeCss(css) {
     this.setState({ css }, () => {
-      this.updateDom(css);
+      updateDom(css);
     });
     this.props.onChange();
+    this.props.updateCss(css);
   }
-  updateDom(css) {
-    const className = 'CssEditor-css';
-    const head = document.head || document.getElementsByTagName('head')[0];
-    let style = document.querySelector('.' + className);
 
-    if (!style) {
-      style = document.createElement('style');
-      style.className = className;
-      style.type = 'text/css';
-      head.appendChild(style);
-    }
-    if (style.styleSheet) {
-      style.styleSheet.cssText = css;
-    } else {
-      style.innerHTML = css;
-    }
-  }
   render() {
-    const { dashboard, userId, filters,
-      addSlicesToDashboard, startPeriodicRender,
-      serialize, onSave, editMode } = this.props;
+    const {
+      dashboardTitle,
+      startPeriodicRender,
+      forceRefreshAllCharts,
+      editMode,
+    } = this.props;
+
     const emailBody = t('Checkout this dashboard: %s', window.location.href);
-    const emailLink = 'mailto:?Subject=Superset%20Dashboard%20'
-      + `${dashboard.dashboard_title}&Body=${emailBody}`;
-    let saveText = t('Save as');
-    if (editMode) {
-      saveText = t('Save');
-    }
+    const emailLink =
+      'mailto:?Subject=Superset%20Dashboard%20' +
+      `${dashboardTitle}&Body=${emailBody}`;
+
     return (
       <span>
-        <DropdownButton title="Actions" bsSize="small" id="bg-nested-dropdown" pullRight>
-          <ActionMenuItem
-            text={t('Force Refresh')}
-            tooltip={t('Force refresh the whole dashboard')}
-            faIcon="refresh"
-            onClick={this.refresh}
-          />
+        <DropdownButton
+          title="Actions"
+          bsSize="small"
+          id="bg-nested-dropdown"
+          pullRight
+        >
+          <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')}
-                faIcon="clock-o"
-              />
+            onChange={refreshInterval =>
+              startPeriodicRender(refreshInterval * 1000)
             }
+            triggerNode={<span>{t('Set auto-refresh interval')}</span>}
           />
-          <SaveModal
-            dashboard={dashboard}
-            filters={filters}
-            serialize={serialize}
-            onSave={onSave}
-            css={this.state.css}
-            triggerNode={
-              <MenuItemContent
-                text={saveText}
-                tooltip={t('Save the dashboard')}
-                faIcon="save"
-              />
-            }
-          />
-          {editMode &&
-            <ActionMenuItem
-              text={t('Edit properties')}
-              tooltip={t("Edit the dashboards's properties")}
-              faIcon="edit"
-              onClick={() => { window.location = `/dashboardmodelview/edit/${dashboard.id}`; }}
-            />
-          }
-          {editMode &&
-            <ActionMenuItem
-              text={t('Email')}
-              tooltip={t('Email a link to this dashboard')}
-              onClick={() => { window.location = emailLink; }}
-              faIcon="envelope"
-            />
-          }
-          {editMode &&
-            <SliceAdder
-              dashboard={dashboard}
-              addSlicesToDashboard={addSlicesToDashboard}
-              userId={userId}
-              triggerNode={
-                <MenuItemContent
-                  text={t('Add Slices')}
-                  tooltip={t('Add some slices to this dashboard')}
-                  faIcon="plus"
-                />
-              }
-            />
-          }
-          {editMode &&
+          {editMode && (
+            <MenuItem
+              target="_blank"
+              href={`/dashboardmodelview/edit/${this.props.dashboardInfo.id}`}
+            >
+              {t('Edit dashboard metadata')}
+            </MenuItem>
+          )}
+          {editMode && (
+            <MenuItem href={emailLink}>{t('Email dashboard link')}</MenuItem>
+          )}
+          {editMode && (
             <CssEditor
-              dashboard={dashboard}
-              triggerNode={
-                <MenuItemContent
-                  text={t('Edit CSS')}
-                  tooltip={t('Change the style of the dashboard using CSS code')}
-                  faIcon="css3"
-                />
-              }
+              triggerNode={<span>{t('Edit CSS')}</span>}
               initialCss={this.state.css}
               templates={this.state.cssTemplates}
-              onChange={this.changeCss.bind(this)}
+              onChange={this.changeCss}
             />
-          }
+          )}
         </DropdownButton>
       </span>
     );
   }
 }
+
 Controls.propTypes = propTypes;
+Controls.defaultProps = defaultProps;
 
 export default Controls;
diff --git a/superset/assets/src/dashboard/components/CssEditor.jsx b/superset/assets/src/dashboard/components/CssEditor.jsx
index 5abf5f81b5..45ef86d63a 100644
--- a/superset/assets/src/dashboard/components/CssEditor.jsx
+++ b/superset/assets/src/dashboard/components/CssEditor.jsx
@@ -29,15 +29,20 @@ class CssEditor extends React.PureComponent {
       css: props.initialCss,
       cssTemplateOptions: [],
     };
+    this.changeCss = this.changeCss.bind(this);
+    this.changeCssTemplate = this.changeCssTemplate.bind(this);
   }
+
   changeCss(css) {
     this.setState({ css }, () => {
       this.props.onChange(css);
     });
   }
+
   changeCssTemplate(opt) {
     this.changeCss(opt.css);
   }
+
   renderTemplateSelector() {
     if (this.props.templates) {
       return (
@@ -46,13 +51,14 @@ class CssEditor extends React.PureComponent {
           <Select
             options={this.props.templates}
             placeholder={t('Load a CSS template')}
-            onChange={this.changeCssTemplate.bind(this)}
+            onChange={this.changeCssTemplate}
           />
         </div>
       );
     }
     return null;
   }
+
   render() {
     return (
       <ModalTrigger
@@ -70,7 +76,7 @@ class CssEditor extends React.PureComponent {
                   theme="github"
                   minLines={8}
                   maxLines={30}
-                  onChange={this.changeCss.bind(this)}
+                  onChange={this.changeCss}
                   height="200px"
                   width="100%"
                   editorProps={{ $blockScrolling: true }}
@@ -85,6 +91,7 @@ class CssEditor extends React.PureComponent {
     );
   }
 }
+
 CssEditor.propTypes = propTypes;
 CssEditor.defaultProps = defaultProps;
 
diff --git a/superset/assets/src/dashboard/components/Dashboard.jsx b/superset/assets/src/dashboard/components/Dashboard.jsx
index 2a6a227997..644ddf0c62 100644
--- a/superset/assets/src/dashboard/components/Dashboard.jsx
+++ b/superset/assets/src/dashboard/components/Dashboard.jsx
@@ -1,349 +1,179 @@
+/* global window */
 import React from 'react';
 import PropTypes from 'prop-types';
 
 import AlertsWrapper from '../../components/AlertsWrapper';
-import GridLayout from './GridLayout';
-import Header from './Header';
-import { exportChart } from '../../explore/exploreUtils';
+import getChartIdsFromLayout from '../util/getChartIdsFromLayout';
+import DashboardBuilder from '../containers/DashboardBuilder';
+import {
+  chartPropShape,
+  slicePropShape,
+  dashboardInfoPropShape,
+  dashboardStatePropShape,
+} from '../util/propShapes';
 import { areObjectsEqual } from '../../reduxUtils';
-import { Logger, ActionLog, LOG_ACTIONS_PAGE_LOAD,
-  LOG_ACTIONS_LOAD_EVENT, LOG_ACTIONS_RENDER_EVENT } from '../../logger';
+import getFormDataWithExtraFilters from '../util/charts/getFormDataWithExtraFilters';
+import {
+  Logger,
+  ActionLog,
+  LOG_ACTIONS_PAGE_LOAD,
+  LOG_ACTIONS_LOAD_EVENT,
+  LOG_ACTIONS_RENDER_EVENT,
+} from '../../logger';
 import { t } from '../../locales';
 
-import '../../../stylesheets/dashboard.css';
+import '../stylesheets/index.less';
 
 const propTypes = {
-  actions: PropTypes.object,
+  actions: PropTypes.shape({
+    addSliceToDashboard: PropTypes.func.isRequired,
+    removeSliceFromDashboard: PropTypes.func.isRequired,
+    runQuery: PropTypes.func.isRequired,
+  }).isRequired,
+  dashboardInfo: dashboardInfoPropShape.isRequired,
+  dashboardState: dashboardStatePropShape.isRequired,
+  charts: PropTypes.objectOf(chartPropShape).isRequired,
+  slices: PropTypes.objectOf(slicePropShape).isRequired,
+  datasources: PropTypes.object.isRequired,
+  layout: PropTypes.object.isRequired,
+  impressionId: PropTypes.string.isRequired,
   initMessages: PropTypes.array,
-  dashboard: PropTypes.object.isRequired,
-  slices: PropTypes.object,
-  datasources: PropTypes.object,
-  filters: PropTypes.object,
-  refresh: PropTypes.bool,
   timeout: PropTypes.number,
   userId: PropTypes.string,
-  isStarred: PropTypes.bool,
-  editMode: PropTypes.bool,
-  impressionId: PropTypes.string,
 };
 
 const defaultProps = {
   initMessages: [],
-  dashboard: {},
-  slices: {},
-  datasources: {},
-  filters: {},
-  refresh: false,
   timeout: 60,
   userId: '',
-  isStarred: false,
-  editMode: false,
 };
 
 class Dashboard extends React.PureComponent {
+  static onBeforeUnload(hasChanged) {
+    if (hasChanged) {
+      window.addEventListener('beforeunload', Dashboard.unload);
+    } else {
+      window.removeEventListener('beforeunload', Dashboard.unload);
+    }
+  }
+
+  static unload() {
+    const message = t('You have unsaved changes.');
+    window.event.returnValue = message; // Gecko + IE
+    return message; // Gecko + Webkit, Safari, Chrome etc.
+  }
+
   constructor(props) {
     super(props);
-    this.refreshTimer = null;
+
     this.firstLoad = true;
     this.loadingLog = new ActionLog({
       impressionId: props.impressionId,
       actionType: LOG_ACTIONS_PAGE_LOAD,
       source: 'dashboard',
-      sourceId: props.dashboard.id,
+      sourceId: props.dashboardInfo.id,
       eventNames: [LOG_ACTIONS_LOAD_EVENT, LOG_ACTIONS_RENDER_EVENT],
     });
     Logger.start(this.loadingLog);
-
-    // alert for unsaved changes
-    this.state = { unsavedChanges: false };
-
-    this.rerenderCharts = this.rerenderCharts.bind(this);
-    this.updateDashboardTitle = this.updateDashboardTitle.bind(this);
-    this.onSave = this.onSave.bind(this);
-    this.onChange = this.onChange.bind(this);
-    this.serialize = this.serialize.bind(this);
-    this.fetchAllSlices = this.fetchSlices.bind(this, this.getAllSlices());
-    this.startPeriodicRender = this.startPeriodicRender.bind(this);
-    this.addSlicesToDashboard = this.addSlicesToDashboard.bind(this);
-    this.fetchSlice = this.fetchSlice.bind(this);
-    this.getFormDataExtra = this.getFormDataExtra.bind(this);
-    this.exploreChart = this.exploreChart.bind(this);
-    this.exportCSV = this.exportCSV.bind(this);
-    this.props.actions.fetchFaveStar = this.props.actions.fetchFaveStar.bind(this);
-    this.props.actions.saveFaveStar = this.props.actions.saveFaveStar.bind(this);
-    this.props.actions.saveSlice = this.props.actions.saveSlice.bind(this);
-    this.props.actions.removeSlice = this.props.actions.removeSlice.bind(this);
-    this.props.actions.removeChart = this.props.actions.removeChart.bind(this);
-    this.props.actions.updateDashboardLayout = this.props.actions.updateDashboardLayout.bind(this);
-    this.props.actions.toggleExpandSlice = this.props.actions.toggleExpandSlice.bind(this);
-    this.props.actions.addFilter = this.props.actions.addFilter.bind(this);
-    this.props.actions.clearFilter = this.props.actions.clearFilter.bind(this);
-    this.props.actions.removeFilter = this.props.actions.removeFilter.bind(this);
-  }
-
-  componentDidMount() {
-    window.addEventListener('resize', this.rerenderCharts);
   }
 
   componentWillReceiveProps(nextProps) {
-    if (this.firstLoad &&
-      Object.values(nextProps.slices)
-        .every(slice => (['rendered', 'failed', 'stopped'].indexOf(slice.chartStatus) > -1))
+    if (
+      this.firstLoad &&
+      Object.values(nextProps.charts).every(
+        chart =>
+          ['rendered', 'failed', 'stopped'].indexOf(chart.chartStatus) > -1,
+      )
     ) {
       Logger.end(this.loadingLog);
       this.firstLoad = false;
     }
+
+    const currentChartIds = getChartIdsFromLayout(this.props.layout);
+    const nextChartIds = getChartIdsFromLayout(nextProps.layout);
+
+    if (currentChartIds.length < nextChartIds.length) {
+      // adding new chart
+      const newChartId = nextChartIds.find(
+        key => currentChartIds.indexOf(key) === -1,
+      );
+      this.props.actions.addSliceToDashboard(newChartId);
+    } else if (currentChartIds.length > nextChartIds.length) {
+      // remove chart
+      const removedChartId = currentChartIds.find(
+        key => nextChartIds.indexOf(key) === -1,
+      );
+      this.props.actions.removeSliceFromDashboard(removedChartId);
+    }
   }
 
   componentDidUpdate(prevProps) {
-    if (this.props.refresh) {
-      let changedFilterKey;
-      const prevFiltersKeySet = new Set(Object.keys(prevProps.filters));
-      Object.keys(this.props.filters).some((key) => {
-        prevFiltersKeySet.delete(key);
-        if (prevProps.filters[key] === undefined ||
-          !areObjectsEqual(prevProps.filters[key], this.props.filters[key])) {
+    const { refresh, filters, hasUnsavedChanges } = this.props.dashboardState;
+    if (refresh) {
+      // refresh charts if a filter was removed, added, or changed
+      let changedFilterKey = null;
+      const currFilterKeys = Object.keys(filters);
+      const prevFilterKeys = Object.keys(prevProps.dashboardState.filters);
+
+      currFilterKeys.forEach(key => {
+        const prevFilter = prevProps.dashboardState.filters[key];
+        if (
+          // filter was added or changed
+          typeof prevFilter === 'undefined' ||
+          !areObjectsEqual(prevFilter, filters[key])
+        ) {
           changedFilterKey = key;
-          return true;
         }
-        return false;
       });
-      // has changed filter or removed a filter?
-      if (!!changedFilterKey || prevFiltersKeySet.size) {
+
+      if (
+        !!changedFilterKey ||
+        currFilterKeys.length !== prevFilterKeys.length
+      ) {
         this.refreshExcept(changedFilterKey);
       }
     }
-  }
-
-  componentWillUnmount() {
-    window.removeEventListener('resize', this.rerenderCharts);
-  }
 
-  onBeforeUnload(hasChanged) {
-    if (hasChanged) {
-      window.addEventListener('beforeunload', this.unload);
+    if (hasUnsavedChanges) {
+      Dashboard.onBeforeUnload(true);
     } else {
-      window.removeEventListener('beforeunload', this.unload);
+      Dashboard.onBeforeUnload(false);
     }
   }
 
-  onChange() {
-    this.onBeforeUnload(true);
-    this.setState({ unsavedChanges: true });
-  }
-
-  onSave() {
-    this.onBeforeUnload(false);
-    this.setState({ unsavedChanges: false });
-  }
-
   // return charts in array
-  getAllSlices() {
-    return Object.values(this.props.slices);
-  }
-
-  getFormDataExtra(slice) {
-    const formDataExtra = Object.assign({}, slice.formData);
-    const extraFilters = this.effectiveExtraFilters(slice.slice_id);
-    formDataExtra.extra_filters = formDataExtra.filters.concat(extraFilters);
-    return formDataExtra;
-  }
-
-  getFilters(sliceId) {
-    return this.props.filters[sliceId];
-  }
-
-  unload() {
-    const message = t('You have unsaved changes.');
-    window.event.returnValue = message; // Gecko + IE
-    return message; // Gecko + Webkit, Safari, Chrome etc.
-  }
-
-  effectiveExtraFilters(sliceId) {
-    const metadata = this.props.dashboard.metadata;
-    const filters = this.props.filters;
-    const f = [];
-    const immuneSlices = metadata.filter_immune_slices || [];
-    if (sliceId && immuneSlices.includes(sliceId)) {
-      // The slice is immune to dashboard filters
-      return f;
-    }
-
-    // Building a list of fields the slice is immune to filters on
-    let immuneToFields = [];
-    if (
-      sliceId &&
-      metadata.filter_immune_slice_fields &&
-      metadata.filter_immune_slice_fields[sliceId]) {
-      immuneToFields = metadata.filter_immune_slice_fields[sliceId];
-    }
-    for (const filteringSliceId in filters) {
-      if (filteringSliceId === sliceId.toString()) {
-        // Filters applied by the slice don't apply to itself
-        continue;
-      }
-      for (const field in filters[filteringSliceId]) {
-        if (!immuneToFields.includes(field)) {
-          f.push({
-            col: field,
-            op: 'in',
-            val: filters[filteringSliceId][field],
-          });
-        }
-      }
-    }
-    return f;
+  getAllCharts() {
+    return Object.values(this.props.charts);
   }
 
   refreshExcept(filterKey) {
-    const immune = this.props.dashboard.metadata.filter_immune_slices || [];
-    let slices = this.getAllSlices();
-    if (filterKey) {
-      slices = slices.filter(slice => (
-        String(slice.slice_id) !== filterKey &&
-        immune.indexOf(slice.slice_id) === -1
-      ));
-    }
-    this.fetchSlices(slices);
-  }
-
-  stopPeriodicRender() {
-    if (this.refreshTimer) {
-      clearTimeout(this.refreshTimer);
-      this.refreshTimer = null;
-    }
-  }
-
-  startPeriodicRender(interval) {
-    this.stopPeriodicRender();
-    const immune = this.props.dashboard.metadata.timed_refresh_immune_slices || [];
-    const refreshAll = () => {
-      const affectedSlices = this.getAllSlices()
-        .filter(slice => immune.indexOf(slice.slice_id) === -1);
-      this.fetchSlices(affectedSlices, true, interval * 0.2);
-    };
-    const fetchAndRender = () => {
-      refreshAll();
-      if (interval > 0) {
-        this.refreshTimer = setTimeout(fetchAndRender, interval);
+    const immune = this.props.dashboardInfo.metadata.filter_immune_slices || [];
+
+    this.getAllCharts().forEach(chart => {
+      // filterKey is a string, immune array contains numbers
+      if (String(chart.id) !== filterKey && immune.indexOf(chart.id) === -1) {
+        const updatedFormData = getFormDataWithExtraFilters({
+          chart,
+          dashboardMetadata: this.props.dashboardInfo.metadata,
+          filters: this.props.dashboardState.filters,
+          sliceId: chart.id,
+        });
+
+        this.props.actions.runQuery(
+          updatedFormData,
+          false,
+          this.props.timeout,
+          chart.id,
+        );
       }
-    };
-
-    fetchAndRender();
-  }
-
-  updateDashboardTitle(title) {
-    this.props.actions.updateDashboardTitle(title);
-    this.onChange();
-  }
-
-  serialize() {
-    return this.props.dashboard.layout.map(reactPos => ({
-      slice_id: reactPos.i,
-      col: reactPos.x + 1,
-      row: reactPos.y,
-      size_x: reactPos.w,
-      size_y: reactPos.h,
-    }));
-  }
-
-  addSlicesToDashboard(sliceIds) {
-    return this.props.actions.addSlicesToDashboard(this.props.dashboard.id, sliceIds);
-  }
-
-  fetchSlice(slice, force = false) {
-    return this.props.actions.runQuery(
-      this.getFormDataExtra(slice), force, this.props.timeout, slice.chartKey,
-    );
-  }
-
-  // fetch and render an list of slices
-  fetchSlices(slc, force = false, interval = 0) {
-    const slices = slc || this.getAllSlices();
-    if (!interval) {
-      slices.forEach((slice) => { this.fetchSlice(slice, force); });
-      return;
-    }
-
-    const meta = this.props.dashboard.metadata;
-    const refreshTime = Math.max(interval, meta.stagger_time || 5000); // default 5 seconds
-    if (typeof meta.stagger_refresh !== 'boolean') {
-      meta.stagger_refresh = meta.stagger_refresh === undefined ?
-        true : meta.stagger_refresh === 'true';
-    }
-    const delay = meta.stagger_refresh ? refreshTime / (slices.length - 1) : 0;
-    slices.forEach((slice, i) => {
-      setTimeout(() => { this.fetchSlice(slice, force); }, delay * i);
-    });
-  }
-
-  exploreChart(slice) {
-    const formData = this.getFormDataExtra(slice);
-    exportChart(formData);
-  }
-
-  exportCSV(slice) {
-    const formData = this.getFormDataExtra(slice);
-    exportChart(formData, 'csv');
-  }
-
-  // re-render chart without fetch
-  rerenderCharts() {
-    this.getAllSlices().forEach((slice) => {
-      setTimeout(() => {
-        this.props.actions.renderTriggered(new Date().getTime(), slice.chartKey);
-      }, 50);
     });
   }
 
   render() {
     return (
-      <div id="dashboard-container">
-        <div id="dashboard-header">
-          <AlertsWrapper initMessages={this.props.initMessages} />
-          <Header
-            dashboard={this.props.dashboard}
-            unsavedChanges={this.state.unsavedChanges}
-            filters={this.props.filters}
-            userId={this.props.userId}
-            isStarred={this.props.isStarred}
-            updateDashboardTitle={this.updateDashboardTitle}
-            onSave={this.onSave}
-            onChange={this.onChange}
-            serialize={this.serialize}
-            fetchFaveStar={this.props.actions.fetchFaveStar}
-            saveFaveStar={this.props.actions.saveFaveStar}
-            renderSlices={this.fetchAllSlices}
-            startPeriodicRender={this.startPeriodicRender}
-            addSlicesToDashboard={this.addSlicesToDashboard}
-            editMode={this.props.editMode}
-            setEditMode={this.props.actions.setEditMode}
-          />
-        </div>
-        <div id="grid-container" className="slice-grid gridster">
-          <GridLayout
-            dashboard={this.props.dashboard}
-            datasources={this.props.datasources}
-            filters={this.props.filters}
-            charts={this.props.slices}
-            timeout={this.props.timeout}
-            onChange={this.onChange}
-            getFormDataExtra={this.getFormDataExtra}
-            exploreChart={this.exploreChart}
-            exportCSV={this.exportCSV}
-            fetchSlice={this.fetchSlice}
-            saveSlice={this.props.actions.saveSlice}
-            removeSlice={this.props.actions.removeSlice}
-            removeChart={this.props.actions.removeChart}
-            updateDashboardLayout={this.props.actions.updateDashboardLayout}
-            toggleExpandSlice={this.props.actions.toggleExpandSlice}
-            addFilter={this.props.actions.addFilter}
-            getFilters={this.getFilters}
-            clearFilter={this.props.actions.clearFilter}
-            removeFilter={this.props.actions.removeFilter}
-            editMode={this.props.editMode}
-          />
-        </div>
+      <div>
+        <AlertsWrapper initMessages={this.props.initMessages} />
+        <DashboardBuilder />
       </div>
     );
   }
diff --git a/superset/assets/src/dashboard/components/DashboardBuilder.jsx b/superset/assets/src/dashboard/components/DashboardBuilder.jsx
new file mode 100644
index 0000000000..0f42f1b5f0
--- /dev/null
+++ b/superset/assets/src/dashboard/components/DashboardBuilder.jsx
@@ -0,0 +1,211 @@
+/* eslint-env browser */
+import cx from 'classnames';
+// ParentSize uses resize observer so the dashboard will update size
+// when its container size changes, due to e.g., builder side panel opening
+import ParentSize from '@vx/responsive/build/components/ParentSize';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Sticky, StickyContainer } from 'react-sticky';
+import { TabContainer, TabContent, TabPane } from 'react-bootstrap';
+
+import BuilderComponentPane from './BuilderComponentPane';
+import DashboardHeader from '../containers/DashboardHeader';
+import DashboardGrid from '../containers/DashboardGrid';
+import IconButton from './IconButton';
+import DragDroppable from './dnd/DragDroppable';
+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,
+  deleteTopLevelTabs: PropTypes.func.isRequired,
+  editMode: PropTypes.bool.isRequired,
+  showBuilderPane: PropTypes.bool,
+  handleComponentDrop: PropTypes.func.isRequired,
+  toggleBuilderPane: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+  showBuilderPane: false,
+};
+
+class DashboardBuilder extends React.Component {
+  static shouldFocusTabs(event, container) {
+    // don't focus the tabs when we click on a tab
+    return (
+      event.target.tagName === 'UL' ||
+      (/icon-button/.test(event.target.className) &&
+        container.contains(event.target))
+    );
+  }
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      tabIndex: 0, // top-level tabs
+    };
+    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 {
+      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 childIds = topLevelTabs ? topLevelTabs.children : [DASHBOARD_GRID_ID];
+
+    return (
+      <StickyContainer
+        className={cx('dashboard', editMode && 'dashboard--editing')}
+      >
+        {topLevelTabs || !editMode ? ( // you cannot drop on/displace tabs if they already exist
+          <DashboardHeader />
+        ) : (
+          <DragDroppable
+            component={dashboardRoot}
+            parentComponent={null}
+            depth={DASHBOARD_ROOT_DEPTH}
+            index={0}
+            orientation="column"
+            onDrop={handleComponentDrop}
+            editMode
+          >
+            {({ dropIndicatorProps }) => (
+              <div>
+                <DashboardHeader />
+                {dropIndicatorProps && <div {...dropIndicatorProps} />}
+              </div>
+            )}
+          </DragDroppable>
+        )}
+
+        {topLevelTabs && (
+          <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">
+          <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]}
+                          // see isValidChild for why tabs do not increment the depth of their children
+                          depth={DASHBOARD_ROOT_DEPTH + (topLevelTabs ? 0 : 1)}
+                          width={width}
+                        />
+                      </TabPane>
+                    ))}
+                  </TabContent>
+                </TabContainer>
+              )}
+            </ParentSize>
+          </div>
+
+          {this.props.editMode &&
+            this.props.showBuilderPane && (
+              <BuilderComponentPane
+                topOffset={topLevelTabs ? TABS_HEIGHT : 0}
+                toggleBuilderPane={this.props.toggleBuilderPane}
+              />
+            )}
+        </div>
+        <ToastPresenter />
+      </StickyContainer>
+    );
+  }
+}
+
+DashboardBuilder.propTypes = propTypes;
+DashboardBuilder.defaultProps = defaultProps;
+DashboardBuilder.childContextTypes = {
+  dragDropManager: PropTypes.object.isRequired,
+};
+
+export default DashboardBuilder;
diff --git a/superset/assets/src/dashboard/components/DashboardContainer.jsx b/superset/assets/src/dashboard/components/DashboardContainer.jsx
deleted file mode 100644
index a18a5d2990..0000000000
--- a/superset/assets/src/dashboard/components/DashboardContainer.jsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { bindActionCreators } from 'redux';
-import { connect } from 'react-redux';
-
-import * as dashboardActions from '../actions';
-import * as chartActions from '../../chart/chartAction';
-import Dashboard from './Dashboard';
-
-function mapStateToProps({ charts, dashboard, impressionId }) {
-  return {
-    initMessages: dashboard.common.flash_messages,
-    timeout: dashboard.common.conf.SUPERSET_WEBSERVER_TIMEOUT,
-    dashboard: dashboard.dashboard,
-    slices: charts,
-    datasources: dashboard.datasources,
-    filters: dashboard.filters,
-    refresh: !!dashboard.refresh,
-    userId: dashboard.userId,
-    isStarred: !!dashboard.isStarred,
-    editMode: dashboard.editMode,
-    impressionId,
-  };
-}
-
-function mapDispatchToProps(dispatch) {
-  const actions = { ...chartActions, ...dashboardActions };
-  return {
-    actions: bindActionCreators(actions, dispatch),
-  };
-}
-
-export default connect(mapStateToProps, mapDispatchToProps)(Dashboard);
diff --git a/superset/assets/src/dashboard/components/DashboardGrid.jsx b/superset/assets/src/dashboard/components/DashboardGrid.jsx
new file mode 100644
index 0000000000..77503bb1fc
--- /dev/null
+++ b/superset/assets/src/dashboard/components/DashboardGrid.jsx
@@ -0,0 +1,198 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { componentShape } from '../util/propShapes';
+import DashboardComponent from '../containers/DashboardComponent';
+import DragDroppable from './dnd/DragDroppable';
+
+import { GRID_GUTTER_SIZE, GRID_COLUMN_COUNT } from '../util/constants';
+
+const propTypes = {
+  depth: PropTypes.number.isRequired,
+  editMode: PropTypes.bool.isRequired,
+  gridComponent: componentShape.isRequired,
+  handleComponentDrop: PropTypes.func.isRequired,
+  resizeComponent: PropTypes.func.isRequired,
+  width: PropTypes.number.isRequired,
+};
+
+const defaultProps = {};
+
+class DashboardGrid extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {
+      isResizing: false,
+      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);
+    this.getRowGuidePosition = this.getRowGuidePosition.bind(this);
+    this.setGridRef = this.setGridRef.bind(this);
+  }
+
+  getRowGuidePosition(resizeRef) {
+    if (resizeRef && this.grid) {
+      return (
+        resizeRef.getBoundingClientRect().bottom -
+        this.grid.getBoundingClientRect().top -
+        1
+      );
+    }
+    return null;
+  }
+
+  setGridRef(ref) {
+    this.grid = ref;
+  }
+
+  handleResizeStart({ ref, direction }) {
+    let rowGuideTop = null;
+    if (direction === 'bottom' || direction === 'bottomRight') {
+      rowGuideTop = this.getRowGuidePosition(ref);
+    }
+
+    this.setState(() => ({
+      isResizing: true,
+      rowGuideTop,
+    }));
+  }
+
+  handleResize({ ref, direction }) {
+    if (direction === 'bottom' || direction === 'bottomRight') {
+      this.setState(() => ({ rowGuideTop: this.getRowGuidePosition(ref) }));
+    }
+  }
+
+  handleResizeStop({ id, widthMultiple: width, heightMultiple: height }) {
+    this.props.resizeComponent({ id, width, height });
+
+    this.setState(() => ({
+      isResizing: false,
+      rowGuideTop: null,
+    }));
+  }
+
+  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,
+      width,
+    } = this.props;
+
+    const columnPlusGutterWidth =
+      (width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT;
+
+    const columnWidth = columnPlusGutterWidth - GRID_GUTTER_SIZE;
+    const { isResizing, rowGuideTop } = this.state;
+
+    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>
+    );
+  }
+}
+
+DashboardGrid.propTypes = propTypes;
+DashboardGrid.defaultProps = defaultProps;
+
+export default DashboardGrid;
diff --git a/superset/assets/src/dashboard/components/DeleteComponentButton.jsx b/superset/assets/src/dashboard/components/DeleteComponentButton.jsx
new file mode 100644
index 0000000000..8470947085
--- /dev/null
+++ b/superset/assets/src/dashboard/components/DeleteComponentButton.jsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import IconButton from './IconButton';
+
+const propTypes = {
+  onDelete: PropTypes.func.isRequired,
+};
+
+const defaultProps = {};
+
+export default class DeleteComponentButton extends React.PureComponent {
+  render() {
+    const { onDelete } = this.props;
+    return <IconButton onClick={onDelete} className="fa fa-trash" />;
+  }
+}
+
+DeleteComponentButton.propTypes = propTypes;
+DeleteComponentButton.defaultProps = defaultProps;
diff --git a/superset/assets/src/dashboard/components/GridCell.jsx b/superset/assets/src/dashboard/components/GridCell.jsx
deleted file mode 100644
index 91fe83943b..0000000000
--- a/superset/assets/src/dashboard/components/GridCell.jsx
+++ /dev/null
@@ -1,157 +0,0 @@
-/* eslint-disable react/no-danger */
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import SliceHeader from './SliceHeader';
-import ChartContainer from '../../chart/ChartContainer';
-
-import '../../../stylesheets/dashboard.css';
-
-const propTypes = {
-  timeout: PropTypes.number,
-  datasource: PropTypes.object,
-  isLoading: PropTypes.bool,
-  isCached: PropTypes.bool,
-  cachedDttm: PropTypes.string,
-  isExpanded: PropTypes.bool,
-  widgetHeight: PropTypes.number,
-  widgetWidth: PropTypes.number,
-  slice: PropTypes.object,
-  chartKey: PropTypes.string,
-  formData: PropTypes.object,
-  filters: PropTypes.object,
-  forceRefresh: PropTypes.func,
-  removeSlice: PropTypes.func,
-  updateSliceName: PropTypes.func,
-  toggleExpandSlice: PropTypes.func,
-  exploreChart: PropTypes.func,
-  exportCSV: PropTypes.func,
-  addFilter: PropTypes.func,
-  getFilters: PropTypes.func,
-  clearFilter: PropTypes.func,
-  removeFilter: PropTypes.func,
-  editMode: PropTypes.bool,
-  annotationQuery: PropTypes.object,
-};
-
-const defaultProps = {
-  forceRefresh: () => ({}),
-  removeSlice: () => ({}),
-  updateSliceName: () => ({}),
-  toggleExpandSlice: () => ({}),
-  exploreChart: () => ({}),
-  exportCSV: () => ({}),
-  addFilter: () => ({}),
-  getFilters: () => ({}),
-  clearFilter: () => ({}),
-  removeFilter: () => ({}),
-  editMode: false,
-};
-
-class GridCell extends React.PureComponent {
-  constructor(props) {
-    super(props);
-
-    const sliceId = this.props.slice.slice_id;
-    this.addFilter = this.props.addFilter.bind(this, sliceId);
-    this.getFilters = this.props.getFilters.bind(this, sliceId);
-    this.clearFilter = this.props.clearFilter.bind(this, sliceId);
-    this.removeFilter = this.props.removeFilter.bind(this, sliceId);
-  }
-
-  getDescriptionId(slice) {
-    return 'description_' + slice.slice_id;
-  }
-
-  getHeaderId(slice) {
-    return 'header_' + slice.slice_id;
-  }
-
-  width() {
-    return this.props.widgetWidth - 10;
-  }
-
-  height(slice) {
-    const widgetHeight = this.props.widgetHeight;
-    const headerHeight = this.headerHeight(slice);
-    const descriptionId = this.getDescriptionId(slice);
-    let descriptionHeight = 0;
-    if (this.props.isExpanded && this.refs[descriptionId]) {
-      descriptionHeight = this.refs[descriptionId].offsetHeight + 10;
-    }
-
-    return widgetHeight - headerHeight - descriptionHeight;
-  }
-
-  headerHeight(slice) {
-    const headerId = this.getHeaderId(slice);
-    return this.refs[headerId] ? this.refs[headerId].offsetHeight : 30;
-  }
-
-  render() {
-    const {
-      isExpanded, isLoading, isCached, cachedDttm,
-      removeSlice, updateSliceName, toggleExpandSlice, forceRefresh,
-      chartKey, slice, datasource, formData, timeout, annotationQuery,
-      exploreChart, exportCSV,
-    } = this.props;
-    return (
-      <div
-        className={isLoading ? 'slice-cell-highlight' : 'slice-cell'}
-        id={`${slice.slice_id}-cell`}
-      >
-        <div ref={this.getHeaderId(slice)}>
-          <SliceHeader
-            slice={slice}
-            isExpanded={isExpanded}
-            isCached={isCached}
-            cachedDttm={cachedDttm}
-            removeSlice={removeSlice}
-            updateSliceName={updateSliceName}
-            toggleExpandSlice={toggleExpandSlice}
-            forceRefresh={forceRefresh}
-            editMode={this.props.editMode}
-            annotationQuery={annotationQuery}
-            exploreChart={exploreChart}
-            exportCSV={exportCSV}
-          />
-        </div>
-        {
-        /* This usage of dangerouslySetInnerHTML is safe since it is being used to render
-           markdown that is sanitized with bleach. See:
-             https://github.com/apache/incubator-superset/pull/4390
-           and
-             https://github.com/apache/incubator-superset/commit/b6fcc22d5a2cb7a5e92599ed5795a0169385a825 */}
-        <div
-          className="slice_description bs-callout bs-callout-default"
-          style={isExpanded ? {} : { display: 'none' }}
-          ref={this.getDescriptionId(slice)}
-          dangerouslySetInnerHTML={{ __html: slice.description_markeddown }}
-        />
-        <div className="row chart-container">
-          <input type="hidden" value="false" />
-          <ChartContainer
-            containerId={`slice-container-${slice.slice_id}`}
-            chartKey={chartKey}
-            datasource={datasource}
-            formData={formData}
-            headerHeight={this.headerHeight(slice)}
-            height={this.height(slice)}
-            width={this.width()}
-            timeout={timeout}
-            vizType={slice.formData.viz_type}
-            addFilter={this.addFilter}
-            getFilters={this.getFilters}
-            clearFilter={this.clearFilter}
-            removeFilter={this.removeFilter}
-          />
-        </div>
-      </div>
-    );
-  }
-}
-
-GridCell.propTypes = propTypes;
-GridCell.defaultProps = defaultProps;
-
-export default GridCell;
diff --git a/superset/assets/src/dashboard/components/GridLayout.jsx b/superset/assets/src/dashboard/components/GridLayout.jsx
deleted file mode 100644
index ef0ec24796..0000000000
--- a/superset/assets/src/dashboard/components/GridLayout.jsx
+++ /dev/null
@@ -1,198 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { Responsive, WidthProvider } from 'react-grid-layout';
-
-import GridCell from './GridCell';
-
-require('react-grid-layout/css/styles.css');
-require('react-resizable/css/styles.css');
-
-const ResponsiveReactGridLayout = WidthProvider(Responsive);
-
-const propTypes = {
-  dashboard: PropTypes.object.isRequired,
-  datasources: PropTypes.object,
-  charts: PropTypes.object.isRequired,
-  filters: PropTypes.object,
-  timeout: PropTypes.number,
-  onChange: PropTypes.func,
-  getFormDataExtra: PropTypes.func,
-  exploreChart: PropTypes.func,
-  exportCSV: PropTypes.func,
-  fetchSlice: PropTypes.func,
-  saveSlice: PropTypes.func,
-  removeSlice: PropTypes.func,
-  removeChart: PropTypes.func,
-  updateDashboardLayout: PropTypes.func,
-  toggleExpandSlice: PropTypes.func,
-  addFilter: PropTypes.func,
-  getFilters: PropTypes.func,
-  clearFilter: PropTypes.func,
-  removeFilter: PropTypes.func,
-  editMode: PropTypes.bool.isRequired,
-};
-
-const defaultProps = {
-  onChange: () => ({}),
-  getFormDataExtra: () => ({}),
-  exploreChart: () => ({}),
-  exportCSV: () => ({}),
-  fetchSlice: () => ({}),
-  saveSlice: () => ({}),
-  removeSlice: () => ({}),
-  removeChart: () => ({}),
-  updateDashboardLayout: () => ({}),
-  toggleExpandSlice: () => ({}),
-  addFilter: () => ({}),
-  getFilters: () => ({}),
-  clearFilter: () => ({}),
-  removeFilter: () => ({}),
-};
-
-class GridLayout extends React.Component {
-  constructor(props) {
-    super(props);
-
-    this.onResizeStop = this.onResizeStop.bind(this);
-    this.onDragStop = this.onDragStop.bind(this);
-    this.forceRefresh = this.forceRefresh.bind(this);
-    this.removeSlice = this.removeSlice.bind(this);
-    this.updateSliceName = this.props.dashboard.dash_edit_perm ?
-      this.updateSliceName.bind(this) : null;
-  }
-
-  onResizeStop(layout) {
-    this.props.updateDashboardLayout(layout);
-    this.props.onChange();
-  }
-
-  onDragStop(layout) {
-    this.props.updateDashboardLayout(layout);
-    this.props.onChange();
-  }
-
-  getWidgetId(slice) {
-    return 'widget_' + slice.slice_id;
-  }
-
-  getWidgetHeight(slice) {
-    const widgetId = this.getWidgetId(slice);
-    if (!widgetId || !this.refs[widgetId]) {
-      return 400;
-    }
-    return this.refs[widgetId].offsetHeight;
-  }
-
-  getWidgetWidth(slice) {
-    const widgetId = this.getWidgetId(slice);
-    if (!widgetId || !this.refs[widgetId]) {
-      return 400;
-    }
-    return this.refs[widgetId].offsetWidth;
-  }
-
-  findSliceIndexById(sliceId) {
-    return this.props.dashboard.slices
-      .map(slice => (slice.slice_id)).indexOf(sliceId);
-  }
-
-  forceRefresh(sliceId) {
-    return this.props.fetchSlice(this.props.charts['slice_' + sliceId], true);
-  }
-
-  removeSlice(slice) {
-    if (!slice) {
-      return;
-    }
-
-    // remove slice dashboard and charts
-    this.props.removeSlice(slice);
-    this.props.removeChart(this.props.charts['slice_' + slice.slice_id].chartKey);
-    this.props.onChange();
-  }
-
-  updateSliceName(sliceId, sliceName) {
-    const index = this.findSliceIndexById(sliceId);
-    if (index === -1) {
-      return;
-    }
-
-    const currentSlice = this.props.dashboard.slices[index];
-    if (currentSlice.slice_name === sliceName) {
-      return;
-    }
-
-    this.props.saveSlice(currentSlice, sliceName);
-  }
-
-  isExpanded(slice) {
-    return this.props.dashboard.metadata.expanded_slices &&
-      this.props.dashboard.metadata.expanded_slices[slice.slice_id];
-  }
-
-  render() {
-    const cells = this.props.dashboard.slices.map((slice) => {
-      const chartKey = `slice_${slice.slice_id}`;
-      const currentChart = this.props.charts[chartKey];
-      const queryResponse = currentChart.queryResponse || {};
-      return (
-        <div
-          id={'slice_' + slice.slice_id}
-          key={slice.slice_id}
-          data-slice-id={slice.slice_id}
-          className={`widget ${slice.form_data.viz_type}`}
-          ref={this.getWidgetId(slice)}
-        >
-          <GridCell
-            slice={slice}
-            chartKey={chartKey}
-            datasource={this.props.datasources[slice.form_data.datasource]}
-            filters={this.props.filters}
-            formData={this.props.getFormDataExtra(slice)}
-            timeout={this.props.timeout}
-            widgetHeight={this.getWidgetHeight(slice)}
-            widgetWidth={this.getWidgetWidth(slice)}
-            exploreChart={this.props.exploreChart}
-            exportCSV={this.props.exportCSV}
-            isExpanded={!!this.isExpanded(slice)}
-            isLoading={currentChart.chartStatus === 'loading'}
-            isCached={queryResponse.is_cached}
-            cachedDttm={queryResponse.cached_dttm}
-            toggleExpandSlice={this.props.toggleExpandSlice}
-            forceRefresh={this.forceRefresh}
-            removeSlice={this.removeSlice}
-            updateSliceName={this.updateSliceName}
-            addFilter={this.props.addFilter}
-            getFilters={this.props.getFilters}
-            clearFilter={this.props.clearFilter}
-            removeFilter={this.props.removeFilter}
-            editMode={this.props.editMode}
-            annotationQuery={currentChart.annotationQuery}
-            annotationError={currentChart.annotationError}
-          />
-        </div>);
-    });
-
-    return (
-      <ResponsiveReactGridLayout
-        className="layout"
-        layouts={{ lg: this.props.dashboard.layout }}
-        onResizeStop={this.onResizeStop}
-        onDragStop={this.onDragStop}
-        cols={{ lg: 48, md: 48, sm: 40, xs: 32, xxs: 24 }}
-        rowHeight={10}
-        autoSize
-        margin={[20, 20]}
-        useCSSTransforms
-        draggableHandle=".drag"
-      >
-        {cells}
-      </ResponsiveReactGridLayout>
-    );
-  }
-}
-
-GridLayout.propTypes = propTypes;
-GridLayout.defaultProps = defaultProps;
-
-export default GridLayout;
diff --git a/superset/assets/src/dashboard/components/Header.jsx b/superset/assets/src/dashboard/components/Header.jsx
index 52d3024ff9..31bd08c4f7 100644
--- a/superset/assets/src/dashboard/components/Header.jsx
+++ b/superset/assets/src/dashboard/components/Header.jsx
@@ -1,116 +1,274 @@
+/* eslint-env browser */
 import React from 'react';
 import PropTypes from 'prop-types';
+import {
+  DropdownButton,
+  MenuItem,
+  ButtonGroup,
+  ButtonToolbar,
+} from 'react-bootstrap';
 
 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,
+  SAVE_TYPE_NEWDASHBOARD,
+  SAVE_TYPE_OVERWRITE,
+} from '../util/constants';
 
 const propTypes = {
-  dashboard: PropTypes.object.isRequired,
+  addSuccessToast: PropTypes.func.isRequired,
+  addDangerToast: PropTypes.func.isRequired,
+  dashboardInfo: PropTypes.object.isRequired,
+  dashboardTitle: PropTypes.string.isRequired,
+  charts: PropTypes.objectOf(chartPropShape).isRequired,
+  layout: PropTypes.object.isRequired,
   filters: PropTypes.object.isRequired,
-  userId: PropTypes.string.isRequired,
-  isStarred: PropTypes.bool,
-  addSlicesToDashboard: PropTypes.func,
-  onSave: PropTypes.func,
-  onChange: PropTypes.func,
-  fetchFaveStar: PropTypes.func,
-  renderSlices: PropTypes.func,
-  saveFaveStar: PropTypes.func,
-  serialize: PropTypes.func,
-  startPeriodicRender: PropTypes.func,
-  updateDashboardTitle: PropTypes.func,
+  expandedSlices: PropTypes.object.isRequired,
+  css: PropTypes.string.isRequired,
+  isStarred: PropTypes.bool.isRequired,
+  onSave: PropTypes.func.isRequired,
+  onChange: PropTypes.func.isRequired,
+  fetchFaveStar: PropTypes.func.isRequired,
+  fetchCharts: PropTypes.func.isRequired,
+  saveFaveStar: PropTypes.func.isRequired,
+  startPeriodicRender: PropTypes.func.isRequired,
+  updateDashboardTitle: PropTypes.func.isRequired,
   editMode: PropTypes.bool.isRequired,
   setEditMode: PropTypes.func.isRequired,
-  unsavedChanges: PropTypes.bool.isRequired,
+  showBuilderPane: PropTypes.bool.isRequired,
+  toggleBuilderPane: PropTypes.func.isRequired,
+  updateCss: PropTypes.func.isRequired,
+  hasUnsavedChanges: PropTypes.bool.isRequired,
+  maxUndoHistoryExceeded: PropTypes.bool.isRequired,
+
+  // redux
+  onUndo: PropTypes.func.isRequired,
+  onRedo: PropTypes.func.isRequired,
+  undoLength: PropTypes.number.isRequired,
+  redoLength: PropTypes.number.isRequired,
+  setMaxUndoHistoryExceeded: PropTypes.func.isRequired,
+  maxUndoHistoryToast: PropTypes.func.isRequired,
 };
 
 class Header extends React.PureComponent {
+  static discardChanges() {
+    window.location.reload();
+  }
+
   constructor(props) {
     super(props);
-    this.handleSaveTitle = this.handleSaveTitle.bind(this);
+    this.state = {
+      didNotifyMaxUndoHistoryToast: false,
+    };
+
+    this.handleChangeText = this.handleChangeText.bind(this);
     this.toggleEditMode = this.toggleEditMode.bind(this);
+    this.forceRefresh = this.forceRefresh.bind(this);
+    this.overwriteDashboard = this.overwriteDashboard.bind(this);
   }
-  handleSaveTitle(title) {
-    this.props.updateDashboardTitle(title);
+
+  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();
+    }
   }
-  toggleEditMode() {
-    this.props.setEditMode(!this.props.editMode);
+
+  forceRefresh() {
+    return this.props.fetchCharts(Object.values(this.props.charts), true);
   }
-  renderUnsaved() {
-    if (!this.props.unsavedChanges) {
-      return null;
+
+  handleChangeText(nextText) {
+    const { updateDashboardTitle, onChange } = this.props;
+    if (nextText && this.props.dashboardTitle !== nextText) {
+      updateDashboardTitle(nextText);
+      onChange();
     }
-    return (
-      <InfoTooltipWithTrigger
-        label="unsaved"
-        tooltip={t('Unsaved changes')}
-        icon="exclamation-triangle"
-        className="text-danger m-r-5"
-        placement="top"
-      />
-    );
   }
-  renderEditButton() {
-    if (!this.props.dashboard.dash_save_perm) {
-      return null;
-    }
-    const btnText = this.props.editMode ? 'Switch to View Mode' : 'Edit Dashboard';
-    return (
-      <Button
-        bsStyle="default"
-        className="m-r-5"
-        style={{ width: '150px' }}
-        onClick={this.toggleEditMode}
-      >
-        {btnText}
-      </Button>);
+
+  toggleEditMode() {
+    this.props.setEditMode(!this.props.editMode);
+  }
+
+  overwriteDashboard() {
+    const {
+      dashboardTitle,
+      layout: positions,
+      expandedSlices,
+      css,
+      filters,
+      dashboardInfo,
+    } = this.props;
+
+    const data = {
+      positions,
+      expanded_slices: expandedSlices,
+      css,
+      dashboard_title: dashboardTitle,
+      default_filters: JSON.stringify(filters),
+    };
+
+    this.props.onSave(data, dashboardInfo.id, SAVE_TYPE_OVERWRITE);
   }
+
   render() {
-    const dashboard = this.props.dashboard;
+    const {
+      dashboardTitle,
+      layout,
+      filters,
+      expandedSlices,
+      css,
+      onUndo,
+      onRedo,
+      undoLength,
+      redoLength,
+      onChange,
+      onSave,
+      updateCss,
+      editMode,
+      showBuilderPane,
+      dashboardInfo,
+      hasUnsavedChanges,
+    } = this.props;
+
+    const userCanEdit = dashboardInfo.dash_edit_perm;
+    const userCanSaveAs = dashboardInfo.dash_save_perm;
+
     return (
-      <div className="title">
-        <div className="pull-left">
-          <h1 className="outer-container pull-left">
-            <EditableTitle
-              title={dashboard.dashboard_title}
-              canEdit={dashboard.dash_save_perm && this.props.editMode}
-              onSaveTitle={this.handleSaveTitle}
-              showTooltip={this.props.editMode}
+      <div className="dashboard-header">
+        <div className="dashboard-component-header header-large">
+          <EditableTitle
+            title={dashboardTitle}
+            canEdit={userCanEdit && editMode}
+            onSaveTitle={this.handleChangeText}
+            showTooltip={false}
+          />
+          <span className="favstar m-l-5">
+            <FaveStar
+              itemId={dashboardInfo.id}
+              fetchFaveStar={this.props.fetchFaveStar}
+              saveFaveStar={this.props.saveFaveStar}
+              isStarred={this.props.isStarred}
             />
-            <span className="favstar m-r-5">
-              <FaveStar
-                itemId={dashboard.id}
-                fetchFaveStar={this.props.fetchFaveStar}
-                saveFaveStar={this.props.saveFaveStar}
-                isStarred={this.props.isStarred}
-              />
-            </span>
-            {this.renderUnsaved()}
-          </h1>
+          </span>
         </div>
-        <div className="pull-right" style={{ marginTop: '35px' }}>
-          {this.renderEditButton()}
+        <ButtonToolbar>
+          {userCanSaveAs && (
+            <ButtonGroup>
+              {editMode && (
+                <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={redoLength < 1}
+                >
+                  <div title="Redo" className="redo-action fa fa-share" />
+                </Button>
+              )}
+
+              {editMode && (
+                <Button bsSize="small" onClick={this.props.toggleBuilderPane}>
+                  {showBuilderPane
+                    ? t('Hide builder pane')
+                    : t('Insert components')}
+                </Button>
+              )}
+
+              {!hasUnsavedChanges ? (
+                <Button
+                  bsSize="small"
+                  onClick={this.toggleEditMode}
+                  bsStyle={hasUnsavedChanges ? 'primary' : undefined}
+                  disabled={!userCanEdit}
+                >
+                  {editMode ? t('Switch to view mode') : t('Edit dashboard')}
+                </Button>
+              ) : (
+                <Button
+                  bsSize="small"
+                  bsStyle={hasUnsavedChanges ? 'primary' : undefined}
+                  onClick={this.overwriteDashboard}
+                >
+                  {t('Save changes')}
+                </Button>
+              )}
+              <DropdownButton
+                title=""
+                id="save-dash-split-button"
+                bsStyle={hasUnsavedChanges ? 'primary' : undefined}
+                bsSize="small"
+                pullRight
+              >
+                <SaveModal
+                  addSuccessToast={this.props.addSuccessToast}
+                  addDangerToast={this.props.addDangerToast}
+                  dashboardId={dashboardInfo.id}
+                  dashboardTitle={dashboardTitle}
+                  saveType={SAVE_TYPE_NEWDASHBOARD}
+                  layout={layout}
+                  filters={filters}
+                  expandedSlices={expandedSlices}
+                  css={css}
+                  onSave={onSave}
+                  isMenuItem
+                  triggerNode={<span>{t('Save as')}</span>}
+                  canOverwrite={userCanEdit}
+                />
+                {hasUnsavedChanges && (
+                  <MenuItem eventKey="discard" onSelect={Header.discardChanges}>
+                    {t('Discard changes')}
+                  </MenuItem>
+                )}
+              </DropdownButton>
+            </ButtonGroup>
+          )}
+
           <Controls
-            dashboard={dashboard}
-            filters={this.props.filters}
-            userId={this.props.userId}
-            addSlicesToDashboard={this.props.addSlicesToDashboard}
-            onSave={this.props.onSave}
-            onChange={this.props.onChange}
-            renderSlices={this.props.renderSlices}
-            serialize={this.props.serialize}
+            addSuccessToast={this.props.addSuccessToast}
+            addDangerToast={this.props.addDangerToast}
+            dashboardInfo={dashboardInfo}
+            dashboardTitle={dashboardTitle}
+            layout={layout}
+            filters={filters}
+            expandedSlices={expandedSlices}
+            css={css}
+            onSave={onSave}
+            onChange={onChange}
+            forceRefreshAllCharts={this.forceRefresh}
             startPeriodicRender={this.props.startPeriodicRender}
-            editMode={this.props.editMode}
+            updateCss={updateCss}
+            editMode={editMode}
           />
-        </div>
-        <div className="clearfix" />
+        </ButtonToolbar>
       </div>
     );
   }
 }
+
 Header.propTypes = propTypes;
 
 export default Header;
diff --git a/superset/assets/src/dashboard/components/IconButton.jsx b/superset/assets/src/dashboard/components/IconButton.jsx
new file mode 100644
index 0000000000..18fd3b1030
--- /dev/null
+++ b/superset/assets/src/dashboard/components/IconButton.jsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const propTypes = {
+  onClick: PropTypes.func.isRequired,
+  className: PropTypes.string,
+  label: PropTypes.string,
+};
+
+const defaultProps = {
+  className: null,
+  label: null,
+};
+
+export default class IconButton extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick(event) {
+    event.preventDefault();
+    const { onClick } = this.props;
+    onClick(event);
+  }
+
+  render() {
+    const { className, label } = this.props;
+    return (
+      <div
+        className="icon-button"
+        onClick={this.handleClick}
+        tabIndex="0"
+        role="button"
+      >
+        <span className={className} />
+        {label && <span className="icon-button-label">{label}</span>}
+      </div>
+    );
+  }
+}
+
+IconButton.propTypes = propTypes;
+IconButton.defaultProps = defaultProps;
diff --git a/superset/assets/src/dashboard/components/RefreshIntervalModal.jsx b/superset/assets/src/dashboard/components/RefreshIntervalModal.jsx
index 4cba010d95..3d92dd5cd0 100644
--- a/superset/assets/src/dashboard/components/RefreshIntervalModal.jsx
+++ b/superset/assets/src/dashboard/components/RefreshIntervalModal.jsx
@@ -16,7 +16,7 @@ const defaultProps = {
 };
 
 const options = [
-  [0, t('Don\'t refresh')],
+  [0, t("Don't refresh")],
   [10, t('10 seconds')],
   [30, t('30 seconds')],
   [60, t('1 minute')],
@@ -42,9 +42,12 @@ class RefreshIntervalModal extends React.PureComponent {
             <Select
               options={options}
               value={this.state.refreshFrequency}
-              onChange={(opt) => {
-                this.setState({ refreshFrequency: opt.value });
-                this.props.onChange(opt.value);
+              onChange={opt => {
+                const value = opt ? opt.value : options[0].value;
+                this.setState({
+                  refreshFrequency: value,
+                });
+                this.props.onChange(value);
               }}
             />
           </div>
diff --git a/superset/assets/src/dashboard/components/SaveModal.jsx b/superset/assets/src/dashboard/components/SaveModal.jsx
index da465a0057..804674c325 100644
--- a/superset/assets/src/dashboard/components/SaveModal.jsx
+++ b/superset/assets/src/dashboard/components/SaveModal.jsx
@@ -1,128 +1,128 @@
-/* global notify */
+/* eslint-env browser */
 import React from 'react';
 import PropTypes from 'prop-types';
+
 import { Button, FormControl, FormGroup, Radio } from 'react-bootstrap';
-import { getAjaxErrorMsg } from '../../modules/utils';
 import ModalTrigger from '../../components/ModalTrigger';
 import { t } from '../../locales';
 import Checkbox from '../../components/Checkbox';
-
-const $ = window.$ = require('jquery');
+import { SAVE_TYPE_OVERWRITE, SAVE_TYPE_NEWDASHBOARD } from '../util/constants';
 
 const propTypes = {
-  css: PropTypes.string,
-  dashboard: PropTypes.object.isRequired,
+  addSuccessToast: PropTypes.func.isRequired,
+  addDangerToast: PropTypes.func.isRequired,
+  dashboardId: PropTypes.number.isRequired,
+  dashboardTitle: PropTypes.string.isRequired,
+  expandedSlices: PropTypes.object.isRequired,
+  layout: PropTypes.object.isRequired,
+  saveType: PropTypes.oneOf([SAVE_TYPE_OVERWRITE, SAVE_TYPE_NEWDASHBOARD]),
   triggerNode: PropTypes.node.isRequired,
   filters: PropTypes.object.isRequired,
-  serialize: PropTypes.func,
-  onSave: PropTypes.func,
+  css: PropTypes.string.isRequired,
+  onSave: PropTypes.func.isRequired,
+  isMenuItem: PropTypes.bool,
+  canOverwrite: PropTypes.bool.isRequired,
+};
+
+const defaultProps = {
+  isMenuItem: false,
+  saveType: SAVE_TYPE_OVERWRITE,
 };
 
 class SaveModal extends React.PureComponent {
   constructor(props) {
     super(props);
     this.state = {
-      dashboard: props.dashboard,
-      css: props.css,
-      saveType: 'overwrite',
-      newDashName: props.dashboard.dashboard_title + ' [copy]',
+      saveType: props.saveType,
+      newDashName: `${props.dashboardTitle} [copy]`,
       duplicateSlices: false,
     };
     this.modal = null;
     this.handleSaveTypeChange = this.handleSaveTypeChange.bind(this);
     this.handleNameChange = this.handleNameChange.bind(this);
     this.saveDashboard = this.saveDashboard.bind(this);
+    this.setModalRef = this.setModalRef.bind(this);
+    this.toggleDuplicateSlices = this.toggleDuplicateSlices.bind(this);
+    this.onSave = this.props.onSave.bind(this);
+  }
+
+  setModalRef(ref) {
+    this.modal = ref;
   }
+
   toggleDuplicateSlices() {
     this.setState({ duplicateSlices: !this.state.duplicateSlices });
   }
+
   handleSaveTypeChange(event) {
     this.setState({
       saveType: event.target.value,
     });
   }
+
   handleNameChange(event) {
     this.setState({
       newDashName: event.target.value,
-      saveType: 'newDashboard',
+      saveType: SAVE_TYPE_NEWDASHBOARD,
     });
   }
-  saveDashboardRequest(data, url, saveType) {
-    const saveModal = this.modal;
-    const onSaveDashboard = this.props.onSave;
-    Object.assign(data, { css: this.props.css });
-    $.ajax({
-      type: 'POST',
-      url,
-      data: {
-        data: JSON.stringify(data),
-      },
-      success(resp) {
-        saveModal.close();
-        onSaveDashboard();
-        if (saveType === 'newDashboard') {
-          window.location = `/superset/dashboard/${resp.id}/`;
-        } else {
-          notify.success(t('This dashboard was saved successfully.'));
-        }
-      },
-      error(error) {
-        saveModal.close();
-        const errorMsg = getAjaxErrorMsg(error);
-        notify.error(t('Sorry, there was an error saving this dashboard: ') + '</ br>' + errorMsg);
-      },
-    });
-  }
-  saveDashboard(saveType, newDashboardTitle) {
-    const dashboard = this.props.dashboard;
-    const positions = this.props.serialize();
+
+  saveDashboard() {
+    const { saveType, newDashName } = this.state;
+    const {
+      dashboardTitle,
+      layout: positions,
+      css,
+      expandedSlices,
+      filters,
+      dashboardId,
+    } = this.props;
+
     const data = {
       positions,
-      css: this.state.css,
-      expanded_slices: dashboard.metadata.expanded_slices || {},
-      dashboard_title: dashboard.dashboard_title,
-      default_filters: JSON.stringify(this.props.filters),
+      css,
+      expanded_slices: expandedSlices,
+      dashboard_title: dashboardTitle,
+      default_filters: JSON.stringify(filters),
       duplicate_slices: this.state.duplicateSlices,
     };
-    let url = null;
-    if (saveType === 'overwrite') {
-      url = `/superset/save_dash/${dashboard.id}/`;
-      this.saveDashboardRequest(data, url, saveType);
-    } else if (saveType === 'newDashboard') {
-      if (!newDashboardTitle) {
-        this.modal.close();
-        showModal({
-          title: t('Error'),
-          body: t('You must pick a name for the new dashboard'),
-        });
-      } else {
-        data.dashboard_title = newDashboardTitle;
-        url = `/superset/copy_dash/${dashboard.id}/`;
-        this.saveDashboardRequest(data, url, saveType);
-      }
+
+    if (saveType === SAVE_TYPE_NEWDASHBOARD && !newDashName) {
+      this.props.addDangerToast(
+        t('You must pick a name for the new dashboard'),
+      );
+    } else {
+      this.onSave(data, dashboardId, saveType).done(resp => {
+        if (saveType === SAVE_TYPE_NEWDASHBOARD) {
+          window.location = `/superset/dashboard/${resp.id}/`;
+        }
+      });
+      this.modal.close();
     }
   }
+
   render() {
     return (
       <ModalTrigger
-        ref={(modal) => { this.modal = modal; }}
-        isMenuItem
+        ref={this.setModalRef}
+        isMenuItem={this.props.isMenuItem}
         triggerNode={this.props.triggerNode}
         modalTitle={t('Save Dashboard')}
         modalBody={
           <FormGroup>
             <Radio
-              value="overwrite"
+              value={SAVE_TYPE_OVERWRITE}
               onChange={this.handleSaveTypeChange}
-              checked={this.state.saveType === 'overwrite'}
+              checked={this.state.saveType === SAVE_TYPE_OVERWRITE}
+              disabled={!this.props.canOverwrite}
             >
-              {t('Overwrite Dashboard [%s]', this.props.dashboard.dashboard_title)}
+              {t('Overwrite Dashboard [%s]', this.props.dashboardTitle)}
             </Radio>
             <hr />
             <Radio
-              value="newDashboard"
+              value={SAVE_TYPE_NEWDASHBOARD}
               onChange={this.handleSaveTypeChange}
-              checked={this.state.saveType === 'newDashboard'}
+              checked={this.state.saveType === SAVE_TYPE_NEWDASHBOARD}
             >
               {t('Save as:')}
             </Radio>
@@ -136,7 +136,7 @@ class SaveModal extends React.PureComponent {
             <div className="m-l-25 m-t-5">
               <Checkbox
                 checked={this.state.duplicateSlices}
-                onChange={this.toggleDuplicateSlices.bind(this)}
+                onChange={this.toggleDuplicateSlices}
               />
               <span className="m-l-5">also copy (duplicate) slices</span>
             </div>
@@ -144,10 +144,7 @@ class SaveModal extends React.PureComponent {
         }
         modalFooter={
           <div>
-            <Button
-              bsStyle="primary"
-              onClick={() => { this.saveDashboard(this.state.saveType, this.state.newDashName); }}
-            >
+            <Button bsStyle="primary" onClick={this.saveDashboard}>
               {t('Save')}
             </Button>
           </div>
@@ -156,6 +153,8 @@ class SaveModal extends React.PureComponent {
     );
   }
 }
+
 SaveModal.propTypes = propTypes;
+SaveModal.defaultProps = defaultProps;
 
 export default SaveModal;
diff --git a/superset/assets/src/dashboard/components/SliceAdder.jsx b/superset/assets/src/dashboard/components/SliceAdder.jsx
index d5be8caff6..d8ed53ead8 100644
--- a/superset/assets/src/dashboard/components/SliceAdder.jsx
+++ b/superset/assets/src/dashboard/components/SliceAdder.jsx
@@ -1,219 +1,243 @@
+/* eslint-env browser */
 import React from 'react';
-import $ from 'jquery';
 import PropTypes from 'prop-types';
-import { BootstrapTable, TableHeaderColumn } from 'react-bootstrap-table';
+import { DropdownButton, MenuItem } from 'react-bootstrap';
+import { List } from 'react-virtualized';
+import SearchInput, { createFilter } from 'react-search-input';
 
-import ModalTrigger from '../../components/ModalTrigger';
-import { t } from '../../locales';
-
-require('react-bootstrap-table/css/react-bootstrap-table.css');
+import AddSliceCard from './AddSliceCard';
+import AddSliceDragPreview from './dnd/AddSliceDragPreview';
+import DragDroppable from './dnd/DragDroppable';
+import { CHART_TYPE, NEW_COMPONENT_SOURCE_TYPE } from '../util/componentTypes';
+import { NEW_CHART_ID, NEW_COMPONENTS_SOURCE_ID } from '../util/constants';
+import { slicePropShape } from '../util/propShapes';
 
 const propTypes = {
-  dashboard: PropTypes.object.isRequired,
-  triggerNode: PropTypes.node.isRequired,
+  fetchAllSlices: PropTypes.func.isRequired,
+  isLoading: PropTypes.bool.isRequired,
+  slices: PropTypes.objectOf(slicePropShape).isRequired,
+  lastUpdated: PropTypes.number.isRequired,
+  errorMessage: PropTypes.string,
   userId: PropTypes.string.isRequired,
-  addSlicesToDashboard: PropTypes.func,
+  selectedSliceIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+  editMode: PropTypes.bool,
+  height: PropTypes.number,
+};
+
+const defaultProps = {
+  selectedSliceIds: [],
+  editMode: false,
+  errorMessage: '',
+  height: window.innerHeight,
 };
 
+const KEYS_TO_FILTERS = ['slice_name', 'viz_type', 'datasource_name'];
+const KEYS_TO_SORT = [
+  { key: 'slice_name', label: 'Name' },
+  { key: 'viz_type', label: 'Visualization' },
+  { key: 'datasource_name', label: 'Datasource' },
+  { key: 'changed_on', label: 'Recent' },
+];
+
+const MARGIN_BOTTOM = 16;
+const SIDEPANE_HEADER_HEIGHT = 55;
+const SLICE_ADDER_CONTROL_HEIGHT = 64;
+
 class SliceAdder extends React.Component {
+  static sortByComparator(attr) {
+    const desc = attr === 'changed_on' ? -1 : 1;
+
+    return (a, b) => {
+      if (a[attr] < b[attr]) {
+        return -1 * desc;
+      } else if (a[attr] > b[attr]) {
+        return 1 * desc;
+      }
+      return 0;
+    };
+  }
+
   constructor(props) {
     super(props);
     this.state = {
-      slices: [],
-      slicesLoaded: false,
-      selectionMap: {},
+      filteredSlices: [],
+      searchTerm: '',
+      sortBy: KEYS_TO_SORT.findIndex(item => item.key === 'changed_on'),
+      selectedSliceIdsSet: new Set(props.selectedSliceIds),
     };
 
-    this.options = {
-      defaultSortOrder: 'desc',
-      defaultSortName: 'modified',
-      sizePerPage: 10,
-    };
+    this.rowRenderer = this.rowRenderer.bind(this);
+    this.searchUpdated = this.searchUpdated.bind(this);
+    this.handleKeyPress = this.handleKeyPress.bind(this);
+    this.handleSelect = this.handleSelect.bind(this);
+  }
 
-    this.addSlices = this.addSlices.bind(this);
-    this.toggleSlice = this.toggleSlice.bind(this);
+  componentDidMount() {
+    this.slicesRequest = this.props.fetchAllSlices(this.props.userId);
+  }
 
-    this.selectRowProp = {
-      mode: 'checkbox',
-      clickToSelect: true,
-      onSelect: this.toggleSlice,
-    };
+  componentWillReceiveProps(nextProps) {
+    const nextState = {};
+    if (nextProps.lastUpdated !== this.props.lastUpdated) {
+      nextState.filteredSlices = Object.values(nextProps.slices)
+        .filter(createFilter(this.state.searchTerm, KEYS_TO_FILTERS))
+        .sort(SliceAdder.sortByComparator(KEYS_TO_SORT[this.state.sortBy].key));
+    }
+
+    if (nextProps.selectedSliceIds !== this.props.selectedSliceIds) {
+      nextState.selectedSliceIdsSet = new Set(nextProps.selectedSliceIds);
+    }
+
+    if (Object.keys(nextState).length) {
+      this.setState(nextState);
+    }
   }
 
   componentWillUnmount() {
-    if (this.slicesRequest) {
+    if (this.slicesRequest && this.slicesRequest.abort) {
       this.slicesRequest.abort();
     }
   }
 
-  onEnterModal() {
-    const uri = `/sliceaddview/api/read?_flt_0_created_by=${this.props.userId}`;
-    this.slicesRequest = $.ajax({
-      url: uri,
-      type: 'GET',
-      success: (response) => {
-        // Prepare slice data for table
-        const slices = response.result.map(slice => ({
-          id: slice.id,
-          sliceName: slice.slice_name,
-          vizType: slice.viz_type,
-          datasourceLink: slice.datasource_link,
-          modified: slice.modified,
-        }));
-
-        this.setState({
-          slices,
-          selectionMap: {},
-          slicesLoaded: true,
-        });
-      },
-      error: (error) => {
-        this.errored = true;
-        this.setState({
-          errorMsg: t('Sorry, there was an error fetching slices to this dashboard: ') +
-          this.getAjaxErrorMsg(error),
-        });
-      },
-    });
+  getFilteredSortedSlices(searchTerm, sortBy) {
+    return Object.values(this.props.slices)
+      .filter(createFilter(searchTerm, KEYS_TO_FILTERS))
+      .sort(SliceAdder.sortByComparator(KEYS_TO_SORT[sortBy].key));
   }
 
-  getAjaxErrorMsg(error) {
-    const respJSON = error.responseJSON;
-    return (respJSON && respJSON.message) ? respJSON.message :
-      error.responseText;
+  handleKeyPress(ev) {
+    if (ev.key === 'Enter') {
+      ev.preventDefault();
+
+      this.searchUpdated(ev.target.value);
+    }
   }
 
-  addSlices() {
-    const adder = this;
-    this.props.addSlicesToDashboard(Object.keys(this.state.selectionMap))
-      // if successful, page will be reloaded.
-      .fail((error) => {
-        adder.errored = true;
-        adder.setState({
-          errorMsg: t('Sorry, there was an error adding slices to this dashboard: ') +
-          this.getAjaxErrorMsg(error),
-        });
-      });
+  searchUpdated(searchTerm) {
+    this.setState({
+      searchTerm,
+      filteredSlices: this.getFilteredSortedSlices(
+        searchTerm,
+        this.state.sortBy,
+      ),
+    });
   }
 
-  toggleSlice(slice) {
-    const selectionMap = Object.assign({}, this.state.selectionMap);
-    selectionMap[slice.id] = !selectionMap[slice.id];
-    this.setState({ selectionMap });
+  handleSelect(sortBy) {
+    this.setState({
+      sortBy,
+      filteredSlices: this.getFilteredSortedSlices(
+        this.state.searchTerm,
+        sortBy,
+      ),
+    });
   }
 
-  modifiedDateComparator(a, b, order) {
-    if (order === 'desc') {
-      if (a.changed_on > b.changed_on) {
-        return -1;
-      } else if (a.changed_on < b.changed_on) {
-        return 1;
-      }
-      return 0;
-    }
+  rowRenderer({ key, index, style }) {
+    const { filteredSlices, selectedSliceIdsSet } = this.state;
+    const cellData = filteredSlices[index];
+    const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
+    const type = CHART_TYPE;
+    const id = NEW_CHART_ID;
 
-    if (a.changed_on < b.changed_on) {
-      return -1;
-    } else if (a.changed_on > b.changed_on) {
-      return 1;
-    }
-    return 0;
+    const meta = {
+      chartId: cellData.slice_id,
+      sliceName: cellData.slice_name,
+    };
+
+    return (
+      <DragDroppable
+        key={key}
+        component={{ type, id, meta }}
+        parentComponent={{
+          id: NEW_COMPONENTS_SOURCE_ID,
+          type: NEW_COMPONENT_SOURCE_TYPE,
+        }}
+        index={index}
+        depth={0}
+        disableDragDrop={isSelected}
+        editMode={this.props.editMode}
+        // we must use a custom drag preview within the List because
+        // it does not seem to work within a fixed-position container
+        useEmptyDragPreview
+      >
+        {({ dragSourceRef }) => (
+          <AddSliceCard
+            innerRef={dragSourceRef}
+            style={style}
+            sliceName={cellData.slice_name}
+            lastModified={
+              cellData.modified ? cellData.modified.replace(/<[^>]*>/g, '') : ''
+            }
+            visType={cellData.viz_type}
+            datasourceLink={cellData.datasource_link}
+            isSelected={isSelected}
+          />
+        )}
+      </DragDroppable>
+    );
   }
 
   render() {
-    const hideLoad = this.state.slicesLoaded || this.errored;
-    let enableAddSlice = this.state.selectionMap && Object.keys(this.state.selectionMap);
-    if (enableAddSlice) {
-      enableAddSlice = enableAddSlice.some(function (key) {
-        return this.state.selectionMap[key];
-      }, this);
-    }
-    const modalContent = (
-      <div>
-        <img
-          src="/static/assets/images/loading.gif"
-          className={'loading ' + (hideLoad ? 'hidden' : '')}
-          alt={hideLoad ? '' : 'loading'}
-        />
-        <div className={this.errored ? '' : 'hidden'}>
-          {this.state.errorMsg}
-        </div>
-        <div className={this.state.slicesLoaded ? '' : 'hidden'}>
-          <BootstrapTable
-            ref="table"
-            data={this.state.slices}
-            selectRow={this.selectRowProp}
-            options={this.options}
-            hover
-            search
-            pagination
-            condensed
-            height="auto"
-          >
-            <TableHeaderColumn
-              dataField="id"
-              isKey
-              dataSort
-              hidden
-            />
-            <TableHeaderColumn
-              dataField="sliceName"
-              dataSort
-            >
-              {t('Name')}
-            </TableHeaderColumn>
-            <TableHeaderColumn
-              dataField="vizType"
-              dataSort
-            >
-              {t('Viz')}
-            </TableHeaderColumn>
-            <TableHeaderColumn
-              dataField="datasourceLink"
-              dataSort
-              // Will cause react-bootstrap-table to interpret the HTML returned
-              dataFormat={datasourceLink => datasourceLink}
-            >
-              {t('Datasource')}
-            </TableHeaderColumn>
-            <TableHeaderColumn
-              dataField="modified"
-              dataSort
-              sortFunc={this.modifiedDateComparator}
-              // Will cause react-bootstrap-table to interpret the HTML returned
-              dataFormat={modified => modified}
-            >
-              {t('Modified')}
-            </TableHeaderColumn>
-          </BootstrapTable>
-          <button
-            type="button"
-            className="btn btn-default"
-            data-dismiss="modal"
-            onClick={this.addSlices}
-            disabled={!enableAddSlice}
+    const slicesListHeight =
+      this.props.height -
+      SIDEPANE_HEADER_HEIGHT -
+      SLICE_ADDER_CONTROL_HEIGHT -
+      MARGIN_BOTTOM;
+    return (
+      <div className="slice-adder-container">
+        <div className="controls">
+          <DropdownButton
+            title={KEYS_TO_SORT[this.state.sortBy].label}
+            onSelect={this.handleSelect}
+            id="slice-adder-sortby"
           >
-            {t('Add Slices')}
-          </button>
+            {KEYS_TO_SORT.map((item, index) => (
+              <MenuItem key={item.key} eventKey={index}>
+                {item.label}
+              </MenuItem>
+            ))}
+          </DropdownButton>
+
+          <SearchInput
+            className="search-input"
+            onChange={this.searchUpdated}
+            onKeyPress={this.handleKeyPress}
+          />
         </div>
-      </div>
-    );
 
-    return (
-      <ModalTrigger
-        triggerNode={this.props.triggerNode}
-        tooltip={t('Add a new slice to the dashboard')}
-        beforeOpen={this.onEnterModal.bind(this)}
-        isMenuItem
-        modalBody={modalContent}
-        bsSize="large"
-        setModalAsTriggerChildren
-        modalTitle={t('Add Slices to Dashboard')}
-      />
+        {this.props.isLoading && (
+          <img
+            src="/static/assets/images/loading.gif"
+            className="loading"
+            alt="loading"
+          />
+        )}
+
+        {this.props.errorMessage && <div>{this.props.errorMessage}</div>}
+
+        {!this.props.isLoading &&
+          this.state.filteredSlices.length > 0 && (
+            <List
+              width={376}
+              height={slicesListHeight}
+              rowCount={this.state.filteredSlices.length}
+              rowHeight={136}
+              rowRenderer={this.rowRenderer}
+              searchTerm={this.state.searchTerm}
+              sortBy={this.state.sortBy}
+              selectedSliceIds={this.props.selectedSliceIds}
+            />
+          )}
+
+        {/* Drag preview is just a single fixed-position element */}
+        <AddSliceDragPreview slices={this.state.filteredSlices} />
+      </div>
     );
   }
 }
 
 SliceAdder.propTypes = propTypes;
+SliceAdder.defaultProps = defaultProps;
 
 export default SliceAdder;
diff --git a/superset/assets/src/dashboard/components/SliceHeader.jsx b/superset/assets/src/dashboard/components/SliceHeader.jsx
index 8abcc86d61..7086676825 100644
--- a/superset/assets/src/dashboard/components/SliceHeader.jsx
+++ b/superset/assets/src/dashboard/components/SliceHeader.jsx
@@ -1,17 +1,17 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import moment from 'moment';
 
 import { t } from '../../locales';
 import EditableTitle from '../../components/EditableTitle';
 import TooltipWrapper from '../../components/TooltipWrapper';
+import SliceHeaderControls from './SliceHeaderControls';
 
 const propTypes = {
+  innerRef: PropTypes.func,
   slice: PropTypes.object.isRequired,
   isExpanded: PropTypes.bool,
   isCached: PropTypes.bool,
   cachedDttm: PropTypes.string,
-  removeSlice: PropTypes.func,
   updateSliceName: PropTypes.func,
   toggleExpandSlice: PropTypes.func,
   forceRefresh: PropTypes.func,
@@ -20,9 +20,11 @@ const propTypes = {
   editMode: PropTypes.bool,
   annotationQuery: PropTypes.object,
   annotationError: PropTypes.object,
+  sliceName: PropTypes.string,
 };
 
 const defaultProps = {
+  innerRef: null,
   forceRefresh: () => ({}),
   removeSlice: () => ({}),
   updateSliceName: () => ({}),
@@ -30,142 +32,76 @@ const defaultProps = {
   exploreChart: () => ({}),
   exportCSV: () => ({}),
   editMode: false,
+  annotationQuery: {},
+  annotationError: {},
+  cachedDttm: null,
+  isCached: false,
+  isExpanded: false,
+  sliceName: '',
 };
 
 class SliceHeader extends React.PureComponent {
-  constructor(props) {
-    super(props);
-
-    this.onSaveTitle = this.onSaveTitle.bind(this);
-    this.onToggleExpandSlice = this.onToggleExpandSlice.bind(this);
-    this.exportCSV = this.props.exportCSV.bind(this, this.props.slice);
-    this.exploreChart = this.props.exploreChart.bind(this, this.props.slice);
-    this.forceRefresh = this.props.forceRefresh.bind(this, this.props.slice.slice_id);
-    this.removeSlice = this.props.removeSlice.bind(this, this.props.slice);
-  }
-
-  onSaveTitle(newTitle) {
-    if (this.props.updateSliceName) {
-      this.props.updateSliceName(this.props.slice.slice_id, newTitle);
-    }
-  }
-
-  onToggleExpandSlice() {
-    this.props.toggleExpandSlice(this.props.slice, !this.props.isExpanded);
-  }
-
   render() {
-    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 {
+      slice,
+      isExpanded,
+      isCached,
+      cachedDttm,
+      toggleExpandSlice,
+      forceRefresh,
+      exploreChart,
+      exportCSV,
+      innerRef,
+      sliceName,
+    } = this.props;
+
     const annoationsLoading = t('Annotation layers are still loading.');
     const annoationsError = t('One ore more annotation layers failed loading.');
 
     return (
-      <div className="row chart-header">
-        <div className="col-md-12">
-          <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.'}
-            />
-            {!!Object.values(this.props.annotationQuery || {}).length &&
-              <TooltipWrapper
-                label="annotations-loading"
-                placement="top"
-                tooltip={annoationsLoading}
-              >
-                <i className="fa fa-refresh warning" />
-              </TooltipWrapper>
+      <div className="chart-header" ref={innerRef}>
+        <div className="header">
+          <EditableTitle
+            title={
+              sliceName ||
+              (this.props.editMode
+                ? '---' // this makes an empty title clickable
+                : '')
             }
-            {!!Object.values(this.props.annotationError || {}).length &&
-              <TooltipWrapper
-                label="annoation-errors"
-                placement="top"
-                tooltip={annoationsError}
-              >
-                <i className="fa fa-exclamation-circle danger" />
-              </TooltipWrapper>
-            }
-          </div>
-          <div className="chart-controls">
-            <div id={'controls_' + slice.slice_id} className="pull-right">
-              {this.props.editMode &&
-                <a>
-                  <TooltipWrapper
-                    placement="top"
-                    label="move"
-                    tooltip={t('Move chart')}
-                  >
-                    <i className="fa fa-arrows drag" />
-                  </TooltipWrapper>
-                </a>
-              }
-              <a className={`refresh ${isCached ? 'danger' : ''}`} onClick={this.forceRefresh}>
-                <TooltipWrapper
-                  placement="top"
-                  label="refresh"
-                  tooltip={refreshTooltip}
-                >
-                  <i className="fa fa-repeat" />
-                </TooltipWrapper>
-              </a>
-              {slice.description &&
-              <a onClick={this.onToggleExpandSlice}>
-                <TooltipWrapper
-                  placement="top"
-                  label="description"
-                  tooltip={t('Toggle chart description')}
-                >
-                  <i className="fa fa-info-circle slice_info" />
-                </TooltipWrapper>
-              </a>
-              }
-              <a href={slice.edit_url} target="_blank">
-                <TooltipWrapper
-                  placement="top"
-                  label="edit"
-                  tooltip={t('Edit chart')}
-                >
-                  <i className="fa fa-pencil" />
-                </TooltipWrapper>
-              </a>
-              <a className="exportCSV" onClick={this.exportCSV}>
-                <TooltipWrapper
-                  placement="top"
-                  label="exportCSV"
-                  tooltip={t('Export CSV')}
-                >
-                  <i className="fa fa-table" />
-                </TooltipWrapper>
-              </a>
-              <a className="exploreChart" onClick={this.exploreChart}>
-                <TooltipWrapper
-                  placement="top"
-                  label="exploreChart"
-                  tooltip={t('Explore chart')}
-                >
-                  <i className="fa fa-share" />
-                </TooltipWrapper>
-              </a>
-              {this.props.editMode &&
-                <a className="remove-chart" onClick={this.removeSlice}>
-                  <TooltipWrapper
-                    placement="top"
-                    label="close"
-                    tooltip={t('Remove chart from dashboard')}
-                  >
-                    <i className="fa fa-close" />
-                  </TooltipWrapper>
-                </a>
-              }
-            </div>
-          </div>
+            canEdit={this.props.editMode}
+            onSaveTitle={this.props.updateSliceName}
+            showTooltip={false}
+          />
+          {!!Object.values(this.props.annotationQuery).length && (
+            <TooltipWrapper
+              label="annotations-loading"
+              placement="top"
+              tooltip={annoationsLoading}
+            >
+              <i className="fa fa-refresh warning" />
+            </TooltipWrapper>
+          )}
+          {!!Object.values(this.props.annotationError).length && (
+            <TooltipWrapper
+              label="annoation-errors"
+              placement="top"
+              tooltip={annoationsError}
+            >
+              <i className="fa fa-exclamation-circle danger" />
+            </TooltipWrapper>
+          )}
+          {!this.props.editMode && (
+            <SliceHeaderControls
+              slice={slice}
+              isCached={isCached}
+              isExpanded={isExpanded}
+              cachedDttm={cachedDttm}
+              toggleExpandSlice={toggleExpandSlice}
+              forceRefresh={forceRefresh}
+              exploreChart={exploreChart}
+              exportCSV={exportCSV}
+            />
+          )}
         </div>
       </div>
     );
diff --git a/superset/assets/src/dashboard/components/SliceHeaderControls.jsx b/superset/assets/src/dashboard/components/SliceHeaderControls.jsx
new file mode 100644
index 0000000000..de8e653a81
--- /dev/null
+++ b/superset/assets/src/dashboard/components/SliceHeaderControls.jsx
@@ -0,0 +1,122 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+import moment from 'moment';
+import { Dropdown, MenuItem } from 'react-bootstrap';
+
+import { t } from '../../locales';
+
+const propTypes = {
+  slice: PropTypes.object.isRequired,
+  isCached: PropTypes.bool,
+  isExpanded: PropTypes.bool,
+  cachedDttm: PropTypes.string,
+  supersetCanExplore: PropTypes.bool,
+  sliceCanEdit: PropTypes.bool,
+  toggleExpandSlice: PropTypes.func,
+  forceRefresh: PropTypes.func,
+  exploreChart: PropTypes.func,
+  exportCSV: PropTypes.func,
+};
+
+const defaultProps = {
+  forceRefresh: () => ({}),
+  toggleExpandSlice: () => ({}),
+  exploreChart: () => ({}),
+  exportCSV: () => ({}),
+  cachedDttm: null,
+  isCached: false,
+  isExpanded: false,
+  supersetCanExplore: false,
+  sliceCanEdit: 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);
+    this.exportCSV = this.props.exportCSV.bind(this, this.props.slice.slice_id);
+    this.exploreChart = this.props.exploreChart.bind(
+      this,
+      this.props.slice.slice_id,
+    );
+    this.toggleExpandSlice = this.props.toggleExpandSlice.bind(
+      this,
+      this.props.slice.slice_id,
+    );
+    this.toggleControls = this.toggleControls.bind(this);
+
+    this.state = {
+      showControls: false,
+    };
+  }
+
+  toggleControls() {
+    this.setState({
+      showControls: !this.state.showControls,
+    });
+  }
+
+  render() {
+    const slice = this.props.slice;
+    const isCached = this.props.isCached;
+    const cachedWhen = moment.utc(this.props.cachedDttm).fromNow();
+    const refreshTooltip = isCached ? t('Cached %s', cachedWhen) : '';
+
+    return (
+      <Dropdown
+        id={`slice_${slice.slice_id}-controls`}
+        className={cx(isCached && 'is-cached')}
+        pullRight
+      >
+        <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>
+          )}
+
+          {this.props.sliceCanEdit && (
+            <MenuItem href={slice.edit_url} target="_blank">
+              {t('Edit chart metadata')}
+            </MenuItem>
+          )}
+
+          <MenuItem onClick={this.exportCSV}>{t('Export CSV')}</MenuItem>
+
+          {this.props.supersetCanExplore && (
+            <MenuItem onClick={this.exploreChart}>
+              {t('Explore chart')}
+            </MenuItem>
+          )}
+        </Dropdown.Menu>
+      </Dropdown>
+    );
+  }
+}
+
+SliceHeaderControls.propTypes = propTypes;
+SliceHeaderControls.defaultProps = defaultProps;
+
+export default SliceHeaderControls;
diff --git a/superset/assets/src/dashboard/components/Toast.jsx b/superset/assets/src/dashboard/components/Toast.jsx
new file mode 100644
index 0000000000..3c5a3caaaf
--- /dev/null
+++ b/superset/assets/src/dashboard/components/Toast.jsx
@@ -0,0 +1,94 @@
+import { Alert } from 'react-bootstrap';
+import cx from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import { toastShape } from '../util/propShapes';
+import {
+  INFO_TOAST,
+  SUCCESS_TOAST,
+  WARNING_TOAST,
+  DANGER_TOAST,
+} from '../util/constants';
+
+const propTypes = {
+  toast: toastShape.isRequired,
+  onCloseToast: PropTypes.func.isRequired,
+  delay: PropTypes.number,
+  duration: PropTypes.number, // if duration is >0, the toast will close on its own
+};
+
+const defaultProps = {
+  delay: 0,
+  duration: 0,
+};
+
+class Toast extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      visible: false,
+    };
+
+    this.showToast = this.showToast.bind(this);
+    this.handleClosePress = this.handleClosePress.bind(this);
+  }
+
+  componentDidMount() {
+    const { delay, duration } = this.props;
+
+    setTimeout(this.showToast, delay);
+
+    if (duration > 0) {
+      this.hideTimer = setTimeout(this.handleClosePress, delay + duration);
+    }
+  }
+
+  componentWillUnmount() {
+    clearTimeout(this.hideTimer);
+  }
+
+  showToast() {
+    this.setState({ visible: true });
+  }
+
+  handleClosePress() {
+    clearTimeout(this.hideTimer);
+
+    this.setState({ visible: false }, () => {
+      // Wait for the transition
+      setTimeout(() => {
+        this.props.onCloseToast(this.props.toast.id);
+      }, 150);
+    });
+  }
+
+  render() {
+    const { visible } = this.state;
+    const {
+      toast: { toastType, text },
+    } = this.props;
+
+    return (
+      <Alert
+        onDismiss={this.handleClosePress}
+        bsClass={cx(
+          'alert',
+          'toast',
+          visible && 'toast--visible',
+          toastType === INFO_TOAST && 'toast--info',
+          toastType === SUCCESS_TOAST && 'toast--success',
+          toastType === WARNING_TOAST && 'toast--warning',
+          toastType === DANGER_TOAST && 'toast--danger',
+        )}
+      >
+        {text}
+      </Alert>
+    );
+  }
+}
+
+Toast.propTypes = propTypes;
+Toast.defaultProps = defaultProps;
+
+export default Toast;
diff --git a/superset/assets/src/dashboard/components/ToastPresenter.jsx b/superset/assets/src/dashboard/components/ToastPresenter.jsx
new file mode 100644
index 0000000000..19d44b0e8a
--- /dev/null
+++ b/superset/assets/src/dashboard/components/ToastPresenter.jsx
@@ -0,0 +1,36 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import Toast from './Toast';
+import { toastShape } from '../util/propShapes';
+
+const propTypes = {
+  toasts: PropTypes.arrayOf(toastShape),
+  removeToast: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+  toasts: [],
+};
+
+// eslint-disable-next-line react/prefer-stateless-function
+class ToastPresenter extends React.Component {
+  render() {
+    const { toasts, removeToast } = this.props;
+
+    return (
+      toasts.length > 0 && (
+        <div className="toast-presenter">
+          {toasts.map(toast => (
+            <Toast key={toast.id} toast={toast} onCloseToast={removeToast} />
+          ))}
+        </div>
+      )
+    );
+  }
+}
+
+ToastPresenter.propTypes = propTypes;
+ToastPresenter.defaultProps = defaultProps;
+
+export default ToastPresenter;
diff --git a/superset/assets/src/dashboard/components/dnd/AddSliceDragPreview.jsx b/superset/assets/src/dashboard/components/dnd/AddSliceDragPreview.jsx
new file mode 100644
index 0000000000..91fc0558b3
--- /dev/null
+++ b/superset/assets/src/dashboard/components/dnd/AddSliceDragPreview.jsx
@@ -0,0 +1,75 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { DragLayer } from 'react-dnd';
+
+import AddSliceCard from '../AddSliceCard';
+import { slicePropShape } from '../../util/propShapes';
+import {
+  NEW_COMPONENT_SOURCE_TYPE,
+  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,
+  }),
+  slices: PropTypes.arrayOf(slicePropShape),
+  isDragging: PropTypes.bool.isRequired,
+  currentOffset: PropTypes.shape({
+    x: PropTypes.number.isRequired,
+    y: PropTypes.number.isRequired,
+  }),
+};
+
+const defaultProps = {
+  currentOffset: null,
+  dragItem: null,
+  slices: null,
+};
+
+function AddSliceDragPreview({ dragItem, slices, isDragging, currentOffset }) {
+  if (!isDragging || !currentOffset || !dragItem || !slices) return null;
+
+  const slice = slices[dragItem.index];
+
+  // make sure it's a new component and a chart
+  const shouldRender =
+    slice &&
+    dragItem.parentType === NEW_COMPONENT_SOURCE_TYPE &&
+    dragItem.type === CHART_TYPE;
+
+  return !shouldRender ? null : (
+    <AddSliceCard
+      style={{
+        ...staticCardStyles,
+        transform: `translate(${currentOffset.x}px, ${currentOffset.y}px)`,
+      }}
+      sliceName={slice.slice_name}
+      lastModified={
+        slice.modified ? slice.modified.replace(/<[^>]*>/g, '') : ''
+      }
+      visType={slice.viz_type}
+      datasourceLink={slice.datasource_link}
+    />
+  );
+}
+
+AddSliceDragPreview.propTypes = propTypes;
+AddSliceDragPreview.defaultProps = defaultProps;
+
+// This injects these props into the component
+export default DragLayer(monitor => ({
+  dragItem: monitor.getItem(),
+  currentOffset: monitor.getSourceClientOffset(),
+  isDragging: monitor.isDragging(),
+}))(AddSliceDragPreview);
diff --git a/superset/assets/src/dashboard/components/dnd/DragDroppable.jsx b/superset/assets/src/dashboard/components/dnd/DragDroppable.jsx
new file mode 100644
index 0000000000..ef116ea132
--- /dev/null
+++ b/superset/assets/src/dashboard/components/dnd/DragDroppable.jsx
@@ -0,0 +1,141 @@
+import { getEmptyImage } from 'react-dnd-html5-backend';
+import React from 'react';
+import PropTypes from 'prop-types';
+import { DragSource, DropTarget } from 'react-dnd';
+import cx from 'classnames';
+
+import { componentShape } from '../../util/propShapes';
+import { dragConfig, dropConfig } from './dragDroppableConfig';
+import {
+  DROP_TOP,
+  DROP_RIGHT,
+  DROP_BOTTOM,
+  DROP_LEFT,
+} from '../../util/getDropPosition';
+
+const propTypes = {
+  children: PropTypes.func,
+  className: PropTypes.string,
+  component: componentShape.isRequired,
+  parentComponent: componentShape,
+  depth: PropTypes.number.isRequired,
+  disableDragDrop: PropTypes.bool,
+  orientation: PropTypes.oneOf(['row', 'column']),
+  index: PropTypes.number.isRequired,
+  style: PropTypes.object,
+  onDrop: PropTypes.func,
+  editMode: PropTypes.bool.isRequired,
+  useEmptyDragPreview: PropTypes.bool,
+
+  // from react-dnd
+  isDragging: PropTypes.bool.isRequired,
+  isDraggingOver: PropTypes.bool.isRequired,
+  isDraggingOverShallow: PropTypes.bool.isRequired,
+  droppableRef: PropTypes.func.isRequired,
+  dragSourceRef: PropTypes.func.isRequired,
+  dragPreviewRef: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+  className: null,
+  style: null,
+  parentComponent: null,
+  disableDragDrop: false,
+  children() {},
+  onDrop() {},
+  orientation: 'row',
+  useEmptyDragPreview: false,
+};
+
+// export unwrapped component for testing
+export class UnwrappedDragDroppable extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      dropIndicator: null, // this gets set/modified by the react-dnd HOCs
+    };
+    this.setRef = this.setRef.bind(this);
+  }
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  setRef(ref) {
+    this.ref = ref;
+    // this is needed for a custom drag preview
+    if (this.props.useEmptyDragPreview) {
+      this.props.dragPreviewRef(getEmptyImage(), {
+        // IE fallback: specify that we'd rather screenshot the node
+        // when it already knows it's being dragged so we can hide it with CSS.
+        captureDraggingState: true,
+      });
+    } else {
+      this.props.dragPreviewRef(ref);
+    }
+    this.props.droppableRef(ref);
+  }
+
+  render() {
+    const {
+      children,
+      className,
+      orientation,
+      dragSourceRef,
+      isDragging,
+      isDraggingOver,
+      style,
+      editMode,
+    } = 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
+        style={style}
+        ref={this.setRef}
+        className={cx(
+          'dragdroppable',
+          orientation === 'row' && 'dragdroppable-row',
+          orientation === 'column' && 'dragdroppable-column',
+          isDragging && 'dragdroppable--dragging',
+          className,
+        )}
+      >
+        {children(childProps)}
+      </div>
+    );
+  }
+}
+
+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)(UnwrappedDragDroppable),
+);
diff --git a/superset/assets/src/dashboard/components/dnd/DragHandle.jsx b/superset/assets/src/dashboard/components/dnd/DragHandle.jsx
new file mode 100644
index 0000000000..23d7d11e83
--- /dev/null
+++ b/superset/assets/src/dashboard/components/dnd/DragHandle.jsx
@@ -0,0 +1,40 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+const propTypes = {
+  position: PropTypes.oneOf(['left', 'top']),
+  innerRef: PropTypes.func,
+  dotCount: PropTypes.number,
+};
+
+const defaultProps = {
+  position: 'left',
+  innerRef: null,
+  dotCount: 8,
+};
+
+export default class DragHandle extends React.PureComponent {
+  render() {
+    const { innerRef, position, dotCount } = this.props;
+    return (
+      <div
+        ref={innerRef}
+        className={cx(
+          'drag-handle',
+          position === 'left' && 'drag-handle--left',
+          position === 'top' && 'drag-handle--top',
+        )}
+      >
+        {Array(dotCount)
+          .fill(null)
+          .map((_, i) => (
+            <div key={`handle-dot-${i}`} className="drag-handle-dot" />
+          ))}
+      </div>
+    );
+  }
+}
+
+DragHandle.propTypes = propTypes;
+DragHandle.defaultProps = defaultProps;
diff --git a/superset/assets/src/dashboard/components/dnd/dragDroppableConfig.js b/superset/assets/src/dashboard/components/dnd/dragDroppableConfig.js
new file mode 100644
index 0000000000..36c34a0e40
--- /dev/null
+++ b/superset/assets/src/dashboard/components/dnd/dragDroppableConfig.js
@@ -0,0 +1,67 @@
+import handleHover from './handleHover';
+import handleDrop from './handleDrop';
+
+// note: the 'type' hook is not useful for us as dropping is contigent on other properties
+const TYPE = 'DRAG_DROPPABLE';
+
+export const dragConfig = [
+  TYPE,
+  {
+    canDrag(props) {
+      return !props.disableDragDrop;
+    },
+
+    // this defines the dragging item object returned by monitor.getItem()
+    beginDrag(props /* , monitor, component */) {
+      const { component, index, parentComponent = {} } = props;
+      return {
+        type: component.type,
+        id: component.id,
+        meta: component.meta,
+        index,
+        parentId: parentComponent.id,
+        parentType: parentComponent.type,
+      };
+    },
+  },
+  function dragStateToProps(connect, monitor) {
+    return {
+      dragSourceRef: connect.dragSource(),
+      dragPreviewRef: connect.dragPreview(),
+      isDragging: monitor.isDragging(),
+    };
+  },
+];
+
+export const dropConfig = [
+  TYPE,
+  {
+    hover(props, monitor, component) {
+      if (
+        component &&
+        component.decoratedComponentInstance &&
+        component.decoratedComponentInstance.mounted
+      ) {
+        handleHover(props, monitor, component.decoratedComponentInstance);
+      }
+    },
+    // note:
+    //  the react-dnd api requires that the drop() method return a result or undefined
+    //  monitor.didDrop() cannot be used because it returns true only for the most-nested target
+    drop(props, monitor, component) {
+      const Component = component.decoratedComponentInstance;
+      const dropResult = monitor.getDropResult();
+      if ((!dropResult || !dropResult.destination) && Component.mounted) {
+        return handleDrop(props, monitor, Component);
+      }
+      return undefined;
+    },
+  },
+  function dropStateToProps(connect, monitor) {
+    return {
+      droppableRef: connect.dropTarget(),
+      isDraggingOver: monitor.isOver(),
+      isDraggingOverShallow: monitor.isOver({ shallow: true }),
+    };
+  },
+];
diff --git a/superset/assets/src/dashboard/components/dnd/handleDrop.js b/superset/assets/src/dashboard/components/dnd/handleDrop.js
new file mode 100644
index 0000000000..3739b18385
--- /dev/null
+++ b/superset/assets/src/dashboard/components/dnd/handleDrop.js
@@ -0,0 +1,80 @@
+import getDropPosition, {
+  DROP_TOP,
+  DROP_RIGHT,
+  DROP_BOTTOM,
+  DROP_LEFT,
+} from '../../util/getDropPosition';
+
+export default function handleDrop(props, monitor, Component) {
+  // this may happen due to throttling
+  if (!Component.mounted) return undefined;
+
+  Component.setState(() => ({ dropIndicator: null }));
+  const dropPosition = getDropPosition(monitor, Component);
+
+  if (!dropPosition) {
+    return undefined;
+  }
+
+  const {
+    parentComponent,
+    component,
+    index: componentIndex,
+    onDrop,
+    orientation,
+  } = Component.props;
+
+  const draggingItem = monitor.getItem();
+
+  const dropAsChildOrSibling =
+    (orientation === 'row' &&
+      (dropPosition === DROP_TOP || dropPosition === DROP_BOTTOM)) ||
+    (orientation === 'column' &&
+      (dropPosition === DROP_LEFT || dropPosition === DROP_RIGHT))
+      ? 'sibling'
+      : 'child';
+
+  const dropResult = {
+    source: {
+      id: draggingItem.parentId,
+      type: draggingItem.parentType,
+      index: draggingItem.index,
+    },
+    dragging: {
+      id: draggingItem.id,
+      type: draggingItem.type,
+      meta: draggingItem.meta,
+    },
+  };
+
+  // simplest case, append as child
+  if (dropAsChildOrSibling === 'child') {
+    dropResult.destination = {
+      id: component.id,
+      type: component.type,
+      index: component.children.length,
+    };
+  } else {
+    // if the item is in the same list with a smaller index, you must account for the
+    // "missing" index upon movement within the list
+    const sameParent =
+      parentComponent && draggingItem.parentId === parentComponent.id;
+    const sameParentLowerIndex =
+      sameParent && draggingItem.index < componentIndex;
+
+    let nextIndex = sameParentLowerIndex ? componentIndex - 1 : componentIndex;
+    if (dropPosition === DROP_BOTTOM || dropPosition === DROP_RIGHT) {
+      nextIndex += 1;
+    }
+
+    dropResult.destination = {
+      id: parentComponent.id,
+      type: parentComponent.type,
+      index: nextIndex,
+    };
+  }
+
+  onDrop(dropResult);
+
+  return dropResult;
+}
diff --git a/superset/assets/src/dashboard/components/dnd/handleHover.js b/superset/assets/src/dashboard/components/dnd/handleHover.js
new file mode 100644
index 0000000000..a303e133f0
--- /dev/null
+++ b/superset/assets/src/dashboard/components/dnd/handleHover.js
@@ -0,0 +1,23 @@
+import throttle from 'lodash.throttle';
+import getDropPosition from '../../util/getDropPosition';
+
+const HOVER_THROTTLE_MS = 200;
+
+function handleHover(props, monitor, Component) {
+  // this may happen due to throttling
+  if (!Component.mounted) return;
+
+  const dropPosition = getDropPosition(monitor, Component);
+
+  if (!dropPosition) {
+    Component.setState(() => ({ dropIndicator: null }));
+    return;
+  }
+
+  Component.setState(() => ({
+    dropIndicator: dropPosition,
+  }));
+}
+
+// this is called very frequently by react-dnd
+export default throttle(handleHover, HOVER_THROTTLE_MS);
diff --git a/superset/assets/src/dashboard/components/gridComponents/Chart.jsx b/superset/assets/src/dashboard/components/gridComponents/Chart.jsx
new file mode 100644
index 0000000000..b36f3a3252
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/Chart.jsx
@@ -0,0 +1,245 @@
+import cx from 'classnames';
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { exportChart } from '../../../explore/exploreUtils';
+import SliceHeader from '../SliceHeader';
+import ChartContainer from '../../../chart/ChartContainer';
+import { chartPropType } from '../../../chart/chartReducer';
+import { slicePropShape } from '../../util/propShapes';
+import { VIZ_TYPES } from '../../../visualizations/main';
+
+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,
+  toggleExpandSlice: PropTypes.func.isRequired,
+  addFilter: PropTypes.func.isRequired,
+  removeFilter: PropTypes.func.isRequired,
+  editMode: PropTypes.bool.isRequired,
+  isExpanded: PropTypes.bool.isRequired,
+  supersetCanExplore: PropTypes.bool.isRequired,
+  sliceCanEdit: PropTypes.bool.isRequired,
+};
+
+// we use state + shouldComponentUpdate() logic to prevent perf-wrecking
+// resizing across all slices on a dashboard on every update
+const RESIZE_TIMEOUT = 350;
+const SHOULD_UPDATE_ON_PROP_CHANGES = Object.keys(propTypes).filter(
+  prop => prop !== 'width' && prop !== 'height',
+);
+const OVERFLOWABLE_VIZ_TYPES = new Set([VIZ_TYPES.filter_box]);
+
+class Chart extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      width: props.width,
+      height: props.height,
+    };
+
+    this.addFilter = this.addFilter.bind(this);
+    this.exploreChart = this.exploreChart.bind(this);
+    this.exportCSV = this.exportCSV.bind(this);
+    this.forceRefresh = this.forceRefresh.bind(this);
+    this.getFilters = this.getFilters.bind(this);
+    this.removeFilter = this.removeFilter.bind(this);
+    this.resize = this.resize.bind(this);
+    this.setDescriptionRef = this.setDescriptionRef.bind(this);
+    this.setHeaderRef = this.setHeaderRef.bind(this);
+  }
+
+  shouldComponentUpdate(nextProps, nextState) {
+    // this logic mostly pertains to chart resizing. we keep a copy of the dimensions in
+    // state so that we can buffer component size updates and only update on the final call
+    // which improves performance significantly
+    if (
+      nextState.width !== this.state.width ||
+      nextState.height !== this.state.height
+    ) {
+      return true;
+    }
+
+    for (let i = 0; i < SHOULD_UPDATE_ON_PROP_CHANGES.length; i += 1) {
+      const prop = SHOULD_UPDATE_ON_PROP_CHANGES[i];
+      if (nextProps[prop] !== this.props[prop]) {
+        return true;
+      }
+    }
+
+    if (
+      nextProps.width !== this.props.width ||
+      nextProps.height !== this.props.height
+    ) {
+      clearTimeout(this.resizeTimeout);
+      this.resizeTimeout = setTimeout(this.resize, RESIZE_TIMEOUT);
+    }
+
+    return false;
+  }
+
+  componentWillUnmount() {
+    clearTimeout(this.resizeTimeout);
+  }
+
+  getFilters() {
+    return this.props.filters;
+  }
+
+  getChartHeight() {
+    const headerHeight = this.getHeaderHeight();
+    const descriptionHeight =
+      this.props.isExpanded && this.descriptionRef
+        ? this.descriptionRef.offsetHeight
+        : 0;
+
+    return this.state.height - headerHeight - descriptionHeight;
+  }
+
+  getHeaderHeight() {
+    return (this.headerRef && this.headerRef.offsetHeight) || 30;
+  }
+
+  setDescriptionRef(ref) {
+    this.descriptionRef = ref;
+  }
+
+  setHeaderRef(ref) {
+    this.headerRef = ref;
+  }
+
+  resize() {
+    const { width, height } = this.props;
+    this.setState(() => ({ width, height }));
+  }
+
+  addFilter(...args) {
+    this.props.addFilter(this.props.chart, ...args);
+  }
+
+  exploreChart() {
+    exportChart(this.props.formData);
+  }
+
+  exportCSV() {
+    exportChart(this.props.formData, 'csv');
+  }
+
+  forceRefresh() {
+    return this.props.refreshChart(this.props.chart, true, this.props.timeout);
+  }
+
+  removeFilter(...args) {
+    this.props.removeFilter(this.props.id, ...args);
+  }
+
+  render() {
+    const {
+      id,
+      chart,
+      slice,
+      datasource,
+      isExpanded,
+      editMode,
+      formData,
+      updateSliceName,
+      sliceName,
+      toggleExpandSlice,
+      timeout,
+      supersetCanExplore,
+      sliceCanEdit,
+    } = this.props;
+
+    const { width } = this.state;
+    const { queryResponse } = chart;
+    const isCached = queryResponse && queryResponse.is_cached;
+    const cachedDttm = queryResponse && queryResponse.cached_dttm;
+    const isOverflowable = OVERFLOWABLE_VIZ_TYPES.has(slice && slice.viz_type);
+
+    return (
+      <div>
+        <SliceHeader
+          innerRef={this.setHeaderRef}
+          slice={slice}
+          isExpanded={!!isExpanded}
+          isCached={isCached}
+          cachedDttm={cachedDttm}
+          toggleExpandSlice={toggleExpandSlice}
+          forceRefresh={this.forceRefresh}
+          editMode={editMode}
+          annotationQuery={chart.annotationQuery}
+          exploreChart={this.exploreChart}
+          exportCSV={this.exportCSV}
+          updateSliceName={updateSliceName}
+          sliceName={sliceName}
+          supersetCanExplore={supersetCanExplore}
+          sliceCanEdit={sliceCanEdit}
+        />
+
+        {/*
+          This usage of dangerouslySetInnerHTML is safe since it is being used to render
+          markdown that is sanitized with bleach. See:
+             https://github.com/apache/incubator-superset/pull/4390
+          and
+             https://github.com/apache/incubator-superset/commit/b6fcc22d5a2cb7a5e92599ed5795a0169385a825
+        */}
+        {isExpanded &&
+          slice.description_markeddown && (
+            <div
+              className="slice_description bs-callout bs-callout-default"
+              ref={this.setDescriptionRef}
+              // eslint-disable-next-line react/no-danger
+              dangerouslySetInnerHTML={{ __html: slice.description_markeddown }}
+            />
+          )}
+
+        <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>
+    );
+  }
+}
+
+Chart.propTypes = propTypes;
+
+export default Chart;
diff --git a/superset/assets/src/dashboard/components/gridComponents/ChartHolder.jsx b/superset/assets/src/dashboard/components/gridComponents/ChartHolder.jsx
new file mode 100644
index 0000000000..ab030f4764
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/ChartHolder.jsx
@@ -0,0 +1,159 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Chart from '../../containers/Chart';
+import DeleteComponentButton from '../DeleteComponentButton';
+import DragDroppable from '../dnd/DragDroppable';
+import HoverMenu from '../menu/HoverMenu';
+import ResizableContainer from '../resizable/ResizableContainer';
+import { componentShape } from '../../util/propShapes';
+import { ROW_TYPE, COLUMN_TYPE } from '../../util/componentTypes';
+import {
+  GRID_MIN_COLUMN_COUNT,
+  GRID_MIN_ROW_UNITS,
+  GRID_BASE_UNIT,
+} from '../../util/constants';
+
+const CHART_MARGIN = 32;
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  parentId: PropTypes.string.isRequired,
+  component: componentShape.isRequired,
+  parentComponent: componentShape.isRequired,
+  index: PropTypes.number.isRequired,
+  depth: PropTypes.number.isRequired,
+  editMode: PropTypes.bool.isRequired,
+
+  // grid related
+  availableColumnCount: PropTypes.number.isRequired,
+  columnWidth: PropTypes.number.isRequired,
+  onResizeStart: PropTypes.func.isRequired,
+  onResize: PropTypes.func.isRequired,
+  onResizeStop: PropTypes.func.isRequired,
+
+  // dnd
+  deleteComponent: PropTypes.func.isRequired,
+  updateComponents: PropTypes.func.isRequired,
+  handleComponentDrop: PropTypes.func.isRequired,
+};
+
+const defaultProps = {};
+
+class ChartHolder extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      isFocused: false,
+    };
+
+    this.handleChangeFocus = this.handleChangeFocus.bind(this);
+    this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+    this.handleUpdateSliceName = this.handleUpdateSliceName.bind(this);
+  }
+
+  handleChangeFocus(nextFocus) {
+    this.setState(() => ({ isFocused: nextFocus }));
+  }
+
+  handleDeleteComponent() {
+    const { deleteComponent, id, parentId } = this.props;
+    deleteComponent(id, parentId);
+  }
+
+  handleUpdateSliceName(nextName) {
+    const { component, updateComponents } = this.props;
+    updateComponents({
+      [component.id]: {
+        ...component,
+        meta: {
+          ...component.meta,
+          sliceName: nextName,
+        },
+      },
+    });
+  }
+
+  render() {
+    const { isFocused } = this.state;
+
+    const {
+      component,
+      parentComponent,
+      index,
+      depth,
+      availableColumnCount,
+      columnWidth,
+      onResizeStart,
+      onResize,
+      onResizeStop,
+      handleComponentDrop,
+      editMode,
+    } = this.props;
+
+    // inherit the size of parent columns
+    const widthMultiple =
+      parentComponent.type === COLUMN_TYPE
+        ? parentComponent.meta.width || GRID_MIN_COLUMN_COUNT
+        : component.meta.width || GRID_MIN_COLUMN_COUNT;
+
+    return (
+      <DragDroppable
+        component={component}
+        parentComponent={parentComponent}
+        orientation={depth % 2 === 1 ? 'column' : 'row'}
+        index={index}
+        depth={depth}
+        onDrop={handleComponentDrop}
+        disableDragDrop={isFocused}
+        editMode={editMode}
+      >
+        {({ dropIndicatorProps, dragSourceRef }) => (
+          <ResizableContainer
+            id={component.id}
+            adjustableWidth={parentComponent.type === ROW_TYPE}
+            adjustableHeight
+            widthStep={columnWidth}
+            widthMultiple={widthMultiple}
+            heightStep={GRID_BASE_UNIT}
+            heightMultiple={component.meta.height}
+            minWidthMultiple={GRID_MIN_COLUMN_COUNT}
+            minHeightMultiple={GRID_MIN_ROW_UNITS}
+            maxWidthMultiple={availableColumnCount + widthMultiple}
+            onResizeStart={onResizeStart}
+            onResize={onResize}
+            onResizeStop={onResizeStop}
+            editMode={editMode}
+          >
+            <div
+              ref={dragSourceRef}
+              className="dashboard-component dashboard-component-chart-holder"
+            >
+              <Chart
+                id={component.meta.chartId}
+                width={widthMultiple * columnWidth}
+                height={component.meta.height * GRID_BASE_UNIT - CHART_MARGIN}
+                sliceName={component.meta.sliceName || ''}
+                updateSliceName={this.handleUpdateSliceName}
+              />
+              {editMode && (
+                <HoverMenu position="top">
+                  <DeleteComponentButton
+                    onDelete={this.handleDeleteComponent}
+                  />
+                </HoverMenu>
+              )}
+            </div>
+
+            {dropIndicatorProps && <div {...dropIndicatorProps} />}
+          </ResizableContainer>
+        )}
+      </DragDroppable>
+    );
+  }
+}
+
+ChartHolder.propTypes = propTypes;
+ChartHolder.defaultProps = defaultProps;
+
+export default ChartHolder;
diff --git a/superset/assets/src/dashboard/components/gridComponents/Column.jsx b/superset/assets/src/dashboard/components/gridComponents/Column.jsx
new file mode 100644
index 0000000000..7249034e69
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/Column.jsx
@@ -0,0 +1,192 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import DashboardComponent from '../../containers/DashboardComponent';
+import DeleteComponentButton from '../DeleteComponentButton';
+import DragDroppable from '../dnd/DragDroppable';
+import DragHandle from '../dnd/DragHandle';
+import HoverMenu from '../menu/HoverMenu';
+import IconButton from '../IconButton';
+import ResizableContainer from '../resizable/ResizableContainer';
+import BackgroundStyleDropdown from '../menu/BackgroundStyleDropdown';
+import WithPopoverMenu from '../menu/WithPopoverMenu';
+
+import backgroundStyleOptions from '../../util/backgroundStyleOptions';
+import { componentShape } from '../../util/propShapes';
+
+import { BACKGROUND_TRANSPARENT } from '../../util/constants';
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  parentId: PropTypes.string.isRequired,
+  component: componentShape.isRequired,
+  parentComponent: componentShape.isRequired,
+  index: PropTypes.number.isRequired,
+  depth: PropTypes.number.isRequired,
+  editMode: PropTypes.bool.isRequired,
+
+  // grid related
+  availableColumnCount: PropTypes.number.isRequired,
+  columnWidth: PropTypes.number.isRequired,
+  minColumnWidth: PropTypes.number.isRequired,
+  onResizeStart: PropTypes.func.isRequired,
+  onResize: PropTypes.func.isRequired,
+  onResizeStop: PropTypes.func.isRequired,
+
+  // dnd
+  deleteComponent: PropTypes.func.isRequired,
+  handleComponentDrop: PropTypes.func.isRequired,
+  updateComponents: PropTypes.func.isRequired,
+};
+
+const defaultProps = {};
+
+class Column extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {
+      isFocused: false,
+    };
+    this.handleChangeBackground = this.handleUpdateMeta.bind(
+      this,
+      'background',
+    );
+    this.handleChangeFocus = this.handleChangeFocus.bind(this);
+    this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+  }
+
+  handleDeleteComponent() {
+    const { deleteComponent, id, parentId } = this.props;
+    deleteComponent(id, parentId);
+  }
+
+  handleChangeFocus(nextFocus) {
+    this.setState(() => ({ isFocused: Boolean(nextFocus) }));
+  }
+
+  handleUpdateMeta(metaKey, nextValue) {
+    const { updateComponents, component } = this.props;
+    if (nextValue && component.meta[metaKey] !== nextValue) {
+      updateComponents({
+        [component.id]: {
+          ...component,
+          meta: {
+            ...component.meta,
+            [metaKey]: nextValue,
+          },
+        },
+      });
+    }
+  }
+
+  render() {
+    const {
+      component: columnComponent,
+      parentComponent,
+      index,
+      availableColumnCount,
+      columnWidth,
+      minColumnWidth,
+      depth,
+      onResizeStart,
+      onResize,
+      onResizeStop,
+      handleComponentDrop,
+      editMode,
+    } = this.props;
+
+    const columnItems = columnComponent.children || [];
+    const backgroundStyle = backgroundStyleOptions.find(
+      opt =>
+        opt.value ===
+        (columnComponent.meta.background || BACKGROUND_TRANSPARENT),
+    );
+
+    return (
+      <DragDroppable
+        component={columnComponent}
+        parentComponent={parentComponent}
+        orientation="column"
+        index={index}
+        depth={depth}
+        onDrop={handleComponentDrop}
+        editMode={editMode}
+      >
+        {({ dropIndicatorProps, dragSourceRef }) => (
+          <ResizableContainer
+            id={columnComponent.id}
+            adjustableWidth
+            adjustableHeight={false}
+            widthStep={columnWidth}
+            widthMultiple={columnComponent.meta.width}
+            minWidthMultiple={minColumnWidth}
+            maxWidthMultiple={
+              availableColumnCount + (columnComponent.meta.width || 0)
+            }
+            onResizeStart={onResizeStart}
+            onResize={onResize}
+            onResizeStop={onResizeStop}
+            editMode={editMode}
+          >
+            <WithPopoverMenu
+              isFocused={this.state.isFocused}
+              onChangeFocus={this.handleChangeFocus}
+              disableClick
+              menuItems={[
+                <BackgroundStyleDropdown
+                  id={`${columnComponent.id}-background`}
+                  value={columnComponent.meta.background}
+                  onChange={this.handleChangeBackground}
+                />,
+              ]}
+              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',
+                  columnItems.length === 0 && 'grid-column--empty',
+                  backgroundStyle.className,
+                )}
+              >
+                {columnItems.map((componentId, itemIndex) => (
+                  <DashboardComponent
+                    key={componentId}
+                    id={componentId}
+                    parentId={columnComponent.id}
+                    depth={depth + 1}
+                    index={itemIndex}
+                    availableColumnCount={columnComponent.meta.width}
+                    columnWidth={columnWidth}
+                    onResizeStart={onResizeStart}
+                    onResize={onResize}
+                    onResizeStop={onResizeStop}
+                  />
+                ))}
+
+                {dropIndicatorProps && <div {...dropIndicatorProps} />}
+              </div>
+            </WithPopoverMenu>
+          </ResizableContainer>
+        )}
+      </DragDroppable>
+    );
+  }
+}
+
+Column.propTypes = propTypes;
+Column.defaultProps = defaultProps;
+
+export default Column;
diff --git a/superset/assets/src/dashboard/components/gridComponents/Divider.jsx b/superset/assets/src/dashboard/components/gridComponents/Divider.jsx
new file mode 100644
index 0000000000..7c7936d857
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/Divider.jsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import DragDroppable from '../dnd/DragDroppable';
+import HoverMenu from '../menu/HoverMenu';
+import DeleteComponentButton from '../DeleteComponentButton';
+import { componentShape } from '../../util/propShapes';
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  parentId: PropTypes.string.isRequired,
+  component: componentShape.isRequired,
+  depth: PropTypes.number.isRequired,
+  parentComponent: componentShape.isRequired,
+  index: PropTypes.number.isRequired,
+  editMode: PropTypes.bool.isRequired,
+  handleComponentDrop: PropTypes.func.isRequired,
+  deleteComponent: PropTypes.func.isRequired,
+};
+
+class Divider extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+  }
+
+  handleDeleteComponent() {
+    const { deleteComponent, id, parentId } = this.props;
+    deleteComponent(id, parentId);
+  }
+
+  render() {
+    const {
+      component,
+      depth,
+      parentComponent,
+      index,
+      handleComponentDrop,
+      editMode,
+    } = this.props;
+
+    return (
+      <DragDroppable
+        component={component}
+        parentComponent={parentComponent}
+        orientation="row"
+        index={index}
+        depth={depth}
+        onDrop={handleComponentDrop}
+        editMode={editMode}
+      >
+        {({ dropIndicatorProps, dragSourceRef }) => (
+          <div ref={dragSourceRef}>
+            {editMode && (
+              <HoverMenu position="left">
+                <DeleteComponentButton onDelete={this.handleDeleteComponent} />
+              </HoverMenu>
+            )}
+
+            <div className="dashboard-component dashboard-component-divider" />
+
+            {dropIndicatorProps && <div {...dropIndicatorProps} />}
+          </div>
+        )}
+      </DragDroppable>
+    );
+  }
+}
+
+Divider.propTypes = propTypes;
+
+export default Divider;
diff --git a/superset/assets/src/dashboard/components/gridComponents/Header.jsx b/superset/assets/src/dashboard/components/gridComponents/Header.jsx
new file mode 100644
index 0000000000..683af9e957
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/Header.jsx
@@ -0,0 +1,164 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import DragDroppable from '../dnd/DragDroppable';
+import DragHandle from '../dnd/DragHandle';
+import EditableTitle from '../../../components/EditableTitle';
+import HoverMenu from '../menu/HoverMenu';
+import WithPopoverMenu from '../menu/WithPopoverMenu';
+import BackgroundStyleDropdown from '../menu/BackgroundStyleDropdown';
+import DeleteComponentButton from '../DeleteComponentButton';
+import PopoverDropdown from '../menu/PopoverDropdown';
+import headerStyleOptions from '../../util/headerStyleOptions';
+import backgroundStyleOptions from '../../util/backgroundStyleOptions';
+import { componentShape } from '../../util/propShapes';
+import { SMALL_HEADER, BACKGROUND_TRANSPARENT } from '../../util/constants';
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  parentId: PropTypes.string.isRequired,
+  component: componentShape.isRequired,
+  depth: PropTypes.number.isRequired,
+  parentComponent: componentShape.isRequired,
+  index: PropTypes.number.isRequired,
+  editMode: PropTypes.bool.isRequired,
+
+  // redux
+  handleComponentDrop: PropTypes.func.isRequired,
+  deleteComponent: PropTypes.func.isRequired,
+  updateComponents: PropTypes.func.isRequired,
+};
+
+const defaultProps = {};
+
+class Header extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {
+      isFocused: false,
+    };
+    this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+    this.handleChangeFocus = this.handleChangeFocus.bind(this);
+    this.handleUpdateMeta = this.handleUpdateMeta.bind(this);
+    this.handleChangeSize = this.handleUpdateMeta.bind(this, 'headerSize');
+    this.handleChangeBackground = this.handleUpdateMeta.bind(
+      this,
+      'background',
+    );
+    this.handleChangeText = this.handleUpdateMeta.bind(this, 'text');
+  }
+
+  handleChangeFocus(nextFocus) {
+    this.setState(() => ({ isFocused: nextFocus }));
+  }
+
+  handleUpdateMeta(metaKey, nextValue) {
+    const { updateComponents, component } = this.props;
+    if (nextValue && component.meta[metaKey] !== nextValue) {
+      updateComponents({
+        [component.id]: {
+          ...component,
+          meta: {
+            ...component.meta,
+            [metaKey]: nextValue,
+          },
+        },
+      });
+    }
+  }
+
+  handleDeleteComponent() {
+    const { deleteComponent, id, parentId } = this.props;
+    deleteComponent(id, parentId);
+  }
+
+  render() {
+    const { isFocused } = this.state;
+
+    const {
+      component,
+      depth,
+      parentComponent,
+      index,
+      handleComponentDrop,
+      editMode,
+    } = this.props;
+
+    const headerStyle = headerStyleOptions.find(
+      opt => opt.value === (component.meta.headerSize || SMALL_HEADER),
+    );
+
+    const rowStyle = backgroundStyleOptions.find(
+      opt =>
+        opt.value === (component.meta.background || BACKGROUND_TRANSPARENT),
+    );
+
+    return (
+      <DragDroppable
+        component={component}
+        parentComponent={parentComponent}
+        orientation="row"
+        index={index}
+        depth={depth}
+        onDrop={handleComponentDrop}
+        disableDragDrop={isFocused}
+        editMode={editMode}
+      >
+        {({ dropIndicatorProps, dragSourceRef }) => (
+          <div ref={dragSourceRef}>
+            {editMode &&
+            depth <= 2 && ( // drag handle looks bad when nested
+                <HoverMenu position="left">
+                  <DragHandle position="left" />
+                </HoverMenu>
+              )}
+
+            <WithPopoverMenu
+              onChangeFocus={this.handleChangeFocus}
+              menuItems={[
+                <PopoverDropdown
+                  id={`${component.id}-header-style`}
+                  options={headerStyleOptions}
+                  value={component.meta.headerSize}
+                  onChange={this.handleChangeSize}
+                  renderTitle={option => `${option.label} header`}
+                />,
+                <BackgroundStyleDropdown
+                  id={`${component.id}-background`}
+                  value={component.meta.background}
+                  onChange={this.handleChangeBackground}
+                />,
+                <DeleteComponentButton onDelete={this.handleDeleteComponent} />,
+              ]}
+              editMode={editMode}
+            >
+              <div
+                className={cx(
+                  'dashboard-component',
+                  'dashboard-component-header',
+                  headerStyle.className,
+                  rowStyle.className,
+                )}
+              >
+                <EditableTitle
+                  title={component.meta.text}
+                  canEdit={editMode}
+                  onSaveTitle={this.handleChangeText}
+                  showTooltip={false}
+                />
+              </div>
+            </WithPopoverMenu>
+
+            {dropIndicatorProps && <div {...dropIndicatorProps} />}
+          </div>
+        )}
+      </DragDroppable>
+    );
+  }
+}
+
+Header.propTypes = propTypes;
+Header.defaultProps = defaultProps;
+
+export default Header;
diff --git a/superset/assets/src/dashboard/components/gridComponents/Markdown.jsx b/superset/assets/src/dashboard/components/gridComponents/Markdown.jsx
new file mode 100644
index 0000000000..459f89a771
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/Markdown.jsx
@@ -0,0 +1,225 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ReactMarkdown from 'react-markdown';
+import AceEditor from 'react-ace';
+import 'brace/mode/markdown';
+import 'brace/theme/textmate';
+
+import DeleteComponentButton from '../DeleteComponentButton';
+import DragDroppable from '../dnd/DragDroppable';
+import ResizableContainer from '../resizable/ResizableContainer';
+import MarkdownModeDropdown from '../menu/MarkdownModeDropdown';
+import WithPopoverMenu from '../menu/WithPopoverMenu';
+import { componentShape } from '../../util/propShapes';
+import { ROW_TYPE, COLUMN_TYPE } from '../../util/componentTypes';
+import {
+  GRID_MIN_COLUMN_COUNT,
+  GRID_MIN_ROW_UNITS,
+  GRID_BASE_UNIT,
+} from '../../util/constants';
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  parentId: PropTypes.string.isRequired,
+  component: componentShape.isRequired,
+  parentComponent: componentShape.isRequired,
+  index: PropTypes.number.isRequired,
+  depth: PropTypes.number.isRequired,
+  editMode: PropTypes.bool.isRequired,
+
+  // grid related
+  availableColumnCount: PropTypes.number.isRequired,
+  columnWidth: PropTypes.number.isRequired,
+  onResizeStart: PropTypes.func.isRequired,
+  onResize: PropTypes.func.isRequired,
+  onResizeStop: PropTypes.func.isRequired,
+
+  // dnd
+  deleteComponent: PropTypes.func.isRequired,
+  handleComponentDrop: PropTypes.func.isRequired,
+  updateComponents: PropTypes.func.isRequired,
+};
+
+const defaultProps = {};
+const markdownPlaceHolder = `### New Markdown
+Insert *bold* or _italic_ text, and (urls)[www.url.com] here.`;
+
+class Markdown extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {
+      isFocused: false,
+      markdownSource: props.component.meta.code,
+      editor: null,
+      editorMode: props.component.meta.code ? 'preview' : 'edit', // show edit mode when code is empty
+    };
+
+    this.handleChangeFocus = this.handleChangeFocus.bind(this);
+    this.handleChangeEditorMode = this.handleChangeEditorMode.bind(this);
+    this.handleMarkdownChange = this.handleMarkdownChange.bind(this);
+    this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+    this.setEditor = this.setEditor.bind(this);
+  }
+
+  componentDidUpdate(prevProps) {
+    if (
+      this.state.editor &&
+      (prevProps.component.meta.width !== this.props.component.meta.width ||
+        prevProps.columnWidth !== this.props.columnWidth)
+    ) {
+      this.state.editor.resize(true);
+    }
+  }
+
+  setEditor(editor) {
+    editor.getSession().setUseWrapMode(true);
+    this.setState({
+      editor,
+    });
+  }
+
+  handleChangeFocus(nextFocus) {
+    this.setState(() => ({ isFocused: Boolean(nextFocus) }));
+  }
+
+  handleChangeEditorMode(mode) {
+    if (this.state.editorMode === 'edit') {
+      const { updateComponents, component } = this.props;
+      if (component.meta.code !== this.state.markdownSource) {
+        updateComponents({
+          [component.id]: {
+            ...component,
+            meta: {
+              ...component.meta,
+              code: this.state.markdownSource,
+            },
+          },
+        });
+      }
+    }
+
+    this.setState(() => ({
+      editorMode: mode,
+    }));
+  }
+
+  handleMarkdownChange(nextValue) {
+    this.setState({
+      markdownSource: nextValue,
+    });
+  }
+
+  handleDeleteComponent() {
+    const { deleteComponent, id, parentId } = this.props;
+    deleteComponent(id, parentId);
+  }
+
+  renderEditMode() {
+    return (
+      <AceEditor
+        mode="markdown"
+        theme="textmate"
+        onChange={this.handleMarkdownChange}
+        width={'100%'}
+        height={'100%'}
+        editorProps={{ $blockScrolling: true }}
+        value={this.state.markdownSource || markdownPlaceHolder}
+        readOnly={false}
+        onLoad={this.setEditor}
+      />
+    );
+  }
+
+  renderPreviewMode() {
+    return (
+      <ReactMarkdown source={this.state.markdownSource} escapeHtml={false} />
+    );
+  }
+
+  render() {
+    const { isFocused } = this.state;
+
+    const {
+      component,
+      parentComponent,
+      index,
+      depth,
+      availableColumnCount,
+      columnWidth,
+      onResizeStart,
+      onResize,
+      onResizeStop,
+      handleComponentDrop,
+      editMode,
+    } = this.props;
+
+    // inherit the size of parent columns
+    const widthMultiple =
+      parentComponent.type === COLUMN_TYPE
+        ? parentComponent.meta.width || GRID_MIN_COLUMN_COUNT
+        : component.meta.width || GRID_MIN_COLUMN_COUNT;
+
+    return (
+      <DragDroppable
+        component={component}
+        parentComponent={parentComponent}
+        orientation={depth % 2 === 1 ? 'column' : 'row'}
+        index={index}
+        depth={depth}
+        onDrop={handleComponentDrop}
+        disableDragDrop={isFocused}
+        editMode={editMode}
+      >
+        {({ dropIndicatorProps, dragSourceRef }) => (
+          <WithPopoverMenu
+            onChangeFocus={this.handleChangeFocus}
+            menuItems={[
+              <MarkdownModeDropdown
+                id={`${component.id}-mode`}
+                value={this.state.editorMode}
+                onChange={this.handleChangeEditorMode}
+              />,
+              <DeleteComponentButton onDelete={this.handleDeleteComponent} />,
+            ]}
+            editMode={editMode}
+          >
+            <div className="dashboard-markdown">
+              <ResizableContainer
+                id={component.id}
+                adjustableWidth={parentComponent.type === ROW_TYPE}
+                adjustableHeight
+                widthStep={columnWidth}
+                widthMultiple={widthMultiple}
+                heightStep={GRID_BASE_UNIT}
+                heightMultiple={component.meta.height}
+                minWidthMultiple={GRID_MIN_COLUMN_COUNT}
+                minHeightMultiple={GRID_MIN_ROW_UNITS}
+                maxWidthMultiple={availableColumnCount + widthMultiple}
+                onResizeStart={onResizeStart}
+                onResize={onResize}
+                onResizeStop={onResizeStop}
+                editMode={editMode}
+              >
+                <div
+                  ref={dragSourceRef}
+                  className="dashboard-component dashboard-component-chart-holder"
+                >
+                  {editMode && this.state.editorMode === 'edit'
+                    ? this.renderEditMode()
+                    : this.renderPreviewMode()}
+                </div>
+
+                {dropIndicatorProps && <div {...dropIndicatorProps} />}
+              </ResizableContainer>
+            </div>
+          </WithPopoverMenu>
+        )}
+      </DragDroppable>
+    );
+  }
+}
+
+Markdown.propTypes = propTypes;
+Markdown.defaultProps = defaultProps;
+
+export default Markdown;
diff --git a/superset/assets/src/dashboard/components/gridComponents/Row.jsx b/superset/assets/src/dashboard/components/gridComponents/Row.jsx
new file mode 100644
index 0000000000..28e7042c04
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/Row.jsx
@@ -0,0 +1,177 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import DragDroppable from '../dnd/DragDroppable';
+import DragHandle from '../dnd/DragHandle';
+import DashboardComponent from '../../containers/DashboardComponent';
+import DeleteComponentButton from '../DeleteComponentButton';
+import HoverMenu from '../menu/HoverMenu';
+import IconButton from '../IconButton';
+import BackgroundStyleDropdown from '../menu/BackgroundStyleDropdown';
+import WithPopoverMenu from '../menu/WithPopoverMenu';
+
+import { componentShape } from '../../util/propShapes';
+import backgroundStyleOptions from '../../util/backgroundStyleOptions';
+import { BACKGROUND_TRANSPARENT } from '../../util/constants';
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  parentId: PropTypes.string.isRequired,
+  component: componentShape.isRequired,
+  parentComponent: componentShape.isRequired,
+  index: PropTypes.number.isRequired,
+  depth: PropTypes.number.isRequired,
+  editMode: PropTypes.bool.isRequired,
+
+  // grid related
+  availableColumnCount: PropTypes.number.isRequired,
+  columnWidth: PropTypes.number.isRequired,
+  occupiedColumnCount: PropTypes.number.isRequired,
+  onResizeStart: PropTypes.func.isRequired,
+  onResize: PropTypes.func.isRequired,
+  onResizeStop: PropTypes.func.isRequired,
+
+  // dnd
+  handleComponentDrop: PropTypes.func.isRequired,
+  deleteComponent: PropTypes.func.isRequired,
+  updateComponents: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+  rowHeight: null,
+};
+
+class Row extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {
+      isFocused: false,
+    };
+    this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+    this.handleUpdateMeta = this.handleUpdateMeta.bind(this);
+    this.handleChangeBackground = this.handleUpdateMeta.bind(
+      this,
+      'background',
+    );
+    this.handleChangeFocus = this.handleChangeFocus.bind(this);
+  }
+
+  handleChangeFocus(nextFocus) {
+    this.setState(() => ({ isFocused: Boolean(nextFocus) }));
+  }
+
+  handleUpdateMeta(metaKey, nextValue) {
+    const { updateComponents, component } = this.props;
+    if (nextValue && component.meta[metaKey] !== nextValue) {
+      updateComponents({
+        [component.id]: {
+          ...component,
+          meta: {
+            ...component.meta,
+            [metaKey]: nextValue,
+          },
+        },
+      });
+    }
+  }
+
+  handleDeleteComponent() {
+    const { deleteComponent, component, parentId } = this.props;
+    deleteComponent(component.id, parentId);
+  }
+
+  render() {
+    const {
+      component: rowComponent,
+      parentComponent,
+      index,
+      availableColumnCount,
+      columnWidth,
+      occupiedColumnCount,
+      depth,
+      onResizeStart,
+      onResize,
+      onResizeStop,
+      handleComponentDrop,
+      editMode,
+    } = this.props;
+
+    const rowItems = rowComponent.children || [];
+
+    const backgroundStyle = backgroundStyleOptions.find(
+      opt =>
+        opt.value === (rowComponent.meta.background || BACKGROUND_TRANSPARENT),
+    );
+
+    return (
+      <DragDroppable
+        component={rowComponent}
+        parentComponent={parentComponent}
+        orientation="row"
+        index={index}
+        depth={depth}
+        onDrop={handleComponentDrop}
+        editMode={editMode}
+      >
+        {({ dropIndicatorProps, dragSourceRef }) => (
+          <WithPopoverMenu
+            isFocused={this.state.isFocused}
+            onChangeFocus={this.handleChangeFocus}
+            disableClick
+            menuItems={[
+              <BackgroundStyleDropdown
+                id={`${rowComponent.id}-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',
+                rowItems.length === 0 && 'grid-row--empty',
+                backgroundStyle.className,
+              )}
+            >
+              {rowItems.map((componentId, itemIndex) => (
+                <DashboardComponent
+                  key={componentId}
+                  id={componentId}
+                  parentId={rowComponent.id}
+                  depth={depth + 1}
+                  index={itemIndex}
+                  availableColumnCount={
+                    availableColumnCount - occupiedColumnCount
+                  }
+                  columnWidth={columnWidth}
+                  onResizeStart={onResizeStart}
+                  onResize={onResize}
+                  onResizeStop={onResizeStop}
+                />
+              ))}
+
+              {dropIndicatorProps && <div {...dropIndicatorProps} />}
+            </div>
+          </WithPopoverMenu>
+        )}
+      </DragDroppable>
+    );
+  }
+}
+
+Row.propTypes = propTypes;
+Row.defaultProps = defaultProps;
+
+export default Row;
diff --git a/superset/assets/src/dashboard/components/gridComponents/Tab.jsx b/superset/assets/src/dashboard/components/gridComponents/Tab.jsx
new file mode 100644
index 0000000000..63619c1574
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/Tab.jsx
@@ -0,0 +1,181 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import DashboardComponent from '../../containers/DashboardComponent';
+import DragDroppable from '../dnd/DragDroppable';
+import EditableTitle from '../../../components/EditableTitle';
+import DeleteComponentButton from '../DeleteComponentButton';
+import WithPopoverMenu from '../menu/WithPopoverMenu';
+import { componentShape } from '../../util/propShapes';
+import { DASHBOARD_ROOT_DEPTH } from '../../util/constants';
+
+export const RENDER_TAB = 'RENDER_TAB';
+export const RENDER_TAB_CONTENT = 'RENDER_TAB_CONTENT';
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  parentId: PropTypes.string.isRequired,
+  component: componentShape.isRequired,
+  parentComponent: componentShape.isRequired,
+  index: PropTypes.number.isRequired,
+  depth: PropTypes.number.isRequired,
+  renderType: PropTypes.oneOf([RENDER_TAB, RENDER_TAB_CONTENT]).isRequired,
+  onDropOnTab: PropTypes.func,
+  onDeleteTab: PropTypes.func,
+  editMode: PropTypes.bool.isRequired,
+
+  // grid related
+  availableColumnCount: PropTypes.number,
+  columnWidth: PropTypes.number,
+  onResizeStart: PropTypes.func,
+  onResize: PropTypes.func,
+  onResizeStop: PropTypes.func,
+
+  // redux
+  handleComponentDrop: PropTypes.func.isRequired,
+  deleteComponent: PropTypes.func.isRequired,
+  updateComponents: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+  availableColumnCount: 0,
+  columnWidth: 0,
+  onDropOnTab() {},
+  onDeleteTab() {},
+  onResizeStart() {},
+  onResize() {},
+  onResizeStop() {},
+};
+
+export default class Tab extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {
+      isFocused: false,
+    };
+    this.handleChangeFocus = this.handleChangeFocus.bind(this);
+    this.handleChangeText = this.handleChangeText.bind(this);
+    this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+    this.handleDrop = this.handleDrop.bind(this);
+  }
+
+  handleChangeFocus(nextFocus) {
+    this.setState(() => ({ isFocused: nextFocus }));
+  }
+
+  handleChangeText(nextTabText) {
+    const { updateComponents, component } = this.props;
+    if (nextTabText && nextTabText !== component.meta.text) {
+      updateComponents({
+        [component.id]: {
+          ...component,
+          meta: {
+            ...component.meta,
+            text: nextTabText,
+          },
+        },
+      });
+    }
+  }
+
+  handleDeleteComponent() {
+    const { index, id, parentId } = this.props;
+    this.props.deleteComponent(id, parentId);
+    this.props.onDeleteTab(index);
+  }
+
+  handleDrop(dropResult) {
+    this.props.handleComponentDrop(dropResult);
+    this.props.onDropOnTab(dropResult);
+  }
+
+  renderTabContent() {
+    const {
+      component: tabComponent,
+      depth,
+      availableColumnCount,
+      columnWidth,
+      onResizeStart,
+      onResize,
+      onResizeStop,
+    } = this.props;
+
+    return (
+      <div className="dashboard-component-tabs-content">
+        {tabComponent.children.map((componentId, componentIndex) => (
+          <DashboardComponent
+            key={componentId}
+            id={componentId}
+            parentId={tabComponent.id}
+            depth={depth} // see isValidChild.js for why tabs don't increment child depth
+            index={componentIndex}
+            onDrop={this.handleDrop}
+            availableColumnCount={availableColumnCount}
+            columnWidth={columnWidth}
+            onResizeStart={onResizeStart}
+            onResize={onResize}
+            onResizeStop={onResizeStop}
+          />
+        ))}
+      </div>
+    );
+  }
+
+  renderTab() {
+    const { isFocused } = this.state;
+    const { component, parentComponent, index, depth, editMode } = this.props;
+
+    return (
+      <DragDroppable
+        component={component}
+        parentComponent={parentComponent}
+        orientation="column"
+        index={index}
+        depth={depth}
+        onDrop={this.handleDrop}
+        // 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={depth === DASHBOARD_ROOT_DEPTH + 1}
+        editMode={editMode}
+      >
+        {({ dropIndicatorProps, dragSourceRef }) => (
+          <div className="dragdroppable-tab" ref={dragSourceRef}>
+            <WithPopoverMenu
+              onChangeFocus={this.handleChangeFocus}
+              menuItems={
+                parentComponent.children.length <= 1
+                  ? []
+                  : [
+                      <DeleteComponentButton
+                        onDelete={this.handleDeleteComponent}
+                      />,
+                    ]
+              }
+              editMode={editMode}
+            >
+              <EditableTitle
+                title={component.meta.text}
+                canEdit={editMode && isFocused}
+                onSaveTitle={this.handleChangeText}
+                showTooltip={false}
+              />
+            </WithPopoverMenu>
+
+            {dropIndicatorProps && <div {...dropIndicatorProps} />}
+          </div>
+        )}
+      </DragDroppable>
+    );
+  }
+
+  render() {
+    const { renderType } = this.props;
+    return renderType === RENDER_TAB
+      ? this.renderTab()
+      : this.renderTabContent();
+  }
+}
+
+Tab.propTypes = propTypes;
+Tab.defaultProps = defaultProps;
diff --git a/superset/assets/src/dashboard/components/gridComponents/Tabs.jsx b/superset/assets/src/dashboard/components/gridComponents/Tabs.jsx
new file mode 100644
index 0000000000..813961d228
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/Tabs.jsx
@@ -0,0 +1,236 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Tabs as BootstrapTabs, Tab as BootstrapTab } from 'react-bootstrap';
+
+import DragDroppable from '../dnd/DragDroppable';
+import DragHandle from '../dnd/DragHandle';
+import DashboardComponent from '../../containers/DashboardComponent';
+import DeleteComponentButton from '../DeleteComponentButton';
+import HoverMenu from '../menu/HoverMenu';
+import { componentShape } from '../../util/propShapes';
+import { NEW_TAB_ID, DASHBOARD_ROOT_ID } from '../../util/constants';
+import { RENDER_TAB, RENDER_TAB_CONTENT } from './Tab';
+import { TAB_TYPE } from '../../util/componentTypes';
+
+const NEW_TAB_INDEX = -1;
+const MAX_TAB_COUNT = 5;
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  parentId: PropTypes.string.isRequired,
+  component: componentShape.isRequired,
+  parentComponent: componentShape.isRequired,
+  index: PropTypes.number.isRequired,
+  depth: PropTypes.number.isRequired,
+  renderTabContent: PropTypes.bool, // whether to render tabs + content or just tabs
+  editMode: PropTypes.bool.isRequired,
+
+  // grid related
+  availableColumnCount: PropTypes.number,
+  columnWidth: PropTypes.number,
+  onResizeStart: PropTypes.func,
+  onResize: PropTypes.func,
+  onResizeStop: PropTypes.func,
+
+  // dnd
+  createComponent: PropTypes.func.isRequired,
+  handleComponentDrop: PropTypes.func.isRequired,
+  onChangeTab: PropTypes.func,
+  deleteComponent: PropTypes.func.isRequired,
+  updateComponents: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+  children: null,
+  renderTabContent: true,
+  availableColumnCount: 0,
+  columnWidth: 0,
+  onChangeTab() {},
+  onResizeStart() {},
+  onResize() {},
+  onResizeStop() {},
+};
+
+class Tabs extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {
+      tabIndex: 0,
+    };
+    this.handleClickTab = this.handleClickTab.bind(this);
+    this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+    this.handleDeleteTab = this.handleDeleteTab.bind(this);
+    this.handleDropOnTab = this.handleDropOnTab.bind(this);
+  }
+
+  componentWillReceiveProps(nextProps) {
+    const maxIndex = Math.max(0, nextProps.component.children.length - 1);
+    if (this.state.tabIndex > maxIndex) {
+      this.setState(() => ({ tabIndex: maxIndex }));
+    }
+  }
+
+  handleClickTab(tabIndex) {
+    const { component, createComponent } = this.props;
+
+    if (tabIndex === NEW_TAB_INDEX) {
+      createComponent({
+        destination: {
+          id: component.id,
+          type: component.type,
+          index: component.children.length,
+        },
+        dragging: {
+          id: NEW_TAB_ID,
+          type: TAB_TYPE,
+        },
+      });
+    } else if (tabIndex !== this.state.tabIndex) {
+      this.setState(() => ({ tabIndex }));
+      this.props.onChangeTab({ tabIndex, tabId: component.children[tabIndex] });
+    }
+  }
+
+  handleDeleteComponent() {
+    const { deleteComponent, id, parentId } = this.props;
+    deleteComponent(id, parentId);
+  }
+
+  handleDeleteTab(tabIndex) {
+    this.handleClickTab(Math.max(0, tabIndex - 1));
+  }
+
+  handleDropOnTab(dropResult) {
+    const { component } = this.props;
+
+    // Ensure dropped tab is visible
+    const { destination } = dropResult;
+    if (destination) {
+      const dropTabIndex =
+        destination.id === component.id
+          ? destination.index // dropped ON tabs
+          : component.children.indexOf(destination.id); // dropped IN tab
+
+      if (dropTabIndex > -1) {
+        setTimeout(() => {
+          this.handleClickTab(dropTabIndex);
+        }, 30);
+      }
+    }
+  }
+
+  render() {
+    const {
+      depth,
+      component: tabsComponent,
+      parentComponent,
+      index,
+      availableColumnCount,
+      columnWidth,
+      onResizeStart,
+      onResize,
+      onResizeStop,
+      handleComponentDrop,
+      renderTabContent,
+      editMode,
+    } = this.props;
+
+    const { tabIndex: selectedTabIndex } = this.state;
+    const { children: tabIds } = tabsComponent;
+
+    return (
+      <DragDroppable
+        component={tabsComponent}
+        parentComponent={parentComponent}
+        orientation="row"
+        index={index}
+        depth={depth}
+        onDrop={handleComponentDrop}
+        editMode={editMode}
+      >
+        {({
+          dropIndicatorProps: tabsDropIndicatorProps,
+          dragSourceRef: tabsDragSourceRef,
+        }) => (
+          <div className="dashboard-component dashboard-component-tabs">
+            {editMode && (
+              <HoverMenu innerRef={tabsDragSourceRef} position="left">
+                <DragHandle position="left" />
+                <DeleteComponentButton onDelete={this.handleDeleteComponent} />
+              </HoverMenu>
+            )}
+
+            <BootstrapTabs
+              id={tabsComponent.id}
+              activeKey={selectedTabIndex}
+              onSelect={this.handleClickTab}
+              // 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
+                // use `renderType` to indicate what the DashboardComponent should render. This
+                // prevents us from passing the entire dashboard component lookup to render Tabs.jsx
+                <BootstrapTab
+                  key={tabId}
+                  eventKey={tabIndex}
+                  title={
+                    <DashboardComponent
+                      id={tabId}
+                      parentId={tabsComponent.id}
+                      depth={depth}
+                      index={tabIndex}
+                      renderType={RENDER_TAB}
+                      availableColumnCount={availableColumnCount}
+                      columnWidth={columnWidth}
+                      onDropOnTab={this.handleDropOnTab}
+                      onDeleteTab={this.handleDeleteTab}
+                    />
+                  }
+                >
+                  {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>
+              ))}
+
+              {editMode &&
+                tabIds.length < MAX_TAB_COUNT && (
+                  <BootstrapTab
+                    eventKey={NEW_TAB_INDEX}
+                    title={<div className="fa fa-plus" />}
+                  />
+                )}
+            </BootstrapTabs>
+
+            {/* don't indicate that a drop on root is allowed when tabs already exist */}
+            {tabsDropIndicatorProps &&
+              parentComponent.id !== DASHBOARD_ROOT_ID && (
+                <div {...tabsDropIndicatorProps} />
+              )}
+          </div>
+        )}
+      </DragDroppable>
+    );
+  }
+}
+
+Tabs.propTypes = propTypes;
+Tabs.defaultProps = defaultProps;
+
+export default Tabs;
diff --git a/superset/assets/src/dashboard/components/gridComponents/index.js b/superset/assets/src/dashboard/components/gridComponents/index.js
new file mode 100644
index 0000000000..c56bed01cd
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/index.js
@@ -0,0 +1,39 @@
+import {
+  CHART_TYPE,
+  MARKDOWN_TYPE,
+  COLUMN_TYPE,
+  DIVIDER_TYPE,
+  HEADER_TYPE,
+  ROW_TYPE,
+  TAB_TYPE,
+  TABS_TYPE,
+} from '../../util/componentTypes';
+
+import ChartHolder from './ChartHolder';
+import Markdown from './Markdown';
+import Column from './Column';
+import Divider from './Divider';
+import Header from './Header';
+import Row from './Row';
+import Tab from './Tab';
+import Tabs from './Tabs';
+
+export { default as ChartHolder } from './ChartHolder';
+export { default as Markdown } from './Markdown';
+export { default as Column } from './Column';
+export { default as Divider } from './Divider';
+export { default as Header } from './Header';
+export { default as Row } from './Row';
+export { default as Tab } from './Tab';
+export { default as Tabs } from './Tabs';
+
+export default {
+  [CHART_TYPE]: ChartHolder,
+  [MARKDOWN_TYPE]: Markdown,
+  [COLUMN_TYPE]: Column,
+  [DIVIDER_TYPE]: Divider,
+  [HEADER_TYPE]: Header,
+  [ROW_TYPE]: Row,
+  [TAB_TYPE]: Tab,
+  [TABS_TYPE]: Tabs,
+};
diff --git a/superset/assets/src/dashboard/components/gridComponents/new/DraggableNewComponent.jsx b/superset/assets/src/dashboard/components/gridComponents/new/DraggableNewComponent.jsx
new file mode 100644
index 0000000000..d579dc1624
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/new/DraggableNewComponent.jsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import DragDroppable from '../../dnd/DragDroppable';
+import { NEW_COMPONENTS_SOURCE_ID } from '../../../util/constants';
+import { NEW_COMPONENT_SOURCE_TYPE } from '../../../util/componentTypes';
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  type: PropTypes.string.isRequired,
+  label: PropTypes.string.isRequired,
+  className: PropTypes.string,
+};
+
+const defaultProps = {
+  className: null,
+};
+
+export default class DraggableNewComponent extends React.PureComponent {
+  render() {
+    const { label, id, type, className } = this.props;
+    return (
+      <DragDroppable
+        component={{ type, id }}
+        parentComponent={{
+          id: NEW_COMPONENTS_SOURCE_ID,
+          type: NEW_COMPONENT_SOURCE_TYPE,
+        }}
+        index={0}
+        depth={0}
+        editMode
+      >
+        {({ dragSourceRef }) => (
+          <div ref={dragSourceRef} className="new-component">
+            <div className={cx('new-component-placeholder', className)} />
+            {label}
+          </div>
+        )}
+      </DragDroppable>
+    );
+  }
+}
+
+DraggableNewComponent.propTypes = propTypes;
+DraggableNewComponent.defaultProps = defaultProps;
diff --git a/superset/assets/src/dashboard/components/gridComponents/new/NewColumn.jsx b/superset/assets/src/dashboard/components/gridComponents/new/NewColumn.jsx
new file mode 100644
index 0000000000..f624e58a37
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/new/NewColumn.jsx
@@ -0,0 +1,16 @@
+import React from 'react';
+
+import { COLUMN_TYPE } from '../../../util/componentTypes';
+import { NEW_COLUMN_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+export default function DraggableNewColumn() {
+  return (
+    <DraggableNewComponent
+      id={NEW_COLUMN_ID}
+      type={COLUMN_TYPE}
+      label="Column"
+      className="fa fa-long-arrow-down"
+    />
+  );
+}
diff --git a/superset/assets/src/dashboard/components/gridComponents/new/NewDivider.jsx b/superset/assets/src/dashboard/components/gridComponents/new/NewDivider.jsx
new file mode 100644
index 0000000000..de07a24aa3
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/new/NewDivider.jsx
@@ -0,0 +1,16 @@
+import React from 'react';
+
+import { DIVIDER_TYPE } from '../../../util/componentTypes';
+import { NEW_DIVIDER_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+export default function DraggableNewDivider() {
+  return (
+    <DraggableNewComponent
+      id={NEW_DIVIDER_ID}
+      type={DIVIDER_TYPE}
+      label="Divider"
+      className="divider-placeholder"
+    />
+  );
+}
diff --git a/superset/assets/src/dashboard/components/gridComponents/new/NewHeader.jsx b/superset/assets/src/dashboard/components/gridComponents/new/NewHeader.jsx
new file mode 100644
index 0000000000..50bd600399
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/new/NewHeader.jsx
@@ -0,0 +1,16 @@
+import React from 'react';
+
+import { HEADER_TYPE } from '../../../util/componentTypes';
+import { NEW_HEADER_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+export default function DraggableNewHeader() {
+  return (
+    <DraggableNewComponent
+      id={NEW_HEADER_ID}
+      type={HEADER_TYPE}
+      label="Header"
+      className="fa fa-header"
+    />
+  );
+}
diff --git a/superset/assets/src/dashboard/components/gridComponents/new/NewMarkdown.jsx b/superset/assets/src/dashboard/components/gridComponents/new/NewMarkdown.jsx
new file mode 100644
index 0000000000..e4c8892145
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/new/NewMarkdown.jsx
@@ -0,0 +1,16 @@
+import React from 'react';
+
+import { MARKDOWN_TYPE } from '../../../util/componentTypes';
+import { NEW_MARKDOWN_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+export default function DraggableNewDivider() {
+  return (
+    <DraggableNewComponent
+      id={NEW_MARKDOWN_ID}
+      type={MARKDOWN_TYPE}
+      label="Markdown"
+      className="fa fa-code"
+    />
+  );
+}
diff --git a/superset/assets/src/dashboard/components/gridComponents/new/NewRow.jsx b/superset/assets/src/dashboard/components/gridComponents/new/NewRow.jsx
new file mode 100644
index 0000000000..81bdc93d18
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/new/NewRow.jsx
@@ -0,0 +1,16 @@
+import React from 'react';
+
+import { ROW_TYPE } from '../../../util/componentTypes';
+import { NEW_ROW_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+export default function DraggableNewRow() {
+  return (
+    <DraggableNewComponent
+      id={NEW_ROW_ID}
+      type={ROW_TYPE}
+      label="Row"
+      className="fa fa-long-arrow-right"
+    />
+  );
+}
diff --git a/superset/assets/src/dashboard/components/gridComponents/new/NewTabs.jsx b/superset/assets/src/dashboard/components/gridComponents/new/NewTabs.jsx
new file mode 100644
index 0000000000..fd9366bb55
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/new/NewTabs.jsx
@@ -0,0 +1,16 @@
+import React from 'react';
+
+import { TABS_TYPE } from '../../../util/componentTypes';
+import { NEW_TABS_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+export default function DraggableNewTabs() {
+  return (
+    <DraggableNewComponent
+      id={NEW_TABS_ID}
+      type={TABS_TYPE}
+      label="Tabs"
+      className="fa fa-window-restore"
+    />
+  );
+}
diff --git a/superset/assets/src/dashboard/components/menu/BackgroundStyleDropdown.jsx b/superset/assets/src/dashboard/components/menu/BackgroundStyleDropdown.jsx
new file mode 100644
index 0000000000..41cf1df72a
--- /dev/null
+++ b/superset/assets/src/dashboard/components/menu/BackgroundStyleDropdown.jsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import backgroundStyleOptions from '../../util/backgroundStyleOptions';
+import PopoverDropdown from './PopoverDropdown';
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  value: PropTypes.string.isRequired,
+  onChange: PropTypes.func.isRequired,
+};
+
+function renderButton(option) {
+  return (
+    <div className={cx('background-style-option', option.className)}>
+      {`${option.label} background`}
+    </div>
+  );
+}
+
+function renderOption(option) {
+  return (
+    <div className={cx('background-style-option', option.className)}>
+      {option.label}
+    </div>
+  );
+}
+
+export default class BackgroundStyleDropdown extends React.PureComponent {
+  render() {
+    const { id, value, onChange } = this.props;
+    return (
+      <PopoverDropdown
+        id={id}
+        options={backgroundStyleOptions}
+        value={value}
+        onChange={onChange}
+        renderButton={renderButton}
+        renderOption={renderOption}
+      />
+    );
+  }
+}
+
+BackgroundStyleDropdown.propTypes = propTypes;
diff --git a/superset/assets/src/dashboard/components/menu/HoverMenu.jsx b/superset/assets/src/dashboard/components/menu/HoverMenu.jsx
new file mode 100644
index 0000000000..c238d023d8
--- /dev/null
+++ b/superset/assets/src/dashboard/components/menu/HoverMenu.jsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+const propTypes = {
+  position: PropTypes.oneOf(['left', 'top']),
+  innerRef: PropTypes.func,
+  children: PropTypes.node,
+};
+
+const defaultProps = {
+  position: 'left',
+  innerRef: null,
+  children: null,
+};
+
+export default class HoverMenu extends React.PureComponent {
+  render() {
+    const { innerRef, position, children } = this.props;
+    return (
+      <div
+        ref={innerRef}
+        className={cx(
+          'hover-menu',
+          position === 'left' && 'hover-menu--left',
+          position === 'top' && 'hover-menu--top',
+        )}
+      >
+        {children}
+      </div>
+    );
+  }
+}
+
+HoverMenu.propTypes = propTypes;
+HoverMenu.defaultProps = defaultProps;
diff --git a/superset/assets/src/dashboard/components/menu/MarkdownModeDropdown.jsx b/superset/assets/src/dashboard/components/menu/MarkdownModeDropdown.jsx
new file mode 100644
index 0000000000..10aa932233
--- /dev/null
+++ b/superset/assets/src/dashboard/components/menu/MarkdownModeDropdown.jsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { t } from '../../../locales';
+
+import PopoverDropdown from './PopoverDropdown';
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  value: PropTypes.string.isRequired,
+  onChange: PropTypes.func.isRequired,
+};
+
+const dropdownOptions = [
+  {
+    value: 'edit',
+    label: t('Edit'),
+  },
+  {
+    value: 'preview',
+    label: t('Preview'),
+  },
+];
+
+export default class MarkdownModeDropdown extends React.PureComponent {
+  render() {
+    const { id, value, onChange } = this.props;
+
+    return (
+      <PopoverDropdown
+        id={id}
+        options={dropdownOptions}
+        value={value}
+        onChange={onChange}
+      />
+    );
+  }
+}
+
+MarkdownModeDropdown.propTypes = propTypes;
diff --git a/superset/assets/src/dashboard/components/menu/PopoverDropdown.jsx b/superset/assets/src/dashboard/components/menu/PopoverDropdown.jsx
new file mode 100644
index 0000000000..4971793026
--- /dev/null
+++ b/superset/assets/src/dashboard/components/menu/PopoverDropdown.jsx
@@ -0,0 +1,66 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { DropdownButton, MenuItem } from 'react-bootstrap';
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  options: PropTypes.arrayOf(
+    PropTypes.shape({
+      value: PropTypes.string.isRequired,
+      label: PropTypes.string.isRequired,
+      className: PropTypes.string,
+    }),
+  ).isRequired,
+  onChange: PropTypes.func.isRequired,
+  value: PropTypes.string.isRequired,
+  renderButton: PropTypes.func,
+  renderOption: PropTypes.func,
+};
+
+const defaultProps = {
+  renderButton: option => option.label,
+  renderOption: option => (
+    <div className={option.className}>{option.label}</div>
+  ),
+};
+
+class PopoverDropdown extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.handleSelect = this.handleSelect.bind(this);
+  }
+
+  handleSelect(nextValue) {
+    this.props.onChange(nextValue);
+  }
+
+  render() {
+    const { id, value, options, renderButton, renderOption } = this.props;
+    const selected = options.find(opt => opt.value === value);
+    return (
+      <DropdownButton
+        id={id}
+        bsSize="small"
+        title={renderButton(selected)}
+        className="popover-dropdown"
+      >
+        {options.map(option => (
+          <MenuItem
+            key={option.value}
+            eventKey={option.value}
+            active={option.value === value}
+            onSelect={this.handleSelect}
+            className="dropdown-item"
+          >
+            {renderOption(option)}
+          </MenuItem>
+        ))}
+      </DropdownButton>
+    );
+  }
+}
+
+PopoverDropdown.propTypes = propTypes;
+PopoverDropdown.defaultProps = defaultProps;
+
+export default PopoverDropdown;
diff --git a/superset/assets/src/dashboard/components/menu/WithPopoverMenu.jsx b/superset/assets/src/dashboard/components/menu/WithPopoverMenu.jsx
new file mode 100644
index 0000000000..2a047ac573
--- /dev/null
+++ b/superset/assets/src/dashboard/components/menu/WithPopoverMenu.jsx
@@ -0,0 +1,123 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+const propTypes = {
+  children: PropTypes.node,
+  disableClick: PropTypes.bool,
+  menuItems: PropTypes.arrayOf(PropTypes.node),
+  onChangeFocus: PropTypes.func,
+  isFocused: PropTypes.bool,
+  shouldFocus: PropTypes.func,
+  editMode: PropTypes.bool.isRequired,
+  style: PropTypes.object,
+};
+
+const defaultProps = {
+  children: null,
+  disableClick: false,
+  onChangeFocus: null,
+  onPressDelete() {},
+  menuItems: [],
+  isFocused: false,
+  shouldFocus: (event, container) => container.contains(event.target),
+  style: null,
+};
+
+class WithPopoverMenu extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {
+      isFocused: props.isFocused,
+    };
+    this.setRef = this.setRef.bind(this);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.editMode && nextProps.isFocused && !this.state.isFocused) {
+      document.addEventListener('click', this.handleClick, true);
+      document.addEventListener('drag', this.handleClick, true);
+      this.setState({ isFocused: true });
+    } else if (this.state.isFocused && !nextProps.editMode) {
+      document.removeEventListener('click', this.handleClick, true);
+      document.removeEventListener('drag', this.handleClick, true);
+      this.setState({ isFocused: false });
+    }
+  }
+
+  componentWillUnmount() {
+    document.removeEventListener('click', this.handleClick, true);
+    document.removeEventListener('drag', this.handleClick, true);
+  }
+
+  setRef(ref) {
+    this.container = ref;
+  }
+
+  handleClick(event) {
+    if (!this.props.editMode) {
+      return;
+    }
+    const {
+      onChangeFocus,
+      shouldFocus: shouldFocusFunc,
+      disableClick,
+    } = this.props;
+    const shouldFocus = shouldFocusFunc(event, this.container);
+
+    if (!disableClick && shouldFocus && !this.state.isFocused) {
+      // if not focused, set focus and add a window event listener to capture outside clicks
+      // this enables us to not set a click listener for ever item on a dashboard
+      document.addEventListener('click', this.handleClick, true);
+      document.addEventListener('drag', this.handleClick, true);
+      this.setState(() => ({ isFocused: true }));
+      if (onChangeFocus) {
+        onChangeFocus(true);
+      }
+    } else if (!shouldFocus && this.state.isFocused) {
+      document.removeEventListener('click', this.handleClick, true);
+      document.removeEventListener('drag', this.handleClick, true);
+      this.setState(() => ({ isFocused: false }));
+      if (onChangeFocus) {
+        onChangeFocus(false);
+      }
+    }
+  }
+
+  render() {
+    const { children, menuItems, editMode, style } = this.props;
+    const { isFocused } = this.state;
+
+    return (
+      <div
+        ref={this.setRef}
+        onClick={this.handleClick}
+        role="none"
+        className={cx(
+          'with-popover-menu',
+          editMode && isFocused && 'with-popover-menu--focused',
+        )}
+        style={style}
+      >
+        {children}
+        {editMode &&
+          isFocused &&
+          menuItems.length > 0 && (
+            <div className="popover-menu">
+              {menuItems.map((node, i) => (
+                <div className="menu-item" key={`menu-item-${i}`}>
+                  {node}
+                </div>
+              ))}
+            </div>
+          )}
+      </div>
+    );
+  }
+}
+
+WithPopoverMenu.propTypes = propTypes;
+WithPopoverMenu.defaultProps = defaultProps;
+
+export default WithPopoverMenu;
diff --git a/superset/assets/src/dashboard/components/resizable/ResizableContainer.jsx b/superset/assets/src/dashboard/components/resizable/ResizableContainer.jsx
new file mode 100644
index 0000000000..7e09e73d91
--- /dev/null
+++ b/superset/assets/src/dashboard/components/resizable/ResizableContainer.jsx
@@ -0,0 +1,213 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Resizable from 're-resizable';
+import cx from 'classnames';
+
+import ResizableHandle from './ResizableHandle';
+import resizableConfig from '../../util/resizableConfig';
+import { GRID_BASE_UNIT, GRID_GUTTER_SIZE } from '../../util/constants';
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  children: PropTypes.node,
+  adjustableWidth: PropTypes.bool,
+  adjustableHeight: PropTypes.bool,
+  gutterWidth: PropTypes.number,
+  widthStep: PropTypes.number,
+  heightStep: PropTypes.number,
+  widthMultiple: PropTypes.number,
+  heightMultiple: PropTypes.number,
+  minWidthMultiple: PropTypes.number,
+  maxWidthMultiple: PropTypes.number,
+  minHeightMultiple: PropTypes.number,
+  maxHeightMultiple: PropTypes.number,
+  staticHeight: PropTypes.number,
+  staticHeightMultiple: PropTypes.number,
+  staticWidth: PropTypes.number,
+  staticWidthMultiple: PropTypes.number,
+  onResizeStop: PropTypes.func,
+  onResize: PropTypes.func,
+  onResizeStart: PropTypes.func,
+  editMode: PropTypes.bool.isRequired,
+};
+
+const defaultProps = {
+  children: null,
+  adjustableWidth: true,
+  adjustableHeight: true,
+  gutterWidth: GRID_GUTTER_SIZE,
+  widthStep: GRID_BASE_UNIT,
+  heightStep: GRID_BASE_UNIT,
+  widthMultiple: null,
+  heightMultiple: null,
+  minWidthMultiple: 1,
+  maxWidthMultiple: Infinity,
+  minHeightMultiple: 1,
+  maxHeightMultiple: Infinity,
+  staticHeight: null,
+  staticHeightMultiple: null,
+  staticWidth: null,
+  staticWidthMultiple: null,
+  onResizeStop: null,
+  onResize: null,
+  onResizeStart: null,
+};
+
+// because columns are not multiples of a single variable (width = n*cols + (n-1) * gutters)
+// we snap to the base unit and then snap to _actual_ column multiples on stop
+const SNAP_TO_GRID = [GRID_BASE_UNIT, GRID_BASE_UNIT];
+const HANDLE_CLASSES = {
+  right: 'resizable-container-handle--right',
+  bottom: 'resizable-container-handle--bottom',
+};
+class ResizableContainer extends React.PureComponent {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isResizing: false,
+    };
+
+    this.handleResizeStart = this.handleResizeStart.bind(this);
+    this.handleResize = this.handleResize.bind(this);
+    this.handleResizeStop = this.handleResizeStop.bind(this);
+  }
+
+  handleResizeStart(event, direction, ref) {
+    const { id, onResizeStart } = this.props;
+
+    if (onResizeStart) {
+      onResizeStart({ id, direction, ref });
+    }
+
+    this.setState(() => ({ isResizing: true }));
+  }
+
+  handleResize(event, direction, ref) {
+    const { onResize, id } = this.props;
+    if (onResize) {
+      onResize({ id, direction, ref });
+    }
+  }
+
+  handleResizeStop(event, direction, ref, delta) {
+    const {
+      id,
+      onResizeStop,
+      widthStep,
+      heightStep,
+      widthMultiple,
+      heightMultiple,
+      adjustableHeight,
+      adjustableWidth,
+      gutterWidth,
+    } = this.props;
+
+    if (onResizeStop) {
+      const nextWidthMultiple =
+        widthMultiple + Math.round(delta.width / (widthStep + gutterWidth));
+      const nextHeightMultiple =
+        heightMultiple + Math.round(delta.height / heightStep);
+
+      onResizeStop({
+        id,
+        widthMultiple: adjustableWidth ? nextWidthMultiple : null,
+        heightMultiple: adjustableHeight ? nextHeightMultiple : null,
+      });
+
+      this.setState(() => ({ isResizing: false }));
+    }
+  }
+
+  render() {
+    const {
+      children,
+      adjustableWidth,
+      adjustableHeight,
+      widthStep,
+      heightStep,
+      widthMultiple,
+      heightMultiple,
+      staticHeight,
+      staticHeightMultiple,
+      staticWidth,
+      staticWidthMultiple,
+      minWidthMultiple,
+      maxWidthMultiple,
+      minHeightMultiple,
+      maxHeightMultiple,
+      gutterWidth,
+      editMode,
+    } = this.props;
+
+    const size = {
+      width: adjustableWidth
+        ? (widthStep + gutterWidth) * widthMultiple - gutterWidth
+        : (staticWidthMultiple && staticWidthMultiple * widthStep) ||
+          staticWidth ||
+          undefined,
+      height: adjustableHeight
+        ? heightStep * heightMultiple
+        : (staticHeightMultiple && staticHeightMultiple * heightStep) ||
+          staticHeight ||
+          undefined,
+    };
+
+    let enableConfig = resizableConfig.notAdjustable;
+
+    if (editMode && adjustableWidth && adjustableHeight) {
+      enableConfig = resizableConfig.widthAndHeight;
+    } else if (editMode && adjustableWidth) {
+      enableConfig = resizableConfig.widthOnly;
+    } else if (editMode && adjustableHeight) {
+      enableConfig = resizableConfig.heightOnly;
+    }
+
+    const { isResizing } = this.state;
+
+    return (
+      <Resizable
+        enable={enableConfig}
+        grid={SNAP_TO_GRID}
+        minWidth={
+          adjustableWidth
+            ? minWidthMultiple * (widthStep + gutterWidth) - gutterWidth
+            : undefined
+        }
+        minHeight={
+          adjustableHeight ? minHeightMultiple * heightStep : undefined
+        }
+        maxWidth={
+          adjustableWidth
+            ? Math.max(
+                size.width,
+                maxWidthMultiple * (widthStep + gutterWidth) - gutterWidth,
+              )
+            : undefined
+        }
+        maxHeight={
+          adjustableHeight
+            ? Math.max(size.height, maxHeightMultiple * heightStep)
+            : undefined
+        }
+        size={size}
+        onResizeStart={this.handleResizeStart}
+        onResize={this.handleResize}
+        onResizeStop={this.handleResizeStop}
+        handleComponent={ResizableHandle}
+        className={cx(
+          'resizable-container',
+          isResizing && 'resizable-container--resizing',
+        )}
+        handleClasses={HANDLE_CLASSES}
+      >
+        {children}
+      </Resizable>
+    );
+  }
+}
+
+ResizableContainer.propTypes = propTypes;
+ResizableContainer.defaultProps = defaultProps;
+
+export default ResizableContainer;
diff --git a/superset/assets/src/dashboard/components/resizable/ResizableHandle.jsx b/superset/assets/src/dashboard/components/resizable/ResizableHandle.jsx
new file mode 100644
index 0000000000..b696b26fa4
--- /dev/null
+++ b/superset/assets/src/dashboard/components/resizable/ResizableHandle.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+
+export function BottomRightResizeHandle() {
+  return <div className="resize-handle resize-handle--bottom-right" />;
+}
+
+export function RightResizeHandle() {
+  return <div className="resize-handle resize-handle--right" />;
+}
+
+export function BottomResizeHandle() {
+  return <div className="resize-handle resize-handle--bottom" />;
+}
+
+export default {
+  right: RightResizeHandle,
+  bottom: BottomResizeHandle,
+  bottomRight: BottomRightResizeHandle,
+};
diff --git a/superset/assets/src/dashboard/containers/Chart.jsx b/superset/assets/src/dashboard/containers/Chart.jsx
new file mode 100644
index 0000000000..71258b48c1
--- /dev/null
+++ b/superset/assets/src/dashboard/containers/Chart.jsx
@@ -0,0 +1,63 @@
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+
+import {
+  addFilter,
+  removeFilter,
+  toggleExpandSlice,
+} from '../actions/dashboardState';
+import { refreshChart } from '../../chart/chartAction';
+import getFormDataWithExtraFilters from '../util/charts/getFormDataWithExtraFilters';
+import { updateComponents } from '../actions/dashboardLayout';
+import Chart from '../components/gridComponents/Chart';
+
+const EMPTY_FILTERS = {};
+
+function mapStateToProps(
+  {
+    charts: chartQueries,
+    dashboardInfo,
+    dashboardState,
+    datasources,
+    sliceEntities,
+  },
+  ownProps,
+) {
+  const { id } = ownProps;
+  const chart = chartQueries[id] || {};
+  const { filters } = dashboardState;
+
+  return {
+    chart,
+    datasource: datasources[chart.form_data.datasource] || {},
+    slice: sliceEntities.slices[id],
+    timeout: dashboardInfo.common.conf.SUPERSET_WEBSERVER_TIMEOUT,
+    filters: filters[id] || EMPTY_FILTERS,
+    // note: this method caches filters if possible to prevent render cascades
+    formData: getFormDataWithExtraFilters({
+      chart,
+      dashboardMetadata: dashboardInfo.metadata,
+      filters,
+      sliceId: id,
+    }),
+    editMode: dashboardState.editMode,
+    isExpanded: !!dashboardState.expandedSlices[id],
+    supersetCanExplore: !!dashboardInfo.superset_can_explore,
+    sliceCanEdit: !!dashboardInfo.slice_can_edit,
+  };
+}
+
+function mapDispatchToProps(dispatch) {
+  return bindActionCreators(
+    {
+      updateComponents,
+      toggleExpandSlice,
+      addFilter,
+      refreshChart,
+      removeFilter,
+    },
+    dispatch,
+  );
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(Chart);
diff --git a/superset/assets/src/dashboard/containers/Dashboard.jsx b/superset/assets/src/dashboard/containers/Dashboard.jsx
new file mode 100644
index 0000000000..bcf2ace219
--- /dev/null
+++ b/superset/assets/src/dashboard/containers/Dashboard.jsx
@@ -0,0 +1,47 @@
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+
+import {
+  addSliceToDashboard,
+  removeSliceFromDashboard,
+} from '../actions/dashboardState';
+import { runQuery } from '../../chart/chartAction';
+import Dashboard from '../components/Dashboard';
+
+function mapStateToProps({
+  datasources,
+  sliceEntities,
+  charts,
+  dashboardInfo,
+  dashboardState,
+  dashboardLayout,
+  impressionId,
+}) {
+  return {
+    initMessages: dashboardInfo.common.flash_messages,
+    timeout: dashboardInfo.common.conf.SUPERSET_WEBSERVER_TIMEOUT,
+    userId: dashboardInfo.userId,
+    dashboardInfo,
+    dashboardState,
+    charts,
+    datasources,
+    slices: sliceEntities.slices,
+    layout: dashboardLayout.present,
+    impressionId,
+  };
+}
+
+function mapDispatchToProps(dispatch) {
+  return {
+    actions: bindActionCreators(
+      {
+        addSliceToDashboard,
+        removeSliceFromDashboard,
+        runQuery,
+      },
+      dispatch,
+    ),
+  };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(Dashboard);
diff --git a/superset/assets/src/dashboard/containers/DashboardBuilder.jsx b/superset/assets/src/dashboard/containers/DashboardBuilder.jsx
new file mode 100644
index 0000000000..fde1e76e84
--- /dev/null
+++ b/superset/assets/src/dashboard/containers/DashboardBuilder.jsx
@@ -0,0 +1,30 @@
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+import DashboardBuilder from '../components/DashboardBuilder';
+
+import { toggleBuilderPane } from '../actions/dashboardState';
+import {
+  deleteTopLevelTabs,
+  handleComponentDrop,
+} from '../actions/dashboardLayout';
+
+function mapStateToProps({ dashboardLayout: undoableLayout, dashboardState }) {
+  return {
+    dashboardLayout: undoableLayout.present,
+    editMode: dashboardState.editMode,
+    showBuilderPane: dashboardState.showBuilderPane,
+  };
+}
+
+function mapDispatchToProps(dispatch) {
+  return bindActionCreators(
+    {
+      deleteTopLevelTabs,
+      handleComponentDrop,
+      toggleBuilderPane,
+    },
+    dispatch,
+  );
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(DashboardBuilder);
diff --git a/superset/assets/src/dashboard/containers/DashboardComponent.jsx b/superset/assets/src/dashboard/containers/DashboardComponent.jsx
new file mode 100644
index 0000000000..29071cb18f
--- /dev/null
+++ b/superset/assets/src/dashboard/containers/DashboardComponent.jsx
@@ -0,0 +1,87 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+
+import ComponentLookup from '../components/gridComponents';
+import getTotalChildWidth from '../util/getChildWidth';
+import { componentShape } from '../util/propShapes';
+import { COLUMN_TYPE, ROW_TYPE } from '../util/componentTypes';
+import { GRID_MIN_COLUMN_COUNT } from '../util/constants';
+
+import {
+  createComponent,
+  deleteComponent,
+  updateComponents,
+  handleComponentDrop,
+} from '../actions/dashboardLayout';
+
+const propTypes = {
+  component: componentShape.isRequired,
+  parentComponent: componentShape.isRequired,
+  createComponent: PropTypes.func.isRequired,
+  deleteComponent: PropTypes.func.isRequired,
+  updateComponents: PropTypes.func.isRequired,
+  handleComponentDrop: PropTypes.func.isRequired,
+};
+
+function mapStateToProps(
+  { dashboardLayout: undoableLayout, dashboardState },
+  ownProps,
+) {
+  const dashboardLayout = undoableLayout.present;
+  const { id, parentId } = ownProps;
+  const component = dashboardLayout[id];
+  const props = {
+    component,
+    parentComponent: dashboardLayout[parentId],
+    editMode: dashboardState.editMode,
+  };
+
+  // rows and columns need more data about their child dimensions
+  // doing this allows us to not pass the entire component lookup to all Components
+  if (props.component.type === ROW_TYPE) {
+    props.occupiedColumnCount = getTotalChildWidth({
+      id,
+      components: dashboardLayout,
+    });
+  } else if (props.component.type === COLUMN_TYPE) {
+    props.minColumnWidth = GRID_MIN_COLUMN_COUNT;
+
+    component.children.forEach(childId => {
+      // rows don't have widths, so find the width of its children
+      if (dashboardLayout[childId].type === ROW_TYPE) {
+        props.minColumnWidth = Math.max(
+          props.minColumnWidth,
+          getTotalChildWidth({ id: childId, components: dashboardLayout }),
+        );
+      }
+    });
+  }
+
+  return props;
+}
+
+function mapDispatchToProps(dispatch) {
+  return bindActionCreators(
+    {
+      createComponent,
+      deleteComponent,
+      updateComponents,
+      handleComponentDrop,
+    },
+    dispatch,
+  );
+}
+
+class DashboardComponent extends React.PureComponent {
+  render() {
+    const { component } = this.props;
+    const Component = ComponentLookup[component.type];
+    return Component ? <Component {...this.props} /> : null;
+  }
+}
+
+DashboardComponent.propTypes = propTypes;
+
+export default connect(mapStateToProps, mapDispatchToProps)(DashboardComponent);
diff --git a/superset/assets/src/dashboard/containers/DashboardGrid.jsx b/superset/assets/src/dashboard/containers/DashboardGrid.jsx
new file mode 100644
index 0000000000..718b5437e3
--- /dev/null
+++ b/superset/assets/src/dashboard/containers/DashboardGrid.jsx
@@ -0,0 +1,26 @@
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+import DashboardGrid from '../components/DashboardGrid';
+
+import {
+  handleComponentDrop,
+  resizeComponent,
+} from '../actions/dashboardLayout';
+
+function mapStateToProps({ dashboardState }) {
+  return {
+    editMode: dashboardState.editMode,
+  };
+}
+
+function mapDispatchToProps(dispatch) {
+  return bindActionCreators(
+    {
+      handleComponentDrop,
+      resizeComponent,
+    },
+    dispatch,
+  );
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(DashboardGrid);
diff --git a/superset/assets/src/dashboard/containers/DashboardHeader.jsx b/superset/assets/src/dashboard/containers/DashboardHeader.jsx
new file mode 100644
index 0000000000..19be06cd0e
--- /dev/null
+++ b/superset/assets/src/dashboard/containers/DashboardHeader.jsx
@@ -0,0 +1,81 @@
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+
+import DashboardHeader from '../components/Header';
+
+import {
+  setEditMode,
+  toggleBuilderPane,
+  fetchFaveStar,
+  saveFaveStar,
+  fetchCharts,
+  startPeriodicRender,
+  updateCss,
+  onChange,
+  saveDashboardRequest,
+  setMaxUndoHistoryExceeded,
+  maxUndoHistoryToast,
+} from '../actions/dashboardState';
+
+import {
+  undoLayoutAction,
+  redoLayoutAction,
+  updateDashboardTitle,
+} from '../actions/dashboardLayout';
+
+import { addSuccessToast, addDangerToast } from '../actions/messageToasts';
+
+import { DASHBOARD_HEADER_ID } from '../util/constants';
+
+function mapStateToProps({
+  dashboardLayout: undoableLayout,
+  dashboardState: dashboard,
+  dashboardInfo,
+  charts,
+}) {
+  return {
+    dashboardInfo,
+    undoLength: undoableLayout.past.length,
+    redoLength: undoableLayout.future.length,
+    layout: undoableLayout.present,
+    filters: dashboard.filters,
+    dashboardTitle: (
+      (undoableLayout.present[DASHBOARD_HEADER_ID] || {}).meta || {}
+    ).text,
+    expandedSlices: dashboard.expandedSlices,
+    css: dashboard.css,
+    charts,
+    userId: dashboardInfo.userId,
+    isStarred: !!dashboard.isStarred,
+    hasUnsavedChanges: !!dashboard.hasUnsavedChanges,
+    maxUndoHistoryExceeded: !!dashboard.maxUndoHistoryExceeded,
+    editMode: !!dashboard.editMode,
+    showBuilderPane: !!dashboard.showBuilderPane,
+  };
+}
+
+function mapDispatchToProps(dispatch) {
+  return bindActionCreators(
+    {
+      addSuccessToast,
+      addDangerToast,
+      onUndo: undoLayoutAction,
+      onRedo: redoLayoutAction,
+      setEditMode,
+      toggleBuilderPane,
+      fetchFaveStar,
+      saveFaveStar,
+      fetchCharts,
+      startPeriodicRender,
+      updateDashboardTitle,
+      updateCss,
+      onChange,
+      onSave: saveDashboardRequest,
+      setMaxUndoHistoryExceeded,
+      maxUndoHistoryToast,
+    },
+    dispatch,
+  );
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(DashboardHeader);
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/containers/ToastPresenter.jsx b/superset/assets/src/dashboard/containers/ToastPresenter.jsx
new file mode 100644
index 0000000000..7e70abc594
--- /dev/null
+++ b/superset/assets/src/dashboard/containers/ToastPresenter.jsx
@@ -0,0 +1,10 @@
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+import ToastPresenter from '../components/ToastPresenter';
+
+import { removeToast } from '../actions/messageToasts';
+
+export default connect(
+  ({ messageToasts: toasts }) => ({ toasts }),
+  dispatch => bindActionCreators({ removeToast }, dispatch),
+)(ToastPresenter);
diff --git a/superset/assets/src/dashboard/fixtures/emptyDashboardLayout.js b/superset/assets/src/dashboard/fixtures/emptyDashboardLayout.js
new file mode 100644
index 0000000000..e306288766
--- /dev/null
+++ b/superset/assets/src/dashboard/fixtures/emptyDashboardLayout.js
@@ -0,0 +1,34 @@
+import {
+  DASHBOARD_GRID_TYPE,
+  HEADER_TYPE,
+  DASHBOARD_ROOT_TYPE,
+} from '../util/componentTypes';
+
+import {
+  DASHBOARD_ROOT_ID,
+  DASHBOARD_HEADER_ID,
+  DASHBOARD_GRID_ID,
+} from '../util/constants';
+
+export default {
+  [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: [],
+    meta: {},
+  },
+
+  [DASHBOARD_HEADER_ID]: {
+    type: HEADER_TYPE,
+    id: DASHBOARD_HEADER_ID,
+    meta: {
+      text: 'New dashboard',
+    },
+  },
+};
diff --git a/superset/assets/src/dashboard/index.jsx b/superset/assets/src/dashboard/index.jsx
index 774e07101f..846b82d411 100644
--- a/superset/assets/src/dashboard/index.jsx
+++ b/superset/assets/src/dashboard/index.jsx
@@ -7,18 +7,22 @@ import thunk from 'redux-thunk';
 import { initEnhancer } from '../reduxUtils';
 import { appSetup } from '../common';
 import { initJQueryAjax } from '../modules/utils';
-import DashboardContainer from './components/DashboardContainer';
-import rootReducer, { getInitialState } from './reducers';
+import DashboardContainer from './containers/Dashboard';
+import getInitialState from './reducers/getInitialState';
+import rootReducer from './reducers/index';
 
 appSetup();
 initJQueryAjax();
 
 const appContainer = document.getElementById('app');
 const bootstrapData = JSON.parse(appContainer.getAttribute('data-bootstrap'));
-const initState = Object.assign({}, getInitialState(bootstrapData));
+const initState = getInitialState(bootstrapData);
 
 const store = createStore(
-  rootReducer, initState, compose(applyMiddleware(thunk), initEnhancer(false)));
+  rootReducer,
+  initState,
+  compose(applyMiddleware(thunk), initEnhancer(false)),
+);
 
 ReactDOM.render(
   <Provider store={store}>
@@ -26,4 +30,3 @@ ReactDOM.render(
   </Provider>,
   appContainer,
 );
-
diff --git a/superset/assets/src/dashboard/reducers.js b/superset/assets/src/dashboard/reducers.js
deleted file mode 100644
index 074c44524a..0000000000
--- a/superset/assets/src/dashboard/reducers.js
+++ /dev/null
@@ -1,214 +0,0 @@
-/* eslint-disable camelcase */
-import { combineReducers } from 'redux';
-import d3 from 'd3';
-import shortid from 'shortid';
-
-import charts, { chart } from '../chart/chartReducer';
-import * as actions from './actions';
-import { getParam } from '../modules/utils';
-import { alterInArr, removeFromArr } from '../reduxUtils';
-import { applyDefaultFormData } from '../explore/stores/store';
-import { getColorFromScheme } from '../modules/colors';
-
-export function getInitialState(bootstrapData) {
-  const { user_id, datasources, common, editMode } = bootstrapData;
-  delete common.locale;
-  delete common.language_pack;
-
-  const dashboard = { ...bootstrapData.dashboard_data };
-  let filters = {};
-  try {
-    // allow request parameter overwrite dashboard metadata
-    filters = JSON.parse(getParam('preselect_filters') || dashboard.metadata.default_filters);
-  } catch (e) {
-    //
-  }
-
-  // Priming the color palette with user's label-color mapping provided in
-  // the dashboard's JSON metadata
-  if (dashboard.metadata && dashboard.metadata.label_colors) {
-    const colorMap = dashboard.metadata.label_colors;
-    for (const label in colorMap) {
-      getColorFromScheme(label, null, colorMap[label]);
-    }
-  }
-
-  dashboard.posDict = {};
-  dashboard.layout = [];
-  if (Array.isArray(dashboard.position_json)) {
-    dashboard.position_json.forEach((position) => {
-      dashboard.posDict[position.slice_id] = position;
-    });
-  } else {
-    dashboard.position_json = [];
-  }
-
-  const lastRowId = Math.max(0, Math.max.apply(null,
-    dashboard.position_json.map(pos => (pos.row + pos.size_y))));
-  let newSliceCounter = 0;
-  dashboard.slices.forEach((slice) => {
-    const sliceId = slice.slice_id;
-    let pos = dashboard.posDict[sliceId];
-    if (!pos) {
-      // append new slices to dashboard bottom, 3 slices per row
-      pos = {
-        col: (newSliceCounter % 3) * 16 + 1,
-        row: lastRowId + Math.floor(newSliceCounter / 3) * 16,
-        size_x: 16,
-        size_y: 16,
-      };
-      newSliceCounter++;
-    }
-
-    dashboard.layout.push({
-      i: String(sliceId),
-      x: pos.col - 1,
-      y: pos.row,
-      w: pos.size_x,
-      minW: 2,
-      h: pos.size_y,
-    });
-  });
-
-  // will use charts action/reducers to handle chart render
-  const initCharts = {};
-  dashboard.slices.forEach((slice) => {
-    const chartKey = 'slice_' + slice.slice_id;
-    initCharts[chartKey] = { ...chart,
-      chartKey,
-      slice_id: slice.slice_id,
-      form_data: slice.form_data,
-      formData: applyDefaultFormData(slice.form_data),
-    };
-  });
-
-  // also need to add formData for dashboard.slices
-  dashboard.slices = dashboard.slices.map(slice =>
-    ({ ...slice, formData: applyDefaultFormData(slice.form_data) }),
-  );
-
-  return {
-    charts: initCharts,
-    dashboard: { filters, dashboard, userId: user_id, datasources, common, editMode },
-  };
-}
-
-export const dashboard = function (state = {}, action) {
-  const actionHandlers = {
-    [actions.UPDATE_DASHBOARD_TITLE]() {
-      const newDashboard = { ...state.dashboard, dashboard_title: action.title };
-      return { ...state, dashboard: newDashboard };
-    },
-    [actions.UPDATE_DASHBOARD_LAYOUT]() {
-      const newDashboard = { ...state.dashboard, layout: action.layout };
-      return { ...state, dashboard: newDashboard };
-    },
-    [actions.REMOVE_SLICE]() {
-      const key = String(action.slice.slice_id);
-      const newLayout = state.dashboard.layout.filter(reactPos => (reactPos.i !== key));
-      const newDashboard = removeFromArr(state.dashboard, 'slices', action.slice, 'slice_id');
-      // if this slice is a filter
-      const newFilter = { ...state.filters };
-      let refresh = false;
-      if (state.filters[key]) {
-        delete newFilter[key];
-        refresh = true;
-      }
-      return {
-        ...state,
-        dashboard: { ...newDashboard, layout: newLayout },
-        filters: newFilter,
-        refresh,
-      };
-    },
-    [actions.TOGGLE_FAVE_STAR]() {
-      return { ...state, isStarred: action.isStarred };
-    },
-    [actions.SET_EDIT_MODE]() {
-      return { ...state, editMode: action.editMode };
-    },
-    [actions.TOGGLE_EXPAND_SLICE]() {
-      const updatedExpandedSlices = { ...state.dashboard.metadata.expanded_slices };
-      const sliceId = action.slice.slice_id;
-      if (action.isExpanded) {
-        updatedExpandedSlices[sliceId] = true;
-      } else {
-        delete updatedExpandedSlices[sliceId];
-      }
-      const metadata = { ...state.dashboard.metadata, expanded_slices: updatedExpandedSlices };
-      const newDashboard = { ...state.dashboard, metadata };
-      return { ...state, dashboard: newDashboard };
-    },
-
-    // filters
-    [actions.ADD_FILTER]() {
-      const selectedSlice = state.dashboard.slices
-        .find(slice => (slice.slice_id === action.sliceId));
-      if (!selectedSlice) {
-        return state;
-      }
-
-      let filters = state.filters;
-      const { sliceId, col, vals, merge, refresh } = action;
-      const filterKeys = ['__from', '__to', '__time_col',
-        '__time_grain', '__time_origin', '__granularity'];
-      if (filterKeys.indexOf(col) >= 0 ||
-        selectedSlice.formData.groupby.indexOf(col) !== -1) {
-        let newFilter = {};
-        if (!(sliceId in filters)) {
-          // Straight up set the filters if none existed for the slice
-          newFilter = { [col]: vals };
-        } else if (filters[sliceId] && !(col in filters[sliceId]) || !merge) {
-          newFilter = { ...filters[sliceId], [col]: vals };
-          // d3.merge pass in array of arrays while some value form filter components
-          // from and to filter box require string to be process and return
-        } else if (filters[sliceId][col] instanceof Array) {
-          newFilter[col] = d3.merge([filters[sliceId][col], vals]);
-        } else {
-          newFilter[col] = d3.merge([[filters[sliceId][col]], vals])[0] || '';
-        }
-        filters = { ...filters, [sliceId]: newFilter };
-      }
-      return { ...state, filters, refresh };
-    },
-    [actions.CLEAR_FILTER]() {
-      const newFilters = { ...state.filters };
-      delete newFilters[action.sliceId];
-      return { ...state, filter: newFilters, refresh: true };
-    },
-    [actions.REMOVE_FILTER]() {
-      const { sliceId, col, vals, refresh } = action;
-      const excluded = new Set(vals);
-      const valFilter = val => !excluded.has(val);
-
-      let filters = state.filters;
-      // Have to be careful not to modify the dashboard state so that
-      // the render actually triggers
-      if (sliceId in state.filters && col in state.filters[sliceId]) {
-        const newFilter = filters[sliceId][col].filter(valFilter);
-        filters = { ...filters, [sliceId]: newFilter };
-      }
-      return { ...state, filters, refresh };
-    },
-
-    // slice reducer
-    [actions.UPDATE_SLICE_NAME]() {
-      const newDashboard = alterInArr(
-        state.dashboard, 'slices',
-        action.slice, { slice_name: action.sliceName },
-        'slice_id');
-      return { ...state, dashboard: newDashboard };
-    },
-  };
-
-  if (action.type in actionHandlers) {
-    return actionHandlers[action.type]();
-  }
-  return state;
-};
-
-export default combineReducers({
-  charts,
-  dashboard,
-  impressionId: () => (shortid.generate()),
-});
diff --git a/superset/assets/src/dashboard/reducers/dashboardLayout.js b/superset/assets/src/dashboard/reducers/dashboardLayout.js
new file mode 100644
index 0000000000..4b3ee49722
--- /dev/null
+++ b/superset/assets/src/dashboard/reducers/dashboardLayout.js
@@ -0,0 +1,265 @@
+import {
+  DASHBOARD_ROOT_ID,
+  DASHBOARD_GRID_ID,
+  GRID_MIN_COLUMN_COUNT,
+  NEW_COMPONENTS_SOURCE_ID,
+} from '../util/constants';
+import newComponentFactory from '../util/newComponentFactory';
+import newEntitiesFromDrop from '../util/newEntitiesFromDrop';
+import reorderItem from '../util/dnd-reorder';
+import shouldWrapChildInRow from '../util/shouldWrapChildInRow';
+import {
+  CHART_TYPE,
+  COLUMN_TYPE,
+  MARKDOWN_TYPE,
+  ROW_TYPE,
+  TAB_TYPE,
+  TABS_TYPE,
+} from '../util/componentTypes';
+
+import {
+  UPDATE_COMPONENTS,
+  DELETE_COMPONENT,
+  CREATE_COMPONENT,
+  MOVE_COMPONENT,
+  CREATE_TOP_LEVEL_TABS,
+  DELETE_TOP_LEVEL_TABS,
+} from '../actions/dashboardLayout';
+
+const actionHandlers = {
+  [UPDATE_COMPONENTS](state, action) {
+    const {
+      payload: { nextComponents },
+    } = action;
+    return {
+      ...state,
+      ...nextComponents,
+    };
+  },
+
+  [DELETE_COMPONENT](state, action) {
+    const {
+      payload: { id, parentId },
+    } = action;
+
+    if (!parentId || !id || !state[id] || !state[parentId]) return state;
+
+    const nextComponents = { ...state };
+
+    // recursively find children to remove
+    function recursivelyDeleteChildren(componentId, componentParentId) {
+      // delete child and it's children
+      const component = nextComponents[componentId];
+      delete nextComponents[componentId];
+
+      const { children = [] } = component;
+      children.forEach(childId => {
+        recursivelyDeleteChildren(childId, componentId);
+      });
+
+      const parent = nextComponents[componentParentId];
+      if (parent) {
+        // may have been deleted in another recursion
+        const componentIndex = (parent.children || []).indexOf(componentId);
+        if (componentIndex > -1) {
+          const nextChildren = [...parent.children];
+          nextChildren.splice(componentIndex, 1);
+          nextComponents[componentParentId] = {
+            ...parent,
+            children: nextChildren,
+          };
+        }
+      }
+    }
+
+    recursivelyDeleteChildren(id, parentId);
+
+    return nextComponents;
+  },
+
+  [CREATE_COMPONENT](state, action) {
+    const {
+      payload: { dropResult },
+    } = action;
+    const { destination, dragging } = dropResult;
+    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
+    if (
+      destination.type === COLUMN_TYPE &&
+      [CHART_TYPE, MARKDOWN_TYPE].includes(dragging.type)
+    ) {
+      const newEntitiesArray = Object.values(newEntities);
+      const component = newEntitiesArray.find(
+        entity => entity.type === dragging.type,
+      );
+
+      newEntities[component.id] = {
+        ...component,
+        meta: {
+          ...component.meta,
+          width: GRID_MIN_COLUMN_COUNT,
+        },
+      };
+    }
+
+    return {
+      ...state,
+      ...newEntities,
+    };
+  },
+
+  [MOVE_COMPONENT](state, action) {
+    const {
+      payload: { dropResult },
+    } = action;
+    const { source, destination, dragging } = dropResult;
+
+    if (!source || !destination || !dragging) return state;
+
+    const nextEntities = reorderItem({
+      entitiesMap: state,
+      source,
+      destination,
+    });
+
+    // wrap the dragged component in a row depending on destination type
+    const wrapInRow = shouldWrapChildInRow({
+      parentType: destination.type,
+      childType: dragging.type,
+    });
+
+    if (wrapInRow) {
+      const destinationEntity = nextEntities[destination.id];
+      const destinationChildren = destinationEntity.children;
+      const newRow = newComponentFactory(ROW_TYPE);
+      newRow.children = [destinationChildren[destination.index]];
+      destinationChildren[destination.index] = newRow.id;
+      nextEntities[newRow.id] = newRow;
+    }
+
+    // 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
+    if (
+      destination.type === COLUMN_TYPE &&
+      [CHART_TYPE, MARKDOWN_TYPE].includes(dragging.type)
+    ) {
+      const component = nextEntities[dragging.id];
+      nextEntities[dragging.id] = {
+        ...component,
+        meta: {
+          ...component.meta,
+          width: GRID_MIN_COLUMN_COUNT,
+        },
+      };
+    }
+
+    return {
+      ...state,
+      ...nextEntities,
+    };
+  },
+
+  [CREATE_TOP_LEVEL_TABS](state, action) {
+    const {
+      payload: { dropResult },
+    } = action;
+    const { source, dragging } = dropResult;
+
+    // move children of current root to be children of the dragging tab
+    const rootComponent = state[DASHBOARD_ROOT_ID];
+    const topLevelId = rootComponent.children[0];
+    const topLevelComponent = state[topLevelId];
+
+    if (source.id !== NEW_COMPONENTS_SOURCE_ID) {
+      // component already exists
+      const draggingTabs = state[dragging.id];
+      const draggingTabId = draggingTabs.children[0];
+      const draggingTab = state[draggingTabId];
+
+      // move all children except the one that is dragging
+      const childrenToMove = [...topLevelComponent.children].filter(
+        id => id !== dragging.id,
+      );
+
+      return {
+        ...state,
+        [DASHBOARD_ROOT_ID]: {
+          ...rootComponent,
+          children: [dragging.id],
+        },
+        [topLevelId]: {
+          ...topLevelComponent,
+          children: [],
+        },
+        [draggingTabId]: {
+          ...draggingTab,
+          children: [...draggingTab.children, ...childrenToMove],
+        },
+      };
+    }
+
+    // create new component
+    const newEntities = newEntitiesFromDrop({ dropResult, layout: state });
+    const newEntitiesArray = Object.values(newEntities);
+    const tabComponent = newEntitiesArray.find(
+      component => component.type === TAB_TYPE,
+    );
+    const tabsComponent = newEntitiesArray.find(
+      component => component.type === TABS_TYPE,
+    );
+
+    tabComponent.children = [...topLevelComponent.children];
+    newEntities[topLevelId] = { ...topLevelComponent, children: [] };
+    newEntities[DASHBOARD_ROOT_ID] = {
+      ...rootComponent,
+      children: [tabsComponent.id],
+    };
+
+    return {
+      ...state,
+      ...newEntities,
+    };
+  },
+
+  [DELETE_TOP_LEVEL_TABS](state) {
+    const rootComponent = state[DASHBOARD_ROOT_ID];
+    const topLevelId = rootComponent.children[0];
+    const topLevelTabs = state[topLevelId];
+
+    if (topLevelTabs.type !== TABS_TYPE) return state;
+
+    let childrenToMove = [];
+    const nextEntities = { ...state };
+
+    topLevelTabs.children.forEach(tabId => {
+      const tabComponent = state[tabId];
+      childrenToMove = [...childrenToMove, ...tabComponent.children];
+      delete nextEntities[tabId];
+    });
+
+    delete nextEntities[topLevelId];
+
+    nextEntities[DASHBOARD_ROOT_ID] = {
+      ...rootComponent,
+      children: [DASHBOARD_GRID_ID],
+    };
+
+    nextEntities[DASHBOARD_GRID_ID] = {
+      ...state[DASHBOARD_GRID_ID],
+      children: childrenToMove,
+    };
+
+    return nextEntities;
+  },
+};
+
+export default function layoutReducer(state = {}, action) {
+  if (action.type in actionHandlers) {
+    const handler = actionHandlers[action.type];
+    return handler(state, action);
+  }
+
+  return state;
+}
diff --git a/superset/assets/src/dashboard/reducers/dashboardState.js b/superset/assets/src/dashboard/reducers/dashboardState.js
new file mode 100644
index 0000000000..c7f2277b34
--- /dev/null
+++ b/superset/assets/src/dashboard/reducers/dashboardState.js
@@ -0,0 +1,153 @@
+/* eslint-disable camelcase */
+import {
+  ADD_SLICE,
+  ADD_FILTER,
+  ON_CHANGE,
+  ON_SAVE,
+  REMOVE_SLICE,
+  REMOVE_FILTER,
+  SET_EDIT_MODE,
+  SET_MAX_UNDO_HISTORY_EXCEEDED,
+  SET_UNSAVED_CHANGES,
+  TOGGLE_BUILDER_PANE,
+  TOGGLE_EXPAND_SLICE,
+  TOGGLE_FAVE_STAR,
+  UPDATE_CSS,
+} from '../actions/dashboardState';
+
+export default function dashboardStateReducer(state = {}, action) {
+  const actionHandlers = {
+    [UPDATE_CSS]() {
+      return { ...state, css: action.css };
+    },
+    [ADD_SLICE]() {
+      const updatedSliceIds = new Set(state.sliceIds);
+      updatedSliceIds.add(action.slice.slice_id);
+      return {
+        ...state,
+        sliceIds: Array.from(updatedSliceIds),
+      };
+    },
+    [REMOVE_SLICE]() {
+      const sliceId = action.sliceId;
+      const updatedSliceIds = new Set(state.sliceIds);
+      updatedSliceIds.delete(sliceId);
+
+      const key = sliceId;
+      // if this slice is a filter
+      const newFilter = { ...state.filters };
+      let refresh = false;
+      if (state.filters[key]) {
+        delete newFilter[key];
+        refresh = true;
+      }
+      return {
+        ...state,
+        sliceIds: Array.from(updatedSliceIds),
+        filters: newFilter,
+        refresh,
+      };
+    },
+    [TOGGLE_FAVE_STAR]() {
+      return { ...state, isStarred: action.isStarred };
+    },
+    [SET_EDIT_MODE]() {
+      return {
+        ...state,
+        editMode: action.editMode,
+        showBuilderPane: !!action.editMode,
+      };
+    },
+    [SET_MAX_UNDO_HISTORY_EXCEEDED]() {
+      const { maxUndoHistoryExceeded = true } = action.payload;
+      return { ...state, maxUndoHistoryExceeded };
+    },
+    [TOGGLE_BUILDER_PANE]() {
+      return { ...state, showBuilderPane: !state.showBuilderPane };
+    },
+    [TOGGLE_EXPAND_SLICE]() {
+      const updatedExpandedSlices = { ...state.expandedSlices };
+      const sliceId = action.sliceId;
+      if (updatedExpandedSlices[sliceId]) {
+        delete updatedExpandedSlices[sliceId];
+      } else {
+        updatedExpandedSlices[sliceId] = true;
+      }
+      return { ...state, expandedSlices: updatedExpandedSlices };
+    },
+    [ON_CHANGE]() {
+      return { ...state, hasUnsavedChanges: true };
+    },
+    [ON_SAVE]() {
+      return {
+        ...state,
+        hasUnsavedChanges: false,
+        maxUndoHistoryExceeded: false,
+      };
+    },
+
+    // filters
+    [ADD_FILTER]() {
+      const hasSelectedFilter = state.sliceIds.includes(action.chart.id);
+      if (!hasSelectedFilter) {
+        return state;
+      }
+
+      let filters = state.filters;
+      const { chart, col, vals, merge, refresh } = action;
+      const sliceId = chart.id;
+      const filterKeys = [
+        '__from',
+        '__to',
+        '__time_col',
+        '__time_grain',
+        '__time_origin',
+        '__granularity',
+      ];
+      if (
+        filterKeys.indexOf(col) >= 0 ||
+        action.chart.formData.groupby.indexOf(col) !== -1
+      ) {
+        let newFilter = {};
+        if (!(sliceId in filters)) {
+          // Straight up set the filters if none existed for the slice
+          newFilter = { [col]: vals };
+        } else if ((filters[sliceId] && !(col in filters[sliceId])) || !merge) {
+          newFilter = { ...filters[sliceId], [col]: vals };
+          // d3.merge pass in array of arrays while some value form filter components
+          // from and to filter box require string to be process and return
+        } else if (filters[sliceId][col] instanceof Array) {
+          newFilter[col] = [...filters[sliceId][col], ...vals];
+        } else {
+          newFilter[col] = [filters[sliceId][col], ...vals];
+        }
+        filters = { ...filters, [sliceId]: newFilter };
+      }
+      return { ...state, filters, refresh };
+    },
+    [REMOVE_FILTER]() {
+      const { sliceId, col, vals, refresh } = action;
+      const excluded = new Set(vals);
+
+      let filters = state.filters;
+      // Have to be careful not to modify the dashboard state so that
+      // the render actually triggers
+      if (sliceId in state.filters && col in state.filters[sliceId]) {
+        const newFilter = filters[sliceId][col].filter(
+          val => !excluded.has(val),
+        );
+        filters = { ...filters, [sliceId]: { [col]: newFilter } };
+      }
+      return { ...state, filters, refresh };
+    },
+    [SET_UNSAVED_CHANGES]() {
+      const { hasUnsavedChanges } = action.payload;
+      return { ...state, hasUnsavedChanges };
+    },
+  };
+
+  if (action.type in actionHandlers) {
+    return actionHandlers[action.type]();
+  }
+  return state;
+}
diff --git a/superset/assets/src/dashboard/reducers/datasources.js b/superset/assets/src/dashboard/reducers/datasources.js
new file mode 100644
index 0000000000..87f6d093e2
--- /dev/null
+++ b/superset/assets/src/dashboard/reducers/datasources.js
@@ -0,0 +1,20 @@
+import { SET_DATASOURCE } from '../actions/datasources';
+
+export default function datasourceReducer(datasources = {}, action) {
+  const actionHandlers = {
+    [SET_DATASOURCE]() {
+      return action.datasource;
+    },
+  };
+
+  if (action.type in actionHandlers) {
+    return {
+      ...datasources,
+      [action.key]: actionHandlers[action.type](
+        datasources[action.key],
+        action,
+      ),
+    };
+  }
+  return datasources;
+}
diff --git a/superset/assets/src/dashboard/reducers/getInitialState.js b/superset/assets/src/dashboard/reducers/getInitialState.js
new file mode 100644
index 0000000000..63a5411774
--- /dev/null
+++ b/superset/assets/src/dashboard/reducers/getInitialState.js
@@ -0,0 +1,143 @@
+/* eslint-disable camelcase */
+import shortid from 'shortid';
+
+import { chart } from '../../chart/chartReducer';
+import { initSliceEntities } from './sliceEntities';
+import { getParam } from '../../modules/utils';
+import { applyDefaultFormData } from '../../explore/stores/store';
+import { getColorFromScheme } from '../../modules/colors';
+import layoutConverter from '../util/dashboardLayoutConverter';
+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;
+  delete common.locale;
+  delete common.language_pack;
+
+  const dashboard = { ...bootstrapData.dashboard_data };
+  let filters = {};
+  try {
+    // allow request parameter overwrite dashboard metadata
+    filters = JSON.parse(
+      getParam('preselect_filters') || dashboard.metadata.default_filters,
+    );
+  } catch (e) {
+    //
+  }
+
+  // Priming the color palette with user's label-color mapping provided in
+  // the dashboard's JSON metadata
+  if (dashboard.metadata && dashboard.metadata.label_colors) {
+    const colorMap = dashboard.metadata.label_colors;
+    Object.keys(colorMap).forEach(label => {
+      getColorFromScheme(label, null, colorMap[label]);
+    });
+  }
+
+  // dashboard layout
+  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: [],
+  };
+
+  // create 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();
+  dashboard.slices.forEach(slice => {
+    const key = slice.slice_id;
+    if (['separator', 'markup'].indexOf(slice.form_data.viz_type) === -1) {
+      chartQueries[key] = {
+        ...chart,
+        id: key,
+        form_data: slice.form_data,
+        formData: applyDefaultFormData(slice.form_data),
+      };
+
+      slices[key] = {
+        slice_id: key,
+        slice_url: slice.slice_url,
+        slice_name: slice.slice_name,
+        form_data: slice.form_data,
+        edit_url: slice.edit_url,
+        viz_type: slice.form_data.viz_type,
+        datasource: slice.form_data.datasource,
+        description: slice.description,
+        description_markeddown: slice.description_markeddown,
+      };
+
+      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.sliceName = slice.slice_name;
+    }
+  });
+
+  return {
+    datasources,
+    sliceEntities: { ...initSliceEntities, slices, isLoading: false },
+    charts: chartQueries,
+    dashboardInfo: {
+      // read-only data
+      id: dashboard.id,
+      slug: dashboard.slug,
+      metadata: {
+        filter_immune_slice_fields:
+          dashboard.metadata.filter_immune_slice_fields,
+        filter_immune_slices: dashboard.metadata.filter_immune_slices,
+        timed_refresh_immune_slices:
+          dashboard.metadata.timed_refresh_immune_slices,
+      },
+      userId: user_id,
+      dash_edit_perm: dashboard.dash_edit_perm,
+      dash_save_perm: dashboard.dash_save_perm,
+      superset_can_explore: dashboard.superset_can_explore,
+      slice_can_edit: dashboard.slice_can_edit,
+      common,
+    },
+    dashboardState: {
+      sliceIds: Array.from(sliceIds),
+      refresh: false,
+      filters,
+      expandedSlices: dashboard.metadata.expanded_slices || {},
+      css: dashboard.css || '',
+      editMode: false,
+      showBuilderPane: false,
+      hasUnsavedChanges: false,
+      maxUndoHistoryExceeded: false,
+    },
+    dashboardLayout,
+    messageToasts: [],
+    impressionId: shortid.generate(),
+  };
+}
diff --git a/superset/assets/src/dashboard/reducers/index.js b/superset/assets/src/dashboard/reducers/index.js
new file mode 100644
index 0000000000..787cd5f0bf
--- /dev/null
+++ b/superset/assets/src/dashboard/reducers/index.js
@@ -0,0 +1,22 @@
+import { combineReducers } from 'redux';
+
+import charts from '../../chart/chartReducer';
+import dashboardState from './dashboardState';
+import datasources from './datasources';
+import sliceEntities from './sliceEntities';
+import dashboardLayout from '../reducers/undoableDashboardLayout';
+import messageToasts from '../reducers/messageToasts';
+
+const dashboardInfo = (state = {}) => state;
+const impressionId = (state = '') => state;
+
+export default combineReducers({
+  charts,
+  datasources,
+  dashboardInfo,
+  dashboardState,
+  dashboardLayout,
+  impressionId,
+  messageToasts,
+  sliceEntities,
+});
diff --git a/superset/assets/src/dashboard/reducers/messageToasts.js b/superset/assets/src/dashboard/reducers/messageToasts.js
new file mode 100644
index 0000000000..7383ab0386
--- /dev/null
+++ b/superset/assets/src/dashboard/reducers/messageToasts.js
@@ -0,0 +1,20 @@
+import { ADD_TOAST, REMOVE_TOAST } from '../actions/messageToasts';
+
+export default function messageToastsReducer(toasts = [], action) {
+  switch (action.type) {
+    case ADD_TOAST: {
+      const { payload: toast } = action;
+      return [toast, ...toasts];
+    }
+
+    case REMOVE_TOAST: {
+      const {
+        payload: { id },
+      } = action;
+      return [...toasts].filter(toast => toast.id !== id);
+    }
+
+    default:
+      return toasts;
+  }
+}
diff --git a/superset/assets/src/dashboard/reducers/sliceEntities.js b/superset/assets/src/dashboard/reducers/sliceEntities.js
new file mode 100644
index 0000000000..c5e46c26ab
--- /dev/null
+++ b/superset/assets/src/dashboard/reducers/sliceEntities.js
@@ -0,0 +1,54 @@
+import {
+  FETCH_ALL_SLICES_FAILED,
+  FETCH_ALL_SLICES_STARTED,
+  SET_ALL_SLICES,
+} from '../actions/sliceEntities';
+import { t } from '../../locales';
+
+export const initSliceEntities = {
+  slices: {},
+  isLoading: true,
+  errorMessage: null,
+  lastUpdated: 0,
+};
+
+export default function sliceEntitiesReducer(
+  state = initSliceEntities,
+  action,
+) {
+  const actionHandlers = {
+    [FETCH_ALL_SLICES_STARTED]() {
+      return {
+        ...state,
+        isLoading: true,
+      };
+    },
+    [SET_ALL_SLICES]() {
+      return {
+        ...state,
+        isLoading: false,
+        slices: { ...state.slices, ...action.slices }, // append more slices
+        lastUpdated: new Date().getTime(),
+      };
+    },
+    [FETCH_ALL_SLICES_FAILED]() {
+      const respJSON = action.error.responseJSON;
+      const errorMessage =
+        t('Sorry, there was an error fetching slices: ') +
+        (respJSON && respJSON.message)
+          ? respJSON.message
+          : action.error.responseText;
+      return {
+        ...state,
+        isLoading: false,
+        errorMessage,
+        lastUpdated: new Date().getTime(),
+      };
+    },
+  };
+
+  if (action.type in actionHandlers) {
+    return actionHandlers[action.type]();
+  }
+  return state;
+}
diff --git a/superset/assets/src/dashboard/reducers/undoableDashboardLayout.js b/superset/assets/src/dashboard/reducers/undoableDashboardLayout.js
new file mode 100644
index 0000000000..45e36ee647
--- /dev/null
+++ b/superset/assets/src/dashboard/reducers/undoableDashboardLayout.js
@@ -0,0 +1,30 @@
+import undoable, { includeAction } from 'redux-undo';
+import { UNDO_LIMIT } from '../util/constants';
+import {
+  UPDATE_COMPONENTS,
+  DELETE_COMPONENT,
+  CREATE_COMPONENT,
+  CREATE_TOP_LEVEL_TABS,
+  DELETE_TOP_LEVEL_TABS,
+  RESIZE_COMPONENT,
+  MOVE_COMPONENT,
+  HANDLE_COMPONENT_DROP,
+} from '../actions/dashboardLayout';
+
+import dashboardLayout from './dashboardLayout';
+
+export default undoable(dashboardLayout, {
+  // +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,
+    CREATE_COMPONENT,
+    CREATE_TOP_LEVEL_TABS,
+    DELETE_TOP_LEVEL_TABS,
+    RESIZE_COMPONENT,
+    MOVE_COMPONENT,
+    HANDLE_COMPONENT_DROP,
+  ]),
+});
diff --git a/superset/assets/src/dashboard/stylesheets/builder-sidepane.less b/superset/assets/src/dashboard/stylesheets/builder-sidepane.less
new file mode 100644
index 0000000000..5f87d0c10f
--- /dev/null
+++ b/superset/assets/src/dashboard/stylesheets/builder-sidepane.less
@@ -0,0 +1,143 @@
+.dashboard-builder-sidepane {
+  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: 16px;
+    display: flex;
+    align-items: center;
+  }
+
+  .trigger {
+    font-size: 16px;
+    color: @almost-black;
+    opacity: 1;
+    margin-left: auto;
+    cursor: pointer;
+  }
+
+  .slices-layer .trigger {
+    margin-left: 0;
+    margin-right: 20px;
+  }
+
+  .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;
+    }
+
+    &.slide-out {
+      left: 0;
+    }
+
+    .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;
+    }
+  }
+
+  .new-component-label {
+    flex-grow: 1;
+  }
+
+  .chart-card-container {
+    padding: 16px;
+
+    .chart-card {
+      border: 1px solid @gray-light;
+      height: 120px;
+      padding: 16px;
+      cursor: move;
+    }
+
+    .chart-card.is-selected {
+      opacity: 0.45;
+      cursor: not-allowed;
+    }
+
+    .card-title {
+      margin-bottom: 8px;
+      font-weight: bold;
+    }
+
+    .card-body {
+      display: flex;
+      flex-direction: column;
+
+      .item {
+        height: 18px;
+      }
+
+      label {
+        margin-right: 5px;
+      }
+    }
+  }
+
+  .slice-adder-container {
+    .controls {
+      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 {
+        width: 100%;
+
+        &:focus {
+          outline: none;
+          border-color: @gray;
+        }
+      }
+    }
+
+    .ReactVirtualized__Grid.ReactVirtualized__List:focus {
+      outline: none;
+    }
+  }
+}
diff --git a/superset/assets/src/dashboard/stylesheets/builder.less b/superset/assets/src/dashboard/stylesheets/builder.less
new file mode 100644
index 0000000000..ecf192ec1e
--- /dev/null
+++ b/superset/assets/src/dashboard/stylesheets/builder.less
@@ -0,0 +1,63 @@
+.dashboard {
+  position: relative;
+  color: @almost-black;
+  margin-top: -20px;
+}
+
+.dashboard-header {
+  background: white;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 24px;
+  box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.1); /* @TODO color */
+}
+
+.dashboard-content {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: nowrap;
+  height: auto;
+}
+
+/* only top-level tabs have popover, give it more padding to match header + tabs */
+.dashboard > .with-popover-menu > .popover-menu {
+  left: 24px;
+}
+
+/* drop shadow for top-level tabs only */
+.dashboard .dashboard-component-tabs {
+  box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.1);
+  padding-left: 8px; /* note this is added to tab-level padding, to match header */
+}
+
+.dashboard-content .grid-container .dashboard-component-tabs {
+  box-shadow: none;
+  padding-left: 0;
+}
+
+.dashboard-content > div:first-child {
+  width: 100%;
+  flex-grow: 1;
+  position: relative;
+}
+
+/* @TODO remove upon new theme */
+.btn.btn-primary {
+  background: @almost-black !important;
+  border-color: @almost-black;
+  color: white !important;
+}
+
+.dropdown-toggle.btn.btn-primary .caret {
+  color: white;
+}
+
+.background--transparent {
+  background-color: transparent;
+}
+
+.background--white {
+  background-color: white;
+}
diff --git a/superset/assets/src/dashboard/stylesheets/buttons.less b/superset/assets/src/dashboard/stylesheets/buttons.less
new file mode 100644
index 0000000000..41ca478978
--- /dev/null
+++ b/superset/assets/src/dashboard/stylesheets/buttons.less
@@ -0,0 +1,23 @@
+.icon-button {
+  color: @gray;
+  font-size: 1.2em;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: center;
+  outline: none;
+}
+
+.icon-button:hover,
+.icon-button:active,
+.icon-button:focus {
+  color: @almost-black;
+  outline: none;
+  text-decoration: none;
+}
+
+.icon-button-label {
+  color: @gray-dark;
+  padding-left: 8px;
+  font-size: 0.9em;
+}
diff --git a/superset/assets/src/dashboard/stylesheets/components/chart.less b/superset/assets/src/dashboard/stylesheets/components/chart.less
new file mode 100644
index 0000000000..73914fba52
--- /dev/null
+++ b/superset/assets/src/dashboard/stylesheets/components/chart.less
@@ -0,0 +1,64 @@
+.dashboard-component-chart-holder {
+  width: 100%;
+  height: 100%;
+  color: @gray-dark;
+  background-color: white;
+  position: relative;
+  padding: 16px;
+}
+
+.dashboard-chart {
+  overflow: hidden;
+}
+
+.dashboard-chart.dashboard-chart--overflowable {
+  overflow: visible;
+}
+
+.dashboard--editing .dashboard-component-chart-holder:after {
+  content: '';
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 0px;
+  left: 0px;
+  z-index: 1;
+  pointer-events: none;
+  border: 1px solid transparent;
+}
+
+.dashboard--editing
+  .resizable-container:hover
+  > .dashboard-component-chart-holder:after,
+.dashboard--editing .dashboard-component-chart-holder:hover:after {
+  border: 1px solid @gray-light;
+}
+
+.dashboard--editing
+  .resizable-container.resizable-container--resizing:hover
+  > .dashboard-component-chart-holder:after {
+  border: 1px solid @indicator-color;
+}
+
+.dashboard--editing
+  .dashboard-component-chart-holder
+  .dashboard-chart
+  .chart-container {
+  cursor: move;
+  opacity: 0.2;
+}
+
+.dashboard--editing
+  .dashboard-component-chart-holder:hover
+  .dashboard-chart
+  .chart-container {
+  opacity: 0.7;
+}
+
+.dashboard--editing
+  .dashboard-component-chart-holder
+  .dashboard-chart
+  .slice_container {
+  /* disable chart interactions in edit mode */
+  pointer-events: none;
+}
diff --git a/superset/assets/src/dashboard/stylesheets/components/column.less b/superset/assets/src/dashboard/stylesheets/components/column.less
new file mode 100644
index 0000000000..2f26d95441
--- /dev/null
+++ b/superset/assets/src/dashboard/stylesheets/components/column.less
@@ -0,0 +1,46 @@
+.grid-column {
+  width: 100%;
+  position: relative;
+}
+
+/* gutters between elements in a column */
+.grid-column > :not(:only-child):not(.hover-menu):not(:last-child) {
+  margin-bottom: 16px;
+}
+
+.dashboard--editing .grid-column:after {
+  border: 1px solid transparent;
+  content: '';
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  z-index: 1;
+  pointer-events: none;
+}
+
+.dashboard--editing
+  .resizable-container.resizable-container--resizing:hover
+  > .grid-column: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--empty {
+  min-height: 72px;
+}
+
+.grid-column--empty:before {
+  content: 'Empty column';
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: @gray-light;
+}
diff --git a/superset/assets/src/dashboard/stylesheets/components/divider.less b/superset/assets/src/dashboard/stylesheets/components/divider.less
new file mode 100644
index 0000000000..e4625d3495
--- /dev/null
+++ b/superset/assets/src/dashboard/stylesheets/components/divider.less
@@ -0,0 +1,24 @@
+.dashboard-component-divider {
+  width: 100%;
+  padding: 8px 0; /* this is padding not margin to enable a larger mouse target */
+  background-color: transparent;
+}
+
+.dashboard-component-divider:after {
+  content: "";
+  height: 1px;
+  width: 100%;
+  background-color: @gray-light;
+  display: block;
+}
+
+.new-component-placeholder.divider-placeholder:after {
+  content: "";
+  height: 2px;
+  width: 100%;
+  background-color: @gray-light;
+}
+
+.dragdroppable .dashboard-component-divider {
+  cursor: move;
+}
diff --git a/superset/assets/src/dashboard/stylesheets/components/header.less b/superset/assets/src/dashboard/stylesheets/components/header.less
new file mode 100644
index 0000000000..940310336c
--- /dev/null
+++ b/superset/assets/src/dashboard/stylesheets/components/header.less
@@ -0,0 +1,55 @@
+.dashboard-component-header {
+  width: 100%;
+  line-height: 1.1;
+  font-weight: 700;
+  padding: 16px 0;
+  color: @almost-black;
+}
+
+.dashboard-header .dashboard-component-header {
+  font-weight: 300;
+  width: auto;
+}
+
+.dashboard-header .btn-group button {
+  margin-right: 8px;
+}
+
+.dashboard-header .undo-action,
+.dashboard-header .redo-action {
+  line-height: 18px;
+  font-size: 12px;
+}
+
+.dashboard--editing .dragdroppable-row .dashboard-component-header {
+  cursor: move;
+}
+
+/* note: sizes should be a multiple of the 8px grid unit so that rows in the grid align */
+.header-small {
+  font-size: 16px;
+}
+
+.header-medium {
+  font-size: 24px;
+}
+
+.header-large {
+  font-size: 32px;
+}
+
+.background--white .dashboard-component-header,
+.dashboard-component-header.background--white,
+.dashboard-component-tabs .dashboard-component-header,
+.dashboard-component-tabs .dashboard-component-divider {
+  padding-left: 16px;
+  padding-right: 16px;
+}
+
+/*
+ * grids add margin between items, so don't double pad within columns
+ * we'll not worry about double padding on top as it can serve as a visual separator
+ */
+.grid-column > :not(:only-child):not(:last-child) .dashboard-component-header {
+  margin-bottom: -16px;
+}
diff --git a/superset/assets/src/dashboard/stylesheets/components/index.less b/superset/assets/src/dashboard/stylesheets/components/index.less
new file mode 100644
index 0000000000..5f8d610100
--- /dev/null
+++ b/superset/assets/src/dashboard/stylesheets/components/index.less
@@ -0,0 +1,8 @@
+@import './chart.less';
+@import './column.less';
+@import './divider.less';
+@import './header.less';
+@import './new-component.less';
+@import './row.less';
+@import './tabs.less';
+@import './markdown.less';
\ No newline at end of file
diff --git a/superset/assets/src/dashboard/stylesheets/components/markdown.less b/superset/assets/src/dashboard/stylesheets/components/markdown.less
new file mode 100644
index 0000000000..d377c68b61
--- /dev/null
+++ b/superset/assets/src/dashboard/stylesheets/components/markdown.less
@@ -0,0 +1,11 @@
+.dashboard-markdown {
+  overflow: hidden;
+
+  .dashboard--editing & {
+    cursor: move;
+  }
+
+  #brace-editor {
+    border: none;
+  }
+}
\ No newline at end of file
diff --git a/superset/assets/src/dashboard/stylesheets/components/new-component.less b/superset/assets/src/dashboard/stylesheets/components/new-component.less
new file mode 100644
index 0000000000..decb1ad093
--- /dev/null
+++ b/superset/assets/src/dashboard/stylesheets/components/new-component.less
@@ -0,0 +1,27 @@
+.new-component {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: nowrap;
+  align-items: center;
+  padding: 16px;
+  background: white;
+  cursor: move;
+}
+
+.new-component-placeholder {
+  position: relative;
+  background: @gray-bg;
+  width: 40px;
+  height: 40px;
+  margin-right: 16px;
+  box-shadow: 0 0 1px white;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: @gray;
+  font-size: 1.5em;
+}
+
+.new-component-placeholder.fa-window-restore {
+  font-size: 1em;
+}
diff --git a/superset/assets/src/dashboard/stylesheets/components/row.less b/superset/assets/src/dashboard/stylesheets/components/row.less
new file mode 100644
index 0000000000..382417eb00
--- /dev/null
+++ b/superset/assets/src/dashboard/stylesheets/components/row.less
@@ -0,0 +1,55 @@
+.grid-row {
+  position: relative;
+  display: flex;
+  flex-direction: row;
+  flex-wrap: nowrap;
+  align-items: flex-start;
+  width: 100%;
+  height: fit-content;
+}
+
+/* gutters between elements in a row */
+.grid-row > :not(:only-child):not(:last-child):not(.hover-menu) {
+  margin-right: 16px;
+}
+
+/* hover indicator */
+.dashboard--editing .grid-row:after,
+.dashboard--editing .dashboard-component-tabs > .hover-menu:hover + div:after {
+  border: 1px dashed transparent;
+  content: '';
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  z-index: 1;
+  pointer-events: none;
+}
+
+.dashboard--editing
+  .resizable-container.resizable-container--resizing:hover
+  > .grid-row: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);
+}
+
+.grid-row.grid-row--empty {
+  align-items: center; /* this centers the empty note content */
+  height: 80px;
+}
+
+.grid-row--empty:before {
+  position: absolute;
+  top: 0;
+  left: 0;
+  content: 'Empty row';
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  height: 100%;
+  color: @gray;
+}
diff --git a/superset/assets/src/dashboard/stylesheets/components/tabs.less b/superset/assets/src/dashboard/stylesheets/components/tabs.less
new file mode 100644
index 0000000000..02039b49b1
--- /dev/null
+++ b/superset/assets/src/dashboard/stylesheets/components/tabs.less
@@ -0,0 +1,85 @@
+.dashboard-component-tabs {
+  width: 100%;
+  background-color: white;
+}
+
+.dashboard-component-tabs .dashboard-component-tabs-content {
+  min-height: 48px;
+  margin-top: 1px;
+}
+
+.dashboard-component-tabs .nav-tabs {
+  border-bottom: none;
+}
+
+/* by moving padding from <a/> to <li/> we can restrict the selected tab indicator to text width */
+.dashboard-component-tabs .nav-tabs > li {
+  margin: 0 16px;
+}
+
+.dashboard-component-tabs .nav-tabs > li > a {
+  color: @almost-black;
+  border: none;
+  padding: 12px 0 14px 0;
+  font-size: 15px;
+  margin-right: 0;
+}
+
+.dashboard-component-tabs .nav-tabs > li.active > a {
+  border: none;
+}
+
+.dashboard-component-tabs .nav-tabs > li.active > a:after {
+  content: '';
+  position: absolute;
+  height: 3px;
+  width: 100%;
+  bottom: 0;
+  background: linear-gradient(to right, #e32464, #2c2261);
+}
+
+.dashboard-component-tabs .nav-tabs > li > a:hover {
+  border: none;
+  background: inherit;
+  color: @almost-black;
+}
+
+.dashboard-component-tabs .nav-tabs > li > a:focus {
+  outline: none;
+  background: #fff;
+}
+
+.dashboard-component-tabs .nav-tabs > li .dragdroppable-tab {
+  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;
+}
+
+.dashboard-component-tabs .nav-tabs > li .drop-indicator--left {
+  left: -12px !important;
+}
+
+.dashboard-component-tabs .nav-tabs > li .drop-indicator--right {
+  right: -12px !important;
+}
+
+.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 */
+  opacity: 0.4;
+}
+
+.dashboard-component-tabs li .fa-plus {
+  color: @gray-dark;
+  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
new file mode 100644
index 0000000000..57567860db
--- /dev/null
+++ b/superset/assets/src/dashboard/stylesheets/dashboard.less
@@ -0,0 +1,174 @@
+.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 {
+    top: 20px;
+  }
+
+  .divider {
+    margin: 5px 0;
+  }
+
+  .fa-circle {
+    position: absolute;
+    left: 7px;
+    top: 18px;
+    font-size: 4px;
+    color: @pink;
+  }
+
+  .refresh-tooltip {
+    display: block;
+    height: 16px;
+    margin: 3px 0;
+    color: @gray;
+  }
+}
+
+.dashboard .dashboard-header {
+  #save-dash-split-button {
+    border-radius: 0;
+    margin-left: -8px;
+    height: 30px;
+    width: 30px;
+
+    &.btn.btn-primary {
+      border-left-color: white;
+    }
+
+    .caret {
+      position: absolute;
+      top: 24px;
+      left: 3px;
+    }
+
+    & + .dropdown-menu.dropdown-menu-right {
+      min-width: unset;
+    }
+  }
+}
+
+.dashboard .chart-header,
+.dashboard .dashboard-header {
+  .dropdown-menu {
+    padding: 9px 0;
+  }
+
+  .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: -16px; //increase the click-able area for the button
+
+  &: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;
+  }
+
+  .vertical-dots-container & {
+    display: block;
+  }
+
+  a[role="menuitem"] & {
+    width: 8px;
+    height: 8px;
+    margin-right: 8px;
+  }
+}
+
+
+.modal img.loading {
+  width: 50px;
+  margin: 0;
+  position: relative;
+}
+
+.react-bs-container-body {
+  max-height: 400px;
+  overflow-y: auto;
+}
+
+.hidden,
+#pageDropDown {
+  display: none;
+}
+
+.separator .chart-container {
+  position: absolute;
+  left: 0;
+  right: 0;
+  top: 0;
+  bottom: 0;
+}
+
+.dashboard .title {
+  margin: 0 20px;
+}
+
+.dashboard .title .favstar {
+  font-size: 20px;
+  line-height: 1em;
+  position: relative;
+  top: -5px;
+}
+
+.ace_gutter {
+  z-index: 0;
+}
+.ace_content {
+  z-index: 0;
+}
+.ace_scrollbar {
+  z-index: 0;
+}
+.slice_container .alert {
+  margin: 10px;
+}
+
+i.danger {
+  color: red;
+}
+
+i.warning {
+  color: orange;
+}
diff --git a/superset/assets/src/dashboard/stylesheets/dnd.less b/superset/assets/src/dashboard/stylesheets/dnd.less
new file mode 100644
index 0000000000..0a10c61c22
--- /dev/null
+++ b/superset/assets/src/dashboard/stylesheets/dnd.less
@@ -0,0 +1,78 @@
+.dragdroppable {
+  position: relative;
+}
+
+.dragdroppable--dragging {
+  opacity: 0.15;
+}
+
+.dragdroppable-row {
+  width: 100%;
+}
+
+/* drop indicators */
+.drop-indicator {
+  display: block;
+  background-color: @indicator-color;
+  position: absolute;
+  z-index: 10;
+}
+
+.drop-indicator--top {
+  top: 0;
+  left: 0;
+  height: 4px;
+  width: 100%;
+  min-width: 16px;
+}
+
+.drop-indicator--bottom {
+  top: 100%;
+  left: 0;
+  height: 4px;
+  width: 100%;
+  min-width: 16px;
+}
+
+.drop-indicator--right {
+  top: 0;
+  left: 100%;
+  height: 100%;
+  width: 4px;
+  min-height: 16px;
+}
+
+.drop-indicator--left {
+  top: 0;
+  left: 0;
+  height: 100%;
+  width: 4px;
+  min-height: 16px;
+}
+
+/* drag handles */
+.drag-handle {
+  overflow: hidden;
+  width: 16px;
+  cursor: move;
+}
+
+.drag-handle--left {
+  width: 8px;
+}
+
+.drag-handle-dot {
+  float: left;
+  height: 2px;
+  margin: 1px;
+  width: 2px;
+}
+
+.drag-handle-dot:after {
+  content: '';
+  background: #aaa;
+  float: left;
+  height: 2px;
+  margin: -1px;
+  width: 2px;
+}
diff --git a/superset/assets/src/dashboard/stylesheets/grid.less b/superset/assets/src/dashboard/stylesheets/grid.less
new file mode 100644
index 0000000000..9d09ac7017
--- /dev/null
+++ b/superset/assets/src/dashboard/stylesheets/grid.less
@@ -0,0 +1,55 @@
+.grid-container {
+  min-height: 100%;
+  position: relative;
+  margin: 24px;
+  /* without this, the grid will not get smaller upon toggling the builder panel on */
+  min-width: 0;
+  width: 100%;
+}
+
+/* this is the ParentSize wrapper  */
+.grid-container > div:first-child {
+  height: inherit !important;
+}
+
+.grid-content {
+  min-height: 100%;
+  display: flex;
+  flex-direction: column;
+  margin-bottom: 100px;
+}
+
+/* gutters between rows */
+.grid-content
+  > div:not(:only-child):not(:last-child):not(.empty-grid-droptarget--bottom):not(.empty-grid-droptarget--top) {
+  margin-bottom: 16px;
+}
+
+.grid-content > .empty-grid-droptarget--top {
+  height: 24px;
+  margin-top: -24px;
+}
+.empty-grid-droptarget--bottom {
+  width: 100%;
+  height: 100%;
+}
+
+/* Editing guides */
+.grid-column-guide {
+  position: absolute;
+  top: 0;
+  min-height: 100%;
+  background-color: rgba(68, 192, 255, 0.05);
+  pointer-events: none;
+  box-shadow: inset 0 0 0 1px rgba(68, 192, 255, 0.5);
+}
+
+.grid-row-guide {
+  position: absolute;
+  left: 0;
+  bottom: 2;
+  height: 2;
+  background-color: @indicator-color;
+  pointer-events: none;
+  z-index: 10;
+}
diff --git a/superset/assets/src/dashboard/stylesheets/hover-menu.less b/superset/assets/src/dashboard/stylesheets/hover-menu.less
new file mode 100644
index 0000000000..4f624015ef
--- /dev/null
+++ b/superset/assets/src/dashboard/stylesheets/hover-menu.less
@@ -0,0 +1,77 @@
+.hover-menu {
+  opacity: 0;
+  position: absolute;
+  z-index: 10;
+  font-size: 14px;
+}
+
+.hover-menu--left {
+  width: 24px;
+  top: 50%;
+  transform: translate(0, -50%);
+  left: -24px;
+  padding: 8px 0;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+}
+
+.hover-menu--left > :nth-child(n):not(:only-child):not(:last-child) {
+  margin-bottom: 12px;
+}
+
+.hover-menu--top {
+  height: 24px;
+  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;
+}
+
+div:hover > .hover-menu,
+.hover-menu:hover {
+  opacity: 1;
+}
diff --git a/superset/assets/src/dashboard/stylesheets/index.less b/superset/assets/src/dashboard/stylesheets/index.less
new file mode 100644
index 0000000000..b69c7b0530
--- /dev/null
+++ b/superset/assets/src/dashboard/stylesheets/index.less
@@ -0,0 +1,13 @@
+@import './variables.less';
+
+@import './builder.less';
+@import './builder-sidepane.less';
+@import './buttons.less';
+@import './dashboard.less';
+@import './dnd.less';
+@import './grid.less';
+@import './hover-menu.less';
+@import './popover-menu.less';
+@import './resizable.less';
+@import './components/index.less';
+@import './toast.less';
diff --git a/superset/assets/src/dashboard/stylesheets/popover-menu.less b/superset/assets/src/dashboard/stylesheets/popover-menu.less
new file mode 100644
index 0000000000..d69006c788
--- /dev/null
+++ b/superset/assets/src/dashboard/stylesheets/popover-menu.less
@@ -0,0 +1,134 @@
+.with-popover-menu {
+  position: relative;
+  outline: none;
+}
+
+.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: '';
+  position: absolute;
+  top: 1;
+  left: -1;
+  width: 100%;
+  height: 100%;
+  box-shadow: inset 0 0 0 2px @indicator-color;
+  pointer-events: none;
+  z-index: 9;
+}
+
+.popover-menu {
+  position: absolute;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  flex-wrap: nowrap;
+  left: 1px;
+  top: -42px;
+  height: 40px;
+  padding: 0 16px;
+  background: white;
+  box-shadow: 0 1px 2px 1px rgba(0, 0, 0, 0.2);
+  font-size: 14px;
+  cursor: default;
+  z-index: 1000;
+}
+
+/* the focus menu doesn't account for parent padding */
+.dashboard-component-tabs li .with-popover-menu--focused:after {
+  top: -12px;
+  left: -8px;
+  width: ~'calc(100% + 16px)'; /* escape for .less */
+  height: ~'calc(100% + 28px)';
+}
+
+.dashboard-component-tabs li .popover-menu {
+  top: -56px;
+}
+
+.popover-menu .menu-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+
+/* vertical spacer after each menu item */
+.popover-menu .menu-item:not(:only-child):not(:last-child):after {
+  content: '';
+  width: 1;
+  height: 100%;
+  background: @gray-light;
+  margin: 0 16px;
+}
+
+.popover-menu .popover-dropdown.btn {
+  border: none;
+  padding: 0;
+  font-size: inherit;
+  color: @almost-black;
+}
+
+.popover-menu .popover-dropdown.btn:hover,
+.popover-menu .popover-dropdown.btn:active,
+.popover-menu .popover-dropdown.btn:focus,
+.hover-dropdown .btn:hover,
+.hover-dropdown .btn:active,
+.hover-dropdown .btn:focus {
+  background: initial;
+  box-shadow: none;
+}
+
+.hover-dropdown li.dropdown-item:hover a,
+.popover-menu li.dropdown-item:hover a {
+  background: @gray-light;
+}
+
+.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;
+  font-weight: bold;
+  color: @almost-black;
+}
+
+/* background style menu */
+.background-style-option {
+  display: inline-block;
+}
+
+.background-style-option:before {
+  content: '';
+  width: 1em;
+  height: 1em;
+  margin-right: 8px;
+  display: inline-block;
+  vertical-align: middle;
+}
+
+.background-style-option.background--white {
+  padding-left: 0;
+  background: transparent;
+}
+
+.background-style-option.background--white:before {
+  background: white;
+  border: 1px solid @gray-light;
+}
+
+.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-size: 8px 8px;
+  background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
+}
diff --git a/superset/assets/src/dashboard/stylesheets/resizable.less b/superset/assets/src/dashboard/stylesheets/resizable.less
new file mode 100644
index 0000000000..973daaba5a
--- /dev/null
+++ b/superset/assets/src/dashboard/stylesheets/resizable.less
@@ -0,0 +1,86 @@
+.resizable-container {
+  background-color: transparent;
+  position: relative;
+}
+
+/* after ensures border visibility on top of any children */
+.resizable-container--resizing:after {
+  content: "";
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  box-shadow: inset 0 0 0 2px @indicator-color;
+}
+
+.resize-handle {
+  opacity: 0;
+  z-index: 10;
+}
+
+  .resizable-container:hover .resize-handle,
+  .resizable-container--resizing .resize-handle {
+    opacity: 1;
+  }
+
+.resize-handle--bottom-right {
+  position: absolute;
+  border: solid;
+  border-width: 0 1.5px 1.5px 0;
+  border-right-color: @gray;
+  border-bottom-color: @gray;
+  right: 16px;
+  bottom: 16px;
+  width: 8px;
+  height: 8px;
+}
+
+
+.resize-handle--right {
+  width: 2px;
+  height: 20px;
+  right: 4px;
+  top: 50%;
+  transform: translate(0, -50%);
+  position: absolute;
+  border-left: 1px solid @gray;
+  border-right: 1px solid @gray;
+}
+
+.dragdroppable-column .resizable-container-handle--right {
+  /* override the default because the inner column's handle's mouse target is very small */
+  right: -10px !important;
+}
+
+.dragdroppable-column .dragdroppable-column .resizable-container-handle--right {
+  /* override the default because the inner column's handle's mouse target is very small */
+  right: 0px !important;
+}
+
+.resize-handle--bottom {
+  height: 2px;
+  width: 20px;
+  bottom: 4px;
+  left: 50%;
+  transform: translate(-50%);
+  position: absolute;
+  border-top: 1px solid @gray;
+  border-bottom: 1px solid @gray;
+}
+
+.resizable-container-handle--bottom {
+  bottom: 0 !important;
+}
+
+.resizable-container--resizing > span .resize-handle {
+  border-color: @indicator-color;
+}
+
+/* re-resizable sets an empty div to 100% width and height, which doesn't
+  play well with many 100% height containers we need
+ */
+.resizable-container ~ div {
+  width: auto !important;
+  height: auto !important;
+}
diff --git a/superset/assets/src/dashboard/stylesheets/toast.less b/superset/assets/src/dashboard/stylesheets/toast.less
new file mode 100644
index 0000000000..1d1ebc53d4
--- /dev/null
+++ b/superset/assets/src/dashboard/stylesheets/toast.less
@@ -0,0 +1,58 @@
+.toast-presenter {
+  position: fixed;
+  bottom: 16px;
+  left: 50%;
+  transform: translate(-50%, 0);
+  width: 500px;
+  z-index: 3000; // top of the world
+}
+
+.toast {
+  background: white;
+  color: @almost-black;
+  opacity: 0;
+  position: relative;
+  white-space: pre-line;
+  box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.35);
+  will-change: transform, opacity;
+  transform: translateY(-100%);
+  transition: transform .3s, opacity .3s;
+}
+
+.toast > button {
+  color: @almost-black;
+}
+
+.toast > button:hover {
+  color: @gray-dark;
+}
+
+.toast--visible {
+  transform: translateY(0);
+  opacity: 1;
+}
+
+.toast:after {
+  content: "";
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 6px;
+  height: 100%;
+}
+
+.toast--info:after {
+  background: linear-gradient(to bottom, @pink, @purple);
+}
+
+.toast--success:after {
+  background: @success;
+}
+
+.toast--warning:after {
+  background: @warning;
+}
+
+.toast--danger:after {
+  background: @danger;
+}
diff --git a/superset/assets/src/dashboard/stylesheets/variables.less b/superset/assets/src/dashboard/stylesheets/variables.less
new file mode 100644
index 0000000000..8f53f99a8a
--- /dev/null
+++ b/superset/assets/src/dashboard/stylesheets/variables.less
@@ -0,0 +1,19 @@
+@indicator-color: #44C0FF;
+
+@almost-black: #263238;
+@gray-dark: #484848;
+@gray: #879399;
+@gray-light: #CFD8DC;
+@gray-bg: #f5f5f5;
+@menu-hover: #F2F3F5;
+
+/* builder component pane */
+@builder-pane-width: 374px;
+
+/* toasts */
+@pink: #E32364;
+@purple: #2C2261;
+
+@success: #00BFA5;
+@warning: #FFAB00;
+@danger: @pink;
diff --git a/superset/assets/src/dashboard/util/backgroundStyleOptions.js b/superset/assets/src/dashboard/util/backgroundStyleOptions.js
new file mode 100644
index 0000000000..926e7f1407
--- /dev/null
+++ b/superset/assets/src/dashboard/util/backgroundStyleOptions.js
@@ -0,0 +1,15 @@
+import { t } from '../../locales';
+import { BACKGROUND_TRANSPARENT, BACKGROUND_WHITE } from './constants';
+
+export default [
+  {
+    value: BACKGROUND_TRANSPARENT,
+    label: t('Transparent'),
+    className: 'background--transparent',
+  },
+  {
+    value: BACKGROUND_WHITE,
+    label: t('White'),
+    className: 'background--white',
+  },
+];
diff --git a/superset/assets/src/dashboard/util/charts/getEffectiveExtraFilters.js b/superset/assets/src/dashboard/util/charts/getEffectiveExtraFilters.js
new file mode 100644
index 0000000000..7b8b5ce9ae
--- /dev/null
+++ b/superset/assets/src/dashboard/util/charts/getEffectiveExtraFilters.js
@@ -0,0 +1,42 @@
+export default function getEffectiveExtraFilters({
+  dashboardMetadata,
+  filters,
+  sliceId,
+}) {
+  const immuneSlices = dashboardMetadata.filter_immune_slices || [];
+
+  if (sliceId && immuneSlices.includes(sliceId)) {
+    // The slice is immune to dashboard filters
+    return [];
+  }
+
+  // Build a list of fields the slice is immune to filters on
+  const effectiveFilters = [];
+  let immuneToFields = [];
+  if (
+    sliceId &&
+    dashboardMetadata.filter_immune_slice_fields &&
+    dashboardMetadata.filter_immune_slice_fields[sliceId]
+  ) {
+    immuneToFields = dashboardMetadata.filter_immune_slice_fields[sliceId];
+  }
+
+  Object.keys(filters).forEach(filteringSliceId => {
+    if (filteringSliceId === sliceId.toString()) {
+      // Filters applied by the slice don't apply to itself
+      return;
+    }
+    const filtersFromSlice = filters[filteringSliceId];
+    Object.keys(filtersFromSlice).forEach(field => {
+      if (!immuneToFields.includes(field)) {
+        effectiveFilters.push({
+          col: field,
+          op: 'in',
+          val: filtersFromSlice[field],
+        });
+      }
+    });
+  });
+
+  return effectiveFilters;
+}
diff --git a/superset/assets/src/dashboard/util/charts/getFormDataWithExtraFilters.js b/superset/assets/src/dashboard/util/charts/getFormDataWithExtraFilters.js
new file mode 100644
index 0000000000..d107d05ca5
--- /dev/null
+++ b/superset/assets/src/dashboard/util/charts/getFormDataWithExtraFilters.js
@@ -0,0 +1,40 @@
+import getEffectiveExtraFilters from './getEffectiveExtraFilters';
+
+// We cache formData objects so that our connected container components don't always trigger
+// render cascades. we cannot leverage the reselect library because our cache size is >1
+const cachedDashboardMetadataByChart = {};
+const cachedFiltersByChart = {};
+const cachedFormdataByChart = {};
+
+export default function getFormDataWithExtraFilters({
+  chart,
+  dashboardMetadata,
+  filters,
+  sliceId,
+}) {
+  // if dashboard metadata + filters have not changed, use cache if possible
+  if (
+    (cachedDashboardMetadataByChart[sliceId] || {}) === dashboardMetadata &&
+    (cachedFiltersByChart[sliceId] || {}) === filters &&
+    !!cachedFormdataByChart[sliceId]
+  ) {
+    return cachedFormdataByChart[sliceId];
+  }
+
+  const extraFilters = getEffectiveExtraFilters({
+    dashboardMetadata,
+    filters,
+    sliceId,
+  });
+
+  const formData = {
+    ...chart.formData,
+    extra_filters: [...chart.formData.filters, ...extraFilters],
+  };
+
+  cachedDashboardMetadataByChart[sliceId] = dashboardMetadata;
+  cachedFiltersByChart[sliceId] = filters;
+  cachedFormdataByChart[sliceId] = formData;
+
+  return formData;
+}
diff --git a/superset/assets/src/dashboard/util/componentIsResizable.js b/superset/assets/src/dashboard/util/componentIsResizable.js
new file mode 100644
index 0000000000..45812d762b
--- /dev/null
+++ b/superset/assets/src/dashboard/util/componentIsResizable.js
@@ -0,0 +1,5 @@
+import { COLUMN_TYPE, CHART_TYPE, MARKDOWN_TYPE } from './componentTypes';
+
+export default function componentIsResizable(entity) {
+  return [COLUMN_TYPE, CHART_TYPE, MARKDOWN_TYPE].indexOf(entity.type) > -1;
+}
diff --git a/superset/assets/src/dashboard/util/componentTypes.js b/superset/assets/src/dashboard/util/componentTypes.js
new file mode 100644
index 0000000000..b773417983
--- /dev/null
+++ b/superset/assets/src/dashboard/util/componentTypes.js
@@ -0,0 +1,27 @@
+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_ROOT_TYPE = 'DASHBOARD_ROOT_TYPE';
+export const DIVIDER_TYPE = 'DASHBOARD_DIVIDER_TYPE';
+export const HEADER_TYPE = 'DASHBOARD_HEADER_TYPE';
+export const MARKDOWN_TYPE = 'DASHBOARD_MARKDOWN_TYPE';
+export const NEW_COMPONENT_SOURCE_TYPE = 'NEW_COMPONENT_SOURCE_TYPE';
+export const ROW_TYPE = 'DASHBOARD_ROW_TYPE';
+export const TABS_TYPE = 'DASHBOARD_TABS_TYPE';
+export const TAB_TYPE = 'DASHBOARD_TAB_TYPE';
+
+export default {
+  CHART_TYPE,
+  COLUMN_TYPE,
+  DASHBOARD_HEADER_TYPE,
+  DASHBOARD_GRID_TYPE,
+  DASHBOARD_ROOT_TYPE,
+  DIVIDER_TYPE,
+  HEADER_TYPE,
+  MARKDOWN_TYPE,
+  NEW_COMPONENT_SOURCE_TYPE,
+  ROW_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+};
diff --git a/superset/assets/src/dashboard/util/constants.js b/superset/assets/src/dashboard/util/constants.js
new file mode 100644
index 0000000000..ef2c8bb45d
--- /dev/null
+++ b/superset/assets/src/dashboard/util/constants.js
@@ -0,0 +1,47 @@
+// Ids
+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';
+export const NEW_COLUMN_ID = 'NEW_COLUMN_ID';
+export const NEW_DIVIDER_ID = 'NEW_DIVIDER_ID';
+export const NEW_HEADER_ID = 'NEW_HEADER_ID';
+export const NEW_MARKDOWN_ID = 'NEW_MARKDOWN_ID';
+export const NEW_ROW_ID = 'NEW_ROW_ID';
+export const NEW_TAB_ID = 'NEW_TAB_ID';
+export const NEW_TABS_ID = 'NEW_TABS_ID';
+
+// grid constants
+export const DASHBOARD_ROOT_DEPTH = 0;
+export const GRID_BASE_UNIT = 8;
+export const GRID_GUTTER_SIZE = 2 * GRID_BASE_UNIT;
+export const GRID_COLUMN_COUNT = 12;
+export const GRID_MIN_COLUMN_COUNT = 2;
+export const GRID_MIN_ROW_UNITS = 5;
+export const GRID_MAX_ROW_UNITS = 100;
+export const GRID_MIN_ROW_HEIGHT = GRID_GUTTER_SIZE;
+
+// Header types
+export const SMALL_HEADER = 'SMALL_HEADER';
+export const MEDIUM_HEADER = 'MEDIUM_HEADER';
+export const LARGE_HEADER = 'LARGE_HEADER';
+
+// Style types
+export const BACKGROUND_WHITE = 'BACKGROUND_WHITE';
+export const BACKGROUND_TRANSPARENT = 'BACKGROUND_TRANSPARENT';
+
+// Toast types
+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;
+
+// save dash options
+export const SAVE_TYPE_OVERWRITE = 'overwrite';
+export const SAVE_TYPE_NEWDASHBOARD = 'newDashboard';
diff --git a/superset/assets/src/dashboard/util/dashboardLayoutConverter.js b/superset/assets/src/dashboard/util/dashboardLayoutConverter.js
new file mode 100644
index 0000000000..e28e3be314
--- /dev/null
+++ b/superset/assets/src/dashboard/util/dashboardLayoutConverter.js
@@ -0,0 +1,348 @@
+/* eslint-disable no-param-reassign */
+/* eslint-disable camelcase */
+/* eslint-disable no-loop-func */
+import {
+  ROW_TYPE,
+  COLUMN_TYPE,
+  CHART_TYPE,
+  MARKDOWN_TYPE,
+  DASHBOARD_ROOT_TYPE,
+  DASHBOARD_GRID_TYPE,
+} from './componentTypes';
+
+import {
+  DASHBOARD_GRID_ID,
+  DASHBOARD_ROOT_ID,
+  DASHBOARD_VERSION_KEY,
+} from './constants';
+
+const MAX_RECURSIVE_LEVEL = 6;
+const GRID_RATIO = 4;
+const ROW_HEIGHT = 8;
+const generateId = (() => {
+  let componentId = 1;
+  return () => {
+    const id = componentId;
+    componentId += 1;
+    return id;
+  };
+})();
+
+/**
+ *
+ * @param positions: single array of slices
+ * @returns boundary object {top: number, bottom: number, left: number, right: number}
+ */
+function getBoundary(positions) {
+  let top = Number.MAX_VALUE;
+  let bottom = 0;
+  let left = Number.MAX_VALUE;
+  let right = 1;
+  positions.forEach(item => {
+    const { row, col, size_x, size_y } = item;
+    if (row <= top) top = row;
+    if (col <= left) left = col;
+    if (bottom <= row + size_y) bottom = row + size_y;
+    if (right <= col + size_x) right = col + size_x;
+  });
+
+  return {
+    top,
+    bottom,
+    left,
+    right,
+  };
+}
+
+function getRowContainer() {
+  return {
+    type: ROW_TYPE,
+    id: `DASHBOARD_ROW_TYPE-${generateId()}`,
+    children: [],
+    meta: {
+      background: 'BACKGROUND_TRANSPARENT',
+    },
+  };
+}
+
+function getColContainer() {
+  return {
+    type: COLUMN_TYPE,
+    id: `DASHBOARD_COLUMN_TYPE-${generateId()}`,
+    children: [],
+    meta: {
+      background: 'BACKGROUND_TRANSPARENT',
+    },
+  };
+}
+
+function getChartHolder(item) {
+  const { size_x, size_y, slice_id, code } = item;
+
+  const width = Math.max(1, Math.floor(size_x / GRID_RATIO));
+  const height = Math.max(1, Math.round(size_y / GRID_RATIO));
+  if (code !== undefined) {
+    return {
+      type: MARKDOWN_TYPE,
+      id: `DASHBOARD_MARKDOWN_TYPE-${generateId()}`,
+      children: [],
+      meta: {
+        width,
+        height: Math.round(height * 100 / ROW_HEIGHT),
+        code,
+      },
+    };
+  }
+  return {
+    type: CHART_TYPE,
+    id: `DASHBOARD_CHART_TYPE-${generateId()}`,
+    children: [],
+    meta: {
+      width,
+      height: Math.round(height * 100 / ROW_HEIGHT),
+      chartId: parseInt(slice_id, 10),
+    },
+  };
+}
+
+function getChildrenMax(items, attr, layout) {
+  return Math.max.apply(null, items.map(child => layout[child].meta[attr]));
+}
+
+function getChildrenSum(items, attr, layout) {
+  return items.reduce(
+    (preValue, child) => preValue + layout[child].meta[attr],
+    0,
+  );
+}
+
+function sortByRowId(item1, item2) {
+  return item1.row - item2.row;
+}
+
+function sortByColId(item1, item2) {
+  return item1.col - item2.col;
+}
+
+function hasOverlap(positions, xAxis = true) {
+  return positions
+    .slice()
+    .sort(xAxis ? sortByColId : sortByRowId)
+    .some((item, index, arr) => {
+      if (index === arr.length - 1) {
+        return false;
+      }
+
+      if (xAxis) {
+        return item.col + item.size_x > arr[index + 1].col;
+      }
+      return item.row + item.size_y > arr[index + 1].row;
+    });
+}
+
+function doConvert(positions, level, parent, root) {
+  if (positions.length === 0) {
+    return;
+  }
+
+  if (positions.length === 1 || level >= MAX_RECURSIVE_LEVEL) {
+    // special treatment for single chart dash, always wrap chart inside a row
+    if (parent.type === DASHBOARD_GRID_TYPE) {
+      const rowContainer = getRowContainer();
+      root[rowContainer.id] = rowContainer;
+      parent.children.push(rowContainer.id);
+      parent = rowContainer;
+    }
+
+    const chartHolder = getChartHolder(positions[0]);
+    root[chartHolder.id] = chartHolder;
+    parent.children.push(chartHolder.id);
+    return;
+  }
+
+  let currentItems = positions.slice();
+  const { top, bottom, left, right } = getBoundary(positions);
+  // find row dividers
+  const layers = [];
+  let currentRow = top + 1;
+  while (currentItems.length && currentRow <= bottom) {
+    const upper = [];
+    const lower = [];
+
+    const isRowDivider = currentItems.every(item => {
+      const { row, size_y } = item;
+      if (row + size_y <= currentRow) {
+        lower.push(item);
+        return true;
+      } else if (row >= currentRow) {
+        upper.push(item);
+        return true;
+      }
+      return false;
+    });
+
+    if (isRowDivider) {
+      currentItems = upper.slice();
+      layers.push(lower);
+    }
+    currentRow += 1;
+  }
+
+  layers.forEach(layer => {
+    if (layer.length === 0) {
+      return;
+    }
+
+    if (layer.length === 1 && parent.type === COLUMN_TYPE) {
+      const chartHolder = getChartHolder(layer[0]);
+      root[chartHolder.id] = chartHolder;
+      parent.children.push(chartHolder.id);
+      return;
+    }
+
+    // create a new row
+    const rowContainer = getRowContainer();
+    root[rowContainer.id] = rowContainer;
+    parent.children.push(rowContainer.id);
+
+    currentItems = layer.slice();
+    if (!hasOverlap(currentItems)) {
+      currentItems.sort(sortByColId).forEach(item => {
+        const chartHolder = getChartHolder(item);
+        root[chartHolder.id] = chartHolder;
+        rowContainer.children.push(chartHolder.id);
+      });
+    } else {
+      // find col dividers for each layer
+      let currentCol = left + 1;
+      while (currentItems.length && currentCol <= right) {
+        const upper = [];
+        const lower = [];
+
+        const isColDivider = currentItems.every(item => {
+          const { col, size_x } = item;
+          if (col + size_x <= currentCol) {
+            lower.push(item);
+            return true;
+          } else if (col >= currentCol) {
+            upper.push(item);
+            return true;
+          }
+          return false;
+        });
+
+        if (isColDivider) {
+          if (lower.length === 1) {
+            const chartHolder = getChartHolder(lower[0]);
+            root[chartHolder.id] = chartHolder;
+            rowContainer.children.push(chartHolder.id);
+          } else {
+            // create a new column
+            const colContainer = getColContainer();
+            root[colContainer.id] = colContainer;
+            rowContainer.children.push(colContainer.id);
+
+            if (!hasOverlap(lower, false)) {
+              lower.sort(sortByRowId).forEach(item => {
+                const chartHolder = getChartHolder(item);
+                root[chartHolder.id] = chartHolder;
+                colContainer.children.push(chartHolder.id);
+              });
+            } else {
+              doConvert(lower, level + 2, colContainer, root);
+            }
+
+            // add col meta
+            colContainer.meta.width = getChildrenMax(
+              colContainer.children,
+              'width',
+              root,
+            );
+          }
+
+          currentItems = upper.slice();
+        }
+        currentCol += 1;
+      }
+    }
+
+    rowContainer.meta.width = getChildrenSum(
+      rowContainer.children,
+      'width',
+      root,
+    );
+  });
+}
+
+export function convertToLayout(positions) {
+  const root = {
+    [DASHBOARD_VERSION_KEY]: 'v2',
+    [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: [],
+    },
+  };
+
+  doConvert(positions, 0, root[DASHBOARD_GRID_ID], root);
+
+  // remove row's width/height and col's height
+  Object.values(root).forEach(item => {
+    if (ROW_TYPE === item.type) {
+      const meta = item.meta;
+      delete meta.width;
+    }
+  });
+
+  // console.log(JSON.stringify(root));
+  return root;
+}
+
+export default function(dashboard) {
+  const positions = [];
+
+  // position data clean up. some dashboard didn't have position_json
+  let { position_json } = dashboard;
+  const positionDict = {};
+  if (Array.isArray(position_json)) {
+    position_json.forEach(position => {
+      positionDict[position.slice_id] = position;
+    });
+  } else {
+    position_json = [];
+  }
+
+  const lastRowId = Math.max(
+    0,
+    Math.max.apply(null, position_json.map(pos => pos.row + pos.size_y)),
+  );
+  let newSliceCounter = 0;
+  dashboard.slices.forEach(({ slice_id, form_data }) => {
+    let position = positionDict[slice_id];
+    if (!position) {
+      // append new slices to dashboard bottom, 3 slices per row
+      position = {
+        col: (newSliceCounter % 3) * 16 + 1,
+        row: lastRowId + Math.floor(newSliceCounter / 3) * 16,
+        size_x: 16,
+        size_y: 16,
+        slice_id,
+      };
+      newSliceCounter += 1;
+    }
+    if (form_data && ['markup', 'separator'].indexOf(form_data.viz_type) > -1) {
+      position = {
+        ...position,
+        code: form_data.code,
+      };
+    }
+    positions.push(position);
+  });
+
+  return convertToLayout(positions);
+}
diff --git a/superset/assets/src/dashboard/util/dnd-reorder.js b/superset/assets/src/dashboard/util/dnd-reorder.js
new file mode 100644
index 0000000000..76fb56c912
--- /dev/null
+++ b/superset/assets/src/dashboard/util/dnd-reorder.js
@@ -0,0 +1,46 @@
+export function reorder(list, startIndex, endIndex) {
+  const result = [...list];
+  const [removed] = result.splice(startIndex, 1);
+  result.splice(endIndex, 0, removed);
+
+  return result;
+}
+
+export default function reorderItem({ entitiesMap, source, destination }) {
+  const current = [...entitiesMap[source.id].children];
+  const next = [...entitiesMap[destination.id].children];
+  const target = current[source.index];
+
+  // moving to same list
+  if (source.id === destination.id) {
+    const reordered = reorder(current, source.index, destination.index);
+
+    const result = {
+      ...entitiesMap,
+      [source.id]: {
+        ...entitiesMap[source.id],
+        children: reordered,
+      },
+    };
+
+    return result;
+  }
+
+  // moving to different list
+  current.splice(source.index, 1); // remove from original
+  next.splice(destination.index, 0, target); // insert into next
+
+  const result = {
+    ...entitiesMap,
+    [source.id]: {
+      ...entitiesMap[source.id],
+      children: current,
+    },
+    [destination.id]: {
+      ...entitiesMap[destination.id],
+      children: next,
+    },
+  };
+
+  return result;
+}
diff --git a/superset/assets/src/dashboard/util/dropOverflowsParent.js b/superset/assets/src/dashboard/util/dropOverflowsParent.js
new file mode 100644
index 0000000000..328d8e3999
--- /dev/null
+++ b/superset/assets/src/dashboard/util/dropOverflowsParent.js
@@ -0,0 +1,45 @@
+import { COLUMN_TYPE } from '../util/componentTypes';
+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, layout) {
+  const { source, destination, dragging } = dropResult;
+
+  // moving a component within a container should never overflow
+  if (source.id === destination.id) {
+    return false;
+  }
+
+  const isNewComponent = source.id === NEW_COMPONENTS_SOURCE_ID;
+  const grandparentId = findParentId({
+    childId: destination.id,
+    layout,
+  });
+
+  const child = isNewComponent
+    ? newComponentFactory(dragging.type)
+    : layout[dragging.id] || {};
+  const parent = layout[destination.id] || {};
+  const grandparent = layout[grandparentId] || {};
+
+  const childWidth = (child.meta && child.meta.width) || 0;
+
+  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
new file mode 100644
index 0000000000..c2e285d589
--- /dev/null
+++ b/superset/assets/src/dashboard/util/findParentId.js
@@ -0,0 +1,19 @@
+export default function findParentId({ childId, layout = {} }) {
+  let parentId = null;
+
+  const ids = Object.keys(layout);
+  for (let i = 0; i < ids.length - 1; i += 1) {
+    const id = ids[i];
+    const component = layout[id] || {};
+    if (
+      id !== childId &&
+      component.children &&
+      component.children.includes(childId)
+    ) {
+      parentId = id;
+      break;
+    }
+  }
+
+  return parentId;
+}
diff --git a/superset/assets/src/dashboard/util/getChartIdsFromLayout.js b/superset/assets/src/dashboard/util/getChartIdsFromLayout.js
new file mode 100644
index 0000000000..9aebb611d8
--- /dev/null
+++ b/superset/assets/src/dashboard/util/getChartIdsFromLayout.js
@@ -0,0 +1,15 @@
+import { CHART_TYPE } from './componentTypes';
+
+export default function getChartIdsFromLayout(layout) {
+  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/getChildWidth.js b/superset/assets/src/dashboard/util/getChildWidth.js
new file mode 100644
index 0000000000..69d2792a40
--- /dev/null
+++ b/superset/assets/src/dashboard/util/getChildWidth.js
@@ -0,0 +1,13 @@
+export default function getTotalChildWidth({ id, components }) {
+  const component = components[id];
+  if (!component) return 0;
+
+  let width = 0;
+
+  (component.children || []).forEach(childId => {
+    const child = components[childId] || {};
+    width += (child.meta || {}).width || 0;
+  });
+
+  return width;
+}
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/getDropPosition.js b/superset/assets/src/dashboard/util/getDropPosition.js
new file mode 100644
index 0000000000..2a02702088
--- /dev/null
+++ b/superset/assets/src/dashboard/util/getDropPosition.js
@@ -0,0 +1,108 @@
+import isValidChild from './isValidChild';
+import { TAB_TYPE, TABS_TYPE } from './componentTypes';
+
+export const DROP_TOP = 'DROP_TOP';
+export const DROP_RIGHT = 'DROP_RIGHT';
+export const DROP_BOTTOM = 'DROP_BOTTOM';
+export const DROP_LEFT = 'DROP_LEFT';
+
+// this defines how close the mouse must be to the edge of a component to display
+// a sibling type drop indicator
+const SIBLING_DROP_THRESHOLD = 20;
+
+export default function getDropPosition(monitor, Component) {
+  const {
+    depth: componentDepth,
+    parentComponent,
+    component,
+    orientation,
+    isDraggingOverShallow,
+  } = Component.props;
+
+  const draggingItem = monitor.getItem();
+
+  // if dropped self on self, do nothing
+  if (
+    !draggingItem ||
+    draggingItem.id === component.id ||
+    !isDraggingOverShallow
+  ) {
+    return null;
+  }
+
+  const validChild = isValidChild({
+    parentType: component.type,
+    parentDepth: componentDepth,
+    childType: draggingItem.type,
+  });
+
+  const parentType = parentComponent && parentComponent.type;
+  const parentDepth = // see isValidChild.js for why tabs don't increment child depth
+    componentDepth +
+    (parentType === TAB_TYPE || parentType === TABS_TYPE ? 0 : -1);
+
+  const validSibling = isValidChild({
+    parentType,
+    parentDepth,
+    childType: draggingItem.type,
+  });
+
+  if (!validChild && !validSibling) {
+    return null;
+  }
+
+  const hasChildren = (component.children || []).length > 0;
+  const childDropOrientation =
+    orientation === 'row' ? 'vertical' : 'horizontal';
+  const siblingDropOrientation =
+    orientation === 'row' ? 'horizontal' : 'vertical';
+
+  if (validChild && !validSibling) {
+    // easiest case, insert as child
+    if (childDropOrientation === 'vertical') {
+      return hasChildren ? DROP_RIGHT : DROP_LEFT;
+    }
+    return hasChildren ? DROP_BOTTOM : DROP_TOP;
+  }
+
+  const refBoundingRect = Component.ref.getBoundingClientRect();
+  const clientOffset = monitor.getClientOffset();
+
+  // Drop based on mouse position relative to component center
+  if (validSibling && !validChild) {
+    if (siblingDropOrientation === 'vertical') {
+      const refMiddleX =
+        refBoundingRect.left +
+        (refBoundingRect.right - refBoundingRect.left) / 2;
+      return clientOffset.x < refMiddleX ? DROP_LEFT : DROP_RIGHT;
+    }
+    const refMiddleY =
+      refBoundingRect.top + (refBoundingRect.bottom - refBoundingRect.top) / 2;
+    return clientOffset.y < refMiddleY ? DROP_TOP : DROP_BOTTOM;
+  }
+
+  // either is valid, so choose location based on boundary deltas
+  if (validSibling && validChild) {
+    const deltaTop = Math.abs(clientOffset.y - refBoundingRect.top);
+    const deltaBottom = Math.abs(clientOffset.y - refBoundingRect.bottom);
+    const deltaLeft = Math.abs(clientOffset.x - refBoundingRect.left);
+    const deltaRight = Math.abs(clientOffset.x - refBoundingRect.right);
+
+    // if near enough to a sibling boundary, drop there
+    if (siblingDropOrientation === 'vertical') {
+      if (deltaLeft < SIBLING_DROP_THRESHOLD) return DROP_LEFT;
+      if (deltaRight < SIBLING_DROP_THRESHOLD) return DROP_RIGHT;
+    } else {
+      if (deltaTop < SIBLING_DROP_THRESHOLD) return DROP_TOP;
+      if (deltaBottom < SIBLING_DROP_THRESHOLD) return DROP_BOTTOM;
+    }
+
+    // drop as child
+    if (childDropOrientation === 'vertical') {
+      return hasChildren ? DROP_RIGHT : DROP_LEFT;
+    }
+    return hasChildren ? DROP_BOTTOM : DROP_TOP;
+  }
+
+  return null;
+}
diff --git a/superset/assets/src/dashboard/util/headerStyleOptions.js b/superset/assets/src/dashboard/util/headerStyleOptions.js
new file mode 100644
index 0000000000..7efa040ef5
--- /dev/null
+++ b/superset/assets/src/dashboard/util/headerStyleOptions.js
@@ -0,0 +1,8 @@
+import { t } from '../../locales';
+import { SMALL_HEADER, MEDIUM_HEADER, LARGE_HEADER } from './constants';
+
+export default [
+  { value: SMALL_HEADER, label: t('Small'), className: 'header-small' },
+  { value: MEDIUM_HEADER, label: t('Medium'), className: 'header-medium' },
+  { value: LARGE_HEADER, label: t('Large'), className: 'header-large' },
+];
diff --git a/superset/assets/src/dashboard/util/isValidChild.js b/superset/assets/src/dashboard/util/isValidChild.js
new file mode 100644
index 0000000000..80bf69ea6d
--- /dev/null
+++ b/superset/assets/src/dashboard/util/isValidChild.js
@@ -0,0 +1,98 @@
+/* eslint max-len: 0 */
+/**
+ * When determining if a component is a valid child of another component we must consider both
+ *   - parent + child component types
+ *   - component depth, or depth of nesting of container components
+ *
+ * We consider types because some components aren't containers (e.g. a heading) and we consider
+ * depth to prevent infinite nesting of container components.
+ *
+ * The following example container nestings should be valid, which means that some containers
+ * don't increase the (depth) of their children, namely tabs and tab:
+ *   (a) root (0) > grid (1) >                         row (2) > column (3) > row (4) > non-container (5)
+ *   (b) root (0) > grid (1) >    tabs (2) > tab (2) > row (2) > column (3) > row (4) > non-container (5)
+ *   (c) root (0) > top-tab (1) >                      row (2) > column (3) > row (4) > non-container (5)
+ *   (d) root (0) > top-tab (1) > tabs (2) > tab (2) > row (2) > column (3) > row (4) > non-container (5)
+ */
+import {
+  CHART_TYPE,
+  COLUMN_TYPE,
+  DASHBOARD_GRID_TYPE,
+  DASHBOARD_ROOT_TYPE,
+  DIVIDER_TYPE,
+  HEADER_TYPE,
+  MARKDOWN_TYPE,
+  ROW_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+} from './componentTypes';
+
+import { DASHBOARD_ROOT_DEPTH as rootDepth } from './constants';
+
+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 = {
+  [DASHBOARD_ROOT_TYPE]: {
+    [TABS_TYPE]: rootDepth,
+    [DASHBOARD_GRID_TYPE]: rootDepth,
+  },
+
+  [DASHBOARD_GRID_TYPE]: {
+    [CHART_TYPE]: depthOne,
+    [MARKDOWN_TYPE]: depthOne,
+    [COLUMN_TYPE]: depthOne,
+    [DIVIDER_TYPE]: depthOne,
+    [HEADER_TYPE]: depthOne,
+    [ROW_TYPE]: depthOne,
+    [TABS_TYPE]: depthOne,
+  },
+
+  [ROW_TYPE]: {
+    [CHART_TYPE]: depthFour,
+    [MARKDOWN_TYPE]: depthFour,
+    [COLUMN_TYPE]: depthFour,
+  },
+
+  [TABS_TYPE]: {
+    [TAB_TYPE]: depthTwo,
+  },
+
+  [TAB_TYPE]: {
+    [CHART_TYPE]: depthTwo,
+    [MARKDOWN_TYPE]: depthTwo,
+    [COLUMN_TYPE]: depthTwo,
+    [DIVIDER_TYPE]: depthTwo,
+    [HEADER_TYPE]: depthTwo,
+    [ROW_TYPE]: depthTwo,
+    [TABS_TYPE]: rootDepth, // you cannot drop a Tabs within a Tab
+  },
+
+  [COLUMN_TYPE]: {
+    [CHART_TYPE]: depthFive,
+    [HEADER_TYPE]: depthFive,
+    [MARKDOWN_TYPE]: depthFive,
+    [ROW_TYPE]: depthThree,
+    [DIVIDER_TYPE]: depthThree,
+  },
+
+  // these have no valid children
+  [CHART_TYPE]: {},
+  [DIVIDER_TYPE]: {},
+  [HEADER_TYPE]: {},
+  [MARKDOWN_TYPE]: {},
+};
+
+export default function isValidChild({ parentType, childType, parentDepth }) {
+  if (!parentType || !childType || typeof parentDepth !== 'number') {
+    return false;
+  }
+
+  const maxParentDepth = (parentMaxDepthLookup[parentType] || {})[childType];
+
+  return typeof maxParentDepth === 'number' && parentDepth <= maxParentDepth;
+}
diff --git a/superset/assets/src/dashboard/util/newComponentFactory.js b/superset/assets/src/dashboard/util/newComponentFactory.js
new file mode 100644
index 0000000000..8d259afa4b
--- /dev/null
+++ b/superset/assets/src/dashboard/util/newComponentFactory.js
@@ -0,0 +1,45 @@
+import shortid from 'shortid';
+
+import {
+  CHART_TYPE,
+  COLUMN_TYPE,
+  DIVIDER_TYPE,
+  HEADER_TYPE,
+  MARKDOWN_TYPE,
+  ROW_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+} from './componentTypes';
+
+import { MEDIUM_HEADER, BACKGROUND_TRANSPARENT } from './constants';
+
+const typeToDefaultMetaData = {
+  [CHART_TYPE]: { width: 3, height: 30 },
+  [COLUMN_TYPE]: { width: 3, background: BACKGROUND_TRANSPARENT },
+  [DIVIDER_TYPE]: null,
+  [HEADER_TYPE]: {
+    text: 'New header',
+    headerSize: MEDIUM_HEADER,
+    background: BACKGROUND_TRANSPARENT,
+  },
+  [MARKDOWN_TYPE]: { width: 3, height: 30 },
+  [ROW_TYPE]: { background: BACKGROUND_TRANSPARENT },
+  [TABS_TYPE]: null,
+  [TAB_TYPE]: { text: 'New Tab' },
+};
+
+function uuid(type) {
+  return `${type}-${shortid.generate()}`;
+}
+
+export default function entityFactory(type, meta) {
+  return {
+    type,
+    id: uuid(type),
+    children: [],
+    meta: {
+      ...typeToDefaultMetaData[type],
+      ...meta,
+    },
+  };
+}
diff --git a/superset/assets/src/dashboard/util/newEntitiesFromDrop.js b/superset/assets/src/dashboard/util/newEntitiesFromDrop.js
new file mode 100644
index 0000000000..8abc9b9985
--- /dev/null
+++ b/superset/assets/src/dashboard/util/newEntitiesFromDrop.js
@@ -0,0 +1,43 @@
+import shouldWrapChildInRow from './shouldWrapChildInRow';
+import newComponentFactory from './newComponentFactory';
+
+import { ROW_TYPE, TABS_TYPE, TAB_TYPE } from './componentTypes';
+
+export default function newEntitiesFromDrop({ dropResult, layout }) {
+  const { dragging, destination } = dropResult;
+
+  const dragType = dragging.type;
+  const dropEntity = layout[destination.id];
+  const dropType = dropEntity.type;
+  let newDropChild = newComponentFactory(dragType, dragging.meta);
+  const wrapChildInRow = shouldWrapChildInRow({
+    parentType: dropType,
+    childType: dragType,
+  });
+
+  const newEntities = {
+    [newDropChild.id]: newDropChild,
+  };
+
+  if (wrapChildInRow) {
+    const rowWrapper = newComponentFactory(ROW_TYPE);
+    rowWrapper.children = [newDropChild.id];
+    newEntities[rowWrapper.id] = rowWrapper;
+    newDropChild = rowWrapper;
+  } else if (dragType === TABS_TYPE) {
+    // create a new tab component
+    const tabChild = newComponentFactory(TAB_TYPE);
+    newDropChild.children = [tabChild.id];
+    newEntities[tabChild.id] = tabChild;
+  }
+
+  const nextDropChildren = [...dropEntity.children];
+  nextDropChildren.splice(destination.index, 0, newDropChild.id);
+
+  newEntities[destination.id] = {
+    ...dropEntity,
+    children: nextDropChildren,
+  };
+
+  return newEntities;
+}
diff --git a/superset/assets/src/dashboard/util/propShapes.jsx b/superset/assets/src/dashboard/util/propShapes.jsx
new file mode 100644
index 0000000000..f07497c56f
--- /dev/null
+++ b/superset/assets/src/dashboard/util/propShapes.jsx
@@ -0,0 +1,86 @@
+import PropTypes from 'prop-types';
+import componentTypes from './componentTypes';
+import backgroundStyleOptions from './backgroundStyleOptions';
+import headerStyleOptions from './headerStyleOptions';
+import {
+  INFO_TOAST,
+  SUCCESS_TOAST,
+  WARNING_TOAST,
+  DANGER_TOAST,
+} from './constants';
+
+export const componentShape = PropTypes.shape({
+  id: PropTypes.string.isRequired,
+  type: PropTypes.oneOf(Object.values(componentTypes)).isRequired,
+  children: PropTypes.arrayOf(PropTypes.string),
+  meta: PropTypes.shape({
+    // Dimensions
+    width: PropTypes.number,
+    height: PropTypes.number,
+
+    // Header
+    headerSize: PropTypes.oneOf(headerStyleOptions.map(opt => opt.value)),
+
+    // Row
+    background: PropTypes.oneOf(backgroundStyleOptions.map(opt => opt.value)),
+  }),
+});
+
+export const toastShape = PropTypes.shape({
+  id: PropTypes.string.isRequired,
+  toastType: PropTypes.oneOf([
+    INFO_TOAST,
+    SUCCESS_TOAST,
+    WARNING_TOAST,
+    DANGER_TOAST,
+  ]).isRequired,
+  text: PropTypes.string.isRequired,
+});
+
+export const chartPropShape = PropTypes.shape({
+  id: PropTypes.number.isRequired,
+  chartAlert: PropTypes.string,
+  chartStatus: PropTypes.string,
+  chartUpdateEndTime: PropTypes.number,
+  chartUpdateStartTime: PropTypes.number,
+  latestQueryFormData: PropTypes.object,
+  queryRequest: PropTypes.object,
+  queryResponse: PropTypes.object,
+  triggerQuery: PropTypes.bool,
+  lastRendered: PropTypes.number,
+});
+
+export const slicePropShape = PropTypes.shape({
+  slice_id: PropTypes.number.isRequired,
+  slice_url: PropTypes.string.isRequired,
+  slice_name: PropTypes.string.isRequired,
+  edit_url: PropTypes.string.isRequired,
+  datasource: PropTypes.string,
+  datasource_name: PropTypes.string,
+  datasource_link: PropTypes.string,
+  changedOn: PropTypes.number,
+  modified: PropTypes.string,
+  viz_type: PropTypes.string.isRequired,
+  description: PropTypes.string,
+  description_markeddown: PropTypes.string,
+});
+
+export const dashboardStatePropShape = PropTypes.shape({
+  sliceIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+  refresh: PropTypes.bool.isRequired,
+  filters: PropTypes.object.isRequired,
+  expandedSlices: PropTypes.object,
+  editMode: PropTypes.bool,
+  showBuilderPane: PropTypes.bool,
+  hasUnsavedChanges: PropTypes.bool,
+});
+
+export const dashboardInfoPropShape = PropTypes.shape({
+  id: PropTypes.number.isRequired,
+  metadata: PropTypes.object,
+  slug: PropTypes.string,
+  dash_edit_perm: PropTypes.bool.isRequired,
+  dash_save_perm: PropTypes.bool.isRequired,
+  common: PropTypes.object,
+  userId: PropTypes.string.isRequired,
+});
diff --git a/superset/assets/src/dashboard/util/resizableConfig.js b/superset/assets/src/dashboard/util/resizableConfig.js
new file mode 100644
index 0000000000..f94914ee31
--- /dev/null
+++ b/superset/assets/src/dashboard/util/resizableConfig.js
@@ -0,0 +1,35 @@
+// config for a ResizableContainer
+const adjustableWidthAndHeight = {
+  top: false,
+  right: false,
+  bottom: false,
+  left: false,
+  topRight: false,
+  bottomRight: true,
+  bottomLeft: false,
+  topLeft: false,
+};
+
+const adjustableWidth = {
+  ...adjustableWidthAndHeight,
+  right: true,
+  bottomRight: false,
+};
+
+const adjustableHeight = {
+  ...adjustableWidthAndHeight,
+  bottom: true,
+  bottomRight: false,
+};
+
+const notAdjustable = {
+  ...adjustableWidthAndHeight,
+  bottomRight: false,
+};
+
+export default {
+  widthAndHeight: adjustableWidthAndHeight,
+  widthOnly: adjustableWidth,
+  heightOnly: adjustableHeight,
+  notAdjustable,
+};
diff --git a/superset/assets/src/dashboard/util/shouldWrapChildInRow.js b/superset/assets/src/dashboard/util/shouldWrapChildInRow.js
new file mode 100644
index 0000000000..e7e648cf1b
--- /dev/null
+++ b/superset/assets/src/dashboard/util/shouldWrapChildInRow.js
@@ -0,0 +1,30 @@
+import {
+  DASHBOARD_GRID_TYPE,
+  CHART_TYPE,
+  COLUMN_TYPE,
+  MARKDOWN_TYPE,
+  TAB_TYPE,
+} from './componentTypes';
+
+const typeToWrapChildLookup = {
+  [DASHBOARD_GRID_TYPE]: {
+    [CHART_TYPE]: true,
+    [COLUMN_TYPE]: true,
+    [MARKDOWN_TYPE]: true,
+  },
+
+  [TAB_TYPE]: {
+    [CHART_TYPE]: true,
+    [COLUMN_TYPE]: true,
+    [MARKDOWN_TYPE]: true,
+  },
+};
+
+export default function shouldWrapChildInRow({ parentType, childType }) {
+  if (!parentType || !childType) return false;
+
+  const wrapChildLookup = typeToWrapChildLookup[parentType];
+  if (!wrapChildLookup) return false;
+
+  return Boolean(wrapChildLookup[childType]);
+}
diff --git a/superset/assets/src/explore/components/ExploreChartHeader.jsx b/superset/assets/src/explore/components/ExploreChartHeader.jsx
index 69871dc58f..3825335f1d 100644
--- a/superset/assets/src/explore/components/ExploreChartHeader.jsx
+++ b/superset/assets/src/explore/components/ExploreChartHeader.jsx
@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import { chartPropType } from '../../chart/chartReducer';
+import { chartPropShape } from '../../dashboard/util/propShapes';
 import ExploreActionButtons from './ExploreActionButtons';
 import RowCountLabel from './RowCountLabel';
 import EditableTitle from '../../components/EditableTitle';
@@ -28,13 +28,13 @@ const propTypes = {
   table_name: PropTypes.string,
   form_data: PropTypes.object,
   timeout: PropTypes.number,
-  chart: PropTypes.shape(chartPropType),
+  chart: chartPropShape,
 };
 
 class ExploreChartHeader extends React.PureComponent {
   runQuery() {
     this.props.actions.runQuery(this.props.form_data, true,
-      this.props.timeout, this.props.chart.chartKey);
+      this.props.timeout, this.props.chart.id);
   }
 
   updateChartTitleOrSaveSlice(newTitle) {
diff --git a/superset/assets/src/explore/components/ExploreChartPanel.jsx b/superset/assets/src/explore/components/ExploreChartPanel.jsx
index bfb24fff7f..bcda75d711 100644
--- a/superset/assets/src/explore/components/ExploreChartPanel.jsx
+++ b/superset/assets/src/explore/components/ExploreChartPanel.jsx
@@ -3,7 +3,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { Panel } from 'react-bootstrap';
 
-import { chartPropType } from '../../chart/chartReducer';
+import { chartPropShape } from '../../dashboard/util/propShapes';
 import ChartContainer from '../../chart/ChartContainer';
 import ExploreChartHeader from './ExploreChartHeader';
 
@@ -27,7 +27,7 @@ const propTypes = {
   standalone: PropTypes.bool,
   timeout: PropTypes.number,
   refreshOverlayVisible: PropTypes.bool,
-  chart: PropTypes.shape(chartPropType),
+  chart: chartPropShape,
   errorMessage: PropTypes.node,
 };
 
@@ -38,14 +38,15 @@ class ExploreChartPanel extends React.PureComponent {
   }
 
   renderChart() {
+    const { chart } = this.props;
     return (
       <ChartContainer
+        chartId={chart.id}
         containerId={this.props.containerId}
         datasource={this.props.datasource}
         formData={this.props.form_data}
         height={this.getHeight()}
         slice={this.props.slice}
-        chartKey={this.props.chart.chartKey}
         setControlValue={this.props.actions.setControlValue}
         timeout={this.props.timeout}
         vizType={this.props.vizType}
@@ -53,6 +54,16 @@ class ExploreChartPanel extends React.PureComponent {
         errorMessage={this.props.errorMessage}
         onQuery={this.props.onQuery}
         onDismissRefreshOverlay={this.props.onDismissRefreshOverlay}
+        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}
       />
     );
   }
diff --git a/superset/assets/src/explore/components/ExploreViewContainer.jsx b/superset/assets/src/explore/components/ExploreViewContainer.jsx
index 1fec139c54..9155c229e5 100644
--- a/superset/assets/src/explore/components/ExploreViewContainer.jsx
+++ b/superset/assets/src/explore/components/ExploreViewContainer.jsx
@@ -11,7 +11,7 @@ import QueryAndSaveBtns from './QueryAndSaveBtns';
 import { getExploreUrlAndPayload, getExploreLongUrl } from '../exploreUtils';
 import { areObjectsEqual } from '../../reduxUtils';
 import { getFormDataFromControls } from '../stores/store';
-import { chartPropType } from '../../chart/chartReducer';
+import { chartPropShape } from '../../dashboard/util/propShapes';
 import * as exploreActions from '../actions/exploreActions';
 import * as saveModalActions from '../actions/saveModalActions';
 import * as chartActions from '../../chart/chartAction';
@@ -22,7 +22,7 @@ const propTypes = {
   actions: PropTypes.object.isRequired,
   datasource_type: PropTypes.string.isRequired,
   isDatasourceMetaLoading: PropTypes.bool.isRequired,
-  chart: PropTypes.shape(chartPropType).isRequired,
+  chart: chartPropShape.isRequired,
   slice: PropTypes.object,
   controls: PropTypes.object.isRequired,
   forcedHeight: PropTypes.string,
@@ -72,7 +72,7 @@ class ExploreViewContainer extends React.Component {
     }
     if (np.controls.viz_type.value !== this.props.controls.viz_type.value) {
       this.props.actions.resetControls();
-      this.props.actions.triggerQuery(true, this.props.chart.chartKey);
+      this.props.actions.triggerQuery(true, this.props.chart.id);
     }
     if (np.controls.datasource.value !== this.props.controls.datasource.value) {
       this.props.actions.fetchDatasourceMetadata(np.form_data.datasource, true);
@@ -81,8 +81,8 @@ class ExploreViewContainer extends React.Component {
     const changedControlKeys = this.findChangedControlKeys(this.props.controls, np.controls);
     if (this.hasDisplayControlChanged(changedControlKeys, np.controls)) {
       this.props.actions.updateQueryFormData(
-        getFormDataFromControls(np.controls), this.props.chart.chartKey);
-      this.props.actions.renderTriggered(new Date().getTime(), this.props.chart.chartKey);
+        getFormDataFromControls(np.controls), this.props.chart.id);
+      this.props.actions.renderTriggered(new Date().getTime(), this.props.chart.id);
     }
     if (this.hasQueryControlChanged(changedControlKeys, np.controls)) {
       this.setState({ chartIsStale: true, refreshOverlayVisible: true });
@@ -107,7 +107,7 @@ class ExploreViewContainer extends React.Component {
   onQuery() {
     // remove alerts when query
     this.props.actions.removeControlPanelAlert();
-    this.props.actions.triggerQuery(true, this.props.chart.chartKey);
+    this.props.actions.triggerQuery(true, this.props.chart.id);
 
     this.setState({ chartIsStale: false, refreshOverlayVisible: false });
     this.addHistory({});
@@ -153,7 +153,7 @@ class ExploreViewContainer extends React.Component {
   triggerQueryIfNeeded() {
     if (this.props.chart.triggerQuery && !this.hasErrors()) {
       this.props.actions.runQuery(this.props.form_data, false,
-        this.props.timeout, this.props.chart.chartKey);
+        this.props.timeout, this.props.chart.id);
     }
   }
 
@@ -193,7 +193,7 @@ class ExploreViewContainer extends React.Component {
         formData,
         false,
         this.props.timeout,
-        this.props.chart.chartKey,
+        this.props.chart.id,
       );
     }
   }
diff --git a/superset/assets/src/explore/exploreUtils.js b/superset/assets/src/explore/exploreUtils.js
index 1c1271b045..fcab33f121 100644
--- a/superset/assets/src/explore/exploreUtils.js
+++ b/superset/assets/src/explore/exploreUtils.js
@@ -3,7 +3,7 @@ import URI from 'urijs';
 
 export function getChartKey(explore) {
   const slice = explore.slice;
-  return slice ? ('slice_' + slice.slice_id) : 'slice';
+  return slice ? (slice.slice_id) : 0;
 }
 
 export function getAnnotationJsonUrl(slice_id, form_data, isNative) {
diff --git a/superset/assets/src/explore/index.jsx b/superset/assets/src/explore/index.jsx
index 74f5a0bb68..575c9ab30d 100644
--- a/superset/assets/src/explore/index.jsx
+++ b/superset/assets/src/explore/index.jsx
@@ -49,7 +49,7 @@ const chartKey = getChartKey(bootstrappedState);
 const initState = {
   charts: {
     [chartKey]: {
-      chartKey,
+      id: chartKey,
       chartAlert: null,
       chartStatus: 'loading',
       chartUpdateEndTime: null,
diff --git a/superset/assets/src/explore/reducers/index.js b/superset/assets/src/explore/reducers/index.js
index 13d0ed1b0b..953b0b5fca 100644
--- a/superset/assets/src/explore/reducers/index.js
+++ b/superset/assets/src/explore/reducers/index.js
@@ -1,13 +1,14 @@
 import { combineReducers } from 'redux';
-import shortid from 'shortid';
 
 import charts from '../../chart/chartReducer';
 import saveModal from './saveModalReducer';
 import explore from './exploreReducer';
 
+const impressionId = (state = '') => (state);
+
 export default combineReducers({
   charts,
   saveModal,
   explore,
-  impressionId: () => (shortid.generate()),
+  impressionId,
 });
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/modules/utils.js b/superset/assets/src/modules/utils.js
index 016444a9f7..c5d4e75450 100644
--- a/superset/assets/src/modules/utils.js
+++ b/superset/assets/src/modules/utils.js
@@ -165,7 +165,6 @@ export const controllerInterface = {
   addFiler: () => {},
   setFilter: () => {},
   getFilters: () => false,
-  clearFilter: () => {},
   removeFilter: () => {},
   filters: {},
 };
@@ -199,10 +198,14 @@ export function slugify(string) {
 
 export function getAjaxErrorMsg(error) {
   const respJSON = error.responseJSON;
-  return (respJSON && respJSON.message) ? respJSON.message :
+  return (respJSON && respJSON.error) ? respJSON.error :
           error.responseText;
 }
 
+export function getDatasourceParameter(datasourceId, datasourceType) {
+  return `${datasourceId}__${datasourceType}`;
+}
+
 export function customizeToolTip(chart, xAxisFormatter, yAxisFormatters) {
   chart.useInteractiveGuideline(true);
   chart.interactiveLayer.tooltip.contentGenerator(function (d) {
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.css b/superset/assets/src/visualizations/nvd3_vis.css
index fed0d013dc..f7539e1254 100644
--- a/superset/assets/src/visualizations/nvd3_vis.css
+++ b/superset/assets/src/visualizations/nvd3_vis.css
@@ -63,4 +63,3 @@ g.opacityMedium path, line.opacityMedium {
 g.opacityHigh path, line.opacityHigh {
   stroke-opacity: .8
 }
-
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/src/visualizations/table.css b/superset/assets/src/visualizations/table.css
index a5b8462c53..9af0c0e5f5 100644
--- a/superset/assets/src/visualizations/table.css
+++ b/superset/assets/src/visualizations/table.css
@@ -30,11 +30,10 @@ table.table thead th.sorting:after, table.table thead th.sorting_asc:after, tabl
   white-space: pre-wrap;
 }
 
+.widget.table {
+  width: auto;
+  max-width: unset;
+}
 .widget.table thead tr {
   height: 25px;
 }
-
-.dashboard .slice_container.table {
-  padding-left: 10px;
-  padding-right: 10px;
-}
diff --git a/superset/assets/stylesheets/dashboard.css b/superset/assets/stylesheets/dashboard.css
deleted file mode 100644
index c1f08a7e38..0000000000
--- a/superset/assets/stylesheets/dashboard.css
+++ /dev/null
@@ -1,156 +0,0 @@
-.dashboard a i {
-  cursor: pointer;
-}
-.dashboard i.drag {
-  cursor: move !important;
-}
-.dashboard .slice-grid .preview-holder {
-  z-index: 1;
-  position: absolute;
-  background-color: #AAA;
-  border-color: #AAA;
-  opacity: 0.3;
-}
-div.widget .chart-controls {
-  background-clip: content-box;
-  position: absolute;
-  z-index: 100;
-  right: 0;
-  top: 5px;
-  padding: 5px 5px;
-  opacity: 0;
-  transition: opacity 0.5s ease-in-out;
-}
-div.widget:hover .chart-controls {
-  opacity: 0.75;
-  transition: opacity 0.5s ease-in-out;
-}
-.slice-grid div.widget {
-  border-radius: 0;
-  border: 0;
-  box-shadow: none;
-  background-color: #fff;
-  overflow: visible;
-}
-
-.slice-grid .slice_container {
-  background-color: #fff;
-}
-
-.dashboard .slice-grid .dragging,
-.dashboard .slice-grid .resizing {
-  opacity: 0.5;
-}
-.dashboard img.loading {
-  width: 20px;
-  margin: 5px;
-  position: absolute;
-}
-
-.dashboard .slice_title {
-  text-align: center;
-  font-weight: bold;
-  font-size: 14px;
-  padding: 5px;
-}
-.dashboard div.slice_content {
-  width: 100%;
-  height: 100%;
-}
-
-.modal img.loading {
-  width: 50px;
-  margin: 0;
-  position: relative;
-}
-
-.react-bs-container-body {
-  max-height: 400px;
-  overflow-y: auto;
-}
-
-.hidden, #pageDropDown {
-  display: none;
-}
-
-.slice-grid div.separator.widget {
- border: 1px solid transparent;
-  box-shadow: none;
-  z-index: 1;
-}
-.slice-grid div.separator.widget:hover {
-  border: 1px solid #EEE;
-}
-.slice-grid div.separator.widget .chart-header {
-  background-color: transparent;
-  color: transparent;
-}
-.slice-grid div.separator.widget h1,h2,h3,h4 {
-  margin-top: 0px;
-}
-
-.slice-cell {
-  box-shadow: 0px 0px 20px 5px rgba(0,0,0,0);
-  transition: box-shadow 1s ease-in;
-  height: 100%;
-}
-
-.slice-cell-highlight {
-  box-shadow: 0px 0px 20px 5px rgba(0,0,0,0.2);
-  height: 100%;
-}
-
-.slice-cell .editable-title input[type="button"] {
-  font-weight: bold;
-}
-
-.dashboard .separator.widget .slice_container {
-  padding: 0;
-  overflow: visible;
-}
-.dashboard .separator.widget .slice_container hr {
-  margin-top: 5px;
-  margin-bottom: 5px;
-}
-.separator .chart-container {
-  position: absolute;
-  left: 0;
-  right: 0;
-  top: 0;
-  bottom: 0;
-}
-
-.dashboard .title {
-  margin: 0 20px;
-}
-
-.dashboard .title .favstar {
-  font-size: 20px;
-  position: relative;
-  top: -5px;
-}
-
-.chart-header .header {
-  font-size: 16px;
-  margin: 0 -10px;
-}
-.ace_gutter {
-    z-index: 0;
-}
-.ace_content {
-    z-index: 0;
-}
-.ace_scrollbar {
-    z-index: 0;
-}
-.slice_container .alert {
-    margin: 10px;
-}
-
-i.danger {
-  color: red;
-}
-
-i.warning {
-  color: orange;
-}
diff --git a/superset/assets/stylesheets/superset.less b/superset/assets/stylesheets/superset.less
index 035acceb1f..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;
@@ -141,7 +140,7 @@ div.navbar {
 img.loading {
   width: 40px;
   position: relative;
-  z-index: 10;
+  z-index: 1;
   margin: 10px;
 }
 img.viz-thumb-option {
@@ -162,7 +161,6 @@ li.widget:hover {
 div.widget .chart-header {
   padding-top: 8px;
   color: #333;
-  border-bottom: 1px solid #aaa;
   margin: 0 10px;
 }
 
@@ -177,10 +175,6 @@ div.widget .chart-header {
 }
 
 
-div.widget .chart-header a {
-  margin-left: 5px;
-}
-
 #is_cached {
   display: none;
 }
@@ -228,34 +222,43 @@ table.table-no-hover tr:hover {
 }
 
 .editable-title input {
-    padding: 2px 6px 3px 6px;
+  outline: none;
+  background: transparent;
+  border: none;
+  box-shadow: none;
+  padding: 0;
+  cursor: initial;
 }
 
 .editable-title input[type="button"] {
-    border-color: transparent;
-    background: transparent;
-    white-space: normal;
-    text-align: left;
+  border-color: transparent;
+  background: transparent;
+  font-size: inherit;
+  line-height: inherit;
+  white-space: normal;
+  text-align: left;
+  cursor: initial;
 }
 
-.editable-title input[type="button"]:hover {
-    cursor: text;
+.editable-title.editable-title--editable input[type="button"] {
+  cursor: pointer;
 }
 
-.editable-title input[type="button"]:focus {
-    outline: none;
+.editable-title.editable-title--editing input[type="button"] {
+  cursor: text;
 }
+
 .m-r-5 {
-    margin-right: 5px;
+  margin-right: 5px;
 }
 .m-r-3 {
-    margin-right: 3px;
+  margin-right: 3px;
 }
 .m-t-5 {
-    margin-top: 5px;
+  margin-top: 5px;
 }
 .m-t-10 {
-    margin-top: 10px;
+  margin-top: 10px;
 }
 .m-b-10 {
     margin-bottom: 10px;
@@ -450,6 +453,17 @@ g.annotation-container {
   border-color: @brand-primary;
 }
 
+.fave-unfave-icon {
+  .fa-star-o,
+  .fa-star {
+    &,
+    &:hover,
+    &:active {
+      color: #263238;
+    }
+  }
+}
+
 .metric-edit-popover-label-input {
   border-radius: 4px;
   height: 30px;
diff --git a/superset/assets/stylesheets/welcome.css b/superset/assets/stylesheets/welcome.css
index 8e2496ee8e..1f7285278c 100644
--- a/superset/assets/stylesheets/welcome.css
+++ b/superset/assets/stylesheets/welcome.css
@@ -3,7 +3,7 @@
 }
 
 img.loading {
-    width: 25px;
+  width: 25px;
 }
 
 .welcome table {
diff --git a/superset/assets/yarn.lock b/superset/assets/yarn.lock
index 5ebc447b92..2add7976f8 100644
--- a/superset/assets/yarn.lock
+++ b/superset/assets/yarn.lock
@@ -332,6 +332,13 @@
   dependencies:
     lodash "^4.0.8"
 
+"@vx/responsive@0.0.153":
+  version "0.0.153"
+  resolved "https://registry.yarnpkg.com/@vx/responsive/-/responsive-0.0.153.tgz#2ce7e819341d2e59ff4151b40e5792aea460e202"
+  dependencies:
+    lodash "^4.0.8"
+    resize-observer-polyfill "1.5.0"
+
 "@vx/scale@0.0.121":
   version "0.0.121"
   resolved "https://registry.yarnpkg.com/@vx/scale/-/scale-0.0.121.tgz#5f49ea2060469ded0bf0e3ef5a5bb1416b81180e"
@@ -418,6 +425,13 @@
     classnames "^2.2.5"
     prop-types "^15.5.10"
 
+JSONStream@^1.3.2:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.2.tgz#c102371b6ec3a7cf3b847ca00c20bb0fce4c6dea"
+  dependencies:
+    jsonparse "^1.2.0"
+    through ">=2.2.7 <3"
+
 "JSV@>= 4.0.x":
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/JSV/-/JSV-4.0.2.tgz#d077f6825571f82132f9dffaed587b4029feff57"
@@ -426,7 +440,7 @@ abab@^1.0.3:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/abab/-/abab-1.0.4.tgz#5faad9c2c07f60dd76770f71cf025b62a63cfd4e"
 
-abbrev@1, abbrev@^1.0.7:
+abbrev@1, abbrev@^1.0.7, abbrev@~1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
 
@@ -474,6 +488,18 @@ acorn@^5.5.0:
   version "5.5.3"
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.5.3.tgz#f473dd47e0277a08e28e9bec5aeeb04751f0b8c9"
 
+agent-base@4, agent-base@^4.1.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.2.0.tgz#9838b5c3392b962bad031e6a4c5e1024abec45ce"
+  dependencies:
+    es6-promisify "^5.0.0"
+
+agentkeepalive@^3.3.0:
+  version "3.4.1"
+  resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-3.4.1.tgz#aa95aebc3a749bca5ed53e3880a09f5235b48f0c"
+  dependencies:
+    humanize-ms "^1.2.1"
+
 ajv-keywords@^2.0.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-2.1.0.tgz#a296e17f7bfae7c1ce4f7e0de53d29cb32162df0"
@@ -541,7 +567,7 @@ ansi-regex@^2.0.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
 
-ansi-regex@^3.0.0:
+ansi-regex@^3.0.0, ansi-regex@~3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
 
@@ -609,7 +635,7 @@ application-config@~0.1.1:
     application-config-path "^0.1.0"
     mkdirp "^0.5.1"
 
-aproba@^1.0.3, aproba@^1.1.1:
+aproba@^1.0.3, aproba@^1.1.1, aproba@^1.1.2, aproba@~1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
 
@@ -690,7 +716,7 @@ arrify@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
 
-asap@^2.0.0, asap@^2.0.3, asap@~2.0.3, asap@~2.0.5:
+asap@^2.0.0, asap@^2.0.3, asap@^2.0.6, asap@~2.0.3, asap@~2.0.5:
   version "2.0.6"
   resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46"
 
@@ -1431,6 +1457,10 @@ babylon@^6.15.0, babylon@^6.18.0:
   version "6.18.0"
   resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3"
 
+bail@^1.0.0:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/bail/-/bail-1.0.3.tgz#63cfb9ddbac829b02a3128cd53224be78e6c21a3"
+
 balanced-match@^0.4.2:
   version "0.4.2"
   resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838"
@@ -1457,6 +1487,16 @@ big.js@^3.1.3:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.2.0.tgz#a5fc298b81b9e0dca2e458824784b65c52ba588e"
 
+bin-links@^1.1.0:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/bin-links/-/bin-links-1.1.2.tgz#fb74bd54bae6b7befc6c6221f25322ac830d9757"
+  dependencies:
+    bluebird "^3.5.0"
+    cmd-shim "^2.0.2"
+    gentle-fs "^2.0.0"
+    graceful-fs "^4.1.11"
+    write-file-atomic "^2.3.0"
+
 binary-extensions@^1.0.0:
   version "1.10.0"
   resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.10.0.tgz#9aeb9a6c5e88638aad171e167f5900abe24835d0"
@@ -1483,7 +1523,7 @@ bluebird@1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-1.0.3.tgz#c4b441184802e3b64a61eeed4578271b4c8bf6ac"
 
-bluebird@^3.4.3, bluebird@^3.5.0:
+bluebird@^3.4.3, bluebird@^3.5.0, bluebird@^3.5.1, bluebird@~3.5.1:
   version "3.5.1"
   resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9"
 
@@ -1585,6 +1625,10 @@ brace@^0.10.0:
   dependencies:
     w3c-blob "0.0.1"
 
+brace@^0.11.0:
+  version "0.11.1"
+  resolved "https://registry.yarnpkg.com/brace/-/brace-0.11.1.tgz#4896fcc9d544eef45f4bb7660db320d3b379fe58"
+
 braces@^1.8.2:
   version "1.8.5"
   resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7"
@@ -1743,6 +1787,28 @@ builtins@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/builtins/-/builtins-1.0.3.tgz#cb94faeb61c8696451db36534e1422f94f0aee88"
 
+byline@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/byline/-/byline-5.0.0.tgz#741c5216468eadc457b03410118ad77de8c1ddb1"
+
+cacache@^10.0.0, cacache@^10.0.4:
+  version "10.0.4"
+  resolved "https://registry.yarnpkg.com/cacache/-/cacache-10.0.4.tgz#6452367999eff9d4188aefd9a14e9d7c6a263460"
+  dependencies:
+    bluebird "^3.5.1"
+    chownr "^1.0.1"
+    glob "^7.1.2"
+    graceful-fs "^4.1.11"
+    lru-cache "^4.1.1"
+    mississippi "^2.0.0"
+    mkdirp "^0.5.1"
+    move-concurrently "^1.0.1"
+    promise-inflight "^1.0.1"
+    rimraf "^2.6.2"
+    ssri "^5.2.4"
+    unique-filename "^1.1.0"
+    y18n "^4.0.0"
+
 cacache@^10.0.1:
   version "10.0.2"
   resolved "https://registry.yarnpkg.com/cacache/-/cacache-10.0.2.tgz#105a93a162bbedf3a25da42e1939ed99ffb145f8"
@@ -1761,6 +1827,10 @@ cacache@^10.0.1:
     unique-filename "^1.1.0"
     y18n "^3.2.1"
 
+call-limit@~1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/call-limit/-/call-limit-1.1.0.tgz#6fd61b03f3da42a2cd0ec2b60f02bd0e71991fea"
+
 call-matcher@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/call-matcher/-/call-matcher-1.0.1.tgz#5134d077984f712a54dad3cbf62de28dce416ca8"
@@ -1888,6 +1958,18 @@ change-emitter@^0.1.2:
   version "0.1.6"
   resolved "https://registry.yarnpkg.com/change-emitter/-/change-emitter-0.1.6.tgz#e8b2fe3d7f1ab7d69a32199aff91ea6931409515"
 
+character-entities-legacy@^1.0.0:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-1.1.2.tgz#7c6defb81648498222c9855309953d05f4d63a9c"
+
+character-entities@^1.0.0:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-1.2.2.tgz#58c8f371c0774ef0ba9b2aca5f00d8f100e6e363"
+
+character-reference-invalid@^1.0.0:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.2.tgz#21e421ad3d84055952dab4a43a04e73cd425d3ed"
+
 chardet@^0.4.0:
   version "0.4.2"
   resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.4.2.tgz#b5473b33dc97c424e5d98dc87d55d4d8a29c8bf2"
@@ -1940,6 +2022,14 @@ chownr@^1.0.1, chownr@~1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.0.1.tgz#e2a75042a9551908bebd25b8523d5f9769d79181"
 
+ci-info@^1.0.0:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.1.3.tgz#710193264bb05c77b8c90d02f5aaf22216a667b2"
+
+cidr-regex@1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/cidr-regex/-/cidr-regex-1.0.6.tgz#74abfd619df370b9d54ab14475568e97dd64c0c1"
+
 cint@^8.2.1:
   version "8.2.1"
   resolved "https://registry.yarnpkg.com/cint/-/cint-8.2.1.tgz#70386b1b48e2773d0d63166a55aff94ef4456a12"
@@ -1961,7 +2051,7 @@ clap@^1.0.9:
   dependencies:
     chalk "^1.1.3"
 
-classnames@2.x, classnames@^2.1.2, classnames@^2.2.3, classnames@^2.2.4, classnames@^2.2.5:
+classnames@^2.2.3, classnames@^2.2.4, classnames@^2.2.5:
   version "2.2.5"
   resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.5.tgz#fb3801d453467649ef3603c7d61a02bd129bde6d"
 
@@ -1987,6 +2077,15 @@ cli-cursor@^2.1.0:
   dependencies:
     restore-cursor "^2.0.0"
 
+cli-table2@~0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/cli-table2/-/cli-table2-0.2.0.tgz#2d1ef7f218a0e786e214540562d4bd177fe32d97"
+  dependencies:
+    lodash "^3.10.1"
+    string-width "^1.0.1"
+  optionalDependencies:
+    colors "^1.1.2"
+
 cli-table@^0.3.1:
   version "0.3.1"
   resolved "https://registry.yarnpkg.com/cli-table/-/cli-table-0.3.1.tgz#f53b05266a8b1a0b934b3d0821e6e2dc5914ae23"
@@ -2027,6 +2126,14 @@ cliui@^3.2.0:
     strip-ansi "^3.0.1"
     wrap-ansi "^2.0.0"
 
+cliui@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/cliui/-/cliui-4.0.0.tgz#743d4650e05f36d1ed2575b59638d87322bfbbcc"
+  dependencies:
+    string-width "^2.1.1"
+    strip-ansi "^4.0.0"
+    wrap-ansi "^2.0.0"
+
 clone-deep@^0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-0.3.0.tgz#348c61ae9cdbe0edfe053d91ff4cc521d790ede8"
@@ -2044,7 +2151,7 @@ clone@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.1.tgz#d217d1e961118e3ac9a4b8bba3285553bf647cdb"
 
-cmd-shim@~2.0.2:
+cmd-shim@^2.0.2, cmd-shim@~2.0.2:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/cmd-shim/-/cmd-shim-2.0.2.tgz#6fcbda99483a8fd15d7d30a196ca69d688a2efdb"
   dependencies:
@@ -2065,6 +2172,10 @@ code-point-at@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
 
+collapse-white-space@^1.0.2:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/collapse-white-space/-/collapse-white-space-1.0.4.tgz#ce05cf49e54c3277ae573036a26851ba430a0091"
+
 color-convert@^1.3.0, color-convert@^1.9.0:
   version "1.9.0"
   resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.0.tgz#1accf97dd739b983bf994d56fec8f95853641b7a"
@@ -2105,6 +2216,10 @@ colors@1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b"
 
+colors@^1.1.2:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/colors/-/colors-1.2.1.tgz#f4a3d302976aaf042356ba1ade3b1a2c62d9d794"
+
 colors@~1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63"
@@ -2696,15 +2811,15 @@ debug@2.6.8:
   dependencies:
     ms "2.0.0"
 
-debug@^2.1.2, debug@^2.2.0, debug@^2.6.3, debug@^2.6.8:
-  version "2.6.9"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
+debug@3.1.0, debug@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
   dependencies:
     ms "2.0.0"
 
-debug@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
+debug@^2.1.2, debug@^2.2.0, debug@^2.6.3, debug@^2.6.8:
+  version "2.6.9"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
   dependencies:
     ms "2.0.0"
 
@@ -2734,6 +2849,10 @@ deck.gl@^5.1.4:
     seer "^0.2.4"
     viewport-mercator-project "^5.0.0"
 
+decode-uri-component@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
+
 deep-eql@^3.0.0:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-3.0.1.tgz#dfc9404400ad1c8fe023e7da1df1c147c4b444df"
@@ -2816,6 +2935,14 @@ detect-indent@^4.0.0:
   dependencies:
     repeating "^2.0.0"
 
+detect-indent@~5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-5.0.0.tgz#3871cc0a6a002e8c3e5b3cf7f336264675f06b9d"
+
+detect-newline@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2"
+
 dezalgo@^1.0.0, dezalgo@^1.0.1, dezalgo@~1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.3.tgz#7f742de066fc748bc8db820569dddce49bf0d456"
@@ -2839,12 +2966,25 @@ diffie-hellman@^5.0.0:
     miller-rabin "^4.0.0"
     randombytes "^2.0.0"
 
+disposables@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/disposables/-/disposables-1.0.2.tgz#36c6a674475f55a2d6913567a601444e487b4b6e"
+
 distributions@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/distributions/-/distributions-1.0.0.tgz#16466e676df7f311929941d3d7f02010466671a9"
   dependencies:
     mathfn "^1.0.0"
 
+dnd-core@^2.6.0:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-2.6.0.tgz#12bad66d58742c6e5f7cf2943fb6859440f809c4"
+  dependencies:
+    asap "^2.0.6"
+    invariant "^2.0.0"
+    lodash "^4.2.0"
+    redux "^3.7.1"
+
 doctrine@1.5.0:
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa"
@@ -2924,6 +3064,10 @@ dot-prop@^4.1.0:
   dependencies:
     is-obj "^1.0.0"
 
+dotenv@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-5.0.1.tgz#a5317459bd3d79ab88cff6e44057a6a3fbb1fcef"
+
 duplexer2@^0.1.4:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1"
@@ -2980,10 +3124,6 @@ electron-to-chromium@^1.2.7, electron-to-chromium@^1.3.18:
   version "1.3.24"
   resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.24.tgz#9b7b88bb05ceb9fa016a177833cc2dde388f21b6"
 
-element-class@^0.2.0:
-  version "0.2.2"
-  resolved "https://registry.yarnpkg.com/element-class/-/element-class-0.2.2.tgz#9d3bbd0767f9013ef8e1c8ebe722c1402a60050e"
-
 elliptic@^6.0.0:
   version "6.4.0"
   resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.4.0.tgz#cac9af8762c85836187003c8dfe193e5e2eae5df"
@@ -3048,6 +3188,10 @@ enzyme@^2.0.0:
     prop-types "^15.5.10"
     uuid "^3.0.1"
 
+err-code@^1.0.0:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/err-code/-/err-code-1.1.2.tgz#06e0116d3028f6aef4806849eb0ea6a748ae6960"
+
 errno@^0.1.1, errno@^0.1.3:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.4.tgz#b896e23a9e5e8ba33871fc996abd3635fc9a1c7d"
@@ -3060,6 +3204,12 @@ errno@^0.1.4:
   dependencies:
     prr "~1.0.1"
 
+errno@~0.1.7:
+  version "0.1.7"
+  resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618"
+  dependencies:
+    prr "~1.0.1"
+
 error-ex@^1.2.0:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.1.tgz#f855a86ce61adc4e8621c3cda21e7a7612c3a8dc"
@@ -3114,10 +3264,16 @@ es6-promise@^3.0.2, es6-promise@^3.1.2:
   version "3.3.1"
   resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613"
 
-es6-promise@^4.1.1:
+es6-promise@^4.0.3, es6-promise@^4.1.1:
   version "4.2.4"
   resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.4.tgz#dc4221c2b16518760bd8c39a52d8f356fc00ed29"
 
+es6-promisify@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203"
+  dependencies:
+    es6-promise "^4.0.3"
+
 es6-set@~0.1.5:
   version "0.1.5"
   resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.5.tgz#d2b3ec5d4d800ced818db538d28974db0a73ccb1"
@@ -3214,6 +3370,12 @@ eslint-config-airbnb@^15.0.1:
   dependencies:
     eslint-config-airbnb-base "^11.3.0"
 
+eslint-config-prettier@^2.9.0:
+  version "2.9.0"
+  resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-2.9.0.tgz#5ecd65174d486c22dff389fe036febf502d468a3"
+  dependencies:
+    get-stdin "^5.0.1"
+
 eslint-import-resolver-node@^0.3.1:
   version "0.3.1"
   resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.1.tgz#4422574cde66a9a7b099938ee4d508a199e0e3cc"
@@ -3255,6 +3417,13 @@ eslint-plugin-jsx-a11y@^5.1.1:
     emoji-regex "^6.1.0"
     jsx-ast-utils "^1.4.0"
 
+eslint-plugin-prettier@^2.6.0:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-2.6.0.tgz#33e4e228bdb06142d03c560ce04ec23f6c767dd7"
+  dependencies:
+    fast-diff "^1.1.1"
+    jest-docblock "^21.0.0"
+
 eslint-plugin-react@^7.0.1:
   version "7.4.0"
   resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.4.0.tgz#300a95861b9729c087d362dd64abcc351a74364a"
@@ -3422,10 +3591,6 @@ execa@^0.7.0:
     signal-exit "^3.0.0"
     strip-eof "^1.0.0"
 
-exenv@1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.0.tgz#3835f127abf075bfe082d0aed4484057c78e3c89"
-
 exit-hook@^1.0.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8"
@@ -3459,7 +3624,7 @@ extend-shallow@^2.0.1:
   dependencies:
     is-extendable "^0.1.0"
 
-extend@~3.0.0, extend@~3.0.1:
+extend@^3.0.0, extend@~3.0.0, extend@~3.0.1:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444"
 
@@ -3507,7 +3672,7 @@ fast-deep-equal@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz#96256a3bc975595eb36d82e9929d060d893439ff"
 
-fast-diff@^1.0.1:
+fast-diff@^1.0.1, fast-diff@^1.1.1:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.1.2.tgz#4b62c42b8e03de3f848460b639079920695d0154"
 
@@ -3604,6 +3769,10 @@ find-cache-dir@^1.0.0:
     make-dir "^1.0.0"
     pkg-dir "^2.0.0"
 
+find-npm-prefix@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/find-npm-prefix/-/find-npm-prefix-1.0.2.tgz#8d8ce2c78b3b4b9e66c8acc6a37c231eb841cfdf"
+
 find-up@1.1.2, find-up@^1.0.0:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f"
@@ -3718,6 +3887,13 @@ fraction.js@4.0.4:
   version "4.0.4"
   resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.0.4.tgz#04e567110718adf7b52974a10434ab4c67a5183e"
 
+from2@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/from2/-/from2-1.3.0.tgz#88413baaa5f9a597cfde9221d86986cd3c061dfd"
+  dependencies:
+    inherits "~2.0.1"
+    readable-stream "~1.1.10"
+
 from2@^2.1.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/from2/-/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af"
@@ -3735,11 +3911,17 @@ fs-extra@^0.30.0:
     path-is-absolute "^1.0.0"
     rimraf "^2.2.8"
 
+fs-minipass@^1.2.5:
+  version "1.2.5"
+  resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.5.tgz#06c277218454ec288df77ada54a03b8702aacb9d"
+  dependencies:
+    minipass "^2.2.1"
+
 fs-readdir-recursive@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/fs-readdir-recursive/-/fs-readdir-recursive-1.0.0.tgz#8cd1745c8b4f8a29c8caec392476921ba195f560"
 
-fs-vacuum@~1.2.9:
+fs-vacuum@^1.2.10, fs-vacuum@~1.2.10, fs-vacuum@~1.2.9:
   version "1.2.10"
   resolved "https://registry.yarnpkg.com/fs-vacuum/-/fs-vacuum-1.2.10.tgz#b7629bec07a4031a2548fdf99f5ecf1cc8b31e36"
   dependencies:
@@ -3747,7 +3929,7 @@ fs-vacuum@~1.2.9:
     path-is-inside "^1.0.1"
     rimraf "^2.5.2"
 
-fs-write-stream-atomic@^1.0.8, fs-write-stream-atomic@~1.0.8:
+fs-write-stream-atomic@^1.0.8, fs-write-stream-atomic@~1.0.10, fs-write-stream-atomic@~1.0.8:
   version "1.0.10"
   resolved "https://registry.yarnpkg.com/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz#b47df53493ef911df75731e70a9ded0189db40c9"
   dependencies:
@@ -3807,6 +3989,10 @@ functional-red-black-tree@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
 
+fuse.js@^3.0.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.2.0.tgz#f0448e8069855bf2a3e683cdc1d320e7e2a07ef4"
+
 gauge@~2.6.0:
   version "2.6.0"
   resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.6.0.tgz#d35301ad18e96902b4751dcbbe40f4218b942a46"
@@ -3850,6 +4036,23 @@ generic-names@^1.0.1:
   dependencies:
     loader-utils "^0.2.16"
 
+genfun@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/genfun/-/genfun-4.0.1.tgz#ed10041f2e4a7f1b0a38466d17a5c3e27df1dfc1"
+
+gentle-fs@^2.0.0, gentle-fs@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/gentle-fs/-/gentle-fs-2.0.1.tgz#585cfd612bfc5cd52471fdb42537f016a5ce3687"
+  dependencies:
+    aproba "^1.1.2"
+    fs-vacuum "^1.2.10"
+    graceful-fs "^4.1.11"
+    iferr "^0.1.5"
+    mkdirp "^0.5.1"
+    path-is-inside "^1.0.2"
+    read-cmd-shim "^1.0.1"
+    slide "^1.1.6"
+
 geojson-area@0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/geojson-area/-/geojson-area-0.1.0.tgz#d48d807082cfadf4a78df1349be50f38bf1894ae"
@@ -4064,7 +4267,7 @@ glob@7.1.1:
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
-glob@^7.0.3, glob@^7.0.5, glob@^7.1.1, glob@^7.1.2, glob@~7.1.0:
+glob@^7.0.3, glob@^7.0.5, glob@^7.1.1, glob@^7.1.2, glob@~7.1.0, glob@~7.1.2:
   version "7.1.2"
   resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15"
   dependencies:
@@ -4158,7 +4361,7 @@ got@^6.7.1:
     unzip-response "^2.0.1"
     url-parse-lax "^1.0.0"
 
-graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.4, graceful-fs@^4.1.6, graceful-fs@^4.1.9, graceful-fs@~4.1.9:
+graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.4, graceful-fs@^4.1.6, graceful-fs@^4.1.9, graceful-fs@~4.1.11, graceful-fs@~4.1.9:
   version "4.1.11"
   resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658"
 
@@ -4355,6 +4558,10 @@ hoist-non-react-statics@^1.0.0, hoist-non-react-statics@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-1.2.0.tgz#aa448cf0986d55cc40773b17174b7dd066cb7cfb"
 
+hoist-non-react-statics@^2.1.0:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.0.tgz#d2ca2dfc19c5a91c5a6615ce8e564ef0347e2a40"
+
 hoist-non-react-statics@^2.2.1:
   version "2.3.1"
   resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.3.1.tgz#343db84c6018c650778898240135a1420ee22ce0"
@@ -4370,7 +4577,7 @@ hosted-git-info@^2.1.4:
   version "2.5.0"
   resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.5.0.tgz#6d60e34b3abbc8313062c3b798ef8d901a07af3c"
 
-hosted-git-info@^2.1.5, hosted-git-info@^2.4.2:
+hosted-git-info@^2.1.5, hosted-git-info@^2.4.2, hosted-git-info@^2.5.0, hosted-git-info@^2.6.0:
   version "2.6.0"
   resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.6.0.tgz#23235b29ab230c576aab0d4f13fc046b0b038222"
 
@@ -4407,6 +4614,17 @@ htmlparser2@^3.9.1:
     inherits "^2.0.1"
     readable-stream "^2.0.2"
 
+http-cache-semantics@^3.8.0:
+  version "3.8.1"
+  resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-3.8.1.tgz#39b0e16add9b605bf0a9ef3d9daaf4843b4cacd2"
+
+http-proxy-agent@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-2.1.0.tgz#e4821beef5b2142a2026bd73926fe537631c5405"
+  dependencies:
+    agent-base "4"
+    debug "3.1.0"
+
 http-signature@~0.10.0:
   version "0.10.1"
   resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-0.10.1.tgz#4fbdac132559aa8323121e540779c0a012b27e66"
@@ -4435,6 +4653,19 @@ https-browserify@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-0.0.1.tgz#3f91365cabe60b77ed0ebba24b454e3e09d95a82"
 
+https-proxy-agent@^2.1.0:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.1.tgz#51552970fa04d723e04c56d04178c3f92592bbc0"
+  dependencies:
+    agent-base "^4.1.0"
+    debug "^3.1.0"
+
+humanize-ms@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed"
+  dependencies:
+    ms "^2.0.0"
+
 hyperquest@~1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/hyperquest/-/hyperquest-1.2.0.tgz#39e1fef66888dc7ce0dec6c0dd814f6fc8944ad5"
@@ -4486,6 +4717,12 @@ ignore-styles@^5.0.1:
   version "5.0.1"
   resolved "https://registry.yarnpkg.com/ignore-styles/-/ignore-styles-5.0.1.tgz#b49ef2274bdafcd8a4880a966bfe38d1a0bf4671"
 
+ignore-walk@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8"
+  dependencies:
+    minimatch "^3.0.4"
+
 ignore@^3.3.3:
   version "3.3.7"
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.7.tgz#612289bfb3c220e186a58118618d5be8c1bab021"
@@ -4525,7 +4762,7 @@ infinity-agent@^2.0.0:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/infinity-agent/-/infinity-agent-2.0.3.tgz#45e0e2ff7a9eb030b27d62b74b3744b7a7ac4216"
 
-inflight@^1.0.4, inflight@~1.0.5:
+inflight@^1.0.4, inflight@~1.0.5, inflight@~1.0.6:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
   dependencies:
@@ -4540,7 +4777,7 @@ inherits@2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1"
 
-ini@1.x.x, ini@^1.3.4, ini@~1.3.4:
+ini@1.x.x, ini@^1.3.4, ini@^1.3.5, ini@~1.3.4:
   version "1.3.5"
   resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
 
@@ -4548,6 +4785,19 @@ ini@~1.3.0:
   version "1.3.4"
   resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.4.tgz#0537cb79daf59b59a1a517dff706c86ec039162e"
 
+init-package-json@^1.10.3:
+  version "1.10.3"
+  resolved "https://registry.yarnpkg.com/init-package-json/-/init-package-json-1.10.3.tgz#45ffe2f610a8ca134f2bd1db5637b235070f6cbe"
+  dependencies:
+    glob "^7.1.1"
+    npm-package-arg "^4.0.0 || ^5.0.0 || ^6.0.0"
+    promzard "^0.3.0"
+    read "~1.0.1"
+    read-package-json "1 || 2"
+    semver "2.x || 3.x || 4 || 5"
+    validate-npm-package-license "^3.0.1"
+    validate-npm-package-name "^3.0.0"
+
 init-package-json@~1.9.4:
   version "1.9.6"
   resolved "https://registry.yarnpkg.com/init-package-json/-/init-package-json-1.9.6.tgz#789fc2b74466a4952b9ea77c0575bc78ebd60a61"
@@ -4619,10 +4869,25 @@ invert-kv@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6"
 
+ip@^1.1.4:
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a"
+
 is-absolute-url@^2.0.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6"
 
+is-alphabetical@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-1.0.2.tgz#1fa6e49213cb7885b75d15862fb3f3d96c884f41"
+
+is-alphanumerical@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/is-alphanumerical/-/is-alphanumerical-1.0.2.tgz#1138e9ae5040158dc6ff76b820acd6b7a181fd40"
+  dependencies:
+    is-alphabetical "^1.0.0"
+    is-decimal "^1.0.0"
+
 is-arrayish@^0.2.1:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
@@ -4633,7 +4898,7 @@ is-binary-path@^1.0.0:
   dependencies:
     binary-extensions "^1.0.0"
 
-is-buffer@^1.0.2:
+is-buffer@^1.0.2, is-buffer@^1.1.4:
   version "1.1.6"
   resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
 
@@ -4651,10 +4916,26 @@ is-callable@^1.1.1, is-callable@^1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.3.tgz#86eb75392805ddc33af71c92a0eedf74ee7604b2"
 
+is-ci@^1.0.10:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-1.1.0.tgz#247e4162e7860cebbdaf30b774d6b0ac7dcfe7a5"
+  dependencies:
+    ci-info "^1.0.0"
+
+is-cidr@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-cidr/-/is-cidr-1.0.0.tgz#fb5aacf659255310359da32cae03e40c6a1c2afc"
+  dependencies:
+    cidr-regex "1.0.6"
+
 is-date-object@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.1.tgz#9aa20eb6aeebbff77fbd33e74ca01b33581d3a16"
 
+is-decimal@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-1.0.2.tgz#894662d6a8709d307f3a276ca4339c8fa5dff0ff"
+
 is-dotfile@^1.0.0:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1"
@@ -4695,6 +4976,10 @@ is-glob@^2.0.0, is-glob@^2.0.1:
   dependencies:
     is-extglob "^1.0.0"
 
+is-hexadecimal@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.2.tgz#b6e710d7d07bb66b98cb8cece5c9b4921deeb835"
+
 is-installed-globally@^0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.1.0.tgz#0dfd98f5a9111716dd535dda6492f67bf3d25a80"
@@ -4751,7 +5036,7 @@ is-path-inside@^1.0.0:
   dependencies:
     path-is-inside "^1.0.1"
 
-is-plain-obj@^1.0.0:
+is-plain-obj@^1.0.0, is-plain-obj@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e"
 
@@ -4827,6 +5112,14 @@ is-utf8@^0.2.0:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72"
 
+is-whitespace-character@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/is-whitespace-character/-/is-whitespace-character-1.0.2.tgz#ede53b4c6f6fb3874533751ec9280d01928d03ed"
+
+is-word-character@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/is-word-character/-/is-word-character-1.0.2.tgz#46a5dac3f2a1840898b91e576cd40d493f3ae553"
+
 is@~0.2.6:
   version "0.2.7"
   resolved "https://registry.yarnpkg.com/is/-/is-0.2.7.tgz#3b34a2c48f359972f35042849193ae7264b63562"
@@ -4948,6 +5241,10 @@ jed@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/jed/-/jed-1.1.1.tgz#7a549bbd9ffe1585b0cd0a191e203055bee574b4"
 
+jest-docblock@^21.0.0:
+  version "21.2.0"
+  resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-21.2.0.tgz#51529c3b30d5fd159da60c27ceedc195faf8d414"
+
 jju@^1.1.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/jju/-/jju-1.3.0.tgz#dadd9ef01924bc728b03f2f7979bdbd62f7a2aaa"
@@ -5033,6 +5330,10 @@ json-loader@^0.5.4:
   version "0.5.7"
   resolved "https://registry.yarnpkg.com/json-loader/-/json-loader-0.5.7.tgz#dca14a70235ff82f0ac9a3abeb60d337a365185d"
 
+json-parse-better-errors@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
+
 json-parse-better-errors@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.1.tgz#50183cd1b2d25275de069e9e71b467ac9eab973a"
@@ -5094,6 +5395,10 @@ jsonlint-lines-primitives@~1.6.0:
     JSV ">= 4.0.x"
     nomnom ">= 1.5.x"
 
+jsonparse@^1.2.0:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
+
 jsonpointer@^4.0.0:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-4.0.1.tgz#4fd92cb34e0e9db3c89c8622ecf51f9b978c6cb9"
@@ -5183,6 +5488,10 @@ lazy-cache@^1.0.3:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e"
 
+lazy-property@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/lazy-property/-/lazy-property-1.0.0.tgz#84ddc4b370679ba8bd4cdcfa4c06b43d57111147"
+
 lcid@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835"
@@ -5217,6 +5526,37 @@ levn@^0.3.0, levn@~0.3.0:
     prelude-ls "~1.1.2"
     type-check "~0.3.2"
 
+libcipm@^1.6.0:
+  version "1.6.2"
+  resolved "https://registry.yarnpkg.com/libcipm/-/libcipm-1.6.2.tgz#5a9d83b8606b9733cfff016ad9b37d3b8198ae09"
+  dependencies:
+    bin-links "^1.1.0"
+    bluebird "^3.5.1"
+    find-npm-prefix "^1.0.2"
+    graceful-fs "^4.1.11"
+    lock-verify "^2.0.0"
+    npm-lifecycle "^2.0.0"
+    npm-logical-tree "^1.2.1"
+    npm-package-arg "^6.0.0"
+    pacote "^7.5.1"
+    protoduck "^5.0.0"
+    read-package-json "^2.0.12"
+    rimraf "^2.6.2"
+    worker-farm "^1.5.4"
+
+libnpx@^10.0.1:
+  version "10.2.0"
+  resolved "https://registry.yarnpkg.com/libnpx/-/libnpx-10.2.0.tgz#1bf4a1c9f36081f64935eb014041da10855e3102"
+  dependencies:
+    dotenv "^5.0.1"
+    npm-package-arg "^6.0.0"
+    rimraf "^2.6.2"
+    safe-buffer "^5.1.0"
+    update-notifier "^2.3.0"
+    which "^1.3.0"
+    y18n "^4.0.0"
+    yargs "^11.0.0"
+
 load-json-file@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0"
@@ -5264,10 +5604,23 @@ locate-path@^2.0.0:
     p-locate "^2.0.0"
     path-exists "^3.0.0"
 
+lock-verify@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/lock-verify/-/lock-verify-2.0.1.tgz#6d671eea60b459c6048b3b26b62959208be67682"
+  dependencies:
+    npm-package-arg "^5.1.2"
+    semver "^5.4.1"
+
 lockfile@~1.0.2:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/lockfile/-/lockfile-1.0.3.tgz#2638fc39a0331e9cac1a04b71799931c9c50df79"
 
+lockfile@~1.0.3:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/lockfile/-/lockfile-1.0.4.tgz#07f819d25ae48f87e538e6578b6964a4981a5609"
+  dependencies:
+    signal-exit "^3.0.2"
+
 lodash-es@^4.2.0, lodash-es@^4.2.1:
   version "4.17.4"
   resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.4.tgz#dcc1d7552e150a0640073ba9cb31d70f032950e7"
@@ -5310,7 +5663,7 @@ lodash._root@~3.0.0:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/lodash._root/-/lodash._root-3.0.1.tgz#fba1c4524c19ee9a5f8136b4609f017cf4ded692"
 
-lodash.assign@^4.0.3, lodash.assign@^4.0.6, lodash.assign@^4.2.0:
+lodash.assign@^4.0.3, lodash.assign@^4.0.6:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-4.2.0.tgz#0d99f3ccd7a6d261d19bdaeb9245005d285808e7"
 
@@ -5378,7 +5731,7 @@ lodash.isarray@^3.0.0:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55"
 
-lodash.isequal@^4.0.0, lodash.isequal@^4.1.1:
+lodash.isequal@^4.1.1:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
 
@@ -5442,7 +5795,7 @@ lodash@2.4.1:
   version "2.4.1"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-2.4.1.tgz#5b7723034dda4d262e5a46fb2c58d7cc22f71420"
 
-lodash@3.x:
+lodash@3.x, lodash@^3.10.1:
   version "3.10.1"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6"
 
@@ -5482,7 +5835,7 @@ lowlight@~1.9.1:
   dependencies:
     highlight.js "~9.12.0"
 
-lru-cache@^4.0.0:
+lru-cache@^4.0.0, lru-cache@~4.1.1:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.2.tgz#45234b2e6e2f2b33da125624c4664929a0224c3f"
   dependencies:
@@ -5520,6 +5873,22 @@ make-dir@^1.0.0:
   dependencies:
     pify "^2.3.0"
 
+make-fetch-happen@^2.5.0, make-fetch-happen@^2.6.0:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-2.6.0.tgz#8474aa52198f6b1ae4f3094c04e8370d35ea8a38"
+  dependencies:
+    agentkeepalive "^3.3.0"
+    cacache "^10.0.0"
+    http-cache-semantics "^3.8.0"
+    http-proxy-agent "^2.0.0"
+    https-proxy-agent "^2.1.0"
+    lru-cache "^4.1.1"
+    mississippi "^1.2.0"
+    node-fetch-npm "^2.0.2"
+    promise-retry "^1.1.1"
+    socks-proxy-agent "^3.0.1"
+    ssri "^5.0.0"
+
 mapbox-gl-supported@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/mapbox-gl-supported/-/mapbox-gl-supported-1.2.0.tgz#cbd34df894206cadda9a33c8d9a4609f26bb1989"
@@ -5588,6 +5957,10 @@ mapbox-gl@^0.44.2:
     vt-pbf "^3.0.1"
     webworkify "^1.5.0"
 
+markdown-escapes@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/markdown-escapes/-/markdown-escapes-1.0.2.tgz#e639cbde7b99c841c0bacc8a07982873b46d2122"
+
 material-colors@^1.2.1:
   version "1.2.5"
   resolved "https://registry.yarnpkg.com/material-colors/-/material-colors-1.2.5.tgz#5292593e6754cb1bcc2b98030e4e0d6a3afc9ea1"
@@ -5638,6 +6011,10 @@ md5@^2.1.0:
     crypt "~0.0.1"
     is-buffer "~1.1.1"
 
+meant@~1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/meant/-/meant-1.0.1.tgz#66044fea2f23230ec806fb515efea29c44d2115d"
+
 mem@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/mem/-/mem-1.1.0.tgz#5edd52b485ca1d900fe64895505399a0dfa45f76"
@@ -5742,6 +6119,34 @@ minimist@~0.0.1:
   version "0.0.10"
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf"
 
+minipass@^2.2.1, minipass@^2.2.4:
+  version "2.2.4"
+  resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.2.4.tgz#03c824d84551ec38a8d1bb5bc350a5a30a354a40"
+  dependencies:
+    safe-buffer "^5.1.1"
+    yallist "^3.0.0"
+
+minizlib@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.1.0.tgz#11e13658ce46bc3a70a267aac58359d1e0c29ceb"
+  dependencies:
+    minipass "^2.2.1"
+
+mississippi@^1.2.0:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-1.3.1.tgz#2a8bb465e86550ac8b36a7b6f45599171d78671e"
+  dependencies:
+    concat-stream "^1.5.0"
+    duplexify "^3.4.2"
+    end-of-stream "^1.1.0"
+    flush-write-stream "^1.0.0"
+    from2 "^2.1.0"
+    parallel-transform "^1.1.0"
+    pump "^1.0.0"
+    pumpify "^1.3.3"
+    stream-each "^1.1.0"
+    through2 "^2.0.0"
+
 mississippi@^1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-1.3.0.tgz#d201583eb12327e3c5c1642a404a9cacf94e34f5"
@@ -5757,6 +6162,36 @@ mississippi@^1.3.0:
     stream-each "^1.1.0"
     through2 "^2.0.0"
 
+mississippi@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-2.0.0.tgz#3442a508fafc28500486feea99409676e4ee5a6f"
+  dependencies:
+    concat-stream "^1.5.0"
+    duplexify "^3.4.2"
+    end-of-stream "^1.1.0"
+    flush-write-stream "^1.0.0"
+    from2 "^2.1.0"
+    parallel-transform "^1.1.0"
+    pump "^2.0.1"
+    pumpify "^1.3.3"
+    stream-each "^1.1.0"
+    through2 "^2.0.0"
+
+mississippi@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-3.0.0.tgz#ea0a3291f97e0b5e8776b363d5f0a12d94c67022"
+  dependencies:
+    concat-stream "^1.5.0"
+    duplexify "^3.4.2"
+    end-of-stream "^1.1.0"
+    flush-write-stream "^1.0.0"
+    from2 "^2.1.0"
+    parallel-transform "^1.1.0"
+    pump "^3.0.0"
+    pumpify "^1.3.3"
+    stream-each "^1.1.0"
+    through2 "^2.0.0"
+
 mixin-object@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/mixin-object/-/mixin-object-2.0.1.tgz#4fb949441dab182540f1fe035ba60e1947a5e57e"
@@ -5826,6 +6261,10 @@ ms@2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
 
+ms@^2.0.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a"
+
 multi-glob@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/multi-glob/-/multi-glob-1.0.1.tgz#e67d2ab4429d27606e6eb4db35094afc91788750"
@@ -5907,6 +6346,14 @@ node-alias@^1.0.4:
     chalk "^1.1.1"
     lodash "^4.2.0"
 
+node-fetch-npm@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/node-fetch-npm/-/node-fetch-npm-2.0.2.tgz#7258c9046182dca345b4208eda918daf33697ff7"
+  dependencies:
+    encoding "^0.1.11"
+    json-parse-better-errors "^1.0.0"
+    safe-buffer "^5.1.1"
+
 node-fetch@^1.0.1:
   version "1.7.3"
   resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef"
@@ -5914,9 +6361,9 @@ node-fetch@^1.0.1:
     encoding "^0.1.11"
     is-stream "^1.0.1"
 
-node-gyp@~3.4.0:
-  version "3.4.0"
-  resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.4.0.tgz#dda558393b3ecbbe24c9e6b8703c71194c63fa36"
+node-gyp@^3.6.2:
+  version "3.6.2"
+  resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.6.2.tgz#9bfbe54562286284838e750eac05295853fa1c60"
   dependencies:
     fstream "^1.0.0"
     glob "^7.0.3"
@@ -5924,12 +6371,30 @@ node-gyp@~3.4.0:
     minimatch "^3.0.2"
     mkdirp "^0.5.0"
     nopt "2 || 3"
-    npmlog "0 || 1 || 2 || 3"
+    npmlog "0 || 1 || 2 || 3 || 4"
     osenv "0"
-    path-array "^1.0.0"
     request "2"
     rimraf "2"
-    semver "2.x || 3.x || 4 || 5"
+    semver "~5.3.0"
+    tar "^2.0.0"
+    which "1"
+
+node-gyp@~3.4.0:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.4.0.tgz#dda558393b3ecbbe24c9e6b8703c71194c63fa36"
+  dependencies:
+    fstream "^1.0.0"
+    glob "^7.0.3"
+    graceful-fs "^4.1.2"
+    minimatch "^3.0.2"
+    mkdirp "^0.5.0"
+    nopt "2 || 3"
+    npmlog "0 || 1 || 2 || 3"
+    osenv "0"
+    path-array "^1.0.0"
+    request "2"
+    rimraf "2"
+    semver "2.x || 3.x || 4 || 5"
     tar "^2.0.0"
     which "1"
 
@@ -6004,7 +6469,7 @@ nomnom@1.8.1, "nomnom@>= 1.5.x":
   dependencies:
     abbrev "1"
 
-nopt@^4.0.1:
+nopt@^4.0.1, nopt@~4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d"
   dependencies:
@@ -6015,7 +6480,7 @@ normalize-git-url@~3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/normalize-git-url/-/normalize-git-url-3.0.2.tgz#8e5f14be0bdaedb73e07200310aa416c27350fc4"
 
-normalize-package-data@^2.0.0, normalize-package-data@^2.3.2, "normalize-package-data@~1.0.1 || ^2.0.0":
+normalize-package-data@^2.0.0, normalize-package-data@^2.3.2, normalize-package-data@^2.4.0, "normalize-package-data@~1.0.1 || ^2.0.0", normalize-package-data@~2.4.0:
   version "2.4.0"
   resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.4.0.tgz#12f95a307d58352075a04907b84ac8be98ac012f"
   dependencies:
@@ -6052,6 +6517,10 @@ normalize-url@^1.4.0:
     query-string "^4.1.0"
     sort-keys "^1.0.0"
 
+npm-bundled@^1.0.1:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.3.tgz#7e71703d973af3370a9591bafe3a63aca0be2308"
+
 npm-cache-filename@~1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/npm-cache-filename/-/npm-cache-filename-1.0.2.tgz#ded306c5b0bfc870a9e9faf823bc5f283e05ae11"
@@ -6086,6 +6555,23 @@ npm-install-checks@~3.0.0:
   dependencies:
     semver "^2.3.0 || 3.x || 4 || 5"
 
+npm-lifecycle@^2.0.0, npm-lifecycle@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/npm-lifecycle/-/npm-lifecycle-2.0.1.tgz#897313f05ed24db8e28d99fa8b42c31b625e6237"
+  dependencies:
+    byline "^5.0.0"
+    graceful-fs "^4.1.11"
+    node-gyp "^3.6.2"
+    resolve-from "^4.0.0"
+    slide "^1.1.6"
+    uid-number "0.0.6"
+    umask "^1.1.0"
+    which "^1.3.0"
+
+npm-logical-tree@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/npm-logical-tree/-/npm-logical-tree-1.2.1.tgz#44610141ca24664cad35d1e607176193fd8f5b88"
+
 "npm-package-arg@^3.0.0 || ^4.0.0", npm-package-arg@^4.1.1, npm-package-arg@~4.2.0:
   version "4.2.1"
   resolved "https://registry.yarnpkg.com/npm-package-arg/-/npm-package-arg-4.2.1.tgz#593303fdea85f7c422775f17f9eb7670f680e3ec"
@@ -6093,7 +6579,16 @@ npm-install-checks@~3.0.0:
     hosted-git-info "^2.1.5"
     semver "^5.1.0"
 
-"npm-package-arg@^4.0.0 || ^5.0.0":
+"npm-package-arg@^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", "npm-package-arg@^4.0.0 || ^5.0.0 || ^6.0.0", npm-package-arg@^6.0.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/npm-package-arg/-/npm-package-arg-6.1.0.tgz#15ae1e2758a5027efb4c250554b85a737db7fcc1"
+  dependencies:
+    hosted-git-info "^2.6.0"
+    osenv "^0.1.5"
+    semver "^5.5.0"
+    validate-npm-package-name "^3.0.0"
+
+"npm-package-arg@^4.0.0 || ^5.0.0", npm-package-arg@^5.1.2:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/npm-package-arg/-/npm-package-arg-5.1.2.tgz#fb18d17bb61e60900d6312619919bd753755ab37"
   dependencies:
@@ -6102,6 +6597,54 @@ npm-install-checks@~3.0.0:
     semver "^5.1.0"
     validate-npm-package-name "^3.0.0"
 
+npm-package-arg@~6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/npm-package-arg/-/npm-package-arg-6.0.0.tgz#8cce04b49d3f9faec3f56b0fe5f4391aeb9d2fac"
+  dependencies:
+    hosted-git-info "^2.5.0"
+    osenv "^0.1.4"
+    semver "^5.4.1"
+    validate-npm-package-name "^3.0.0"
+
+npm-packlist@^1.1.10, npm-packlist@~1.1.10:
+  version "1.1.10"
+  resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.1.10.tgz#1039db9e985727e464df066f4cf0ab6ef85c398a"
+  dependencies:
+    ignore-walk "^3.0.1"
+    npm-bundled "^1.0.1"
+
+npm-pick-manifest@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/npm-pick-manifest/-/npm-pick-manifest-2.1.0.tgz#dc381bdd670c35d81655e1d5a94aa3dd4d87fce5"
+  dependencies:
+    npm-package-arg "^6.0.0"
+    semver "^5.4.1"
+
+npm-profile@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/npm-profile/-/npm-profile-3.0.1.tgz#65a1018340f14399a086b5d0a9bd0d13145d8e57"
+  dependencies:
+    aproba "^1.1.2"
+    make-fetch-happen "^2.5.0"
+
+npm-registry-client@^8.5.1:
+  version "8.5.1"
+  resolved "https://registry.yarnpkg.com/npm-registry-client/-/npm-registry-client-8.5.1.tgz#8115809c0a4b40938b8a109b8ea74d26c6f5d7f1"
+  dependencies:
+    concat-stream "^1.5.2"
+    graceful-fs "^4.1.6"
+    normalize-package-data "~1.0.1 || ^2.0.0"
+    npm-package-arg "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0"
+    once "^1.3.3"
+    request "^2.74.0"
+    retry "^0.10.0"
+    safe-buffer "^5.1.1"
+    semver "2 >=2.2.1 || 3.x || 4 || 5"
+    slide "^1.1.3"
+    ssri "^5.2.4"
+  optionalDependencies:
+    npmlog "2 || ^3.1.0 || ^4.0.0"
+
 npm-registry-client@~7.2.1:
   version "7.2.1"
   resolved "https://registry.yarnpkg.com/npm-registry-client/-/npm-registry-client-7.2.1.tgz#c792266b088cc313f8525e7e35248626c723db75"
@@ -6128,6 +6671,10 @@ npm-user-validate@~0.1.5:
   version "0.1.5"
   resolved "https://registry.yarnpkg.com/npm-user-validate/-/npm-user-validate-0.1.5.tgz#52465d50c2d20294a57125b996baedbf56c5004b"
 
+npm-user-validate@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/npm-user-validate/-/npm-user-validate-1.0.0.tgz#8ceca0f5cea04d4e93519ef72d0557a75122e951"
+
 npm@^3, npm@^3.10.6:
   version "3.10.10"
   resolved "https://registry.yarnpkg.com/npm/-/npm-3.10.10.tgz#5b1d577e4c8869d6c8603bc89e9cd1637303e46e"
@@ -6204,6 +6751,110 @@ npm@^3, npm@^3.10.6:
     wrappy "~1.0.2"
     write-file-atomic "~1.2.0"
 
+npm@^5.7.1:
+  version "5.8.0"
+  resolved "https://registry.yarnpkg.com/npm/-/npm-5.8.0.tgz#5e4bfb8c2e7ada01dd41ec0555d13dd0f446ddb2"
+  dependencies:
+    JSONStream "^1.3.2"
+    abbrev "~1.1.1"
+    ansi-regex "~3.0.0"
+    ansicolors "~0.3.2"
+    ansistyles "~0.1.3"
+    aproba "~1.2.0"
+    archy "~1.0.0"
+    bin-links "^1.1.0"
+    bluebird "~3.5.1"
+    cacache "^10.0.4"
+    call-limit "~1.1.0"
+    chownr "~1.0.1"
+    cli-table2 "~0.2.0"
+    cmd-shim "~2.0.2"
+    columnify "~1.5.4"
+    config-chain "~1.1.11"
+    detect-indent "~5.0.0"
+    detect-newline "^2.1.0"
+    dezalgo "~1.0.3"
+    editor "~1.0.0"
+    find-npm-prefix "^1.0.2"
+    fs-vacuum "~1.2.10"
+    fs-write-stream-atomic "~1.0.10"
+    gentle-fs "^2.0.1"
+    glob "~7.1.2"
+    graceful-fs "~4.1.11"
+    has-unicode "~2.0.1"
+    hosted-git-info "^2.6.0"
+    iferr "~0.1.5"
+    inflight "~1.0.6"
+    inherits "~2.0.3"
+    ini "^1.3.5"
+    init-package-json "^1.10.3"
+    is-cidr "~1.0.0"
+    json-parse-better-errors "^1.0.1"
+    lazy-property "~1.0.0"
+    libcipm "^1.6.0"
+    libnpx "^10.0.1"
+    lockfile "~1.0.3"
+    lodash._baseuniq "~4.6.0"
+    lodash.clonedeep "~4.5.0"
+    lodash.union "~4.6.0"
+    lodash.uniq "~4.5.0"
+    lodash.without "~4.4.0"
+    lru-cache "~4.1.1"
+    meant "~1.0.1"
+    mississippi "^3.0.0"
+    mkdirp "~0.5.1"
+    move-concurrently "^1.0.1"
+    nopt "~4.0.1"
+    normalize-package-data "~2.4.0"
+    npm-cache-filename "~1.0.2"
+    npm-install-checks "~3.0.0"
+    npm-lifecycle "^2.0.1"
+    npm-package-arg "~6.0.0"
+    npm-packlist "~1.1.10"
+    npm-profile "^3.0.1"
+    npm-registry-client "^8.5.1"
+    npm-user-validate "~1.0.0"
+    npmlog "~4.1.2"
+    once "~1.4.0"
+    opener "~1.4.3"
+    osenv "^0.1.5"
+    pacote "^7.6.1"
+    path-is-inside "~1.0.2"
+    promise-inflight "~1.0.1"
+    qrcode-terminal "~0.11.0"
+    query-string "^5.1.0"
+    qw "~1.0.1"
+    read "~1.0.7"
+    read-cmd-shim "~1.0.1"
+    read-installed "~4.0.3"
+    read-package-json "^2.0.13"
+    read-package-tree "~5.1.6"
+    readable-stream "^2.3.5"
+    request "~2.83.0"
+    retry "~0.10.1"
+    rimraf "~2.6.2"
+    safe-buffer "~5.1.1"
+    semver "^5.5.0"
+    sha "~2.0.1"
+    slide "~1.1.6"
+    sorted-object "~2.0.1"
+    sorted-union-stream "~2.1.3"
+    ssri "^5.2.4"
+    strip-ansi "~4.0.0"
+    tar "^4.4.0"
+    text-table "~0.2.0"
+    uid-number "0.0.6"
+    umask "~1.1.0"
+    unique-filename "~1.1.0"
+    unpipe "~1.0.0"
+    update-notifier "~2.3.0"
+    uuid "^3.2.1"
+    validate-npm-package-name "~3.0.0"
+    which "~1.3.0"
+    worker-farm "^1.5.4"
+    wrappy "~1.0.2"
+    write-file-atomic "^2.3.0"
+
 npmi@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/npmi/-/npmi-2.0.1.tgz#32607657e1bd47ca857ab4e9d98f0a0cff96bcea"
@@ -6220,7 +6871,7 @@ npmi@^2.0.1:
     gauge "~2.6.0"
     set-blocking "~2.0.0"
 
-npmlog@^4.0.2:
+"npmlog@0 || 1 || 2 || 3 || 4", "npmlog@2 || ^3.1.0 || ^4.0.0", npmlog@^4.0.2, npmlog@~4.1.2:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b"
   dependencies:
@@ -6357,7 +7008,7 @@ open@^0.0.5:
   version "0.0.5"
   resolved "https://registry.yarnpkg.com/open/-/open-0.0.5.tgz#42c3e18ec95466b6bf0dc42f3a2945c3f0cad8fc"
 
-opener@~1.4.2:
+opener@~1.4.2, opener@~1.4.3:
   version "1.4.3"
   resolved "https://registry.yarnpkg.com/opener/-/opener-1.4.3.tgz#5c6da2c5d7e5831e8ffa3964950f8d6674ac90b8"
 
@@ -6418,7 +7069,7 @@ os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
 
-osenv@0, osenv@^0.1.0, osenv@~0.1.3:
+osenv@0, osenv@^0.1.0, osenv@^0.1.5, osenv@~0.1.3:
   version "0.1.5"
   resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410"
   dependencies:
@@ -6491,6 +7142,35 @@ package-json@^4.0.0:
     registry-url "^3.0.3"
     semver "^5.1.0"
 
+pacote@^7.5.1, pacote@^7.6.1:
+  version "7.6.1"
+  resolved "https://registry.yarnpkg.com/pacote/-/pacote-7.6.1.tgz#d44621c89a5a61f173989b60236757728387c094"
+  dependencies:
+    bluebird "^3.5.1"
+    cacache "^10.0.4"
+    get-stream "^3.0.0"
+    glob "^7.1.2"
+    lru-cache "^4.1.1"
+    make-fetch-happen "^2.6.0"
+    minimatch "^3.0.4"
+    mississippi "^3.0.0"
+    mkdirp "^0.5.1"
+    normalize-package-data "^2.4.0"
+    npm-package-arg "^6.0.0"
+    npm-packlist "^1.1.10"
+    npm-pick-manifest "^2.1.0"
+    osenv "^0.1.5"
+    promise-inflight "^1.0.1"
+    promise-retry "^1.1.1"
+    protoduck "^5.0.0"
+    rimraf "^2.6.2"
+    safe-buffer "^5.1.1"
+    semver "^5.5.0"
+    ssri "^5.2.4"
+    tar "^4.4.0"
+    unique-filename "^1.1.0"
+    which "^1.3.0"
+
 pako@~0.2.0:
   version "0.2.9"
   resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75"
@@ -6513,6 +7193,17 @@ parse-asn1@^5.0.0:
     evp_bytestokey "^1.0.0"
     pbkdf2 "^3.0.3"
 
+parse-entities@^1.1.0:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-1.1.2.tgz#9eaf719b29dc3bd62246b4332009072e01527777"
+  dependencies:
+    character-entities "^1.0.0"
+    character-entities-legacy "^1.0.0"
+    character-reference-invalid "^1.0.0"
+    is-alphanumerical "^1.0.0"
+    is-decimal "^1.0.0"
+    is-hexadecimal "^1.0.0"
+
 parse-glob@^3.0.4:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c"
@@ -6941,6 +7632,10 @@ preserve@^0.2.0:
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b"
 
+prettier@^1.12.1:
+  version "1.12.1"
+  resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.12.1.tgz#c1ad20e803e7749faf905a409d2367e06bbe7325"
+
 private@^0.1.6, private@^0.1.7:
   version "0.1.7"
   resolved "https://registry.yarnpkg.com/private/-/private-0.1.7.tgz#68ce5e8a1ef0a23bb570cc28537b5332aba63ef1"
@@ -6949,6 +7644,10 @@ process-nextick-args@~1.0.6:
   version "1.0.7"
   resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-1.0.7.tgz#150e20b756590ad3f91093f25a4f2ad8bff30ba3"
 
+process-nextick-args@~2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz#a37d732f4271b4ab1ad070d35508e8290788ffaa"
+
 process@^0.11.0:
   version "0.11.10"
   resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
@@ -6957,10 +7656,17 @@ progress@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.0.tgz#8a1be366bf8fc23db2bd23f10c6fe920b4389d1f"
 
-promise-inflight@^1.0.1:
+promise-inflight@^1.0.1, promise-inflight@~1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3"
 
+promise-retry@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/promise-retry/-/promise-retry-1.1.1.tgz#6739e968e3051da20ce6497fb2b50f6911df3d6d"
+  dependencies:
+    err-code "^1.0.0"
+    retry "^0.10.0"
+
 "promise@>=3.2 <8", promise@^7.1.1:
   version "7.3.1"
   resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf"
@@ -6994,6 +7700,14 @@ prop-types@15.x, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.6, pr
     loose-envify "^1.3.1"
     object-assign "^4.1.1"
 
+prop-types@^15.6.1:
+  version "15.6.1"
+  resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.1.tgz#36644453564255ddda391191fb3a125cbdf654ca"
+  dependencies:
+    fbjs "^0.8.16"
+    loose-envify "^1.3.1"
+    object-assign "^4.1.1"
+
 proto-list@~1.2.1:
   version "1.2.4"
   resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849"
@@ -7002,6 +7716,12 @@ protocol-buffers-schema@^2.0.2:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/protocol-buffers-schema/-/protocol-buffers-schema-2.2.0.tgz#d29c6cd73fb655978fb6989691180db844119f61"
 
+protoduck@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/protoduck/-/protoduck-5.0.0.tgz#752145e6be0ad834cb25716f670a713c860dce70"
+  dependencies:
+    genfun "^4.0.1"
+
 proxy-from-env@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.0.0.tgz#33c50398f70ea7eb96d21f7b817630a55791c7ee"
@@ -7035,13 +7755,20 @@ pump@^1.0.0:
     end-of-stream "^1.1.0"
     once "^1.3.1"
 
-pump@^2.0.0:
+pump@^2.0.0, pump@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/pump/-/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909"
   dependencies:
     end-of-stream "^1.1.0"
     once "^1.3.1"
 
+pump@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64"
+  dependencies:
+    end-of-stream "^1.1.0"
+    once "^1.3.1"
+
 pumpify@^1.3.3:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-1.4.0.tgz#80b7c5df7e24153d03f0e7ac8a05a5d068bd07fb"
@@ -7062,6 +7789,10 @@ q@^1.1.2:
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/q/-/q-1.5.0.tgz#dd01bac9d06d30e6f219aecb8253ee9ebdc308f1"
 
+qrcode-terminal@~0.11.0:
+  version "0.11.0"
+  resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.11.0.tgz#ffc6c28a2fc0bfb47052b47e23f4f446a5fbdb9e"
+
 qs@~0.6.0:
   version "0.6.6"
   resolved "https://registry.yarnpkg.com/qs/-/qs-0.6.6.tgz#6e015098ff51968b8a3c819001d5f2c89bc4b107"
@@ -7085,6 +7816,14 @@ query-string@^4.1.0, query-string@^4.2.2:
     object-assign "^4.1.0"
     strict-uri-encode "^1.0.0"
 
+query-string@^5.1.0:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/query-string/-/query-string-5.1.1.tgz#a78c012b71c17e05f2e3fa2319dd330682efb3cb"
+  dependencies:
+    decode-uri-component "^0.2.0"
+    object-assign "^4.1.0"
+    strict-uri-encode "^1.0.0"
+
 querystring-es3@^0.2.0:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73"
@@ -7112,6 +7851,16 @@ quote-stream@~0.0.0:
     minimist "0.0.8"
     through2 "~0.4.1"
 
+qw@~1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/qw/-/qw-1.0.1.tgz#efbfdc740f9ad054304426acb183412cc8b996d4"
+
+raf@^3.3.0:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.0.tgz#a28876881b4bc2ca9117d4138163ddb80f781575"
+  dependencies:
+    performance-now "^2.1.0"
+
 randomatic@^1.1.3:
   version "1.1.7"
   resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-1.1.7.tgz#c7abe9cc8b87c0baa876b19fde83fd464797e38c"
@@ -7155,16 +7904,18 @@ rc@^1.1.7:
     minimist "^1.2.0"
     strip-json-comments "~2.0.1"
 
-react-ace@^5.0.1:
-  version "5.2.2"
-  resolved "https://registry.yarnpkg.com/react-ace/-/react-ace-5.2.2.tgz#2e35296531bcf3ba49f08ffb1ec482f8938a8d3b"
+re-resizable@^4.3.1:
+  version "4.4.8"
+  resolved "https://registry.yarnpkg.com/re-resizable/-/re-resizable-4.4.8.tgz#1c7eedfd9b9ed1f83b3adfa7a97cda76881e4e57"
+
+react-ace@^5.10.0:
+  version "5.10.0"
+  resolved "https://registry.yarnpkg.com/react-ace/-/react-ace-5.10.0.tgz#e328b37ac52759f700be5afdb86ada2f5ec84c5e"
   dependencies:
-    brace "^0.10.0"
+    brace "^0.11.0"
     lodash.get "^4.4.2"
     lodash.isequal "^4.1.1"
     prop-types "^15.5.8"
-    react "^15.6.2"
-    react-dom "^15.6.2"
 
 react-addons-css-transition-group@^15.6.0:
   version "15.6.2"
@@ -7202,15 +7953,6 @@ react-bootstrap-slider@2.0.1:
     react "^15.6.1"
     react-dom "^15.6.1"
 
-react-bootstrap-table@^4.0.2:
-  version "4.0.6"
-  resolved "https://registry.yarnpkg.com/react-bootstrap-table/-/react-bootstrap-table-4.0.6.tgz#23ab95e9363436abd1d13f4d67cc454a06a297e0"
-  dependencies:
-    classnames "^2.1.2"
-    prop-types "^15.5.10"
-    react-modal "^1.4.0"
-    react-s-alert "^1.3.0"
-
 react-bootstrap@^0.31.5:
   version "0.31.5"
   resolved "https://registry.yarnpkg.com/react-bootstrap/-/react-bootstrap-0.31.5.tgz#57040fa8b1274e1e074803c21a1b895fdabea05a"
@@ -7245,9 +7987,22 @@ react-datetime@2.9.0:
     prop-types "^15.5.7"
     react-onclickoutside "^5.9.0"
 
-react-dom-factories@^1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/react-dom-factories/-/react-dom-factories-1.0.2.tgz#eb7705c4db36fb501b3aa38ff759616aa0ff96e0"
+react-dnd-html5-backend@^2.5.4:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-2.6.0.tgz#590cd1cca78441bb274edd571fef4c0b16ddcf8e"
+  dependencies:
+    lodash "^4.2.0"
+
+react-dnd@^2.5.4:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/react-dnd/-/react-dnd-2.6.0.tgz#7fa25676cf827d58a891293e3c1ab59da002545a"
+  dependencies:
+    disposables "^1.0.1"
+    dnd-core "^2.6.0"
+    hoist-non-react-statics "^2.1.0"
+    invariant "^2.1.0"
+    lodash "^4.2.0"
+    prop-types "^15.5.10"
 
 "react-dom@^15.0.0 || 15.x", react-dom@^15.6.1, react-dom@^15.6.2:
   version "15.6.2"
@@ -7258,13 +8013,6 @@ react-dom-factories@^1.0.0:
     object-assign "^4.1.0"
     prop-types "^15.5.10"
 
-react-draggable@3.x:
-  version "3.0.5"
-  resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-3.0.5.tgz#c031e0ed4313531f9409d6cd84c8ebcec0ddfe2d"
-  dependencies:
-    classnames "^2.2.5"
-    prop-types "^15.6.0"
-
 "react-draggable@^2.2.6 || ^3.0.3":
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-3.0.3.tgz#a6f9b3a7171981b76dadecf238316925cb9eacf4"
@@ -7280,16 +8028,6 @@ react-gravatar@^2.6.1:
     md5 "^2.1.0"
     query-string "^4.2.2"
 
-react-grid-layout@0.16.5:
-  version "0.16.5"
-  resolved "https://registry.yarnpkg.com/react-grid-layout/-/react-grid-layout-0.16.5.tgz#1ff12d12afa875c11fe05802f7509e52bfe9a2cb"
-  dependencies:
-    classnames "2.x"
-    lodash.isequal "^4.0.0"
-    prop-types "15.x"
-    react-draggable "3.x"
-    react-resizable "1.x"
-
 react-html-attributes@^1.3.0:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/react-html-attributes/-/react-html-attributes-1.4.1.tgz#97b5ec710da68833598c8be6f89ac436216840a5"
@@ -7321,16 +8059,15 @@ react-map-gl@^3.0.4:
     prop-types "^15.5.7"
     viewport-mercator-project "^4.0.1"
 
-react-modal@^1.4.0:
-  version "1.9.7"
-  resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-1.9.7.tgz#07ef56790b953e3b98ef1e2989e347983c72871d"
+react-markdown@^3.3.0:
+  version "3.3.2"
+  resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-3.3.2.tgz#35d305e8a29b640717b9dac4658a1caeafd44c94"
   dependencies:
-    create-react-class "^15.5.2"
-    element-class "^0.2.0"
-    exenv "1.2.0"
-    lodash.assign "^4.2.0"
-    prop-types "^15.5.7"
-    react-dom-factories "^1.0.0"
+    prop-types "^15.6.1"
+    remark-parse "^5.0.0"
+    unified "^6.1.5"
+    unist-util-visit "^1.3.0"
+    xtend "^4.0.1"
 
 react-onclickoutside@^5.9.0:
   version "5.11.1"
@@ -7359,18 +8096,19 @@ react-redux@^5.0.2:
     loose-envify "^1.1.0"
     prop-types "^15.5.10"
 
-react-resizable@1.x, react-resizable@^1.3.3:
+react-resizable@^1.3.3:
   version "1.7.5"
   resolved "https://registry.yarnpkg.com/react-resizable/-/react-resizable-1.7.5.tgz#83eb75bb3684da6989bbbf4f826e1470f0af902e"
   dependencies:
     prop-types "15.x"
     react-draggable "^2.2.6 || ^3.0.3"
 
-react-s-alert@^1.3.0:
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/react-s-alert/-/react-s-alert-1.3.1.tgz#4de6e8258cd233bcffef84f73fbf46ea9507dcc9"
+react-search-input@^0.11.3:
+  version "0.11.3"
+  resolved "https://registry.yarnpkg.com/react-search-input/-/react-search-input-0.11.3.tgz#3dd1f9fc584b6bc40a6ee133ae042b6fbb7ae8dd"
   dependencies:
-    babel-runtime "^6.23.0"
+    fuse.js "^3.0.0"
+    prop-types "^15.5.8"
 
 react-select-fast-filter-options@^0.2.1:
   version "0.2.3"
@@ -7411,6 +8149,13 @@ react-split-pane@^0.1.63, react-split-pane@^0.1.66:
     prop-types "^15.5.10"
     react-style-proptype "^3.0.0"
 
+react-sticky@^6.0.2:
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/react-sticky/-/react-sticky-6.0.2.tgz#d301c1b5307649220dbc045fcbacd077885c5ede"
+  dependencies:
+    prop-types "^15.5.8"
+    raf "^3.3.0"
+
 react-style-proptype@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/react-style-proptype/-/react-style-proptype-3.0.0.tgz#89e0b646f266c656abb0f0dd8202dbd5036c31e6"
@@ -7511,7 +8256,7 @@ read-all-stream@^3.0.0:
     pinkie-promise "^2.0.0"
     readable-stream "^2.0.0"
 
-read-cmd-shim@~1.0.1:
+read-cmd-shim@^1.0.1, read-cmd-shim@~1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/read-cmd-shim/-/read-cmd-shim-1.0.1.tgz#2d5d157786a37c055d22077c32c53f8329e91c7b"
   dependencies:
@@ -7530,7 +8275,7 @@ read-installed@~4.0.3:
   optionalDependencies:
     graceful-fs "^4.1.2"
 
-"read-package-json@1 || 2", read-package-json@^2.0.0, read-package-json@~2.0.4:
+"read-package-json@1 || 2", read-package-json@^2.0.0, read-package-json@^2.0.12, read-package-json@^2.0.13, read-package-json@~2.0.4:
   version "2.0.13"
   resolved "https://registry.yarnpkg.com/read-package-json/-/read-package-json-2.0.13.tgz#2e82ebd9f613baa6d2ebe3aa72cefe3f68e41f4a"
   dependencies:
@@ -7541,7 +8286,7 @@ read-installed@~4.0.3:
   optionalDependencies:
     graceful-fs "^4.1.2"
 
-read-package-tree@~5.1.5:
+read-package-tree@~5.1.5, read-package-tree@~5.1.6:
   version "5.1.6"
   resolved "https://registry.yarnpkg.com/read-package-tree/-/read-package-tree-5.1.6.tgz#4f03e83d0486856fb60d97c94882841c2a7b1b7a"
   dependencies:
@@ -7608,7 +8353,19 @@ read@1, read@~1.0.1, read@~1.0.5, read@~1.0.7:
     isarray "0.0.1"
     string_decoder "~0.10.x"
 
-readable-stream@~1.1.0, readable-stream@~1.1.9:
+readable-stream@^2.3.5:
+  version "2.3.6"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf"
+  dependencies:
+    core-util-is "~1.0.0"
+    inherits "~2.0.3"
+    isarray "~1.0.0"
+    process-nextick-args "~2.0.0"
+    safe-buffer "~5.1.1"
+    string_decoder "~1.1.1"
+    util-deprecate "~1.0.1"
+
+readable-stream@~1.1.0, readable-stream@~1.1.10, readable-stream@~1.1.9:
   version "1.1.14"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
   dependencies:
@@ -7712,7 +8469,11 @@ redux-thunk@^2.1.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.2.0.tgz#e615a16e16b47a19a515766133d1e3e99b7852e5"
 

  (This diff was longer than 20,000 lines, and has been truncated...)


 

----------------------------------------------------------------
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