superset-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From ccwilli...@apache.org
Subject [incubator-superset] branch dashboard-builder updated: [dashboard builder] static layout + toasts (#4763)
Date Thu, 05 Apr 2018 00:34:14 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


The following commit(s) were added to refs/heads/dashboard-builder by this push:
     new a19a260  [dashboard builder] static layout + toasts (#4763)
a19a260 is described below

commit a19a26074d6074593c03b02fd1ee234e1d816937
Author: Chris Williams <williaster@users.noreply.github.com>
AuthorDate: Wed Apr 4 17:34:11 2018 -0700

    [dashboard builder] static layout + toasts (#4763)
    
    * [dashboard-builder] remove spacer component
    
    * [dashboard-builder] better transparent indicator, better grid gutter logic, no dragging top-level tabs, headers are multiples of grid unit, fix row height granularity, update redux state key dashboard => dashboardLayout
    
    * [dashboard-builder] don't blast column child dimensions on resize
    
    * [dashboard-builder] ResizableContainer min size can't be smaller than size, fix row style, role=none on WithPopoverMenu container
    
    * [edit mode] add edit mode to redux and propogate to all <DashboardComponent />s
    
    * [toasts] add Toast component, ToastPresenter container and component, and toast redux actions + reducers
    
    * [dashboard-builder] add info toast when dropResult overflows parent
---
 .../javascripts/components/EditableTitle.jsx       |  16 ++-
 superset/assets/javascripts/dashboard/index.jsx    |   5 +-
 .../v2/actions/{index.js => dashboardLayout.js}    |  35 +++---
 .../javascripts/dashboard/v2/actions/editMode.js   |   9 ++
 .../dashboard/v2/actions/messageToasts.js          |  49 ++++++++
 .../v2/components/BuilderComponentPane.jsx         |   2 -
 .../dashboard/v2/components/DashboardBuilder.jsx   |  28 +++--
 .../dashboard/v2/components/DashboardGrid.jsx      |  10 +-
 .../dashboard/v2/components/DashboardHeader.jsx    |  14 +--
 .../javascripts/dashboard/v2/components/Toast.jsx  |  87 ++++++++++++++
 .../dashboard/v2/components/ToastPresenter.jsx     |  39 +++++++
 .../dashboard/v2/components/dnd/DragDroppable.jsx  |   4 +
 .../dashboard/v2/components/dnd/handleDrop.js      |   2 +-
 .../v2/components/gridComponents/Chart.jsx         |  17 +--
 .../v2/components/gridComponents/Column.jsx        |  75 +++++-------
 .../v2/components/gridComponents/Divider.jsx       |  10 +-
 .../v2/components/gridComponents/Header.jsx        |  13 ++-
 .../dashboard/v2/components/gridComponents/Row.jsx |  69 +++++------
 .../v2/components/gridComponents/Spacer.jsx        | 106 -----------------
 .../dashboard/v2/components/gridComponents/Tab.jsx |  18 ++-
 .../v2/components/gridComponents/Tabs.jsx          |  39 ++++---
 .../v2/components/gridComponents/index.js          |   4 -
 .../gridComponents/new/DraggableNewComponent.jsx   |   1 +
 .../v2/components/gridComponents/new/NewSpacer.jsx |  24 ----
 .../v2/components/menu/WithPopoverMenu.jsx         |  40 ++++---
 .../v2/components/resizable/ResizableContainer.jsx |  32 ++++--
 .../dashboard/v2/containers/DashboardBuilder.jsx   |   7 +-
 .../dashboard/v2/containers/DashboardComponent.jsx |  17 +--
 .../dashboard/v2/containers/DashboardGrid.jsx      |   4 +-
 .../dashboard/v2/containers/DashboardHeader.jsx    |  16 ++-
 .../dashboard/v2/containers/ToastPresenter.jsx     |  10 ++
 .../reducers/{dashboard.js => dashboardLayout.js}  |   5 +-
 .../javascripts/dashboard/v2/reducers/editMode.js  |  11 ++
 .../javascripts/dashboard/v2/reducers/index.js     |  12 +-
 .../dashboard/v2/reducers/messageToasts.js         |  18 +++
 .../dashboard/v2/stylesheets/builder.less          |  14 ++-
 .../v2/stylesheets/components/DashboardBuilder.jsx | 127 ---------------------
 .../dashboard/v2/stylesheets/components/chart.less |   4 +-
 .../v2/stylesheets/components/column.less          |  30 +++--
 .../v2/stylesheets/components/divider.less         |   2 +-
 .../v2/stylesheets/components/header.less          |  28 ++++-
 .../dashboard/v2/stylesheets/components/index.less |   1 -
 .../v2/stylesheets/components/new-component.less   |  12 --
 .../dashboard/v2/stylesheets/components/row.less   |  23 ++--
 .../v2/stylesheets/components/spacer.less          |  13 ---
 .../javascripts/dashboard/v2/stylesheets/grid.less |  24 +---
 .../dashboard/v2/stylesheets/index.less            |   1 +
 .../dashboard/v2/stylesheets/popover-menu.less     |   4 +-
 .../dashboard/v2/stylesheets/resizable.less        |  16 +--
 .../dashboard/v2/stylesheets/toast.less            |  59 ++++++++++
 .../dashboard/v2/stylesheets/variables.less        |   8 ++
 .../dashboard/v2/util/componentIsResizable.js      |   2 -
 .../dashboard/v2/util/componentTypes.js            |   2 -
 .../javascripts/dashboard/v2/util/constants.js     |   8 +-
 .../dashboard/v2/util/dropOverflowsParent.js       |  24 ++++
 .../javascripts/dashboard/v2/util/getChildWidth.js |   5 +-
 .../dashboard/v2/util/getDropPosition.js           |   2 +
 .../javascripts/dashboard/v2/util/isValidChild.js  |  11 +-
 .../dashboard/v2/util/newComponentFactory.js       |   6 +-
 .../dashboard/v2/util/newComponentIdToType.js      |  35 ------
 .../javascripts/dashboard/v2/util/propShapes.jsx   |   7 ++
 superset/assets/stylesheets/superset.less          |  29 +++--
 62 files changed, 715 insertions(+), 630 deletions(-)

diff --git a/superset/assets/javascripts/components/EditableTitle.jsx b/superset/assets/javascripts/components/EditableTitle.jsx
index a7e3f17..45fea1d 100644
--- a/superset/assets/javascripts/components/EditableTitle.jsx
+++ b/superset/assets/javascripts/components/EditableTitle.jsx
@@ -116,7 +116,7 @@ class EditableTitle extends React.PureComponent {
   }
 
   render() {
-    let input = (
+    let content = (
       <input
         required
         type={this.state.isEditing ? 'text' : 'button'}
@@ -129,19 +129,25 @@ class EditableTitle extends React.PureComponent {
       />
     );
     if (this.props.showTooltip) {
-      input = (
+      content = (
         <TooltipWrapper
           label="title"
           tooltip={this.props.canEdit ? t('click to edit title') :
               this.props.noPermitTooltip || t('You don\'t have the rights to alter this title.')}
         >
-          {input}
+          {content}
         </TooltipWrapper>
       );
     }
     return (
-      <span className={cx('editable-title', this.props.canEdit && 'editable-title--editable')}>
-        {input}
+      <span
+        className={cx(
+          'editable-title',
+          this.props.canEdit && 'editable-title--editable',
+          this.state.isEditing && 'editable-title--editing',
+        )}
+      >
+        {content}
       </span>
     );
   }
diff --git a/superset/assets/javascripts/dashboard/index.jsx b/superset/assets/javascripts/dashboard/index.jsx
index bb21a43..1aadc58 100644
--- a/superset/assets/javascripts/dashboard/index.jsx
+++ b/superset/assets/javascripts/dashboard/index.jsx
@@ -19,12 +19,15 @@ initJQueryAjax();
 const appContainer = document.getElementById('app');
 // const bootstrapData = JSON.parse(appContainer.getAttribute('data-bootstrap'));
 // const initState = Object.assign({}, getInitialState(bootstrapData));
+
 const initState = {
-  dashboard: {
+  dashboardLayout: {
     past: [],
     present: emptyDashboardLayout,
     future: [],
   },
+  editMode: true,
+  messageToasts: [],
 };
 
 const store = createStore(
diff --git a/superset/assets/javascripts/dashboard/v2/actions/index.js b/superset/assets/javascripts/dashboard/v2/actions/dashboardLayout.js
similarity index 79%
rename from superset/assets/javascripts/dashboard/v2/actions/index.js
rename to superset/assets/javascripts/dashboard/v2/actions/dashboardLayout.js
index a6c7b77..b6d41c4 100644
--- a/superset/assets/javascripts/dashboard/v2/actions/index.js
+++ b/superset/assets/javascripts/dashboard/v2/actions/dashboardLayout.js
@@ -1,10 +1,8 @@
+import { addInfoToast } from './messageToasts';
+import { CHART_TYPE, MARKDOWN_TYPE, TABS_TYPE } from '../util/componentTypes';
 import { DASHBOARD_ROOT_ID, NEW_COMPONENTS_SOURCE_ID } from '../util/constants';
+import dropOverflowsParent from '../util/dropOverflowsParent';
 import findParentId from '../util/findParentId';
-import {
-  CHART_TYPE,
-  MARKDOWN_TYPE,
-  TABS_TYPE,
-} from '../util/componentTypes';
 
 // Component CRUD -------------------------------------------------------------
 export const UPDATE_COMPONENTS = 'UPDATE_COMPONENTS';
@@ -61,8 +59,8 @@ export function deleteTopLevelTabs() {
 export const RESIZE_COMPONENT = 'RESIZE_COMPONENT';
 export function resizeComponent({ id, width, height }) {
   return (dispatch, getState) => {
-    const { dashboard: undoableDashboard } = getState();
-    const { present: dashboard } = undoableDashboard;
+    const { dashboardLayout: undoableLayout } = getState();
+    const { present: dashboard } = undoableLayout;
     const component = dashboard[id];
 
     if (
@@ -88,8 +86,8 @@ export function resizeComponent({ id, width, height }) {
             ...child,
             meta: {
               ...child.meta,
-              width: width || component.meta.width,
-              height: height || component.meta.height,
+              width: width || child.meta.width,
+              height: height || child.meta.height,
             },
           };
         }
@@ -114,6 +112,15 @@ export function moveComponent(dropResult) {
 export const HANDLE_COMPONENT_DROP = 'HANDLE_COMPONENT_DROP';
 export function handleComponentDrop(dropResult) {
   return (dispatch, getState) => {
+    const overflowsParent = dropOverflowsParent(dropResult, getState().dashboardLayout.present);
+
+    if (overflowsParent) {
+      return dispatch(addInfoToast(
+        `Parent does not have enough space for this component.
+         Try decreasing its width or add it to a new row.`,
+      ));
+    }
+
     const { source, destination } = dropResult;
     const droppedOnRoot = destination && destination.id === DASHBOARD_ROOT_ID;
     const isNewComponent = source.id === NEW_COMPONENTS_SOURCE_ID;
@@ -133,14 +140,14 @@ export function handleComponentDrop(dropResult) {
       dispatch(moveComponent(dropResult));
     }
 
-    // if we moved a tab and the parent tabs no longer has children, delete it.
+    // 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];
+      const { dashboardLayout: undoableLayout } = getState();
+      const { present: layout } = undoableLayout;
+      const sourceComponent = layout[source.id];
 
       if (sourceComponent.type === TABS_TYPE && sourceComponent.children.length === 0) {
-        const parentId = findParentId({ childId: source.id, components: dashboard });
+        const parentId = findParentId({ childId: source.id, components: layout });
         dispatch(deleteComponent(source.id, parentId));
       }
     }
diff --git a/superset/assets/javascripts/dashboard/v2/actions/editMode.js b/superset/assets/javascripts/dashboard/v2/actions/editMode.js
new file mode 100644
index 0000000..0a849ea
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/actions/editMode.js
@@ -0,0 +1,9 @@
+export const SET_EDIT_MODE = 'SET_EDIT_MODE';
+export function setEditMode(editMode) {
+  return {
+    type: SET_EDIT_MODE,
+    payload: {
+      editMode,
+    },
+  };
+}
diff --git a/superset/assets/javascripts/dashboard/v2/actions/messageToasts.js b/superset/assets/javascripts/dashboard/v2/actions/messageToasts.js
new file mode 100644
index 0000000..af10ead
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/actions/messageToasts.js
@@ -0,0 +1,49 @@
+import { INFO_TOAST, SUCCESS_TOAST, WARNING_TOAST, DANGER_TOAST } from '../util/constants';
+
+function getToastUuid(type) {
+  return `${Math.random().toString(16).slice(2)}-${type}-${Math.random().toString(16).slice(2)}`;
+}
+
+export const ADD_TOAST = 'ADD_TOAST';
+export function addToast({ toastType, text }) {
+  debugger;
+  return {
+    type: ADD_TOAST,
+    payload: {
+      id: getToastUuid(toastType),
+      toastType,
+      text,
+    },
+  };
+}
+
+export const REMOVE_TOAST = 'REMOVE_TOAST';
+export function removeToast(id) {
+  return {
+    type: REMOVE_TOAST,
+    payload: {
+      id,
+    },
+  };
+}
+
+// Different types of toasts
+export const ADD_INFO_TOAST = 'ADD_INFO_TOAST';
+export function addInfoToast(text) {
+  return dispatch => dispatch(addToast({ text, toastType: INFO_TOAST }));
+}
+
+export const ADD_SUCCESS_TOAST = 'ADD_SUCCESS_TOAST';
+export function addSuccessToast(text) {
+  return dispatch => dispatch(addToast({ text, toastType: SUCCESS_TOAST }));
+}
+
+export const ADD_WARNING_TOAST = 'ADD_WARNING_TOAST';
+export function addWarningToast(text) {
+  return dispatch => dispatch(addToast({ text, toastType: WARNING_TOAST }));
+}
+
+export const ADD_DANGER_TOAST = 'ADD_DANGER_TOAST';
+export function addDangerToast(text) {
+  return dispatch => dispatch(addToast({ text, toastType: DANGER_TOAST }));
+}
diff --git a/superset/assets/javascripts/dashboard/v2/components/BuilderComponentPane.jsx b/superset/assets/javascripts/dashboard/v2/components/BuilderComponentPane.jsx
index 86f3788..efef5a5 100644
--- a/superset/assets/javascripts/dashboard/v2/components/BuilderComponentPane.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/BuilderComponentPane.jsx
@@ -6,7 +6,6 @@ import NewColumn from './gridComponents/new/NewColumn';
 import NewDivider from './gridComponents/new/NewDivider';
 import NewHeader from './gridComponents/new/NewHeader';
 import NewRow from './gridComponents/new/NewRow';
-import NewSpacer from './gridComponents/new/NewSpacer';
 import NewTabs from './gridComponents/new/NewTabs';
 
 const propTypes = {
@@ -24,7 +23,6 @@ class BuilderComponentPane extends React.PureComponent {
         <NewHeader />
 
         <NewDivider />
-        <NewSpacer />
 
         <NewTabs />
         <NewRow />
diff --git a/superset/assets/javascripts/dashboard/v2/components/DashboardBuilder.jsx b/superset/assets/javascripts/dashboard/v2/components/DashboardBuilder.jsx
index f371718..8e2d985 100644
--- a/superset/assets/javascripts/dashboard/v2/components/DashboardBuilder.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/DashboardBuilder.jsx
@@ -1,3 +1,4 @@
+import cx from 'classnames';
 import React from 'react';
 import PropTypes from 'prop-types';
 import HTML5Backend from 'react-dnd-html5-backend';
@@ -9,6 +10,7 @@ import DashboardGrid from '../containers/DashboardGrid';
 import IconButton from './IconButton';
 import DragDroppable from './dnd/DragDroppable';
 import DashboardComponent from '../containers/DashboardComponent';
+import ToastPresenter from '../containers/ToastPresenter';
 import WithPopoverMenu from './menu/WithPopoverMenu';
 
 import {
@@ -18,11 +20,10 @@ import {
 } from '../util/constants';
 
 const propTypes = {
-  editMode: PropTypes.bool,
-
   // redux
-  dashboard: PropTypes.object.isRequired,
+  dashboardLayout: PropTypes.object.isRequired,
   deleteTopLevelTabs: PropTypes.func.isRequired,
+  editMode: PropTypes.bool.isRequired,
   handleComponentDrop: PropTypes.func.isRequired,
 };
 
@@ -52,20 +53,20 @@ class DashboardBuilder extends React.Component {
 
   render() {
     const { tabIndex } = this.state;
-    const { handleComponentDrop, dashboard, deleteTopLevelTabs } = this.props;
-    const dashboardRoot = dashboard[DASHBOARD_ROOT_ID];
+    const { handleComponentDrop, dashboardLayout, deleteTopLevelTabs, editMode } = this.props;
+    const dashboardRoot = dashboardLayout[DASHBOARD_ROOT_ID];
     const rootChildId = dashboardRoot.children[0];
-    const topLevelTabs = rootChildId !== DASHBOARD_GRID_ID && dashboard[rootChildId];
+    const topLevelTabs = rootChildId !== DASHBOARD_GRID_ID && dashboardLayout[rootChildId];
 
     const gridComponentId = topLevelTabs
       ? topLevelTabs.children[Math.min(topLevelTabs.children.length - 1, tabIndex)]
       : DASHBOARD_GRID_ID;
 
-    const gridComponent = dashboard[gridComponentId];
+    const gridComponent = dashboardLayout[gridComponentId];
 
     return (
-      <div className="dashboard-v2">
-        {topLevelTabs ? ( // you cannot drop on/displace tabs if they already exist
+      <div className={cx('dashboard-v2', editMode && 'dashboard-v2--editing')}>
+        {topLevelTabs || !editMode ? ( // you cannot drop on/displace tabs if they already exist
           <DashboardHeader />
         ) : (
           <DragDroppable
@@ -74,7 +75,8 @@ class DashboardBuilder extends React.Component {
             depth={DASHBOARD_ROOT_DEPTH}
             index={0}
             orientation="column"
-            onDrop={topLevelTabs ? null : handleComponentDrop}
+            onDrop={handleComponentDrop}
+            editMode
           >
             {({ dropIndicatorProps }) => (
               <div>
@@ -94,6 +96,7 @@ class DashboardBuilder extends React.Component {
                 onClick={deleteTopLevelTabs}
               />,
             ]}
+            editMode={editMode}
           >
             <DashboardComponent
               id={topLevelTabs.id}
@@ -105,13 +108,14 @@ class DashboardBuilder extends React.Component {
             />
           </WithPopoverMenu>}
 
-        <div className="dashboard-builder">
+        <div className="dashboard-content">
           <DashboardGrid
             gridComponent={gridComponent}
             depth={DASHBOARD_ROOT_DEPTH + 1}
           />
-          <BuilderComponentPane />
+          {editMode && <BuilderComponentPane />}
         </div>
+        <ToastPresenter />
       </div>
     );
   }
diff --git a/superset/assets/javascripts/dashboard/v2/components/DashboardGrid.jsx b/superset/assets/javascripts/dashboard/v2/components/DashboardGrid.jsx
index cfe99c7..9f4cb93 100644
--- a/superset/assets/javascripts/dashboard/v2/components/DashboardGrid.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/DashboardGrid.jsx
@@ -13,6 +13,7 @@ import {
 
 const propTypes = {
   depth: PropTypes.number.isRequired,
+  editMode: PropTypes.bool.isRequired,
   gridComponent: componentShape.isRequired,
   handleComponentDrop: PropTypes.func.isRequired,
   resizeComponent: PropTypes.func.isRequired,
@@ -70,7 +71,7 @@ class DashboardGrid extends React.PureComponent {
   }
 
   render() {
-    const { gridComponent, handleComponentDrop, depth } = this.props;
+    const { gridComponent, handleComponentDrop, depth, editMode } = this.props;
     const { isResizing, rowGuideTop } = this.state;
 
     return (
@@ -99,18 +100,19 @@ class DashboardGrid extends React.PureComponent {
                 ))}
 
                 {/* render an empty drop target */}
-                {gridComponent.children.length === 0 &&
+                {editMode &&
                   <DragDroppable
                     component={gridComponent}
                     depth={depth}
                     parentComponent={null}
-                    index={0}
+                    index={gridComponent.children.length}
                     orientation="column"
                     onDrop={handleComponentDrop}
                     className="empty-grid-droptarget"
+                    editMode
                   >
                     {({ dropIndicatorProps }) => dropIndicatorProps &&
-                      <div {...dropIndicatorProps} />}
+                      <div className="drop-indicator drop-indicator--top" />}
                   </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 e0d14c4..ca204e5 100644
--- a/superset/assets/javascripts/dashboard/v2/components/DashboardHeader.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/DashboardHeader.jsx
@@ -7,8 +7,7 @@ import { componentShape } from '../util/propShapes';
 import EditableTitle from '../../../components/EditableTitle';
 
 const propTypes = {
-  // editMode: PropTypes.bool.isRequired,
-  // setEditMode: PropTypes.func.isRequired,
+  editMode: PropTypes.bool.isRequired,
   component: componentShape.isRequired,
 
   // redux
@@ -17,6 +16,7 @@ const propTypes = {
   onRedo: PropTypes.func.isRequired,
   canUndo: PropTypes.bool.isRequired,
   canRedo: PropTypes.bool.isRequired,
+  setEditMode: PropTypes.func.isRequired,
 };
 
 class DashboardHeader extends React.Component {
@@ -27,8 +27,7 @@ class DashboardHeader extends React.Component {
   }
 
   toggleEditMode() {
-    console.log('@TODO toggleEditMode');
-    // this.props.setEditMode(!this.props.editMode);
+    this.props.setEditMode(!this.props.editMode);
   }
 
   handleChangeText(nextText) {
@@ -47,19 +46,18 @@ class DashboardHeader extends React.Component {
   }
 
   render() {
-    const { component, onUndo, onRedo, canUndo, canRedo } = this.props;
-    const editMode = true;
+    const { component, onUndo, onRedo, canUndo, canRedo, editMode } = this.props;
 
     return (
       <div className="dashboard-header">
-        <h1>
+        <div className="dashboard-component-header header-large">
           <EditableTitle
             title={component.meta.text}
             onSaveTitle={this.handleChangeText}
             showTooltip={false}
             canEdit={editMode}
           />
-        </h1>
+        </div>
         <ButtonToolbar>
           <ButtonGroup>
             <Button
diff --git a/superset/assets/javascripts/dashboard/v2/components/Toast.jsx b/superset/assets/javascripts/dashboard/v2/components/Toast.jsx
new file mode 100644
index 0000000..537388d
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/Toast.jsx
@@ -0,0 +1,87 @@
+import { Alert } from 'react-bootstrap';
+import cx from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import { toastShape } from '../util/propShapes';
+import { INFO_TOAST, SUCCESS_TOAST, WARNING_TOAST, DANGER_TOAST } from '../util/constants';
+
+const propTypes = {
+  toast: toastShape.isRequired,
+  onCloseToast: PropTypes.func.isRequired,
+  delay: PropTypes.number,
+  duration: PropTypes.number, // if duration is >0, the toast will close on its own
+};
+
+const defaultProps = {
+  delay: 0,
+  duration: 0,
+};
+
+class Toast extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      visible: false,
+    };
+
+    this.showToast = this.showToast.bind(this);
+    this.handleClosePress = this.handleClosePress.bind(this);
+  }
+
+  componentDidMount() {
+    const { delay, duration } = this.props;
+
+    setTimeout(this.showToast, delay);
+
+    if (duration > 0) {
+      this.hideTimer = setTimeout(this.handleClosePress, delay + duration);
+    }
+  }
+
+  componentWillUnmount() {
+    clearTimeout(this.hideTimer);
+  }
+
+  showToast() {
+    this.setState({ visible: true });
+  }
+
+  handleClosePress() {
+    clearTimeout(this.hideTimer);
+
+    this.setState({ visible: false }, () => {
+      // Wait for the transition
+      setTimeout(() => {
+        this.props.onCloseToast(this.props.toast.id);
+      }, 150);
+    });
+  }
+
+  render() {
+    const { visible } = this.state;
+    const { toast: { toastType, text } } = this.props;
+
+    return (
+      <Alert
+        onDismiss={this.handleClosePress}
+        bsClass={cx(
+          'alert',
+          'toast',
+          visible && 'toast--visible',
+          toastType === INFO_TOAST && 'toast--info',
+          toastType === SUCCESS_TOAST && 'toast--success',
+          toastType === WARNING_TOAST && 'toast--warning',
+          toastType === DANGER_TOAST && 'toast--danger',
+        )}
+      >
+        {text}
+      </Alert>
+    );
+  }
+}
+
+Toast.propTypes = propTypes;
+Toast.defaultProps = defaultProps;
+
+export default Toast;
diff --git a/superset/assets/javascripts/dashboard/v2/components/ToastPresenter.jsx b/superset/assets/javascripts/dashboard/v2/components/ToastPresenter.jsx
new file mode 100644
index 0000000..95a0251
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/ToastPresenter.jsx
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import Toast from './Toast';
+import { toastShape } from '../util/propShapes';
+
+const propTypes = {
+  toasts: PropTypes.arrayOf(toastShape),
+  removeToast: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+  toasts: [],
+};
+
+// eslint-disable-next-line react/prefer-stateless-function
+class ToastPresenter extends React.Component {
+  render() {
+    const { toasts, removeToast } = this.props;
+
+    return (
+      toasts.length > 0 &&
+        <div className="toast-presenter">
+          {toasts.map(toast => (
+            <Toast
+              key={toast.id}
+              toast={toast}
+              onCloseToast={removeToast}
+            />
+          ))}
+        </div>
+    );
+  }
+}
+
+ToastPresenter.propTypes = propTypes;
+ToastPresenter.defaultProps = defaultProps;
+
+export default ToastPresenter;
diff --git a/superset/assets/javascripts/dashboard/v2/components/dnd/DragDroppable.jsx b/superset/assets/javascripts/dashboard/v2/components/dnd/DragDroppable.jsx
index 89664e5..775e092 100644
--- a/superset/assets/javascripts/dashboard/v2/components/dnd/DragDroppable.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/dnd/DragDroppable.jsx
@@ -18,6 +18,7 @@ const propTypes = {
   index: PropTypes.number.isRequired,
   style: PropTypes.object,
   onDrop: PropTypes.func,
+  editMode: PropTypes.bool.isRequired,
 
   // from react-dnd
   isDragging: PropTypes.bool.isRequired,
@@ -70,8 +71,11 @@ class DragDroppable extends React.Component {
       isDragging,
       isDraggingOver,
       style,
+      editMode,
     } = this.props;
 
+    if (!editMode) return children({});
+
     const { dropIndicator } = this.state;
 
     return (
diff --git a/superset/assets/javascripts/dashboard/v2/components/dnd/handleDrop.js b/superset/assets/javascripts/dashboard/v2/components/dnd/handleDrop.js
index 2207ca6..f27b604 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 || !Component.props.onDrop) return undefined;
+  if (!Component.mounted) return undefined;
 
   Component.setState(() => ({ dropIndicator: null }));
   const dropPosition = getDropPosition(monitor, Component);
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Chart.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Chart.jsx
index 7ca506d..668d268 100644
--- a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Chart.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Chart.jsx
@@ -9,10 +9,7 @@ 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,
-} from '../../util/constants';
+import { GRID_MIN_COLUMN_COUNT, GRID_MIN_ROW_UNITS } from '../../util/constants';
 
 const propTypes = {
   id: PropTypes.string.isRequired,
@@ -21,6 +18,7 @@ const propTypes = {
   parentComponent: componentShape.isRequired,
   index: PropTypes.number.isRequired,
   depth: PropTypes.number.isRequired,
+  editMode: PropTypes.bool.isRequired,
 
   // grid related
   availableColumnCount: PropTypes.number.isRequired,
@@ -71,6 +69,7 @@ class Chart extends React.Component {
       onResize,
       onResizeStop,
       handleComponentDrop,
+      editMode,
     } = this.props;
 
     return (
@@ -82,6 +81,7 @@ class Chart extends React.Component {
         depth={depth}
         onDrop={handleComponentDrop}
         disableDragDrop={isFocused}
+        editMode={editMode}
       >
         {({ dropIndicatorProps, dragSourceRef }) => (
           <ResizableContainer
@@ -97,16 +97,19 @@ class Chart extends React.Component {
             onResizeStart={onResizeStart}
             onResize={onResize}
             onResizeStop={onResizeStop}
+            editMode={editMode}
           >
-            <HoverMenu innerRef={dragSourceRef} position="top">
-              <DragHandle position="top" />
-            </HoverMenu>
+            {editMode &&
+              <HoverMenu innerRef={dragSourceRef} position="top">
+                <DragHandle position="top" />
+              </HoverMenu>}
 
             <WithPopoverMenu
               onChangeFocus={this.handleChangeFocus}
               menuItems={[
                 <DeleteComponentButton onDelete={this.handleDeleteComponent} />,
               ]}
+              editMode={editMode}
             >
               <div className="dashboard-component dashboard-component-chart">
                 <div className="fa fa-area-chart" />
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Column.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Column.jsx
index d51870d..fe5a721 100644
--- a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Column.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Column.jsx
@@ -15,12 +15,7 @@ import WithPopoverMenu from '../menu/WithPopoverMenu';
 import backgroundStyleOptions from '../../util/backgroundStyleOptions';
 import { componentShape } from '../../util/propShapes';
 
-import {
-  BACKGROUND_TRANSPARENT,
-  GRID_GUTTER_SIZE,
-} from '../../util/constants';
-
-const GUTTER = 'GUTTER';
+import { BACKGROUND_TRANSPARENT } from '../../util/constants';
 
 const propTypes = {
   id: PropTypes.string.isRequired,
@@ -29,6 +24,7 @@ const propTypes = {
   parentComponent: componentShape.isRequired,
   index: PropTypes.number.isRequired,
   depth: PropTypes.number.isRequired,
+  editMode: PropTypes.bool.isRequired,
 
   // grid related
   availableColumnCount: PropTypes.number.isRequired,
@@ -95,23 +91,14 @@ class Column extends React.PureComponent {
       onResize,
       onResizeStop,
       handleComponentDrop,
+      editMode,
     } = this.props;
 
-    const columnItems = [];
-
-    (columnComponent.children || []).forEach((id, childIndex) => {
-      columnItems.push(id);
-      if (childIndex < columnComponent.children.length - 1) {
-        columnItems.push(GUTTER);
-      }
-    });
-
+    const columnItems = columnComponent.children || [];
     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}
@@ -120,6 +107,7 @@ class Column extends React.PureComponent {
         index={index}
         depth={depth}
         onDrop={handleComponentDrop}
+        editMode={editMode}
       >
         {({ dropIndicatorProps, dragSourceRef }) => (
           <ResizableContainer
@@ -133,6 +121,7 @@ class Column extends React.PureComponent {
             onResizeStart={onResizeStart}
             onResize={onResize}
             onResizeStop={onResizeStop}
+            editMode={editMode}
           >
             <WithPopoverMenu
               isFocused={this.state.isFocused}
@@ -145,6 +134,7 @@ class Column extends React.PureComponent {
                   onChange={this.handleChangeBackground}
                 />,
               ]}
+              editMode={editMode}
             >
               <div
                 className={cx(
@@ -153,35 +143,30 @@ class Column extends React.PureComponent {
                   backgroundStyle.className,
                 )}
               >
-                <HoverMenu innerRef={dragSourceRef} position="top">
-                  <DragHandle position="top" />
-                  <DeleteComponentButton onDelete={this.handleDeleteComponent} />
-                  <IconButton
-                    onClick={this.handleChangeFocus}
-                    className="fa fa-cog"
-                  />
-                </HoverMenu>
-
-                {columnItems.map((componentId, itemIndex) => {
-                  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}
+                {editMode &&
+                  <HoverMenu innerRef={dragSourceRef} position="top">
+                    <DragHandle position="top" />
+                    <DeleteComponentButton onDelete={this.handleDeleteComponent} />
+                    <IconButton
+                      onClick={this.handleChangeFocus}
+                      className="fa fa-cog"
                     />
-                  );
-                })}
+                  </HoverMenu>}
+
+                {columnItems.map((componentId, itemIndex) => (
+                  <DashboardComponent
+                    key={componentId}
+                    id={componentId}
+                    parentId={columnComponent.id}
+                    depth={depth + 1}
+                    index={itemIndex}
+                    availableColumnCount={columnComponent.meta.width}
+                    columnWidth={columnWidth}
+                    onResizeStart={onResizeStart}
+                    onResize={onResize}
+                    onResizeStop={onResizeStop}
+                  />
+                ))}
 
                 {dropIndicatorProps && <div {...dropIndicatorProps} />}
               </div>
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Divider.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Divider.jsx
index ff29c3f..b3010e9 100644
--- a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Divider.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Divider.jsx
@@ -13,6 +13,7 @@ const propTypes = {
   depth: PropTypes.number.isRequired,
   parentComponent: componentShape.isRequired,
   index: PropTypes.number.isRequired,
+  editMode: PropTypes.bool.isRequired,
   handleComponentDrop: PropTypes.func.isRequired,
   deleteComponent: PropTypes.func.isRequired,
 };
@@ -35,6 +36,7 @@ class Divider extends React.PureComponent {
       parentComponent,
       index,
       handleComponentDrop,
+      editMode,
     } = this.props;
 
     return (
@@ -45,12 +47,14 @@ class Divider extends React.PureComponent {
         index={index}
         depth={depth}
         onDrop={handleComponentDrop}
+        editMode={editMode}
       >
         {({ dropIndicatorProps, dragSourceRef }) => (
           <div ref={dragSourceRef}>
-            <HoverMenu position="left">
-              <DeleteComponentButton onDelete={this.handleDeleteComponent} />
-            </HoverMenu>
+            {editMode &&
+              <HoverMenu position="left">
+                <DeleteComponentButton onDelete={this.handleDeleteComponent} />
+              </HoverMenu>}
 
             <div className="dashboard-component dashboard-component-divider" />
 
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Header.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Header.jsx
index d8744d6..97945a9 100644
--- a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Header.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Header.jsx
@@ -22,6 +22,7 @@ const propTypes = {
   depth: PropTypes.number.isRequired,
   parentComponent: componentShape.isRequired,
   index: PropTypes.number.isRequired,
+  editMode: PropTypes.bool.isRequired,
 
   // redux
   handleComponentDrop: PropTypes.func.isRequired,
@@ -79,6 +80,7 @@ class Header extends React.PureComponent {
       parentComponent,
       index,
       handleComponentDrop,
+      editMode,
     } = this.props;
 
     const headerStyle = headerStyleOptions.find(
@@ -98,12 +100,14 @@ class Header extends React.PureComponent {
         depth={depth}
         onDrop={handleComponentDrop}
         disableDragDrop={isFocused}
+        editMode={editMode}
       >
         {({ dropIndicatorProps, dragSourceRef }) => (
           <div ref={dragSourceRef}>
-            <HoverMenu position="left">
-              <DragHandle position="left" />
-            </HoverMenu>
+            {editMode &&
+              <HoverMenu position="left">
+                <DragHandle position="left" />
+              </HoverMenu>}
 
             <WithPopoverMenu
               onChangeFocus={this.handleChangeFocus}
@@ -122,6 +126,7 @@ class Header extends React.PureComponent {
                 />,
                 <DeleteComponentButton onDelete={this.handleDeleteComponent} />,
               ]}
+              editMode={editMode}
             >
               <div
                 className={cx(
@@ -133,7 +138,7 @@ class Header extends React.PureComponent {
               >
                 <EditableTitle
                   title={component.meta.text}
-                  canEdit={isFocused}
+                  canEdit={editMode}
                   onSaveTitle={this.handleChangeText}
                   showTooltip={false}
                 />
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Row.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Row.jsx
index a60524f..9866bc8 100644
--- a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Row.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Row.jsx
@@ -13,9 +13,7 @@ import WithPopoverMenu from '../menu/WithPopoverMenu';
 
 import { componentShape } from '../../util/propShapes';
 import backgroundStyleOptions from '../../util/backgroundStyleOptions';
-import { GRID_GUTTER_SIZE, BACKGROUND_TRANSPARENT } from '../../util/constants';
-
-const GUTTER = 'GUTTER';
+import { BACKGROUND_TRANSPARENT } from '../../util/constants';
 
 const propTypes = {
   id: PropTypes.string.isRequired,
@@ -24,6 +22,7 @@ const propTypes = {
   parentComponent: componentShape.isRequired,
   index: PropTypes.number.isRequired,
   depth: PropTypes.number.isRequired,
+  editMode: PropTypes.bool.isRequired,
 
   // grid related
   availableColumnCount: PropTypes.number.isRequired,
@@ -92,17 +91,10 @@ class Row extends React.PureComponent {
       onResize,
       onResizeStop,
       handleComponentDrop,
+      editMode,
     } = this.props;
 
-    const rowItems = [];
-
-    // this adds a gutter between each child in the row.
-    (rowComponent.children || []).forEach((id, childIndex) => {
-      rowItems.push(id);
-      if (childIndex < rowComponent.children.length - 1) {
-        rowItems.push(GUTTER);
-      }
-    });
+    const rowItems = rowComponent.children || [];
 
     const backgroundStyle = backgroundStyleOptions.find(
       opt => opt.value === (rowComponent.meta.background || BACKGROUND_TRANSPARENT),
@@ -116,6 +108,7 @@ class Row extends React.PureComponent {
         index={index}
         depth={depth}
         onDrop={handleComponentDrop}
+        editMode={editMode}
       >
         {({ dropIndicatorProps, dragSourceRef }) => (
           <WithPopoverMenu
@@ -129,6 +122,7 @@ class Row extends React.PureComponent {
                 onChange={this.handleChangeBackground}
               />,
             ]}
+            editMode={editMode}
           >
             <div
               className={cx(
@@ -137,35 +131,30 @@ class Row extends React.PureComponent {
                 backgroundStyle.className,
               )}
             >
-              <HoverMenu innerRef={dragSourceRef} position="left">
-                <DragHandle position="left" />
-                <DeleteComponentButton onDelete={this.handleDeleteComponent} />
-                <IconButton
-                  onClick={this.handleChangeFocus}
-                  className="fa fa-cog"
-                />
-              </HoverMenu>
-
-              {rowItems.map((componentId, itemIndex) => {
-                if (componentId === GUTTER) {
-                  return <div key={`gutter-${itemIndex}`} style={{ width: GRID_GUTTER_SIZE }} />;
-                }
-
-                return (
-                  <DashboardComponent
-                    key={componentId}
-                    id={componentId}
-                    parentId={rowComponent.id}
-                    depth={depth + 1}
-                    index={itemIndex / 2} // account for gutters!
-                    availableColumnCount={availableColumnCount - occupiedColumnCount}
-                    columnWidth={columnWidth}
-                    onResizeStart={onResizeStart}
-                    onResize={onResize}
-                    onResizeStop={onResizeStop}
+              {editMode &&
+                <HoverMenu innerRef={dragSourceRef} position="left">
+                  <DragHandle position="left" />
+                  <DeleteComponentButton onDelete={this.handleDeleteComponent} />
+                  <IconButton
+                    onClick={this.handleChangeFocus}
+                    className="fa fa-cog"
                   />
-                );
-              })}
+                </HoverMenu>}
+
+              {rowItems.map((componentId, itemIndex) => (
+                <DashboardComponent
+                  key={componentId}
+                  id={componentId}
+                  parentId={rowComponent.id}
+                  depth={depth + 1}
+                  index={itemIndex}
+                  availableColumnCount={availableColumnCount - occupiedColumnCount}
+                  columnWidth={columnWidth}
+                  onResizeStart={onResizeStart}
+                  onResize={onResize}
+                  onResizeStop={onResizeStop}
+                />
+              ))}
 
               {dropIndicatorProps && <div {...dropIndicatorProps} />}
             </div>
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Spacer.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Spacer.jsx
deleted file mode 100644
index 7a287d8..0000000
--- a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Spacer.jsx
+++ /dev/null
@@ -1,106 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import DeleteComponentButton from '../DeleteComponentButton';
-import DragDroppable from '../dnd/DragDroppable';
-import HoverMenu from '../menu/HoverMenu';
-import ResizableContainer from '../resizable/ResizableContainer';
-import { componentShape } from '../../util/propShapes';
-
-const propTypes = {
-  id: PropTypes.string.isRequired,
-  parentId: PropTypes.string.isRequired,
-  component: componentShape.isRequired,
-  parentComponent: componentShape.isRequired,
-  index: PropTypes.number.isRequired,
-  depth: PropTypes.number.isRequired,
-
-  // grid related
-  availableColumnCount: PropTypes.number.isRequired,
-  columnWidth: PropTypes.number.isRequired,
-  onResizeStart: PropTypes.func.isRequired,
-  onResize: PropTypes.func.isRequired,
-  onResizeStop: PropTypes.func.isRequired,
-
-  // dnd
-  deleteComponent: PropTypes.func.isRequired,
-  handleComponentDrop: PropTypes.func.isRequired,
-};
-
-const defaultProps = {
-};
-
-class Spacer extends React.PureComponent {
-  constructor(props) {
-    super(props);
-    this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
-  }
-
-  handleDeleteComponent() {
-    const { deleteComponent, id, parentId } = this.props;
-    deleteComponent(id, parentId);
-  }
-
-  render() {
-    const {
-      component,
-      parentComponent,
-      index,
-      depth,
-      availableColumnCount,
-      columnWidth,
-      onResizeStart,
-      onResize,
-      onResizeStop,
-      handleComponentDrop,
-    } = this.props;
-
-    const orientation = depth % 2 === 0 ? 'row' : 'column';
-    const hoverMenuPosition = orientation === 'row' ? 'left' : 'top';
-    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 }) => (
-          <ResizableContainer
-            id={component.id}
-            adjustableWidth={adjustableWidth}
-            adjustableHeight={adjustableHeight}
-            widthStep={columnWidth}
-            widthMultiple={component.meta.width || 1}
-            heightMultiple={adjustableHeight ? component.meta.height || 1 : undefined}
-            minWidthMultiple={1}
-            minHeightMultiple={1}
-            maxWidthMultiple={availableColumnCount + (component.meta.width || 0)}
-            onResizeStart={onResizeStart}
-            onResize={onResize}
-            onResizeStop={onResizeStop}
-          >
-            <HoverMenu position={hoverMenuPosition}>
-              <DeleteComponentButton onDelete={this.handleDeleteComponent} />
-            </HoverMenu>
-
-            <div ref={dragSourceRef} className="grid-spacer" />
-
-            {dropIndicatorProps && <div {...dropIndicatorProps} />}
-          </ResizableContainer>
-        )}
-      </DragDroppable>
-    );
-  }
-}
-
-Spacer.propTypes = propTypes;
-Spacer.defaultProps = defaultProps;
-
-export default Spacer;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tab.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tab.jsx
index 9548a4b..218c4e7 100644
--- a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tab.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tab.jsx
@@ -7,6 +7,7 @@ import EditableTitle from '../../../../components/EditableTitle';
 import DeleteComponentButton from '../DeleteComponentButton';
 import WithPopoverMenu from '../menu/WithPopoverMenu';
 import { componentShape } from '../../util/propShapes';
+import { DASHBOARD_ROOT_DEPTH } from '../../util/constants';
 
 export const RENDER_TAB = 'RENDER_TAB';
 export const RENDER_TAB_CONTENT = 'RENDER_TAB_CONTENT';
@@ -21,6 +22,7 @@ const propTypes = {
   renderType: PropTypes.oneOf([RENDER_TAB, RENDER_TAB_CONTENT]).isRequired,
   onDropOnTab: PropTypes.func,
   onDeleteTab: PropTypes.func,
+  editMode: PropTypes.bool.isRequired,
 
   // grid related
   availableColumnCount: PropTypes.number,
@@ -77,9 +79,9 @@ export default class Tab extends React.PureComponent {
   }
 
   handleDeleteComponent() {
-    const { onDeleteTab, index, deleteComponent, id, parentId } = this.props;
-    deleteComponent(id, parentId);
-    onDeleteTab(index);
+    const { index, id, parentId } = this.props;
+    this.props.deleteComponent(id, parentId);
+    this.props.onDeleteTab(index);
   }
 
   handleDrop(dropResult) {
@@ -126,6 +128,7 @@ export default class Tab extends React.PureComponent {
       parentComponent,
       index,
       depth,
+      editMode,
     } = this.props;
 
     return (
@@ -136,7 +139,11 @@ export default class Tab extends React.PureComponent {
         index={index}
         depth={depth}
         onDrop={this.handleDrop}
-        disableDragDrop={isFocused}
+        // disable drag drop of top-level Tab's to prevent invalid nesting of a child in
+        // itself, e.g. if a top-level Tab has a Tabs child, dragging the Tab into the Tabs would
+        // reusult in circular children
+        disableDragDrop={isFocused || depth === DASHBOARD_ROOT_DEPTH + 1}
+        editMode={editMode}
       >
         {({ dropIndicatorProps, dragSourceRef }) => (
           <div className="dragdroppable-tab" ref={dragSourceRef}>
@@ -145,10 +152,11 @@ export default class Tab extends React.PureComponent {
               menuItems={parentComponent.children.length <= 1 ? [] : [
                 <DeleteComponentButton onDelete={this.handleDeleteComponent} />,
               ]}
+              editMode={editMode}
             >
               <EditableTitle
                 title={component.meta.text}
-                canEdit={isFocused}
+                canEdit={editMode && isFocused}
                 onSaveTitle={this.handleChangeText}
                 showTooltip={false}
               />
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tabs.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tabs.jsx
index cc5f637..1f5f0c6 100644
--- a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tabs.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tabs.jsx
@@ -22,7 +22,8 @@ const propTypes = {
   parentComponent: componentShape.isRequired,
   index: PropTypes.number.isRequired,
   depth: PropTypes.number.isRequired,
-  renderTabContent: PropTypes.bool,
+  renderTabContent: PropTypes.bool, // whether to render tabs + content or just tabs
+  editMode: PropTypes.bool.isRequired,
 
   // grid related
   availableColumnCount: PropTypes.number,
@@ -40,11 +41,11 @@ const propTypes = {
 };
 
 const defaultProps = {
-  onChangeTab: null,
   children: null,
   renderTabContent: true,
   availableColumnCount: 0,
   columnWidth: 0,
+  onChangeTab() {},
   onResizeStart() {},
   onResize() {},
   onResizeStop() {},
@@ -70,14 +71,9 @@ class Tabs extends React.PureComponent {
   }
 
   handleClickTab(tabIndex) {
-    const { onChangeTab, component, createComponent } = this.props;
+    const { component, createComponent } = this.props;
 
-    if (tabIndex !== NEW_TAB_INDEX && tabIndex !== this.state.tabIndex) {
-      this.setState(() => ({ tabIndex }));
-      if (onChangeTab) {
-        onChangeTab({ tabIndex, tabId: component.children[tabIndex] });
-      }
-    } else if (tabIndex === NEW_TAB_INDEX) {
+    if (tabIndex === NEW_TAB_INDEX) {
       createComponent({
         destination: {
           id: component.id,
@@ -89,6 +85,9 @@ class Tabs extends React.PureComponent {
           type: TAB_TYPE,
         },
       });
+    } else if (tabIndex !== this.state.tabIndex) {
+      this.setState(() => ({ tabIndex }));
+      this.props.onChangeTab({ tabIndex, tabId: component.children[tabIndex] });
     }
   }
 
@@ -132,6 +131,7 @@ class Tabs extends React.PureComponent {
       onResizeStop,
       handleComponentDrop,
       renderTabContent,
+      editMode,
     } = this.props;
 
     const { tabIndex: selectedTabIndex } = this.state;
@@ -145,13 +145,15 @@ class Tabs extends React.PureComponent {
         index={index}
         depth={depth}
         onDrop={handleComponentDrop}
+        editMode={editMode}
       >
         {({ dropIndicatorProps: tabsDropIndicatorProps, dragSourceRef: tabsDragSourceRef }) => (
           <div className="dashboard-component dashboard-component-tabs">
-            <HoverMenu innerRef={tabsDragSourceRef} position="left">
-              <DragHandle position="left" />
-              <DeleteComponentButton onDelete={this.handleDeleteComponent} />
-            </HoverMenu>
+            {editMode &&
+              <HoverMenu innerRef={tabsDragSourceRef} position="left">
+                <DragHandle position="left" />
+                <DeleteComponentButton onDelete={this.handleDeleteComponent} />
+              </HoverMenu>}
 
             <BootstrapTabs
               id={tabsComponent.id}
@@ -202,11 +204,12 @@ class Tabs extends React.PureComponent {
                 </BootstrapTab>
               ))}
 
-              {tabIds.length < MAX_TAB_COUNT &&
-                <BootstrapTab
-                  eventKey={NEW_TAB_INDEX}
-                  title={<div className="fa fa-plus" />}
-                />}
+              {editMode &&
+                tabIds.length < MAX_TAB_COUNT &&
+                  <BootstrapTab
+                    eventKey={NEW_TAB_INDEX}
+                    title={<div className="fa fa-plus" />}
+                  />}
 
             </BootstrapTabs>
 
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/index.js b/superset/assets/javascripts/dashboard/v2/components/gridComponents/index.js
index 3a3fad5..96c9a19 100644
--- a/superset/assets/javascripts/dashboard/v2/components/gridComponents/index.js
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/index.js
@@ -5,7 +5,6 @@ import {
   HEADER_TYPE,
   INVISIBLE_ROW_TYPE,
   ROW_TYPE,
-  SPACER_TYPE,
   TAB_TYPE,
   TABS_TYPE,
 } from '../../util/componentTypes';
@@ -15,7 +14,6 @@ import Column from './Column';
 import Divider from './Divider';
 import Header from './Header';
 import Row from './Row';
-import Spacer from './Spacer';
 import Tab from './Tab';
 import Tabs from './Tabs';
 
@@ -24,7 +22,6 @@ export { default as Column } from './Column';
 export { default as Divider } from './Divider';
 export { default as Header } from './Header';
 export { default as Row } from './Row';
-export { default as Spacer } from './Spacer';
 export { default as Tab } from './Tab';
 export { default as Tabs } from './Tabs';
 
@@ -35,7 +32,6 @@ export default {
   [HEADER_TYPE]: Header,
   [INVISIBLE_ROW_TYPE]: Row,
   [ROW_TYPE]: Row,
-  [SPACER_TYPE]: Spacer,
   [TAB_TYPE]: Tab,
   [TABS_TYPE]: Tabs,
 };
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 778f58e..eebd6e0 100644
--- a/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/DraggableNewComponent.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/DraggableNewComponent.jsx
@@ -26,6 +26,7 @@ export default class DraggableNewComponent extends React.PureComponent {
         parentComponent={{ id: NEW_COMPONENTS_SOURCE_ID, type: NEW_COMPONENT_SOURCE_TYPE }}
         index={0}
         depth={0}
+        editMode
       >
         {({ dragSourceRef }) => (
           <div ref={dragSourceRef} className="new-component">
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewSpacer.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewSpacer.jsx
deleted file mode 100644
index 7287770..0000000
--- a/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewSpacer.jsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import React from 'react';
-// import PropTypes from 'prop-types';
-
-import { SPACER_TYPE } from '../../../util/componentTypes';
-import { NEW_SPACER_ID } from '../../../util/constants';
-import DraggableNewComponent from './DraggableNewComponent';
-
-const propTypes = {
-};
-
-export default class DraggableNewChart extends React.PureComponent {
-  render() {
-    return (
-      <DraggableNewComponent
-        id={NEW_SPACER_ID}
-        type={SPACER_TYPE}
-        label="Spacer"
-        className="spacer-placeholder fa fa-arrows"
-      />
-    );
-  }
-}
-
-DraggableNewChart.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 2054090..f213442 100644
--- a/superset/assets/javascripts/dashboard/v2/components/menu/WithPopoverMenu.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/menu/WithPopoverMenu.jsx
@@ -9,6 +9,7 @@ const propTypes = {
   onChangeFocus: PropTypes.func,
   isFocused: PropTypes.bool,
   shouldFocus: PropTypes.func,
+  editMode: PropTypes.bool.isRequired,
 };
 
 const defaultProps = {
@@ -32,10 +33,14 @@ class WithPopoverMenu extends React.PureComponent {
   }
 
   componentWillReceiveProps(nextProps) {
-    if (nextProps.isFocused && !this.state.isFocused) {
+    if (nextProps.editMode && nextProps.isFocused && !this.state.isFocused) {
       document.addEventListener('click', this.handleClick, true);
       document.addEventListener('drag', this.handleClick, true);
       this.setState({ isFocused: true });
+    } else if (this.state.isFocused && !nextProps.editMode) {
+      document.removeEventListener('click', this.handleClick, true);
+      document.removeEventListener('drag', this.handleClick, true);
+      this.setState({ isFocused: false });
     }
   }
 
@@ -49,10 +54,14 @@ class WithPopoverMenu extends React.PureComponent {
   }
 
   handleClick(event) {
-    const { onChangeFocus, shouldFocus: shouldFocusThunk } = this.props;
-    const shouldFocus = shouldFocusThunk(event, this.container);
+    const { onChangeFocus, shouldFocus: shouldFocusFunc, disableClick, editMode } = this.props;
+    const shouldFocus = shouldFocusFunc(event, this.container);
+
+    if (!editMode) {
+      return;
+    }
 
-    if (shouldFocus && !this.state.isFocused) {
+    if (!disableClick && shouldFocus && !this.state.isFocused) {
       // if not focused, set focus and add a window event listener to capture outside clicks
       // this enables us to not set a click listener for ever item on a dashboard
       document.addEventListener('click', this.handleClick, true);
@@ -72,27 +81,28 @@ class WithPopoverMenu extends React.PureComponent {
   }
 
   render() {
-    const { children, menuItems, disableClick } = this.props;
+    const { children, menuItems, editMode } = this.props;
     const { isFocused } = this.state;
 
     return (
       <div
         ref={this.setRef}
-        onClick={!disableClick && this.handleClick}
-        role="button" // @TODO consider others?
-        tabIndex="0"
+        onClick={this.handleClick}
+        role="none"
         className={cx(
           'with-popover-menu',
-          isFocused && 'with-popover-menu--focused',
+          editMode && isFocused && 'with-popover-menu--focused',
         )}
       >
         {children}
-        {isFocused && menuItems.length ?
-          <div className="popover-menu" >
-            {menuItems.map((node, i) => (
-              <div className="menu-item" key={`menu-item-${i}`}>{node}</div>
-            ))}
-          </div> : null}
+        {editMode &&
+          isFocused &&
+          menuItems.length > 0 &&
+            <div className="popover-menu" >
+              {menuItems.map((node, i) => (
+                <div className="menu-item" key={`menu-item-${i}`}>{node}</div>
+              ))}
+            </div>}
       </div>
     );
   }
diff --git a/superset/assets/javascripts/dashboard/v2/components/resizable/ResizableContainer.jsx b/superset/assets/javascripts/dashboard/v2/components/resizable/ResizableContainer.jsx
index fbb7d1d..a532ff0 100644
--- a/superset/assets/javascripts/dashboard/v2/components/resizable/ResizableContainer.jsx
+++ b/superset/assets/javascripts/dashboard/v2/components/resizable/ResizableContainer.jsx
@@ -5,11 +5,7 @@ import cx from 'classnames';
 
 import ResizableHandle from './ResizableHandle';
 import resizableConfig from '../../util/resizableConfig';
-import {
-  GRID_BASE_UNIT,
-  GRID_ROW_HEIGHT_UNIT,
-  GRID_GUTTER_SIZE,
-} from '../../util/constants';
+import { GRID_BASE_UNIT, GRID_GUTTER_SIZE } from '../../util/constants';
 
 const propTypes = {
   id: PropTypes.string.isRequired,
@@ -25,10 +21,14 @@ const propTypes = {
   maxWidthMultiple: PropTypes.number,
   minHeightMultiple: PropTypes.number,
   maxHeightMultiple: PropTypes.number,
+  staticHeight: PropTypes.number,
   staticHeightMultiple: PropTypes.number,
+  staticWidth: PropTypes.number,
+  staticWidthMultiple: PropTypes.number,
   onResizeStop: PropTypes.func,
   onResize: PropTypes.func,
   onResizeStart: PropTypes.func,
+  editMode: PropTypes.bool.isRequired,
 };
 
 const defaultProps = {
@@ -37,14 +37,17 @@ const defaultProps = {
   adjustableHeight: true,
   gutterWidth: GRID_GUTTER_SIZE,
   widthStep: GRID_BASE_UNIT,
-  heightStep: GRID_ROW_HEIGHT_UNIT,
+  heightStep: GRID_BASE_UNIT,
   widthMultiple: null,
   heightMultiple: null,
   minWidthMultiple: 1,
   maxWidthMultiple: Infinity,
   minHeightMultiple: 1,
   maxHeightMultiple: Infinity,
+  staticHeight: null,
   staticHeightMultiple: null,
+  staticWidth: null,
+  staticWidthMultiple: null,
   onResizeStop: null,
   onResize: null,
   onResizeStart: null,
@@ -99,9 +102,9 @@ class ResizableContainer extends React.PureComponent {
 
     if (onResizeStop) {
       const nextWidthMultiple =
-        Math.round(widthMultiple + (delta.width / (widthStep + gutterWidth)));
+        widthMultiple + Math.round(delta.width / (widthStep + gutterWidth));
       const nextHeightMultiple =
-        Math.round(heightMultiple + (delta.height / heightStep));
+        heightMultiple + Math.round(delta.height / heightStep);
 
       onResizeStop({
         id,
@@ -131,6 +134,7 @@ class ResizableContainer extends React.PureComponent {
       minHeightMultiple,
       maxHeightMultiple,
       gutterWidth,
+      editMode,
     } = this.props;
 
     const size = {
@@ -146,6 +150,14 @@ class ResizableContainer extends React.PureComponent {
           || undefined,
     };
 
+    if (!editMode) {
+      return (
+        <div style={{ ...size }}>
+          {children}
+        </div>
+      );
+    }
+
     let enableConfig = resizableConfig.notAdjustable;
     if (adjustableWidth && adjustableHeight) enableConfig = resizableConfig.widthAndHeight;
     else if (adjustableWidth) enableConfig = resizableConfig.widthOnly;
@@ -164,10 +176,10 @@ class ResizableContainer extends React.PureComponent {
           ? (minHeightMultiple * heightStep)
           : undefined}
         maxWidth={adjustableWidth
-          ? (maxWidthMultiple * (widthStep + gutterWidth)) - gutterWidth
+          ? Math.max(size.width, (maxWidthMultiple * (widthStep + gutterWidth)) - gutterWidth)
           : undefined}
         maxHeight={adjustableHeight
-          ? (maxHeightMultiple * heightStep)
+          ? Math.max(size.height, maxHeightMultiple * heightStep)
           : undefined}
         size={size}
         onResizeStart={this.handleResizeStart}
diff --git a/superset/assets/javascripts/dashboard/v2/containers/DashboardBuilder.jsx b/superset/assets/javascripts/dashboard/v2/containers/DashboardBuilder.jsx
index 6bd8658..b8d717e 100644
--- a/superset/assets/javascripts/dashboard/v2/containers/DashboardBuilder.jsx
+++ b/superset/assets/javascripts/dashboard/v2/containers/DashboardBuilder.jsx
@@ -5,11 +5,12 @@ import DashboardBuilder from '../components/DashboardBuilder';
 import {
   deleteTopLevelTabs,
   handleComponentDrop,
-} from '../actions';
+} from '../actions/dashboardLayout';
 
-function mapStateToProps({ dashboard: undoableDashboard }) {
+function mapStateToProps({ dashboardLayout: undoableLayout, editMode }) {
   return {
-    dashboard: undoableDashboard.present,
+    dashboardLayout: undoableLayout.present,
+    editMode,
   };
 }
 
diff --git a/superset/assets/javascripts/dashboard/v2/containers/DashboardComponent.jsx b/superset/assets/javascripts/dashboard/v2/containers/DashboardComponent.jsx
index f7e86cc..add5a6d 100644
--- a/superset/assets/javascripts/dashboard/v2/containers/DashboardComponent.jsx
+++ b/superset/assets/javascripts/dashboard/v2/containers/DashboardComponent.jsx
@@ -14,7 +14,7 @@ import {
   deleteComponent,
   updateComponents,
   handleComponentDrop,
-} from '../actions';
+} from '../actions/dashboardLayout';
 
 const propTypes = {
   component: componentShape.isRequired,
@@ -25,28 +25,29 @@ const propTypes = {
   handleComponentDrop: PropTypes.func.isRequired,
 };
 
-function mapStateToProps({ dashboard: undoableDashboard }, ownProps) {
-  const components = undoableDashboard.present;
+function mapStateToProps({ dashboardLayout: undoableLayout, editMode }, ownProps) {
+  const dashboardLayout = undoableLayout.present;
   const { id, parentId } = ownProps;
-  const component = components[id];
+  const component = dashboardLayout[id];
   const props = {
     component,
-    parentComponent: components[parentId],
+    parentComponent: dashboardLayout[parentId],
+    editMode,
   };
 
   // rows and columns need more data about their child dimensions
   // doing this allows us to not pass the entire component lookup to all Components
   if (props.component.type === ROW_TYPE) {
-    props.occupiedColumnCount = getTotalChildWidth({ id, components });
+    props.occupiedColumnCount = getTotalChildWidth({ id, components: dashboardLayout });
   } else if (props.component.type === COLUMN_TYPE) {
     props.minColumnWidth = GRID_MIN_COLUMN_COUNT;
 
     component.children.forEach((childId) => {
       // rows don't have widths, so find the width of its children
-      if (components[childId].type === ROW_TYPE) {
+      if (dashboardLayout[childId].type === ROW_TYPE) {
         props.minColumnWidth = Math.max(
           props.minColumnWidth,
-          getTotalChildWidth({ id: childId, components }),
+          getTotalChildWidth({ id: childId, components: dashboardLayout }),
         );
       }
     });
diff --git a/superset/assets/javascripts/dashboard/v2/containers/DashboardGrid.jsx b/superset/assets/javascripts/dashboard/v2/containers/DashboardGrid.jsx
index eb01616..67b2396 100644
--- a/superset/assets/javascripts/dashboard/v2/containers/DashboardGrid.jsx
+++ b/superset/assets/javascripts/dashboard/v2/containers/DashboardGrid.jsx
@@ -5,7 +5,7 @@ import DashboardGrid from '../components/DashboardGrid';
 import {
   handleComponentDrop,
   resizeComponent,
-} from '../actions';
+} from '../actions/dashboardLayout';
 
 function mapDispatchToProps(dispatch) {
   return bindActionCreators({
@@ -14,4 +14,4 @@ function mapDispatchToProps(dispatch) {
   }, dispatch);
 }
 
-export default connect(null, mapDispatchToProps)(DashboardGrid);
+export default connect(({ editMode }) => ({ editMode }), mapDispatchToProps)(DashboardGrid);
diff --git a/superset/assets/javascripts/dashboard/v2/containers/DashboardHeader.jsx b/superset/assets/javascripts/dashboard/v2/containers/DashboardHeader.jsx
index 52e7e7a..8855d2c 100644
--- a/superset/assets/javascripts/dashboard/v2/containers/DashboardHeader.jsx
+++ b/superset/assets/javascripts/dashboard/v2/containers/DashboardHeader.jsx
@@ -1,4 +1,4 @@
-import { ActionCreators as UndoActionCreators } from 'redux-undo'
+import { ActionCreators as UndoActionCreators } from 'redux-undo';
 import { bindActionCreators } from 'redux';
 import { connect } from 'react-redux';
 
@@ -8,13 +8,16 @@ import { DASHBOARD_HEADER_ID } from '../util/constants';
 import {
   updateComponents,
   handleComponentDrop,
-} from '../actions';
+} from '../actions/dashboardLayout';
 
-function mapStateToProps({ dashboard: undoableDashboard }) {
+import { setEditMode } from '../actions/editMode';
+
+function mapStateToProps({ dashboardLayout: undoableLayout, editMode }) {
   return {
-    component: undoableDashboard.present[DASHBOARD_HEADER_ID],
-    canUndo: undoableDashboard.past.length > 0,
-    canRedo: undoableDashboard.future.length > 0,
+    component: undoableLayout.present[DASHBOARD_HEADER_ID],
+    canUndo: undoableLayout.past.length > 0,
+    canRedo: undoableLayout.future.length > 0,
+    editMode,
   };
 }
 
@@ -24,6 +27,7 @@ function mapDispatchToProps(dispatch) {
     handleComponentDrop,
     onUndo: UndoActionCreators.undo,
     onRedo: UndoActionCreators.redo,
+    setEditMode,
   }, dispatch);
 }
 
diff --git a/superset/assets/javascripts/dashboard/v2/containers/ToastPresenter.jsx b/superset/assets/javascripts/dashboard/v2/containers/ToastPresenter.jsx
new file mode 100644
index 0000000..7e70abc
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/containers/ToastPresenter.jsx
@@ -0,0 +1,10 @@
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+import ToastPresenter from '../components/ToastPresenter';
+
+import { removeToast } from '../actions/messageToasts';
+
+export default connect(
+  ({ messageToasts: toasts }) => ({ toasts }),
+  dispatch => bindActionCreators({ removeToast }, dispatch),
+)(ToastPresenter);
diff --git a/superset/assets/javascripts/dashboard/v2/reducers/dashboard.js b/superset/assets/javascripts/dashboard/v2/reducers/dashboardLayout.js
similarity index 98%
rename from superset/assets/javascripts/dashboard/v2/reducers/dashboard.js
rename to superset/assets/javascripts/dashboard/v2/reducers/dashboardLayout.js
index 9b03861..994ac47 100644
--- a/superset/assets/javascripts/dashboard/v2/reducers/dashboard.js
+++ b/superset/assets/javascripts/dashboard/v2/reducers/dashboardLayout.js
@@ -10,7 +10,6 @@ import {
   ROW_TYPE,
   TAB_TYPE,
   TABS_TYPE,
-
 } from '../util/componentTypes';
 
 import {
@@ -20,7 +19,7 @@ import {
   MOVE_COMPONENT,
   CREATE_TOP_LEVEL_TABS,
   DELETE_TOP_LEVEL_TABS,
-} from '../actions';
+} from '../actions/dashboardLayout';
 
 const actionHandlers = {
   [UPDATE_COMPONENTS](state, action) {
@@ -224,7 +223,7 @@ const actionHandlers = {
   },
 };
 
-export default function dashboardReducer(state = {}, action) {
+export default function layoutReducer(state = {}, action) {
   if (action.type in actionHandlers) {
     const handler = actionHandlers[action.type];
     return handler(state, action);
diff --git a/superset/assets/javascripts/dashboard/v2/reducers/editMode.js b/superset/assets/javascripts/dashboard/v2/reducers/editMode.js
new file mode 100644
index 0000000..b1a1630
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/reducers/editMode.js
@@ -0,0 +1,11 @@
+import { SET_EDIT_MODE } from '../actions/editMode';
+
+export default function editModeReducer(editMode = false, action) {
+  switch (action.type) {
+    case SET_EDIT_MODE:
+      return action.payload.editMode;
+
+    default:
+      return editMode;
+  }
+}
diff --git a/superset/assets/javascripts/dashboard/v2/reducers/index.js b/superset/assets/javascripts/dashboard/v2/reducers/index.js
index 9c0575e..731734d 100644
--- a/superset/assets/javascripts/dashboard/v2/reducers/index.js
+++ b/superset/assets/javascripts/dashboard/v2/reducers/index.js
@@ -1,13 +1,17 @@
 import { combineReducers } from 'redux';
 import undoable, { distinctState } from 'redux-undo';
 
-import dashboard from './dashboard';
+import dashboardLayout from './dashboardLayout';
+import editMode from './editMode';
+import messageToasts from './messageToasts';
 
-const undoableDashboard = undoable(dashboard, {
-  limit: 10,
+const undoableLayout = undoable(dashboardLayout, {
+  limit: 15,
   filter: distinctState(),
 });
 
 export default combineReducers({
-  dashboard: undoableDashboard,
+  dashboardLayout: undoableLayout,
+  editMode,
+  messageToasts,
 });
diff --git a/superset/assets/javascripts/dashboard/v2/reducers/messageToasts.js b/superset/assets/javascripts/dashboard/v2/reducers/messageToasts.js
new file mode 100644
index 0000000..1f5728a
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/reducers/messageToasts.js
@@ -0,0 +1,18 @@
+import { ADD_TOAST, REMOVE_TOAST } from '../actions/messageToasts';
+
+export default function messageToastsReducer(toasts = [], action) {
+  switch (action.type) {
+    case ADD_TOAST: {
+      const { payload: toast } = action;
+      return [toast, ...toasts];
+    }
+
+    case REMOVE_TOAST: {
+      const { payload: { id } } = action;
+      return [...toasts].filter(toast => toast.id !== id);
+    }
+
+    default:
+      return toasts;
+  }
+}
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/builder.less b/superset/assets/javascripts/dashboard/v2/stylesheets/builder.less
index 5f1a5b0..3651c57 100644
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/builder.less
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/builder.less
@@ -14,7 +14,7 @@
   box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.1); /* @TODO color */
 }
 
-.dashboard-builder {
+.dashboard-content {
   display: flex;
   flex-direction: row;
   flex-wrap: nowrap;
@@ -32,12 +32,12 @@
   padding-left: 8px; /* note this is added to tab-level padding, to match header */
 }
 
-.dashboard-builder .grid-container .dashboard-component-tabs {
+.dashboard-content .grid-container .dashboard-component-tabs {
   box-shadow: none;
   padding-left: 0;
 }
 
-.dashboard-builder > div:first-child {
+.dashboard-content > div:first-child {
   width: 100%;
   flex-grow: 1;
   position: relative;
@@ -62,3 +62,11 @@
   background: @almost-black !important;
   color: white !important;
 }
+
+.background--transparent {
+  background-color: transparent;
+}
+
+.background--white {
+  background-color: white;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/components/DashboardBuilder.jsx b/superset/assets/javascripts/dashboard/v2/stylesheets/components/DashboardBuilder.jsx
deleted file mode 100644
index e011ad4..0000000
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/components/DashboardBuilder.jsx
+++ /dev/null
@@ -1,127 +0,0 @@
-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/chart.less b/superset/assets/javascripts/dashboard/v2/stylesheets/components/chart.less
index 2bdf3cc..141c3e9 100644
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/components/chart.less
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/components/chart.less
@@ -14,8 +14,6 @@
   opacity: 0.3;
 }
 
-.grid-container--resizing .dashboard-component-chart,
-.dashboard-builder--dragging .dashboard-component-chart,
-.dashboard-component-chart:hover {
+.dashboard-v2--editing .dashboard-component-chart:hover {
   box-shadow: inset 0 0 0 1px @gray-light;
 }
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/components/column.less b/superset/assets/javascripts/dashboard/v2/stylesheets/components/column.less
index 31ae21d..9565112 100644
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/components/column.less
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/components/column.less
@@ -1,18 +1,34 @@
 .grid-column {
   width: 100%;
-  min-height: 56px;
 }
 
-.grid-column > .hover-menu--top {
-  top: -20px;
+/* gutters between elements in a column */
+.grid-column > :not(:only-child):not(.hover-menu):not(:last-child) {
+  margin-bottom: 16px;
+}
+
+.dashboard-v2--editing .grid-column:after {
+  border: 1px dashed transparent;
+  content: "";
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 1px;
+  left: 0;
+  z-index: 1;
+  pointer-events: none;
+}
+
+.dashboard-v2--editing .grid-column:hover:after {
+  border: 1px solid @gray-light;
 }
 
-.grid-column.background--transparent {
-  background-color: transparent;
+.grid-column > .hover-menu--top {
+  top: -20px;
 }
 
-.grid-column.background--white {
-  background-color: white;
+.grid-column--empty {
+  min-height: 72px;
 }
 
 .grid-column--empty:before {
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/components/divider.less b/superset/assets/javascripts/dashboard/v2/stylesheets/components/divider.less
index f1d3d86..e4625d3 100644
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/components/divider.less
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/components/divider.less
@@ -1,6 +1,6 @@
 .dashboard-component-divider {
   width: 100%;
-  padding: 24px 0; /* this is padding not margin to enable a larger mouse target */
+  padding: 8px 0; /* this is padding not margin to enable a larger mouse target */
   background-color: transparent;
 }
 
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/components/header.less b/superset/assets/javascripts/dashboard/v2/stylesheets/components/header.less
index 77066da..37c7598 100644
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/components/header.less
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/components/header.less
@@ -2,25 +2,45 @@
   width: 100%;
   line-height: 1em;
   font-weight: 700;
-  background-color: inherit;
   padding: 16px 0;
   color: @almost-black;
 }
 
+.dashboard-header .dashboard-component-header {
+  font-weight: 300;
+  width: auto;
+}
+
+.dragdroppable-row .dashboard-component-header {
+  cursor: move;
+}
+
+/* note: sizes should be a multiple of the 8px grid unit so that rows in the grid align */
 .header-small {
   font-size: 16px;
 }
 
 .header-medium {
-  font-size: 22px;
+  font-size: 24px;
 }
 
 .header-large {
   font-size: 32px;
 }
 
-.dragdroppable-row .dragdroppable-row .dashboard-component-header,
-.dragdroppable-row .dragdroppable-row .dashboard-component-divider {
+.background--white .dashboard-component-header,
+.dashboard-component-header.background--white,
+.dashboard-component-tabs .dashboard-component-header,
+.dashboard-component-tabs .dashboard-component-divider {
   padding-left: 16px;
   padding-right: 16px;
 }
+
+/*
+ * grids add margin between items, so don't double pad within columns
+ * we'll not worry about double padding on top as it can serve as a visual separator
+ */
+// .grid-content > :not(:only-child):not(:last-child) .dashboard-component-header,
+.grid-column > :not(:only-child):not(:last-child) .dashboard-component-header {
+  margin-bottom: -16px;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/components/index.less b/superset/assets/javascripts/dashboard/v2/stylesheets/components/index.less
index 5da54e5..5a1803e 100644
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/components/index.less
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/components/index.less
@@ -4,5 +4,4 @@
 @import './header.less';
 @import './new-component.less';
 @import './row.less';
-@import './spacer.less';
 @import './tabs.less';
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 e36fee2..decb1ad 100644
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/components/new-component.less
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/components/new-component.less
@@ -22,18 +22,6 @@
   font-size: 1.5em;
 }
 
-.new-component-placeholder.spacer-placeholder {
-  font-size: 1em;
-}
-
 .new-component-placeholder.fa-window-restore {
   font-size: 1em;
 }
-
-.new-component-placeholder.spacer-placeholder:after {
-  content: "";
-  position: absolute;
-  height: 60%;
-  width: 60%;
-  border: 1px dashed @gray;
-}
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/components/row.less b/superset/assets/javascripts/dashboard/v2/stylesheets/components/row.less
index 2036815..956966d 100644
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/components/row.less
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/components/row.less
@@ -5,19 +5,28 @@
   align-items: flex-start;
   width: 100%;
   height: fit-content;
-  background-color: transparent;
 }
 
-.grid-row.background--transparent {
-  background-color: transparent;
+/* gutters between elements in a row */
+.grid-row > :not(:only-child):not(:last-child):not(.hover-menu) {
+  margin-right: 16px;
 }
 
-.grid-row.background--white {
-  background-color: white;
+/* hover indicator */
+.dashboard-v2--editing .grid-row:after {
+  border: 1px dashed transparent;
+  content: "";
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 1px;
+  left: 0;
+  z-index: 1;
+  pointer-events: none;
 }
 
-.dashboard-component-header.grid-row--white {
-  padding-left: 16px;
+.dashboard-v2--editing .grid-row:hover:after {
+  border: 1px solid @gray-light;
 }
 
 .grid-row.grid-row--empty {
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/components/spacer.less b/superset/assets/javascripts/dashboard/v2/stylesheets/components/spacer.less
deleted file mode 100644
index 8716c21..0000000
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/components/spacer.less
+++ /dev/null
@@ -1,13 +0,0 @@
-.grid-spacer {
-  width: 100%;
-  height: 100%;
-  background-color: transparent;
-}
-
-.dragdroppable .grid-spacer {
-  cursor: move;
-}
-
-.dragdroppable:hover .grid-spacer {
-  box-shadow: inset 0 0 0 1px @gray-light;
-}
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/grid.less b/superset/assets/javascripts/dashboard/v2/stylesheets/grid.less
index 7c55dee..45b8a42 100644
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/grid.less
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/grid.less
@@ -9,6 +9,11 @@
   flex-direction: column;
 }
 
+/* gutters between rows */
+.grid-content > div:not(:only-child):not(:last-child):not(.empty-grid-droptarget) {
+  margin-bottom: 16px;
+}
+
 .empty-grid-droptarget {
   width: 100%;
   height: 100%;
@@ -33,22 +38,3 @@
   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/index.less b/superset/assets/javascripts/dashboard/v2/stylesheets/index.less
index d2a41a8..49ff5da 100644
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/index.less
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/index.less
@@ -8,3 +8,4 @@
 @import './popover-menu.less';
 @import './resizable.less';
 @import './components/index.less';
+@import './toast.less';
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/popover-menu.less b/superset/assets/javascripts/dashboard/v2/stylesheets/popover-menu.less
index a36ab1c..848949b 100644
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/popover-menu.less
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/popover-menu.less
@@ -124,5 +124,7 @@
 }
 
 .background-style-option.background--transparent:before {
-  background: @gray-light;
+  background-image: linear-gradient(45deg, @gray 25%, transparent 25%), linear-gradient(-45deg, @gray 25%, transparent 25%), linear-gradient(45deg, transparent 75%, @gray 75%), linear-gradient(-45deg, transparent 75%, @gray 75%);
+  background-size: 8px 8px;
+  background-position: 0 0, 0 4px, 4px -4px, -4px 0px
 }
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/resizable.less b/superset/assets/javascripts/dashboard/v2/stylesheets/resizable.less
index 3ce5cfd..7bdd5f8 100644
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/resizable.less
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/resizable.less
@@ -29,8 +29,8 @@
   border-width: 0 1.5px 1.5px 0;
   border-right-color: @gray;
   border-bottom-color: @gray;
-  right: 16;
-  bottom: 16;
+  right: 16px;
+  bottom: 16px;
   width: 8px;
   height: 8px;
 }
@@ -38,22 +38,18 @@
 .resize-handle--right {
   width: 2px;
   height: 20px;
-  right: -2px;
-  top: 47%;
+  right: 2px;
+  top: ~"calc(50% - 9px)"; /* escape for .less */
   position: absolute;
   border-left: 1px solid @gray;
   border-right: 1px solid @gray;
 }
 
-  .grid-spacer + span .resize-handle--right {
-    right: 3px;
-  }
-
 .resize-handle--bottom {
   height: 2px;
   width: 20px;
-  bottom: 10px;
-  left: 47%;
+  bottom: 2px;
+  left: ~"calc(50% - 10px)"; /* escape for .less */
   position: absolute;
   border-top: 1px solid @gray;
   border-bottom: 1px solid @gray;
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/toast.less b/superset/assets/javascripts/dashboard/v2/stylesheets/toast.less
new file mode 100644
index 0000000..a508637
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/toast.less
@@ -0,0 +1,59 @@
+.toast-presenter {
+  position: fixed;
+  bottom: 16px;
+  left: 50%;
+  transform: translate(-50%, 0);
+  width: 500px;
+  z-index: 3000; // top of the world
+}
+
+.toast {
+  background: white;
+  color: @almost-black;
+  opacity: 0;
+  position: relative;
+  white-space: pre-line;
+  box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.15);
+  border-radius: 2px;
+  will-change: transform, opacity;
+  transform: translateY(-100%);
+  transition: transform .3s, opacity .3s;
+}
+
+.toast > button {
+  color: @almost-black;
+}
+
+.toast > button:hover {
+  color: @gray-dark;
+}
+
+.toast--visible {
+  transform: translateY(0);
+  opacity: 1;
+}
+
+.toast:after {
+  content: "";
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 4px;
+  height: 100%;
+}
+
+.toast--info:after {
+  background: linear-gradient(to bottom, @pink, @purple);
+}
+
+.toast--success:after {
+  background: @success;
+}
+
+.toast--warning:after {
+  background: @warning;
+}
+
+.toast--danger:after {
+  background: @danger;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/variables.less b/superset/assets/javascripts/dashboard/v2/stylesheets/variables.less
index f3a61df..254af23 100644
--- a/superset/assets/javascripts/dashboard/v2/stylesheets/variables.less
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/variables.less
@@ -5,3 +5,11 @@
 @gray: #879399;
 @gray-light: #CFD8DC;
 @gray-bg: #f5f5f5;
+
+/* toasts */
+@pink: #E32364;
+@purple: #2C2261;
+
+@success: #00BFA5;
+@warning: #FFAB00;
+@danger: @pink;
diff --git a/superset/assets/javascripts/dashboard/v2/util/componentIsResizable.js b/superset/assets/javascripts/dashboard/v2/util/componentIsResizable.js
index ab701a7..c0016f3 100644
--- a/superset/assets/javascripts/dashboard/v2/util/componentIsResizable.js
+++ b/superset/assets/javascripts/dashboard/v2/util/componentIsResizable.js
@@ -1,5 +1,4 @@
 import {
-  SPACER_TYPE,
   COLUMN_TYPE,
   CHART_TYPE,
   MARKDOWN_TYPE,
@@ -7,7 +6,6 @@ import {
 
 export default function componentIsResizable(entity) {
   return [
-    SPACER_TYPE,
     COLUMN_TYPE,
     CHART_TYPE,
     MARKDOWN_TYPE,
diff --git a/superset/assets/javascripts/dashboard/v2/util/componentTypes.js b/superset/assets/javascripts/dashboard/v2/util/componentTypes.js
index c667138..2866898 100644
--- a/superset/assets/javascripts/dashboard/v2/util/componentTypes.js
+++ b/superset/assets/javascripts/dashboard/v2/util/componentTypes.js
@@ -8,7 +8,6 @@ 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';
 export const TAB_TYPE = 'DASHBOARD_TAB_TYPE';
 
@@ -23,7 +22,6 @@ export default {
   MARKDOWN_TYPE,
   NEW_COMPONENT_SOURCE_TYPE,
   ROW_TYPE,
-  SPACER_TYPE,
   TABS_TYPE,
   TAB_TYPE,
 };
diff --git a/superset/assets/javascripts/dashboard/v2/util/constants.js b/superset/assets/javascripts/dashboard/v2/util/constants.js
index e892456..36ef71b 100644
--- a/superset/assets/javascripts/dashboard/v2/util/constants.js
+++ b/superset/assets/javascripts/dashboard/v2/util/constants.js
@@ -10,7 +10,6 @@ export const NEW_DIVIDER_ID = 'NEW_DIVIDER_ID';
 export const NEW_HEADER_ID = 'NEW_HEADER_ID';
 export const NEW_MARKDOWN_ID = 'NEW_MARKDOWN_ID';
 export const NEW_ROW_ID = 'NEW_ROW_ID';
-export const NEW_SPACER_ID = 'NEW_SPACER_ID';
 export const NEW_TAB_ID = 'NEW_TAB_ID';
 export const NEW_TABS_ID = 'NEW_TABS_ID';
 
@@ -18,7 +17,6 @@ export const NEW_TABS_ID = 'NEW_TABS_ID';
 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;
 export const GRID_COLUMN_COUNT = 12;
 export const GRID_MIN_COLUMN_COUNT = 3;
 export const GRID_MIN_ROW_UNITS = 5;
@@ -33,3 +31,9 @@ export const LARGE_HEADER = 'LARGE_HEADER';
 // Style types
 export const BACKGROUND_WHITE = 'BACKGROUND_WHITE';
 export const BACKGROUND_TRANSPARENT = 'BACKGROUND_TRANSPARENT';
+
+// Toast types
+export const INFO_TOAST = 'INFO_TOAST';
+export const SUCCESS_TOAST = 'SUCCESS_TOAST';
+export const WARNING_TOAST = 'WARNING_TOAST';
+export const DANGER_TOAST = 'DANGER_TOAST';
diff --git a/superset/assets/javascripts/dashboard/v2/util/dropOverflowsParent.js b/superset/assets/javascripts/dashboard/v2/util/dropOverflowsParent.js
new file mode 100644
index 0000000..0fd0c4e
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/dropOverflowsParent.js
@@ -0,0 +1,24 @@
+import { COLUMN_TYPE } from '../util/componentTypes';
+import { GRID_COLUMN_COUNT, NEW_COMPONENTS_SOURCE_ID } from './constants';
+import findParentId from './findParentId';
+import getChildWidth from './getChildWidth';
+import newComponentFactory from './newComponentFactory';
+
+export default function doesChildOverflowParent(dropResult, components) {
+  const { source, destination, dragging } = dropResult;
+  const isNewComponent = source.id === NEW_COMPONENTS_SOURCE_ID;
+
+  const grandparentId = findParentId({ childId: destination.id, components });
+
+  const child = isNewComponent ? newComponentFactory(dragging.type) : components[dragging.id] || {};
+  const parent = components[destination.id] || {};
+  const grandparent = components[grandparentId] || {};
+
+  const grandparentWidth = (grandparent.meta && grandparent.meta.width) || GRID_COLUMN_COUNT;
+  const parentWidth = (parent.meta && parent.meta.width) || grandparentWidth;
+  const parentChildWidth = parent.type === COLUMN_TYPE
+    ? 0 : getChildWidth({ id: destination.id, components });
+  const childWidth = (child.meta && child.meta.width) || 0;
+
+  return parentWidth - parentChildWidth < childWidth;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/util/getChildWidth.js b/superset/assets/javascripts/dashboard/v2/util/getChildWidth.js
index 516624d..aa32b96 100644
--- a/superset/assets/javascripts/dashboard/v2/util/getChildWidth.js
+++ b/superset/assets/javascripts/dashboard/v2/util/getChildWidth.js
@@ -1,4 +1,4 @@
-export default function getTotalChildWidth({ id, components, recurse = false }) {
+export default function getTotalChildWidth({ id, components }) {
   const component = components[id];
   if (!component) return 0;
 
@@ -7,9 +7,6 @@ export default function getTotalChildWidth({ id, components, recurse = false })
   (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 6a3bd0e..9605db2 100644
--- a/superset/assets/javascripts/dashboard/v2/util/getDropPosition.js
+++ b/superset/assets/javascripts/dashboard/v2/util/getDropPosition.js
@@ -6,6 +6,8 @@ export const DROP_RIGHT = 'DROP_RIGHT';
 export const DROP_BOTTOM = 'DROP_BOTTOM';
 export const DROP_LEFT = 'DROP_LEFT';
 
+// this defines how close the mouse must be to the edge of a component to display
+// a sibling type drop indicator
 const SIBLING_DROP_THRESHOLD = 15;
 
 export default function getDropPosition(monitor, Component) {
diff --git a/superset/assets/javascripts/dashboard/v2/util/isValidChild.js b/superset/assets/javascripts/dashboard/v2/util/isValidChild.js
index 9c6ae8e..66942f0 100644
--- a/superset/assets/javascripts/dashboard/v2/util/isValidChild.js
+++ b/superset/assets/javascripts/dashboard/v2/util/isValidChild.js
@@ -23,7 +23,6 @@ import {
   HEADER_TYPE,
   MARKDOWN_TYPE,
   ROW_TYPE,
-  SPACER_TYPE,
   TABS_TYPE,
   TAB_TYPE,
 } from './componentTypes';
@@ -48,7 +47,6 @@ const parentMaxDepthLookup = {
     [DIVIDER_TYPE]: depthOne,
     [HEADER_TYPE]: depthOne,
     [ROW_TYPE]: depthOne,
-    [SPACER_TYPE]: depthOne,
     [TABS_TYPE]: depthOne,
   },
 
@@ -56,7 +54,6 @@ const parentMaxDepthLookup = {
     [CHART_TYPE]: depthFour,
     [MARKDOWN_TYPE]: depthFour,
     [COLUMN_TYPE]: depthTwo,
-    [SPACER_TYPE]: depthFour,
   },
 
   [TABS_TYPE]: {
@@ -69,7 +66,6 @@ const parentMaxDepthLookup = {
     [DIVIDER_TYPE]: depthTwo,
     [HEADER_TYPE]: depthTwo,
     [ROW_TYPE]: depthTwo,
-    [SPACER_TYPE]: depthTwo,
     [TABS_TYPE]: depthTwo,
   },
 
@@ -78,7 +74,6 @@ const parentMaxDepthLookup = {
     [HEADER_TYPE]: depthThree,
     [MARKDOWN_TYPE]: depthThree,
     [ROW_TYPE]: depthThree,
-    [SPACER_TYPE]: depthThree,
   },
 
   // these have no valid children
@@ -86,11 +81,13 @@ const parentMaxDepthLookup = {
   [DIVIDER_TYPE]: {},
   [HEADER_TYPE]: {},
   [MARKDOWN_TYPE]: {},
-  [SPACER_TYPE]: {},
 };
 
 export default function isValidChild({ parentType, childType, parentDepth }) {
-  if (!parentType || !childType || typeof parentDepth !== 'number') return false;
+  if (!parentType || !childType || typeof parentDepth !== 'number') {
+    return false;
+  }
+
   const maxParentDepth = (parentMaxDepthLookup[parentType] || {})[childType];
 
   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 9bc01a7..af69eb8 100644
--- a/superset/assets/javascripts/dashboard/v2/util/newComponentFactory.js
+++ b/superset/assets/javascripts/dashboard/v2/util/newComponentFactory.js
@@ -5,7 +5,6 @@ import {
   HEADER_TYPE,
   MARKDOWN_TYPE,
   ROW_TYPE,
-  SPACER_TYPE,
   TABS_TYPE,
   TAB_TYPE,
 } from './componentTypes';
@@ -16,7 +15,7 @@ import {
 } from './constants';
 
 const typeToDefaultMetaData = {
-  [CHART_TYPE]: { width: 3, height: 15 },
+  [CHART_TYPE]: { width: 3, height: 30 },
   [COLUMN_TYPE]: { width: 3, background: BACKGROUND_TRANSPARENT },
   [DIVIDER_TYPE]: null,
   [HEADER_TYPE]: {
@@ -24,9 +23,8 @@ const typeToDefaultMetaData = {
     headerSize: MEDIUM_HEADER,
     background: BACKGROUND_TRANSPARENT,
   },
-  [MARKDOWN_TYPE]: { width: 3, height: 15 },
+  [MARKDOWN_TYPE]: { width: 3, height: 30 },
   [ROW_TYPE]: { background: BACKGROUND_TRANSPARENT },
-  [SPACER_TYPE]: {},
   [TABS_TYPE]: null,
   [TAB_TYPE]: { text: 'New Tab' },
 };
diff --git a/superset/assets/javascripts/dashboard/v2/util/newComponentIdToType.js b/superset/assets/javascripts/dashboard/v2/util/newComponentIdToType.js
deleted file mode 100644
index 38d1c7c..0000000
--- a/superset/assets/javascripts/dashboard/v2/util/newComponentIdToType.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import {
-  CHART_TYPE,
-  COLUMN_TYPE,
-  DIVIDER_TYPE,
-  HEADER_TYPE,
-  MARKDOWN_TYPE,
-  ROW_TYPE,
-  SPACER_TYPE,
-  TABS_TYPE,
-  TAB_TYPE,
-} from './componentTypes';
-
-import {
-  NEW_CHART_ID,
-  NEW_COLUMN_ID,
-  NEW_DIVIDER_ID,
-  NEW_HEADER_ID,
-  NEW_MARKDOWN_ID,
-  NEW_ROW_ID,
-  NEW_SPACER_ID,
-  NEW_TABS_ID,
-  NEW_TAB_ID,
-} from './constants';
-
-export default {
-  [NEW_CHART_ID]: CHART_TYPE, // @TODO we will have to encode real chart ids => type in the future
-  [NEW_COLUMN_ID]: COLUMN_TYPE,
-  [NEW_DIVIDER_ID]: DIVIDER_TYPE,
-  [NEW_HEADER_ID]: HEADER_TYPE,
-  [NEW_MARKDOWN_ID]: MARKDOWN_TYPE,
-  [NEW_ROW_ID]: ROW_TYPE,
-  [NEW_SPACER_ID]: SPACER_TYPE,
-  [NEW_TABS_ID]: TABS_TYPE,
-  [NEW_TAB_ID]: TAB_TYPE,
-};
diff --git a/superset/assets/javascripts/dashboard/v2/util/propShapes.jsx b/superset/assets/javascripts/dashboard/v2/util/propShapes.jsx
index d701cc2..8acc192 100644
--- a/superset/assets/javascripts/dashboard/v2/util/propShapes.jsx
+++ b/superset/assets/javascripts/dashboard/v2/util/propShapes.jsx
@@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
 import componentTypes from './componentTypes';
 import backgroundStyleOptions from './backgroundStyleOptions';
 import headerStyleOptions from './headerStyleOptions';
+import { INFO_TOAST, SUCCESS_TOAST, WARNING_TOAST, DANGER_TOAST } from './constants';
 
 export const componentShape = PropTypes.shape({ // eslint-disable-line
   id: PropTypes.string.isRequired,
@@ -22,3 +23,9 @@ export const componentShape = PropTypes.shape({ // eslint-disable-line
     background: PropTypes.oneOf(backgroundStyleOptions.map(opt => opt.value)),
   }),
 });
+
+export const toastShape = PropTypes.shape({
+  id: PropTypes.string.isRequired,
+  toastType: PropTypes.oneOf([INFO_TOAST, SUCCESS_TOAST, WARNING_TOAST, DANGER_TOAST]).isRequired,
+  text: PropTypes.string.isRequired,
+});
diff --git a/superset/assets/stylesheets/superset.less b/superset/assets/stylesheets/superset.less
index a7a69b1..84089d0 100644
--- a/superset/assets/stylesheets/superset.less
+++ b/superset/assets/stylesheets/superset.less
@@ -233,32 +233,37 @@ table.table-no-hover tr:hover {
   border: none;
   box-shadow: none;
   padding: 0;
+  cursor: initial;
 }
 
 .editable-title input[type="button"] {
-    border-color: transparent;
-    background: transparent;
-    font-size: inherit;
-    line-height: inherit;
-    white-space: normal;
-    text-align: left;
+  border-color: transparent;
+  background: transparent;
+  font-size: inherit;
+  line-height: inherit;
+  white-space: normal;
+  text-align: left;
 }
 
-.editable-title--editable input[type="button"]:hover {
-    cursor: text;
+.editable-title.editable-title--editable {
+  cursor: pointer;
+}
+
+.editable-title.editable-title--editing {
+  cursor: text;
 }
 
 .m-r-5 {
-    margin-right: 5px;
+  margin-right: 5px;
 }
 .m-r-3 {
-    margin-right: 3px;
+  margin-right: 3px;
 }
 .m-t-5 {
-    margin-top: 5px;
+  margin-top: 5px;
 }
 .m-t-10 {
-    margin-top: 10px;
+  margin-top: 10px;
 }
 .m-b-10 {
     margin-bottom: 10px;

-- 
To stop receiving notification emails like this one, please contact
ccwilliams@apache.org.

Mime
View raw message