superset-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From ccwilli...@apache.org
Subject [incubator-superset] 03/26: [dashboard-builder] add top-level tabs + undo-redo (#4626)
Date Fri, 22 Jun 2018 00:54:18 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 abc3ec076af5fcd61e5544d743288f8b326e851f
Author: Chris Williams <williaster@users.noreply.github.com>
AuthorDate: Fri Mar 23 10:53:48 2018 -0700

    [dashboard-builder] add top-level tabs + undo-redo (#4626)
    
    * [top-level-tabs] initial working version of top-level tabs
    
    * [top-level-tabs] simplify redux and disable ability to displace top-level tabs with other tabs
    
    * [top-level-tabs] improve tab drag and drop css
    
    * [undo-redo] add redux undo redo
    
    * [dnd] clean up dropResult shape, add new component source id + type, use css for drop indicator instead of styles and fix tab indicators.
    
    * [top-level-tabs] add 'Collapse tab content' to delete tabs button
    
    * [dnd] add depth validation to drag and drop logic
    
    * [dashboard-builder] add resize action, enforce minimum width of columns, column children inherit column size when necessary, meta.rowStyle => meta.background, add background to columns
    
    * [dashboard-builder] make sure getChildWidth returns a number
---
 .../javascripts/dashboard/v2/actions/index.js      | 113 +++++++++++++--
 .../dashboard/v2/components/Dashboard.jsx          |  21 +--
 .../dashboard/v2/components/DashboardBuilder.jsx   |  97 ++++++++++++-
 .../dashboard/v2/components/DashboardGrid.jsx      |  64 +++-----
 .../dashboard/v2/components/DashboardHeader.jsx    |  71 +++++++--
 .../dashboard/v2/components/IconButton.jsx         |  12 +-
 .../dashboard/v2/components/dnd/DragDroppable.jsx  |  41 ++++--
 .../v2/components/dnd/dragDroppableConfig.js       |  11 +-
 .../dashboard/v2/components/dnd/handleDrop.js      |  20 ++-
 .../dashboard/v2/components/dnd/handleHover.js     |  18 +--
 .../v2/components/gridComponents/Chart.jsx         |   5 +-
 .../v2/components/gridComponents/Column.jsx        | 132 ++++++++++++-----
 .../v2/components/gridComponents/Divider.jsx       |   3 +
 .../v2/components/gridComponents/Header.jsx        |  23 +--
 .../dashboard/v2/components/gridComponents/Row.jsx |  27 ++--
 .../v2/components/gridComponents/Spacer.jsx        |   9 +-
 .../dashboard/v2/components/gridComponents/Tab.jsx |  31 ++--
 .../v2/components/gridComponents/Tabs.jsx          |  61 +++++---
 .../gridComponents/new/DraggableNewComponent.jsx   |   5 +-
 ...yleDropdown.jsx => BackgroundStyleDropdown.jsx} |  12 +-
 .../v2/components/menu/WithPopoverMenu.jsx         |  10 +-
 .../v2/components/resizable/ResizableContainer.jsx |  41 ++++--
 .../{DashboardGrid.jsx => DashboardBuilder.jsx}    |  12 +-
 .../dashboard/v2/containers/DashboardComponent.jsx |  33 +++--
 .../dashboard/v2/containers/DashboardGrid.jsx      |  12 +-
 .../dashboard/v2/containers/DashboardHeader.jsx    |  31 ++++
 .../dashboard/v2/fixtures/emptyDashboardLayout.js  |  36 +++++
 .../dashboard/v2/fixtures/testLayout.js            | 161 ---------------------
 .../javascripts/dashboard/v2/reducers/dashboard.js | 146 +++++++++++++++++--
 .../javascripts/dashboard/v2/reducers/index.js     |   9 +-
 .../dashboard/v2/stylesheets/builder.less          |  64 ++++++++
 .../dashboard/v2/stylesheets/buttons.less          |   8 +-
 .../v2/stylesheets/components/DashboardBuilder.jsx | 127 ++++++++++++++++
 .../v2/stylesheets/components/column.less          |  10 +-
 .../v2/stylesheets/components/new-component.less   |   1 +
 .../dashboard/v2/stylesheets/components/row.less   |   6 +-
 .../dashboard/v2/stylesheets/components/tabs.less  |  39 +++--
 .../javascripts/dashboard/v2/stylesheets/dnd.less  |  54 ++++---
 .../javascripts/dashboard/v2/stylesheets/grid.less |  43 +++++-
 .../dashboard/v2/stylesheets/hover-menu.less       |  14 +-
 .../dashboard/v2/stylesheets/index.less            |   1 +
 .../dashboard/v2/stylesheets/popover-menu.less     |  24 ++-
 .../dashboard/v2/stylesheets/resizable.less        |  12 +-
 .../dashboard/v2/util/backgroundStyleOptions.js    |   7 +
 .../dashboard/v2/util/componentTypes.js            |  10 +-
 .../javascripts/dashboard/v2/util/constants.js     |  11 +-
 .../dashboard/v2/util/countChildRowsAndColumns.js  |  14 --
 .../javascripts/dashboard/v2/util/dnd-reorder.js   |  18 +--
 .../javascripts/dashboard/v2/util/findParentId.js  |  15 ++
 .../javascripts/dashboard/v2/util/getChildWidth.js |  16 ++
 .../dashboard/v2/util/getDropPosition.js           |  16 +-
 .../javascripts/dashboard/v2/util/isValidChild.js  |  96 +++++++-----
 .../dashboard/v2/util/newComponentFactory.js       |  12 +-
 .../dashboard/v2/util/newEntitiesFromDrop.js       |  20 +--
 .../javascripts/dashboard/v2/util/propShapes.jsx   |   4 +-
 .../dashboard/v2/util/resizableConfig.js           |   7 +-
 .../dashboard/v2/util/rowStyleOptions.js           |   7 -
 .../dashboard/v2/util/shouldWrapChildInRow.js      |   4 +-
 superset/assets/package.json                       |   1 +
 superset/assets/src/components/EditableTitle.jsx   |   6 +-
 superset/assets/src/dashboard/index.jsx            |   8 +-
 superset/assets/stylesheets/dashboard-v2.css       |  42 ------
 superset/assets/stylesheets/superset.less          |   2 +-
 superset/templates/appbuilder/navbar.html          |  15 --
 64 files changed, 1305 insertions(+), 696 deletions(-)

diff --git a/superset/assets/javascripts/dashboard/v2/actions/index.js b/superset/assets/javascripts/dashboard/v2/actions/index.js
index 005a77e..a6c7b77 100644
--- a/superset/assets/javascripts/dashboard/v2/actions/index.js
+++ b/superset/assets/javascripts/dashboard/v2/actions/index.js
@@ -1,3 +1,12 @@
+import { DASHBOARD_ROOT_ID, NEW_COMPONENTS_SOURCE_ID } from '../util/constants';
+import findParentId from '../util/findParentId';
+import {
+  CHART_TYPE,
+  MARKDOWN_TYPE,
+  TABS_TYPE,
+} from '../util/componentTypes';
+
+// Component CRUD -------------------------------------------------------------
 export const UPDATE_COMPONENTS = 'UPDATE_COMPONENTS';
 export function updateComponents(nextComponents) {
   return {
@@ -29,6 +38,67 @@ export function createComponent(dropResult) {
   };
 }
 
+// Tabs -----------------------------------------------------------------------
+export const CREATE_TOP_LEVEL_TABS = 'CREATE_TOP_LEVEL_TABS';
+export function createTopLevelTabs(dropResult) {
+  return {
+    type: CREATE_TOP_LEVEL_TABS,
+    payload: {
+      dropResult,
+    },
+  };
+}
+
+export const DELETE_TOP_LEVEL_TABS = 'DELETE_TOP_LEVEL_TABS';
+export function deleteTopLevelTabs() {
+  return {
+    type: DELETE_TOP_LEVEL_TABS,
+    payload: {},
+  };
+}
+
+// Resize ---------------------------------------------------------------------
+export const RESIZE_COMPONENT = 'RESIZE_COMPONENT';
+export function resizeComponent({ id, width, height }) {
+  return (dispatch, getState) => {
+    const { dashboard: undoableDashboard } = getState();
+    const { present: dashboard } = undoableDashboard;
+    const component = dashboard[id];
+
+    if (
+      component &&
+      (component.meta.width !== width || component.meta.height !== height)
+    ) {
+      // update the size of this component + any resizable children
+      const updatedComponents = {
+        [id]: {
+          ...component,
+          meta: {
+            ...component.meta,
+            width: width || component.meta.width,
+            height: height || component.meta.height,
+          },
+        },
+      };
+
+      component.children.forEach((childId) => {
+        const child = dashboard[childId];
+        if ([CHART_TYPE, MARKDOWN_TYPE].includes(child.type)) {
+          updatedComponents[childId] = {
+            ...child,
+            meta: {
+              ...child.meta,
+              width: width || component.meta.width,
+              height: height || component.meta.height,
+            },
+          };
+        }
+      });
+
+      dispatch(updateComponents(updatedComponents));
+    }
+  };
+}
 
 // Drag and drop --------------------------------------------------------------
 export const MOVE_COMPONENT = 'MOVE_COMPONENT';
@@ -43,27 +113,38 @@ export function moveComponent(dropResult) {
 
 export const HANDLE_COMPONENT_DROP = 'HANDLE_COMPONENT_DROP';
 export function handleComponentDrop(dropResult) {
-  return (dispatch) => {
-    if (
-      dropResult.destination
-      && dropResult.source
+  return (dispatch, getState) => {
+    const { source, destination } = dropResult;
+    const droppedOnRoot = destination && destination.id === DASHBOARD_ROOT_ID;
+    const isNewComponent = source.id === NEW_COMPONENTS_SOURCE_ID;
+
+    if (droppedOnRoot) {
+      dispatch(createTopLevelTabs(dropResult));
+    } else if (destination && isNewComponent) {
+      dispatch(createComponent(dropResult));
+    } else if (
+      destination
+      && source
       && !( // ensure it has moved
-        dropResult.destination.droppableId === dropResult.source.droppableId
-        && dropResult.destination.index === dropResult.source.index
+        destination.id === source.id
+        && destination.index === source.index
       )
     ) {
-      return dispatch(moveComponent(dropResult));
+      dispatch(moveComponent(dropResult));
+    }
 
-      // new components don't have a source
-    } else if (dropResult.destination && !dropResult.source) {
-      return dispatch(createComponent(dropResult));
+    // if we moved a tab and the parent tabs no longer has children, delete it.
+    if (!isNewComponent) {
+      const { dashboard: undoableDashboard } = getState();
+      const { present: dashboard } = undoableDashboard;
+      const sourceComponent = dashboard[source.id];
+
+      if (sourceComponent.type === TABS_TYPE && sourceComponent.children.length === 0) {
+        const parentId = findParentId({ childId: source.id, components: dashboard });
+        dispatch(deleteComponent(source.id, parentId));
+      }
     }
+
     return null;
   };
 }
-
-// Resize ---------------------------------------------------------------------
-
-// export function dashboardComponentResizeStart() {}
-// export function dashboardComponentResize() {}
-// export function dashboardComponentResizeStop() {}
diff --git a/superset/assets/javascripts/dashboard/v2/components/Dashboard.jsx b/superset/assets/javascripts/dashboard/v2/components/Dashboard.jsx
index a2ed1a0..ffd1280 100644
--- a/superset/assets/javascripts/dashboard/v2/components/Dashboard.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/Dashboard.jsx
@@ -1,11 +1,8 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import DashboardBuilder from './DashboardBuilder';
-import StaticDashboard from './StaticDashboard';
-import DashboardHeader from './DashboardHeader';
+import DashboardBuilder from '../containers/DashboardBuilder';
 
-import '../../../../stylesheets/dashboard-v2.css';
 import '../stylesheets/index.less';
 
 const propTypes = {
@@ -22,20 +19,8 @@ const defaultProps = {
 
 class Dashboard extends React.Component {
   render() {
-    const { editMode, actions } = this.props;
-    const { setEditMode, updateDashboardTitle } = actions;
-    return (
-      <div className="dashboard-v2">
-        <DashboardHeader
-          editMode={true}
-          setEditMode={setEditMode}
-          updateDashboardTitle={updateDashboardTitle}
-        />
-
-        {true ?
-          <DashboardBuilder /> : <StaticDashboard />}
-      </div>
-    );
+    // @TODO delete this component?
+    return <DashboardBuilder />;
   }
 }
 
diff --git a/superset/assets/javascripts/dashboard/v2/components/DashboardBuilder.jsx b/superset/assets/javascripts/dashboard/v2/components/DashboardBuilder.jsx
index 1878db6..f371718 100644
--- a/superset/assets/javascripts/dashboard/v2/components/DashboardBuilder.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/DashboardBuilder.jsx
@@ -2,13 +2,28 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import HTML5Backend from 'react-dnd-html5-backend';
 import { DragDropContext } from 'react-dnd';
-import cx from 'classnames';
 
 import BuilderComponentPane from './BuilderComponentPane';
+import DashboardHeader from '../containers/DashboardHeader';
 import DashboardGrid from '../containers/DashboardGrid';
+import IconButton from './IconButton';
+import DragDroppable from './dnd/DragDroppable';
+import DashboardComponent from '../containers/DashboardComponent';
+import WithPopoverMenu from './menu/WithPopoverMenu';
+
+import {
+  DASHBOARD_GRID_ID,
+  DASHBOARD_ROOT_ID,
+  DASHBOARD_ROOT_DEPTH,
+} from '../util/constants';
 
 const propTypes = {
   editMode: PropTypes.bool,
+
+  // redux
+  dashboard: PropTypes.object.isRequired,
+  deleteTopLevelTabs: PropTypes.func.isRequired,
+  handleComponentDrop: PropTypes.func.isRequired,
 };
 
 const defaultProps = {
@@ -16,17 +31,87 @@ const defaultProps = {
 };
 
 class DashboardBuilder extends React.Component {
+  static shouldFocusTabs(event, container) {
+    // don't focus the tabs when we click on a tab
+    return event.target.tagName === 'UL' || (
+      /icon-button/.test(event.target.className) && container.contains(event.target)
+    );
+  }
+
   constructor(props) {
     super(props);
-    // this component might control the state of the side pane etc. in the future
-    this.state = {};
+    this.state = {
+      tabIndex: 0, // top-level tabs
+    };
+    this.handleChangeTab = this.handleChangeTab.bind(this);
+  }
+
+  handleChangeTab({ tabIndex }) {
+    this.setState(() => ({ tabIndex }));
   }
 
   render() {
+    const { tabIndex } = this.state;
+    const { handleComponentDrop, dashboard, deleteTopLevelTabs } = this.props;
+    const dashboardRoot = dashboard[DASHBOARD_ROOT_ID];
+    const rootChildId = dashboardRoot.children[0];
+    const topLevelTabs = rootChildId !== DASHBOARD_GRID_ID && dashboard[rootChildId];
+
+    const gridComponentId = topLevelTabs
+      ? topLevelTabs.children[Math.min(topLevelTabs.children.length - 1, tabIndex)]
+      : DASHBOARD_GRID_ID;
+
+    const gridComponent = dashboard[gridComponentId];
+
     return (
-      <div className={cx('dashboard-builder')}>
-        <DashboardGrid />
-        <BuilderComponentPane />
+      <div className="dashboard-v2">
+        {topLevelTabs ? ( // you cannot drop on/displace tabs if they already exist
+          <DashboardHeader />
+        ) : (
+          <DragDroppable
+            component={dashboardRoot}
+            parentComponent={null}
+            depth={DASHBOARD_ROOT_DEPTH}
+            index={0}
+            orientation="column"
+            onDrop={topLevelTabs ? null : handleComponentDrop}
+          >
+            {({ dropIndicatorProps }) => (
+              <div>
+                <DashboardHeader />
+                {dropIndicatorProps && <div {...dropIndicatorProps} />}
+              </div>
+            )}
+          </DragDroppable>)}
+
+        {topLevelTabs &&
+          <WithPopoverMenu
+            shouldFocus={DashboardBuilder.shouldFocusTabs}
+            menuItems={[
+              <IconButton
+                className="fa fa-level-down"
+                label="Collapse tab content"
+                onClick={deleteTopLevelTabs}
+              />,
+            ]}
+          >
+            <DashboardComponent
+              id={topLevelTabs.id}
+              parentId={DASHBOARD_ROOT_ID}
+              depth={DASHBOARD_ROOT_DEPTH + 1}
+              index={0}
+              renderTabContent={false}
+              onChangeTab={this.handleChangeTab}
+            />
+          </WithPopoverMenu>}
+
+        <div className="dashboard-builder">
+          <DashboardGrid
+            gridComponent={gridComponent}
+            depth={DASHBOARD_ROOT_DEPTH + 1}
+          />
+          <BuilderComponentPane />
+        </div>
       </div>
     );
   }
diff --git a/superset/assets/javascripts/dashboard/v2/components/DashboardGrid.jsx b/superset/assets/javascripts/dashboard/v2/components/DashboardGrid.jsx
index c92161a..cfe99c7 100644
--- a/superset/assets/javascripts/dashboard/v2/components/DashboardGrid.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/DashboardGrid.jsx
@@ -1,21 +1,21 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import ParentSize from '@vx/responsive/build/components/ParentSize';
-import cx from 'classnames';
 
-import DragDroppable from './dnd/DragDroppable';
+import { componentShape } from '../util/propShapes';
 import DashboardComponent from '../containers/DashboardComponent';
+import DragDroppable from './dnd/DragDroppable';
 
 import {
-  DASHBOARD_ROOT_ID,
   GRID_GUTTER_SIZE,
   GRID_COLUMN_COUNT,
 } from '../util/constants';
 
 const propTypes = {
-  dashboard: PropTypes.object.isRequired,
-  updateComponents: PropTypes.func.isRequired,
+  depth: PropTypes.number.isRequired,
+  gridComponent: componentShape.isRequired,
   handleComponentDrop: PropTypes.func.isRequired,
+  resizeComponent: PropTypes.func.isRequired,
 };
 
 const defaultProps = {
@@ -60,24 +60,9 @@ class DashboardGrid extends React.PureComponent {
     }
   }
 
-  handleResizeStop({ id, widthMultiple, heightMultiple }) {
-    const { dashboard: components, updateComponents } = this.props;
-    const component = components[id];
-    if (
-      component &&
-      (component.meta.width !== widthMultiple || component.meta.height !== heightMultiple)
-    ) {
-      updateComponents({
-        [id]: {
-          ...component,
-          meta: {
-            ...component.meta,
-            width: widthMultiple || component.meta.width,
-            height: heightMultiple || component.meta.height,
-          },
-        },
-      });
-    }
+  handleResizeStop({ id, widthMultiple: width, heightMultiple: height }) {
+    this.props.resizeComponent({ id, width, height });
+
     this.setState(() => ({
       isResizing: false,
       rowGuideTop: null,
@@ -85,18 +70,11 @@ class DashboardGrid extends React.PureComponent {
   }
 
   render() {
-    const { dashboard: components, handleComponentDrop } = this.props;
+    const { gridComponent, handleComponentDrop, depth } = this.props;
     const { isResizing, rowGuideTop } = this.state;
-    const rootComponent = components[DASHBOARD_ROOT_ID];
 
     return (
-      <div
-        ref={(ref) => { this.grid = ref; }}
-        className={cx(
-          'grid-container',
-          isResizing && 'grid-container--resizing',
-        )}
-      >
+      <div className="grid-container" ref={(ref) => { this.grid = ref; }}>
         <ParentSize>
           {({ width }) => {
             // account for (COLUMN_COUNT - 1) gutters
@@ -104,13 +82,13 @@ class DashboardGrid extends React.PureComponent {
             const columnWidth = columnPlusGutterWidth - GRID_GUTTER_SIZE;
 
             return width < 50 ? null : (
-              <div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
-                {(rootComponent.children || []).map((id, index) => (
+              <div className="grid-content">
+                {gridComponent.children.map((id, index) => (
                   <DashboardComponent
                     key={id}
                     id={id}
-                    parentId={rootComponent.id}
-                    depth={0}
+                    parentId={gridComponent.id}
+                    depth={depth + 1}
                     index={index}
                     availableColumnCount={GRID_COLUMN_COUNT}
                     columnWidth={columnWidth}
@@ -120,19 +98,19 @@ class DashboardGrid extends React.PureComponent {
                   />
                 ))}
 
-                {rootComponent.children.length === 0 &&
+                {/* render an empty drop target */}
+                {gridComponent.children.length === 0 &&
                   <DragDroppable
-                    component={rootComponent}
+                    component={gridComponent}
+                    depth={depth}
                     parentComponent={null}
                     index={0}
                     orientation="column"
                     onDrop={handleComponentDrop}
+                    className="empty-grid-droptarget"
                   >
-                    {({ dropIndicatorProps }) => (
-                      <div style={{ width: '100%', height: '100%' }}>
-                        {dropIndicatorProps && <div {...dropIndicatorProps} />}
-                      </div>
-                    )}
+                    {({ dropIndicatorProps }) => dropIndicatorProps &&
+                      <div {...dropIndicatorProps} />}
                   </DragDroppable>}
 
                 {isResizing && Array(GRID_COLUMN_COUNT).fill(null).map((_, i) => (
diff --git a/superset/assets/javascripts/dashboard/v2/components/DashboardHeader.jsx b/superset/assets/javascripts/dashboard/v2/components/DashboardHeader.jsx
index 8ffe677..e0d14c4 100644
--- a/superset/assets/javascripts/dashboard/v2/components/DashboardHeader.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/DashboardHeader.jsx
@@ -1,44 +1,83 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import { ButtonToolbar, DropdownButton, MenuItem } from 'react-bootstrap';
+import { ButtonGroup, ButtonToolbar, DropdownButton, MenuItem } from 'react-bootstrap';
 
 import Button from '../../../components/Button';
+import { componentShape } from '../util/propShapes';
 import EditableTitle from '../../../components/EditableTitle';
 
 const propTypes = {
-  updateDashboardTitle: PropTypes.func,
-  editMode: PropTypes.bool.isRequired,
-  setEditMode: PropTypes.func.isRequired,
+  // editMode: PropTypes.bool.isRequired,
+  // setEditMode: PropTypes.func.isRequired,
+  component: componentShape.isRequired,
+
+  // redux
+  updateComponents: PropTypes.func.isRequired,
+  onUndo: PropTypes.func.isRequired,
+  onRedo: PropTypes.func.isRequired,
+  canUndo: PropTypes.bool.isRequired,
+  canRedo: PropTypes.bool.isRequired,
 };
 
-class Header extends React.Component {
+class DashboardHeader extends React.Component {
   constructor(props) {
     super(props);
-    this.handleSaveTitle = this.handleSaveTitle.bind(this);
+    this.handleChangeText = this.handleChangeText.bind(this);
     this.toggleEditMode = this.toggleEditMode.bind(this);
   }
 
-  handleSaveTitle(title) {
-    this.props.updateDashboardTitle(title);
+  toggleEditMode() {
+    console.log('@TODO toggleEditMode');
+    // this.props.setEditMode(!this.props.editMode);
   }
 
-  toggleEditMode() {
-    this.props.setEditMode(!this.props.editMode);
+  handleChangeText(nextText) {
+    const { updateComponents, component } = this.props;
+    if (nextText && component.meta.text !== nextText) {
+      updateComponents({
+        [component.id]: {
+          ...component,
+          meta: {
+            ...component.meta,
+            text: nextText,
+          },
+        },
+      });
+    }
   }
 
   render() {
-    const { editMode } = this.props;
+    const { component, onUndo, onRedo, canUndo, canRedo } = this.props;
+    const editMode = true;
+
     return (
       <div className="dashboard-header">
         <h1>
           <EditableTitle
-            title={'Example header'}
-            canEdit={false}
-            onSaveTitle={() => {}}
+            title={component.meta.text}
+            onSaveTitle={this.handleChangeText}
             showTooltip={false}
+            canEdit={editMode}
           />
         </h1>
         <ButtonToolbar>
+          <ButtonGroup>
+            <Button
+              bsSize="small"
+              onClick={onUndo}
+              disabled={!canUndo}
+            >
+              Undo
+            </Button>
+            <Button
+              bsSize="small"
+              onClick={onRedo}
+              disabled={!canRedo}
+            >
+              Redo
+            </Button>
+          </ButtonGroup>
+
           <DropdownButton title="Actions" bsSize="small" id="btn-dashboard-actions">
             <MenuItem>Action 1</MenuItem>
             <MenuItem>Action 2</MenuItem>
@@ -57,6 +96,6 @@ class Header extends React.Component {
   }
 }
 
-Header.propTypes = propTypes;
+DashboardHeader.propTypes = propTypes;
 
-export default Header;
+export default DashboardHeader;
diff --git a/superset/assets/javascripts/dashboard/v2/components/IconButton.jsx b/superset/assets/javascripts/dashboard/v2/components/IconButton.jsx
index 98044c9..18fd3b1 100644
--- a/superset/assets/javascripts/dashboard/v2/components/IconButton.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/IconButton.jsx
@@ -1,14 +1,15 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import cx from 'classnames';
 
 const propTypes = {
   onClick: PropTypes.func.isRequired,
   className: PropTypes.string,
+  label: PropTypes.string,
 };
 
 const defaultProps = {
   className: null,
+  label: null,
 };
 
 export default class IconButton extends React.PureComponent {
@@ -24,14 +25,17 @@ export default class IconButton extends React.PureComponent {
   }
 
   render() {
-    const { className } = this.props;
+    const { className, label } = this.props;
     return (
       <div
-        className={cx('icon-button', className)}
+        className="icon-button"
         onClick={this.handleClick}
         tabIndex="0"
         role="button"
-      />
+      >
+        <span className={className} />
+        {label && <span className="icon-button-label">{label}</span>}
+      </div>
     );
   }
 }
diff --git a/superset/assets/javascripts/dashboard/v2/components/dnd/DragDroppable.jsx b/superset/assets/javascripts/dashboard/v2/components/dnd/DragDroppable.jsx
index 320872b..89664e5 100644
--- a/superset/assets/javascripts/dashboard/v2/components/dnd/DragDroppable.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/dnd/DragDroppable.jsx
@@ -3,17 +3,21 @@ import PropTypes from 'prop-types';
 import { DragSource, DropTarget } from 'react-dnd';
 import cx from 'classnames';
 
-import { dragConfig, dropConfig } from './dragDroppableConfig';
 import { componentShape } from '../../util/propShapes';
-
+import { dragConfig, dropConfig } from './dragDroppableConfig';
+import { DROP_TOP, DROP_RIGHT, DROP_BOTTOM, DROP_LEFT } from '../../util/getDropPosition';
 
 const propTypes = {
   children: PropTypes.func,
+  className: PropTypes.string,
   component: componentShape.isRequired,
   parentComponent: componentShape,
+  depth: PropTypes.number.isRequired,
   disableDragDrop: PropTypes.bool,
   orientation: PropTypes.oneOf(['row', 'column']),
   index: PropTypes.number.isRequired,
+  style: PropTypes.object,
+  onDrop: PropTypes.func,
 
   // from react-dnd
   isDragging: PropTypes.bool.isRequired,
@@ -22,12 +26,11 @@ const propTypes = {
   droppableRef: PropTypes.func.isRequired,
   dragSourceRef: PropTypes.func.isRequired,
   dragPreviewRef: PropTypes.func.isRequired,
-
-  // from redux
-  onDrop: PropTypes.func,
 };
 
 const defaultProps = {
+  className: null,
+  style: null,
   parentComponent: null,
   disableDragDrop: false,
   children() {},
@@ -41,6 +44,7 @@ class DragDroppable extends React.Component {
     this.state = {
       dropIndicator: null, // this gets set/modified by the react-dnd HOCs
     };
+    this.setRef = this.setRef.bind(this);
   }
 
   componentDidMount() {
@@ -51,38 +55,47 @@ class DragDroppable extends React.Component {
     this.mounted = false;
   }
 
+  setRef(ref) {
+    this.ref = ref;
+    this.props.dragPreviewRef(ref);
+    this.props.droppableRef(ref);
+  }
+
   render() {
     const {
       children,
+      className,
       orientation,
-      droppableRef,
       dragSourceRef,
-      dragPreviewRef,
       isDragging,
       isDraggingOver,
+      style,
     } = this.props;
 
     const { dropIndicator } = this.state;
 
     return (
       <div
-        ref={(ref) => {
-          this.ref = ref;
-          dragPreviewRef(ref);
-          droppableRef(ref);
-        }}
+        style={style}
+        ref={this.setRef}
         className={cx(
           'dragdroppable',
           orientation === 'row' && 'dragdroppable-row',
           orientation === 'column' && 'dragdroppable-column',
           isDragging && 'dragdroppable--dragging',
+          className,
         )}
       >
         {children({
           dragSourceRef,
           dropIndicatorProps: isDraggingOver && dropIndicator && {
-            className: 'drop-indicator',
-            style: dropIndicator,
+            className: cx(
+              'drop-indicator',
+              dropIndicator === DROP_TOP && 'drop-indicator--top',
+              dropIndicator === DROP_BOTTOM && 'drop-indicator--bottom',
+              dropIndicator === DROP_LEFT && 'drop-indicator--left',
+              dropIndicator === DROP_RIGHT && 'drop-indicator--right',
+            ),
           },
         })}
       </div>
diff --git a/superset/assets/javascripts/dashboard/v2/components/dnd/dragDroppableConfig.js b/superset/assets/javascripts/dashboard/v2/components/dnd/dragDroppableConfig.js
index e6d5533..55d7e1d 100644
--- a/superset/assets/javascripts/dashboard/v2/components/dnd/dragDroppableConfig.js
+++ b/superset/assets/javascripts/dashboard/v2/components/dnd/dragDroppableConfig.js
@@ -10,13 +10,16 @@ export const dragConfig = [
     canDrag(props) {
       return !props.disableDragDrop;
     },
+
+    // this defines the dragging item object returned by monitor.getItem()
     beginDrag(props /* , monitor, component */) {
-      const { component, index, parentComponent } = props;
+      const { component, index, parentComponent = {} } = props;
       return {
-        draggableId: component.id,
-        index,
-        parentId: parentComponent && parentComponent.id,
         type: component.type,
+        id: component.id,
+        index,
+        parentId: parentComponent.id,
+        parentType: parentComponent.type,
       };
     },
   },
diff --git a/superset/assets/javascripts/dashboard/v2/components/dnd/handleDrop.js b/superset/assets/javascripts/dashboard/v2/components/dnd/handleDrop.js
index cf790da..2207ca6 100644
--- a/superset/assets/javascripts/dashboard/v2/components/dnd/handleDrop.js
+++ b/superset/assets/javascripts/dashboard/v2/components/dnd/handleDrop.js
@@ -2,7 +2,7 @@ import getDropPosition, { DROP_TOP, DROP_RIGHT, DROP_BOTTOM, DROP_LEFT } from '.
 
 export default function handleDrop(props, monitor, Component) {
   // this may happen due to throttling
-  if (!Component.mounted) return undefined;
+  if (!Component.mounted || !Component.props.onDrop) return undefined;
 
   Component.setState(() => ({ dropIndicator: null }));
   const dropPosition = getDropPosition(monitor, Component);
@@ -27,17 +27,22 @@ export default function handleDrop(props, monitor, Component) {
     ? 'sibling' : 'child';
 
   const dropResult = {
-    source: draggingItem.parentId ? {
-      droppableId: draggingItem.parentId,
+    source: {
+      id: draggingItem.parentId,
+      type: draggingItem.parentType,
       index: draggingItem.index,
-    } : null,
-    draggableId: draggingItem.draggableId,
+    },
+    dragging: {
+      id: draggingItem.id,
+      type: draggingItem.type,
+    },
   };
 
   // simplest case, append as child
   if (dropAsChildOrSibling === 'child') {
     dropResult.destination = {
-      droppableId: component.id,
+      id: component.id,
+      type: component.type,
       index: component.children.length,
     };
   } else {
@@ -52,7 +57,8 @@ export default function handleDrop(props, monitor, Component) {
     }
 
     dropResult.destination = {
-      droppableId: parentComponent.id,
+      id: parentComponent.id,
+      type: parentComponent.type,
       index: nextIndex,
     };
   }
diff --git a/superset/assets/javascripts/dashboard/v2/components/dnd/handleHover.js b/superset/assets/javascripts/dashboard/v2/components/dnd/handleHover.js
index 1eadef4..a303e13 100644
--- a/superset/assets/javascripts/dashboard/v2/components/dnd/handleHover.js
+++ b/superset/assets/javascripts/dashboard/v2/components/dnd/handleHover.js
@@ -1,5 +1,5 @@
 import throttle from 'lodash.throttle';
-import getDropPosition, { DROP_TOP, DROP_RIGHT, DROP_BOTTOM, DROP_LEFT } from '../../util/getDropPosition';
+import getDropPosition from '../../util/getDropPosition';
 
 const HOVER_THROTTLE_MS = 200;
 
@@ -14,22 +14,8 @@ function handleHover(props, monitor, Component) {
     return;
   }
 
-  // @TODO
-  // drop-indicator
-  // drop-indicator--top/right/bottom/left
   Component.setState(() => ({
-    dropIndicator: {
-      top: dropPosition === DROP_BOTTOM ? '100%' : 0,
-      left: dropPosition === DROP_RIGHT ? '100%' : 0,
-      height: dropPosition === DROP_LEFT || dropPosition === DROP_RIGHT ? '100%' : 3,
-      width: dropPosition === DROP_TOP || dropPosition === DROP_BOTTOM ? '100%' : 3,
-      minHeight: dropPosition === DROP_LEFT || dropPosition === DROP_RIGHT ? 16 : null,
-      minWidth: dropPosition === DROP_TOP || dropPosition === DROP_BOTTOM ? 16 : null,
-      margin: 'auto',
-      backgroundColor: '#44C0FF',
-      position: 'absolute',
-      zIndex: 10,
-    },
+    dropIndicator: dropPosition,
   }));
 }
 
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Chart.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Chart.jsx
index 9daa8cf..7ca506d 100644
--- a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Chart.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Chart.jsx
@@ -8,7 +8,7 @@ import HoverMenu from '../menu/HoverMenu';
 import ResizableContainer from '../resizable/ResizableContainer';
 import WithPopoverMenu from '../menu/WithPopoverMenu';
 import { componentShape } from '../../util/propShapes';
-
+import { ROW_TYPE } from '../../util/componentTypes';
 import {
   GRID_MIN_COLUMN_COUNT,
   GRID_MIN_ROW_UNITS,
@@ -79,13 +79,14 @@ class Chart extends React.Component {
         parentComponent={parentComponent}
         orientation={depth % 2 === 1 ? 'column' : 'row'}
         index={index}
+        depth={depth}
         onDrop={handleComponentDrop}
         disableDragDrop={isFocused}
       >
         {({ dropIndicatorProps, dragSourceRef }) => (
           <ResizableContainer
             id={component.id}
-            adjustableWidth={depth <= 1}
+            adjustableWidth={parentComponent.type === ROW_TYPE}
             adjustableHeight
             widthStep={columnWidth}
             widthMultiple={component.meta.width}
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Column.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Column.jsx
index 8409bc1..d51870d 100644
--- a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Column.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Column.jsx
@@ -7,10 +7,18 @@ import DeleteComponentButton from '../DeleteComponentButton';
 import DragDroppable from '../dnd/DragDroppable';
 import DragHandle from '../dnd/DragHandle';
 import HoverMenu from '../menu/HoverMenu';
+import IconButton from '../IconButton';
 import ResizableContainer from '../resizable/ResizableContainer';
+import BackgroundStyleDropdown from '../menu/BackgroundStyleDropdown';
+import WithPopoverMenu from '../menu/WithPopoverMenu';
+
+import backgroundStyleOptions from '../../util/backgroundStyleOptions';
 import { componentShape } from '../../util/propShapes';
 
-import { GRID_GUTTER_SIZE, GRID_MIN_COLUMN_COUNT } from '../../util/constants';
+import {
+  BACKGROUND_TRANSPARENT,
+  GRID_GUTTER_SIZE,
+} from '../../util/constants';
 
 const GUTTER = 'GUTTER';
 
@@ -21,11 +29,11 @@ const propTypes = {
   parentComponent: componentShape.isRequired,
   index: PropTypes.number.isRequired,
   depth: PropTypes.number.isRequired,
-  // occupiedRowCount: PropTypes.number,
 
   // grid related
   availableColumnCount: PropTypes.number.isRequired,
   columnWidth: PropTypes.number.isRequired,
+  minColumnWidth: PropTypes.number.isRequired,
   onResizeStart: PropTypes.func.isRequired,
   onResize: PropTypes.func.isRequired,
   onResizeStop: PropTypes.func.isRequired,
@@ -33,15 +41,20 @@ const propTypes = {
   // dnd
   deleteComponent: PropTypes.func.isRequired,
   handleComponentDrop: PropTypes.func.isRequired,
+  updateComponents: PropTypes.func.isRequired,
 };
 
 const defaultProps = {
-  // occupiedRowCount: null,
 };
 
 class Column extends React.PureComponent {
   constructor(props) {
     super(props);
+    this.state = {
+      isFocused: false,
+    };
+    this.handleChangeBackground = this.handleUpdateMeta.bind(this, 'background');
+    this.handleChangeFocus = this.handleChangeFocus.bind(this);
     this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
   }
 
@@ -50,6 +63,25 @@ class Column extends React.PureComponent {
     deleteComponent(id, parentId);
   }
 
+  handleChangeFocus(nextFocus) {
+    this.setState(() => ({ isFocused: Boolean(nextFocus) }));
+  }
+
+  handleUpdateMeta(metaKey, nextValue) {
+    const { updateComponents, component } = this.props;
+    if (nextValue && component.meta[metaKey] !== nextValue) {
+      updateComponents({
+        [component.id]: {
+          ...component,
+          meta: {
+            ...component.meta,
+            [metaKey]: nextValue,
+          },
+        },
+      });
+    }
+  }
+
   render() {
     const {
       component: columnComponent,
@@ -57,7 +89,7 @@ class Column extends React.PureComponent {
       index,
       availableColumnCount,
       columnWidth,
-      // occupiedRowCount,
+      minColumnWidth,
       depth,
       onResizeStart,
       onResize,
@@ -74,12 +106,19 @@ class Column extends React.PureComponent {
       }
     });
 
+    const backgroundStyle = backgroundStyleOptions.find(
+      opt => opt.value === (columnComponent.meta.background || BACKGROUND_TRANSPARENT),
+    );
+
+    console.log('occupied/avail cols', columnComponent.meta.width, '/', availableColumnCount, 'min width', minColumnWidth)
+
     return (
       <DragDroppable
         component={columnComponent}
         parentComponent={parentComponent}
         orientation="column"
         index={index}
+        depth={depth}
         onDrop={handleComponentDrop}
       >
         {({ dropIndicatorProps, dragSourceRef }) => (
@@ -89,47 +128,64 @@ class Column extends React.PureComponent {
             adjustableHeight={false}
             widthStep={columnWidth}
             widthMultiple={columnComponent.meta.width}
-            // heightMultiple={occupiedRowCount}
-            minWidthMultiple={GRID_MIN_COLUMN_COUNT}
+            minWidthMultiple={minColumnWidth}
             maxWidthMultiple={availableColumnCount + (columnComponent.meta.width || 0)}
             onResizeStart={onResizeStart}
             onResize={onResize}
             onResizeStop={onResizeStop}
           >
-            <div
-              className={cx(
-                'grid-column',
-                columnItems.length === 0 && 'grid-column--empty',
-              )}
+            <WithPopoverMenu
+              isFocused={this.state.isFocused}
+              onChangeFocus={this.handleChangeFocus}
+              disableClick
+              menuItems={[
+                <BackgroundStyleDropdown
+                  id={`${columnComponent.id}-background`}
+                  value={columnComponent.meta.background}
+                  onChange={this.handleChangeBackground}
+                />,
+              ]}
             >
-              <HoverMenu innerRef={dragSourceRef} position="top">
-                <DragHandle position="top" />
-                <DeleteComponentButton onDelete={this.handleDeleteComponent} />
-              </HoverMenu>
-
-              {columnItems.map((componentId, itemIndex) => {
-                if (componentId === GUTTER) {
-                  return <div key={`gutter-${itemIndex}`} style={{ height: GRID_GUTTER_SIZE }} />;
-                }
-
-                return (
-                  <DashboardComponent
-                    key={componentId}
-                    id={componentId}
-                    parentId={columnComponent.id}
-                    depth={depth + 1}
-                    index={itemIndex / 2} // account for gutters!
-                    availableColumnCount={availableColumnCount}
-                    columnWidth={columnWidth}
-                    onResizeStart={onResizeStart}
-                    onResize={onResize}
-                    onResizeStop={onResizeStop}
+              <div
+                className={cx(
+                  'grid-column',
+                  columnItems.length === 0 && 'grid-column--empty',
+                  backgroundStyle.className,
+                )}
+              >
+                <HoverMenu innerRef={dragSourceRef} position="top">
+                  <DragHandle position="top" />
+                  <DeleteComponentButton onDelete={this.handleDeleteComponent} />
+                  <IconButton
+                    onClick={this.handleChangeFocus}
+                    className="fa fa-cog"
                   />
-                );
-              })}
-
-              {dropIndicatorProps && <div {...dropIndicatorProps} />}
-            </div>
+                </HoverMenu>
+
+                {columnItems.map((componentId, itemIndex) => {
+                  if (componentId === GUTTER) {
+                    return <div key={`gutter-${itemIndex}`} style={{ height: GRID_GUTTER_SIZE }} />;
+                  }
+
+                  return (
+                    <DashboardComponent
+                      key={componentId}
+                      id={componentId}
+                      parentId={columnComponent.id}
+                      depth={depth + 1}
+                      index={itemIndex / 2} // account for gutters!
+                      availableColumnCount={columnComponent.meta.width}
+                      columnWidth={columnWidth}
+                      onResizeStart={onResizeStart}
+                      onResize={onResize}
+                      onResizeStop={onResizeStop}
+                    />
+                  );
+                })}
+
+                {dropIndicatorProps && <div {...dropIndicatorProps} />}
+              </div>
+            </WithPopoverMenu>
           </ResizableContainer>
         )}
       </DragDroppable>
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Divider.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Divider.jsx
index 29437e1..ff29c3f 100644
--- a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Divider.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Divider.jsx
@@ -10,6 +10,7 @@ const propTypes = {
   id: PropTypes.string.isRequired,
   parentId: PropTypes.string.isRequired,
   component: componentShape.isRequired,
+  depth: PropTypes.number.isRequired,
   parentComponent: componentShape.isRequired,
   index: PropTypes.number.isRequired,
   handleComponentDrop: PropTypes.func.isRequired,
@@ -30,6 +31,7 @@ class Divider extends React.PureComponent {
   render() {
     const {
       component,
+      depth,
       parentComponent,
       index,
       handleComponentDrop,
@@ -41,6 +43,7 @@ class Divider extends React.PureComponent {
         parentComponent={parentComponent}
         orientation="row"
         index={index}
+        depth={depth}
         onDrop={handleComponentDrop}
       >
         {({ dropIndicatorProps, dragSourceRef }) => (
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Header.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Header.jsx
index 967b483..d8744d6 100644
--- a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Header.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Header.jsx
@@ -7,18 +7,19 @@ import DragHandle from '../dnd/DragHandle';
 import EditableTitle from '../../../../components/EditableTitle';
 import HoverMenu from '../menu/HoverMenu';
 import WithPopoverMenu from '../menu/WithPopoverMenu';
-import RowStyleDropdown from '../menu/RowStyleDropdown';
+import BackgroundStyleDropdown from '../menu/BackgroundStyleDropdown';
 import DeleteComponentButton from '../DeleteComponentButton';
 import PopoverDropdown from '../menu/PopoverDropdown';
 import headerStyleOptions from '../../util/headerStyleOptions';
-import rowStyleOptions from '../../util/rowStyleOptions';
+import backgroundStyleOptions from '../../util/backgroundStyleOptions';
 import { componentShape } from '../../util/propShapes';
-import { SMALL_HEADER, ROW_TRANSPARENT } from '../../util/constants';
+import { SMALL_HEADER, BACKGROUND_TRANSPARENT } from '../../util/constants';
 
 const propTypes = {
   id: PropTypes.string.isRequired,
   parentId: PropTypes.string.isRequired,
   component: componentShape.isRequired,
+  depth: PropTypes.number.isRequired,
   parentComponent: componentShape.isRequired,
   index: PropTypes.number.isRequired,
 
@@ -41,7 +42,7 @@ class Header extends React.PureComponent {
     this.handleChangeFocus = this.handleChangeFocus.bind(this);
     this.handleUpdateMeta = this.handleUpdateMeta.bind(this);
     this.handleChangeSize = this.handleUpdateMeta.bind(this, 'headerSize');
-    this.handleChangeRowStyle = this.handleUpdateMeta.bind(this, 'rowStyle');
+    this.handleChangeBackground = this.handleUpdateMeta.bind(this, 'background');
     this.handleChangeText = this.handleUpdateMeta.bind(this, 'text');
   }
 
@@ -74,6 +75,7 @@ class Header extends React.PureComponent {
 
     const {
       component,
+      depth,
       parentComponent,
       index,
       handleComponentDrop,
@@ -83,8 +85,8 @@ class Header extends React.PureComponent {
       opt => opt.value === (component.meta.headerSize || SMALL_HEADER),
     );
 
-    const rowStyle = rowStyleOptions.find(
-      opt => opt.value === (component.meta.rowStyle || ROW_TRANSPARENT),
+    const rowStyle = backgroundStyleOptions.find(
+      opt => opt.value === (component.meta.background || BACKGROUND_TRANSPARENT),
     );
 
     return (
@@ -93,6 +95,7 @@ class Header extends React.PureComponent {
         parentComponent={parentComponent}
         orientation="row"
         index={index}
+        depth={depth}
         onDrop={handleComponentDrop}
         disableDragDrop={isFocused}
       >
@@ -112,10 +115,10 @@ class Header extends React.PureComponent {
                   onChange={this.handleChangeSize}
                   renderTitle={option => `${option.label} header`}
                 />,
-                <RowStyleDropdown
-                  id={`${component.id}-row-style`}
-                  value={component.meta.rowStyle}
-                  onChange={this.handleChangeRowStyle}
+                <BackgroundStyleDropdown
+                  id={`${component.id}-background`}
+                  value={component.meta.background}
+                  onChange={this.handleChangeBackground}
                 />,
                 <DeleteComponentButton onDelete={this.handleDeleteComponent} />,
               ]}
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Row.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Row.jsx
index 3386f8c..a60524f 100644
--- a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Row.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Row.jsx
@@ -8,12 +8,12 @@ import DashboardComponent from '../../containers/DashboardComponent';
 import DeleteComponentButton from '../DeleteComponentButton';
 import HoverMenu from '../menu/HoverMenu';
 import IconButton from '../IconButton';
-import RowStyleDropdown from '../menu/RowStyleDropdown';
+import BackgroundStyleDropdown from '../menu/BackgroundStyleDropdown';
 import WithPopoverMenu from '../menu/WithPopoverMenu';
 
 import { componentShape } from '../../util/propShapes';
-import rowStyleOptions from '../../util/rowStyleOptions';
-import { GRID_GUTTER_SIZE, ROW_TRANSPARENT } from '../../util/constants';
+import backgroundStyleOptions from '../../util/backgroundStyleOptions';
+import { GRID_GUTTER_SIZE, BACKGROUND_TRANSPARENT } from '../../util/constants';
 
 const GUTTER = 'GUTTER';
 
@@ -29,7 +29,6 @@ const propTypes = {
   availableColumnCount: PropTypes.number.isRequired,
   columnWidth: PropTypes.number.isRequired,
   occupiedColumnCount: PropTypes.number.isRequired,
-  occupiedRowCount: PropTypes.number.isRequired,
   onResizeStart: PropTypes.func.isRequired,
   onResize: PropTypes.func.isRequired,
   onResizeStop: PropTypes.func.isRequired,
@@ -52,7 +51,7 @@ class Row extends React.PureComponent {
     };
     this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
     this.handleUpdateMeta = this.handleUpdateMeta.bind(this);
-    this.handleChangeRowStyle = this.handleUpdateMeta.bind(this, 'rowStyle');
+    this.handleChangeBackground = this.handleUpdateMeta.bind(this, 'background');
     this.handleChangeFocus = this.handleChangeFocus.bind(this);
   }
 
@@ -88,7 +87,6 @@ class Row extends React.PureComponent {
       availableColumnCount,
       columnWidth,
       occupiedColumnCount,
-      occupiedRowCount,
       depth,
       onResizeStart,
       onResize,
@@ -106,8 +104,8 @@ class Row extends React.PureComponent {
       }
     });
 
-    const rowStyle = rowStyleOptions.find(
-      opt => opt.value === (rowComponent.meta.rowStyle || ROW_TRANSPARENT),
+    const backgroundStyle = backgroundStyleOptions.find(
+      opt => opt.value === (rowComponent.meta.background || BACKGROUND_TRANSPARENT),
     );
 
     return (
@@ -116,6 +114,7 @@ class Row extends React.PureComponent {
         parentComponent={parentComponent}
         orientation="row"
         index={index}
+        depth={depth}
         onDrop={handleComponentDrop}
       >
         {({ dropIndicatorProps, dragSourceRef }) => (
@@ -124,19 +123,18 @@ class Row extends React.PureComponent {
             onChangeFocus={this.handleChangeFocus}
             disableClick
             menuItems={[
-              <RowStyleDropdown
-                id={`${rowComponent.id}-row-style`}
-                value={rowComponent.meta.rowStyle}
-                onChange={this.handleChangeRowStyle}
+              <BackgroundStyleDropdown
+                id={`${rowComponent.id}-background`}
+                value={rowComponent.meta.background}
+                onChange={this.handleChangeBackground}
               />,
             ]}
           >
-
             <div
               className={cx(
                 'grid-row',
                 rowItems.length === 0 && 'grid-row--empty',
-                rowStyle.className,
+                backgroundStyle.className,
               )}
             >
               <HoverMenu innerRef={dragSourceRef} position="left">
@@ -161,7 +159,6 @@ class Row extends React.PureComponent {
                     depth={depth + 1}
                     index={itemIndex / 2} // account for gutters!
                     availableColumnCount={availableColumnCount - occupiedColumnCount}
-                    occupiedRowCount={occupiedRowCount}
                     columnWidth={columnWidth}
                     onResizeStart={onResizeStart}
                     onResize={onResize}
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Spacer.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Spacer.jsx
index faac589..7a287d8 100644
--- a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Spacer.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Spacer.jsx
@@ -18,7 +18,6 @@ const propTypes = {
   // grid related
   availableColumnCount: PropTypes.number.isRequired,
   columnWidth: PropTypes.number.isRequired,
-  occupiedRowCount: PropTypes.number,
   onResizeStart: PropTypes.func.isRequired,
   onResize: PropTypes.func.isRequired,
   onResizeStop: PropTypes.func.isRequired,
@@ -29,7 +28,6 @@ const propTypes = {
 };
 
 const defaultProps = {
-  occupiedRowCount: null,
 };
 
 class Spacer extends React.PureComponent {
@@ -51,7 +49,6 @@ class Spacer extends React.PureComponent {
       depth,
       availableColumnCount,
       columnWidth,
-      occupiedRowCount,
       onResizeStart,
       onResize,
       onResizeStop,
@@ -63,12 +60,15 @@ class Spacer extends React.PureComponent {
     const adjustableWidth = orientation === 'column';
     const adjustableHeight = orientation === 'row';
 
+    console.log('spacer', availableColumnCount)
+
     return (
       <DragDroppable
         component={component}
         parentComponent={parentComponent}
         orientation={orientation}
         index={index}
+        depth={depth}
         onDrop={handleComponentDrop}
       >
         {({ dropIndicatorProps, dragSourceRef }) => (
@@ -77,9 +77,8 @@ class Spacer extends React.PureComponent {
             adjustableWidth={adjustableWidth}
             adjustableHeight={adjustableHeight}
             widthStep={columnWidth}
-            widthMultiple={component.meta.width}
+            widthMultiple={component.meta.width || 1}
             heightMultiple={adjustableHeight ? component.meta.height || 1 : undefined}
-            staticHeightMultiple={!adjustableHeight ? occupiedRowCount || 5 : undefined}
             minWidthMultiple={1}
             minHeightMultiple={1}
             maxWidthMultiple={availableColumnCount + (component.meta.width || 0)}
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tab.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tab.jsx
index 74cd9ae..9548a4b 100644
--- a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tab.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tab.jsx
@@ -20,13 +20,14 @@ const propTypes = {
   depth: PropTypes.number.isRequired,
   renderType: PropTypes.oneOf([RENDER_TAB, RENDER_TAB_CONTENT]).isRequired,
   onDropOnTab: PropTypes.func,
+  onDeleteTab: PropTypes.func,
 
   // grid related
-  availableColumnCount: PropTypes.number.isRequired,
-  columnWidth: PropTypes.number.isRequired,
-  onResizeStart: PropTypes.func.isRequired,
-  onResize: PropTypes.func.isRequired,
-  onResizeStop: PropTypes.func.isRequired,
+  availableColumnCount: PropTypes.number,
+  columnWidth: PropTypes.number,
+  onResizeStart: PropTypes.func,
+  onResize: PropTypes.func,
+  onResizeStop: PropTypes.func,
 
   // redux
   handleComponentDrop: PropTypes.func.isRequired,
@@ -35,7 +36,13 @@ const propTypes = {
 };
 
 const defaultProps = {
-  onDropOnTab: null,
+  availableColumnCount: 0,
+  columnWidth: 0,
+  onDropOnTab() {},
+  onDeleteTab() {},
+  onResizeStart() {},
+  onResize() {},
+  onResizeStop() {},
 };
 
 export default class Tab extends React.PureComponent {
@@ -70,14 +77,14 @@ export default class Tab extends React.PureComponent {
   }
 
   handleDeleteComponent() {
-    const { deleteComponent, id, parentId } = this.props;
+    const { onDeleteTab, index, deleteComponent, id, parentId } = this.props;
     deleteComponent(id, parentId);
+    onDeleteTab(index);
   }
 
   handleDrop(dropResult) {
-    const { handleComponentDrop, onDropOnTab } = this.props;
-    handleComponentDrop(dropResult);
-    if (onDropOnTab) onDropOnTab(dropResult);
+    this.props.handleComponentDrop(dropResult);
+    this.props.onDropOnTab(dropResult);
   }
 
   renderTabContent() {
@@ -98,7 +105,7 @@ export default class Tab extends React.PureComponent {
             key={componentId}
             id={componentId}
             parentId={tabComponent.id}
-            depth={depth}
+            depth={depth} // see isValidChild.js for why tabs don't increment child depth
             index={componentIndex}
             onDrop={this.handleDrop}
             availableColumnCount={availableColumnCount}
@@ -118,6 +125,7 @@ export default class Tab extends React.PureComponent {
       component,
       parentComponent,
       index,
+      depth,
     } = this.props;
 
     return (
@@ -126,6 +134,7 @@ export default class Tab extends React.PureComponent {
         parentComponent={parentComponent}
         orientation="column"
         index={index}
+        depth={depth}
         onDrop={this.handleDrop}
         disableDragDrop={isFocused}
       >
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tabs.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tabs.jsx
index 1e2e64c..cc5f637 100644
--- a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tabs.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tabs.jsx
@@ -8,8 +8,9 @@ import DashboardComponent from '../../containers/DashboardComponent';
 import DeleteComponentButton from '../DeleteComponentButton';
 import HoverMenu from '../menu/HoverMenu';
 import { componentShape } from '../../util/propShapes';
-import { NEW_TAB_ID } from '../../util/constants';
+import { NEW_TAB_ID, DASHBOARD_ROOT_ID } from '../../util/constants';
 import { RENDER_TAB, RENDER_TAB_CONTENT } from './Tab';
+import { TAB_TYPE } from '../../util/componentTypes';
 
 const NEW_TAB_INDEX = -1;
 const MAX_TAB_COUNT = 5;
@@ -21,13 +22,14 @@ const propTypes = {
   parentComponent: componentShape.isRequired,
   index: PropTypes.number.isRequired,
   depth: PropTypes.number.isRequired,
+  renderTabContent: PropTypes.bool,
 
   // grid related
-  availableColumnCount: PropTypes.number.isRequired,
-  columnWidth: PropTypes.number.isRequired,
-  onResizeStart: PropTypes.func.isRequired,
-  onResize: PropTypes.func.isRequired,
-  onResizeStop: PropTypes.func.isRequired,
+  availableColumnCount: PropTypes.number,
+  columnWidth: PropTypes.number,
+  onResizeStart: PropTypes.func,
+  onResize: PropTypes.func,
+  onResizeStop: PropTypes.func,
 
   // dnd
   createComponent: PropTypes.func.isRequired,
@@ -40,6 +42,12 @@ const propTypes = {
 const defaultProps = {
   onChangeTab: null,
   children: null,
+  renderTabContent: true,
+  availableColumnCount: 0,
+  columnWidth: 0,
+  onResizeStart() {},
+  onResize() {},
+  onResizeStop() {},
 };
 
 class Tabs extends React.PureComponent {
@@ -48,8 +56,9 @@ class Tabs extends React.PureComponent {
     this.state = {
       tabIndex: 0,
     };
-    this.handleClicKTab = this.handleClicKTab.bind(this);
+    this.handleClickTab = this.handleClickTab.bind(this);
     this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+    this.handleDeleteTab = this.handleDeleteTab.bind(this);
     this.handleDropOnTab = this.handleDropOnTab.bind(this);
   }
 
@@ -60,7 +69,7 @@ class Tabs extends React.PureComponent {
     }
   }
 
-  handleClicKTab(tabIndex) {
+  handleClickTab(tabIndex) {
     const { onChangeTab, component, createComponent } = this.props;
 
     if (tabIndex !== NEW_TAB_INDEX && tabIndex !== this.state.tabIndex) {
@@ -71,10 +80,14 @@ class Tabs extends React.PureComponent {
     } else if (tabIndex === NEW_TAB_INDEX) {
       createComponent({
         destination: {
-          droppableId: component.id,
+          id: component.id,
+          type: component.type,
           index: component.children.length,
         },
-        draggableId: NEW_TAB_ID,
+        dragging: {
+          id: NEW_TAB_ID,
+          type: TAB_TYPE,
+        },
       });
     }
   }
@@ -84,19 +97,23 @@ class Tabs extends React.PureComponent {
     deleteComponent(id, parentId);
   }
 
+  handleDeleteTab(tabIndex) {
+    this.handleClickTab(Math.max(0, tabIndex - 1));
+  }
+
   handleDropOnTab(dropResult) {
     const { component } = this.props;
 
     // Ensure dropped tab is visible
     const { destination } = dropResult;
     if (destination) {
-      const dropTabIndex = destination.droppableId === component.id
+      const dropTabIndex = destination.id === component.id
         ? destination.index // dropped ON tabs
-        : component.children.indexOf(destination.droppableId); // dropped IN tab
+        : component.children.indexOf(destination.id); // dropped IN tab
 
       if (dropTabIndex > -1) {
         setTimeout(() => {
-          this.handleClicKTab(dropTabIndex);
+          this.handleClickTab(dropTabIndex);
         }, 30);
       }
     }
@@ -114,6 +131,7 @@ class Tabs extends React.PureComponent {
       onResize,
       onResizeStop,
       handleComponentDrop,
+      renderTabContent,
     } = this.props;
 
     const { tabIndex: selectedTabIndex } = this.state;
@@ -125,6 +143,7 @@ class Tabs extends React.PureComponent {
         parentComponent={parentComponent}
         orientation="row"
         index={index}
+        depth={depth}
         onDrop={handleComponentDrop}
       >
         {({ dropIndicatorProps: tabsDropIndicatorProps, dragSourceRef: tabsDragSourceRef }) => (
@@ -137,7 +156,7 @@ class Tabs extends React.PureComponent {
             <BootstrapTabs
               id={tabsComponent.id}
               activeKey={selectedTabIndex}
-              onSelect={this.handleClicKTab}
+              onSelect={this.handleClickTab}
               animation={false}
             >
               {tabIds.map((tabId, tabIndex) => (
@@ -156,10 +175,8 @@ class Tabs extends React.PureComponent {
                       renderType={RENDER_TAB}
                       availableColumnCount={availableColumnCount}
                       columnWidth={columnWidth}
-                      onResizeStart={onResizeStart}
-                      onResize={onResize}
-                      onResizeStop={onResizeStop}
                       onDropOnTab={this.handleDropOnTab}
+                      onDeleteTab={this.handleDeleteTab}
                     />
                   }
                 >
@@ -168,11 +185,11 @@ class Tabs extends React.PureComponent {
                     render potentially-expensive charts (this also enables lazy loading
                     their content)
                   */}
-                  {tabIndex === selectedTabIndex &&
+                  {tabIndex === selectedTabIndex && renderTabContent &&
                     <DashboardComponent
                       id={tabId}
                       parentId={tabsComponent.id}
-                      depth={depth}
+                      depth={depth} // see isValidChild.js for why tabs don't increment child depth
                       index={tabIndex}
                       renderType={RENDER_TAB_CONTENT}
                       availableColumnCount={availableColumnCount}
@@ -188,14 +205,14 @@ class Tabs extends React.PureComponent {
               {tabIds.length < MAX_TAB_COUNT &&
                 <BootstrapTab
                   eventKey={NEW_TAB_INDEX}
-                  title={<div className="fa fa-plus-square" />}
+                  title={<div className="fa fa-plus" />}
                 />}
 
             </BootstrapTabs>
 
+            {/* don't indicate that a drop on root is allowed when tabs already exist */}
             {tabsDropIndicatorProps
-              && tabsDropIndicatorProps.style
-              && tabsDropIndicatorProps.style.width === '100%'
+              && parentComponent.id !== DASHBOARD_ROOT_ID
               && <div {...tabsDropIndicatorProps} />}
 
           </div>
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/DraggableNewComponent.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/DraggableNewComponent.jsx
index c4d8d62..778f58e 100644
--- a/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/DraggableNewComponent.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/DraggableNewComponent.jsx
@@ -3,6 +3,8 @@ import PropTypes from 'prop-types';
 import cx from 'classnames';
 
 import DragDroppable from '../../dnd/DragDroppable';
+import { NEW_COMPONENTS_SOURCE_ID } from '../../../util/constants';
+import { NEW_COMPONENT_SOURCE_TYPE } from '../../../util/componentTypes';
 
 const propTypes = {
   id: PropTypes.string.isRequired,
@@ -21,8 +23,9 @@ export default class DraggableNewComponent extends React.PureComponent {
     return (
       <DragDroppable
         component={{ type, id }}
-        parentComponent={null}
+        parentComponent={{ id: NEW_COMPONENTS_SOURCE_ID, type: NEW_COMPONENT_SOURCE_TYPE }}
         index={0}
+        depth={0}
       >
         {({ dragSourceRef }) => (
           <div ref={dragSourceRef} className="new-component">
diff --git a/superset/assets/javascripts/dashboard/v2/components/menu/RowStyleDropdown.jsx b/superset/assets/javascripts/dashboard/v2/components/menu/BackgroundStyleDropdown.jsx
similarity index 65%
rename from superset/assets/javascripts/dashboard/v2/components/menu/RowStyleDropdown.jsx
rename to superset/assets/javascripts/dashboard/v2/components/menu/BackgroundStyleDropdown.jsx
index d3c7eff..41cf1df 100644
--- a/superset/assets/javascripts/dashboard/v2/components/menu/RowStyleDropdown.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/menu/BackgroundStyleDropdown.jsx
@@ -2,7 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import cx from 'classnames';
 
-import rowStyleOptions from '../../util/rowStyleOptions';
+import backgroundStyleOptions from '../../util/backgroundStyleOptions';
 import PopoverDropdown from './PopoverDropdown';
 
 const propTypes = {
@@ -13,7 +13,7 @@ const propTypes = {
 
 function renderButton(option) {
   return (
-    <div className={cx('row-style-option', option.className)}>
+    <div className={cx('background-style-option', option.className)}>
       {`${option.label} background`}
     </div>
   );
@@ -21,19 +21,19 @@ function renderButton(option) {
 
 function renderOption(option) {
   return (
-    <div className={cx('row-style-option', option.className)}>
+    <div className={cx('background-style-option', option.className)}>
       {option.label}
     </div>
   );
 }
 
-export default class RowStyleDropdown extends React.PureComponent {
+export default class BackgroundStyleDropdown extends React.PureComponent {
   render() {
     const { id, value, onChange } = this.props;
     return (
       <PopoverDropdown
         id={id}
-        options={rowStyleOptions}
+        options={backgroundStyleOptions}
         value={value}
         onChange={onChange}
         renderButton={renderButton}
@@ -43,4 +43,4 @@ export default class RowStyleDropdown extends React.PureComponent {
   }
 }
 
-RowStyleDropdown.propTypes = propTypes;
+BackgroundStyleDropdown.propTypes = propTypes;
diff --git a/superset/assets/javascripts/dashboard/v2/components/menu/WithPopoverMenu.jsx b/superset/assets/javascripts/dashboard/v2/components/menu/WithPopoverMenu.jsx
index 7fb24cd..2054090 100644
--- a/superset/assets/javascripts/dashboard/v2/components/menu/WithPopoverMenu.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/menu/WithPopoverMenu.jsx
@@ -8,6 +8,7 @@ const propTypes = {
   menuItems: PropTypes.arrayOf(PropTypes.node),
   onChangeFocus: PropTypes.func,
   isFocused: PropTypes.bool,
+  shouldFocus: PropTypes.func,
 };
 
 const defaultProps = {
@@ -17,6 +18,7 @@ const defaultProps = {
   onPressDelete() {},
   menuItems: [],
   isFocused: false,
+  shouldFocus: (event, container) => container.contains(event.target),
 };
 
 class WithPopoverMenu extends React.PureComponent {
@@ -47,8 +49,10 @@ class WithPopoverMenu extends React.PureComponent {
   }
 
   handleClick(event) {
-    const { onChangeFocus } = this.props;
-    if (!this.state.isFocused) {
+    const { onChangeFocus, shouldFocus: shouldFocusThunk } = this.props;
+    const shouldFocus = shouldFocusThunk(event, this.container);
+
+    if (shouldFocus && !this.state.isFocused) {
       // if not focused, set focus and add a window event listener to capture outside clicks
       // this enables us to not set a click listener for ever item on a dashboard
       document.addEventListener('click', this.handleClick, true);
@@ -57,7 +61,7 @@ class WithPopoverMenu extends React.PureComponent {
       if (onChangeFocus) {
         onChangeFocus(true);
       }
-    } else if (!this.container.contains(event.target)) {
+    } else if (!shouldFocus && this.state.isFocused) {
       document.removeEventListener('click', this.handleClick, true);
       document.removeEventListener('drag', this.handleClick, true);
       this.setState(() => ({ isFocused: false }));
diff --git a/superset/assets/javascripts/dashboard/v2/components/resizable/ResizableContainer.jsx b/superset/assets/javascripts/dashboard/v2/components/resizable/ResizableContainer.jsx
index 5e43678..fbb7d1d 100644
--- a/superset/assets/javascripts/dashboard/v2/components/resizable/ResizableContainer.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/resizable/ResizableContainer.jsx
@@ -50,9 +50,9 @@ const defaultProps = {
   onResizeStart: null,
 };
 
-// because columns are not actually multiples of a single variable (width = n*cols + (n-1)*gutters)
-// we snap to the base unit and then snap to actual column multiples on stop
-const snapToGrid = [GRID_BASE_UNIT, GRID_BASE_UNIT];
+// because columns are not multiples of a single variable (width = n*cols + (n-1) * gutters)
+// we snap to the base unit and then snap to _actual_ column multiples on stop
+const SNAP_TO_GRID = [GRID_BASE_UNIT, GRID_BASE_UNIT];
 
 class ResizableContainer extends React.PureComponent {
   constructor(props) {
@@ -120,9 +120,12 @@ class ResizableContainer extends React.PureComponent {
       adjustableHeight,
       widthStep,
       heightStep,
-      staticHeightMultiple,
       widthMultiple,
       heightMultiple,
+      staticHeight,
+      staticHeightMultiple,
+      staticWidth,
+      staticWidthMultiple,
       minWidthMultiple,
       maxWidthMultiple,
       minHeightMultiple,
@@ -132,42 +135,48 @@ class ResizableContainer extends React.PureComponent {
 
     const size = {
       width: adjustableWidth
-        ? ((widthStep + gutterWidth) * widthMultiple) - gutterWidth : undefined,
+        ? ((widthStep + gutterWidth) * widthMultiple) - gutterWidth
+        : (staticWidthMultiple && staticWidthMultiple * widthStep)
+          || staticWidth
+          || undefined,
       height: adjustableHeight
         ? heightStep * heightMultiple
-        : (staticHeightMultiple && staticHeightMultiple * heightStep) || undefined,
+        : (staticHeightMultiple && staticHeightMultiple * heightStep)
+          || staticHeight
+          || undefined,
     };
 
-    let enableConfig = resizableConfig.widthAndHeight;
-    if (!adjustableHeight) enableConfig = resizableConfig.widthOnly;
-    else if (!adjustableWidth) enableConfig = resizableConfig.heightOnly;
+    let enableConfig = resizableConfig.notAdjustable;
+    if (adjustableWidth && adjustableHeight) enableConfig = resizableConfig.widthAndHeight;
+    else if (adjustableWidth) enableConfig = resizableConfig.widthOnly;
+    else if (adjustableHeight) enableConfig = resizableConfig.heightOnly;
 
     const { isResizing } = this.state;
 
     return (
       <Resizable
         enable={enableConfig}
-        grid={snapToGrid}
+        grid={SNAP_TO_GRID}
         minWidth={adjustableWidth
           ? (minWidthMultiple * (widthStep + gutterWidth)) - gutterWidth
-          : size.width}
+          : undefined}
         minHeight={adjustableHeight
           ? (minHeightMultiple * heightStep)
-          : size.height}
+          : undefined}
         maxWidth={adjustableWidth
           ? (maxWidthMultiple * (widthStep + gutterWidth)) - gutterWidth
-          : size.width}
+          : undefined}
         maxHeight={adjustableHeight
           ? (maxHeightMultiple * heightStep)
-          : size.height}
+          : undefined}
         size={size}
         onResizeStart={this.handleResizeStart}
         onResize={this.handleResize}
         onResizeStop={this.handleResizeStop}
         handleComponent={ResizableHandle}
         className={cx(
-          'grid-resizable-container',
-          isResizing && 'grid-resizable-container--resizing',
+          'resizable-container',
+          isResizing && 'resizable-container--resizing',
         )}
       >
         {children}
diff --git a/superset/assets/javascripts/dashboard/v2/containers/DashboardGrid.jsx b/superset/assets/javascripts/dashboard/v2/containers/DashboardBuilder.jsx
similarity index 59%
copy from superset/assets/javascripts/dashboard/v2/containers/DashboardGrid.jsx
copy to superset/assets/javascripts/dashboard/v2/containers/DashboardBuilder.jsx
index 741151b..6bd8658 100644
--- a/superset/assets/javascripts/dashboard/v2/containers/DashboardGrid.jsx
+++ b/superset/assets/javascripts/dashboard/v2/containers/DashboardBuilder.jsx
@@ -1,23 +1,23 @@
 import { bindActionCreators } from 'redux';
 import { connect } from 'react-redux';
-import DashboardGrid from '../components/DashboardGrid';
+import DashboardBuilder from '../components/DashboardBuilder';
 
 import {
-  updateComponents,
+  deleteTopLevelTabs,
   handleComponentDrop,
 } from '../actions';
 
-function mapStateToProps({ dashboard = {} }) {
+function mapStateToProps({ dashboard: undoableDashboard }) {
   return {
-    dashboard,
+    dashboard: undoableDashboard.present,
   };
 }
 
 function mapDispatchToProps(dispatch) {
   return bindActionCreators({
-    updateComponents,
+    deleteTopLevelTabs,
     handleComponentDrop,
   }, dispatch);
 }
 
-export default connect(mapStateToProps, mapDispatchToProps)(DashboardGrid);
+export default connect(mapStateToProps, mapDispatchToProps)(DashboardBuilder);
diff --git a/superset/assets/javascripts/dashboard/v2/containers/DashboardComponent.jsx b/superset/assets/javascripts/dashboard/v2/containers/DashboardComponent.jsx
index 1340781..f7e86cc 100644
--- a/superset/assets/javascripts/dashboard/v2/containers/DashboardComponent.jsx
+++ b/superset/assets/javascripts/dashboard/v2/containers/DashboardComponent.jsx
@@ -4,9 +4,10 @@ import { bindActionCreators } from 'redux';
 import { connect } from 'react-redux';
 
 import ComponentLookup from '../components/gridComponents';
-import countChildRowsAndColumns from '../util/countChildRowsAndColumns';
+import getTotalChildWidth from '../util/getChildWidth';
 import { componentShape } from '../util/propShapes';
-import { ROW_TYPE } from '../util/componentTypes';
+import { COLUMN_TYPE, ROW_TYPE } from '../util/componentTypes';
+import { GRID_MIN_COLUMN_COUNT } from '../util/constants';
 
 import {
   createComponent,
@@ -24,23 +25,31 @@ const propTypes = {
   handleComponentDrop: PropTypes.func.isRequired,
 };
 
-function mapStateToProps({ dashboard = {} }, ownProps) {
+function mapStateToProps({ dashboard: undoableDashboard }, ownProps) {
+  const components = undoableDashboard.present;
   const { id, parentId } = ownProps;
+  const component = components[id];
   const props = {
-    component: dashboard[id],
-    parentComponent: dashboard[parentId],
+    component,
+    parentComponent: components[parentId],
   };
 
-  // row is a special component that needs extra dims about its children
+  // 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) {
-    const { rowCount, columnCount } = countChildRowsAndColumns({
-      component: props.component,
-      components: dashboard,
-    });
+    props.occupiedColumnCount = getTotalChildWidth({ id, components });
+  } else if (props.component.type === COLUMN_TYPE) {
+    props.minColumnWidth = GRID_MIN_COLUMN_COUNT;
 
-    props.occupiedRowCount = rowCount;
-    props.occupiedColumnCount = columnCount;
+    component.children.forEach((childId) => {
+      // rows don't have widths, so find the width of its children
+      if (components[childId].type === ROW_TYPE) {
+        props.minColumnWidth = Math.max(
+          props.minColumnWidth,
+          getTotalChildWidth({ id: childId, components }),
+        );
+      }
+    });
   }
 
   return props;
diff --git a/superset/assets/javascripts/dashboard/v2/containers/DashboardGrid.jsx b/superset/assets/javascripts/dashboard/v2/containers/DashboardGrid.jsx
index 741151b..eb01616 100644
--- a/superset/assets/javascripts/dashboard/v2/containers/DashboardGrid.jsx
+++ b/superset/assets/javascripts/dashboard/v2/containers/DashboardGrid.jsx
@@ -3,21 +3,15 @@ import { connect } from 'react-redux';
 import DashboardGrid from '../components/DashboardGrid';
 
 import {
-  updateComponents,
   handleComponentDrop,
+  resizeComponent,
 } from '../actions';
 
-function mapStateToProps({ dashboard = {} }) {
-  return {
-    dashboard,
-  };
-}
-
 function mapDispatchToProps(dispatch) {
   return bindActionCreators({
-    updateComponents,
     handleComponentDrop,
+    resizeComponent,
   }, dispatch);
 }
 
-export default connect(mapStateToProps, mapDispatchToProps)(DashboardGrid);
+export default connect(null, mapDispatchToProps)(DashboardGrid);
diff --git a/superset/assets/javascripts/dashboard/v2/containers/DashboardHeader.jsx b/superset/assets/javascripts/dashboard/v2/containers/DashboardHeader.jsx
new file mode 100644
index 0000000..52e7e7a
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/containers/DashboardHeader.jsx
@@ -0,0 +1,31 @@
+import { ActionCreators as UndoActionCreators } from 'redux-undo'
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+
+import DashboardHeader from '../components/DashboardHeader';
+import { DASHBOARD_HEADER_ID } from '../util/constants';
+
+import {
+  updateComponents,
+  handleComponentDrop,
+} from '../actions';
+
+function mapStateToProps({ dashboard: undoableDashboard }) {
+  return {
+    component: undoableDashboard.present[DASHBOARD_HEADER_ID],
+    canUndo: undoableDashboard.past.length > 0,
+    canRedo: undoableDashboard.future.length > 0,
+  };
+}
+
+function mapDispatchToProps(dispatch) {
+  return bindActionCreators({
+    updateComponents,
+    handleComponentDrop,
+    onUndo: UndoActionCreators.undo,
+    onRedo: UndoActionCreators.redo,
+  }, dispatch);
+}
+
+
+export default connect(mapStateToProps, mapDispatchToProps)(DashboardHeader);
diff --git a/superset/assets/javascripts/dashboard/v2/fixtures/emptyDashboardLayout.js b/superset/assets/javascripts/dashboard/v2/fixtures/emptyDashboardLayout.js
new file mode 100644
index 0000000..7816cc2
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/fixtures/emptyDashboardLayout.js
@@ -0,0 +1,36 @@
+import {
+  DASHBOARD_GRID_TYPE,
+  DASHBOARD_HEADER_TYPE,
+  DASHBOARD_ROOT_TYPE,
+} from '../util/componentTypes';
+
+import {
+  DASHBOARD_ROOT_ID,
+  DASHBOARD_HEADER_ID,
+  DASHBOARD_GRID_ID,
+} from '../util/constants';
+
+export default {
+  [DASHBOARD_ROOT_ID]: {
+    type: DASHBOARD_ROOT_TYPE,
+    id: DASHBOARD_ROOT_ID,
+    children: [
+      DASHBOARD_GRID_ID,
+    ],
+  },
+
+  [DASHBOARD_GRID_ID]: {
+    type: DASHBOARD_GRID_TYPE,
+    id: DASHBOARD_GRID_ID,
+    children: [],
+    meta: {},
+  },
+
+  [DASHBOARD_HEADER_ID]: {
+    type: DASHBOARD_HEADER_TYPE,
+    id: DASHBOARD_HEADER_ID,
+    meta: {
+      text: 'New dashboard',
+    },
+  },
+};
diff --git a/superset/assets/javascripts/dashboard/v2/fixtures/testLayout.js b/superset/assets/javascripts/dashboard/v2/fixtures/testLayout.js
deleted file mode 100644
index c3ce897..0000000
--- a/superset/assets/javascripts/dashboard/v2/fixtures/testLayout.js
+++ /dev/null
@@ -1,161 +0,0 @@
-import {
-  COLUMN_TYPE,
-  HEADER_TYPE,
-  ROW_TYPE,
-  SPACER_TYPE,
-  TAB_TYPE,
-  TABS_TYPE,
-  CHART_TYPE,
-  DIVIDER_TYPE,
-  GRID_ROOT_TYPE,
-} from '../util/componentTypes';
-
-import { DASHBOARD_ROOT_ID } from '../util/constants';
-
-export default {
-  [DASHBOARD_ROOT_ID]: {
-    type: GRID_ROOT_TYPE,
-    id: DASHBOARD_ROOT_ID,
-    children: [
-      // 'header0',
-      // 'row0',
-      // 'divider0',
-      // 'row1',
-      // 'tabs0',
-      // 'divider1',
-    ],
-  },
-  // row0: {
-  //   id: 'row0',
-  //   type: INVISIBLE_ROW_TYPE,
-  //   children: [
-  //     // 'charta',
-  //     // 'chartb',
-  //     // 'chartc',
-  //   ],
-  // },
-  // row1: {
-  //   id: 'row1',
-  //   type: ROW_TYPE,
-  //   children: [
-  //     'header1',
-  //   ],
-  // },
-  // row2: {
-  //   id: 'row2',
-  //   type: ROW_TYPE,
-  //   children: [
-  //     'chartd',
-  //     'spacer0',
-  //     'charte',
-  //   ],
-  // },
-  // tabs0: {
-  //   id: 'tabs0',
-  //   type: TABS_TYPE,
-  //   children: [
-  //     'tab0',
-  //     'tab1',
-  //     'tab3',
-  //   ],
-  //   meta: {
-  //   },
-  // },
-  // tab0: {
-  //   id: 'tab0',
-  //   type: TAB_TYPE,
-  //   children: [
-  //     // 'row2',
-  //   ],
-  //   meta: {
-  //     text: 'Tab A',
-  //   },
-  // },
-  // tab1: {
-  //   id: 'tab1',
-  //   type: TAB_TYPE,
-  //   children: [
-  //   ],
-  //   meta: {
-  //     text: 'Tab B',
-  //   },
-  // },
-  // tab3: {
-  //   id: 'tab3',
-  //   type: TAB_TYPE,
-  //   children: [
-  //   ],
-  //   meta: {
-  //     text: 'Tab C',
-  //   },
-  // },
-  // header0: {
-  //   id: 'header0',
-  //   type: HEADER_TYPE,
-  //   meta: {
-  //     text: 'Header 1',
-  //   },
-  // },
-  // header1: {
-  //   id: 'header1',
-  //   type: HEADER_TYPE,
-  //   meta: {
-  //     text: 'Header 2',
-  //   },
-  // },
-  // divider0: {
-  //   id: 'divider0',
-  //   type: DIVIDER_TYPE,
-  // },
-  // divider1: {
-  //   id: 'divider1',
-  //   type: DIVIDER_TYPE,
-  // },
-  // charta: {
-  //   id: 'charta',
-  //   type: CHART_TYPE,
-  //   meta: {
-  //     width: 3,
-  //     height: 10,
-  //   },
-  // },
-  // chartb: {
-  //   id: 'chartb',
-  //   type: CHART_TYPE,
-  //   meta: {
-  //     width: 3,
-  //     height: 10,
-  //   },
-  // },
-  // chartc: {
-  //   id: 'chartc',
-  //   type: CHART_TYPE,
-  //   meta: {
-  //     width: 3,
-  //     height: 10,
-  //   },
-  // },
-  // chartd: {
-  //   id: 'chartd',
-  //   type: CHART_TYPE,
-  //   meta: {
-  //     width: 3,
-  //     height: 10,
-  //   },
-  // },
-  // charte: {
-  //   id: 'charte',
-  //   type: CHART_TYPE,
-  //   meta: {
-  //     width: 3,
-  //     height: 10,
-  //   },
-  // },
-  // spacer0: {
-  //   id: 'spacer0',
-  //   type: SPACER_TYPE,
-  //   meta: {
-  //     width: 1,
-  //   },
-  // },
-};
diff --git a/superset/assets/javascripts/dashboard/v2/reducers/dashboard.js b/superset/assets/javascripts/dashboard/v2/reducers/dashboard.js
index 19fa9d7..9b03861 100644
--- a/superset/assets/javascripts/dashboard/v2/reducers/dashboard.js
+++ b/superset/assets/javascripts/dashboard/v2/reducers/dashboard.js
@@ -1,14 +1,25 @@
+import { DASHBOARD_ROOT_ID, DASHBOARD_GRID_ID, NEW_COMPONENTS_SOURCE_ID } from '../util/constants';
 import newComponentFactory from '../util/newComponentFactory';
 import newEntitiesFromDrop from '../util/newEntitiesFromDrop';
 import reorderItem from '../util/dnd-reorder';
 import shouldWrapChildInRow from '../util/shouldWrapChildInRow';
-import { ROW_TYPE } from '../util/componentTypes';
+import {
+  CHART_TYPE,
+  COLUMN_TYPE,
+  MARKDOWN_TYPE,
+  ROW_TYPE,
+  TAB_TYPE,
+  TABS_TYPE,
+
+} from '../util/componentTypes';
 
 import {
   UPDATE_COMPONENTS,
   DELETE_COMPONENT,
   CREATE_COMPONENT,
   MOVE_COMPONENT,
+  CREATE_TOP_LEVEL_TABS,
+  DELETE_TOP_LEVEL_TABS,
 } from '../actions';
 
 const actionHandlers = {
@@ -28,12 +39,11 @@ const actionHandlers = {
     const nextComponents = { ...state };
 
     // recursively find children to remove
-    let deleteCount = 0;
     function recursivelyDeleteChildren(componentId, componentParentId) {
       // delete child and it's children
       const component = nextComponents[componentId];
       delete nextComponents[componentId];
-      deleteCount += 1;
+
       const { children = [] } = component;
       children.forEach((childId) => { recursivelyDeleteChildren(childId, componentId); });
 
@@ -52,14 +62,30 @@ const actionHandlers = {
     }
 
     recursivelyDeleteChildren(id, parentId);
-    console.log('deleted', deleteCount, 'total components', nextComponents);
 
     return nextComponents;
   },
 
   [CREATE_COMPONENT](state, action) {
     const { payload: { dropResult } } = action;
+    const { destination, dragging } = dropResult;
     const newEntities = newEntitiesFromDrop({ dropResult, components: state });
+
+    // inherit the width of a column parent
+    if (destination.type === COLUMN_TYPE && [CHART_TYPE, MARKDOWN_TYPE].includes(dragging.type)) {
+      const newEntitiesArray = Object.values(newEntities);
+      const component = newEntitiesArray.find(entity => entity.type === dragging.type);
+      const parentColumn = newEntities[destination.id];
+
+      newEntities[component.id] = {
+        ...component,
+        meta: {
+          ...component.meta,
+          width: parentColumn.meta.width,
+        },
+      };
+    }
+
     return {
       ...state,
       ...newEntities,
@@ -68,9 +94,9 @@ const actionHandlers = {
 
   [MOVE_COMPONENT](state, action) {
     const { payload: { dropResult } } = action;
-    const { source, destination, draggableId } = dropResult;
+    const { source, destination, dragging } = dropResult;
 
-    if (!source || !destination || !draggableId) return state;
+    if (!source || !destination || !dragging) return state;
 
     const nextEntities = reorderItem({
       entitiesMap: state,
@@ -78,16 +104,14 @@ const actionHandlers = {
       destination,
     });
 
-    // wrap the dragged component in a row depening on destination type
-    const destinationType = (state[destination.droppableId] || {}).type;
-    const draggableType = (state[draggableId] || {}).type;
+    // wrap the dragged component in a row depending on destination type
     const wrapInRow = shouldWrapChildInRow({
-      parentType: destinationType,
-      childType: draggableType,
+      parentType: destination.type,
+      childType: dragging.type,
     });
 
     if (wrapInRow) {
-      const destinationEntity = nextEntities[destination.droppableId];
+      const destinationEntity = nextEntities[destination.id];
       const destinationChildren = destinationEntity.children;
       const newRow = newComponentFactory(ROW_TYPE);
       newRow.children = [destinationChildren[destination.index]];
@@ -95,11 +119,109 @@ const actionHandlers = {
       nextEntities[newRow.id] = newRow;
     }
 
+    // inherit the width of a column parent
+    if (destination.type === COLUMN_TYPE && [CHART_TYPE, MARKDOWN_TYPE].includes(dragging.type)) {
+      const component = nextEntities[dragging.id];
+      const parentColumn = nextEntities[destination.id];
+      nextEntities[dragging.id] = {
+        ...component,
+        meta: {
+          ...component.meta,
+          width: parentColumn.meta.width,
+        },
+      };
+    }
+
     return {
       ...state,
       ...nextEntities,
     };
   },
+
+  [CREATE_TOP_LEVEL_TABS](state, action) {
+    const { payload: { dropResult } } = action;
+    const { source, dragging } = dropResult;
+
+    // move children of current root to be children of the dragging tab
+    const rootComponent = state[DASHBOARD_ROOT_ID];
+    const topLevelId = rootComponent.children[0];
+    const topLevelComponent = state[topLevelId];
+
+    if (source.id !== NEW_COMPONENTS_SOURCE_ID) {
+      // component already exists
+      const draggingTabs = state[dragging.id];
+      const draggingTabId = draggingTabs.children[0];
+      const draggingTab = state[draggingTabId];
+
+      // move all children except the one that is dragging
+      const childrenToMove = [...topLevelComponent.children].filter(id => id !== dragging.id);
+
+      return {
+        ...state,
+        [DASHBOARD_ROOT_ID]: {
+          ...rootComponent,
+          children: [dragging.id],
+        },
+        [topLevelId]: {
+          ...topLevelComponent,
+          children: [],
+        },
+        [draggingTabId]: {
+          ...draggingTab,
+          children: [
+            ...draggingTab.children,
+            ...childrenToMove,
+          ],
+        },
+      };
+    }
+
+    // create new component
+    const newEntities = newEntitiesFromDrop({ dropResult, components: state });
+    const newEntitiesArray = Object.values(newEntities);
+    const tabComponent = newEntitiesArray.find(component => component.type === TAB_TYPE);
+    const tabsComponent = newEntitiesArray.find(component => component.type === TABS_TYPE);
+
+    tabComponent.children = [...topLevelComponent.children];
+    newEntities[topLevelId] = { ...topLevelComponent, children: [] };
+    newEntities[DASHBOARD_ROOT_ID] = { ...rootComponent, children: [tabsComponent.id] };
+
+    return {
+      ...state,
+      ...newEntities,
+    };
+  },
+
+  [DELETE_TOP_LEVEL_TABS](state) {
+    const rootComponent = state[DASHBOARD_ROOT_ID];
+    const topLevelId = rootComponent.children[0];
+    const topLevelTabs = state[topLevelId];
+
+    if (topLevelTabs.type !== TABS_TYPE) return state;
+
+    let childrenToMove = [];
+    const nextEntities = { ...state };
+
+    topLevelTabs.children.forEach((tabId) => {
+      const tabComponent = state[tabId];
+      childrenToMove = [...childrenToMove, ...tabComponent.children];
+      delete nextEntities[tabId];
+    });
+
+    delete nextEntities[topLevelId];
+
+    nextEntities[DASHBOARD_ROOT_ID] = {
+      ...rootComponent,
+      children: [DASHBOARD_GRID_ID],
+    };
+
+    nextEntities[DASHBOARD_GRID_ID] = {
+      ...(state[DASHBOARD_GRID_ID]),
+      children: childrenToMove,
+    };
+
+    return nextEntities;
+  },
 };
 
 export default function dashboardReducer(state = {}, action) {
diff --git a/superset/assets/javascripts/dashboard/v2/reducers/index.js b/superset/assets/javascripts/dashboard/v2/reducers/index.js
index 103fda0..9c0575e 100644
--- a/superset/assets/javascripts/dashboard/v2/reducers/index.js
+++ b/superset/assets/javascripts/dashboard/v2/reducers/index.js
@@ -1,6 +1,13 @@
 import { combineReducers } from 'redux';
+import undoable, { distinctState } from 'redux-undo';
+
 import dashboard from './dashboard';
 
+const undoableDashboard = undoable(dashboard, {
+  limit: 10,
+  filter: distinctState(),
+});
+
 export default combineReducers({
-  dashboard,
+  dashboard: undoableDashboard,
 });
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/builder.less b/superset/assets/javascripts/dashboard/v2/stylesheets/builder.less
new file mode 100644
index 0000000..5f1a5b0
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/builder.less
@@ -0,0 +1,64 @@
+.dashboard-v2 {
+  margin-top: -20px;
+  position: relative;
+  color: @almost-black;
+}
+
+.dashboard-header {
+  background: white;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 24px;
+  box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.1); /* @TODO color */
+}
+
+.dashboard-builder {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: nowrap;
+  height: auto;
+}
+
+/* only top-level tabs have popover, give it more padding to match header + tabs */
+.dashboard-v2 > .with-popover-menu > .popover-menu {
+  left: 24px;
+}
+
+/* drop shadow for top-level tabs only */
+.dashboard-v2 .dashboard-component-tabs {
+  box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.1);
+  padding-left: 8px; /* note this is added to tab-level padding, to match header */
+}
+
+.dashboard-builder .grid-container .dashboard-component-tabs {
+  box-shadow: none;
+  padding-left: 0;
+}
+
+.dashboard-builder > div:first-child {
+  width: 100%;
+  flex-grow: 1;
+  position: relative;
+}
+
+.dashboard-builder-sidepane {
+  background: white;
+  flex: 0 0 376px;
+  border: 1px solid @gray-light;
+  z-index: 1;
+}
+
+.dashboard-builder-sidepane-header {
+  font-size: 15px;
+  font-weight: 700;
+  border-bottom: 1px solid @gray-light;
+  padding: 14px;
+}
+
+/* @TODO remove upon new theme */
+.btn.btn-primary {
+  background: @almost-black !important;
+  color: white !important;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/buttons.less b/superset/assets/javascripts/dashboard/v2/stylesheets/buttons.less
index a8dd661..41ca478 100644
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/buttons.less
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/buttons.less
@@ -1,6 +1,6 @@
 .icon-button {
   color: @gray;
-  font-size: 1em;
+  font-size: 1.2em;
   display: flex;
   flex-direction: row;
   align-items: center;
@@ -15,3 +15,9 @@
   outline: none;
   text-decoration: none;
 }
+
+.icon-button-label {
+  color: @gray-dark;
+  padding-left: 8px;
+  font-size: 0.9em;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/components/DashboardBuilder.jsx b/superset/assets/javascripts/dashboard/v2/stylesheets/components/DashboardBuilder.jsx
new file mode 100644
index 0000000..e011ad4
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/components/DashboardBuilder.jsx
@@ -0,0 +1,127 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import HTML5Backend from 'react-dnd-html5-backend';
+import { DragDropContext } from 'react-dnd';
+
+import BuilderComponentPane from './BuilderComponentPane';
+import DashboardHeader from '../containers/DashboardHeader';
+import DashboardGrid from './DashboardGrid';
+import IconButton from './IconButton';
+import DragDroppable from './dnd/DragDroppable';
+import DashboardComponent from '../containers/DashboardComponent';
+import WithPopoverMenu from './menu/WithPopoverMenu';
+
+import {
+  DASHBOARD_GRID_ID,
+  DASHBOARD_ROOT_ID,
+  DASHBOARD_ROOT_DEPTH,
+} from '../util/constants';
+
+const propTypes = {
+  editMode: PropTypes.bool,
+
+  // redux
+  dashboard: PropTypes.object.isRequired,
+  deleteTopLevelTabs: PropTypes.func.isRequired,
+  updateComponents: PropTypes.func.isRequired,
+  handleComponentDrop: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+  editMode: true,
+};
+
+class DashboardBuilder extends React.Component {
+  static shouldFocusTabs(event, container) {
+    // don't focus the tabs when we click on a tab
+    return event.target.tagName === 'UL' || (
+      /icon-button/.test(event.target.className) && container.contains(event.target)
+    );
+  }
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      tabIndex: 0, // top-level tabs
+    };
+    this.handleChangeTab = this.handleChangeTab.bind(this);
+  }
+
+  handleChangeTab({ tabIndex }) {
+    this.setState(() => ({ tabIndex }));
+  }
+
+  render() {
+    const { tabIndex } = this.state;
+    const { handleComponentDrop, updateComponents, dashboard, deleteTopLevelTabs } = this.props;
+    const dashboardRoot = dashboard[DASHBOARD_ROOT_ID];
+    const rootChildId = dashboardRoot.children[0];
+    const topLevelTabs = rootChildId !== DASHBOARD_GRID_ID && dashboard[rootChildId];
+
+    const gridComponentId = topLevelTabs
+      ? topLevelTabs.children[Math.min(topLevelTabs.children.length - 1, tabIndex)]
+      : DASHBOARD_GRID_ID;
+
+    const gridComponent = dashboard[gridComponentId];
+
+    return (
+      <div className="dashboard-v2">
+        {topLevelTabs ? ( // you cannot drop on/displace tabs if they already exist
+          <DashboardHeader />
+        ) : (
+          <DragDroppable
+            component={dashboardRoot}
+            parentComponent={null}
+            depth={DASHBOARD_ROOT_DEPTH}
+            index={0}
+            orientation="column"
+            onDrop={topLevelTabs ? null : handleComponentDrop}
+          >
+            {({ dropIndicatorProps }) => (
+              <div>
+                <DashboardHeader />
+                {dropIndicatorProps && <div {...dropIndicatorProps} />}
+              </div>
+            )}
+          </DragDroppable>)}
+
+        {topLevelTabs &&
+          <WithPopoverMenu
+            shouldFocus={DashboardBuilder.shouldFocusTabs}
+            menuItems={[
+              <IconButton
+                className="fa fa-level-down"
+                label="Collapse tab content"
+                onClick={deleteTopLevelTabs}
+              />,
+            ]}
+          >
+            <DashboardComponent
+              id={topLevelTabs.id}
+              parentId={DASHBOARD_ROOT_ID}
+              depth={DASHBOARD_ROOT_DEPTH + 1}
+              index={0}
+              renderTabContent={false}
+              onChangeTab={this.handleChangeTab}
+            />
+          </WithPopoverMenu>}
+
+        <div className="dashboard-builder">
+          <DashboardGrid
+            gridComponent={gridComponent}
+            dashboard={dashboard}
+            handleComponentDrop={handleComponentDrop}
+            updateComponents={updateComponents}
+            depth={DASHBOARD_ROOT_DEPTH + 1}
+          />
+          <BuilderComponentPane />
+        </div>
+      </div>
+    );
+  }
+}
+
+DashboardBuilder.propTypes = propTypes;
+DashboardBuilder.defaultProps = defaultProps;
+
+export default DragDropContext(HTML5Backend)(DashboardBuilder);
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/components/column.less b/superset/assets/javascripts/dashboard/v2/stylesheets/components/column.less
index b96b14b..31ae21d 100644
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/components/column.less
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/components/column.less
@@ -7,7 +7,15 @@
   top: -20px;
 }
 
-.grid-column--empty:after {
+.grid-column.background--transparent {
+  background-color: transparent;
+}
+
+.grid-column.background--white {
+  background-color: white;
+}
+
+.grid-column--empty:before {
   content: "Empty column";
   position: absolute;
   top: 0;
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/components/new-component.less b/superset/assets/javascripts/dashboard/v2/stylesheets/components/new-component.less
index 31e84cb..e36fee2 100644
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/components/new-component.less
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/components/new-component.less
@@ -5,6 +5,7 @@
   align-items: center;
   padding: 16px;
   background: white;
+  cursor: move;
 }
 
 .new-component-placeholder {
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/components/row.less b/superset/assets/javascripts/dashboard/v2/stylesheets/components/row.less
index 8859926..2036815 100644
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/components/row.less
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/components/row.less
@@ -8,11 +8,11 @@
   background-color: transparent;
 }
 
-.grid-row--transparent {
+.grid-row.background--transparent {
   background-color: transparent;
 }
 
-.grid-row--white {
+.grid-row.background--white {
   background-color: white;
 }
 
@@ -25,7 +25,7 @@
   height: 80px;
 }
 
-.grid-row--empty:after {
+.grid-row--empty:before {
   position: absolute;
   top: 0;
   left: 0;
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/components/tabs.less b/superset/assets/javascripts/dashboard/v2/stylesheets/components/tabs.less
index 23e0469..f67c151 100644
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/components/tabs.less
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/components/tabs.less
@@ -2,6 +2,7 @@
   width: 100%;
   background-color: white;
 }
+
 .dashboard-component-tabs .dashboard-component-tabs-content {
   min-height: 48px;
   margin-top: 1px;
@@ -13,13 +14,15 @@
 
 /* by moving padding from <a/> to <li/> we can restrict the selected tab indicator to text width */
 .dashboard-component-tabs .nav-tabs > li {
-  padding: 0 16px;
+  margin: 0 16px;
 }
 
 .dashboard-component-tabs .nav-tabs > li > a {
-  color: #263238;
+  color: @almost-black;
   border: none;
   padding: 12px 0 14px 0;
+  font-size: 15px;
+  margin-right: 0;
 }
 
 .dashboard-component-tabs .nav-tabs > li.active > a {
@@ -38,7 +41,7 @@
 .dashboard-component-tabs .nav-tabs > li > a:hover {
   border: none;
   background: inherit;
-  color: #000000;
+  color: @almost-black;
 }
 
 .dashboard-component-tabs .nav-tabs > li > a:focus {
@@ -51,15 +54,27 @@
 }
 
 .dashboard-component-tabs .nav-tabs > li .drop-indicator {
-  height: 40px !important;
-  top: -10px !important;
-  opacity: 0.5;
+  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 .fa-plus-square {
-  background: linear-gradient(135deg, #E32464, #2C2261);
-  -webkit-background-clip: text;
-  -webkit-text-fill-color: transparent;
-  display: initial;
-  font-size: 16px;
+.dashboard-component-tabs li .fa-plus {
+  color: @gray-dark;
+  font-size: 14px;
+  margin-top: 3px;
 }
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/dnd.less b/superset/assets/javascripts/dashboard/v2/stylesheets/dnd.less
index fb010e0..45a9784 100644
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/dnd.less
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/dnd.less
@@ -3,32 +3,54 @@
 }
 
 .dragdroppable--dragging {
-  opacity: 0.25;
+  opacity: 0.15;
 }
 
 .dragdroppable-row {
   width: 100%;
 }
 
-.grid-container .dragdroppable-row:after,
-.grid-container .dragdroppable-column:after {
-  border: 1px dashed transparent;
-  content: "";
+/* drop indicators */
+.drop-indicator {
+  margin: auto;
+  background-color: @indicator-color;
   position: absolute;
+  z-index: 10;
+}
+
+.drop-indicator--top {
+  top: 0;
+  left: 0;
+  height: 4px;
   width: 100%;
-  height: 100%;
-  top: 1px;
+  min-width: 16px;
+}
+
+.drop-indicator--bottom {
+  top: 100%;
   left: 0;
-  z-index: 1;
-  pointer-events: none;
+  height: 4px;
+  width: 100%;
+  min-width: 16px;
 }
 
-  .grid-container .dragdroppable-row:hover:after,
-  .grid-container .dragdroppable-column:hover:after {
-    border: 1px dashed #aaa;
-  }
+.drop-indicator--right {
+  top: 0;
+  left: 100%;
+  height: 100%;
+  width: 4px;
+  min-height: 16px;
+}
+
+.drop-indicator--left {
+  top: 0;
+  left: 0;
+  height: 100%;
+  width: 4px;
+  min-height: 16px;
+}
 
-/* Drag handle */
+/* drag handles */
 .drag-handle {
   overflow: hidden;
   width: 16px;
@@ -39,10 +61,6 @@
   width: 8px;
 }
 
-.drag-handle--top {
-  /*margin: 10px auto;*/
-}
-
 .drag-handle-dot {
   float: left;
   height: 2px;
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/grid.less b/superset/assets/javascripts/dashboard/v2/stylesheets/grid.less
index c26ee0a..7c55dee 100644
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/grid.less
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/grid.less
@@ -1,9 +1,17 @@
 .grid-container {
-   flex-grow: 1;
-   min-width: 66%;
-   margin: 24px 32px;
-   height: 100%;
-   position: relative;
+  position: relative;
+  margin: 24px;
+}
+
+.grid-content {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
+.empty-grid-droptarget {
+  width: 100%;
+  height: 100%;
 }
 
 /* Editing guides */
@@ -19,7 +27,28 @@
 .grid-row-guide {
   position: absolute;
   left: 0;
-  height: 1;
-  background-color: var(--indicator-color);
+  bottom: 2;
+  height: 2;
+  background-color: @indicator-color;
   pointer-events: none;
+  z-index: 10;
+}
+
+
+.grid-container .grid-row:after,
+.grid-container .grid-column:after {
+  border: 1px dashed transparent;
+  content: "";
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 1px;
+  left: 0;
+  z-index: 1;
+  pointer-events: none;
+}
+
+.grid-container .grid-row:hover:after,
+.grid-container .grid-column:hover:after {
+  border: 1px solid @gray-light;
 }
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/hover-menu.less b/superset/assets/javascripts/dashboard/v2/stylesheets/hover-menu.less
index bc2935c..77edb06 100644
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/hover-menu.less
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/hover-menu.less
@@ -5,10 +5,10 @@
 }
 
 .hover-menu--left {
-  width: 20px;
+  width: 24px;
   height: 100%;
   top: 0;
-  left: -20px;
+  left: -24px;
   display: flex;
   flex-direction: column;
   justify-content: center;
@@ -16,7 +16,7 @@
 }
 
 .hover-menu--left > :nth-child(n):not(:only-child):not(:last-child) {
-  margin-bottom: 8px;
+  margin-bottom: 12px;
 }
 
 .dragdroppable-row .dragdroppable-row .hover-menu--left {
@@ -25,7 +25,7 @@
 
 .hover-menu--top {
   width: 100%;
-  height: 20px;
+  height: 24px;
   top: 0;
   left: 0;
   display: flex;
@@ -35,10 +35,10 @@
 }
 
 .hover-menu--top > :nth-child(n):not(:only-child):not(:last-child) {
-  margin-right: 8px;
+  margin-right: 12px;
 }
 
-.dragdroppable:hover .hover-menu,
-.dragdroppable .hover-menu:hover {
+div:hover > .hover-menu,
+.hover-menu:hover {
   opacity: 1;
 }
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/index.less b/superset/assets/javascripts/dashboard/v2/stylesheets/index.less
index 125c894..d2a41a8 100644
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/index.less
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/index.less
@@ -1,5 +1,6 @@
 @import './variables.less';
 
+@import './builder.less';
 @import './buttons.less';
 @import './dnd.less';
 @import './grid.less';
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/popover-menu.less b/superset/assets/javascripts/dashboard/v2/stylesheets/popover-menu.less
index f68cf13..a36ab1c 100644
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/popover-menu.less
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/popover-menu.less
@@ -37,6 +37,18 @@
   z-index: 10;
 }
 
+/* the focus menu doesn't account for parent padding */
+.dashboard-component-tabs li .with-popover-menu--focused:after {
+  top: -12px;
+  left: -2px;
+  width: ~"calc(100% + 4px)"; /* escape for .less */
+  height: ~"calc(100% + 28px)";
+}
+
+.dashboard-component-tabs li .popover-menu {
+  top: -56px;
+}
+
 .popover-menu .menu-item {
   display: flex;
   flex-direction: row;
@@ -87,12 +99,12 @@
   color: @almost-black;
 }
 
-/* row style menu */
-.row-style-option {
+/* background style menu */
+.background-style-option {
   display: inline-block;
 }
 
-.row-style-option:before {
+.background-style-option:before {
   content: "";
   width: 1em;
   height: 1em;
@@ -101,16 +113,16 @@
   vertical-align: middle;
 }
 
-.row-style-option.grid-row--white {
+.background-style-option.background--white {
   padding-left: 0;
   background: transparent;
 }
 
-.row-style-option.grid-row--white:before {
+.background-style-option.background--white:before {
   background: white;
   border: 1px solid @gray-light;
 }
 
-.row-style-option.grid-row--transparent:before {
+.background-style-option.background--transparent:before {
   background: @gray-light;
 }
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/resizable.less b/superset/assets/javascripts/dashboard/v2/stylesheets/resizable.less
index 0ccd2f8..3ce5cfd 100644
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/resizable.less
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/resizable.less
@@ -1,10 +1,10 @@
-.grid-resizable-container {
+.resizable-container {
   background-color: transparent;
   position: relative;
 }
 
 /* after ensures border visibility on top of any children */
-.grid-resizable-container--resizing:after {
+.resizable-container--resizing:after {
   content: "";
   position: absolute;
   top: 0;
@@ -18,8 +18,8 @@
   opacity: 0;
 }
 
-  .grid-resizable-container:hover .resize-handle,
-  .grid-resizable-container--resizing .resize-handle {
+  .resizable-container:hover .resize-handle,
+  .resizable-container--resizing .resize-handle {
     opacity: 1;
   }
 
@@ -59,14 +59,14 @@
   border-bottom: 1px solid @gray;
 }
 
-.grid-resizable-container--resizing > span .resize-handle {
+.resizable-container--resizing > span .resize-handle {
   border-color: @indicator-color;
 }
 
 /* re-resizable sets an empty div to 100% width and height, which doesn't
   play well with many 100% height containers we need
  */
-.grid-resizable-container ~ div {
+.resizable-container ~ div {
   width: auto !important;
   height: auto !important;
 }
diff --git a/superset/assets/javascripts/dashboard/v2/util/backgroundStyleOptions.js b/superset/assets/javascripts/dashboard/v2/util/backgroundStyleOptions.js
new file mode 100644
index 0000000..cda678f
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/backgroundStyleOptions.js
@@ -0,0 +1,7 @@
+import { t } from '../../../locales';
+import { BACKGROUND_TRANSPARENT, BACKGROUND_WHITE } from './constants';
+
+export default [
+  { value: BACKGROUND_TRANSPARENT, label: t('Transparent'), className: 'background--transparent' },
+  { value: BACKGROUND_WHITE, label: t('White'), className: 'background--white' },
+];
diff --git a/superset/assets/javascripts/dashboard/v2/util/componentTypes.js b/superset/assets/javascripts/dashboard/v2/util/componentTypes.js
index fd5d294..c667138 100644
--- a/superset/assets/javascripts/dashboard/v2/util/componentTypes.js
+++ b/superset/assets/javascripts/dashboard/v2/util/componentTypes.js
@@ -1,9 +1,12 @@
 export const CHART_TYPE = 'DASHBOARD_CHART_TYPE';
 export const COLUMN_TYPE = 'DASHBOARD_COLUMN_TYPE';
+export const DASHBOARD_GRID_TYPE = 'DASHBOARD_GRID_TYPE';
+export const DASHBOARD_HEADER_TYPE = 'DASHBOARD_DASHBOARD_HEADER_TYPE';
+export const DASHBOARD_ROOT_TYPE = 'DASHBOARD_ROOT_TYPE';
 export const DIVIDER_TYPE = 'DASHBOARD_DIVIDER_TYPE';
-export const GRID_ROOT_TYPE = 'DASHBOARD_GRID_ROOT_TYPE';
 export const HEADER_TYPE = 'DASHBOARD_HEADER_TYPE';
 export const MARKDOWN_TYPE = 'DASHBOARD_MARKDOWN_TYPE';
+export const NEW_COMPONENT_SOURCE_TYPE = 'NEW_COMPONENT_SOURCE_TYPE';
 export const ROW_TYPE = 'DASHBOARD_ROW_TYPE';
 export const SPACER_TYPE = 'DASHBOARD_SPACER_TYPE';
 export const TABS_TYPE = 'DASHBOARD_TABS_TYPE';
@@ -12,10 +15,13 @@ export const TAB_TYPE = 'DASHBOARD_TAB_TYPE';
 export default {
   CHART_TYPE,
   COLUMN_TYPE,
+  DASHBOARD_GRID_TYPE,
+  DASHBOARD_HEADER_TYPE,
+  DASHBOARD_ROOT_TYPE,
   DIVIDER_TYPE,
-  GRID_ROOT_TYPE,
   HEADER_TYPE,
   MARKDOWN_TYPE,
+  NEW_COMPONENT_SOURCE_TYPE,
   ROW_TYPE,
   SPACER_TYPE,
   TABS_TYPE,
diff --git a/superset/assets/javascripts/dashboard/v2/util/constants.js b/superset/assets/javascripts/dashboard/v2/util/constants.js
index 44a0f0e..e892456 100644
--- a/superset/assets/javascripts/dashboard/v2/util/constants.js
+++ b/superset/assets/javascripts/dashboard/v2/util/constants.js
@@ -1,5 +1,9 @@
 // Ids
+export const DASHBOARD_GRID_ID = 'DASHBOARD_GRID_ID';
+export const DASHBOARD_HEADER_ID = 'DASHBOARD_HEADER_ID';
 export const DASHBOARD_ROOT_ID = 'DASHBOARD_ROOT_ID';
+
+export const NEW_COMPONENTS_SOURCE_ID = 'NEW_COMPONENTS_SOURCE_ID';
 export const NEW_CHART_ID = 'NEW_CHART_ID';
 export const NEW_COLUMN_ID = 'NEW_COLUMN_ID';
 export const NEW_DIVIDER_ID = 'NEW_DIVIDER_ID';
@@ -11,6 +15,7 @@ export const NEW_TAB_ID = 'NEW_TAB_ID';
 export const NEW_TABS_ID = 'NEW_TABS_ID';
 
 // grid constants
+export const DASHBOARD_ROOT_DEPTH = 0;
 export const GRID_BASE_UNIT = 8;
 export const GRID_GUTTER_SIZE = 2 * GRID_BASE_UNIT;
 export const GRID_ROW_HEIGHT_UNIT = 2 * GRID_BASE_UNIT;
@@ -25,6 +30,6 @@ export const SMALL_HEADER = 'SMALL_HEADER';
 export const MEDIUM_HEADER = 'MEDIUM_HEADER';
 export const LARGE_HEADER = 'LARGE_HEADER';
 
-// Row types
-export const ROW_WHITE = 'ROW_WHITE';
-export const ROW_TRANSPARENT = 'ROW_TRANSPARENT';
+// Style types
+export const BACKGROUND_WHITE = 'BACKGROUND_WHITE';
+export const BACKGROUND_TRANSPARENT = 'BACKGROUND_TRANSPARENT';
diff --git a/superset/assets/javascripts/dashboard/v2/util/countChildRowsAndColumns.js b/superset/assets/javascripts/dashboard/v2/util/countChildRowsAndColumns.js
deleted file mode 100644
index dbc63cd..0000000
--- a/superset/assets/javascripts/dashboard/v2/util/countChildRowsAndColumns.js
+++ /dev/null
@@ -1,14 +0,0 @@
-export default function countChildRowsAndColumns({ component, components }) {
-  let columnCount = 0;
-  let rowCount = 0;
-
-  (component.children || []).forEach((childId) => {
-    const childComponent = components[childId];
-    columnCount += (childComponent.meta || {}).width || 0;
-    if ((childComponent.meta || {}).height) {
-      rowCount = Math.max(rowCount, childComponent.meta.height);
-    }
-  });
-
-  return { columnCount, rowCount };
-}
diff --git a/superset/assets/javascripts/dashboard/v2/util/dnd-reorder.js b/superset/assets/javascripts/dashboard/v2/util/dnd-reorder.js
index 5ebca8c..9a0dedf 100644
--- a/superset/assets/javascripts/dashboard/v2/util/dnd-reorder.js
+++ b/superset/assets/javascripts/dashboard/v2/util/dnd-reorder.js
@@ -11,12 +11,12 @@ export default function reorderItem({
   source,
   destination,
 }) {
-  const current = [...entitiesMap[source.droppableId].children];
-  const next = [...entitiesMap[destination.droppableId].children];
+  const current = [...entitiesMap[source.id].children];
+  const next = [...entitiesMap[destination.id].children];
   const target = current[source.index];
 
   // moving to same list
-  if (source.droppableId === destination.droppableId) {
+  if (source.id === destination.id) {
     const reordered = reorder(
       current,
       source.index,
@@ -25,8 +25,8 @@ export default function reorderItem({
 
     const result = {
       ...entitiesMap,
-      [source.droppableId]: {
-        ...entitiesMap[source.droppableId],
+      [source.id]: {
+        ...entitiesMap[source.id],
         children: reordered,
       },
     };
@@ -40,12 +40,12 @@ export default function reorderItem({
 
   const result = {
     ...entitiesMap,
-    [source.droppableId]: {
-      ...entitiesMap[source.droppableId],
+    [source.id]: {
+      ...entitiesMap[source.id],
       children: current,
     },
-    [destination.droppableId]: {
-      ...entitiesMap[destination.droppableId],
+    [destination.id]: {
+      ...entitiesMap[destination.id],
       children: next,
     },
   };
diff --git a/superset/assets/javascripts/dashboard/v2/util/findParentId.js b/superset/assets/javascripts/dashboard/v2/util/findParentId.js
new file mode 100644
index 0000000..0ca15a6
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/findParentId.js
@@ -0,0 +1,15 @@
+export default function findParentId({ childId, components = {} }) {
+  let parentId = null;
+
+  const ids = Object.keys(components);
+  for (let i = 0; i < ids.length - 1; i += 1) {
+    const id = ids[i];
+    const component = components[id] || {};
+    if (id !== childId && component.children && component.children.includes(childId)) {
+      parentId = id;
+      break;
+    }
+  }
+
+  return parentId;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/util/getChildWidth.js b/superset/assets/javascripts/dashboard/v2/util/getChildWidth.js
new file mode 100644
index 0000000..516624d
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/getChildWidth.js
@@ -0,0 +1,16 @@
+export default function getTotalChildWidth({ id, components, recurse = false }) {
+  const component = components[id];
+  if (!component) return 0;
+
+  let width = 0;
+
+  (component.children || []).forEach((childId) => {
+    const child = components[childId];
+    width += child.meta.width || 0;
+    if (recurse) {
+      width += getTotalChildWidth({ id: childId, components, recurse }) || 0;
+    }
+  });
+
+  return width;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/util/getDropPosition.js b/superset/assets/javascripts/dashboard/v2/util/getDropPosition.js
index e1dfbd3..6a3bd0e 100644
--- a/superset/assets/javascripts/dashboard/v2/util/getDropPosition.js
+++ b/superset/assets/javascripts/dashboard/v2/util/getDropPosition.js
@@ -1,14 +1,16 @@
 import isValidChild from './isValidChild';
+import { TAB_TYPE, TABS_TYPE } from './componentTypes';
 
 export const DROP_TOP = 'DROP_TOP';
 export const DROP_RIGHT = 'DROP_RIGHT';
 export const DROP_BOTTOM = 'DROP_BOTTOM';
 export const DROP_LEFT = 'DROP_LEFT';
 
-const SIBLING_DROP_THRESHOLD = 10;
+const SIBLING_DROP_THRESHOLD = 15;
 
 export default function getDropPosition(monitor, Component) {
   const {
+    depth: componentDepth,
     parentComponent,
     component,
     orientation,
@@ -18,17 +20,23 @@ export default function getDropPosition(monitor, Component) {
   const draggingItem = monitor.getItem();
 
   // if dropped self on self, do nothing
-  if (!draggingItem || draggingItem.draggableId === component.id || !isDraggingOverShallow) {
+  if (!draggingItem || draggingItem.id === component.id || !isDraggingOverShallow) {
     return null;
   }
 
   const validChild = isValidChild({
     parentType: component.type,
+    parentDepth: componentDepth,
     childType: draggingItem.type,
   });
 
+  const parentType = parentComponent && parentComponent.type;
+  const parentDepth = // see isValidChild.js for why tabs don't increment child depth
+    componentDepth + (parentType === TAB_TYPE || parentType === TABS_TYPE ? 0 : -1);
+
   const validSibling = isValidChild({
-    parentType: parentComponent && parentComponent.type,
+    parentType,
+    parentDepth,
     childType: draggingItem.type,
   });
 
@@ -36,7 +44,7 @@ export default function getDropPosition(monitor, Component) {
     return null;
   }
 
-  const hasChildren = component.children.length > 0;
+  const hasChildren = (component.children || []).length > 0;
   const childDropOrientation = orientation === 'row' ? 'vertical' : 'horizontal';
   const siblingDropOrientation = orientation === 'row' ? 'horizontal' : 'vertical';
 
diff --git a/superset/assets/javascripts/dashboard/v2/util/isValidChild.js b/superset/assets/javascripts/dashboard/v2/util/isValidChild.js
index c8921ec..9c6ae8e 100644
--- a/superset/assets/javascripts/dashboard/v2/util/isValidChild.js
+++ b/superset/assets/javascripts/dashboard/v2/util/isValidChild.js
@@ -1,9 +1,26 @@
+/* eslint max-len: 0 */
+/**
+  * When determining if a component is a valid child of another component we must consider both
+  *   - parent + child component types
+  *   - component depth, or depth of nesting of container components
+  *
+  * We consider types because some components aren't containers (e.g. a heading) and we consider
+  * depth to prevent infinite nesting of container components.
+  *
+  * The following example container nestings should be valid, which means that some containers
+  * don't increase the (depth) of their children, namely tabs and tab:
+  *   (a) root (0) > grid (1) >                         row (2) > column (3) > row (4) > non-container (5)
+  *   (b) root (0) > grid (1) >    tabs (2) > tab (2) > row (2) > column (3) > row (4) > non-container (5)
+  *   (c) root (0) > top-tab (1) >                      row (2) > column (3) > row (4) > non-container (5)
+  *   (d) root (0) > top-tab (1) > tabs (2) > tab (2) > row (2) > column (3) > row (4) > non-container (5)
+  */
 import {
   CHART_TYPE,
   COLUMN_TYPE,
+  DASHBOARD_GRID_TYPE,
+  DASHBOARD_ROOT_TYPE,
   DIVIDER_TYPE,
   HEADER_TYPE,
-  GRID_ROOT_TYPE,
   MARKDOWN_TYPE,
   ROW_TYPE,
   SPACER_TYPE,
@@ -11,59 +28,70 @@ import {
   TAB_TYPE,
 } from './componentTypes';
 
-const typeToValidChildType = {
-  // while some components are wrapped in Rows, most types are valid root children
-  [GRID_ROOT_TYPE]: {
-    [CHART_TYPE]: true,
-    [COLUMN_TYPE]: true,
-    [DIVIDER_TYPE]: true,
-    [HEADER_TYPE]: true,
-    [ROW_TYPE]: true,
-    [SPACER_TYPE]: true,
-    [TABS_TYPE]: true,
+import { DASHBOARD_ROOT_DEPTH as rootDepth } from './constants';
+
+const depthOne = rootDepth + 1;
+const depthTwo = rootDepth + 2;
+const depthThree = rootDepth + 3;
+const depthFour = rootDepth + 4;
+
+// when moving components around the depth of child is irrelevant, note these are parent depths
+const parentMaxDepthLookup = {
+  [DASHBOARD_ROOT_TYPE]: {
+    [TABS_TYPE]: rootDepth,
+    [DASHBOARD_GRID_TYPE]: rootDepth,
+  },
+
+  [DASHBOARD_GRID_TYPE]: {
+    [CHART_TYPE]: depthOne,
+    [COLUMN_TYPE]: depthOne,
+    [DIVIDER_TYPE]: depthOne,
+    [HEADER_TYPE]: depthOne,
+    [ROW_TYPE]: depthOne,
+    [SPACER_TYPE]: depthOne,
+    [TABS_TYPE]: depthOne,
   },
 
   [ROW_TYPE]: {
-    [CHART_TYPE]: true,
-    [MARKDOWN_TYPE]: true,
-    [COLUMN_TYPE]: true,
-    [SPACER_TYPE]: true,
+    [CHART_TYPE]: depthFour,
+    [MARKDOWN_TYPE]: depthFour,
+    [COLUMN_TYPE]: depthTwo,
+    [SPACER_TYPE]: depthFour,
   },
 
   [TABS_TYPE]: {
-    [TAB_TYPE]: true,
+    [TAB_TYPE]: depthTwo,
   },
 
   [TAB_TYPE]: {
-    [CHART_TYPE]: true,
-    [COLUMN_TYPE]: true,
-    [DIVIDER_TYPE]: true,
-    [HEADER_TYPE]: true,
-    [ROW_TYPE]: true,
-    [SPACER_TYPE]: true,
+    [CHART_TYPE]: depthTwo,
+    [COLUMN_TYPE]: depthTwo,
+    [DIVIDER_TYPE]: depthTwo,
+    [HEADER_TYPE]: depthTwo,
+    [ROW_TYPE]: depthTwo,
+    [SPACER_TYPE]: depthTwo,
+    [TABS_TYPE]: depthTwo,
   },
 
   [COLUMN_TYPE]: {
-    [CHART_TYPE]: true,
-    [MARKDOWN_TYPE]: true,
-    [HEADER_TYPE]: true,
-    [SPACER_TYPE]: true,
+    [CHART_TYPE]: depthThree,
+    [HEADER_TYPE]: depthThree,
+    [MARKDOWN_TYPE]: depthThree,
+    [ROW_TYPE]: depthThree,
+    [SPACER_TYPE]: depthThree,
   },
 
   // these have no valid children
   [CHART_TYPE]: {},
-  [MARKDOWN_TYPE]: {},
   [DIVIDER_TYPE]: {},
   [HEADER_TYPE]: {},
+  [MARKDOWN_TYPE]: {},
   [SPACER_TYPE]: {},
 };
 
-export default function isValidChild({ parentType, childType }) {
-  if (!parentType || !childType) return false;
-
-  const isValid = Boolean(
-    typeToValidChildType[parentType][childType],
-  );
+export default function isValidChild({ parentType, childType, parentDepth }) {
+  if (!parentType || !childType || typeof parentDepth !== 'number') return false;
+  const maxParentDepth = (parentMaxDepthLookup[parentType] || {})[childType];
 
-  return isValid;
+  return typeof maxParentDepth === 'number' && parentDepth <= maxParentDepth;
 }
diff --git a/superset/assets/javascripts/dashboard/v2/util/newComponentFactory.js b/superset/assets/javascripts/dashboard/v2/util/newComponentFactory.js
index c1ed03e..9bc01a7 100644
--- a/superset/assets/javascripts/dashboard/v2/util/newComponentFactory.js
+++ b/superset/assets/javascripts/dashboard/v2/util/newComponentFactory.js
@@ -12,16 +12,20 @@ import {
 
 import {
   MEDIUM_HEADER,
-  ROW_TRANSPARENT,
+  BACKGROUND_TRANSPARENT,
 } from './constants';
 
 const typeToDefaultMetaData = {
   [CHART_TYPE]: { width: 3, height: 15 },
-  [COLUMN_TYPE]: { width: 3 },
+  [COLUMN_TYPE]: { width: 3, background: BACKGROUND_TRANSPARENT },
   [DIVIDER_TYPE]: null,
-  [HEADER_TYPE]: { text: 'New header', headerSize: MEDIUM_HEADER, rowStyle: ROW_TRANSPARENT },
+  [HEADER_TYPE]: {
+    text: 'New header',
+    headerSize: MEDIUM_HEADER,
+    background: BACKGROUND_TRANSPARENT,
+  },
   [MARKDOWN_TYPE]: { width: 3, height: 15 },
-  [ROW_TYPE]: { rowStyle: ROW_TRANSPARENT },
+  [ROW_TYPE]: { background: BACKGROUND_TRANSPARENT },
   [SPACER_TYPE]: {},
   [TABS_TYPE]: null,
   [TAB_TYPE]: { text: 'New Tab' },
diff --git a/superset/assets/javascripts/dashboard/v2/util/newEntitiesFromDrop.js b/superset/assets/javascripts/dashboard/v2/util/newEntitiesFromDrop.js
index a0d92fa..9e49643 100644
--- a/superset/assets/javascripts/dashboard/v2/util/newEntitiesFromDrop.js
+++ b/superset/assets/javascripts/dashboard/v2/util/newEntitiesFromDrop.js
@@ -1,4 +1,3 @@
-import newComponentIdToType from './newComponentIdToType';
 import shouldWrapChildInRow from './shouldWrapChildInRow';
 import newComponentFactory from './newComponentFactory';
 
@@ -9,21 +8,10 @@ import {
 } from './componentTypes';
 
 export default function newEntitiesFromDrop({ dropResult, components }) {
-  const { draggableId, destination } = dropResult;
-
-  const dragType = newComponentIdToType[draggableId];
-  const dropEntity = components[destination.droppableId];
-
-  if (!dropEntity) {
-    console.warn('Drop target entity', destination.droppableId, 'not found');
-    return null;
-  }
-
-  if (!dragType) {
-    console.warn('Drag type not found for id', draggableId);
-    return null;
-  }
+  const { dragging, destination } = dropResult;
 
+  const dragType = dragging.type;
+  const dropEntity = components[destination.id];
   const dropType = dropEntity.type;
   let newDropChild = newComponentFactory(dragType);
   const wrapChildInRow = shouldWrapChildInRow({ parentType: dropType, childType: dragType });
@@ -46,7 +34,7 @@ export default function newEntitiesFromDrop({ dropResult, components }) {
   const nextDropChildren = [...dropEntity.children];
   nextDropChildren.splice(destination.index, 0, newDropChild.id);
 
-  newEntities[destination.droppableId] = {
+  newEntities[destination.id] = {
     ...dropEntity,
     children: nextDropChildren,
   };
diff --git a/superset/assets/javascripts/dashboard/v2/util/propShapes.jsx b/superset/assets/javascripts/dashboard/v2/util/propShapes.jsx
index be84965..d701cc2 100644
--- a/superset/assets/javascripts/dashboard/v2/util/propShapes.jsx
+++ b/superset/assets/javascripts/dashboard/v2/util/propShapes.jsx
@@ -1,6 +1,6 @@
 import PropTypes from 'prop-types';
 import componentTypes from './componentTypes';
-import rowStyleOptions from './rowStyleOptions';
+import backgroundStyleOptions from './backgroundStyleOptions';
 import headerStyleOptions from './headerStyleOptions';
 
 export const componentShape = PropTypes.shape({ // eslint-disable-line
@@ -19,6 +19,6 @@ export const componentShape = PropTypes.shape({ // eslint-disable-line
     headerSize: PropTypes.oneOf(headerStyleOptions.map(opt => opt.value)),
 
     // Row
-    rowStyle: PropTypes.oneOf(rowStyleOptions.map(opt => opt.value)),
+    background: PropTypes.oneOf(backgroundStyleOptions.map(opt => opt.value)),
   }),
 });
diff --git a/superset/assets/javascripts/dashboard/v2/util/resizableConfig.js b/superset/assets/javascripts/dashboard/v2/util/resizableConfig.js
index 40e9af6..f94914e 100644
--- a/superset/assets/javascripts/dashboard/v2/util/resizableConfig.js
+++ b/superset/assets/javascripts/dashboard/v2/util/resizableConfig.js
@@ -1,5 +1,4 @@
 // config for a ResizableContainer
-
 const adjustableWidthAndHeight = {
   top: false,
   right: false,
@@ -23,8 +22,14 @@ const adjustableHeight = {
   bottomRight: false,
 };
 
+const notAdjustable = {
+  ...adjustableWidthAndHeight,
+  bottomRight: false,
+};
+
 export default {
   widthAndHeight: adjustableWidthAndHeight,
   widthOnly: adjustableWidth,
   heightOnly: adjustableHeight,
+  notAdjustable,
 };
diff --git a/superset/assets/javascripts/dashboard/v2/util/rowStyleOptions.js b/superset/assets/javascripts/dashboard/v2/util/rowStyleOptions.js
deleted file mode 100644
index ad42492..0000000
--- a/superset/assets/javascripts/dashboard/v2/util/rowStyleOptions.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import { t } from '../../../locales';
-import { ROW_TRANSPARENT, ROW_WHITE } from './constants';
-
-export default [
-  { value: ROW_TRANSPARENT, label: t('Transparent'), className: 'grid-row--transparent' },
-  { value: ROW_WHITE, label: t('White'), className: 'grid-row--white' },
-];
diff --git a/superset/assets/javascripts/dashboard/v2/util/shouldWrapChildInRow.js b/superset/assets/javascripts/dashboard/v2/util/shouldWrapChildInRow.js
index 487e247..e7e648c 100644
--- a/superset/assets/javascripts/dashboard/v2/util/shouldWrapChildInRow.js
+++ b/superset/assets/javascripts/dashboard/v2/util/shouldWrapChildInRow.js
@@ -1,5 +1,5 @@
 import {
-  GRID_ROOT_TYPE,
+  DASHBOARD_GRID_TYPE,
   CHART_TYPE,
   COLUMN_TYPE,
   MARKDOWN_TYPE,
@@ -7,7 +7,7 @@ import {
 } from './componentTypes';
 
 const typeToWrapChildLookup = {
-  [GRID_ROOT_TYPE]: {
+  [DASHBOARD_GRID_TYPE]: {
     [CHART_TYPE]: true,
     [COLUMN_TYPE]: true,
     [MARKDOWN_TYPE]: true,
diff --git a/superset/assets/package.json b/superset/assets/package.json
index b3379f3..75f9504 100644
--- a/superset/assets/package.json
+++ b/superset/assets/package.json
@@ -113,6 +113,7 @@
     "redux": "^3.5.2",
     "redux-localstorage": "^0.4.1",
     "redux-thunk": "^2.1.0",
+    "redux-undo": "^0.6.1",
     "shortid": "^2.2.6",
     "sprintf-js": "^1.1.1",
     "srcdoc-polyfill": "^1.0.0",
diff --git a/superset/assets/src/components/EditableTitle.jsx b/superset/assets/src/components/EditableTitle.jsx
index 1497676..a7e3f17 100644
--- a/superset/assets/src/components/EditableTitle.jsx
+++ b/superset/assets/src/components/EditableTitle.jsx
@@ -28,7 +28,7 @@ class EditableTitle extends React.PureComponent {
     this.handleClick = this.handleClick.bind(this);
     this.handleBlur = this.handleBlur.bind(this);
     this.handleChange = this.handleChange.bind(this);
-    this.handleKeyDown = this.handleKeyDown.bind(this);
+    this.handleKeyUp = this.handleKeyUp.bind(this);
     this.handleKeyPress = this.handleKeyPress.bind(this);
   }
 
@@ -79,7 +79,7 @@ class EditableTitle extends React.PureComponent {
     }
   }
 
-  handleKeyDown(ev) {
+  handleKeyUp(ev) {
     // this entire method exists to support using EditableTitle as the title of a
     // react-bootstrap Tab, as a workaround for this line in react-bootstrap https://goo.gl/ZVLmv4
     //
@@ -121,7 +121,7 @@ class EditableTitle extends React.PureComponent {
         required
         type={this.state.isEditing ? 'text' : 'button'}
         value={this.state.title}
-        onKeyDown={this.handleKeyDown}
+        onKeyUp={this.handleKeyUp}
         onChange={this.handleChange}
         onBlur={this.handleBlur}
         onClick={this.handleClick}
diff --git a/superset/assets/src/dashboard/index.jsx b/superset/assets/src/dashboard/index.jsx
index c9236bd..bb21a43 100644
--- a/superset/assets/src/dashboard/index.jsx
+++ b/superset/assets/src/dashboard/index.jsx
@@ -10,7 +10,7 @@ import { initJQueryAjax } from '../modules/utils';
 import DashboardContainer from './components/DashboardContainer';
 // import rootReducer, { getInitialState } from './reducers';
 
-import testLayout from './v2/fixtures/testLayout';
+import emptyDashboardLayout from './v2/fixtures/emptyDashboardLayout';
 import rootReducer from './v2/reducers/';
 
 appSetup();
@@ -20,7 +20,11 @@ const appContainer = document.getElementById('app');
 // const bootstrapData = JSON.parse(appContainer.getAttribute('data-bootstrap'));
 // const initState = Object.assign({}, getInitialState(bootstrapData));
 const initState = {
-  dashboard: testLayout,
+  dashboard: {
+    past: [],
+    present: emptyDashboardLayout,
+    future: [],
+  },
 };
 
 const store = createStore(
diff --git a/superset/assets/stylesheets/dashboard-v2.css b/superset/assets/stylesheets/dashboard-v2.css
deleted file mode 100644
index 534a17e..0000000
--- a/superset/assets/stylesheets/dashboard-v2.css
+++ /dev/null
@@ -1,42 +0,0 @@
-.dashboard-v2 {
-  margin-top: -20px;
-  position: relative;
-  color: #263238;
-}
-
-.dashboard-header {
-  background: white;
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  padding: 0 24px;
-  box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.1);
-  margin-bottom: 2px;
-}
-
-.dashboard-builder {
-  display: flex;
-  flex-direction: row;
-  flex-wrap: nowrap;
-  height: auto;
-}
-
-.dashboard-builder-sidepane {
-  background: white;
-  flex: 0 0 376px;
-  box-shadow: 0 0 0 1px #ccc; /* @TODO color */
-}
-
-.dashboard-builder-sidepane-header {
-  font-size: 16;
-  font-weight: 700;
-  border-bottom: 1px solid #ccc;
-  padding: 16px;
-}
-
-/* @TODO remove upon new theme */
-.btn.btn-primary {
-  background: #263238 !important;
-  color: white !important;
-}
diff --git a/superset/assets/stylesheets/superset.less b/superset/assets/stylesheets/superset.less
index 2c405cd..e9f508b 100644
--- a/superset/assets/stylesheets/superset.less
+++ b/superset/assets/stylesheets/superset.less
@@ -232,7 +232,7 @@ table.table-no-hover tr:hover {
   background: transparent;
   border: none;
   box-shadow: none;
-  padding-left: 0;
+  padding: 0;
 }
 
 .editable-title input[type="button"] {
diff --git a/superset/templates/appbuilder/navbar.html b/superset/templates/appbuilder/navbar.html
index acb292c..77248f0 100644
--- a/superset/templates/appbuilder/navbar.html
+++ b/superset/templates/appbuilder/navbar.html
@@ -29,21 +29,6 @@
       </ul>
       <ul class="nav navbar-nav navbar-right">
         {% include 'appbuilder/navbar_right.html' %}
-        <li>
-          <a href="/static/assets/version_info.json" title="Version info">
-            <i class="fa fa-code-fork"></i> &nbsp;
-          </a>
-        </li>
-        <li>
-          <a href="https://github.com/apache/incubator-superset" title="Superset's Github" target="_blank">
-            <i class="fa fa-github"></i> &nbsp;
-          </a>
-        </li>
-        <li>
-          <a href="https://superset.incubator.apache.org" title="Documentation" target="_blank">
-            <i class="fa fa-book"></i> &nbsp;
-          </a>
-        </li>
       </ul>
     </div>
   </div>


Mime
View raw message