superset-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From ccwilli...@apache.org
Subject [incubator-superset] 14/26: [dashboard v2] tests! (#5066)
Date Fri, 22 Jun 2018 00:54:29 GMT
This is an automated email from the ASF dual-hosted git repository.

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

commit 4bee61374c013be7ec3ee067a30b6fe5ea25b6ec
Author: Chris Williams <williaster@users.noreply.github.com>
AuthorDate: Thu May 24 11:18:25 2018 -0700

    [dashboard v2] tests! (#5066)
    
    * [dashboard v2][tests] add tests for newComponentFactory, isValidChild, dropOverflowsParent, and dnd-reorder
    
    * [dashboard v2][tests] add tests for componentIsResizable, findParentId, getChartIdsFromLayout, newEntitiesFromDrop, and getDropPosition
    
    * [dashboard v2][tests] add mockStore, mockState, and tests for DragDroppable, DashboardBuilder, DashboardGrid, ToastPresenter, and Toast
    
    * [dashboard builder][tests] separate files for state tree fixtures, add ChartHolder, Chart, Divider, Header, Row tests and WithDragDropContext helper
    
    * [dashboard v2][tests] fix dragdrop context with util/getDragDropManager, add test for menu/* and resizable/*, and new components
    
    * [dashboard v2][tests] fix and re-write Dashboard tests, add getFormDataWithExtraFilters_spec
    
    * [dashboard v2][tests] add reducer tests, fix lint error
    
    * [dashboard-v2][tests] add actions/dashboardLayout_spec
    
    * [dashboard v2] fix some prop bugs, open side pane on edit, fix slice name bug
    
    * [dashboard v2] fix slice name save bug
    
    * [dashboard v2] fix lint errors
    
    * [dashboard v2] fix filters bug and add test
    
    * [dashboard v2] fix getFormDataWithExtraFilters_spec
---
 superset/assets/package.json                       |   1 +
 superset/assets/spec/helpers/browser.js            |   4 +-
 .../assets/spec/javascripts/dashboard/.eslintrc    |  33 ++
 .../assets/spec/javascripts/dashboard/.prettierrc  |   4 +
 .../spec/javascripts/dashboard/Dashboard_spec.jsx  | 212 ----------
 .../dashboard/actions/dashboardLayout_spec.js      | 442 ++++++++++++++++++++
 .../dashboard/{ => components}/CodeModal_spec.jsx  |   6 +-
 .../dashboard/{ => components}/CssEditor_spec.jsx  |   6 +-
 .../dashboard/components/DashboardBuilder_spec.jsx | 146 +++++++
 .../dashboard/components/DashboardGrid_spec.jsx    |  82 ++++
 .../dashboard/components/Dashboard_spec.jsx        | 249 ++++++++++++
 .../{ => components}/RefreshIntervalModal_spec.jsx |   2 +-
 .../dashboard/components/ToastPresenter_spec.jsx   |  41 ++
 .../dashboard/components/Toast_spec.jsx            |  43 ++
 .../components/dnd/DragDroppable_spec.jsx          |  90 +++++
 .../components/gridComponents/ChartHolder_spec.jsx | 112 ++++++
 .../components/gridComponents/Chart_spec.jsx       |  92 +++++
 .../components/gridComponents/Column_spec.jsx      | 144 +++++++
 .../components/gridComponents/Divider_spec.jsx     |  70 ++++
 .../components/gridComponents/Header_spec.jsx      | 100 +++++
 .../components/gridComponents/Row_spec.jsx         | 120 ++++++
 .../components/gridComponents/Tab_spec.jsx         | 126 ++++++
 .../components/gridComponents/Tabs_spec.jsx        | 140 +++++++
 .../new/DraggableNewComponent_spec.jsx             |  68 ++++
 .../gridComponents/new/NewColumn_spec.jsx          |  29 ++
 .../gridComponents/new/NewDivider_spec.jsx         |  29 ++
 .../gridComponents/new/NewHeader_spec.jsx          |  29 ++
 .../components/gridComponents/new/NewRow_spec.jsx  |  29 ++
 .../components/gridComponents/new/NewTabs_spec.jsx |  29 ++
 .../dashboard/components/menu/HoverMenu_spec.jsx   |  13 +
 .../components/menu/WithPopoverMenu_spec.jsx       |  71 ++++
 .../resizable/ResizableContainer_spec.jsx          |  20 +
 .../components/resizable/ResizableHandle_spec.jsx  |  29 ++
 .../assets/spec/javascripts/dashboard/fixtures.jsx |  32 +-
 .../dashboard/fixtures/mockChartQueries.js         |  61 +++
 .../dashboard/fixtures/mockDashboardInfo.js        |  12 +
 .../dashboard/fixtures/mockDashboardLayout.js      | 140 +++++++
 .../dashboard/fixtures/mockDashboardState.js       |  13 +
 .../dashboard/fixtures/mockDatasource.js           | 206 ++++++++++
 .../dashboard/fixtures/mockMessageToasts.js        |   9 +
 .../dashboard/fixtures/mockSliceEntities.js        |  39 ++
 .../javascripts/dashboard/fixtures/mockState.js    |  18 +
 .../javascripts/dashboard/fixtures/mockStore.js    |  22 +
 .../dashboard/helpers/WithDragDropContext.jsx      |  27 ++
 .../dashboard/reducers/dashboardLayout_spec.js     | 443 +++++++++++++++++++++
 .../dashboard/reducers/dashboardState_spec.js      | 239 +++++++++++
 .../dashboard/reducers/messageToasts_spec.js       |  32 ++
 .../dashboard/reducers/sliceEntities_spec.js       |  51 +++
 .../spec/javascripts/dashboard/reducers_spec.js    |  38 --
 .../dashboard/util/componentIsResizable_spec.js    |  42 ++
 .../javascripts/dashboard/util/dnd-reorder_spec.js |  62 +++
 .../dashboard/util/dropOverflowsParent_spec.js     | 125 ++++++
 .../dashboard/util/findParentId_spec.js            |  29 ++
 .../dashboard/util/getChartIdsFromLayout_spec.js   |  41 ++
 .../dashboard/util/getDropPosition_spec.js         | 422 ++++++++++++++++++++
 .../util/getFormDataWithExtraFilters_spec.js       |  70 ++++
 .../dashboard/util/isValidChild_spec.js            | 147 +++++++
 .../dashboard/util/newComponentFactory_spec.js     |  51 +++
 .../dashboard/util/newEntitiesFromDrop_spec.js     |  82 ++++
 superset/assets/src/chart/chartAction.js           |   1 -
 .../src/dashboard/actions/dashboardLayout.js       | 159 +++-----
 .../assets/src/dashboard/actions/messageToasts.js  |   8 +-
 .../assets/src/dashboard/components/Dashboard.jsx  |  65 +--
 .../src/dashboard/components/DashboardBuilder.jsx  |  18 +-
 .../assets/src/dashboard/components/SliceAdder.jsx |  31 +-
 .../src/dashboard/components/SliceHeader.jsx       |   9 +-
 .../src/dashboard/components/dnd/DragDroppable.jsx |  46 ++-
 .../dashboard/components/gridComponents/Chart.jsx  |   2 +-
 .../components/gridComponents/ChartHolder.jsx      |   4 +-
 .../dashboard/components/gridComponents/Header.jsx |  11 +-
 .../dashboard/components/gridComponents/Row.jsx    |   2 +-
 superset/assets/src/dashboard/containers/Chart.jsx |   6 +-
 .../src/dashboard/fixtures/emptyDashboardLayout.js |   4 +-
 .../src/dashboard/reducers/dashboardLayout.js      |  10 +-
 .../src/dashboard/reducers/dashboardState.js       |  25 +-
 .../src/dashboard/reducers/getInitialState.js      |   4 +-
 .../assets/src/dashboard/reducers/sliceEntities.js |  14 +-
 .../util/charts/getEffectiveExtraFilters.js        |  10 +-
 .../util/charts/getFormDataWithExtraFilters.js     |   8 +-
 .../assets/src/dashboard/util/componentTypes.js    |   4 +-
 .../src/dashboard/util/dropOverflowsParent.js      |  45 ++-
 superset/assets/src/dashboard/util/findParentId.js |   6 +-
 .../src/dashboard/util/getChartIdsFromLayout.js    |  13 +-
 .../src/dashboard/util/getDragDropManager.js       |  17 +
 superset/assets/src/dashboard/util/isValidChild.js |   5 +-
 .../src/dashboard/util/newEntitiesFromDrop.js      |   7 +-
 superset/assets/src/dashboard/util/propShapes.jsx  |   4 +-
 superset/assets/src/logger.js                      |  11 +-
 superset/assets/yarn.lock                          | 216 +++++++++-
 superset/views/core.py                             |   2 +-
 90 files changed, 5273 insertions(+), 558 deletions(-)

diff --git a/superset/assets/package.json b/superset/assets/package.json
index 0ddceb2..6d116a4 100644
--- a/superset/assets/package.json
+++ b/superset/assets/package.json
@@ -61,6 +61,7 @@
     "deck.gl": "^5.1.4",
     "deep-equal": "^1.0.1",
     "distributions": "^1.0.0",
+    "dnd-core": "^2.6.0",
     "dompurify": "^1.0.3",
     "fastdom": "^1.0.6",
     "geojson-extent": "^0.3.2",
diff --git a/superset/assets/spec/helpers/browser.js b/superset/assets/spec/helpers/browser.js
index d465d86..b30d3c7 100644
--- a/superset/assets/spec/helpers/browser.js
+++ b/superset/assets/spec/helpers/browser.js
@@ -10,6 +10,8 @@ const exposedProperties = ['window', 'navigator', 'document'];
 global.jsdom = jsdom.jsdom;
 global.document = global.jsdom('<!doctype html><html><body></body></html>');
 global.window = document.defaultView;
+global.HTMLElement = window.HTMLElement;
+
 Object.keys(document.defaultView).forEach((property) => {
   if (typeof global[property] === 'undefined') {
     exposedProperties.push(property);
@@ -38,5 +40,5 @@ global.sinon.useFakeXMLHttpRequest();
 
 global.window.XMLHttpRequest = global.XMLHttpRequest;
 global.window.location = { href: 'about:blank' };
-global.window.performance = { now: () => (new Date().getTime()) };
+global.window.performance = { now: () => new Date().getTime() };
 global.$ = require('jquery')(global.window);
diff --git a/superset/assets/spec/javascripts/dashboard/.eslintrc b/superset/assets/spec/javascripts/dashboard/.eslintrc
new file mode 100644
index 0000000..a3f86e3
--- /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 0000000..a20502b
--- /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 f4def13..0000000
--- a/superset/assets/spec/javascripts/dashboard/Dashboard_spec.jsx
+++ /dev/null
@@ -1,212 +0,0 @@
-/* eslint camelcase: 0 */
-import React from 'react';
-import { shallow } from 'enzyme';
-import { describe, it } from 'mocha';
-import { expect } from 'chai';
-import sinon from 'sinon';
-
-import * as sliceActions from '../../../src/dashboard/actions/sliceEntities';
-import * as dashboardActions from '../../../src/dashboard/actions/dashboardState';
-import * as chartActions from '../../../src/chart/chartAction';
-import Dashboard from '../../../src/dashboard/components/Dashboard';
-import { defaultFilters, dashboardState, dashboardInfo, dashboardLayout,
-  charts, datasources, sliceEntities } from './fixtures';
-
-describe('Dashboard', () => {
-  const mockedProps = {
-    actions: { ...chartActions, ...dashboardActions, ...sliceActions },
-    initMessages: [],
-    dashboardState,
-    dashboardInfo,
-    charts,
-    slices: sliceEntities.slices,
-    datasources,
-    layout: dashboardLayout.present,
-    timeout: 60,
-    userId: dashboardInfo.userId,
-  };
-
-  it('should render', () => {
-    const wrapper = shallow(<Dashboard {...mockedProps} />);
-    expect(wrapper.find('#dashboard-container')).to.have.length(1);
-    expect(wrapper.instance().getAllCharts()).to.have.length(3);
-  });
-
-  it('should handle metadata default_filters', () => {
-    const wrapper = shallow(<Dashboard {...mockedProps} />);
-    expect(wrapper.instance().props.dashboardState.filters).deep.equal(defaultFilters);
-  });
-
-  describe('getFormDataExtra', () => {
-    let wrapper;
-    let selectedChart;
-    beforeEach(() => {
-      wrapper = shallow(<Dashboard {...mockedProps} />);
-      selectedChart = charts[248];
-    });
-
-    it('should carry default_filters', () => {
-      const extraFilters = wrapper.instance().getFormDataExtra(selectedChart).extra_filters;
-      expect(extraFilters[0]).to.deep.equal({ col: 'region', op: 'in', val: [] });
-      expect(extraFilters[1]).to.deep.equal({ col: 'country_name', op: 'in', val: ['United States'] });
-    });
-
-    it('should carry updated filter', () => {
-      const newState = {
-        ...wrapper.props('dashboardState'),
-        filters: {
-          256: { region: [] },
-          257: { country_name: ['France'] },
-        },
-      };
-      wrapper.setProps({
-        dashboardState: newState,
-      });
-      const extraFilters = wrapper.instance().getFormDataExtra(selectedChart).extra_filters;
-      expect(extraFilters[1]).to.deep.equal({ col: 'country_name', op: 'in', val: ['France'] });
-    });
-  });
-
-  describe('refreshExcept', () => {
-    let wrapper;
-    let spy;
-    beforeEach(() => {
-      wrapper = shallow(<Dashboard {...mockedProps} />);
-      spy = sinon.spy(mockedProps.actions, 'runQuery');
-    });
-    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);
-      const slice_id = spy.getCall(0).args[0].slice_id;
-      expect(slice_id).to.equal(248);
-    });
-
-    it('should refresh all slices', () => {
-      wrapper.instance().refreshExcept();
-      expect(spy.callCount).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(mockedProps.actions, 'fetchCharts');
-    });
-    afterEach(() => {
-      fetchSlicesStub.restore();
-      refreshExceptSpy.restore();
-    });
-
-    describe('should check if filter has change', () => {
-      beforeEach(() => {
-        refreshExceptSpy.reset();
-      });
-      it('no change', () => {
-        const newState = {
-          ...wrapper.props('dashboardState'),
-          filters: {
-            256: { region: [] },
-            257: { country_name: ['United States'] },
-          },
-        };
-        wrapper.setProps({
-          dashboardState: newState,
-        });
-        wrapper.instance().componentDidUpdate(prevProp);
-        expect(refreshExceptSpy.callCount).to.equal(0);
-      });
-
-      it('remove filter', () => {
-        const newState = {
-          ...wrapper.props('dashboardState'),
-          refresh: true,
-          filters: {
-            256: { region: [] },
-          },
-        };
-        wrapper.setProps({
-          dashboardState: newState,
-        });
-        wrapper.instance().componentDidUpdate(prevProp);
-        expect(refreshExceptSpy.callCount).to.equal(1);
-      });
-
-      it('change filter', () => {
-        const newState = {
-          ...wrapper.props('dashboardState'),
-          refresh: true,
-          filters: {
-            256: { region: [] },
-            257: { country_name: ['Canada'] },
-          },
-        };
-        wrapper.setProps({
-          dashboardState: newState,
-        });
-        wrapper.instance().componentDidUpdate(prevProp);
-        expect(refreshExceptSpy.callCount).to.equal(1);
-      });
-
-      it('add filter', () => {
-        const newState = {
-          ...wrapper.props('dashboardState'),
-          refresh: true,
-          filters: {
-            256: { region: [] },
-            257: { country_name: ['Canada'] },
-            258: { another_filter: ['new'] },
-          },
-        };
-        wrapper.setProps({
-          dashboardState: newState,
-        });
-        wrapper.instance().componentDidUpdate(prevProp);
-        expect(refreshExceptSpy.callCount).to.equal(1);
-      });
-    });
-
-    it('should refresh if refresh flag is true', () => {
-      const newState = {
-        ...wrapper.props('dashboardState'),
-        refresh: true,
-        filters: {
-          256: { region: ['Asian'] },
-        },
-      };
-      wrapper.setProps({
-        dashboardState: newState,
-      });
-      wrapper.instance().componentDidUpdate(prevProp);
-      expect(refreshExceptSpy.callCount).to.equal(1);
-      expect(refreshExceptSpy.lastCall.args[0]).to.equal('256');
-    });
-
-    it('should not refresh filter_immune_slices', () => {
-      const newState = {
-        ...wrapper.props('dashboardState'),
-        refresh: true,
-        filters: {
-          256: { region: [] },
-          257: { country_name: ['Canada'] },
-        },
-      };
-      wrapper.setProps({
-        dashboardState: newState,
-      });
-      wrapper.instance().componentDidUpdate(prevProp);
-      expect(refreshExceptSpy.callCount).to.equal(1);
-      expect(refreshExceptSpy.lastCall.args[0]).to.equal('257');
-    });
-  });
-});
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 0000000..0c4fe12
--- /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 a93c557..d316dc3 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 c325dc1..8c991fa 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 0000000..6b5d051
--- /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 0000000..7e9de51
--- /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 0000000..545b890
--- /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 3a2f700..564857c 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 0000000..7545ad6
--- /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 0000000..6ed0bc5
--- /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 0000000..c7e2c2a
--- /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 0000000..821b637
--- /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 0000000..05756f4
--- /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 0000000..e97414b
--- /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 0000000..c8317f8
--- /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 0000000..1d54775
--- /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 0000000..a718ff4
--- /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 0000000..a984565
--- /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 0000000..d521fe5
--- /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 0000000..4334b37
--- /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 0000000..cb07cb9
--- /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 0000000..71703b3
--- /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 0000000..a499fe8
--- /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 0000000..e91893d
--- /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 0000000..4e71c8c
--- /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 0000000..1f85085
--- /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 0000000..5add770
--- /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 0000000..69fca76
--- /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 0000000..0c37855
--- /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 1565ccd..7a12454 100644
--- a/superset/assets/spec/javascripts/dashboard/fixtures.jsx
+++ b/superset/assets/spec/javascripts/dashboard/fixtures.jsx
@@ -12,11 +12,13 @@ export const regionFilter = {
   form_data: {
     datasource: '2__table',
     date_filter: false,
-    filters: [{
-      col: 'country_name',
-      op: 'in',
-      val: ['United States', 'France', 'Japan'],
-    }],
+    filters: [
+      {
+        col: 'country_name',
+        op: 'in',
+        val: ['United States', 'France', 'Japan'],
+      },
+    ],
     granularity_sqla: null,
     groupby: ['region', 'country_name'],
     having: '',
@@ -35,7 +37,8 @@ export const regionFilter = {
   },
   slice_id: 256,
   slice_name: 'Region Filters',
-  slice_url: '/superset/explore/table/2/?form_data=%7B%22slice_id%22%3A%20256%7D',
+  slice_url:
+    '/superset/explore/table/2/?form_data=%7B%22slice_id%22%3A%20256%7D',
 };
 export const countryFilter = {
   datasource: null,
@@ -64,7 +67,8 @@ export const countryFilter = {
   },
   slice_id: 257,
   slice_name: 'Country Filters',
-  slice_url: '/superset/explore/table/2/?form_data=%7B%22slice_id%22%3A%20257%7D',
+  slice_url:
+    '/superset/explore/table/2/?form_data=%7B%22slice_id%22%3A%20257%7D',
 };
 export const slice = {
   datasource: null,
@@ -115,7 +119,8 @@ export const slice = {
   },
   slice_id: 248,
   slice_name: 'Filtered Population',
-  slice_url: '/superset/explore/table/2/?form_data=%7B%22slice_id%22%3A%20248%7D',
+  slice_url:
+    '/superset/explore/table/2/?form_data=%7B%22slice_id%22%3A%20248%7D',
 };
 
 const mockDashboardData = {
@@ -152,12 +157,15 @@ const mockDashboardData = {
   standalone_mode: false,
 };
 export const {
-  dashboardState, dashboardInfo,
-  charts, datasources, sliceEntities,
-  dashboardLayout } = getInitialState({
+  dashboardState,
+  dashboardInfo,
+  charts,
+  datasources,
+  sliceEntities,
+  dashboardLayout,
+} = getInitialState({
   common: {},
   dashboard_data: mockDashboardData,
   datasources: {},
   user_id: '1',
 });
-
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockChartQueries.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockChartQueries.js
new file mode 100644
index 0000000..b5004a1
--- /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 0000000..4dd2670
--- /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 0000000..865af0a
--- /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 0000000..9d05344
--- /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 0000000..1de7915
--- /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 0000000..07726a8
--- /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 0000000..7c43bea
--- /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 0000000..655f0bf
--- /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 0000000..97132ac
--- /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 0000000..3e892a6
--- /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 0000000..cbe1729
--- /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 0000000..078019d
--- /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 0000000..5280312
--- /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 0000000..7e3bb76
--- /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 580a574..0000000
--- a/superset/assets/spec/javascripts/dashboard/reducers_spec.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import { describe, it } from 'mocha';
-import { expect } from 'chai';
-
-import reducers from '../../../src/dashboard/reducers/dashboardState';
-import * as actions from '../../../src/dashboard/actions/dashboardState';
-import { defaultFilters, dashboardState as initState } from './fixtures';
-
-describe('Dashboard reducers', () => {
-  it('should initialized', () => {
-    expect(initState.sliceIds.size).to.equal(3);
-  });
-
-  it('should remove slice', () => {
-    const action = {
-      type: actions.REMOVE_SLICE,
-      sliceId: 248,
-    };
-
-    const { sliceIds, filters, refresh } = reducers(initState, action);
-    expect(sliceIds.size).to.be.equal(2);
-    expect(filters).to.deep.equal(defaultFilters);
-    expect(refresh).to.equal(false);
-  });
-
-  it('should remove filter slice', () => {
-    const action = {
-      type: actions.REMOVE_SLICE,
-      sliceId: 256,
-    };
-    const initFilters = Object.keys(initState.filters);
-    expect(initFilters).to.have.length(2);
-
-    const { sliceIds, filters, refresh } = reducers(initState, action);
-    expect(sliceIds.size).to.equal(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 0000000..b49a91f
--- /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 0000000..4ff6a52
--- /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 0000000..b153e1e
--- /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 0000000..71c8aec
--- /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 0000000..71bbccd
--- /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 0000000..287b7a6
--- /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 0000000..388630b
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/getFormDataWithExtraFilters_spec.js
@@ -0,0 +1,70 @@
+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 passed filters', () => {
+    const result = getFormDataWithExtraFilters(mockArgs);
+    expect(result.extra_filters).to.have.length(2);
+    expect(result.extra_filters[0]).to.deep.equal({
+      col: 'region',
+      op: 'in',
+      val: ['Spain'],
+    });
+    expect(result.extra_filters[1]).to.deep.equal({
+      col: 'color',
+      op: 'in',
+      val: ['pink', 'purple'],
+    });
+  });
+
+  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(0);
+  });
+
+  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(1);
+  });
+});
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 0000000..ec57494
--- /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 0000000..f52eba9
--- /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 0000000..677c329
--- /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/chartAction.js b/superset/assets/src/chart/chartAction.js
index 33f49d1..529d295 100644
--- a/superset/assets/src/chart/chartAction.js
+++ b/superset/assets/src/chart/chartAction.js
@@ -201,4 +201,3 @@ export function refreshChart(chart, force, timeout) {
     dispatch(runQuery(chart.latestQueryFormData, force, timeout, chart.id))
   );
 }
-
diff --git a/superset/assets/src/dashboard/actions/dashboardLayout.js b/superset/assets/src/dashboard/actions/dashboardLayout.js
index c64ea0d..d210ee6 100644
--- a/superset/assets/src/dashboard/actions/dashboardLayout.js
+++ b/superset/assets/src/dashboard/actions/dashboardLayout.js
@@ -12,20 +12,17 @@ import {
 import dropOverflowsParent from '../util/dropOverflowsParent';
 import findParentId from '../util/findParentId';
 
-// Component CRUD -------------------------------------------------------------
-export const UPDATE_COMPONENTS = 'UPDATE_COMPONENTS';
-function updateLayoutComponents(nextComponents) {
-  return {
-    type: UPDATE_COMPONENTS,
-    payload: {
-      nextComponents,
-    },
-  };
-}
-
-export function updateComponents(nextComponents) {
-  return (dispatch, getState) => {
-    dispatch(updateLayoutComponents(nextComponents));
+// 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));
@@ -33,6 +30,18 @@ export function updateComponents(nextComponents) {
   };
 }
 
+// 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();
@@ -50,90 +59,42 @@ export function updateDashboardTitle(text) {
 }
 
 export const DELETE_COMPONENT = 'DELETE_COMPONENT';
-function deleteLayoutComponent(id, parentId) {
-  return {
-    type: DELETE_COMPONENT,
-    payload: {
-      id,
-      parentId,
-    },
-  };
-}
-
-export function deleteComponent(id, parentId) {
-  return (dispatch, getState) => {
-    dispatch(deleteLayoutComponent(id, parentId));
-
-    if (!getState().dashboardState.hasUnsavedChanges) {
-      dispatch(setUnsavedChanges(true));
-    }
-  };
-}
+export const deleteComponent = setUnsavedChangesAfterAction((id, parentId) => ({
+  type: DELETE_COMPONENT,
+  payload: {
+    id,
+    parentId,
+  },
+}));
 
 export const CREATE_COMPONENT = 'CREATE_COMPONENT';
-function createLayoutComponent(dropResult) {
-  return {
-    type: CREATE_COMPONENT,
-    payload: {
-      dropResult,
-    },
-  };
-}
-
-export function createComponent(dropResult) {
-  return (dispatch, getState) => {
-    dispatch(createLayoutComponent(dropResult));
-
-    if (!getState().dashboardState.hasUnsavedChanges) {
-      dispatch(setUnsavedChanges(true));
-    }
-  };
-}
+export const createComponent = setUnsavedChangesAfterAction(dropResult => ({
+  type: CREATE_COMPONENT,
+  payload: {
+    dropResult,
+  },
+}));
 
 // Tabs -----------------------------------------------------------------------
 export const CREATE_TOP_LEVEL_TABS = 'CREATE_TOP_LEVEL_TABS';
-function createTopLevelTabsAction(dropResult) {
-  return {
-    type: CREATE_TOP_LEVEL_TABS,
-    payload: {
-      dropResult,
-    },
-  };
-}
-
-export function createTopLevelTabs(dropResult) {
-  return (dispatch, getState) => {
-    dispatch(createTopLevelTabsAction(dropResult));
-
-    if (!getState().dashboardState.hasUnsavedChanges) {
-      dispatch(setUnsavedChanges(true));
-    }
-  };
-}
+export const createTopLevelTabs = setUnsavedChangesAfterAction(dropResult => ({
+  type: CREATE_TOP_LEVEL_TABS,
+  payload: {
+    dropResult,
+  },
+}));
 
 export const DELETE_TOP_LEVEL_TABS = 'DELETE_TOP_LEVEL_TABS';
-function deleteTopLevelTabsAction() {
-  return {
-    type: DELETE_TOP_LEVEL_TABS,
-    payload: {},
-  };
-}
-
-export function deleteTopLevelTabs(dropResult) {
-  return (dispatch, getState) => {
-    dispatch(deleteTopLevelTabsAction(dropResult));
-
-    if (!getState().dashboardState.hasUnsavedChanges) {
-      dispatch(setUnsavedChanges(true));
-    }
-  };
-}
+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, dashboardState } = getState();
+    const { dashboardLayout: undoableLayout } = getState();
     const { present: dashboard } = undoableLayout;
     const component = dashboard[id];
     const widthChanged = width && component.meta.width !== width;
@@ -168,16 +129,13 @@ export function resizeComponent({ id, width, height }) {
       });
 
       dispatch(updateComponents(updatedComponents));
-      if (!dashboardState.hasUnsavedChanges) {
-        dispatch(setUnsavedChanges(true));
-      }
     }
   };
 }
 
 // Drag and drop --------------------------------------------------------------
 export const MOVE_COMPONENT = 'MOVE_COMPONENT';
-export function moveComponent(dropResult) {
+function moveComponent(dropResult) {
   return {
     type: MOVE_COMPONENT,
     payload: {
@@ -220,29 +178,24 @@ export function handleComponentDrop(dropResult) {
       dispatch(moveComponent(dropResult));
     }
 
-    const { dashboardLayout: undoableLayout, dashboardState } = getState();
+    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,
-          components: layout,
+          layout,
         });
         dispatch(deleteComponent(source.id, parentId));
       }
     }
 
-    if (!dashboardState.hasUnsavedChanges) {
-      dispatch(setUnsavedChanges(true));
-    }
-
     return null;
   };
 }
@@ -263,12 +216,6 @@ export function undoLayoutAction() {
   };
 }
 
-export function redoLayoutAction() {
-  return (dispatch, getState) => {
-    dispatch(UndoActionCreators.redo());
-
-    if (!getState().dashboardState.hasUnsavedChanges) {
-      dispatch(setUnsavedChanges(true));
-    }
-  };
-}
+export const redoLayoutAction = setUnsavedChangesAfterAction(
+  UndoActionCreators.redo,
+);
diff --git a/superset/assets/src/dashboard/actions/messageToasts.js b/superset/assets/src/dashboard/actions/messageToasts.js
index 367b36f..fde02c4 100644
--- a/superset/assets/src/dashboard/actions/messageToasts.js
+++ b/superset/assets/src/dashboard/actions/messageToasts.js
@@ -1,3 +1,5 @@
+import shortid from 'shortid';
+
 import {
   INFO_TOAST,
   SUCCESS_TOAST,
@@ -6,11 +8,7 @@ import {
 } from '../util/constants';
 
 function getToastUuid(type) {
-  return `${Math.random()
-    .toString(16)
-    .slice(2)}-${type}-${Math.random()
-    .toString(16)
-    .slice(2)}`;
+  return `${type}-${shortid.generate()}`;
 }
 
 export const ADD_TOAST = 'ADD_TOAST';
diff --git a/superset/assets/src/dashboard/components/Dashboard.jsx b/superset/assets/src/dashboard/components/Dashboard.jsx
index 369ed46..644ddf0 100644
--- a/superset/assets/src/dashboard/components/Dashboard.jsx
+++ b/superset/assets/src/dashboard/components/Dashboard.jsx
@@ -91,6 +91,7 @@ class Dashboard extends React.PureComponent {
 
     const currentChartIds = getChartIdsFromLayout(this.props.layout);
     const nextChartIds = getChartIdsFromLayout(nextProps.layout);
+
     if (currentChartIds.length < nextChartIds.length) {
       // adding new chart
       const newChartId = nextChartIds.find(
@@ -109,23 +110,26 @@ class Dashboard extends React.PureComponent {
   componentDidUpdate(prevProps) {
     const { refresh, filters, hasUnsavedChanges } = this.props.dashboardState;
     if (refresh) {
-      let changedFilterKey;
-      const prevFiltersKeySet = new Set(
-        Object.keys(prevProps.dashboardState.filters),
-      );
-      Object.keys(filters).some(key => {
-        prevFiltersKeySet.delete(key);
+      // 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 (
-          prevProps.dashboardState.filters[key] === undefined ||
-          !areObjectsEqual(prevProps.dashboardState.filters[key], filters[key])
+          // 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);
       }
     }
@@ -144,27 +148,24 @@ class Dashboard extends React.PureComponent {
 
   refreshExcept(filterKey) {
     const immune = this.props.dashboardInfo.metadata.filter_immune_slices || [];
-    let charts = this.getAllCharts();
-    if (filterKey) {
-      charts = charts.filter(
-        chart =>
-          String(chart.id) !== filterKey && immune.indexOf(chart.id) === -1,
-      );
-    }
-    charts.forEach(chart => {
-      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,
-      );
+    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,
+        );
+      }
     });
   }
 
diff --git a/superset/assets/src/dashboard/components/DashboardBuilder.jsx b/superset/assets/src/dashboard/components/DashboardBuilder.jsx
index 0951ebf..0f42f1b 100644
--- a/superset/assets/src/dashboard/components/DashboardBuilder.jsx
+++ b/superset/assets/src/dashboard/components/DashboardBuilder.jsx
@@ -1,7 +1,5 @@
 /* eslint-env browser */
 import cx from 'classnames';
-import { DragDropContext } from 'react-dnd';
-import HTML5Backend from 'react-dnd-html5-backend';
 // ParentSize uses resize observer so the dashboard will update size
 // when its container size changes, due to e.g., builder side panel opening
 import ParentSize from '@vx/responsive/build/components/ParentSize';
@@ -19,6 +17,8 @@ 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,
@@ -59,6 +59,12 @@ class DashboardBuilder extends React.Component {
     this.handleChangeTab = this.handleChangeTab.bind(this);
   }
 
+  getChildContext() {
+    return {
+      dragDropManager: this.context.dragDropManager || getDragDropManager(),
+    };
+  }
+
   handleChangeTab({ tabIndex }) {
     this.setState(() => ({ tabIndex }));
     setTimeout(() => {
@@ -170,7 +176,8 @@ class DashboardBuilder extends React.Component {
                       >
                         <DashboardGrid
                           gridComponent={dashboardLayout[id]}
-                          depth={DASHBOARD_ROOT_DEPTH + 1}
+                          // see isValidChild for why tabs do not increment the depth of their children
+                          depth={DASHBOARD_ROOT_DEPTH + (topLevelTabs ? 0 : 1)}
                           width={width}
                         />
                       </TabPane>
@@ -197,5 +204,8 @@ class DashboardBuilder extends React.Component {
 
 DashboardBuilder.propTypes = propTypes;
 DashboardBuilder.defaultProps = defaultProps;
+DashboardBuilder.childContextTypes = {
+  dragDropManager: PropTypes.object.isRequired,
+};
 
-export default DragDropContext(HTML5Backend)(DashboardBuilder);
+export default DashboardBuilder;
diff --git a/superset/assets/src/dashboard/components/SliceAdder.jsx b/superset/assets/src/dashboard/components/SliceAdder.jsx
index 47451c4..d8ed53e 100644
--- a/superset/assets/src/dashboard/components/SliceAdder.jsx
+++ b/superset/assets/src/dashboard/components/SliceAdder.jsx
@@ -19,13 +19,13 @@ const propTypes = {
   lastUpdated: PropTypes.number.isRequired,
   errorMessage: PropTypes.string,
   userId: PropTypes.string.isRequired,
-  selectedSliceIds: PropTypes.object,
+  selectedSliceIds: PropTypes.arrayOf(PropTypes.number).isRequired,
   editMode: PropTypes.bool,
   height: PropTypes.number,
 };
 
 const defaultProps = {
-  selectedSliceIds: new Set(),
+  selectedSliceIds: [],
   editMode: false,
   errorMessage: '',
   height: window.innerHeight,
@@ -63,6 +63,7 @@ class SliceAdder extends React.Component {
       filteredSlices: [],
       searchTerm: '',
       sortBy: KEYS_TO_SORT.findIndex(item => item.key === 'changed_on'),
+      selectedSliceIdsSet: new Set(props.selectedSliceIds),
     };
 
     this.rowRenderer = this.rowRenderer.bind(this);
@@ -76,14 +77,19 @@ class SliceAdder extends React.Component {
   }
 
   componentWillReceiveProps(nextProps) {
+    const nextState = {};
     if (nextProps.lastUpdated !== this.props.lastUpdated) {
-      this.setState({
-        filteredSlices: Object.values(nextProps.slices)
-          .filter(createFilter(this.state.searchTerm, KEYS_TO_FILTERS))
-          .sort(
-            SliceAdder.sortByComparator(KEYS_TO_SORT[this.state.sortBy].key),
-          ),
-      });
+      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);
     }
   }
 
@@ -128,12 +134,15 @@ class SliceAdder extends React.Component {
   }
 
   rowRenderer({ key, index, style }) {
-    const cellData = this.state.filteredSlices[index];
-    const isSelected = this.props.selectedSliceIds.has(cellData.slice_id);
+    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;
+
     const meta = {
       chartId: cellData.slice_id,
+      sliceName: cellData.slice_name,
     };
 
     return (
diff --git a/superset/assets/src/dashboard/components/SliceHeader.jsx b/superset/assets/src/dashboard/components/SliceHeader.jsx
index 3151841..50a2a5d 100644
--- a/superset/assets/src/dashboard/components/SliceHeader.jsx
+++ b/superset/assets/src/dashboard/components/SliceHeader.jsx
@@ -65,10 +65,15 @@ class SliceHeader extends React.PureComponent {
       <div className="chart-header" ref={innerRef}>
         <div className="header">
           <EditableTitle
-            title={sliceName}
+            title={
+              sliceName ||
+              (this.props.editMode
+                ? '---' // this makes an empty title clickable
+                : '')
+            }
             canEdit={this.props.editMode}
             onSaveTitle={this.props.updateSliceName}
-            showTooltip={this.props.editMode}
+            showTooltip={false}
           />
           {!!Object.values(this.props.annotationQuery).length && (
             <TooltipWrapper
diff --git a/superset/assets/src/dashboard/components/dnd/DragDroppable.jsx b/superset/assets/src/dashboard/components/dnd/DragDroppable.jsx
index bfe4973..ef116ea 100644
--- a/superset/assets/src/dashboard/components/dnd/DragDroppable.jsx
+++ b/superset/assets/src/dashboard/components/dnd/DragDroppable.jsx
@@ -47,7 +47,8 @@ const defaultProps = {
   useEmptyDragPreview: false,
 };
 
-class DragDroppable extends React.Component {
+// export unwrapped component for testing
+export class UnwrappedDragDroppable extends React.Component {
   constructor(props) {
     super(props);
     this.state = {
@@ -92,6 +93,25 @@ class DragDroppable extends React.Component {
     } = this.props;
 
     const { dropIndicator } = this.state;
+    const dropIndicatorProps =
+      isDraggingOver && dropIndicator
+        ? {
+            className: cx(
+              'drop-indicator',
+              dropIndicator === DROP_TOP && 'drop-indicator--top',
+              dropIndicator === DROP_BOTTOM && 'drop-indicator--bottom',
+              dropIndicator === DROP_LEFT && 'drop-indicator--left',
+              dropIndicator === DROP_RIGHT && 'drop-indicator--right',
+            ),
+          }
+        : null;
+
+    const childProps = editMode
+      ? {
+          dragSourceRef,
+          dropIndicatorProps,
+        }
+      : {};
 
     return (
       <div
@@ -105,33 +125,17 @@ class DragDroppable extends React.Component {
           className,
         )}
       >
-        {children(
-          !editMode
-            ? {}
-            : {
-                dragSourceRef,
-                dropIndicatorProps: isDraggingOver &&
-                  dropIndicator && {
-                    className: cx(
-                      'drop-indicator',
-                      dropIndicator === DROP_TOP && 'drop-indicator--top',
-                      dropIndicator === DROP_BOTTOM && 'drop-indicator--bottom',
-                      dropIndicator === DROP_LEFT && 'drop-indicator--left',
-                      dropIndicator === DROP_RIGHT && 'drop-indicator--right',
-                    ),
-                  },
-              },
-        )}
+        {children(childProps)}
       </div>
     );
   }
 }
 
-DragDroppable.propTypes = propTypes;
-DragDroppable.defaultProps = defaultProps;
+UnwrappedDragDroppable.propTypes = propTypes;
+UnwrappedDragDroppable.defaultProps = defaultProps;
 
 // note that the composition order here determines using
 // component.method() vs decoratedComponentInstance.method() in the drag/drop config
 export default DropTarget(...dropConfig)(
-  DragSource(...dragConfig)(DragDroppable),
+  DragSource(...dragConfig)(UnwrappedDragDroppable),
 );
diff --git a/superset/assets/src/dashboard/components/gridComponents/Chart.jsx b/superset/assets/src/dashboard/components/gridComponents/Chart.jsx
index 2aedca7..1ace51d 100644
--- a/superset/assets/src/dashboard/components/gridComponents/Chart.jsx
+++ b/superset/assets/src/dashboard/components/gridComponents/Chart.jsx
@@ -140,7 +140,7 @@ class Chart extends React.Component {
     return this.props.refreshChart(this.props.chart, true, this.props.timeout);
   }
 
-  removeFilter(args) {
+  removeFilter(...args) {
     this.props.removeFilter(this.props.id, ...args);
   }
 
diff --git a/superset/assets/src/dashboard/components/gridComponents/ChartHolder.jsx b/superset/assets/src/dashboard/components/gridComponents/ChartHolder.jsx
index bc9f430..ab030f4 100644
--- a/superset/assets/src/dashboard/components/gridComponents/ChartHolder.jsx
+++ b/superset/assets/src/dashboard/components/gridComponents/ChartHolder.jsx
@@ -68,7 +68,7 @@ class ChartHolder extends React.Component {
         ...component,
         meta: {
           ...component.meta,
-          chartName: nextName,
+          sliceName: nextName,
         },
       },
     });
@@ -133,7 +133,7 @@ class ChartHolder extends React.Component {
                 id={component.meta.chartId}
                 width={widthMultiple * columnWidth}
                 height={component.meta.height * GRID_BASE_UNIT - CHART_MARGIN}
-                sliceName={component.meta.chartName}
+                sliceName={component.meta.sliceName || ''}
                 updateSliceName={this.handleUpdateSliceName}
               />
               {editMode && (
diff --git a/superset/assets/src/dashboard/components/gridComponents/Header.jsx b/superset/assets/src/dashboard/components/gridComponents/Header.jsx
index 5114a77..683af9e 100644
--- a/superset/assets/src/dashboard/components/gridComponents/Header.jsx
+++ b/superset/assets/src/dashboard/components/gridComponents/Header.jsx
@@ -107,11 +107,12 @@ class Header extends React.PureComponent {
       >
         {({ dropIndicatorProps, dragSourceRef }) => (
           <div ref={dragSourceRef}>
-            {editMode && (
-              <HoverMenu position="left">
-                <DragHandle position="left" />
-              </HoverMenu>
-            )}
+            {editMode &&
+            depth <= 2 && ( // drag handle looks bad when nested
+                <HoverMenu position="left">
+                  <DragHandle position="left" />
+                </HoverMenu>
+              )}
 
             <WithPopoverMenu
               onChangeFocus={this.handleChangeFocus}
diff --git a/superset/assets/src/dashboard/components/gridComponents/Row.jsx b/superset/assets/src/dashboard/components/gridComponents/Row.jsx
index 3119a08..28e7042 100644
--- a/superset/assets/src/dashboard/components/gridComponents/Row.jsx
+++ b/superset/assets/src/dashboard/components/gridComponents/Row.jsx
@@ -122,7 +122,7 @@ class Row extends React.PureComponent {
             menuItems={[
               <BackgroundStyleDropdown
                 id={`${rowComponent.id}-background`}
-                value={rowComponent.meta.background}
+                value={backgroundStyle.value}
                 onChange={this.handleChangeBackground}
               />,
             ]}
diff --git a/superset/assets/src/dashboard/containers/Chart.jsx b/superset/assets/src/dashboard/containers/Chart.jsx
index 2b38d8a..4ad1270 100644
--- a/superset/assets/src/dashboard/containers/Chart.jsx
+++ b/superset/assets/src/dashboard/containers/Chart.jsx
@@ -11,6 +11,8 @@ import getFormDataWithExtraFilters from '../util/charts/getFormDataWithExtraFilt
 import { updateComponents } from '../actions/dashboardLayout';
 import Chart from '../components/gridComponents/Chart';
 
+const EMPTY_FILTERS = {};
+
 function mapStateToProps(
   {
     charts: chartQueries,
@@ -22,7 +24,7 @@ function mapStateToProps(
   ownProps,
 ) {
   const { id } = ownProps;
-  const chart = chartQueries[id];
+  const chart = chartQueries[id] || {};
   const { filters } = dashboardState;
 
   return {
@@ -30,7 +32,7 @@ function mapStateToProps(
     datasource: chart && datasources[chart.form_data.datasource],
     slice: sliceEntities.slices[id],
     timeout: dashboardInfo.common.conf.SUPERSET_WEBSERVER_TIMEOUT,
-    filters,
+    filters: filters[id] || EMPTY_FILTERS,
     // note: this method caches filters if possible to prevent render cascades
     formData: getFormDataWithExtraFilters({
       chart,
diff --git a/superset/assets/src/dashboard/fixtures/emptyDashboardLayout.js b/superset/assets/src/dashboard/fixtures/emptyDashboardLayout.js
index cee948a..e306288 100644
--- a/superset/assets/src/dashboard/fixtures/emptyDashboardLayout.js
+++ b/superset/assets/src/dashboard/fixtures/emptyDashboardLayout.js
@@ -1,6 +1,6 @@
 import {
   DASHBOARD_GRID_TYPE,
-  DASHBOARD_HEADER_TYPE,
+  HEADER_TYPE,
   DASHBOARD_ROOT_TYPE,
 } from '../util/componentTypes';
 
@@ -25,7 +25,7 @@ export default {
   },
 
   [DASHBOARD_HEADER_ID]: {
-    type: DASHBOARD_HEADER_TYPE,
+    type: HEADER_TYPE,
     id: DASHBOARD_HEADER_ID,
     meta: {
       text: 'New dashboard',
diff --git a/superset/assets/src/dashboard/reducers/dashboardLayout.js b/superset/assets/src/dashboard/reducers/dashboardLayout.js
index 573a143..4b3ee49 100644
--- a/superset/assets/src/dashboard/reducers/dashboardLayout.js
+++ b/superset/assets/src/dashboard/reducers/dashboardLayout.js
@@ -82,7 +82,7 @@ const actionHandlers = {
       payload: { dropResult },
     } = action;
     const { destination, dragging } = dropResult;
-    const newEntities = newEntitiesFromDrop({ dropResult, components: state });
+    const newEntities = newEntitiesFromDrop({ dropResult, layout: state });
 
     // if column is a parent, set any resizable children to have a minimum width so that
     // the chances that they are validly movable to future containers is maximized
@@ -139,18 +139,18 @@ const actionHandlers = {
       nextEntities[newRow.id] = newRow;
     }
 
-    // inherit the width of a column parent
+    // 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];
-      const parentColumn = nextEntities[destination.id];
       nextEntities[dragging.id] = {
         ...component,
         meta: {
           ...component.meta,
-          width: parentColumn.meta.width,
+          width: GRID_MIN_COLUMN_COUNT,
         },
       };
     }
@@ -201,7 +201,7 @@ const actionHandlers = {
     }
 
     // create new component
-    const newEntities = newEntitiesFromDrop({ dropResult, components: state });
+    const newEntities = newEntitiesFromDrop({ dropResult, layout: state });
     const newEntitiesArray = Object.values(newEntities);
     const tabComponent = newEntitiesArray.find(
       component => component.type === TAB_TYPE,
diff --git a/superset/assets/src/dashboard/reducers/dashboardState.js b/superset/assets/src/dashboard/reducers/dashboardState.js
index 2523494..c7f2277 100644
--- a/superset/assets/src/dashboard/reducers/dashboardState.js
+++ b/superset/assets/src/dashboard/reducers/dashboardState.js
@@ -1,6 +1,4 @@
 /* eslint-disable camelcase */
-import { merge as mergeArray } from 'd3';
-
 import {
   ADD_SLICE,
   ADD_FILTER,
@@ -27,7 +25,7 @@ export default function dashboardStateReducer(state = {}, action) {
       updatedSliceIds.add(action.slice.slice_id);
       return {
         ...state,
-        sliceIds: updatedSliceIds,
+        sliceIds: Array.from(updatedSliceIds),
       };
     },
     [REMOVE_SLICE]() {
@@ -45,7 +43,7 @@ export default function dashboardStateReducer(state = {}, action) {
       }
       return {
         ...state,
-        sliceIds: updatedSliceIds,
+        sliceIds: Array.from(updatedSliceIds),
         filters: newFilter,
         refresh,
       };
@@ -54,7 +52,11 @@ export default function dashboardStateReducer(state = {}, action) {
       return { ...state, isStarred: action.isStarred };
     },
     [SET_EDIT_MODE]() {
-      return { ...state, editMode: action.editMode };
+      return {
+        ...state,
+        editMode: action.editMode,
+        showBuilderPane: !!action.editMode,
+      };
     },
     [SET_MAX_UNDO_HISTORY_EXCEEDED]() {
       const { maxUndoHistoryExceeded = true } = action.payload;
@@ -86,7 +88,7 @@ export default function dashboardStateReducer(state = {}, action) {
 
     // filters
     [ADD_FILTER]() {
-      const hasSelectedFilter = state.sliceIds.has(action.chart.id);
+      const hasSelectedFilter = state.sliceIds.includes(action.chart.id);
       if (!hasSelectedFilter) {
         return state;
       }
@@ -115,9 +117,9 @@ export default function dashboardStateReducer(state = {}, action) {
           // 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] = mergeArray([filters[sliceId][col], vals]);
+          newFilter[col] = [...filters[sliceId][col], ...vals];
         } else {
-          newFilter[col] = mergeArray([[filters[sliceId][col]], vals])[0] || '';
+          newFilter[col] = [filters[sliceId][col], ...vals];
         }
         filters = { ...filters, [sliceId]: newFilter };
       }
@@ -126,14 +128,15 @@ export default function dashboardStateReducer(state = {}, action) {
     [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 };
+        const newFilter = filters[sliceId][col].filter(
+          val => !excluded.has(val),
+        );
+        filters = { ...filters, [sliceId]: { [col]: newFilter } };
       }
       return { ...state, filters, refresh };
     },
diff --git a/superset/assets/src/dashboard/reducers/getInitialState.js b/superset/assets/src/dashboard/reducers/getInitialState.js
index 534b15d..d56e480 100644
--- a/superset/assets/src/dashboard/reducers/getInitialState.js
+++ b/superset/assets/src/dashboard/reducers/getInitialState.js
@@ -99,7 +99,7 @@ export default function(bootstrapData) {
     // functionality and python updates slice names based on layout upon dashboard save
     const layoutId = chartIdToLayoutId[key];
     if (layoutId && layout[layoutId]) {
-      layout[layoutId].meta.chartName = slice.slice_name;
+      layout[layoutId].meta.sliceName = slice.slice_name;
     }
   });
 
@@ -126,7 +126,7 @@ export default function(bootstrapData) {
       common,
     },
     dashboardState: {
-      sliceIds,
+      sliceIds: Array.from(sliceIds),
       refresh: false,
       filters,
       expandedSlices: dashboard.metadata.expanded_slices || {},
diff --git a/superset/assets/src/dashboard/reducers/sliceEntities.js b/superset/assets/src/dashboard/reducers/sliceEntities.js
index c1453f5..c5e46c2 100644
--- a/superset/assets/src/dashboard/reducers/sliceEntities.js
+++ b/superset/assets/src/dashboard/reducers/sliceEntities.js
@@ -2,7 +2,6 @@ import {
   FETCH_ALL_SLICES_FAILED,
   FETCH_ALL_SLICES_STARTED,
   SET_ALL_SLICES,
-  UPDATE_SLICE_NAME,
 } from '../actions/sliceEntities';
 import { t } from '../../locales';
 
@@ -18,17 +17,6 @@ export default function sliceEntitiesReducer(
   action,
 ) {
   const actionHandlers = {
-    [UPDATE_SLICE_NAME]() {
-      const updatedSlice = {
-        ...state.slices[action.key],
-        slice_name: action.sliceName,
-      };
-      const updatedSlices = {
-        ...state.slices,
-        [action.key]: updatedSlice,
-      };
-      return { ...state, slices: updatedSlices };
-    },
     [FETCH_ALL_SLICES_STARTED]() {
       return {
         ...state,
@@ -46,7 +34,7 @@ export default function sliceEntitiesReducer(
     [FETCH_ALL_SLICES_FAILED]() {
       const respJSON = action.error.responseJSON;
       const errorMessage =
-        t('Sorry, there was an error adding slices to this dashboard: ') +
+        t('Sorry, there was an error fetching slices: ') +
         (respJSON && respJSON.message)
           ? respJSON.message
           : action.error.responseText;
diff --git a/superset/assets/src/dashboard/util/charts/getEffectiveExtraFilters.js b/superset/assets/src/dashboard/util/charts/getEffectiveExtraFilters.js
index f48631f..7b8b5ce 100644
--- a/superset/assets/src/dashboard/util/charts/getEffectiveExtraFilters.js
+++ b/superset/assets/src/dashboard/util/charts/getEffectiveExtraFilters.js
@@ -5,14 +5,13 @@ export default function getEffectiveExtraFilters({
 }) {
   const immuneSlices = dashboardMetadata.filter_immune_slices || [];
 
-  const effectiveFilters = [];
-
   if (sliceId && immuneSlices.includes(sliceId)) {
     // The slice is immune to dashboard filters
-    return effectiveFilters;
+    return [];
   }
 
   // Build a list of fields the slice is immune to filters on
+  const effectiveFilters = [];
   let immuneToFields = [];
   if (
     sliceId &&
@@ -27,12 +26,13 @@ export default function getEffectiveExtraFilters({
       // Filters applied by the slice don't apply to itself
       return;
     }
-    Object.keys(filters[filteringSliceId]).forEach(field => {
+    const filtersFromSlice = filters[filteringSliceId];
+    Object.keys(filtersFromSlice).forEach(field => {
       if (!immuneToFields.includes(field)) {
         effectiveFilters.push({
           col: field,
           op: 'in',
-          val: filters[filteringSliceId][field],
+          val: filtersFromSlice[field],
         });
       }
     });
diff --git a/superset/assets/src/dashboard/util/charts/getFormDataWithExtraFilters.js b/superset/assets/src/dashboard/util/charts/getFormDataWithExtraFilters.js
index 17f7bb3..2fb5767 100644
--- a/superset/assets/src/dashboard/util/charts/getFormDataWithExtraFilters.js
+++ b/superset/assets/src/dashboard/util/charts/getFormDataWithExtraFilters.js
@@ -14,11 +14,9 @@ export default function getFormDataWithExtraFilters({
 }) {
   // if dashboard metadata + filters have not changed, use cache if possible
   if (
-    cachedDashboardMetadataByChart[sliceId] &&
-    cachedDashboardMetadataByChart[sliceId] === dashboardMetadata &&
-    cachedFiltersByChart[sliceId] &&
-    cachedFiltersByChart[sliceId] === filters &&
-    cachedFormdataByChart[sliceId]
+    (cachedDashboardMetadataByChart[sliceId] || {}) === dashboardMetadata &&
+    (cachedFiltersByChart[sliceId] || {}) === filters &&
+    !!cachedFormdataByChart[sliceId]
   ) {
     return cachedFormdataByChart[sliceId];
   }
diff --git a/superset/assets/src/dashboard/util/componentTypes.js b/superset/assets/src/dashboard/util/componentTypes.js
index 2866898..b773417 100644
--- a/superset/assets/src/dashboard/util/componentTypes.js
+++ b/superset/assets/src/dashboard/util/componentTypes.js
@@ -1,7 +1,7 @@
 export const CHART_TYPE = 'DASHBOARD_CHART_TYPE';
 export const COLUMN_TYPE = 'DASHBOARD_COLUMN_TYPE';
+export const DASHBOARD_HEADER_TYPE = 'DASHBOARD_HEADER_TYPE';
 export const DASHBOARD_GRID_TYPE = 'DASHBOARD_GRID_TYPE';
-export const DASHBOARD_HEADER_TYPE = 'DASHBOARD_DASHBOARD_HEADER_TYPE';
 export const DASHBOARD_ROOT_TYPE = 'DASHBOARD_ROOT_TYPE';
 export const DIVIDER_TYPE = 'DASHBOARD_DIVIDER_TYPE';
 export const HEADER_TYPE = 'DASHBOARD_HEADER_TYPE';
@@ -14,8 +14,8 @@ export const TAB_TYPE = 'DASHBOARD_TAB_TYPE';
 export default {
   CHART_TYPE,
   COLUMN_TYPE,
-  DASHBOARD_GRID_TYPE,
   DASHBOARD_HEADER_TYPE,
+  DASHBOARD_GRID_TYPE,
   DASHBOARD_ROOT_TYPE,
   DIVIDER_TYPE,
   HEADER_TYPE,
diff --git a/superset/assets/src/dashboard/util/dropOverflowsParent.js b/superset/assets/src/dashboard/util/dropOverflowsParent.js
index bc7195f..328d8e3 100644
--- a/superset/assets/src/dashboard/util/dropOverflowsParent.js
+++ b/superset/assets/src/dashboard/util/dropOverflowsParent.js
@@ -1,14 +1,10 @@
 import { COLUMN_TYPE } from '../util/componentTypes';
-import {
-  GRID_COLUMN_COUNT,
-  NEW_COMPONENTS_SOURCE_ID,
-  GRID_MIN_COLUMN_COUNT,
-} from './constants';
+import { GRID_COLUMN_COUNT, NEW_COMPONENTS_SOURCE_ID } from './constants';
 import findParentId from './findParentId';
 import getChildWidth from './getChildWidth';
 import newComponentFactory from './newComponentFactory';
 
-export default function doesChildOverflowParent(dropResult, components) {
+export default function doesChildOverflowParent(dropResult, layout) {
   const { source, destination, dragging } = dropResult;
 
   // moving a component within a container should never overflow
@@ -17,22 +13,33 @@ export default function doesChildOverflowParent(dropResult, components) {
   }
 
   const isNewComponent = source.id === NEW_COMPONENTS_SOURCE_ID;
-  const grandparentId = findParentId({ childId: destination.id, components });
+  const grandparentId = findParentId({
+    childId: destination.id,
+    layout,
+  });
 
   const child = isNewComponent
     ? newComponentFactory(dragging.type)
-    : components[dragging.id] || {};
-  const parent = components[destination.id] || {};
-  const grandparent = components[grandparentId] || {};
-
-  const grandparentWidth =
-    (grandparent.meta && grandparent.meta.width) || GRID_COLUMN_COUNT;
-  const parentWidth = (parent.meta && parent.meta.width) || grandparentWidth;
-  const parentChildWidth =
-    parent.type === COLUMN_TYPE
-      ? (parent.meta && parent.meta.width) || GRID_MIN_COLUMN_COUNT
-      : getChildWidth({ id: destination.id, components });
+    : layout[dragging.id] || {};
+  const parent = layout[destination.id] || {};
+  const grandparent = layout[grandparentId] || {};
+
   const childWidth = (child.meta && child.meta.width) || 0;
 
-  return parentWidth - parentChildWidth < childWidth;
+  const grandparentCapacity =
+    grandparent.meta && typeof grandparent.meta.width === 'number'
+      ? grandparent.meta.width
+      : GRID_COLUMN_COUNT;
+
+  const parentCapacity =
+    parent.meta && typeof parent.meta.width === 'number'
+      ? parent.meta.width
+      : grandparentCapacity;
+
+  const occupiedParentWidth =
+    parent.type === COLUMN_TYPE
+      ? 0
+      : getChildWidth({ id: destination.id, components: layout });
+
+  return parentCapacity < occupiedParentWidth + childWidth;
 }
diff --git a/superset/assets/src/dashboard/util/findParentId.js b/superset/assets/src/dashboard/util/findParentId.js
index f84b0de..c2e285d 100644
--- a/superset/assets/src/dashboard/util/findParentId.js
+++ b/superset/assets/src/dashboard/util/findParentId.js
@@ -1,10 +1,10 @@
-export default function findParentId({ childId, components = {} }) {
+export default function findParentId({ childId, layout = {} }) {
   let parentId = null;
 
-  const ids = Object.keys(components);
+  const ids = Object.keys(layout);
   for (let i = 0; i < ids.length - 1; i += 1) {
     const id = ids[i];
-    const component = components[id] || {};
+    const component = layout[id] || {};
     if (
       id !== childId &&
       component.children &&
diff --git a/superset/assets/src/dashboard/util/getChartIdsFromLayout.js b/superset/assets/src/dashboard/util/getChartIdsFromLayout.js
index f0963c1..9aebb61 100644
--- a/superset/assets/src/dashboard/util/getChartIdsFromLayout.js
+++ b/superset/assets/src/dashboard/util/getChartIdsFromLayout.js
@@ -1,7 +1,14 @@
+import { CHART_TYPE } from './componentTypes';
+
 export default function getChartIdsFromLayout(layout) {
-  return Object.values(layout).reduce((chartIds, value) => {
-    if (value && value.meta && value.meta.chartId) {
-      chartIds.push(value.meta.chartId);
+  return Object.values(layout).reduce((chartIds, currentComponent) => {
+    if (
+      currentComponent &&
+      currentComponent.type === CHART_TYPE &&
+      currentComponent.meta &&
+      currentComponent.meta.chartId
+    ) {
+      chartIds.push(currentComponent.meta.chartId);
     }
     return chartIds;
   }, []);
diff --git a/superset/assets/src/dashboard/util/getDragDropManager.js b/superset/assets/src/dashboard/util/getDragDropManager.js
new file mode 100644
index 0000000..1be8ecb
--- /dev/null
+++ b/superset/assets/src/dashboard/util/getDragDropManager.js
@@ -0,0 +1,17 @@
+import { DragDropManager } from 'dnd-core';
+import HTML5Backend from 'react-dnd-html5-backend';
+
+let defaultManager;
+
+// we use this method to ensure that there is a singleton of the DragDropManager
+// within the app this seems to work fine, but in tests multiple are initialized
+// see this issue for more details https://github.com/react-dnd/react-dnd/issues/186
+// @TODO re-evaluate whether this is required when we move to jest
+// the alternative is simply using an HOC like:
+//  DragDropContext(HTML5Backend)(DashboardBuilder);
+export default function getDragDropManager() {
+  if (!defaultManager) {
+    defaultManager = new DragDropManager(HTML5Backend);
+  }
+  return defaultManager;
+}
diff --git a/superset/assets/src/dashboard/util/isValidChild.js b/superset/assets/src/dashboard/util/isValidChild.js
index a885c31..80bf69e 100644
--- a/superset/assets/src/dashboard/util/isValidChild.js
+++ b/superset/assets/src/dashboard/util/isValidChild.js
@@ -44,6 +44,7 @@ const parentMaxDepthLookup = {
 
   [DASHBOARD_GRID_TYPE]: {
     [CHART_TYPE]: depthOne,
+    [MARKDOWN_TYPE]: depthOne,
     [COLUMN_TYPE]: depthOne,
     [DIVIDER_TYPE]: depthOne,
     [HEADER_TYPE]: depthOne,
@@ -63,11 +64,12 @@ const parentMaxDepthLookup = {
 
   [TAB_TYPE]: {
     [CHART_TYPE]: depthTwo,
+    [MARKDOWN_TYPE]: depthTwo,
     [COLUMN_TYPE]: depthTwo,
     [DIVIDER_TYPE]: depthTwo,
     [HEADER_TYPE]: depthTwo,
     [ROW_TYPE]: depthTwo,
-    [TABS_TYPE]: depthTwo,
+    [TABS_TYPE]: rootDepth, // you cannot drop a Tabs within a Tab
   },
 
   [COLUMN_TYPE]: {
@@ -75,6 +77,7 @@ const parentMaxDepthLookup = {
     [HEADER_TYPE]: depthFive,
     [MARKDOWN_TYPE]: depthFive,
     [ROW_TYPE]: depthThree,
+    [DIVIDER_TYPE]: depthThree,
   },
 
   // these have no valid children
diff --git a/superset/assets/src/dashboard/util/newEntitiesFromDrop.js b/superset/assets/src/dashboard/util/newEntitiesFromDrop.js
index 7fe7f4e..8abc9b9 100644
--- a/superset/assets/src/dashboard/util/newEntitiesFromDrop.js
+++ b/superset/assets/src/dashboard/util/newEntitiesFromDrop.js
@@ -3,14 +3,13 @@ import newComponentFactory from './newComponentFactory';
 
 import { ROW_TYPE, TABS_TYPE, TAB_TYPE } from './componentTypes';
 
-export default function newEntitiesFromDrop({ dropResult, components }) {
+export default function newEntitiesFromDrop({ dropResult, layout }) {
   const { dragging, destination } = dropResult;
 
   const dragType = dragging.type;
-  const dragMeta = dragging.meta;
-  const dropEntity = components[destination.id];
+  const dropEntity = layout[destination.id];
   const dropType = dropEntity.type;
-  let newDropChild = newComponentFactory(dragType, dragMeta);
+  let newDropChild = newComponentFactory(dragType, dragging.meta);
   const wrapChildInRow = shouldWrapChildInRow({
     parentType: dropType,
     childType: dragType,
diff --git a/superset/assets/src/dashboard/util/propShapes.jsx b/superset/assets/src/dashboard/util/propShapes.jsx
index c8e1981..f07497c 100644
--- a/superset/assets/src/dashboard/util/propShapes.jsx
+++ b/superset/assets/src/dashboard/util/propShapes.jsx
@@ -66,9 +66,9 @@ export const slicePropShape = PropTypes.shape({
 });
 
 export const dashboardStatePropShape = PropTypes.shape({
-  sliceIds: PropTypes.object.isRequired,
+  sliceIds: PropTypes.arrayOf(PropTypes.number).isRequired,
   refresh: PropTypes.bool.isRequired,
-  filters: PropTypes.object,
+  filters: PropTypes.object.isRequired,
   expandedSlices: PropTypes.object,
   editMode: PropTypes.bool,
   showBuilderPane: PropTypes.bool,
diff --git a/superset/assets/src/logger.js b/superset/assets/src/logger.js
index c7823fc..65c81b5 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/yarn.lock b/superset/assets/yarn.lock
index 77ca84d..2add797 100644
--- a/superset/assets/yarn.lock
+++ b/superset/assets/yarn.lock
@@ -1457,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"
@@ -1621,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"
@@ -1950,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"
@@ -2152,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"
@@ -3600,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"
 
@@ -4853,6 +4877,17 @@ 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"
@@ -4863,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"
 
@@ -4897,6 +4932,10 @@ 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"
@@ -4937,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"
@@ -4993,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"
 
@@ -5069,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"
@@ -5906,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"
@@ -7138,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"
@@ -7634,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"
@@ -7781,6 +7855,12 @@ 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"
@@ -7828,16 +7908,14 @@ 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.0.1:
-  version "5.2.2"
-  resolved "https://registry.yarnpkg.com/react-ace/-/react-ace-5.2.2.tgz#2e35296531bcf3ba49f08ffb1ec482f8938a8d3b"
+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"
@@ -7981,6 +8059,16 @@ react-map-gl@^3.0.4:
     prop-types "^15.5.7"
     viewport-mercator-project "^4.0.1"
 
+react-markdown@^3.3.0:
+  version "3.3.2"
+  resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-3.3.2.tgz#35d305e8a29b640717b9dac4658a1caeafd44c94"
+  dependencies:
+    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"
   resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-5.11.1.tgz#00314e52567cf55faba94cabbacd119619070623"
@@ -8061,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"
@@ -8456,6 +8551,26 @@ regjsparser@^0.1.4:
   dependencies:
     jsesc "~0.5.0"
 
+remark-parse@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-5.0.0.tgz#4c077f9e499044d1d5c13f80d7a98cf7b9285d95"
+  dependencies:
+    collapse-white-space "^1.0.2"
+    is-alphabetical "^1.0.0"
+    is-decimal "^1.0.0"
+    is-whitespace-character "^1.0.0"
+    is-word-character "^1.0.0"
+    markdown-escapes "^1.0.0"
+    parse-entities "^1.1.0"
+    repeat-string "^1.5.4"
+    state-toggle "^1.0.0"
+    trim "0.0.1"
+    trim-trailing-lines "^1.0.0"
+    unherit "^1.0.4"
+    unist-util-remove-position "^1.0.0"
+    vfile-location "^2.0.0"
+    xtend "^4.0.1"
+
 remove-trailing-separator@^1.0.1:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef"
@@ -8464,7 +8579,7 @@ repeat-element@^1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.2.tgz#ef089a178d1483baae4d93eb98b4f9e4e11d990a"
 
-repeat-string@^1.5.2:
+repeat-string@^1.5.2, repeat-string@^1.5.4:
   version "1.6.1"
   resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
 
@@ -8480,6 +8595,10 @@ repeating@^2.0.0:
   dependencies:
     is-finite "^1.0.0"
 
+replace-ext@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.0.tgz#de63128373fcbf7c3ccfa4de5a480c45a67958eb"
+
 request@2, request@^2.74.0:
   version "2.85.0"
   resolved "https://registry.yarnpkg.com/request/-/request-2.85.0.tgz#5a03615a47c61420b3eb99b7dba204f83603e1fa"
@@ -9229,6 +9348,10 @@ ssri@^5.2.4:
   dependencies:
     safe-buffer "^5.1.1"
 
+state-toggle@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/state-toggle/-/state-toggle-1.0.1.tgz#c3cb0974f40a6a0f8e905b96789eb41afa1cde3a"
+
 static-eval@~0.2.0:
   version "0.2.4"
   resolved "https://registry.yarnpkg.com/static-eval/-/static-eval-0.2.4.tgz#b7d34d838937b969f9641ca07d48f8ede263ea7b"
@@ -9641,6 +9764,18 @@ trim-right@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003"
 
+trim-trailing-lines@^1.0.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/trim-trailing-lines/-/trim-trailing-lines-1.1.1.tgz#e0ec0810fd3c3f1730516b45f49083caaf2774d9"
+
+trim@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/trim/-/trim-0.0.1.tgz#5858547f6b290757ee95cccc666fb50084c460dd"
+
+trough@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.2.tgz#7f1663ec55c480139e2de5e486c6aef6cc24a535"
+
 tryit@^1.0.1:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/tryit/-/tryit-1.0.3.tgz#393be730a9446fd1ead6da59a014308f36c289cb"
@@ -9790,6 +9925,24 @@ unflowify@^1.0.0:
     flow-remove-types "^1.1.2"
     through "^2.3.8"
 
+unherit@^1.0.4:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/unherit/-/unherit-1.1.1.tgz#132748da3e88eab767e08fabfbb89c5e9d28628c"
+  dependencies:
+    inherits "^2.0.1"
+    xtend "^4.0.1"
+
+unified@^6.1.5:
+  version "6.2.0"
+  resolved "https://registry.yarnpkg.com/unified/-/unified-6.2.0.tgz#7fbd630f719126d67d40c644b7e3f617035f6dba"
+  dependencies:
+    bail "^1.0.0"
+    extend "^3.0.0"
+    is-plain-obj "^1.1.0"
+    trough "^1.0.0"
+    vfile "^2.0.0"
+    x-is-string "^0.1.0"
+
 uniq@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff"
@@ -9822,6 +9975,26 @@ unique-string@^1.0.0:
   dependencies:
     crypto-random-string "^1.0.0"
 
+unist-util-is@^2.1.1:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-2.1.2.tgz#1193fa8f2bfbbb82150633f3a8d2eb9a1c1d55db"
+
+unist-util-remove-position@^1.0.0:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/unist-util-remove-position/-/unist-util-remove-position-1.1.2.tgz#86b5dad104d0bbfbeb1db5f5c92f3570575c12cb"
+  dependencies:
+    unist-util-visit "^1.1.0"
+
+unist-util-stringify-position@^1.0.0, unist-util-stringify-position@^1.1.1:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-1.1.2.tgz#3f37fcf351279dcbca7480ab5889bb8a832ee1c6"
+
+unist-util-visit@^1.1.0, unist-util-visit@^1.3.0:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-1.3.1.tgz#c019ac9337a62486be58531bc27e7499ae7d55c7"
+  dependencies:
+    unist-util-is "^2.1.1"
+
 unpipe@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
@@ -9984,6 +10157,25 @@ verror@1.10.0:
     core-util-is "1.0.2"
     extsprintf "^1.2.0"
 
+vfile-location@^2.0.0:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/vfile-location/-/vfile-location-2.0.3.tgz#083ba80e50968e8d420be49dd1ea9a992131df77"
+
+vfile-message@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-1.0.1.tgz#51a2ccd8a6b97a7980bb34efb9ebde9632e93677"
+  dependencies:
+    unist-util-stringify-position "^1.1.1"
+
+vfile@^2.0.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/vfile/-/vfile-2.3.0.tgz#e62d8e72b20e83c324bc6c67278ee272488bf84a"
+  dependencies:
+    is-buffer "^1.1.4"
+    replace-ext "1.0.0"
+    unist-util-stringify-position "^1.0.0"
+    vfile-message "^1.0.0"
+
 viewport-mercator-project@^4.0.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/viewport-mercator-project/-/viewport-mercator-project-4.1.1.tgz#92b0611fa0041041d2f3568da3529a8a846017d0"
@@ -10268,6 +10460,10 @@ write@^0.2.1:
   dependencies:
     mkdirp "^0.5.1"
 
+x-is-string@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/x-is-string/-/x-is-string-0.1.0.tgz#474b50865af3a49a9c4657f05acd145458f77d82"
+
 xdg-basedir@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-2.0.0.tgz#edbc903cc385fc04523d966a335504b5504d1bd2"
diff --git a/superset/views/core.py b/superset/views/core.py
index b14050a..e9c1e00 100755
--- a/superset/views/core.py
+++ b/superset/views/core.py
@@ -1611,7 +1611,7 @@ class Superset(BaseSupersetView):
             ):
                 slice_id = value.get('meta').get('chartId')
                 slice_ids.append(slice_id)
-                slice_id_to_name[slice_id] = value.get('meta').get('chartName')
+                slice_id_to_name[slice_id] = value.get('meta').get('sliceName')
 
         session = db.session()
         Slice = models.Slice  # noqa


Mime
View raw message