superset-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From ccwilli...@apache.org
Subject [incubator-superset] 24/26: [dashboard v2] ui + ux fixes (#5208)
Date Fri, 22 Jun 2018 00:54:39 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 85ae429031ca34d2a8d1234e029bab77c098d179
Author: Chris Williams <williaster@users.noreply.github.com>
AuthorDate: Thu Jun 21 14:42:00 2018 -0700

    [dashboard v2] ui + ux fixes (#5208)
    
    * [dashboard v2] use <Loading /> throughout, small loading gif, improve row/column visual hierarchy, add cached data pop
    
    * [dashboard v2] lots of polish
    
    * [dashboard v2] remove markdown padding on edit, more opaque slice drag preview, unsavedChanges=true upon moving a component, fix initial load logging.
    
    * [dashboard v2] gray loading.gif, sticky header, undo/redo keyboard shortcuts, fix move component saved changes update, v0 double scrollbar fix
    
    * [dashboard v2] move UndoRedoKeylisteners into Header, render only in edit mode, show visual feedback for keyboard shortcut, hide hover menu in top-level tabs
    
    * [dashboard v2] fix grid + sidepane height issues
    
    * [dashboard v2] add auto-resize functionality, update tests. cache findParentId results.
    
    * [dashboard v2][tests] add getDetailedComponentWidth_spec.js
    
    * [dashboard v2] fix lint
---
 superset/assets/images/loading.gif                 | Bin 1945878 -> 79023 bytes
 .../dashboard/actions/dashboardLayout_spec.js      |  71 ++++---
 .../dashboard/components/DashboardGrid_spec.jsx    |  10 +-
 .../dashboard/util/dropOverflowsParent_spec.js     | 118 ++++++++++-
 .../util/getDetailedComponentWidth_spec.js         | 223 +++++++++++++++++++++
 .../dashboard/util/newEntitiesFromDrop_spec.js     |   3 +
 .../assets/src/SqlLab/components/QuerySearch.jsx   |  79 ++++----
 .../assets/src/SqlLab/components/ResultSet.jsx     |   3 +-
 superset/assets/src/components/Loading.jsx         |   9 +-
 .../src/dashboard/actions/dashboardLayout.js       |  41 +---
 .../dashboard/components/BuilderComponentPane.jsx  | 119 ++++++-----
 .../assets/src/dashboard/components/Dashboard.jsx  |  22 +-
 .../src/dashboard/components/DashboardBuilder.jsx  |  93 ++++-----
 .../src/dashboard/components/DashboardGrid.jsx     |  38 +++-
 .../assets/src/dashboard/components/Header.jsx     | 214 ++++++++++++--------
 .../assets/src/dashboard/components/SliceAdder.jsx |   9 +-
 .../dashboard/components/UndoRedoKeylisteners.jsx  |  47 +++++
 .../components/dnd/AddSliceDragPreview.jsx         |   4 +-
 .../src/dashboard/components/dnd/handleHover.js    |   2 +-
 .../components/gridComponents/Markdown.jsx         |  13 +-
 .../dashboard/components/gridComponents/Tab.jsx    |  45 ++++-
 .../dashboard/components/gridComponents/Tabs.jsx   |  20 +-
 .../dashboard/containers/DashboardComponent.jsx    |  21 +-
 .../deprecated/v1/components/SliceAdder.jsx        |   7 +-
 .../src/dashboard/reducers/dashboardLayout.js      |  20 ++
 .../dashboard/stylesheets/builder-sidepane.less    |   4 +-
 .../dashboard/stylesheets/components/chart.less    |  40 +++-
 .../dashboard/stylesheets/components/column.less   |   8 +-
 .../dashboard/stylesheets/components/header.less   |  26 ++-
 .../dashboard/stylesheets/components/markdown.less |   9 +
 .../src/dashboard/stylesheets/components/row.less  |   9 +-
 .../src/dashboard/stylesheets/components/tabs.less | 163 ++++++++-------
 .../src/dashboard/stylesheets/dashboard.less       |  49 ++---
 superset/assets/src/dashboard/stylesheets/dnd.less |  35 +++-
 .../assets/src/dashboard/stylesheets/grid.less     |  17 +-
 .../src/dashboard/stylesheets/popover-menu.less    |   5 +-
 .../src/dashboard/util/dropOverflowsParent.js      |  45 +----
 superset/assets/src/dashboard/util/findParentId.js |  14 +-
 .../assets/src/dashboard/util/getChildWidth.js     |  13 --
 .../dashboard/util/getComponentWidthFromDrop.js    |  57 ++++++
 .../dashboard/util/getDetailedComponentWidth.js    |  76 +++++++
 .../assets/src/dashboard/util/getDropPosition.js   |   2 +-
 .../src/dashboard/util/headerStyleOptions.js       |  18 +-
 .../src/dashboard/util/newEntitiesFromDrop.js      |   8 +
 .../src/explore/components/DisplayQueryButton.jsx  |  14 +-
 .../components/controls/DatasourceControl.jsx      |  58 +++---
 .../assets/src/profile/components/TableLoader.jsx  |  25 ++-
 superset/assets/src/welcome/DashboardTable.jsx     |   8 +-
 superset/assets/yarn.lock                          |  57 +++++-
 49 files changed, 1377 insertions(+), 614 deletions(-)

diff --git a/superset/assets/images/loading.gif b/superset/assets/images/loading.gif
index ae5cbdd..d82fc5d 100644
Binary files a/superset/assets/images/loading.gif and b/superset/assets/images/loading.gif differ
diff --git a/superset/assets/spec/javascripts/dashboard/actions/dashboardLayout_spec.js b/superset/assets/spec/javascripts/dashboard/actions/dashboardLayout_spec.js
index 84f0856..4b28480 100644
--- a/superset/assets/spec/javascripts/dashboard/actions/dashboardLayout_spec.js
+++ b/superset/assets/spec/javascripts/dashboard/actions/dashboardLayout_spec.js
@@ -43,7 +43,9 @@ import {
 
 describe('dashboardLayout actions', () => {
   const mockState = {
-    dashboardState: {},
+    dashboardState: {
+      hasUnsavedChanges: true, // don't dispatch setUnsavedChanges() after every action
+    },
     dashboardInfo: {},
     dashboardLayout: {
       past: [],
@@ -62,9 +64,7 @@ describe('dashboardLayout actions', () => {
 
   describe('updateComponents', () => {
     it('should dispatch an updateLayout action', () => {
-      const { getState, dispatch } = setup({
-        dashboardState: { hasUnsavedChanges: true },
-      });
+      const { getState, dispatch } = setup();
       const nextComponents = { 1: {} };
       const thunk = updateComponents(nextComponents);
       thunk(dispatch, getState);
@@ -91,9 +91,7 @@ describe('dashboardLayout actions', () => {
 
   describe('deleteComponents', () => {
     it('should dispatch an deleteComponent action', () => {
-      const { getState, dispatch } = setup({
-        dashboardState: { hasUnsavedChanges: true },
-      });
+      const { getState, dispatch } = setup();
       const thunk = deleteComponent('id', 'parentId');
       thunk(dispatch, getState);
       expect(dispatch.callCount).to.equal(1);
@@ -135,14 +133,14 @@ describe('dashboardLayout actions', () => {
           },
         },
       });
+
+      expect(dispatch.callCount).to.equal(2);
     });
   });
 
   describe('createTopLevelTabs', () => {
     it('should dispatch a createTopLevelTabs action', () => {
-      const { getState, dispatch } = setup({
-        dashboardState: { hasUnsavedChanges: true },
-      });
+      const { getState, dispatch } = setup();
       const dropResult = {};
       const thunk = createTopLevelTabs(dropResult);
       thunk(dispatch, getState);
@@ -169,9 +167,7 @@ describe('dashboardLayout actions', () => {
 
   describe('deleteTopLevelTabs', () => {
     it('should dispatch a deleteTopLevelTabs action', () => {
-      const { getState, dispatch } = setup({
-        dashboardState: { hasUnsavedChanges: true },
-      });
+      const { getState, dispatch } = setup();
       const dropResult = {};
       const thunk = deleteTopLevelTabs(dropResult);
       thunk(dispatch, getState);
@@ -213,7 +209,6 @@ describe('dashboardLayout actions', () => {
 
     it('should update the size of the component', () => {
       const { getState, dispatch } = setup({
-        dashboardState: { hasUnsavedChanges: true },
         dashboardLayout,
       });
 
@@ -239,6 +234,8 @@ describe('dashboardLayout actions', () => {
           },
         },
       });
+
+      expect(dispatch.callCount).to.equal(2);
     });
 
     it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => {
@@ -265,11 +262,11 @@ describe('dashboardLayout actions', () => {
         dragging: { id: NEW_ROW_ID, type: ROW_TYPE },
       };
 
-      const thunk1 = handleComponentDrop(dropResult);
-      thunk1(dispatch, getState);
+      const handleComponentDropThunk = handleComponentDrop(dropResult);
+      handleComponentDropThunk(dispatch, getState);
 
-      const thunk2 = dispatch.getCall(0).args[0];
-      thunk2(dispatch, getState);
+      const createComponentThunk = dispatch.getCall(0).args[0];
+      createComponentThunk(dispatch, getState);
 
       expect(dispatch.getCall(1).args[0]).to.deep.equal({
         type: CREATE_COMPONENT,
@@ -277,36 +274,47 @@ describe('dashboardLayout actions', () => {
           dropResult,
         },
       });
+
+      expect(dispatch.callCount).to.equal(2);
     });
 
     it('should move a component if the component is not new', () => {
       const { getState, dispatch } = setup({
-        dashboardLayout: { present: { id: { type: ROW_TYPE, children: [] } } },
+        dashboardLayout: {
+          // if 'dragging' is not only child will dispatch deleteComponent thunk
+          present: { id: { type: ROW_TYPE, children: ['_'] } },
+        },
       });
       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 },
+        dragging: { id: 'dragging', type: ROW_TYPE },
       };
 
-      const thunk = handleComponentDrop(dropResult);
-      thunk(dispatch, getState);
+      const handleComponentDropThunk = handleComponentDrop(dropResult);
+      handleComponentDropThunk(dispatch, getState);
 
-      expect(dispatch.getCall(0).args[0]).to.deep.equal({
+      const moveComponentThunk = dispatch.getCall(0).args[0];
+      moveComponentThunk(dispatch, getState);
+
+      expect(dispatch.getCall(1).args[0]).to.deep.equal({
         type: MOVE_COMPONENT,
         payload: {
           dropResult,
         },
       });
+
+      expect(dispatch.callCount).to.equal(2);
     });
 
     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 } },
+            source: { type: ROW_TYPE },
+            destination: { type: ROW_TYPE, children: ['rowChild'] },
+            dragging: { type: CHART_TYPE, meta: { width: 1 } },
+            rowChild: { type: CHART_TYPE, meta: { width: 12 } },
           },
         },
       });
@@ -321,6 +329,8 @@ describe('dashboardLayout actions', () => {
       expect(dispatch.getCall(0).args[0].type).to.deep.equal(
         addInfoToast('').type,
       );
+
+      expect(dispatch.callCount).to.equal(1);
     });
 
     it('should delete a parent Row or Tabs if the moved child was the only child', () => {
@@ -358,6 +368,9 @@ describe('dashboardLayout actions', () => {
           parentId: 'parentId',
         },
       });
+
+      // move thunk, delete thunk, delete result actions
+      expect(dispatch.callCount).to.equal(3);
     });
 
     it('should create top-level tabs if dropped on root', () => {
@@ -380,6 +393,8 @@ describe('dashboardLayout actions', () => {
           dropResult,
         },
       });
+
+      expect(dispatch.callCount).to.equal(2);
     });
   });
 
@@ -413,9 +428,7 @@ describe('dashboardLayout actions', () => {
 
   describe('redoLayoutAction', () => {
     it('should dispatch a redux-undo .redo() action ', () => {
-      const { getState, dispatch } = setup({
-        dashboardState: { hasUnsavedChanges: true },
-      });
+      const { getState, dispatch } = setup();
       const thunk = redoLayoutAction();
       thunk(dispatch, getState);
 
diff --git a/superset/assets/spec/javascripts/dashboard/components/DashboardGrid_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/DashboardGrid_spec.jsx
index 1160d65..d11c37f 100644
--- a/superset/assets/spec/javascripts/dashboard/components/DashboardGrid_spec.jsx
+++ b/superset/assets/spec/javascripts/dashboard/components/DashboardGrid_spec.jsx
@@ -42,11 +42,11 @@ describe('DashboardGrid', () => {
     expect(wrapper.find(DashboardComponent)).to.have.length(2);
   });
 
-  it('should render an empty DragDroppables in editMode to increase the drop target zone', () => {
-    const withChildren = setup({ editMode: false });
-    const withoutChildren = setup({ editMode: true });
-    expect(withChildren.find(DragDroppable)).to.have.length(0);
-    expect(withoutChildren.find(DragDroppable)).to.have.length(1);
+  it('should render two empty DragDroppables in editMode to increase the drop target zone', () => {
+    const viewMode = setup({ editMode: false });
+    const editMode = setup({ editMode: true });
+    expect(viewMode.find(DragDroppable)).to.have.length(0);
+    expect(editMode.find(DragDroppable)).to.have.length(2);
   });
 
   it('should render grid column guides when resizing', () => {
diff --git a/superset/assets/spec/javascripts/dashboard/util/dropOverflowsParent_spec.js b/superset/assets/spec/javascripts/dashboard/util/dropOverflowsParent_spec.js
index b153e1e..8e6f889 100644
--- a/superset/assets/spec/javascripts/dashboard/util/dropOverflowsParent_spec.js
+++ b/superset/assets/spec/javascripts/dashboard/util/dropOverflowsParent_spec.js
@@ -7,6 +7,8 @@ import {
   CHART_TYPE,
   COLUMN_TYPE,
   ROW_TYPE,
+  HEADER_TYPE,
+  TAB_TYPE,
 } from '../../../../src/dashboard/util/componentTypes';
 
 describe('dropOverflowsParent', () => {
@@ -42,7 +44,7 @@ describe('dropOverflowsParent', () => {
     expect(dropOverflowsParent(dropResult, layout)).to.equal(true);
   });
 
-  it('returns false if a parent DOES not have adequate width for child', () => {
+  it('returns false if a parent DOES have adequate width for child', () => {
     const dropResult = {
       source: { id: '_' },
       destination: { id: 'a' },
@@ -74,9 +76,41 @@ describe('dropOverflowsParent', () => {
     expect(dropOverflowsParent(dropResult, layout)).to.equal(false);
   });
 
-  it('it should base result off of column width (instead of its children) if dropped on column', () => {
+  it('returns false if a child CAN shrink to available parent space', () => {
     const dropResult = {
-      source: { id: 'z' },
+      source: { id: '_' },
+      destination: { id: 'a' },
+      dragging: { id: 'z' },
+    };
+
+    const layout = {
+      a: {
+        id: 'a',
+        type: ROW_TYPE,
+        children: ['b', 'b'], // 2x b = 10
+      },
+      b: {
+        id: 'b',
+        type: CHART_TYPE,
+        meta: {
+          width: 5,
+        },
+      },
+      z: {
+        id: 'z',
+        type: CHART_TYPE,
+        meta: {
+          width: 10,
+        },
+      },
+    };
+
+    expect(dropOverflowsParent(dropResult, layout)).to.equal(false);
+  });
+
+  it('returns true if a child CANNOT shrink to available parent space', () => {
+    const dropResult = {
+      source: { id: '_' },
       destination: { id: 'a' },
       dragging: { id: 'b' },
     };
@@ -85,24 +119,71 @@ describe('dropOverflowsParent', () => {
       a: {
         id: 'a',
         type: COLUMN_TYPE,
-        meta: { width: 10 },
+        meta: {
+          width: 6,
+        },
       },
+      // rows with children cannot shrink
       b: {
         id: 'b',
+        type: ROW_TYPE,
+        children: ['bChild', 'bChild', 'bChild'],
+      },
+      bChild: {
+        id: 'bChild',
         type: CHART_TYPE,
         meta: {
-          width: 2,
+          width: 3,
         },
       },
     };
 
-    expect(dropOverflowsParent(dropResult, layout)).to.equal(false);
+    expect(dropOverflowsParent(dropResult, layout)).to.equal(true);
+  });
+
+  it('returns true if a column has children that CANNOT shrink to available parent space', () => {
+    const dropResult = {
+      source: { id: '_' },
+      destination: { id: 'destination' },
+      dragging: { id: 'dragging' },
+    };
+
+    const layout = {
+      destination: {
+        id: 'destination',
+        type: ROW_TYPE,
+        children: ['b', 'b'], // 2x b = 10, 2 available
+      },
+      b: {
+        id: 'b',
+        type: CHART_TYPE,
+        meta: {
+          width: 5,
+        },
+      },
+      dragging: {
+        id: 'dragging',
+        type: COLUMN_TYPE,
+        meta: {
+          width: 10,
+        },
+        children: ['rowWithChildren'], // 2x b = width 10
+      },
+      rowWithChildren: {
+        id: 'rowWithChildren',
+        type: ROW_TYPE,
+        children: ['b', 'b'],
+      },
+    };
+
+    expect(dropOverflowsParent(dropResult, layout)).to.equal(true);
+    // remove children
     expect(
       dropOverflowsParent(dropResult, {
         ...layout,
-        a: { ...layout.a, meta: { width: 1 } },
+        dragging: { ...layout.dragging, children: [] },
       }),
-    ).to.equal(true);
+    ).to.equal(false);
   });
 
   it('should work with new components that are not in the layout', () => {
@@ -122,4 +203,25 @@ describe('dropOverflowsParent', () => {
 
     expect(dropOverflowsParent(dropResult, layout)).to.equal(false);
   });
+
+  it('source/destination without widths should not overflow parent', () => {
+    const dropResult = {
+      source: { id: '_' },
+      destination: { id: 'tab' },
+      dragging: { id: 'header' },
+    };
+
+    const layout = {
+      tab: {
+        id: 'tab',
+        type: TAB_TYPE,
+      },
+      header: {
+        id: 'header',
+        type: HEADER_TYPE,
+      },
+    };
+
+    expect(dropOverflowsParent(dropResult, layout)).to.equal(false);
+  });
 });
diff --git a/superset/assets/spec/javascripts/dashboard/util/getDetailedComponentWidth_spec.js b/superset/assets/spec/javascripts/dashboard/util/getDetailedComponentWidth_spec.js
new file mode 100644
index 0000000..99e2282
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/getDetailedComponentWidth_spec.js
@@ -0,0 +1,223 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import getDetailedComponentWidth from '../../../../src/dashboard/util/getDetailedComponentWidth';
+import * as types from '../../../../src/dashboard/util/componentTypes';
+import {
+  GRID_COLUMN_COUNT,
+  GRID_MIN_COLUMN_COUNT,
+} from '../../../../src/dashboard/util/constants';
+
+describe('getDetailedComponentWidth', () => {
+  it('should return an object with width, minimumWidth, and occupiedWidth', () => {
+    expect(
+      getDetailedComponentWidth({ id: '_', components: {} }),
+    ).to.have.all.keys(['minimumWidth', 'occupiedWidth', 'width']);
+  });
+
+  describe('width', () => {
+    it('should be undefined if the component is not resizable and has no defined width', () => {
+      const empty = {
+        width: undefined,
+        occupiedWidth: undefined,
+        minimumWidth: undefined,
+      };
+
+      expect(
+        getDetailedComponentWidth({
+          component: { id: '', type: types.HEADER_TYPE },
+        }),
+      ).to.deep.equal(empty);
+
+      expect(
+        getDetailedComponentWidth({
+          component: { id: '', type: types.DIVIDER_TYPE },
+        }),
+      ).to.deep.equal(empty);
+
+      expect(
+        getDetailedComponentWidth({
+          component: { id: '', type: types.TAB_TYPE },
+        }),
+      ).to.deep.equal(empty);
+    });
+
+    it('should match component meta width for resizeable components', () => {
+      expect(
+        getDetailedComponentWidth({
+          component: { id: '', type: types.CHART_TYPE, meta: { width: 1 } },
+        }),
+      ).to.deep.equal({ width: 1, occupiedWidth: 1, minimumWidth: 1 });
+
+      expect(
+        getDetailedComponentWidth({
+          component: { id: '', type: types.MARKDOWN_TYPE, meta: { width: 2 } },
+        }),
+      ).to.deep.equal({ width: 2, occupiedWidth: 2, minimumWidth: 1 });
+
+      expect(
+        getDetailedComponentWidth({
+          component: { id: '', type: types.COLUMN_TYPE, meta: { width: 3 } },
+        }),
+        // note: occupiedWidth is zero for colunns/see test below
+      ).to.deep.equal({ width: 3, occupiedWidth: 0, minimumWidth: 1 });
+    });
+
+    it('should be GRID_COLUMN_COUNT for row components WITHOUT parents', () => {
+      expect(
+        getDetailedComponentWidth({
+          id: 'row',
+          components: { row: { id: 'row', type: types.ROW_TYPE } },
+        }),
+      ).to.deep.equal({
+        width: GRID_COLUMN_COUNT,
+        occupiedWidth: 0,
+        minimumWidth: GRID_MIN_COLUMN_COUNT,
+      });
+    });
+
+    it('should match parent width for row components WITH parents', () => {
+      expect(
+        getDetailedComponentWidth({
+          id: 'row',
+          components: {
+            row: { id: 'row', type: types.ROW_TYPE },
+            parent: {
+              id: 'parent',
+              type: types.COLUMN_TYPE,
+              children: ['row'],
+              meta: { width: 7 },
+            },
+          },
+        }),
+      ).to.deep.equal({
+        width: 7,
+        occupiedWidth: 0,
+        minimumWidth: GRID_MIN_COLUMN_COUNT,
+      });
+    });
+
+    it('should use either id or component (to support new components)', () => {
+      expect(
+        getDetailedComponentWidth({
+          id: 'id',
+          components: {
+            id: { id: 'id', type: types.CHART_TYPE, meta: { width: 6 } },
+          },
+        }).width,
+      ).to.equal(6);
+
+      expect(
+        getDetailedComponentWidth({
+          component: { id: 'id', type: types.CHART_TYPE, meta: { width: 6 } },
+        }).width,
+      ).to.equal(6);
+    });
+  });
+
+  describe('occupiedWidth', () => {
+    it('should reflect the sum of child widths for row components', () => {
+      expect(
+        getDetailedComponentWidth({
+          id: 'row',
+          components: {
+            row: {
+              id: 'row',
+              type: types.ROW_TYPE,
+              children: ['child', 'child'],
+            },
+            child: { id: 'child', meta: { width: 3.5 } },
+          },
+        }),
+      ).to.deep.equal({ width: 12, occupiedWidth: 7, minimumWidth: 7 });
+    });
+
+    it('should always be zero for column components', () => {
+      expect(
+        getDetailedComponentWidth({
+          component: { id: '', type: types.COLUMN_TYPE, meta: { width: 2 } },
+        }),
+      ).to.deep.equal({ width: 2, occupiedWidth: 0, minimumWidth: 1 });
+    });
+  });
+
+  describe('minimumWidth', () => {
+    it('should equal GRID_MIN_COLUMN_COUNT for resizable components', () => {
+      expect(
+        getDetailedComponentWidth({
+          component: { id: '', type: types.CHART_TYPE, meta: { width: 1 } },
+        }),
+      ).to.deep.equal({
+        width: 1,
+        minimumWidth: GRID_MIN_COLUMN_COUNT,
+        occupiedWidth: 1,
+      });
+
+      expect(
+        getDetailedComponentWidth({
+          component: { id: '', type: types.MARKDOWN_TYPE, meta: { width: 2 } },
+        }),
+      ).to.deep.equal({
+        width: 2,
+        minimumWidth: GRID_MIN_COLUMN_COUNT,
+        occupiedWidth: 2,
+      });
+
+      expect(
+        getDetailedComponentWidth({
+          component: { id: '', type: types.COLUMN_TYPE, meta: { width: 3 } },
+        }),
+      ).to.deep.equal({
+        width: 3,
+        minimumWidth: GRID_MIN_COLUMN_COUNT,
+        occupiedWidth: 0,
+      });
+    });
+
+    it('should equal the width of row children for column components with row children', () => {
+      expect(
+        getDetailedComponentWidth({
+          id: 'column',
+          components: {
+            column: {
+              id: 'column',
+              type: types.COLUMN_TYPE,
+              children: ['rowChild', 'ignoredChartChild'],
+              meta: { width: 12 },
+            },
+            rowChild: {
+              id: 'rowChild',
+              type: types.ROW_TYPE,
+              children: ['rowChildChild', 'rowChildChild'],
+            },
+            rowChildChild: {
+              id: 'rowChildChild',
+              meta: { width: 3.5 },
+            },
+            ignoredChartChild: {
+              id: 'ignoredChartChild',
+              meta: { width: 100 },
+            },
+          },
+        }),
+        // occupiedWidth is zero for colunns/see test below
+      ).to.deep.equal({ width: 12, occupiedWidth: 0, minimumWidth: 7 });
+    });
+
+    it('should equal occupiedWidth for row components', () => {
+      expect(
+        getDetailedComponentWidth({
+          id: 'row',
+          components: {
+            row: {
+              id: 'row',
+              type: types.ROW_TYPE,
+              children: ['child', 'child'],
+            },
+            child: { id: 'child', meta: { width: 3.5 } },
+          },
+        }),
+      ).to.deep.equal({ width: 12, occupiedWidth: 7, minimumWidth: 7 });
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/newEntitiesFromDrop_spec.js b/superset/assets/spec/javascripts/dashboard/util/newEntitiesFromDrop_spec.js
index 677c329..8d00c18 100644
--- a/superset/assets/spec/javascripts/dashboard/util/newEntitiesFromDrop_spec.js
+++ b/superset/assets/spec/javascripts/dashboard/util/newEntitiesFromDrop_spec.js
@@ -16,6 +16,7 @@ describe('newEntitiesFromDrop', () => {
       dropResult: {
         destination: { id: 'a', index: 0 },
         dragging: { type: CHART_TYPE },
+        source: { id: 'b', index: 0 },
       },
       layout: {
         a: {
@@ -37,6 +38,7 @@ describe('newEntitiesFromDrop', () => {
       dropResult: {
         destination: { id: 'a', index: 0 },
         dragging: { type: TABS_TYPE },
+        source: { id: 'b', index: 0 },
       },
       layout: {
         a: {
@@ -61,6 +63,7 @@ describe('newEntitiesFromDrop', () => {
       dropResult: {
         destination: { id: 'a', index: 0 },
         dragging: { type: CHART_TYPE },
+        source: { id: 'b', index: 0 },
       },
       layout: {
         a: {
diff --git a/superset/assets/src/SqlLab/components/QuerySearch.jsx b/superset/assets/src/SqlLab/components/QuerySearch.jsx
index 9d36d85..45924e3 100644
--- a/superset/assets/src/SqlLab/components/QuerySearch.jsx
+++ b/superset/assets/src/SqlLab/components/QuerySearch.jsx
@@ -2,14 +2,19 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { Button } from 'react-bootstrap';
 import Select from 'react-select';
+import Loading from '../../components/Loading';
 import QueryTable from './QueryTable';
-import { now, epochTimeXHoursAgo,
-  epochTimeXDaysAgo, epochTimeXYearsAgo } from '../../modules/dates';
+import {
+  now,
+  epochTimeXHoursAgo,
+  epochTimeXDaysAgo,
+  epochTimeXYearsAgo,
+} from '../../modules/dates';
 import { STATUS_OPTIONS, TIME_OPTIONS } from '../constants';
 import AsyncSelect from '../../components/AsyncSelect';
 import { t } from '../../locales';
 
-const $ = window.$ = require('jquery');
+const $ = (window.$ = require('jquery'));
 
 const propTypes = {
   actions: PropTypes.object.isRequired,
@@ -47,13 +52,17 @@ class QuerySearch extends React.PureComponent {
     this.refreshQueries();
   }
   onUserClicked(userId) {
-    this.setState({ userId }, () => { this.refreshQueries(); });
+    this.setState({ userId }, () => {
+      this.refreshQueries();
+    });
   }
   onDbClicked(dbId) {
-    this.setState({ databaseId: dbId }, () => { this.refreshQueries(); });
+    this.setState({ databaseId: dbId }, () => {
+      this.refreshQueries();
+    });
   }
   onChange(db) {
-    const val = (db) ? db.value : null;
+    const val = db ? db.value : null;
     this.setState({ databaseId: val });
   }
   getTimeFromSelection(selection) {
@@ -77,25 +86,25 @@ class QuerySearch extends React.PureComponent {
     }
   }
   changeFrom(user) {
-    const val = (user) ? user.value : null;
+    const val = user ? user.value : null;
     this.setState({ from: val });
   }
   changeTo(status) {
-    const val = (status) ? status.value : null;
+    const val = status ? status.value : null;
     this.setState({ to: val });
   }
   changeUser(user) {
-    const val = (user) ? user.value : null;
+    const val = user ? user.value : null;
     this.setState({ userId: val });
   }
   insertParams(baseUrl, params) {
-    const validParams = params.filter(
-      function (p) { return p !== ''; },
-    );
+    const validParams = params.filter(function (p) {
+      return p !== '';
+    });
     return baseUrl + '?' + validParams.join('&');
   }
   changeStatus(status) {
-    const val = (status) ? status.value : null;
+    const val = status ? status.value : null;
     this.setState({ status: val });
   }
   changeSearch(event) {
@@ -120,7 +129,7 @@ class QuerySearch extends React.PureComponent {
     if (data.result.length === 0) {
       this.props.actions.addAlert({
         bsStyle: 'danger',
-        msg: t('It seems you don\'t have access to any database'),
+        msg: t("It seems you don't have access to any database"),
       });
     }
     return options;
@@ -175,8 +184,10 @@ class QuerySearch extends React.PureComponent {
             <Select
               name="select-from"
               placeholder={t('[From]-')}
-              options={TIME_OPTIONS
-                .slice(1, TIME_OPTIONS.length).map(xt => ({ value: xt, label: xt }))}
+              options={TIME_OPTIONS.slice(1, TIME_OPTIONS.length).map(xt => ({
+                value: xt,
+                label: xt,
+              }))}
               value={this.state.from}
               autosize={false}
               onChange={this.changeFrom}
@@ -206,29 +217,21 @@ class QuerySearch extends React.PureComponent {
             </Button>
           </div>
         </div>
-        {this.state.queriesLoading ?
-          (<img className="loading" alt="Loading..." src="/static/assets/images/loading.gif" />)
-          :
-          (
-            <div className="scrollbar-container">
-              <div
-                className="scrollbar-content"
-                style={{ height: this.props.height }}
-              >
-                <QueryTable
-                  columns={[
-                    'state', 'db', 'user', 'time',
-                    'progress', 'rows', 'sql', 'querylink',
-                  ]}
-                  onUserClicked={this.onUserClicked}
-                  onDbClicked={this.onDbClicked}
-                  queries={this.state.queriesArray}
-                  actions={this.props.actions}
-                />
-              </div>
+        {this.state.queriesLoading ? (
+          <Loading />
+        ) : (
+          <div className="scrollbar-container">
+            <div className="scrollbar-content" style={{ height: this.props.height }}>
+              <QueryTable
+                columns={['state', 'db', 'user', 'time', 'progress', 'rows', 'sql', 'querylink']}
+                onUserClicked={this.onUserClicked}
+                onDbClicked={this.onDbClicked}
+                queries={this.state.queriesArray}
+                actions={this.props.actions}
+              />
             </div>
-          )
-        }
+          </div>
+        )}
       </div>
     );
   }
diff --git a/superset/assets/src/SqlLab/components/ResultSet.jsx b/superset/assets/src/SqlLab/components/ResultSet.jsx
index 4959921..67c8fd5 100644
--- a/superset/assets/src/SqlLab/components/ResultSet.jsx
+++ b/superset/assets/src/SqlLab/components/ResultSet.jsx
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
 import { Alert, Button, ButtonGroup, ProgressBar } from 'react-bootstrap';
 import shortid from 'shortid';
 
+import Loading from '../../components/Loading';
 import VisualizeModal from './VisualizeModal';
 import HighlightedSql from './HighlightedSql';
 import FilterableTable from '../../components/FilterableTable/FilterableTable';
@@ -238,7 +239,7 @@ export default class ResultSet extends React.PureComponent {
     }
     return (
       <div>
-        <img className="loading" alt={t('Loading...')} src="/static/assets/images/loading.gif" />
+        <Loading />
         <QueryStateLabel query={query} />
         {progressBar}
         <div>
diff --git a/superset/assets/src/components/Loading.jsx b/superset/assets/src/components/Loading.jsx
index 810c581..953e702 100644
--- a/superset/assets/src/components/Loading.jsx
+++ b/superset/assets/src/components/Loading.jsx
@@ -5,7 +5,7 @@ const propTypes = {
   size: PropTypes.number,
 };
 const defaultProps = {
-  size: 25,
+  size: 50,
 };
 
 export default function Loading(props) {
@@ -15,17 +15,18 @@ export default function Loading(props) {
       alt="Loading..."
       src="/static/assets/images/loading.gif"
       style={{
-        width: props.size,
-        height: props.size,
+        width: Math.min(props.size, 50),
+        // height is auto
         padding: 0,
         margin: 0,
         position: 'absolute',
         left: '50%',
         top: '50%',
-        transform: 'translate(-50%, -60%)',
+        transform: 'translate(-50%, -50%)',
       }}
     />
   );
 }
+
 Loading.propTypes = propTypes;
 Loading.defaultProps = defaultProps;
diff --git a/superset/assets/src/dashboard/actions/dashboardLayout.js b/superset/assets/src/dashboard/actions/dashboardLayout.js
index c4908b0..bd01146 100644
--- a/superset/assets/src/dashboard/actions/dashboardLayout.js
+++ b/superset/assets/src/dashboard/actions/dashboardLayout.js
@@ -2,16 +2,10 @@ import { ActionCreators as UndoActionCreators } from 'redux-undo';
 
 import { addInfoToast } from './messageToasts';
 import { setUnsavedChanges } from './dashboardState';
-import {
-  CHART_TYPE,
-  MARKDOWN_TYPE,
-  TABS_TYPE,
-  ROW_TYPE,
-} from '../util/componentTypes';
+import { TABS_TYPE, ROW_TYPE } from '../util/componentTypes';
 import {
   DASHBOARD_ROOT_ID,
   NEW_COMPONENTS_SOURCE_ID,
-  GRID_MIN_COLUMN_COUNT,
   DASHBOARD_HEADER_ID,
 } from '../util/constants';
 import dropOverflowsParent from '../util/dropOverflowsParent';
@@ -117,22 +111,6 @@ export function resizeComponent({ id, width, height }) {
         },
       };
 
-      // set any resizable children to have a minimum width so that
-      // the chances that they are validly movable to future containers is maximized
-      component.children.forEach(childId => {
-        const child = dashboard[childId];
-        if ([CHART_TYPE, MARKDOWN_TYPE].includes(child.type)) {
-          updatedComponents[childId] = {
-            ...child,
-            meta: {
-              ...child.meta,
-              width: GRID_MIN_COLUMN_COUNT,
-              height: height || child.meta.height,
-            },
-          };
-        }
-      });
-
       dispatch(updateComponents(updatedComponents));
     }
   };
@@ -140,14 +118,12 @@ export function resizeComponent({ id, width, height }) {
 
 // Drag and drop --------------------------------------------------------------
 export const MOVE_COMPONENT = 'MOVE_COMPONENT';
-function moveComponent(dropResult) {
-  return {
-    type: MOVE_COMPONENT,
-    payload: {
-      dropResult,
-    },
-  };
-}
+const moveComponent = setUnsavedChangesAfterAction(dropResult => ({
+  type: MOVE_COMPONENT,
+  payload: {
+    dropResult,
+  },
+}));
 
 export const HANDLE_COMPONENT_DROP = 'HANDLE_COMPONENT_DROP';
 export function handleComponentDrop(dropResult) {
@@ -160,7 +136,7 @@ export function handleComponentDrop(dropResult) {
     if (overflowsParent) {
       return dispatch(
         addInfoToast(
-          `Parent does not have enough space for this component. Try decreasing its width or add it to a new row.`,
+          `There is not enough space for this component. Try decreasing its width, or increasing the destination width.`,
         ),
       );
     }
@@ -191,6 +167,7 @@ export function handleComponentDrop(dropResult) {
       if (
         (sourceComponent.type === TABS_TYPE ||
           sourceComponent.type === ROW_TYPE) &&
+        sourceComponent.children &&
         sourceComponent.children.length === 0
       ) {
         const parentId = findParentId({
diff --git a/superset/assets/src/dashboard/components/BuilderComponentPane.jsx b/superset/assets/src/dashboard/components/BuilderComponentPane.jsx
index c35a637..aafee5d 100644
--- a/superset/assets/src/dashboard/components/BuilderComponentPane.jsx
+++ b/superset/assets/src/dashboard/components/BuilderComponentPane.jsx
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
 import React from 'react';
 import cx from 'classnames';
 import { StickyContainer, Sticky } from 'react-sticky';
+import ParentSize from '@vx/responsive/build/components/ParentSize';
 
 import NewColumn from './gridComponents/new/NewColumn';
 import NewDivider from './gridComponents/new/NewDivider';
@@ -13,6 +14,8 @@ import NewMarkdown from './gridComponents/new/NewMarkdown';
 import SliceAdder from '../containers/SliceAdder';
 import { t } from '../../locales';
 
+const SUPERSET_HEADER_HEIGHT = 59;
+
 const propTypes = {
   topOffset: PropTypes.number,
   toggleBuilderPane: PropTypes.func.isRequired,
@@ -42,62 +45,80 @@ class BuilderComponentPane extends React.PureComponent {
   render() {
     const { topOffset } = this.props;
     return (
-      <StickyContainer className="dashboard-builder-sidepane">
-        <Sticky topOffset={-topOffset}>
-          {({ style, calculatedHeight, isSticky }) => (
-            <div
-              className="viewport"
-              style={isSticky ? { ...style, top: topOffset } : null}
-            >
-              <div
-                className={cx('slider-container', this.state.slideDirection)}
-              >
-                <div className="component-layer slide-content">
-                  <div className="dashboard-builder-sidepane-header">
-                    <span>{t('Insert')}</span>
-                    <i
-                      className="fa fa-times trigger"
-                      onClick={this.props.toggleBuilderPane}
-                      role="none"
-                    />
-                  </div>
+      <div
+        className="dashboard-builder-sidepane"
+        style={{
+          height: `calc(100vh - ${topOffset + SUPERSET_HEADER_HEIGHT}px)`,
+        }}
+      >
+        <ParentSize>
+          {({ height }) => (
+            <StickyContainer>
+              <Sticky topOffset={-topOffset} bottomOffset={Infinity}>
+                {({ style, isSticky }) => (
                   <div
-                    className="new-component static"
-                    role="none"
-                    onClick={this.openSlicesPane}
+                    className="viewport"
+                    style={isSticky ? { ...style, top: topOffset } : null}
                   >
-                    <div className="new-component-placeholder fa fa-area-chart" />
-                    <div className="new-component-label">
-                      {t('Charts & filters')}
-                    </div>
+                    <div
+                      className={cx(
+                        'slider-container',
+                        this.state.slideDirection,
+                      )}
+                    >
+                      <div className="component-layer slide-content">
+                        <div className="dashboard-builder-sidepane-header">
+                          <span>{t('Insert')}</span>
+                          <i
+                            className="fa fa-times trigger"
+                            onClick={this.props.toggleBuilderPane}
+                            role="none"
+                          />
+                        </div>
+                        <div
+                          className="new-component static"
+                          role="none"
+                          onClick={this.openSlicesPane}
+                        >
+                          <div className="new-component-placeholder fa fa-area-chart" />
+                          <div className="new-component-label">
+                            {t('Your charts & filters')}
+                          </div>
 
-                    <i className="fa fa-arrow-right trigger" />
-                  </div>
+                          <i className="fa fa-arrow-right trigger" />
+                        </div>
 
-                  <NewTabs />
-                  <NewRow />
-                  <NewColumn />
+                        <NewTabs />
+                        <NewRow />
+                        <NewColumn />
 
-                  <NewHeader />
-                  <NewMarkdown />
-                  <NewDivider />
-                </div>
-                <div className="slices-layer slide-content">
-                  <div
-                    className="dashboard-builder-sidepane-header"
-                    onClick={this.closeSlicesPane}
-                    role="none"
-                  >
-                    <i className="fa fa-arrow-left trigger" />
-                    <span>{t('All components')}</span>
+                        <NewHeader />
+                        <NewMarkdown />
+                        <NewDivider />
+                      </div>
+                      <div className="slices-layer slide-content">
+                        <div
+                          className="dashboard-builder-sidepane-header"
+                          onClick={this.closeSlicesPane}
+                          role="none"
+                        >
+                          <i className="fa fa-arrow-left trigger" />
+                          <span>{t('All components')}</span>
+                        </div>
+                        <SliceAdder
+                          height={
+                            height + (isSticky ? SUPERSET_HEADER_HEIGHT : 0)
+                          }
+                        />
+                      </div>
+                    </div>
                   </div>
-                  <SliceAdder height={calculatedHeight} />
-                </div>
-              </div>
-            </div>
+                )}
+              </Sticky>
+            </StickyContainer>
           )}
-        </Sticky>
-      </StickyContainer>
+        </ParentSize>
+      </div>
     );
   }
 }
diff --git a/superset/assets/src/dashboard/components/Dashboard.jsx b/superset/assets/src/dashboard/components/Dashboard.jsx
index 99e93aa..f069cd1 100644
--- a/superset/assets/src/dashboard/components/Dashboard.jsx
+++ b/superset/assets/src/dashboard/components/Dashboard.jsx
@@ -77,10 +77,10 @@ class Dashboard extends React.PureComponent {
       eventNames: DASHBOARD_EVENT_NAMES,
     });
     Logger.start(this.actionLog);
+    this.initTs = new Date().getTime();
   }
 
   componentDidMount() {
-    this.ts_mount = new Date().getTime();
     Logger.append(LOG_ACTIONS_MOUNT_DASHBOARD);
   }
 
@@ -91,19 +91,22 @@ class Dashboard extends React.PureComponent {
         : 'v2';
       // log pane loads
       const loadedPaneIds = [];
-      const allPanesDidLoad = Object.entries(nextProps.loadStats).every(
+      let minQueryStartTime = Infinity;
+      const allVisiblePanesDidLoad = Object.entries(nextProps.loadStats).every(
         ([paneId, stats]) => {
-          const { didLoad, minQueryStartTime, ...restStats } = stats;
-
+          const {
+            didLoad,
+            minQueryStartTime: paneMinQueryStart,
+            ...restStats
+          } = stats;
           if (
             didLoad &&
             this.props.loadStats[paneId] &&
             !this.props.loadStats[paneId].didLoad
           ) {
-            const duration = new Date().getTime() - minQueryStartTime;
             Logger.append(LOG_ACTIONS_LOAD_DASHBOARD_PANE, {
               ...restStats,
-              duration,
+              duration: new Date().getTime() - paneMinQueryStart,
               version,
             });
 
@@ -113,15 +116,18 @@ class Dashboard extends React.PureComponent {
           }
           if (this.isFirstLoad && didLoad && stats.slice_ids.length > 0) {
             loadedPaneIds.push(paneId);
+            minQueryStartTime = Math.min(minQueryStartTime, paneMinQueryStart);
           }
+
+          // return true if it is loaded, or it's index is not 0
           return didLoad || stats.index !== 0;
         },
       );
 
-      if (allPanesDidLoad && this.isFirstLoad) {
+      if (allVisiblePanesDidLoad && this.isFirstLoad) {
         Logger.append(LOG_ACTIONS_FIRST_DASHBOARD_LOAD, {
           pane_ids: loadedPaneIds,
-          duration: new Date().getTime() - this.ts_mount,
+          duration: new Date().getTime() - minQueryStartTime,
           version,
         });
         Logger.send(this.actionLog);
diff --git a/superset/assets/src/dashboard/components/DashboardBuilder.jsx b/superset/assets/src/dashboard/components/DashboardBuilder.jsx
index 59a9152..9621a49 100644
--- a/superset/assets/src/dashboard/components/DashboardBuilder.jsx
+++ b/superset/assets/src/dashboard/components/DashboardBuilder.jsx
@@ -26,6 +26,7 @@ import {
 } from '../util/constants';
 
 const TABS_HEIGHT = 47;
+const HEADER_HEIGHT = 67;
 
 const propTypes = {
   // redux
@@ -96,52 +97,52 @@ class DashboardBuilder extends React.Component {
       <StickyContainer
         className={cx('dashboard', editMode && 'dashboard--editing')}
       >
-        <DragDroppable
-          component={dashboardRoot}
-          parentComponent={null}
-          depth={DASHBOARD_ROOT_DEPTH}
-          index={0}
-          orientation="column"
-          onDrop={handleComponentDrop}
-          editMode
-          // you cannot drop on/displace tabs if they already exist
-          disableDragdrop={!editMode || topLevelTabs}
-        >
-          {({ dropIndicatorProps }) => (
-            <div>
-              <DashboardHeader />
-              {dropIndicatorProps && <div {...dropIndicatorProps} />}
-            </div>
+        <Sticky>
+          {({ style }) => (
+            <DragDroppable
+              component={dashboardRoot}
+              parentComponent={null}
+              depth={DASHBOARD_ROOT_DEPTH}
+              index={0}
+              orientation="column"
+              onDrop={handleComponentDrop}
+              editMode={editMode}
+              // you cannot drop on/displace tabs if they already exist
+              disableDragdrop={!!topLevelTabs}
+              style={{ zIndex: 100, ...style }}
+            >
+              {({ dropIndicatorProps }) => (
+                <div>
+                  <DashboardHeader />
+                  {dropIndicatorProps && <div {...dropIndicatorProps} />}
+                  {topLevelTabs && (
+                    <WithPopoverMenu
+                      shouldFocus={DashboardBuilder.shouldFocusTabs}
+                      menuItems={[
+                        <IconButton
+                          className="fa fa-level-down"
+                          label="Collapse tab content"
+                          onClick={this.handleDeleteTopLevelTabs}
+                        />,
+                      ]}
+                      editMode={editMode}
+                    >
+                      <DashboardComponent
+                        id={topLevelTabs.id}
+                        parentId={DASHBOARD_ROOT_ID}
+                        depth={DASHBOARD_ROOT_DEPTH + 1}
+                        index={0}
+                        renderTabContent={false}
+                        renderHoverMenu={false}
+                        onChangeTab={this.handleChangeTab}
+                      />
+                    </WithPopoverMenu>
+                  )}
+                </div>
+              )}
+            </DragDroppable>
           )}
-        </DragDroppable>
-
-        {topLevelTabs && (
-          <Sticky topOffset={50}>
-            {({ style }) => (
-              <WithPopoverMenu
-                shouldFocus={DashboardBuilder.shouldFocusTabs}
-                menuItems={[
-                  <IconButton
-                    className="fa fa-level-down"
-                    label="Collapse tab content"
-                    onClick={this.handleDeleteTopLevelTabs}
-                  />,
-                ]}
-                editMode={editMode}
-                style={{ zIndex: 100, ...style }}
-              >
-                <DashboardComponent
-                  id={topLevelTabs.id}
-                  parentId={DASHBOARD_ROOT_ID}
-                  depth={DASHBOARD_ROOT_DEPTH + 1}
-                  index={0}
-                  renderTabContent={false}
-                  onChangeTab={this.handleChangeTab}
-                />
-              </WithPopoverMenu>
-            )}
-          </Sticky>
-        )}
+        </Sticky>
 
         <div className="dashboard-content">
           <div className="grid-container">
@@ -187,7 +188,7 @@ class DashboardBuilder extends React.Component {
           {this.props.editMode &&
             this.props.showBuilderPane && (
               <BuilderComponentPane
-                topOffset={topLevelTabs ? TABS_HEIGHT : 0}
+                topOffset={HEADER_HEIGHT + (topLevelTabs ? TABS_HEIGHT : 0)}
                 toggleBuilderPane={this.props.toggleBuilderPane}
               />
             )}
diff --git a/superset/assets/src/dashboard/components/DashboardGrid.jsx b/superset/assets/src/dashboard/components/DashboardGrid.jsx
index f5ca6e5..d85015e 100644
--- a/superset/assets/src/dashboard/components/DashboardGrid.jsx
+++ b/superset/assets/src/dashboard/components/DashboardGrid.jsx
@@ -29,6 +29,7 @@ class DashboardGrid extends React.PureComponent {
     this.handleResizeStart = this.handleResizeStart.bind(this);
     this.handleResize = this.handleResize.bind(this);
     this.handleResizeStop = this.handleResizeStop.bind(this);
+    this.handleTopDropTargetDrop = this.handleTopDropTargetDrop.bind(this);
     this.getRowGuidePosition = this.getRowGuidePosition.bind(this);
     this.setGridRef = this.setGridRef.bind(this);
   }
@@ -38,7 +39,7 @@ class DashboardGrid extends React.PureComponent {
       return (
         resizeRef.getBoundingClientRect().bottom -
         this.grid.getBoundingClientRect().top -
-        1
+        2
       );
     }
     return null;
@@ -75,6 +76,19 @@ class DashboardGrid extends React.PureComponent {
     }));
   }
 
+  handleTopDropTargetDrop(dropResult) {
+    if (dropResult) {
+      this.props.handleComponentDrop({
+        ...dropResult,
+        destination: {
+          ...dropResult.destination,
+          // force appending as the first child if top drop target
+          index: 0,
+        },
+      });
+    }
+  }
+
   render() {
     const {
       gridComponent,
@@ -93,6 +107,26 @@ class DashboardGrid extends React.PureComponent {
     return width < 100 ? null : (
       <div className="dashboard-grid" ref={this.setGridRef}>
         <div className="grid-content">
+          {/* make the area above components droppable */}
+          {editMode && (
+            <DragDroppable
+              component={gridComponent}
+              depth={depth}
+              parentComponent={null}
+              index={0}
+              orientation="column"
+              onDrop={this.handleTopDropTargetDrop}
+              className="empty-droptarget"
+              editMode
+            >
+              {({ dropIndicatorProps }) =>
+                dropIndicatorProps && (
+                  <div className="drop-indicator drop-indicator--bottom" />
+                )
+              }
+            </DragDroppable>
+          )}
+
           {gridComponent.children.map((id, index) => (
             <DashboardComponent
               key={id}
@@ -117,7 +151,7 @@ class DashboardGrid extends React.PureComponent {
               index={gridComponent.children.length}
               orientation="column"
               onDrop={handleComponentDrop}
-              className="empty-grid-droptarget--bottom"
+              className="empty-droptarget"
               editMode
             >
               {({ dropIndicatorProps }) =>
diff --git a/superset/assets/src/dashboard/components/Header.jsx b/superset/assets/src/dashboard/components/Header.jsx
index 5fa4afe..3b1b6b1 100644
--- a/superset/assets/src/dashboard/components/Header.jsx
+++ b/superset/assets/src/dashboard/components/Header.jsx
@@ -1,12 +1,12 @@
 /* eslint-env browser */
 import React from 'react';
 import PropTypes from 'prop-types';
-import { ButtonGroup, ButtonToolbar } from 'react-bootstrap';
 
 import HeaderActionsDropdown from './HeaderActionsDropdown';
 import EditableTitle from '../../components/EditableTitle';
 import Button from '../../components/Button';
 import FaveStar from '../../components/FaveStar';
+import UndoRedoKeylisteners from './UndoRedoKeylisteners';
 import V2PreviewModal from '../deprecated/V2PreviewModal';
 
 import { chartPropShape } from '../util/propShapes';
@@ -58,10 +58,14 @@ class Header extends React.PureComponent {
     super(props);
     this.state = {
       didNotifyMaxUndoHistoryToast: false,
+      emphasizeUndo: false,
+      hightlightRedo: false,
       showV2PreviewModal: props.isV2Preview,
     };
 
     this.handleChangeText = this.handleChangeText.bind(this);
+    this.handleCtrlZ = this.handleCtrlZ.bind(this);
+    this.handleCtrlY = this.handleCtrlY.bind(this);
     this.toggleEditMode = this.toggleEditMode.bind(this);
     this.forceRefresh = this.forceRefresh.bind(this);
     this.overwriteDashboard = this.overwriteDashboard.bind(this);
@@ -84,6 +88,11 @@ class Header extends React.PureComponent {
     }
   }
 
+  componentWillUnmount() {
+    clearTimeout(this.ctrlYTimeout);
+    clearTimeout(this.ctrlZTimeout);
+  }
+
   forceRefresh() {
     return this.props.fetchCharts(Object.values(this.props.charts), true);
   }
@@ -96,6 +105,26 @@ class Header extends React.PureComponent {
     }
   }
 
+  handleCtrlY() {
+    this.props.onRedo();
+    this.setState({ emphasizeRedo: true }, () => {
+      if (this.ctrlYTimeout) clearTimeout(this.ctrlYTimeout);
+      this.ctrlYTimeout = setTimeout(() => {
+        this.setState({ emphasizeRedo: false });
+      }, 100);
+    });
+  }
+
+  handleCtrlZ() {
+    this.props.onUndo();
+    this.setState({ emphasizeUndo: true }, () => {
+      if (this.ctrlZTimeout) clearTimeout(this.ctrlZTimeout);
+      this.ctrlZTimeout = setTimeout(() => {
+        this.setState({ emphasizeUndo: false });
+      }, 100);
+    });
+  }
+
   toggleEditMode() {
     this.props.setEditMode(!this.props.editMode);
   }
@@ -183,110 +212,117 @@ class Header extends React.PureComponent {
             )}
         </div>
 
-        <ButtonToolbar>
-          {userCanSaveAs && (
-            <ButtonGroup>
-              {editMode && (
+        {userCanSaveAs && (
+          <div className="button-container">
+            {editMode && (
+              <Button
+                bsSize="small"
+                onClick={onUndo}
+                disabled={undoLength < 1}
+                bsStyle={this.state.emphasizeUndo ? 'primary' : undefined}
+              >
+                <div title="Undo" className="undo-action fa fa-reply" />
+              </Button>
+            )}
+
+            {editMode && (
+              <Button
+                bsSize="small"
+                onClick={onRedo}
+                disabled={redoLength < 1}
+                bsStyle={this.state.emphasizeRedo ? 'primary' : undefined}
+              >
+                <div title="Redo" className="redo-action fa fa-share" />
+              </Button>
+            )}
+
+            {editMode && (
+              <Button bsSize="small" onClick={this.props.toggleBuilderPane}>
+                {showBuilderPane
+                  ? t('Hide components')
+                  : t('Insert components')}
+              </Button>
+            )}
+
+            {editMode &&
+              (hasUnsavedChanges || isV2Preview) && (
                 <Button
                   bsSize="small"
-                  onClick={onUndo}
-                  disabled={undoLength < 1}
+                  bsStyle={popButton ? 'primary' : undefined}
+                  onClick={this.overwriteDashboard}
                 >
-                  <div title="Undo" className="undo-action fa fa-reply" />
+                  {isV2Preview
+                    ? t('Persist as Dashboard v2')
+                    : t('Save changes')}
                 </Button>
               )}
 
-              {editMode && (
+            {!editMode &&
+              isV2Preview && (
                 <Button
                   bsSize="small"
-                  onClick={onRedo}
-                  disabled={redoLength < 1}
+                  onClick={this.toggleEditMode}
+                  bsStyle={popButton ? 'primary' : undefined}
+                  disabled={!userCanEdit}
                 >
-                  <div title="Redo" className="redo-action fa fa-share" />
+                  {t('Edit to persist Dashboard v2')}
                 </Button>
               )}
 
-              {editMode && (
-                <Button bsSize="small" onClick={this.props.toggleBuilderPane}>
-                  {showBuilderPane
-                    ? t('Hide components')
-                    : t('Insert components')}
+            {!editMode &&
+              !isV2Preview &&
+              !hasUnsavedChanges && (
+                <Button
+                  bsSize="small"
+                  onClick={this.toggleEditMode}
+                  bsStyle={popButton ? 'primary' : undefined}
+                  disabled={!userCanEdit}
+                >
+                  {t('Edit dashboard')}
+                </Button>
+              )}
+
+            {editMode &&
+              !isV2Preview &&
+              !hasUnsavedChanges && (
+                <Button
+                  bsSize="small"
+                  onClick={this.toggleEditMode}
+                  bsStyle={undefined}
+                  disabled={!userCanEdit}
+                >
+                  {t('Switch to view mode')}
                 </Button>
               )}
 
-              {editMode &&
-                (hasUnsavedChanges || isV2Preview) && (
-                  <Button
-                    bsSize="small"
-                    bsStyle={popButton ? 'primary' : undefined}
-                    onClick={this.overwriteDashboard}
-                  >
-                    {isV2Preview
-                      ? t('Persist as Dashboard v2')
-                      : t('Save changes')}
-                  </Button>
-                )}
-
-              {!editMode &&
-                isV2Preview && (
-                  <Button
-                    bsSize="small"
-                    onClick={this.toggleEditMode}
-                    bsStyle={popButton ? 'primary' : undefined}
-                    disabled={!userCanEdit}
-                  >
-                    {t('Edit to persist Dashboard v2')}
-                  </Button>
-                )}
-
-              {!editMode &&
-                !isV2Preview &&
-                !hasUnsavedChanges && (
-                  <Button
-                    bsSize="small"
-                    onClick={this.toggleEditMode}
-                    bsStyle={popButton ? 'primary' : undefined}
-                    disabled={!userCanEdit}
-                  >
-                    {t('Edit dashboard')}
-                  </Button>
-                )}
-
-              {editMode &&
-                !isV2Preview &&
-                !hasUnsavedChanges && (
-                  <Button
-                    bsSize="small"
-                    onClick={this.toggleEditMode}
-                    bsStyle={undefined}
-                    disabled={!userCanEdit}
-                  >
-                    {t('Switch to view mode')}
-                  </Button>
-                )}
-
-              <HeaderActionsDropdown
-                addSuccessToast={this.props.addSuccessToast}
-                addDangerToast={this.props.addDangerToast}
-                dashboardId={dashboardInfo.id}
-                dashboardTitle={dashboardTitle}
-                layout={layout}
-                filters={filters}
-                expandedSlices={expandedSlices}
-                css={css}
-                onSave={onSave}
-                onChange={onChange}
-                forceRefreshAllCharts={this.forceRefresh}
-                startPeriodicRender={this.props.startPeriodicRender}
-                updateCss={updateCss}
-                editMode={editMode}
-                hasUnsavedChanges={hasUnsavedChanges}
-                userCanEdit={userCanEdit}
-                isV2Preview={isV2Preview}
+            <HeaderActionsDropdown
+              addSuccessToast={this.props.addSuccessToast}
+              addDangerToast={this.props.addDangerToast}
+              dashboardId={dashboardInfo.id}
+              dashboardTitle={dashboardTitle}
+              layout={layout}
+              filters={filters}
+              expandedSlices={expandedSlices}
+              css={css}
+              onSave={onSave}
+              onChange={onChange}
+              forceRefreshAllCharts={this.forceRefresh}
+              startPeriodicRender={this.props.startPeriodicRender}
+              updateCss={updateCss}
+              editMode={editMode}
+              hasUnsavedChanges={hasUnsavedChanges}
+              userCanEdit={userCanEdit}
+              isV2Preview={isV2Preview}
+            />
+
+            {editMode && (
+              <UndoRedoKeylisteners
+                onUndo={this.handleCtrlZ}
+                onRedo={this.handleCtrlY}
               />
-            </ButtonGroup>
-          )}
-        </ButtonToolbar>
+            )}
+          </div>
+        )}
       </div>
     );
   }
diff --git a/superset/assets/src/dashboard/components/SliceAdder.jsx b/superset/assets/src/dashboard/components/SliceAdder.jsx
index ed652c0..f237a06 100644
--- a/superset/assets/src/dashboard/components/SliceAdder.jsx
+++ b/superset/assets/src/dashboard/components/SliceAdder.jsx
@@ -8,6 +8,7 @@ import SearchInput, { createFilter } from 'react-search-input';
 import AddSliceCard from './AddSliceCard';
 import AddSliceDragPreview from './dnd/AddSliceDragPreview';
 import DragDroppable from './dnd/DragDroppable';
+import Loading from '../../components/Loading';
 import { CHART_TYPE, NEW_COMPONENT_SOURCE_TYPE } from '../util/componentTypes';
 import { NEW_CHART_ID, NEW_COMPONENTS_SOURCE_ID } from '../util/constants';
 import { slicePropShape } from '../util/propShapes';
@@ -222,13 +223,7 @@ class SliceAdder extends React.Component {
           </DropdownButton>
         </div>
 
-        {this.props.isLoading && (
-          <img
-            src="/static/assets/images/loading.gif"
-            className="loading"
-            alt="loading"
-          />
-        )}
+        {this.props.isLoading && <Loading />}
 
         {this.props.errorMessage && <div>{this.props.errorMessage}</div>}
 
diff --git a/superset/assets/src/dashboard/components/UndoRedoKeylisteners.jsx b/superset/assets/src/dashboard/components/UndoRedoKeylisteners.jsx
new file mode 100644
index 0000000..5af0934
--- /dev/null
+++ b/superset/assets/src/dashboard/components/UndoRedoKeylisteners.jsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const propTypes = {
+  onUndo: PropTypes.func.isRequired,
+  onRedo: PropTypes.func.isRequired,
+};
+
+class UndoRedoKeylisteners extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.handleKeydown = this.handleKeydown.bind(this);
+  }
+
+  componentDidMount() {
+    document.addEventListener('keydown', this.handleKeydown);
+  }
+
+  componentWillUnmount() {
+    document.removeEventListener('keydown', this.handleKeydown);
+  }
+
+  handleKeydown(event) {
+    const controlOrCommand = event.keyCode === 90 || event.metaKey;
+    if (controlOrCommand) {
+      const isZChar = event.key === 'z' || event.keyCode === 90;
+      const isYChar = event.key === 'y' || event.keyCode === 89;
+      const isEditingMarkdown = document.querySelector(
+        '.dashboard-markdown--editing',
+      );
+
+      if (!isEditingMarkdown && (isZChar || isYChar)) {
+        event.preventDefault();
+        const func = isZChar ? this.props.onUndo : this.props.onRedo;
+        func();
+      }
+    }
+  }
+
+  render() {
+    return null;
+  }
+}
+
+UndoRedoKeylisteners.propTypes = propTypes;
+
+export default UndoRedoKeylisteners;
diff --git a/superset/assets/src/dashboard/components/dnd/AddSliceDragPreview.jsx b/superset/assets/src/dashboard/components/dnd/AddSliceDragPreview.jsx
index 91fc055..2c1128e 100644
--- a/superset/assets/src/dashboard/components/dnd/AddSliceDragPreview.jsx
+++ b/superset/assets/src/dashboard/components/dnd/AddSliceDragPreview.jsx
@@ -11,11 +11,11 @@ import {
 
 const staticCardStyles = {
   position: 'fixed',
-  background: 'white',
+  background: 'rgba(255, 255, 255, 0.7)',
   pointerEvents: 'none',
   top: 0,
   left: 0,
-  zIndex: 100,
+  zIndex: 101, // this should be higher than top-level tabs
   width: 376 - 2 * 16,
 };
 
diff --git a/superset/assets/src/dashboard/components/dnd/handleHover.js b/superset/assets/src/dashboard/components/dnd/handleHover.js
index cb98a6f..a3b16aa 100644
--- a/superset/assets/src/dashboard/components/dnd/handleHover.js
+++ b/superset/assets/src/dashboard/components/dnd/handleHover.js
@@ -1,7 +1,7 @@
 import throttle from 'lodash.throttle';
 import getDropPosition from '../../util/getDropPosition';
 
-const HOVER_THROTTLE_MS = 150;
+const HOVER_THROTTLE_MS = 100;
 
 function handleHover(props, monitor, Component) {
   // this may happen due to throttling
diff --git a/superset/assets/src/dashboard/components/gridComponents/Markdown.jsx b/superset/assets/src/dashboard/components/gridComponents/Markdown.jsx
index a49a893..bd639e7 100644
--- a/superset/assets/src/dashboard/components/gridComponents/Markdown.jsx
+++ b/superset/assets/src/dashboard/components/gridComponents/Markdown.jsx
@@ -1,6 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import ReactMarkdown from 'react-markdown';
+import cx from 'classnames';
 import AceEditor from 'react-ace';
 import 'brace/mode/markdown';
 import 'brace/theme/textmate';
@@ -138,6 +139,7 @@ class Markdown extends React.PureComponent {
         onChange={this.handleMarkdownChange}
         width="100%"
         height="100%"
+        showGutter={false}
         editorProps={{ $blockScrolling: true }}
         value={
           // thisl allows "select all => delete" to give an empty editor
@@ -183,6 +185,8 @@ class Markdown extends React.PureComponent {
         ? parentComponent.meta.width || GRID_MIN_COLUMN_COUNT
         : component.meta.width || GRID_MIN_COLUMN_COUNT;
 
+    const isEditing = this.state.editorMode === 'edit';
+
     return (
       <DragDroppable
         component={component}
@@ -207,7 +211,12 @@ class Markdown extends React.PureComponent {
             ]}
             editMode={editMode}
           >
-            <div className="dashboard-markdown">
+            <div
+              className={cx(
+                'dashboard-markdown',
+                isEditing && 'dashboard-markdown--editing',
+              )}
+            >
               <ResizableContainer
                 id={component.id}
                 adjustableWidth={parentComponent.type === ROW_TYPE}
@@ -230,7 +239,7 @@ class Markdown extends React.PureComponent {
                   ref={dragSourceRef}
                   className="dashboard-component dashboard-component-chart-holder"
                 >
-                  {editMode && this.state.editorMode === 'edit'
+                  {editMode && isEditing
                     ? this.renderEditMode()
                     : this.renderPreviewMode()}
                 </div>
diff --git a/superset/assets/src/dashboard/components/gridComponents/Tab.jsx b/superset/assets/src/dashboard/components/gridComponents/Tab.jsx
index 4cba2e6..b91d808 100644
--- a/superset/assets/src/dashboard/components/gridComponents/Tab.jsx
+++ b/superset/assets/src/dashboard/components/gridComponents/Tab.jsx
@@ -57,6 +57,7 @@ export default class Tab extends React.PureComponent {
     this.handleChangeText = this.handleChangeText.bind(this);
     this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
     this.handleDrop = this.handleDrop.bind(this);
+    this.handleTopDropTargetDrop = this.handleTopDropTargetDrop.bind(this);
   }
 
   handleChangeFocus(nextFocus) {
@@ -89,21 +90,53 @@ export default class Tab extends React.PureComponent {
     this.props.onDropOnTab(dropResult);
   }
 
+  handleTopDropTargetDrop(dropResult) {
+    if (dropResult) {
+      this.props.handleComponentDrop({
+        ...dropResult,
+        destination: {
+          ...dropResult.destination,
+          // force appending as the first child if top drop target
+          index: 0,
+        },
+      });
+    }
+  }
+
   renderTabContent() {
     const {
       component: tabComponent,
       parentComponent: tabParentComponent,
-      index,
       depth,
       availableColumnCount,
       columnWidth,
       onResizeStart,
       onResize,
       onResizeStop,
+      editMode,
     } = this.props;
 
     return (
       <div className="dashboard-component-tabs-content">
+        {/* Make top of tab droppable */}
+        {editMode && (
+          <DragDroppable
+            component={tabComponent}
+            parentComponent={tabParentComponent}
+            orientation="column"
+            index={0}
+            depth={depth}
+            onDrop={this.handleTopDropTargetDrop}
+            editMode
+            className="empty-droptarget"
+          >
+            {({ dropIndicatorProps }) =>
+              dropIndicatorProps && (
+                <div className="drop-indicator drop-indicator--top" />
+              )
+            }
+          </DragDroppable>
+        )}
         {tabComponent.children.map((componentId, componentIndex) => (
           <DashboardComponent
             key={componentId}
@@ -119,21 +152,21 @@ export default class Tab extends React.PureComponent {
             onResizeStop={onResizeStop}
           />
         ))}
-        {/* Make the content of the tab component droppable in the case that there are no children */}
-        {tabComponent.children.length === 0 && (
+        {/* Make bottom of tab droppable */}
+        {editMode && (
           <DragDroppable
             component={tabComponent}
             parentComponent={tabParentComponent}
             orientation="column"
-            index={index}
+            index={tabComponent.children.length}
             depth={depth}
             onDrop={this.handleDrop}
             editMode
-            className="empty-tab-droptarget"
+            className="empty-droptarget"
           >
             {({ dropIndicatorProps }) =>
               dropIndicatorProps && (
-                <div className="drop-indicator drop-indicator--top" />
+                <div className="drop-indicator drop-indicator--bottom" />
               )
             }
           </DragDroppable>
diff --git a/superset/assets/src/dashboard/components/gridComponents/Tabs.jsx b/superset/assets/src/dashboard/components/gridComponents/Tabs.jsx
index 813961d..01c0e60 100644
--- a/superset/assets/src/dashboard/components/gridComponents/Tabs.jsx
+++ b/superset/assets/src/dashboard/components/gridComponents/Tabs.jsx
@@ -24,6 +24,7 @@ const propTypes = {
   depth: PropTypes.number.isRequired,
   renderTabContent: PropTypes.bool, // whether to render tabs + content or just tabs
   editMode: PropTypes.bool.isRequired,
+  renderHoverMenu: PropTypes.bool,
 
   // grid related
   availableColumnCount: PropTypes.number,
@@ -43,6 +44,7 @@ const propTypes = {
 const defaultProps = {
   children: null,
   renderTabContent: true,
+  renderHoverMenu: true,
   availableColumnCount: 0,
   columnWidth: 0,
   onChangeTab() {},
@@ -132,6 +134,7 @@ class Tabs extends React.PureComponent {
       onResizeStop,
       handleComponentDrop,
       renderTabContent,
+      renderHoverMenu,
       editMode,
     } = this.props;
 
@@ -153,19 +156,20 @@ class Tabs extends React.PureComponent {
           dragSourceRef: tabsDragSourceRef,
         }) => (
           <div className="dashboard-component dashboard-component-tabs">
-            {editMode && (
-              <HoverMenu innerRef={tabsDragSourceRef} position="left">
-                <DragHandle position="left" />
-                <DeleteComponentButton onDelete={this.handleDeleteComponent} />
-              </HoverMenu>
-            )}
+            {editMode &&
+              renderHoverMenu && (
+                <HoverMenu innerRef={tabsDragSourceRef} position="left">
+                  <DragHandle position="left" />
+                  <DeleteComponentButton
+                    onDelete={this.handleDeleteComponent}
+                  />
+                </HoverMenu>
+              )}
 
             <BootstrapTabs
               id={tabsComponent.id}
               activeKey={selectedTabIndex}
               onSelect={this.handleClickTab}
-              // these are important for performant loading of tabs. also, there is a
-              // react-bootstrap bug where mountOnEnter has no effect unless animation=true
               animation
               mountOnEnter
               unmountOnExit={false}
diff --git a/superset/assets/src/dashboard/containers/DashboardComponent.jsx b/superset/assets/src/dashboard/containers/DashboardComponent.jsx
index 29071cb..6f5b2e0 100644
--- a/superset/assets/src/dashboard/containers/DashboardComponent.jsx
+++ b/superset/assets/src/dashboard/containers/DashboardComponent.jsx
@@ -4,10 +4,9 @@ import { bindActionCreators } from 'redux';
 import { connect } from 'react-redux';
 
 import ComponentLookup from '../components/gridComponents';
-import getTotalChildWidth from '../util/getChildWidth';
+import getDetailedComponentWidth from '../util/getDetailedComponentWidth';
 import { componentShape } from '../util/propShapes';
 import { COLUMN_TYPE, ROW_TYPE } from '../util/componentTypes';
-import { GRID_MIN_COLUMN_COUNT } from '../util/constants';
 
 import {
   createComponent,
@@ -40,23 +39,15 @@ function mapStateToProps(
 
   // rows and columns need more data about their child dimensions
   // doing this allows us to not pass the entire component lookup to all Components
-  if (props.component.type === ROW_TYPE) {
-    props.occupiedColumnCount = getTotalChildWidth({
+  const componentType = component.type;
+  if (componentType === ROW_TYPE || componentType === COLUMN_TYPE) {
+    const { occupiedWidth, minimumWidth } = getDetailedComponentWidth({
       id,
       components: dashboardLayout,
     });
-  } else if (props.component.type === COLUMN_TYPE) {
-    props.minColumnWidth = GRID_MIN_COLUMN_COUNT;
 
-    component.children.forEach(childId => {
-      // rows don't have widths, so find the width of its children
-      if (dashboardLayout[childId].type === ROW_TYPE) {
-        props.minColumnWidth = Math.max(
-          props.minColumnWidth,
-          getTotalChildWidth({ id: childId, components: dashboardLayout }),
-        );
-      }
-    });
+    if (componentType === ROW_TYPE) props.occupiedColumnCount = occupiedWidth;
+    if (componentType === COLUMN_TYPE) props.minColumnWidth = minimumWidth;
   }
 
   return props;
diff --git a/superset/assets/src/dashboard/deprecated/v1/components/SliceAdder.jsx b/superset/assets/src/dashboard/deprecated/v1/components/SliceAdder.jsx
index 6c2f624..3d3e468 100644
--- a/superset/assets/src/dashboard/deprecated/v1/components/SliceAdder.jsx
+++ b/superset/assets/src/dashboard/deprecated/v1/components/SliceAdder.jsx
@@ -3,6 +3,7 @@ import $ from 'jquery';
 import PropTypes from 'prop-types';
 import { BootstrapTable, TableHeaderColumn } from 'react-bootstrap-table';
 
+import Loading from '../../../../components/Loading';
 import ModalTrigger from '../../../../components/ModalTrigger';
 import { t } from '../../../../locales';
 
@@ -130,11 +131,7 @@ class SliceAdder extends React.Component {
     }
     const modalContent = (
       <div>
-        <img
-          src="/static/assets/images/loading.gif"
-          className={'loading ' + (hideLoad ? 'hidden' : '')}
-          alt={hideLoad ? '' : 'loading'}
-        />
+        {!hideLoad && <Loading />}
         <div className={this.errored ? '' : 'hidden'}>
           {this.state.errorMsg}
         </div>
diff --git a/superset/assets/src/dashboard/reducers/dashboardLayout.js b/superset/assets/src/dashboard/reducers/dashboardLayout.js
index 396a56c..51cd02a 100644
--- a/superset/assets/src/dashboard/reducers/dashboardLayout.js
+++ b/superset/assets/src/dashboard/reducers/dashboardLayout.js
@@ -3,7 +3,9 @@ import {
   DASHBOARD_GRID_ID,
   NEW_COMPONENTS_SOURCE_ID,
 } from '../util/constants';
+import componentIsResizable from '../util/componentIsResizable';
 import findParentId from '../util/findParentId';
+import getComponentWidthFromDrop from '../util/getComponentWidthFromDrop';
 import newComponentFactory from '../util/newComponentFactory';
 import newEntitiesFromDrop from '../util/newEntitiesFromDrop';
 import reorderItem from '../util/dnd-reorder';
@@ -104,6 +106,24 @@ const actionHandlers = {
       destination,
     });
 
+    if (componentIsResizable(nextEntities[dragging.id])) {
+      // update component width if it changed
+      const nextWidth =
+        getComponentWidthFromDrop({
+          dropResult,
+          layout: state,
+        }) || undefined; // don't set a 0 width
+      if ((nextEntities[dragging.id].meta || {}).width !== nextWidth) {
+        nextEntities[dragging.id] = {
+          ...nextEntities[dragging.id],
+          meta: {
+            ...nextEntities[dragging.id].meta,
+            width: nextWidth,
+          },
+        };
+      }
+    }
+
     // wrap the dragged component in a row depending on destination type
     const wrapInRow = shouldWrapChildInRow({
       parentType: destination.type,
diff --git a/superset/assets/src/dashboard/stylesheets/builder-sidepane.less b/superset/assets/src/dashboard/stylesheets/builder-sidepane.less
index 6250243..e807bcc 100644
--- a/superset/assets/src/dashboard/stylesheets/builder-sidepane.less
+++ b/superset/assets/src/dashboard/stylesheets/builder-sidepane.less
@@ -2,7 +2,6 @@
   flex: 0 0 @builder-pane-width;
   z-index: 10;
   position: relative;
-  box-shadow: -4px 0 4px 0 rgba(0, 0, 0, 0.1);
 
   .dashboard-builder-sidepane-header {
     font-size: 15px;
@@ -34,13 +33,14 @@
     overflow: hidden;
     width: @builder-pane-width;
     height: 100%;
+    box-shadow: -4px 0 4px 0 rgba(0, 0, 0, 0.1);
   }
 
   .slider-container {
     position: absolute;
     background: white;
     width: @builder-pane-width * 2;
-    height: 100%;
+    height: 100vh;
     display: flex;
     transition: all 0.5s ease;
 
diff --git a/superset/assets/src/dashboard/stylesheets/components/chart.less b/superset/assets/src/dashboard/stylesheets/components/chart.less
index 73914fb..bc0005d 100644
--- a/superset/assets/src/dashboard/stylesheets/components/chart.less
+++ b/superset/assets/src/dashboard/stylesheets/components/chart.less
@@ -31,13 +31,14 @@
   .resizable-container:hover
   > .dashboard-component-chart-holder:after,
 .dashboard--editing .dashboard-component-chart-holder:hover:after {
-  border: 1px solid @gray-light;
+  border: 1px dashed @indicator-color;
+  z-index: 2;
 }
 
 .dashboard--editing
   .resizable-container.resizable-container--resizing:hover
   > .dashboard-component-chart-holder:after {
-  border: 1px solid @indicator-color;
+  border: 1px dashed @indicator-color;
 }
 
 .dashboard--editing
@@ -62,3 +63,38 @@
   /* disable chart interactions in edit mode */
   pointer-events: none;
 }
+
+.slice-header-controls-trigger {
+  padding: 0 16px;
+  position: absolute;
+  top: 0;
+  right: -16px; //increase the click-able area for the button
+
+  &:hover {
+    cursor: pointer;
+  }
+}
+
+.dot {
+  height: 4px;
+  width: 4px;
+  background-color: @gray;
+  border-radius: 50%;
+  margin: 2px 0;
+  display: inline-block;
+
+  .is-cached & {
+    background-color: @pink;
+    box-shadow: 0 0 5px 1.5px rgba(255, 0, 0, 0.3);
+  }
+
+  .vertical-dots-container & {
+    display: block;
+  }
+
+  a[role='menuitem'] & {
+    width: 8px;
+    height: 8px;
+    margin-right: 8px;
+  }
+}
diff --git a/superset/assets/src/dashboard/stylesheets/components/column.less b/superset/assets/src/dashboard/stylesheets/components/column.less
index 2f26d95..e923f8c 100644
--- a/superset/assets/src/dashboard/stylesheets/components/column.less
+++ b/superset/assets/src/dashboard/stylesheets/components/column.less
@@ -24,12 +24,16 @@
   .resizable-container.resizable-container--resizing:hover
   > .grid-column:after,
 .dashboard--editing .hover-menu:hover + .grid-column:after {
+  border: 1px dashed @indicator-color;
+  z-index: 2;
+}
+
+.dashboard--editing .grid-column:after {
   border: 1px dashed @gray-light;
-  box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.1);
 }
 
 .grid-column--empty {
-  min-height: 72px;
+  min-height: 100px;
 }
 
 .grid-column--empty:before {
diff --git a/superset/assets/src/dashboard/stylesheets/components/header.less b/superset/assets/src/dashboard/stylesheets/components/header.less
index 9403103..71b2176 100644
--- a/superset/assets/src/dashboard/stylesheets/components/header.less
+++ b/superset/assets/src/dashboard/stylesheets/components/header.less
@@ -6,15 +6,28 @@
   color: @almost-black;
 }
 
+.dashboard--editing .dashboard-grid .dashboard-component-header:after {
+  border: 1px dashed transparent;
+  content: '';
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  z-index: 1;
+  pointer-events: none;
+}
+
+.dashboard--editing .dashboard-grid .dashboard-component-header:hover:after {
+  border: 1px dashed @indicator-color;
+  z-index: 2;
+}
+
 .dashboard-header .dashboard-component-header {
   font-weight: 300;
   width: auto;
 }
 
-.dashboard-header .btn-group button {
-  margin-right: 8px;
-}
-
 .dashboard-header .undo-action,
 .dashboard-header .redo-action {
   line-height: 18px;
@@ -25,6 +38,11 @@
   cursor: move;
 }
 
+.header-style-option {
+  font-weight: 700;
+  color: @almost-black;
+}
+
 /* note: sizes should be a multiple of the 8px grid unit so that rows in the grid align */
 .header-small {
   font-size: 16px;
diff --git a/superset/assets/src/dashboard/stylesheets/components/markdown.less b/superset/assets/src/dashboard/stylesheets/components/markdown.less
index 2cfd929..87974d9 100644
--- a/superset/assets/src/dashboard/stylesheets/components/markdown.less
+++ b/superset/assets/src/dashboard/stylesheets/components/markdown.less
@@ -14,3 +14,12 @@
     border: none;
   }
 }
+
+/* maximize editing space */
+.dashboard-markdown--editing {
+  .dashboard-component-chart-holder {
+    .with-popover-menu--focused & {
+      padding: 1px;
+    }
+  }
+}
diff --git a/superset/assets/src/dashboard/stylesheets/components/row.less b/superset/assets/src/dashboard/stylesheets/components/row.less
index 382417e..06d98e6 100644
--- a/superset/assets/src/dashboard/stylesheets/components/row.less
+++ b/superset/assets/src/dashboard/stylesheets/components/row.less
@@ -32,13 +32,18 @@
   > .grid-row:after,
 .dashboard--editing .hover-menu:hover + .grid-row:after,
 .dashboard--editing .dashboard-component-tabs > .hover-menu:hover + div:after {
+  border: 1px dashed @indicator-color;
+  z-index: 2;
+}
+
+.dashboard--editing .grid-row:after,
+.dashboard--editing .dashboard-component-tabs > .hover-menu + div:after {
   border: 1px dashed @gray-light;
-  box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.1);
 }
 
 .grid-row.grid-row--empty {
   align-items: center; /* this centers the empty note content */
-  height: 80px;
+  height: 100px;
 }
 
 .grid-row--empty:before {
diff --git a/superset/assets/src/dashboard/stylesheets/components/tabs.less b/superset/assets/src/dashboard/stylesheets/components/tabs.less
index b1124da..cee524c 100644
--- a/superset/assets/src/dashboard/stylesheets/components/tabs.less
+++ b/superset/assets/src/dashboard/stylesheets/components/tabs.less
@@ -1,89 +1,84 @@
 .dashboard-component-tabs {
   width: 100%;
   background-color: white;
-}
-
-.dashboard-component-tabs .dashboard-component-tabs-content {
-  min-height: 48px;
-  margin-top: 1px;
-}
-
-.dashboard-component-tabs-content .empty-tab-droptarget {
-  min-height: 24px;
-}
-
-.dashboard-component-tabs .nav-tabs {
-  border-bottom: none;
-}
-
-/* by moving padding from <a/> to <li/> we can restrict the selected tab indicator to text width */
-.dashboard-component-tabs .nav-tabs > li {
-  margin: 0 16px;
-}
-
-.dashboard-component-tabs .nav-tabs > li > a {
-  color: @almost-black;
-  border: none;
-  padding: 12px 0 14px 0;
-  font-size: 15px;
-  margin-right: 0;
-}
-
-.dashboard-component-tabs .nav-tabs > li.active > a {
-  border: none;
-}
-
-.dashboard-component-tabs .nav-tabs > li.active > a:after {
-  content: '';
-  position: absolute;
-  height: 3px;
-  width: 100%;
-  bottom: 0;
-  background: linear-gradient(to right, #e32464, #2c2261);
-}
-
-.dashboard-component-tabs .nav-tabs > li > a:hover {
-  border: none;
-  background: inherit;
-  color: @almost-black;
-}
-
-.dashboard-component-tabs .nav-tabs > li > a:focus {
-  outline: none;
-  background: #fff;
-}
-
-.dashboard-component-tabs .nav-tabs > li .dragdroppable-tab {
-  cursor: move;
-}
-
-/* These expande the outline border + drop indicator for tabs */
-.dashboard-component-tabs .nav-tabs > li .drop-indicator {
-  top: -12px !important;
-  height: ~'calc(100% + 24px)' !important;
-}
-
-.dashboard-component-tabs .nav-tabs > li .drop-indicator--left {
-  left: -12px !important;
-}
-
-.dashboard-component-tabs .nav-tabs > li .drop-indicator--right {
-  right: -12px !important;
-}
-
-.dashboard-component-tabs .nav-tabs > li .drop-indicator--top,
-.dashboard-component-tabs .nav-tabs > li .drop-indicator--bottom {
-  left: -12px !important;
-  width: ~'calc(100% + 24px)' !important; /* escape for .less */
-  opacity: 0.4;
-}
-
-.dashboard-component-tabs li .fa-plus {
-  color: @gray-dark;
-  font-size: 14px;
-  margin-top: 3px;
-}
 
-.dashboard-component-tabs li .editable-title input[type='button'] {
-  cursor: pointer;
+  & .nav-tabs {
+    border-bottom: none;
+
+    /* by moving padding from <a/> to <li/> we can restrict the selected tab indicator to text width */
+    & > li {
+      margin: 0 16px;
+
+      & > a {
+        color: @almost-black;
+        border: none;
+        padding: 12px 0 14px 0;
+        font-size: 15px;
+        margin-right: 0;
+      }
+
+      & > a:hover {
+        border: none;
+        background: inherit;
+        color: @almost-black;
+      }
+
+      & > a:focus {
+        outline: none;
+        background: #fff;
+      }
+
+      & .dragdroppable-tab {
+        cursor: move;
+      }
+
+      & .drop-indicator {
+        top: -12px !important;
+        height: ~'calc(100% + 24px)' !important;
+      }
+
+      & .drop-indicator--left {
+        left: -12px !important;
+      }
+      & .drop-indicator--right {
+        right: -12px !important;
+      }
+
+      & .drop-indicator--bottom,
+      & .drop-indicator--top {
+        left: -12px !important;
+        width: ~'calc(100% + 24px)' !important; /* escape for .less */
+        opacity: 0.4;
+      }
+
+      & .fa-plus {
+        color: @gray-dark;
+        font-size: 14px;
+        margin-top: 3px;
+      }
+
+      & .editable-title input[type='button'] {
+        cursor: pointer;
+      }
+    }
+
+    & li.active > a {
+      border: none;
+    }
+
+    & li.active > a:after {
+      content: '';
+      position: absolute;
+      height: 3px;
+      width: 100%;
+      bottom: 0;
+      background: linear-gradient(to right, #e32464, #2c2261);
+    }
+  }
+
+  & .dashboard-component-tabs-content {
+    min-height: 48px;
+    margin-top: 1px;
+    position: relative;
+  }
 }
diff --git a/superset/assets/src/dashboard/stylesheets/dashboard.less b/superset/assets/src/dashboard/stylesheets/dashboard.less
index 3db5cdc..92f1ff1 100644
--- a/superset/assets/src/dashboard/stylesheets/dashboard.less
+++ b/superset/assets/src/dashboard/stylesheets/dashboard.less
@@ -78,7 +78,7 @@ body {
 .dashboard .dashboard-header {
   #save-dash-split-button {
     border-radius: 0;
-    margin-left: -8px;
+    margin-left: -9px;
     height: 30px;
     width: 30px;
     z-index: 10;
@@ -97,6 +97,15 @@ body {
       min-width: unset;
     }
   }
+
+  .button-container {
+    display: flex;
+    flex-direction: row;
+    flex-wrap: nowrap;
+    & > :not(:last-child) {
+      margin-right: 8px;
+    }
+  }
 }
 
 .dashboard .chart-header,
@@ -120,40 +129,6 @@ body {
   }
 }
 
-.slice-header-controls-trigger {
-  padding: 0 16px;
-  position: absolute;
-  top: 0;
-  right: -16px; //increase the click-able area for the button
-
-  &:hover {
-    cursor: pointer;
-  }
-}
-
-.dot {
-  height: 4px;
-  width: 4px;
-  background-color: @gray;
-  border-radius: 50%;
-  margin: 2px 0;
-  display: inline-block;
-
-  .is-cached & {
-    background-color: @pink;
-  }
-
-  .vertical-dots-container & {
-    display: block;
-  }
-
-  a[role='menuitem'] & {
-    width: 8px;
-    height: 8px;
-    margin-right: 8px;
-  }
-}
-
 .modal img.loading {
   width: 50px;
   margin: 0;
@@ -205,6 +180,10 @@ body {
     line-height: 1em;
     cursor: pointer;
     opacity: 0.9;
+    flex-wrap: nowrap;
+    display: flex;
+    align-items: center;
+    white-space: nowrap;
 
     &:hover {
       opacity: 1;
diff --git a/superset/assets/src/dashboard/stylesheets/dnd.less b/superset/assets/src/dashboard/stylesheets/dnd.less
index 0a10c61..9b5ea89 100644
--- a/superset/assets/src/dashboard/stylesheets/dnd.less
+++ b/superset/assets/src/dashboard/stylesheets/dnd.less
@@ -3,7 +3,7 @@
 }
 
 .dragdroppable--dragging {
-  opacity: 0.15;
+  opacity: 0.2;
 }
 
 .dragdroppable-row {
@@ -76,3 +76,36 @@
   margin: -1px;
   width: 2px;
 }
+
+/* empty drop targets */
+.dashboard-component-tabs-content {
+  & > .empty-droptarget {
+    position: absolute;
+    width: 100%;
+  }
+
+  & > .empty-droptarget:first-child {
+    height: 16px;
+    top: -8px;
+    z-index: 10;
+  }
+
+  & > .empty-droptarget:last-child {
+    height: 12px;
+    bottom: 0px;
+  }
+}
+
+.grid-content {
+  /* note we don't do a :last-child selection because
+    assuming bottom empty-droptarget is last child is fragile */
+  & > .empty-droptarget {
+    width: 100%;
+    height: 100%;
+  }
+
+  & > .empty-droptarget:first-child {
+    height: 24px;
+    margin-top: -24px;
+  }
+}
diff --git a/superset/assets/src/dashboard/stylesheets/grid.less b/superset/assets/src/dashboard/stylesheets/grid.less
index 9d09ac7..1e22e1d 100644
--- a/superset/assets/src/dashboard/stylesheets/grid.less
+++ b/superset/assets/src/dashboard/stylesheets/grid.less
@@ -1,5 +1,4 @@
 .grid-container {
-  min-height: 100%;
   position: relative;
   margin: 24px;
   /* without this, the grid will not get smaller upon toggling the builder panel on */
@@ -7,33 +6,21 @@
   width: 100%;
 }
 
-/* this is the ParentSize wrapper  */
+/* this is the ParentSize wrapper */
 .grid-container > div:first-child {
   height: inherit !important;
 }
 
 .grid-content {
-  min-height: 100%;
   display: flex;
   flex-direction: column;
-  margin-bottom: 100px;
 }
 
 /* gutters between rows */
-.grid-content
-  > div:not(:only-child):not(:last-child):not(.empty-grid-droptarget--bottom):not(.empty-grid-droptarget--top) {
+.grid-content > div:not(:only-child):not(:last-child):not(.empty-droptarget) {
   margin-bottom: 16px;
 }
 
-.grid-content > .empty-grid-droptarget--top {
-  height: 24px;
-  margin-top: -24px;
-}
-.empty-grid-droptarget--bottom {
-  width: 100%;
-  height: 100%;
-}
-
 /* Editing guides */
 .grid-column-guide {
   position: absolute;
diff --git a/superset/assets/src/dashboard/stylesheets/popover-menu.less b/superset/assets/src/dashboard/stylesheets/popover-menu.less
index 0c70f58..3c790e4 100644
--- a/superset/assets/src/dashboard/stylesheets/popover-menu.less
+++ b/superset/assets/src/dashboard/stylesheets/popover-menu.less
@@ -85,7 +85,7 @@
 
 .hover-dropdown li.dropdown-item:hover a,
 .popover-menu li.dropdown-item:hover a {
-  background: @gray-light;
+  background: @menu-hover;
 }
 
 .popover-dropdown .caret {
@@ -96,7 +96,7 @@
 
 .hover-dropdown li.dropdown-item.active a,
 .popover-menu li.dropdown-item.active a {
-  background: white;
+  background: @gray-light;
   font-weight: bold;
   color: @almost-black;
 }
@@ -125,6 +125,7 @@
   border: 1px solid @gray-light;
 }
 
+/* Create the transparent rect icon */
 .background-style-option.background--transparent:before {
   background-image: linear-gradient(45deg, @gray 25%, transparent 25%),
     linear-gradient(-45deg, @gray 25%, transparent 25%),
diff --git a/superset/assets/src/dashboard/util/dropOverflowsParent.js b/superset/assets/src/dashboard/util/dropOverflowsParent.js
index 328d8e3..a5f99f3 100644
--- a/superset/assets/src/dashboard/util/dropOverflowsParent.js
+++ b/superset/assets/src/dashboard/util/dropOverflowsParent.js
@@ -1,45 +1,6 @@
-import { COLUMN_TYPE } from '../util/componentTypes';
-import { GRID_COLUMN_COUNT, NEW_COMPONENTS_SOURCE_ID } from './constants';
-import findParentId from './findParentId';
-import getChildWidth from './getChildWidth';
-import newComponentFactory from './newComponentFactory';
+import getComponentWidthFromDrop from './getComponentWidthFromDrop';
 
 export default function doesChildOverflowParent(dropResult, layout) {
-  const { source, destination, dragging } = dropResult;
-
-  // moving a component within a container should never overflow
-  if (source.id === destination.id) {
-    return false;
-  }
-
-  const isNewComponent = source.id === NEW_COMPONENTS_SOURCE_ID;
-  const grandparentId = findParentId({
-    childId: destination.id,
-    layout,
-  });
-
-  const child = isNewComponent
-    ? newComponentFactory(dragging.type)
-    : layout[dragging.id] || {};
-  const parent = layout[destination.id] || {};
-  const grandparent = layout[grandparentId] || {};
-
-  const childWidth = (child.meta && child.meta.width) || 0;
-
-  const grandparentCapacity =
-    grandparent.meta && typeof grandparent.meta.width === 'number'
-      ? grandparent.meta.width
-      : GRID_COLUMN_COUNT;
-
-  const parentCapacity =
-    parent.meta && typeof parent.meta.width === 'number'
-      ? parent.meta.width
-      : grandparentCapacity;
-
-  const occupiedParentWidth =
-    parent.type === COLUMN_TYPE
-      ? 0
-      : getChildWidth({ id: destination.id, components: layout });
-
-  return parentCapacity < occupiedParentWidth + childWidth;
+  const childWidth = getComponentWidthFromDrop({ dropResult, layout });
+  return typeof childWidth === 'number' && childWidth < 0;
 }
diff --git a/superset/assets/src/dashboard/util/findParentId.js b/superset/assets/src/dashboard/util/findParentId.js
index 9e47bf2..bf26d54 100644
--- a/superset/assets/src/dashboard/util/findParentId.js
+++ b/superset/assets/src/dashboard/util/findParentId.js
@@ -1,4 +1,4 @@
-export default function findParentId({ childId, layout = {} }) {
+function findParentId({ childId, layout = {} }) {
   let parentId = null;
 
   const ids = Object.keys(layout);
@@ -17,3 +17,15 @@ export default function findParentId({ childId, layout = {} }) {
 
   return parentId;
 }
+
+const cache = {};
+export default function findParentIdWithCache({ childId, layout = {} }) {
+  if (cache[childId]) {
+    const lastParent = layout[cache[childId]] || {};
+    if (lastParent.children && lastParent.children.includes(childId)) {
+      return lastParent.id;
+    }
+  }
+  cache[childId] = findParentId({ childId, layout });
+  return cache[childId];
+}
diff --git a/superset/assets/src/dashboard/util/getChildWidth.js b/superset/assets/src/dashboard/util/getChildWidth.js
deleted file mode 100644
index 69d2792..0000000
--- a/superset/assets/src/dashboard/util/getChildWidth.js
+++ /dev/null
@@ -1,13 +0,0 @@
-export default function getTotalChildWidth({ id, components }) {
-  const component = components[id];
-  if (!component) return 0;
-
-  let width = 0;
-
-  (component.children || []).forEach(childId => {
-    const child = components[childId] || {};
-    width += (child.meta || {}).width || 0;
-  });
-
-  return width;
-}
diff --git a/superset/assets/src/dashboard/util/getComponentWidthFromDrop.js b/superset/assets/src/dashboard/util/getComponentWidthFromDrop.js
new file mode 100644
index 0000000..38c7c5a
--- /dev/null
+++ b/superset/assets/src/dashboard/util/getComponentWidthFromDrop.js
@@ -0,0 +1,57 @@
+import { NEW_COMPONENTS_SOURCE_ID } from './constants';
+import findParentId from './findParentId';
+import getDetailedComponentWidth from './getDetailedComponentWidth';
+import newComponentFactory from './newComponentFactory';
+
+export default function getComponentWidthFromDrop({
+  dropResult,
+  layout: components,
+}) {
+  const { source, destination, dragging } = dropResult;
+
+  const isNewComponent = source.id === NEW_COMPONENTS_SOURCE_ID;
+  const component = isNewComponent
+    ? newComponentFactory(dragging.type)
+    : components[dragging.id] || {};
+
+  // moving a component within the same container shouldn't change its width
+  if (source.id === destination.id) {
+    return component.meta.width;
+  }
+
+  const draggingWidth = getDetailedComponentWidth({
+    component,
+    components,
+  });
+
+  const destinationWidth = getDetailedComponentWidth({
+    id: destination.id,
+    components,
+  });
+
+  let destinationCapacity =
+    destinationWidth.width - destinationWidth.occupiedWidth;
+
+  if (isNaN(destinationCapacity)) {
+    const grandparentWidth = getDetailedComponentWidth({
+      id: findParentId({
+        childId: destination.id,
+        layout: components,
+      }),
+      components,
+    });
+
+    destinationCapacity =
+      grandparentWidth.width - grandparentWidth.occupiedWidth;
+  }
+
+  if (isNaN(destinationCapacity) || isNaN(draggingWidth.width)) {
+    return draggingWidth.width;
+  } else if (destinationCapacity >= draggingWidth.width) {
+    return draggingWidth.width;
+  } else if (destinationCapacity >= draggingWidth.minimumWidth) {
+    return destinationCapacity;
+  }
+
+  return -1;
+}
diff --git a/superset/assets/src/dashboard/util/getDetailedComponentWidth.js b/superset/assets/src/dashboard/util/getDetailedComponentWidth.js
new file mode 100644
index 0000000..ee3096d
--- /dev/null
+++ b/superset/assets/src/dashboard/util/getDetailedComponentWidth.js
@@ -0,0 +1,76 @@
+import findParentId from './findParentId';
+import { GRID_MIN_COLUMN_COUNT, GRID_COLUMN_COUNT } from './constants';
+import {
+  ROW_TYPE,
+  COLUMN_TYPE,
+  MARKDOWN_TYPE,
+  CHART_TYPE,
+} from './componentTypes';
+
+function getTotalChildWidth({ id, components }) {
+  const component = components[id];
+  if (!component) return 0;
+
+  let width = 0;
+
+  (component.children || []).forEach(childId => {
+    const child = components[childId] || {};
+    width += (child.meta || {}).width || 0;
+  });
+
+  return width;
+}
+
+export default function getDetailedComponentWidth({
+  // pass either an id, or a component
+  id,
+  component: passedComponent,
+  components = {},
+}) {
+  const result = {
+    width: undefined,
+    occupiedWidth: undefined,
+    minimumWidth: undefined,
+  };
+
+  const component = passedComponent || components[id];
+  if (!component) return result;
+
+  // note these remain as undefined if the component has no defined width
+  result.width = (component.meta || {}).width;
+  result.occupiedWidth = result.width;
+
+  if (component.type === ROW_TYPE) {
+    // not all rows have width 12, e
+    result.width =
+      getDetailedComponentWidth({
+        id: findParentId({
+          childId: component.id,
+          layout: components,
+        }),
+        components,
+      }).width || GRID_COLUMN_COUNT;
+    result.occupiedWidth = getTotalChildWidth({ id: component.id, components });
+    result.minimumWidth = result.occupiedWidth || GRID_MIN_COLUMN_COUNT;
+  } else if (component.type === COLUMN_TYPE) {
+    // find the width of the largest child, only rows count
+    result.minimumWidth = GRID_MIN_COLUMN_COUNT;
+    result.occupiedWidth = 0;
+    (component.children || []).forEach(childId => {
+      // rows don't have widths, so find the width of its children
+      if (components[childId].type === ROW_TYPE) {
+        result.minimumWidth = Math.max(
+          result.minimumWidth,
+          getTotalChildWidth({ id: childId, components }),
+        );
+      }
+    });
+  } else if (
+    component.type === MARKDOWN_TYPE ||
+    component.type === CHART_TYPE
+  ) {
+    result.minimumWidth = GRID_MIN_COLUMN_COUNT;
+  }
+
+  return result;
+}
diff --git a/superset/assets/src/dashboard/util/getDropPosition.js b/superset/assets/src/dashboard/util/getDropPosition.js
index 74dfcaa..dd4add9 100644
--- a/superset/assets/src/dashboard/util/getDropPosition.js
+++ b/superset/assets/src/dashboard/util/getDropPosition.js
@@ -72,7 +72,7 @@ export default function getDropPosition(monitor, Component) {
   const siblingDropOrientation =
     orientation === 'row' ? 'horizontal' : 'vertical';
 
-  if (validChild && !validSibling) {
+  if (isDraggingOverShallow && validChild && !validSibling) {
     // easiest case, insert as child
     if (childDropOrientation === 'vertical') {
       return hasChildren ? DROP_RIGHT : DROP_LEFT;
diff --git a/superset/assets/src/dashboard/util/headerStyleOptions.js b/superset/assets/src/dashboard/util/headerStyleOptions.js
index 7efa040..a37bd5f 100644
--- a/superset/assets/src/dashboard/util/headerStyleOptions.js
+++ b/superset/assets/src/dashboard/util/headerStyleOptions.js
@@ -2,7 +2,19 @@ import { t } from '../../locales';
 import { SMALL_HEADER, MEDIUM_HEADER, LARGE_HEADER } from './constants';
 
 export default [
-  { value: SMALL_HEADER, label: t('Small'), className: 'header-small' },
-  { value: MEDIUM_HEADER, label: t('Medium'), className: 'header-medium' },
-  { value: LARGE_HEADER, label: t('Large'), className: 'header-large' },
+  {
+    value: SMALL_HEADER,
+    label: t('Small'),
+    className: 'header-style-option header-small',
+  },
+  {
+    value: MEDIUM_HEADER,
+    label: t('Medium'),
+    className: 'header-style-option header-medium',
+  },
+  {
+    value: LARGE_HEADER,
+    label: t('Large'),
+    className: 'header-style-option header-large',
+  },
 ];
diff --git a/superset/assets/src/dashboard/util/newEntitiesFromDrop.js b/superset/assets/src/dashboard/util/newEntitiesFromDrop.js
index 8abc9b9..9054d44 100644
--- a/superset/assets/src/dashboard/util/newEntitiesFromDrop.js
+++ b/superset/assets/src/dashboard/util/newEntitiesFromDrop.js
@@ -1,5 +1,7 @@
+import componentIsResizable from './componentIsResizable';
 import shouldWrapChildInRow from './shouldWrapChildInRow';
 import newComponentFactory from './newComponentFactory';
+import getComponentWidthFromDrop from './getComponentWidthFromDrop';
 
 import { ROW_TYPE, TABS_TYPE, TAB_TYPE } from './componentTypes';
 
@@ -10,6 +12,12 @@ export default function newEntitiesFromDrop({ dropResult, layout }) {
   const dropEntity = layout[destination.id];
   const dropType = dropEntity.type;
   let newDropChild = newComponentFactory(dragType, dragging.meta);
+
+  if (componentIsResizable(dragging)) {
+    newDropChild.meta.width = // don't set a 0 width
+      getComponentWidthFromDrop({ dropResult, layout }) || undefined;
+  }
+
   const wrapChildInRow = shouldWrapChildInRow({
     parentType: dropType,
     childType: dragType,
diff --git a/superset/assets/src/explore/components/DisplayQueryButton.jsx b/superset/assets/src/explore/components/DisplayQueryButton.jsx
index 0b78cea..334ec78 100644
--- a/superset/assets/src/explore/components/DisplayQueryButton.jsx
+++ b/superset/assets/src/explore/components/DisplayQueryButton.jsx
@@ -9,6 +9,7 @@ import github from 'react-syntax-highlighter/styles/hljs/github';
 import CopyToClipboard from './../../components/CopyToClipboard';
 import { getExploreUrlAndPayload } from '../exploreUtils';
 
+import Loading from '../../components/Loading';
 import ModalTrigger from './../../components/ModalTrigger';
 import Button from '../../components/Button';
 import { t } from '../../locales';
@@ -18,7 +19,7 @@ registerLanguage('html', html);
 registerLanguage('sql', sql);
 registerLanguage('json', json);
 
-const $ = window.$ = require('jquery');
+const $ = (window.$ = require('jquery'));
 
 const propTypes = {
   animation: PropTypes.bool,
@@ -80,8 +81,9 @@ export default class DisplayQueryButton extends React.PureComponent {
   }
   beforeOpen() {
     if (
-      ['loading', null].indexOf(this.props.chartStatus) >= 0
-      || !this.props.queryResponse || !this.props.queryResponse.query
+      ['loading', null].indexOf(this.props.chartStatus) >= 0 ||
+      !this.props.queryResponse ||
+      !this.props.queryResponse.query
     ) {
       this.fetchQuery();
     } else {
@@ -90,11 +92,7 @@ export default class DisplayQueryButton extends React.PureComponent {
   }
   renderModalBody() {
     if (this.state.isLoading) {
-      return (<img
-        className="loading"
-        alt="Loading..."
-        src="/static/assets/images/loading.gif"
-      />);
+      return <Loading />;
     } else if (this.state.error) {
       return <pre>{this.state.error}</pre>;
     } else if (this.state.query) {
diff --git a/superset/assets/src/explore/components/controls/DatasourceControl.jsx b/superset/assets/src/explore/components/controls/DatasourceControl.jsx
index 404ba5e..d63d6fe 100644
--- a/superset/assets/src/explore/components/controls/DatasourceControl.jsx
+++ b/superset/assets/src/explore/components/controls/DatasourceControl.jsx
@@ -3,11 +3,19 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { Table } from 'reactable';
 import {
-  Row, Col, Collapse, Label, FormControl, Modal,
-  OverlayTrigger, Tooltip, Well,
+  Row,
+  Col,
+  Collapse,
+  Label,
+  FormControl,
+  Modal,
+  OverlayTrigger,
+  Tooltip,
+  Well,
 } from 'react-bootstrap';
 
 import ControlHeader from '../ControlHeader';
+import Loading from '../../../components/Loading';
 import { t } from '../../../locales';
 import ColumnOption from '../../../components/ColumnOption';
 import MetricOption from '../../../components/MetricOption';
@@ -68,7 +76,8 @@ export default class DatasourceControl extends React.PureComponent {
                 className="datasource-link"
               >
                 {ds.name}
-              </a>),
+              </a>
+            ),
             type: ds.type,
           }));
 
@@ -113,7 +122,9 @@ export default class DatasourceControl extends React.PureComponent {
           <div>
             <FormControl
               id="formControlsText"
-              inputRef={(ref) => { this.setSearchRef(ref); }}
+              inputRef={(ref) => {
+                this.setSearchRef(ref);
+              }}
               type="text"
               bsSize="sm"
               value={this.state.filter}
@@ -121,14 +132,8 @@ export default class DatasourceControl extends React.PureComponent {
               onChange={this.changeSearch}
             />
           </div>
-          {this.state.loading &&
-            <img
-              className="loading"
-              alt="Loading..."
-              src="/static/assets/images/loading.gif"
-            />
-          }
-          {this.state.datasources &&
+          {this.state.loading && <Loading />}
+          {this.state.datasources && (
             <Table
               columns={['name', 'type', 'schema', 'connection', 'creator']}
               className="table table-condensed"
@@ -138,9 +143,10 @@ export default class DatasourceControl extends React.PureComponent {
               filterBy={this.state.filter}
               hideFilterInput
             />
-          }
+          )}
         </Modal.Body>
-      </Modal>);
+      </Modal>
+    );
   }
   renderDatasource() {
     const datasource = this.props.datasource;
@@ -157,18 +163,23 @@ export default class DatasourceControl extends React.PureComponent {
             <Col md={6}>
               <strong>Columns</strong>
               {datasource.columns.map(col => (
-                <div key={col.column_name}><ColumnOption showType column={col} /></div>
+                <div key={col.column_name}>
+                  <ColumnOption showType column={col} />
+                </div>
               ))}
             </Col>
             <Col md={6}>
               <strong>Metrics</strong>
               {datasource.metrics.map(m => (
-                <div key={m.metric_name}><MetricOption metric={m} showType /></div>
+                <div key={m.metric_name}>
+                  <MetricOption metric={m} showType />
+                </div>
               ))}
             </Col>
           </Row>
         </Well>
-      </div>);
+      </div>
+    );
   }
   render() {
     return (
@@ -188,7 +199,7 @@ export default class DatasourceControl extends React.PureComponent {
           placement="right"
           overlay={
             <Tooltip id={'edit-datasource-tooltip'}>
-              {t('Edit the datasource\'s configuration')}
+              {t("Edit the datasource's configuration")}
             </Tooltip>
           }
         >
@@ -199,9 +210,7 @@ export default class DatasourceControl extends React.PureComponent {
         <OverlayTrigger
           placement="right"
           overlay={
-            <Tooltip id={'toggle-datasource-tooltip'}>
-              {t('Show datasource configuration')}
-            </Tooltip>
+            <Tooltip id={'toggle-datasource-tooltip'}>{t('Show datasource configuration')}</Tooltip>
           }
         >
           <a href="#">
@@ -211,11 +220,10 @@ export default class DatasourceControl extends React.PureComponent {
             />
           </a>
         </OverlayTrigger>
-        <Collapse in={this.state.showDatasource}>
-          {this.renderDatasource()}
-        </Collapse>
+        <Collapse in={this.state.showDatasource}>{this.renderDatasource()}</Collapse>
         {this.renderModal()}
-      </div>);
+      </div>
+    );
   }
 }
 
diff --git a/superset/assets/src/profile/components/TableLoader.jsx b/superset/assets/src/profile/components/TableLoader.jsx
index 1e67426..462e009 100644
--- a/superset/assets/src/profile/components/TableLoader.jsx
+++ b/superset/assets/src/profile/components/TableLoader.jsx
@@ -2,7 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { Table, Tr, Td } from 'reactable';
 import $ from 'jquery';
-
+import Loading from '../../components/Loading';
 import '../../../stylesheets/reactable-pagination.css';
 
 const propTypes = {
@@ -29,6 +29,9 @@ export default class TableLoader extends React.PureComponent {
     });
   }
   render() {
+    if (this.state.isLoading) {
+      return <Loading />;
+    }
     const tableProps = Object.assign({}, this.props);
     let { columns } = this.props;
     if (!columns && this.state.data.length > 0) {
@@ -37,11 +40,14 @@ export default class TableLoader extends React.PureComponent {
     delete tableProps.dataEndpoint;
     delete tableProps.mutator;
     delete tableProps.columns;
-    if (this.state.isLoading) {
-      return <img alt="loading" width="25" src="/static/assets/images/loading.gif" />;
-    }
+
     return (
-      <Table {...tableProps} className="table" itemsPerPage={50} style={{ textTransform: 'capitalize' }}>
+      <Table
+        {...tableProps}
+        className="table"
+        itemsPerPage={50}
+        style={{ textTransform: 'capitalize' }}
+      >
         {this.state.data.map((row, i) => (
           <Tr key={i}>
             {columns.map((col) => {
@@ -49,9 +55,14 @@ export default class TableLoader extends React.PureComponent {
                 return (
                   <Td key={col} column={col} value={row['_' + col]}>
                     {row[col]}
-                  </Td>);
+                  </Td>
+                );
               }
-              return <Td key={col} column={col}>{row[col]}</Td>;
+              return (
+                <Td key={col} column={col}>
+                  {row[col]}
+                </Td>
+              );
             })}
           </Tr>
         ))}
diff --git a/superset/assets/src/welcome/DashboardTable.jsx b/superset/assets/src/welcome/DashboardTable.jsx
index 78d4bdd..f7f3007 100644
--- a/superset/assets/src/welcome/DashboardTable.jsx
+++ b/superset/assets/src/welcome/DashboardTable.jsx
@@ -4,6 +4,7 @@ import ReactDOM from 'react-dom';
 import PropTypes from 'prop-types';
 import { Table, Tr, Td, Thead, Th, unsafe } from 'reactable';
 
+import Loading from '../components/Loading';
 import '../../stylesheets/reactable-pagination.css';
 
 const $ = window.$ = require('jquery');
@@ -60,12 +61,7 @@ export default class DashboardTable extends React.PureComponent {
         </Table>
       );
     }
-    return (
-      <img
-        className="loading"
-        alt="Loading..."
-        src="/static/assets/images/loading.gif"
-      />);
+    return <Loading />;
   }
 }
 DashboardTable.propTypes = propTypes;
diff --git a/superset/assets/yarn.lock b/superset/assets/yarn.lock
index 2add797..9d1a39b 100644
--- a/superset/assets/yarn.lock
+++ b/superset/assets/yarn.lock
@@ -2051,6 +2051,10 @@ clap@^1.0.9:
   dependencies:
     chalk "^1.1.3"
 
+classnames@2.x, classnames@^2.1.2:
+  version "2.2.6"
+  resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce"
+
 classnames@^2.2.3, classnames@^2.2.4, classnames@^2.2.5:
   version "2.2.5"
   resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.5.tgz#fb3801d453467649ef3603c7d61a02bd129bde6d"
@@ -3591,6 +3595,10 @@ execa@^0.7.0:
     signal-exit "^3.0.0"
     strip-eof "^1.0.0"
 
+exenv@^1.2.0:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d"
+
 exit-hook@^1.0.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8"
@@ -5731,7 +5739,7 @@ lodash.isarray@^3.0.0:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55"
 
-lodash.isequal@^4.1.1:
+lodash.isequal@^4.0.0, lodash.isequal@^4.1.1:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
 
@@ -7953,6 +7961,15 @@ react-bootstrap-slider@2.0.1:
     react "^15.6.1"
     react-dom "^15.6.1"
 
+react-bootstrap-table@^4.0.2:
+  version "4.3.1"
+  resolved "https://registry.yarnpkg.com/react-bootstrap-table/-/react-bootstrap-table-4.3.1.tgz#f704be55b7f6bf0557d2fc5bec6d25fd307d0cde"
+  dependencies:
+    classnames "^2.1.2"
+    prop-types "^15.5.10"
+    react-modal "^3.1.7"
+    react-s-alert "^1.3.2"
+
 react-bootstrap@^0.31.5:
   version "0.31.5"
   resolved "https://registry.yarnpkg.com/react-bootstrap/-/react-bootstrap-0.31.5.tgz#57040fa8b1274e1e074803c21a1b895fdabea05a"
@@ -8013,6 +8030,13 @@ react-dnd@^2.5.4:
     object-assign "^4.1.0"
     prop-types "^15.5.10"
 
+react-draggable@3.x:
+  version "3.0.5"
+  resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-3.0.5.tgz#c031e0ed4313531f9409d6cd84c8ebcec0ddfe2d"
+  dependencies:
+    classnames "^2.2.5"
+    prop-types "^15.6.0"
+
 "react-draggable@^2.2.6 || ^3.0.3":
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-3.0.3.tgz#a6f9b3a7171981b76dadecf238316925cb9eacf4"
@@ -8028,6 +8052,16 @@ react-gravatar@^2.6.1:
     md5 "^2.1.0"
     query-string "^4.2.2"
 
+react-grid-layout@0.16.5:
+  version "0.16.5"
+  resolved "https://registry.yarnpkg.com/react-grid-layout/-/react-grid-layout-0.16.5.tgz#1ff12d12afa875c11fe05802f7509e52bfe9a2cb"
+  dependencies:
+    classnames "2.x"
+    lodash.isequal "^4.0.0"
+    prop-types "15.x"
+    react-draggable "3.x"
+    react-resizable "1.x"
+
 react-html-attributes@^1.3.0:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/react-html-attributes/-/react-html-attributes-1.4.1.tgz#97b5ec710da68833598c8be6f89ac436216840a5"
@@ -8047,6 +8081,10 @@ react-input-autosize@^2.1.2:
   dependencies:
     prop-types "^15.5.8"
 
+react-lifecycles-compat@^3.0.0:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
+
 react-map-gl@^3.0.4:
   version "3.0.5"
   resolved "https://registry.yarnpkg.com/react-map-gl/-/react-map-gl-3.0.5.tgz#8797b4a1a85be1404a2409f43f577ad939475a60"
@@ -8069,6 +8107,15 @@ react-markdown@^3.3.0:
     unist-util-visit "^1.3.0"
     xtend "^4.0.1"
 
+react-modal@^3.1.7:
+  version "3.4.5"
+  resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-3.4.5.tgz#75a7eefb8f4c8247278d5ce1c41249d7785d9f69"
+  dependencies:
+    exenv "^1.2.0"
+    prop-types "^15.5.10"
+    react-lifecycles-compat "^3.0.0"
+    warning "^3.0.0"
+
 react-onclickoutside@^5.9.0:
   version "5.11.1"
   resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-5.11.1.tgz#00314e52567cf55faba94cabbacd119619070623"
@@ -8096,13 +8143,19 @@ react-redux@^5.0.2:
     loose-envify "^1.1.0"
     prop-types "^15.5.10"
 
-react-resizable@^1.3.3:
+react-resizable@1.x, react-resizable@^1.3.3:
   version "1.7.5"
   resolved "https://registry.yarnpkg.com/react-resizable/-/react-resizable-1.7.5.tgz#83eb75bb3684da6989bbbf4f826e1470f0af902e"
   dependencies:
     prop-types "15.x"
     react-draggable "^2.2.6 || ^3.0.3"
 
+react-s-alert@^1.3.2:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/react-s-alert/-/react-s-alert-1.4.1.tgz#ef3665a9d98c4cf2e448fc2d84e48aeca799bb5a"
+  dependencies:
+    babel-runtime "^6.23.0"
+
 react-search-input@^0.11.3:
   version "0.11.3"
   resolved "https://registry.yarnpkg.com/react-search-input/-/react-search-input-0.11.3.tgz#3dd1f9fc584b6bc40a6ee133ae042b6fbb7ae8dd"


Mime
View raw message