You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@superset.apache.org by GitBox <gi...@apache.org> on 2018/04/24 17:51:40 UTC

[GitHub] graceguo-supercat closed pull request #4839: Dashboard builder: fixed 1st round comments

graceguo-supercat closed pull request #4839: Dashboard builder: fixed 1st round comments
URL: https://github.com/apache/incubator-superset/pull/4839
 
 
   

This is a PR merged from a forked repository.
As GitHub hides the original diff on merge, it is displayed below for
the sake of provenance:

As this is a foreign pull request (from a fork), the diff is supplied
below (as it won't show otherwise due to GitHub magic):

diff --git a/superset/assets/javascripts/chart/Chart.jsx b/superset/assets/javascripts/chart/Chart.jsx
index 54736e53cb..7af61a4eb6 100644
--- a/superset/assets/javascripts/chart/Chart.jsx
+++ b/superset/assets/javascripts/chart/Chart.jsx
@@ -17,7 +17,7 @@ import './chart.css';
 const propTypes = {
   annotationData: PropTypes.object,
   actions: PropTypes.object,
-  chartKey: PropTypes.string.isRequired,
+  chartId: PropTypes.number.isRequired,
   containerId: PropTypes.string.isRequired,
   datasource: PropTypes.object.isRequired,
   formData: PropTypes.object.isRequired,
@@ -42,7 +42,6 @@ const propTypes = {
   // dashboard callbacks
   addFilter: PropTypes.func,
   getFilters: PropTypes.func,
-  clearFilter: PropTypes.func,
   removeFilter: PropTypes.func,
   onQuery: PropTypes.func,
   onDismissRefreshOverlay: PropTypes.func,
@@ -51,7 +50,6 @@ const propTypes = {
 const defaultProps = {
   addFilter: () => ({}),
   getFilters: () => ({}),
-  clearFilter: () => ({}),
   removeFilter: () => ({}),
 };
 
@@ -67,7 +65,6 @@ class Chart extends React.PureComponent {
     this.datasource = props.datasource;
     this.addFilter = this.addFilter.bind(this);
     this.getFilters = this.getFilters.bind(this);
-    this.clearFilter = this.clearFilter.bind(this);
     this.removeFilter = this.removeFilter.bind(this);
     this.headerHeight = this.headerHeight.bind(this);
     this.height = this.height.bind(this);
@@ -78,7 +75,7 @@ class Chart extends React.PureComponent {
     if (this.props.triggerQuery) {
       this.props.actions.runQuery(this.props.formData, false,
         this.props.timeout,
-        this.props.chartKey,
+        this.props.chartId,
       );
     }
   }
@@ -118,10 +115,6 @@ class Chart extends React.PureComponent {
     this.props.addFilter(col, vals, merge, refresh);
   }
 
-  clearFilter() {
-    this.props.clearFilter();
-  }
-
   removeFilter(col, vals, refresh = true) {
     this.props.removeFilter(col, vals, refresh);
   }
@@ -192,22 +185,22 @@ class Chart extends React.PureComponent {
       // [re]rendering the visualization
       viz(this, qr, this.props.setControlValue);
       Logger.append(LOG_ACTIONS_RENDER_EVENT, {
-        label: this.props.chartKey,
+        label: 'slice_' + this.props.chartId,
         vis_type: this.props.vizType,
         start_offset: renderStart,
         duration: Logger.getTimestamp() - renderStart,
       });
-      this.props.actions.chartRenderingSucceeded(this.props.chartKey);
+      this.props.actions.chartRenderingSucceeded(this.props.chartId);
     } catch (e) {
       console.error(e);  // eslint-disable-line
-      this.props.actions.chartRenderingFailed(e, this.props.chartKey);
+      this.props.actions.chartRenderingFailed(e, this.props.chartId);
     }
   }
 
   render() {
     const isLoading = this.props.chartStatus === 'loading';
     return (
-      <div className={`token col-md-12 ${isLoading ? 'is-loading' : ''}`}>
+      <div className={`${isLoading ? 'is-loading' : ''}`}>
         {this.renderTooltip()}
         {isLoading &&
           <Loading size={25} />
diff --git a/superset/assets/javascripts/chart/ChartContainer.jsx b/superset/assets/javascripts/chart/ChartContainer.jsx
index b731412fc5..e3cb1f97e5 100644
--- a/superset/assets/javascripts/chart/ChartContainer.jsx
+++ b/superset/assets/javascripts/chart/ChartContainer.jsx
@@ -5,7 +5,7 @@ import * as Actions from './chartAction';
 import Chart from './Chart';
 
 function mapStateToProps({ charts }, ownProps) {
-  const chart = charts[ownProps.chartKey];
+  const chart = charts[ownProps.chartId];
   return {
     annotationData: chart.annotationData,
     chartAlert: chart.chartAlert,
diff --git a/superset/assets/javascripts/chart/chartAction.js b/superset/assets/javascripts/chart/chartAction.js
index b9338a91f5..1d420d54bd 100644
--- a/superset/assets/javascripts/chart/chartAction.js
+++ b/superset/assets/javascripts/chart/chartAction.js
@@ -111,6 +111,11 @@ export function updateQueryFormData(value, key) {
   return { type: UPDATE_QUERY_FORM_DATA, value, key };
 }
 
+export const ADD_CHART = 'ADD_CHART';
+export function addChart(chart, key) {
+  return { type: ADD_CHART, chart, key };
+}
+
 export const RUN_QUERY = 'RUN_QUERY';
 export function runQuery(formData, force = false, timeout = 60, key) {
   return (dispatch) => {
@@ -133,7 +138,7 @@ export function runQuery(formData, force = false, timeout = 60, key) {
       .then(() => queryRequest)
       .then((queryResponse) => {
         Logger.append(LOG_ACTIONS_LOAD_EVENT, {
-          label: key,
+          label: 'slice_' + key,
           is_cached: queryResponse.is_cached,
           row_count: queryResponse.rowcount,
           datasource: formData.datasource,
@@ -180,3 +185,10 @@ export function runQuery(formData, force = false, timeout = 60, key) {
     ]);
   };
 }
+
+export function refreshChart(chart, force, timeout) {
+  return dispatch => (
+    dispatch(runQuery(chart.latestQueryFormData, force, timeout, chart.id))
+  );
+}
+
diff --git a/superset/assets/javascripts/chart/chartReducer.js b/superset/assets/javascripts/chart/chartReducer.js
index f68a5b80ee..21473186c7 100644
--- a/superset/assets/javascripts/chart/chartReducer.js
+++ b/superset/assets/javascripts/chart/chartReducer.js
@@ -1,25 +1,10 @@
 /* eslint camelcase: 0 */
-import PropTypes from 'prop-types';
-
 import { now } from '../modules/dates';
 import * as actions from './chartAction';
 import { t } from '../locales';
 
-export const chartPropType = {
-  chartKey: PropTypes.string.isRequired,
-  chartAlert: PropTypes.string,
-  chartStatus: PropTypes.string,
-  chartUpdateEndTime: PropTypes.number,
-  chartUpdateStartTime: PropTypes.number,
-  latestQueryFormData: PropTypes.object,
-  queryRequest: PropTypes.object,
-  queryResponse: PropTypes.object,
-  triggerQuery: PropTypes.bool,
-  lastRendered: PropTypes.number,
-};
-
 export const chart = {
-  chartKey: '',
+  id: 0,
   chartAlert: null,
   chartStatus: 'loading',
   chartUpdateEndTime: null,
@@ -33,6 +18,12 @@ export const chart = {
 
 export default function chartReducer(charts = {}, action) {
   const actionHandlers = {
+    [actions.ADD_CHART]() {
+      return {
+        ...chart,
+        ...action.chart,
+      };
+    },
     [actions.CHART_UPDATE_SUCCEEDED](state) {
       return { ...state,
         chartStatus: 'success',
diff --git a/superset/assets/javascripts/components/EditableTitle.jsx b/superset/assets/javascripts/components/EditableTitle.jsx
index b773340846..45fea1dcb0 100644
--- a/superset/assets/javascripts/components/EditableTitle.jsx
+++ b/superset/assets/javascripts/components/EditableTitle.jsx
@@ -1,5 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import cx from 'classnames';
 import TooltipWrapper from './TooltipWrapper';
 import { t } from '../locales';
 
@@ -27,8 +28,10 @@ class EditableTitle extends React.PureComponent {
     this.handleClick = this.handleClick.bind(this);
     this.handleBlur = this.handleBlur.bind(this);
     this.handleChange = this.handleChange.bind(this);
+    this.handleKeyUp = this.handleKeyUp.bind(this);
     this.handleKeyPress = this.handleKeyPress.bind(this);
   }
+
   componentWillReceiveProps(nextProps) {
     if (nextProps.title !== this.state.title) {
       this.setState({
@@ -37,8 +40,9 @@ class EditableTitle extends React.PureComponent {
       });
     }
   }
+
   handleClick() {
-    if (!this.props.canEdit) {
+    if (!this.props.canEdit || this.state.isEditing) {
       return;
     }
 
@@ -46,6 +50,7 @@ class EditableTitle extends React.PureComponent {
       isEditing: true,
     });
   }
+
   handleBlur() {
     if (!this.props.canEdit) {
       return;
@@ -67,9 +72,31 @@ class EditableTitle extends React.PureComponent {
       this.setState({
         lastTitle: this.state.title,
       });
+    }
+
+    if (this.props.title !== this.state.title) {
       this.props.onSaveTitle(this.state.title);
     }
   }
+
+  handleKeyUp(ev) {
+    // this entire method exists to support using EditableTitle as the title of a
+    // react-bootstrap Tab, as a workaround for this line in react-bootstrap https://goo.gl/ZVLmv4
+    //
+    // tl;dr when a Tab EditableTitle is being edited, typically the Tab it's within has been
+    // clicked and is focused/active. for accessibility, when focused the Tab <a /> intercepts
+    // the ' ' key (among others, including all arrows) and onChange() doesn't fire. somehow
+    // keydown is still called so we can detect this and manually add a ' ' to the current title
+    if (ev.key === ' ') {
+      let title = ev.target.value;
+      const titleLength = (title || '').length;
+      if (title && title[titleLength - 1] !== ' ') {
+        title = `${title} `;
+        this.setState(() => ({ title }));
+      }
+    }
+  }
+
   handleChange(ev) {
     if (!this.props.canEdit) {
       return;
@@ -79,6 +106,7 @@ class EditableTitle extends React.PureComponent {
       title: ev.target.value,
     });
   }
+
   handleKeyPress(ev) {
     if (ev.key === 'Enter') {
       ev.preventDefault();
@@ -86,12 +114,14 @@ class EditableTitle extends React.PureComponent {
       this.handleBlur();
     }
   }
+
   render() {
-    let input = (
+    let content = (
       <input
         required
         type={this.state.isEditing ? 'text' : 'button'}
         value={this.state.title}
+        onKeyUp={this.handleKeyUp}
         onChange={this.handleChange}
         onBlur={this.handleBlur}
         onClick={this.handleClick}
@@ -99,18 +129,26 @@ class EditableTitle extends React.PureComponent {
       />
     );
     if (this.props.showTooltip) {
-      input = (
+      content = (
         <TooltipWrapper
           label="title"
           tooltip={this.props.canEdit ? t('click to edit title') :
               this.props.noPermitTooltip || t('You don\'t have the rights to alter this title.')}
         >
-          {input}
+          {content}
         </TooltipWrapper>
       );
     }
     return (
-      <span className="editable-title">{input}</span>
+      <span
+        className={cx(
+          'editable-title',
+          this.props.canEdit && 'editable-title--editable',
+          this.state.isEditing && 'editable-title--editing',
+        )}
+      >
+        {content}
+      </span>
     );
   }
 }
diff --git a/superset/assets/javascripts/dashboard/actions.js b/superset/assets/javascripts/dashboard/actions.js
deleted file mode 100644
index c7f1a6aa26..0000000000
--- a/superset/assets/javascripts/dashboard/actions.js
+++ /dev/null
@@ -1,127 +0,0 @@
-/* global notify */
-import $ from 'jquery';
-import { getExploreUrlAndPayload } from '../explore/exploreUtils';
-
-export const ADD_FILTER = 'ADD_FILTER';
-export function addFilter(sliceId, col, vals, merge = true, refresh = true) {
-  return { type: ADD_FILTER, sliceId, col, vals, merge, refresh };
-}
-
-export const CLEAR_FILTER = 'CLEAR_FILTER';
-export function clearFilter(sliceId) {
-  return { type: CLEAR_FILTER, sliceId };
-}
-
-export const REMOVE_FILTER = 'REMOVE_FILTER';
-export function removeFilter(sliceId, col, vals, refresh = true) {
-  return { type: REMOVE_FILTER, sliceId, col, vals, refresh };
-}
-
-export const UPDATE_DASHBOARD_LAYOUT = 'UPDATE_DASHBOARD_LAYOUT';
-export function updateDashboardLayout(layout) {
-  return { type: UPDATE_DASHBOARD_LAYOUT, layout };
-}
-
-export const UPDATE_DASHBOARD_TITLE = 'UPDATE_DASHBOARD_TITLE';
-export function updateDashboardTitle(title) {
-  return { type: UPDATE_DASHBOARD_TITLE, title };
-}
-
-export function addSlicesToDashboard(dashboardId, sliceIds) {
-  return () => (
-    $.ajax({
-      type: 'POST',
-      url: `/superset/add_slices/${dashboardId}/`,
-      data: {
-        data: JSON.stringify({ slice_ids: sliceIds }),
-      },
-    })
-      .done(() => {
-        // Refresh page to allow for slices to re-render
-        window.location.reload();
-      })
-  );
-}
-
-export const REMOVE_SLICE = 'REMOVE_SLICE';
-export function removeSlice(slice) {
-  return { type: REMOVE_SLICE, slice };
-}
-
-export const UPDATE_SLICE_NAME = 'UPDATE_SLICE_NAME';
-export function updateSliceName(slice, sliceName) {
-  return { type: UPDATE_SLICE_NAME, slice, sliceName };
-}
-export function saveSlice(slice, sliceName) {
-  const oldName = slice.slice_name;
-  return (dispatch) => {
-    const sliceParams = {};
-    sliceParams.slice_id = slice.slice_id;
-    sliceParams.action = 'overwrite';
-    sliceParams.slice_name = sliceName;
-
-    const { url, payload } = getExploreUrlAndPayload({
-      formData: slice.form_data,
-      endpointType: 'base',
-      force: false,
-      curUrl: null,
-      requestParams: sliceParams,
-    });
-    return $.ajax({
-      url,
-      type: 'POST',
-      data: {
-        form_data: JSON.stringify(payload),
-      },
-      success: () => {
-        dispatch(updateSliceName(slice, sliceName));
-        notify.success('This slice name was saved successfully.');
-      },
-      error: () => {
-        // if server-side reject the overwrite action,
-        // revert to old state
-        dispatch(updateSliceName(slice, oldName));
-        notify.error("You don't have the rights to alter this slice");
-      },
-    });
-  };
-}
-
-const FAVESTAR_BASE_URL = '/superset/favstar/Dashboard';
-export const TOGGLE_FAVE_STAR = 'TOGGLE_FAVE_STAR';
-export function toggleFaveStar(isStarred) {
-  return { type: TOGGLE_FAVE_STAR, isStarred };
-}
-
-export const FETCH_FAVE_STAR = 'FETCH_FAVE_STAR';
-export function fetchFaveStar(id) {
-  return function (dispatch) {
-    const url = `${FAVESTAR_BASE_URL}/${id}/count`;
-    return $.get(url)
-      .done((data) => {
-        if (data.count > 0) {
-          dispatch(toggleFaveStar(true));
-        }
-      });
-  };
-}
-
-export const SAVE_FAVE_STAR = 'SAVE_FAVE_STAR';
-export function saveFaveStar(id, isStarred) {
-  return function (dispatch) {
-    const urlSuffix = isStarred ? 'unselect' : 'select';
-    const url = `${FAVESTAR_BASE_URL}/${id}/${urlSuffix}/`;
-    $.get(url);
-    dispatch(toggleFaveStar(!isStarred));
-  };
-}
-
-export const TOGGLE_EXPAND_SLICE = 'TOGGLE_EXPAND_SLICE';
-export function toggleExpandSlice(slice, isExpanded) {
-  return { type: TOGGLE_EXPAND_SLICE, slice, isExpanded };
-}
-
-export const SET_EDIT_MODE = 'SET_EDIT_MODE';
-export function setEditMode(editMode) {
-  return { type: SET_EDIT_MODE, editMode };
-}
diff --git a/superset/assets/javascripts/dashboard/actions/dashboard.js b/superset/assets/javascripts/dashboard/actions/dashboard.js
new file mode 100644
index 0000000000..cce332887b
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/actions/dashboard.js
@@ -0,0 +1,165 @@
+import $ from 'jquery';
+
+import { addChart, removeChart, refreshChart } from '../../chart/chartAction';
+import { chart as initChart } from '../../chart/chartReducer';
+import { fetchDatasourceMetadata } from '../../dashboard/actions/datasources';
+import { applyDefaultFormData } from '../../explore/stores/store';
+
+export const ADD_FILTER = 'ADD_FILTER';
+export function addFilter(chart, col, vals, merge = true, refresh = true) {
+  return { type: ADD_FILTER, chart, col, vals, merge, refresh };
+}
+
+export const REMOVE_FILTER = 'REMOVE_FILTER';
+export function removeFilter(sliceId, col, vals, refresh = true) {
+  return { type: REMOVE_FILTER, sliceId, col, vals, refresh };
+}
+
+export const UPDATE_DASHBOARD_TITLE = 'UPDATE_DASHBOARD_TITLE';
+export function updateDashboardTitle(title) {
+  return { type: UPDATE_DASHBOARD_TITLE, title };
+}
+
+export const ADD_SLICE = 'ADD_SLICE';
+export function addSlice(slice) {
+  return { type: ADD_SLICE, slice };
+}
+
+export const REMOVE_SLICE = 'REMOVE_SLICE';
+export function removeSlice(sliceId) {
+  return { type: REMOVE_SLICE, sliceId };
+}
+
+const FAVESTAR_BASE_URL = '/superset/favstar/Dashboard';
+export const TOGGLE_FAVE_STAR = 'TOGGLE_FAVE_STAR';
+export function toggleFaveStar(isStarred) {
+  return { type: TOGGLE_FAVE_STAR, isStarred };
+}
+
+export const FETCH_FAVE_STAR = 'FETCH_FAVE_STAR';
+export function fetchFaveStar(id) {
+  return function (dispatch) {
+    const url = `${FAVESTAR_BASE_URL}/${id}/count`;
+    return $.get(url)
+      .done((data) => {
+        if (data.count > 0) {
+          dispatch(toggleFaveStar(true));
+        }
+      });
+  };
+}
+
+export const SAVE_FAVE_STAR = 'SAVE_FAVE_STAR';
+export function saveFaveStar(id, isStarred) {
+  return function (dispatch) {
+    const urlSuffix = isStarred ? 'unselect' : 'select';
+    const url = `${FAVESTAR_BASE_URL}/${id}/${urlSuffix}/`;
+    $.get(url);
+    dispatch(toggleFaveStar(!isStarred));
+  };
+}
+
+export const TOGGLE_EXPAND_SLICE = 'TOGGLE_EXPAND_SLICE';
+export function toggleExpandSlice(slice, isExpanded) {
+  return { type: TOGGLE_EXPAND_SLICE, slice, isExpanded };
+}
+
+export const SET_EDIT_MODE = 'SET_EDIT_MODE';
+export function setEditMode(editMode) {
+  return { type: SET_EDIT_MODE, editMode };
+}
+
+export const ON_CHANGE = 'ON_CHANGE';
+export function onChange() {
+  return { type: ON_CHANGE };
+}
+
+export const ON_SAVE = 'ON_SAVE';
+export function onSave() {
+  return { type: ON_SAVE };
+}
+
+let refreshTimer = null;
+export function startPeriodicRender(interval) {
+  const stopPeriodicRender = () => {
+    if (refreshTimer) {
+      clearTimeout(refreshTimer);
+      refreshTimer = null;
+    }
+  };
+
+  return (dispatch, getState) => {
+    stopPeriodicRender();
+
+    const { metadata } = getState().dashboardInfo;
+    const immune = metadata.timed_refresh_immune_slices || [];
+    const refreshAll = () => {
+      const affected =
+        Object.values(getState().charts)
+          .filter(chart => immune.indexOf(chart.id) === -1);
+      return dispatch(fetchCharts(affected, true, interval * 0.2));
+    };
+    const fetchAndRender = () => {
+      refreshAll();
+      if (interval > 0) {
+        refreshTimer = setTimeout(fetchAndRender, interval);
+      }
+    };
+
+    fetchAndRender();
+  }
+}
+
+export function fetchCharts(chartList = [], force = false, interval = 0) {
+  return (dispatch, getState) => {
+    const timeout = getState().dashboardInfo.common.conf.SUPERSET_WEBSERVER_TIMEOUT;
+    if (!interval) {
+      chartList.forEach(chart => (dispatch(refreshChart(chart, force, timeout))));
+      return;
+    }
+
+    const { metadata: meta } = getState().dashboardInfo;
+    const refreshTime = Math.max(interval, meta.stagger_time || 5000); // default 5 seconds
+    if (typeof meta.stagger_refresh !== 'boolean') {
+      meta.stagger_refresh = meta.stagger_refresh === undefined ?
+        true : meta.stagger_refresh === 'true';
+    }
+    const delay = meta.stagger_refresh ? refreshTime / (chartList.length - 1) : 0;
+    chartList.forEach((chart, i) => {
+      setTimeout(() => dispatch(refreshChart(chart, force, timeout)), delay * i);
+    });
+  }
+}
+
+export const TOGGLE_BUILDER_PANE = 'TOGGLE_BUILDER_PANE';
+export function toggleBuilderPane() {
+  return { type: TOGGLE_BUILDER_PANE };
+}
+
+export function addSliceToDashboard(id) {
+  return (dispatch, getState) => {
+    const { sliceEntities } = getState();
+    const selectedSlice = sliceEntities.slices[id];
+    const form_data = selectedSlice.form_data;
+    const newChart = {
+      ...initChart,
+      id,
+      form_data,
+      formData: applyDefaultFormData(form_data),
+    };
+
+    return Promise
+      .all([
+        dispatch(addChart(newChart, id)),
+        dispatch(fetchDatasourceMetadata(form_data.datasource)),
+      ])
+      .then(() => dispatch(addSlice(selectedSlice)));
+  };
+}
+
+export function removeSliceFromDashboard(chart) {
+  return (dispatch) => {
+    dispatch(removeSlice(chart.id));
+    dispatch(removeChart(chart.id));
+  };
+}
diff --git a/superset/assets/javascripts/dashboard/actions/datasources.js b/superset/assets/javascripts/dashboard/actions/datasources.js
new file mode 100644
index 0000000000..c7a0caae98
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/actions/datasources.js
@@ -0,0 +1,35 @@
+import $ from 'jquery';
+
+export const SET_DATASOURCE = 'SET_DATASOURCE';
+export function setDatasource(datasource, key) {
+  return { type: SET_DATASOURCE, datasource, key };
+}
+
+export const FETCH_DATASOURCE_STARTED = 'FETCH_DATASOURCE_STARTED';
+export function fetchDatasourceStarted(key) {
+  return { type: FETCH_DATASOURCE_STARTED, key };
+}
+
+export const FETCH_DATASOURCE_FAILED = 'FETCH_DATASOURCE_FAILED';
+export function fetchDatasourceFailed(error, key) {
+  return { type: FETCH_DATASOURCE_FAILED, error, key };
+}
+
+export function fetchDatasourceMetadata(key) {
+  return (dispatch, getState) => {
+    const { datasources } = getState();
+    const datasource = datasources[key];
+
+    if (datasource) {
+      return dispatch(setDatasource(datasource, key))
+    } else {
+      const url = `/superset/fetch_datasource_metadata?datasourceKey=${key}`;
+      return $.ajax({
+        type: 'GET',
+        url,
+        success: datasource => dispatch(setDatasource(datasource, key)),
+        error: error => dispatch(fetchDatasourceFailed(error.responseJSON.error, key)),
+      });
+    }
+  };
+}
diff --git a/superset/assets/javascripts/dashboard/actions/sliceEntities.js b/superset/assets/javascripts/dashboard/actions/sliceEntities.js
new file mode 100644
index 0000000000..a5cb737b16
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/actions/sliceEntities.js
@@ -0,0 +1,91 @@
+/* global notify */
+import $ from 'jquery';
+
+export const UPDATE_SLICE_NAME = 'UPDATE_SLICE_NAME';
+export function updateSliceName(key, sliceName) {
+  return { type: UPDATE_SLICE_NAME, key, sliceName };
+}
+
+export function saveSliceName(slice, sliceName) {
+  const oldName = slice.slice_name;
+  return (dispatch) => {
+    const sliceParams = {};
+    sliceParams.slice_id = slice.slice_id;
+    sliceParams.action = 'overwrite';
+    sliceParams.slice_name = sliceName;
+
+    const url = slice.slice_url + '&' +
+      Object.keys(sliceParams)
+      .map(key => (key + '=' + sliceParams[key]))
+      .join('&');
+    const key = slice.slice_id;
+    return $.ajax({
+      url,
+      type: 'POST',
+      success: () => {
+        dispatch(updateSliceName(key, sliceName));
+        notify.success('This slice name was saved successfully.');
+      },
+      error: () => {
+        // if server-side reject the overwrite action,
+        // revert to old state
+        dispatch(updateSliceName(key, oldName));
+        notify.error("You don't have the rights to alter this slice");
+      },
+    });
+  };
+}
+
+export const SET_ALL_SLICES = 'SET_ALL_SLICES';
+export function setAllSlices(slices) {
+  return { type: SET_ALL_SLICES, slices };
+}
+
+export const FETCH_ALL_SLICES_STARTED = 'FETCH_ALL_SLICES_STARTED';
+export function fetchAllSlicesStarted() {
+  return { type: FETCH_ALL_SLICES_STARTED };
+}
+
+export const FETCH_ALL_SLICES_FAILED = 'FETCH_ALL_SLICES_FAILED';
+export function fetchAllSlicesFailed(error) {
+  return { type: FETCH_ALL_SLICES_FAILED, error };
+}
+
+export function fetchAllSlices(userId) {
+  return (dispatch, getState) => {
+    const { sliceEntities }  = getState();
+    if (sliceEntities.lastUpdated === 0) {
+      dispatch(fetchAllSlicesStarted());
+
+      const uri = `/sliceaddview/api/read?_flt_0_created_by=${userId}`;
+      return $.ajax({
+        url: uri,
+        type: 'GET',
+        success: response => {
+          const slices = {};
+          response.result.forEach(slice => {
+            const form_data = JSON.parse(slice.params);
+            slices[slice.id] = {
+              slice_id: slice.id,
+              slice_url: slice.slice_url,
+              slice_name: slice.slice_name,
+              edit_url: slice.edit_url,
+              form_data,
+              datasource: form_data.datasource,
+              datasource_name: slice.datasource_name_text,
+              datasource_link: slice.datasource_link,
+              changed_on: new Date(slice.changed_on).getTime(),
+              description: slice.description,
+              description_markdown: slice.description_markeddown,
+              viz_type: slice.viz_type,
+              modified: slice.modified,
+            }});
+          return dispatch(setAllSlices(slices));
+        },
+        error: error => dispatch(fetchAllSlicesFailed(error)),
+      });
+    } else {
+      return dispatch(setAllSlices(sliceEntities.slices));
+    }
+  };
+}
diff --git a/superset/assets/javascripts/dashboard/components/ActionMenuItem.jsx b/superset/assets/javascripts/dashboard/components/ActionMenuItem.jsx
new file mode 100644
index 0000000000..0a0545956b
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/components/ActionMenuItem.jsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { MenuItem } from 'react-bootstrap';
+
+import InfoTooltipWithTrigger from '../../components/InfoTooltipWithTrigger';
+
+export function MenuItemContent({ faIcon, text, tooltip, children }) {
+  return (
+    <span>
+      {faIcon &&
+        <i className={`fa fa-${faIcon}`}>&nbsp;</i>
+      }
+      {text} {''}
+      <InfoTooltipWithTrigger
+        tooltip={tooltip}
+        label={faIcon ? `dash-${faIcon}`: ''}
+        placement="top"
+      />
+      {children}
+    </span>
+  );
+}
+MenuItemContent.propTypes = {
+  faIcon: PropTypes.string,
+  text: PropTypes.string,
+  tooltip: PropTypes.string,
+  children: PropTypes.node,
+};
+
+export function ActionMenuItem(props) {
+  return (
+    <MenuItem
+      onClick={props.onClick}
+      href={props.href}
+      target={props.target}
+    >
+      <MenuItemContent {...props} />
+    </MenuItem>
+  );
+}
+ActionMenuItem.propTypes = {
+  onClick: PropTypes.func,
+};
\ No newline at end of file
diff --git a/superset/assets/javascripts/dashboard/components/Controls.jsx b/superset/assets/javascripts/dashboard/components/Controls.jsx
index 00cb6d56dc..8755e8f74d 100644
--- a/superset/assets/javascripts/dashboard/components/Controls.jsx
+++ b/superset/assets/javascripts/dashboard/components/Controls.jsx
@@ -1,69 +1,34 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import { DropdownButton, MenuItem } from 'react-bootstrap';
+import $ from 'jquery';
+import { DropdownButton } from 'react-bootstrap';
 
-import CssEditor from './CssEditor';
 import RefreshIntervalModal from './RefreshIntervalModal';
 import SaveModal from './SaveModal';
-import SliceAdder from './SliceAdder';
+import { ActionMenuItem, MenuItemContent } from './ActionMenuItem';
 import { t } from '../../locales';
-import InfoTooltipWithTrigger from '../../components/InfoTooltipWithTrigger';
-
-const $ = window.$ = require('jquery');
 
 const propTypes = {
-  dashboard: PropTypes.object.isRequired,
+  dashboardInfo: PropTypes.object.isRequired,
+  dashboardTitle: PropTypes.string.isRequired,
+  layout: PropTypes.object.isRequired,
   filters: PropTypes.object.isRequired,
+  expandedSlices: PropTypes.object.isRequired,
   slices: PropTypes.array,
-  userId: PropTypes.string.isRequired,
-  addSlicesToDashboard: PropTypes.func,
   onSave: PropTypes.func,
   onChange: PropTypes.func,
-  renderSlices: PropTypes.func,
-  serialize: PropTypes.func,
+  forceRefreshAllCharts: PropTypes.func,
   startPeriodicRender: PropTypes.func,
   editMode: PropTypes.bool,
 };
 
-function MenuItemContent({ faIcon, text, tooltip, children }) {
-  return (
-    <span>
-      <i className={`fa fa-${faIcon}`} /> {text} {''}
-      <InfoTooltipWithTrigger
-        tooltip={tooltip}
-        label={`dash-${faIcon}`}
-        placement="top"
-      />
-      {children}
-    </span>
-  );
-}
-MenuItemContent.propTypes = {
-  faIcon: PropTypes.string.isRequired,
-  text: PropTypes.string,
-  tooltip: PropTypes.string,
-  children: PropTypes.node,
-};
-
-function ActionMenuItem(props) {
-  return (
-    <MenuItem onClick={props.onClick}>
-      <MenuItemContent {...props} />
-    </MenuItem>
-  );
-}
-ActionMenuItem.propTypes = {
-  onClick: PropTypes.func,
-};
-
 class Controls extends React.PureComponent {
   constructor(props) {
     super(props);
     this.state = {
-      css: props.dashboard.css || '',
+      css: '',
       cssTemplates: [],
     };
-    this.refresh = this.refresh.bind(this);
     this.toggleModal = this.toggleModal.bind(this);
     this.updateDom = this.updateDom.bind(this);
   }
@@ -79,10 +44,6 @@ class Controls extends React.PureComponent {
       this.setState({ cssTemplates });
     });
   }
-  refresh() {
-    // Force refresh all slices
-    this.props.renderSlices(true);
-  }
   toggleModal(modal) {
     let currentModal;
     if (modal !== this.state.currentModal) {
@@ -114,12 +75,12 @@ class Controls extends React.PureComponent {
     }
   }
   render() {
-    const { dashboard, userId, filters,
-      addSlicesToDashboard, startPeriodicRender,
-      serialize, onSave, editMode } = this.props;
+    const { dashboardTitle, layout, filters, expandedSlices,
+      startPeriodicRender, forceRefreshAllCharts, onSave,
+      editMode } = this.props;
     const emailBody = t('Checkout this dashboard: %s', window.location.href);
     const emailLink = 'mailto:?Subject=Superset%20Dashboard%20'
-      + `${dashboard.dashboard_title}&Body=${emailBody}`;
+      + `${dashboardTitle}&Body=${emailBody}`;
     let saveText = t('Save as');
     if (editMode) {
       saveText = t('Save');
@@ -130,8 +91,7 @@ class Controls extends React.PureComponent {
           <ActionMenuItem
             text={t('Force Refresh')}
             tooltip={t('Force refresh the whole dashboard')}
-            faIcon="refresh"
-            onClick={this.refresh}
+            onClick={forceRefreshAllCharts}
           />
           <RefreshIntervalModal
             onChange={refreshInterval => startPeriodicRender(refreshInterval * 1000)}
@@ -139,21 +99,21 @@ class Controls extends React.PureComponent {
               <MenuItemContent
                 text={t('Set autorefresh')}
                 tooltip={t('Set the auto-refresh interval for this session')}
-                faIcon="clock-o"
               />
             }
           />
           <SaveModal
-            dashboard={dashboard}
+            dashboardId={this.props.dashboardInfo.id}
+            dashboardTitle={dashboardTitle}
+            layout={layout}
             filters={filters}
-            serialize={serialize}
+            expandedSlices={expandedSlices}
             onSave={onSave}
             css={this.state.css}
             triggerNode={
               <MenuItemContent
                 text={saveText}
                 tooltip={t('Save the dashboard')}
-                faIcon="save"
               />
             }
           />
@@ -161,8 +121,7 @@ class Controls extends React.PureComponent {
             <ActionMenuItem
               text={t('Edit properties')}
               tooltip={t("Edit the dashboards's properties")}
-              faIcon="edit"
-              onClick={() => { window.location = `/dashboardmodelview/edit/${dashboard.id}`; }}
+              onClick={() => { window.location = `/dashboardmodelview/edit/${this.props.dashboardInfo.id}`; }}
             />
           }
           {editMode &&
@@ -170,36 +129,6 @@ class Controls extends React.PureComponent {
               text={t('Email')}
               tooltip={t('Email a link to this dashboard')}
               onClick={() => { window.location = emailLink; }}
-              faIcon="envelope"
-            />
-          }
-          {editMode &&
-            <SliceAdder
-              dashboard={dashboard}
-              addSlicesToDashboard={addSlicesToDashboard}
-              userId={userId}
-              triggerNode={
-                <MenuItemContent
-                  text={t('Add Slices')}
-                  tooltip={t('Add some slices to this dashboard')}
-                  faIcon="plus"
-                />
-              }
-            />
-          }
-          {editMode &&
-            <CssEditor
-              dashboard={dashboard}
-              triggerNode={
-                <MenuItemContent
-                  text={t('Edit CSS')}
-                  tooltip={t('Change the style of the dashboard using CSS code')}
-                  faIcon="css3"
-                />
-              }
-              initialCss={this.state.css}
-              templates={this.state.cssTemplates}
-              onChange={this.changeCss.bind(this)}
             />
           }
         </DropdownButton>
diff --git a/superset/assets/javascripts/dashboard/components/Dashboard.jsx b/superset/assets/javascripts/dashboard/components/Dashboard.jsx
index 2a6a227997..f8c11c0e46 100644
--- a/superset/assets/javascripts/dashboard/components/Dashboard.jsx
+++ b/superset/assets/javascripts/dashboard/components/Dashboard.jsx
@@ -3,81 +3,69 @@ import PropTypes from 'prop-types';
 
 import AlertsWrapper from '../../components/AlertsWrapper';
 import GridLayout from './GridLayout';
-import Header from './Header';
+import {
+  chartPropShape,
+  slicePropShape,
+  dashboardInfoPropShape,
+  dashboardStatePropShape,
+} from '../v2/util/propShapes';
 import { exportChart } from '../../explore/exploreUtils';
 import { areObjectsEqual } from '../../reduxUtils';
+import { getChartIdsFromLayout } from '../util/dashboardHelper';
 import { Logger, ActionLog, LOG_ACTIONS_PAGE_LOAD,
   LOG_ACTIONS_LOAD_EVENT, LOG_ACTIONS_RENDER_EVENT } from '../../logger';
 import { t } from '../../locales';
 
-import '../../../stylesheets/dashboard.css';
+import '../../../stylesheets/dashboard.less';
+import '../v2/stylesheets/index.less';
 
 const propTypes = {
-  actions: PropTypes.object,
+  actions: PropTypes.object.isRequired,
+  dashboardInfo: dashboardInfoPropShape.isRequired,
+  dashboardState: dashboardStatePropShape.isRequired,
+  charts: PropTypes.objectOf(chartPropShape).isRequired,
+  slices:  PropTypes.objectOf(slicePropShape).isRequired,
+  datasources: PropTypes.object.isRequired,
+  layout: PropTypes.object.isRequired,
+  impressionId: PropTypes.string.isRequired,
   initMessages: PropTypes.array,
-  dashboard: PropTypes.object.isRequired,
-  slices: PropTypes.object,
-  datasources: PropTypes.object,
-  filters: PropTypes.object,
-  refresh: PropTypes.bool,
   timeout: PropTypes.number,
   userId: PropTypes.string,
-  isStarred: PropTypes.bool,
-  editMode: PropTypes.bool,
-  impressionId: PropTypes.string,
 };
 
 const defaultProps = {
   initMessages: [],
-  dashboard: {},
-  slices: {},
-  datasources: {},
-  filters: {},
-  refresh: false,
   timeout: 60,
   userId: '',
-  isStarred: false,
-  editMode: false,
 };
 
 class Dashboard extends React.PureComponent {
   constructor(props) {
     super(props);
-    this.refreshTimer = null;
+
     this.firstLoad = true;
     this.loadingLog = new ActionLog({
       impressionId: props.impressionId,
       actionType: LOG_ACTIONS_PAGE_LOAD,
       source: 'dashboard',
-      sourceId: props.dashboard.id,
+      sourceId: props.dashboardInfo.id,
       eventNames: [LOG_ACTIONS_LOAD_EVENT, LOG_ACTIONS_RENDER_EVENT],
     });
     Logger.start(this.loadingLog);
 
-    // alert for unsaved changes
-    this.state = { unsavedChanges: false };
-
     this.rerenderCharts = this.rerenderCharts.bind(this);
-    this.updateDashboardTitle = this.updateDashboardTitle.bind(this);
-    this.onSave = this.onSave.bind(this);
-    this.onChange = this.onChange.bind(this);
-    this.serialize = this.serialize.bind(this);
-    this.fetchAllSlices = this.fetchSlices.bind(this, this.getAllSlices());
-    this.startPeriodicRender = this.startPeriodicRender.bind(this);
-    this.addSlicesToDashboard = this.addSlicesToDashboard.bind(this);
-    this.fetchSlice = this.fetchSlice.bind(this);
+    this.getFilters = this.getFilters.bind(this);
+    this.refreshExcept = this.refreshExcept.bind(this);
     this.getFormDataExtra = this.getFormDataExtra.bind(this);
     this.exploreChart = this.exploreChart.bind(this);
     this.exportCSV = this.exportCSV.bind(this);
-    this.props.actions.fetchFaveStar = this.props.actions.fetchFaveStar.bind(this);
-    this.props.actions.saveFaveStar = this.props.actions.saveFaveStar.bind(this);
-    this.props.actions.saveSlice = this.props.actions.saveSlice.bind(this);
-    this.props.actions.removeSlice = this.props.actions.removeSlice.bind(this);
-    this.props.actions.removeChart = this.props.actions.removeChart.bind(this);
-    this.props.actions.updateDashboardLayout = this.props.actions.updateDashboardLayout.bind(this);
-    this.props.actions.toggleExpandSlice = this.props.actions.toggleExpandSlice.bind(this);
+
+    this.props.actions.saveSliceName = this.props.actions.saveSliceName.bind(this);
+    this.props.actions.removeSliceFromDashboard =
+      this.props.actions.removeSliceFromDashboard.bind(this);
+    this.props.actions.toggleExpandSlice =
+      this.props.actions.toggleExpandSlice.bind(this);
     this.props.actions.addFilter = this.props.actions.addFilter.bind(this);
-    this.props.actions.clearFilter = this.props.actions.clearFilter.bind(this);
     this.props.actions.removeFilter = this.props.actions.removeFilter.bind(this);
   }
 
@@ -87,22 +75,36 @@ class Dashboard extends React.PureComponent {
 
   componentWillReceiveProps(nextProps) {
     if (this.firstLoad &&
-      Object.values(nextProps.slices)
-        .every(slice => (['rendered', 'failed', 'stopped'].indexOf(slice.chartStatus) > -1))
+      Object.values(nextProps.charts)
+        .every(chart => (['rendered', 'failed', 'stopped'].indexOf(chart.chartStatus) > -1))
     ) {
       Logger.end(this.loadingLog);
       this.firstLoad = false;
     }
+
+    const currentChartIds = getChartIdsFromLayout(this.props.layout);
+    const nextChartIds = getChartIdsFromLayout(nextProps.layout);
+    if (currentChartIds.length < nextChartIds.length) {
+      // adding new chart
+      const newChartId = nextChartIds.find((key) => (currentChartIds.indexOf(key) === -1));
+      this.props.actions.addSliceToDashboard(newChartId);
+      this.props.actions.onChange();
+    } else if (currentChartIds.length > nextChartIds.length) {
+      // remove chart
+      const removedChartId = currentChartIds.find((key) => (nextChartIds.indexOf(key) === -1));
+      this.props.actions.removeSliceFromDashboard(this.props.charts[removedChartId]);
+      this.props.actions.onChange();
+    }
   }
 
   componentDidUpdate(prevProps) {
-    if (this.props.refresh) {
+    if (this.props.dashboardState.refresh) {
       let changedFilterKey;
-      const prevFiltersKeySet = new Set(Object.keys(prevProps.filters));
-      Object.keys(this.props.filters).some((key) => {
+      const prevFiltersKeySet = new Set(Object.keys(prevProps.dashboardState.filters));
+      Object.keys(this.props.dashboardState.filters).some((key) => {
         prevFiltersKeySet.delete(key);
-        if (prevProps.filters[key] === undefined ||
-          !areObjectsEqual(prevProps.filters[key], this.props.filters[key])) {
+        if (prevProps.dashboardState.filters[key] === undefined ||
+          !areObjectsEqual(prevProps.dashboardState.filters[key], this.props.dashboardState.filters[key])) {
           changedFilterKey = key;
           return true;
         }
@@ -113,6 +115,12 @@ class Dashboard extends React.PureComponent {
         this.refreshExcept(changedFilterKey);
       }
     }
+
+    if (this.props.hasUnsavedChanges) {
+      this.onBeforeUnload(true);
+    } else {
+      this.onBeforeUnload(false);
+    }
   }
 
   componentWillUnmount() {
@@ -127,30 +135,22 @@ class Dashboard extends React.PureComponent {
     }
   }
 
-  onChange() {
-    this.onBeforeUnload(true);
-    this.setState({ unsavedChanges: true });
-  }
-
-  onSave() {
-    this.onBeforeUnload(false);
-    this.setState({ unsavedChanges: false });
-  }
-
   // return charts in array
-  getAllSlices() {
-    return Object.values(this.props.slices);
+  getAllCharts() {
+    return Object.values(this.props.charts);
   }
 
-  getFormDataExtra(slice) {
-    const formDataExtra = Object.assign({}, slice.formData);
-    const extraFilters = this.effectiveExtraFilters(slice.slice_id);
-    formDataExtra.extra_filters = formDataExtra.filters.concat(extraFilters);
+  getFormDataExtra(chart) {
+    const extraFilters = this.effectiveExtraFilters(chart.id);
+    const formDataExtra = {
+      ...chart.formData,
+      extra_filters: extraFilters,
+    };
     return formDataExtra;
   }
 
   getFilters(sliceId) {
-    return this.props.filters[sliceId];
+    return this.props.dashboardState.filters[sliceId];
   }
 
   unload() {
@@ -160,8 +160,8 @@ class Dashboard extends React.PureComponent {
   }
 
   effectiveExtraFilters(sliceId) {
-    const metadata = this.props.dashboard.metadata;
-    const filters = this.props.filters;
+    const metadata = this.props.dashboardInfo.metadata;
+    const filters = this.props.dashboardState.filters;
     const f = [];
     const immuneSlices = metadata.filter_immune_slices || [];
     if (sliceId && immuneSlices.includes(sliceId)) {
@@ -196,154 +196,73 @@ class Dashboard extends React.PureComponent {
   }
 
   refreshExcept(filterKey) {
-    const immune = this.props.dashboard.metadata.filter_immune_slices || [];
-    let slices = this.getAllSlices();
+    const immune = this.props.dashboardInfo.metadata.filter_immune_slices || [];
+    let charts = this.getAllCharts();
     if (filterKey) {
-      slices = slices.filter(slice => (
-        String(slice.slice_id) !== filterKey &&
-        immune.indexOf(slice.slice_id) === -1
-      ));
-    }
-    this.fetchSlices(slices);
-  }
-
-  stopPeriodicRender() {
-    if (this.refreshTimer) {
-      clearTimeout(this.refreshTimer);
-      this.refreshTimer = null;
-    }
-  }
-
-  startPeriodicRender(interval) {
-    this.stopPeriodicRender();
-    const immune = this.props.dashboard.metadata.timed_refresh_immune_slices || [];
-    const refreshAll = () => {
-      const affectedSlices = this.getAllSlices()
-        .filter(slice => immune.indexOf(slice.slice_id) === -1);
-      this.fetchSlices(affectedSlices, true, interval * 0.2);
-    };
-    const fetchAndRender = () => {
-      refreshAll();
-      if (interval > 0) {
-        this.refreshTimer = setTimeout(fetchAndRender, interval);
-      }
-    };
-
-    fetchAndRender();
-  }
-
-  updateDashboardTitle(title) {
-    this.props.actions.updateDashboardTitle(title);
-    this.onChange();
-  }
-
-  serialize() {
-    return this.props.dashboard.layout.map(reactPos => ({
-      slice_id: reactPos.i,
-      col: reactPos.x + 1,
-      row: reactPos.y,
-      size_x: reactPos.w,
-      size_y: reactPos.h,
-    }));
-  }
-
-  addSlicesToDashboard(sliceIds) {
-    return this.props.actions.addSlicesToDashboard(this.props.dashboard.id, sliceIds);
-  }
-
-  fetchSlice(slice, force = false) {
-    return this.props.actions.runQuery(
-      this.getFormDataExtra(slice), force, this.props.timeout, slice.chartKey,
-    );
-  }
-
-  // fetch and render an list of slices
-  fetchSlices(slc, force = false, interval = 0) {
-    const slices = slc || this.getAllSlices();
-    if (!interval) {
-      slices.forEach((slice) => { this.fetchSlice(slice, force); });
-      return;
+      charts = charts.filter(chart => (String(chart.id) !== filterKey && immune.indexOf(chart.id) === -1));
     }
-
-    const meta = this.props.dashboard.metadata;
-    const refreshTime = Math.max(interval, meta.stagger_time || 5000); // default 5 seconds
-    if (typeof meta.stagger_refresh !== 'boolean') {
-      meta.stagger_refresh = meta.stagger_refresh === undefined ?
-        true : meta.stagger_refresh === 'true';
-    }
-    const delay = meta.stagger_refresh ? refreshTime / (slices.length - 1) : 0;
-    slices.forEach((slice, i) => {
-      setTimeout(() => { this.fetchSlice(slice, force); }, delay * i);
+    charts.forEach((chart) => {
+      const updatedFormData = this.getFormDataExtra(chart);
+      this.props.actions.runQuery(updatedFormData, false, this.props.timeout, chart.id);
     });
   }
 
-  exploreChart(slice) {
-    const formData = this.getFormDataExtra(slice);
+  exploreChart(chartId) {
+    const chart = this.props.charts[chartId];
+    const formData = this.getFormDataExtra(chart);
     exportChart(formData);
   }
 
-  exportCSV(slice) {
-    const formData = this.getFormDataExtra(slice);
+  exportCSV(chartId) {
+    const chart = this.props.charts[chartId];
+    const formData = this.getFormDataExtra(chart);
     exportChart(formData, 'csv');
   }
 
   // re-render chart without fetch
   rerenderCharts() {
-    this.getAllSlices().forEach((slice) => {
+    this.getAllCharts().forEach((chart) => {
       setTimeout(() => {
-        this.props.actions.renderTriggered(new Date().getTime(), slice.chartKey);
+        this.props.actions.renderTriggered(new Date().getTime(), chart.id);
       }, 50);
     });
   }
 
   render() {
+    const {
+      expandedSlices = {}, filters, sliceIds,
+      editMode, showBuilderPane,
+    } = this.props.dashboardState;
+
     return (
       <div id="dashboard-container">
-        <div id="dashboard-header">
+        <div>
           <AlertsWrapper initMessages={this.props.initMessages} />
-          <Header
-            dashboard={this.props.dashboard}
-            unsavedChanges={this.state.unsavedChanges}
-            filters={this.props.filters}
-            userId={this.props.userId}
-            isStarred={this.props.isStarred}
-            updateDashboardTitle={this.updateDashboardTitle}
-            onSave={this.onSave}
-            onChange={this.onChange}
-            serialize={this.serialize}
-            fetchFaveStar={this.props.actions.fetchFaveStar}
-            saveFaveStar={this.props.actions.saveFaveStar}
-            renderSlices={this.fetchAllSlices}
-            startPeriodicRender={this.startPeriodicRender}
-            addSlicesToDashboard={this.addSlicesToDashboard}
-            editMode={this.props.editMode}
-            setEditMode={this.props.actions.setEditMode}
-          />
-        </div>
-        <div id="grid-container" className="slice-grid gridster">
-          <GridLayout
-            dashboard={this.props.dashboard}
-            datasources={this.props.datasources}
-            filters={this.props.filters}
-            charts={this.props.slices}
-            timeout={this.props.timeout}
-            onChange={this.onChange}
-            getFormDataExtra={this.getFormDataExtra}
-            exploreChart={this.exploreChart}
-            exportCSV={this.exportCSV}
-            fetchSlice={this.fetchSlice}
-            saveSlice={this.props.actions.saveSlice}
-            removeSlice={this.props.actions.removeSlice}
-            removeChart={this.props.actions.removeChart}
-            updateDashboardLayout={this.props.actions.updateDashboardLayout}
-            toggleExpandSlice={this.props.actions.toggleExpandSlice}
-            addFilter={this.props.actions.addFilter}
-            getFilters={this.getFilters}
-            clearFilter={this.props.actions.clearFilter}
-            removeFilter={this.props.actions.removeFilter}
-            editMode={this.props.editMode}
-          />
         </div>
+        <GridLayout
+          dashboardInfo={this.props.dashboardInfo}
+          layout={this.props.layout}
+          datasources={this.props.datasources}
+          slices={this.props.slices}
+          sliceIds={sliceIds}
+          expandedSlices={expandedSlices}
+          filters={filters}
+          charts={this.props.charts}
+          timeout={this.props.timeout}
+          onChange={this.onChange}
+          rerenderCharts={this.rerenderCharts}
+          getFormDataExtra={this.getFormDataExtra}
+          exploreChart={this.exploreChart}
+          exportCSV={this.exportCSV}
+          refreshChart={this.props.actions.refreshChart}
+          saveSliceName={this.props.actions.saveSliceName}
+          toggleExpandSlice={this.props.actions.toggleExpandSlice}
+          addFilter={this.props.actions.addFilter}
+          getFilters={this.getFilters}
+          removeFilter={this.props.actions.removeFilter}
+          editMode={editMode}
+          showBuilderPane={showBuilderPane}
+        />
       </div>
     );
   }
diff --git a/superset/assets/javascripts/dashboard/components/DashboardContainer.jsx b/superset/assets/javascripts/dashboard/components/DashboardContainer.jsx
index a18a5d2990..00447febdc 100644
--- a/superset/assets/javascripts/dashboard/components/DashboardContainer.jsx
+++ b/superset/assets/javascripts/dashboard/components/DashboardContainer.jsx
@@ -1,28 +1,48 @@
 import { bindActionCreators } from 'redux';
 import { connect } from 'react-redux';
 
-import * as dashboardActions from '../actions';
-import * as chartActions from '../../chart/chartAction';
+import {
+  toggleExpandSlice,
+  addFilter,
+  removeFilter,
+  addSliceToDashboard,
+  removeSliceFromDashboard,
+  onChange,
+} from '../actions/dashboard';
+import { saveSliceName } from '../actions/sliceEntities';
+import { refreshChart, runQuery, renderTriggered } from '../../chart/chartAction';
 import Dashboard from './Dashboard';
 
-function mapStateToProps({ charts, dashboard, impressionId }) {
+function mapStateToProps({ datasources, sliceEntities, charts,
+                           dashboardInfo, dashboardState,
+                           dashboardLayout, impressionId }) {
   return {
-    initMessages: dashboard.common.flash_messages,
-    timeout: dashboard.common.conf.SUPERSET_WEBSERVER_TIMEOUT,
-    dashboard: dashboard.dashboard,
-    slices: charts,
-    datasources: dashboard.datasources,
-    filters: dashboard.filters,
-    refresh: !!dashboard.refresh,
-    userId: dashboard.userId,
-    isStarred: !!dashboard.isStarred,
-    editMode: dashboard.editMode,
+    initMessages: dashboardInfo.common.flash_messages,
+    timeout: dashboardInfo.common.conf.SUPERSET_WEBSERVER_TIMEOUT,
+    userId: dashboardInfo.userId,
+    dashboardInfo,
+    dashboardState,
+    charts,
+    datasources,
+    slices: sliceEntities.slices,
+    layout: dashboardLayout.present,
     impressionId,
   };
 }
 
 function mapDispatchToProps(dispatch) {
-  const actions = { ...chartActions, ...dashboardActions };
+  const actions = {
+    refreshChart,
+    runQuery,
+    renderTriggered,
+    saveSliceName,
+    toggleExpandSlice,
+    addFilter,
+    removeFilter,
+    addSliceToDashboard,
+    removeSliceFromDashboard,
+    onChange,
+  };
   return {
     actions: bindActionCreators(actions, dispatch),
   };
diff --git a/superset/assets/javascripts/dashboard/components/GridCell.jsx b/superset/assets/javascripts/dashboard/components/GridCell.jsx
index 91fe83943b..68142b2fd8 100644
--- a/superset/assets/javascripts/dashboard/components/GridCell.jsx
+++ b/superset/assets/javascripts/dashboard/components/GridCell.jsx
@@ -4,8 +4,7 @@ import PropTypes from 'prop-types';
 
 import SliceHeader from './SliceHeader';
 import ChartContainer from '../../chart/ChartContainer';
-
-import '../../../stylesheets/dashboard.css';
+import { chartPropShape, slicePropShape } from '../v2/util/propShapes';
 
 const propTypes = {
   timeout: PropTypes.number,
@@ -16,34 +15,30 @@ const propTypes = {
   isExpanded: PropTypes.bool,
   widgetHeight: PropTypes.number,
   widgetWidth: PropTypes.number,
-  slice: PropTypes.object,
-  chartKey: PropTypes.string,
+  slice: slicePropShape.isRequired,
+  chart: chartPropShape.isRequired,
   formData: PropTypes.object,
   filters: PropTypes.object,
-  forceRefresh: PropTypes.func,
-  removeSlice: PropTypes.func,
+  refreshChart: PropTypes.func,
   updateSliceName: PropTypes.func,
   toggleExpandSlice: PropTypes.func,
   exploreChart: PropTypes.func,
   exportCSV: PropTypes.func,
   addFilter: PropTypes.func,
   getFilters: PropTypes.func,
-  clearFilter: PropTypes.func,
   removeFilter: PropTypes.func,
   editMode: PropTypes.bool,
   annotationQuery: PropTypes.object,
 };
 
 const defaultProps = {
-  forceRefresh: () => ({}),
-  removeSlice: () => ({}),
+  refreshChart: () => ({}),
   updateSliceName: () => ({}),
   toggleExpandSlice: () => ({}),
   exploreChart: () => ({}),
   exportCSV: () => ({}),
   addFilter: () => ({}),
   getFilters: () => ({}),
-  clearFilter: () => ({}),
   removeFilter: () => ({}),
   editMode: false,
 };
@@ -53,9 +48,9 @@ class GridCell extends React.PureComponent {
     super(props);
 
     const sliceId = this.props.slice.slice_id;
-    this.addFilter = this.props.addFilter.bind(this, sliceId);
+    this.forceRefresh = this.forceRefresh.bind(this);
+    this.addFilter = this.props.addFilter.bind(this, this.props.chart);
     this.getFilters = this.props.getFilters.bind(this, sliceId);
-    this.clearFilter = this.props.clearFilter.bind(this, sliceId);
     this.removeFilter = this.props.removeFilter.bind(this, sliceId);
   }
 
@@ -68,7 +63,7 @@ class GridCell extends React.PureComponent {
   }
 
   width() {
-    return this.props.widgetWidth - 10;
+    return this.props.widgetWidth - 32;
   }
 
   height(slice) {
@@ -80,7 +75,7 @@ class GridCell extends React.PureComponent {
       descriptionHeight = this.refs[descriptionId].offsetHeight + 10;
     }
 
-    return widgetHeight - headerHeight - descriptionHeight;
+    return widgetHeight - headerHeight - descriptionHeight - 32;
   }
 
   headerHeight(slice) {
@@ -88,13 +83,18 @@ class GridCell extends React.PureComponent {
     return this.refs[headerId] ? this.refs[headerId].offsetHeight : 30;
   }
 
+  forceRefresh() {
+    return this.props.refreshChart(this.props.chart, true, this.props.timeout);
+  }
+
   render() {
     const {
       isExpanded, isLoading, isCached, cachedDttm,
-      removeSlice, updateSliceName, toggleExpandSlice, forceRefresh,
-      chartKey, slice, datasource, formData, timeout, annotationQuery,
-      exploreChart, exportCSV,
+      updateSliceName, toggleExpandSlice,
+      chart, slice, datasource, formData, timeout, annotationQuery,
+      exploreChart, exportCSV, editMode,
     } = this.props;
+
     return (
       <div
         className={isLoading ? 'slice-cell-highlight' : 'slice-cell'}
@@ -106,11 +106,10 @@ class GridCell extends React.PureComponent {
             isExpanded={isExpanded}
             isCached={isCached}
             cachedDttm={cachedDttm}
-            removeSlice={removeSlice}
             updateSliceName={updateSliceName}
             toggleExpandSlice={toggleExpandSlice}
-            forceRefresh={forceRefresh}
-            editMode={this.props.editMode}
+            forceRefresh={this.forceRefresh}
+            editMode={editMode}
             annotationQuery={annotationQuery}
             exploreChart={exploreChart}
             exportCSV={exportCSV}
@@ -128,21 +127,22 @@ class GridCell extends React.PureComponent {
           ref={this.getDescriptionId(slice)}
           dangerouslySetInnerHTML={{ __html: slice.description_markeddown }}
         />
-        <div className="row chart-container">
+        <div className="chart-container"
+             style={{ width: this.width(), height: this.height(slice) }}
+        >
           <input type="hidden" value="false" />
           <ChartContainer
             containerId={`slice-container-${slice.slice_id}`}
-            chartKey={chartKey}
+            chartId={chart.id}
             datasource={datasource}
             formData={formData}
             headerHeight={this.headerHeight(slice)}
             height={this.height(slice)}
             width={this.width()}
             timeout={timeout}
-            vizType={slice.formData.viz_type}
+            vizType={slice.viz_type}
             addFilter={this.addFilter}
             getFilters={this.getFilters}
-            clearFilter={this.clearFilter}
             removeFilter={this.removeFilter}
           />
         </div>
diff --git a/superset/assets/javascripts/dashboard/components/GridLayout.jsx b/superset/assets/javascripts/dashboard/components/GridLayout.jsx
index ef0ec24796..101efd7267 100644
--- a/superset/assets/javascripts/dashboard/components/GridLayout.jsx
+++ b/superset/assets/javascripts/dashboard/components/GridLayout.jsx
@@ -1,51 +1,48 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import { Responsive, WidthProvider } from 'react-grid-layout';
+import cx from 'classnames';
 
 import GridCell from './GridCell';
-
-require('react-grid-layout/css/styles.css');
-require('react-resizable/css/styles.css');
-
-const ResponsiveReactGridLayout = WidthProvider(Responsive);
+import { slicePropShape, chartPropShape } from '../v2/util/propShapes';
+import DashboardBuilder from '../v2/containers/DashboardBuilder';
 
 const propTypes = {
-  dashboard: PropTypes.object.isRequired,
+  dashboardInfo: PropTypes.shape().isRequired,
+  layout: PropTypes.object.isRequired,
   datasources: PropTypes.object,
-  charts: PropTypes.object.isRequired,
+  charts: PropTypes.objectOf(chartPropShape).isRequired,
+  slices: PropTypes.objectOf(slicePropShape).isRequired,
+  expandedSlices: PropTypes.object,
   filters: PropTypes.object,
   timeout: PropTypes.number,
   onChange: PropTypes.func,
+  rerenderCharts: PropTypes.func,
   getFormDataExtra: PropTypes.func,
   exploreChart: PropTypes.func,
   exportCSV: PropTypes.func,
-  fetchSlice: PropTypes.func,
-  saveSlice: PropTypes.func,
-  removeSlice: PropTypes.func,
-  removeChart: PropTypes.func,
-  updateDashboardLayout: PropTypes.func,
+  refreshChart: PropTypes.func,
+  saveSliceName: PropTypes.func,
   toggleExpandSlice: PropTypes.func,
   addFilter: PropTypes.func,
   getFilters: PropTypes.func,
-  clearFilter: PropTypes.func,
   removeFilter: PropTypes.func,
   editMode: PropTypes.bool.isRequired,
+  showBuilderPane: PropTypes.bool.isRequired,
 };
 
 const defaultProps = {
+  expandedSlices: {},
+  filters: {},
+  timeout: 60,
   onChange: () => ({}),
   getFormDataExtra: () => ({}),
   exploreChart: () => ({}),
   exportCSV: () => ({}),
-  fetchSlice: () => ({}),
-  saveSlice: () => ({}),
-  removeSlice: () => ({}),
-  removeChart: () => ({}),
-  updateDashboardLayout: () => ({}),
+  refreshChart: () => ({}),
+  saveSliceName: () => ({}),
   toggleExpandSlice: () => ({}),
   addFilter: () => ({}),
   getFilters: () => ({}),
-  clearFilter: () => ({}),
   removeFilter: () => ({}),
 };
 
@@ -53,141 +50,100 @@ class GridLayout extends React.Component {
   constructor(props) {
     super(props);
 
-    this.onResizeStop = this.onResizeStop.bind(this);
-    this.onDragStop = this.onDragStop.bind(this);
-    this.forceRefresh = this.forceRefresh.bind(this);
-    this.removeSlice = this.removeSlice.bind(this);
-    this.updateSliceName = this.props.dashboard.dash_edit_perm ?
+    this.updateSliceName = this.props.dashboardInfo.dash_edit_perm ?
       this.updateSliceName.bind(this) : null;
   }
 
-  onResizeStop(layout) {
-    this.props.updateDashboardLayout(layout);
-    this.props.onChange();
-  }
-
-  onDragStop(layout) {
-    this.props.updateDashboardLayout(layout);
-    this.props.onChange();
-  }
-
-  getWidgetId(slice) {
-    return 'widget_' + slice.slice_id;
+  getWidgetId(sliceId) {
+    return 'widget_' + sliceId;
   }
 
-  getWidgetHeight(slice) {
-    const widgetId = this.getWidgetId(slice);
+  getWidgetHeight(sliceId) {
+    const widgetId = this.getWidgetId(sliceId);
     if (!widgetId || !this.refs[widgetId]) {
       return 400;
     }
-    return this.refs[widgetId].offsetHeight;
+    return this.refs[widgetId].parentNode.clientHeight;
   }
 
-  getWidgetWidth(slice) {
-    const widgetId = this.getWidgetId(slice);
+  getWidgetWidth(sliceId) {
+    const widgetId = this.getWidgetId(sliceId);
     if (!widgetId || !this.refs[widgetId]) {
       return 400;
     }
-    return this.refs[widgetId].offsetWidth;
-  }
-
-  findSliceIndexById(sliceId) {
-    return this.props.dashboard.slices
-      .map(slice => (slice.slice_id)).indexOf(sliceId);
-  }
-
-  forceRefresh(sliceId) {
-    return this.props.fetchSlice(this.props.charts['slice_' + sliceId], true);
-  }
-
-  removeSlice(slice) {
-    if (!slice) {
-      return;
-    }
-
-    // remove slice dashboard and charts
-    this.props.removeSlice(slice);
-    this.props.removeChart(this.props.charts['slice_' + slice.slice_id].chartKey);
-    this.props.onChange();
+    return this.refs[widgetId].parentNode.clientWidth;
   }
 
   updateSliceName(sliceId, sliceName) {
-    const index = this.findSliceIndexById(sliceId);
-    if (index === -1) {
+    const key = sliceId;
+    const currentSlice = this.props.slices[key];
+    if (!currentSlice || currentSlice.slice_name === sliceName) {
       return;
     }
 
-    const currentSlice = this.props.dashboard.slices[index];
-    if (currentSlice.slice_name === sliceName) {
-      return;
-    }
-
-    this.props.saveSlice(currentSlice, sliceName);
+    this.props.saveSliceName(currentSlice, sliceName);
   }
 
-  isExpanded(slice) {
-    return this.props.dashboard.metadata.expanded_slices &&
-      this.props.dashboard.metadata.expanded_slices[slice.slice_id];
+  isExpanded(sliceId) {
+    return this.props.expandedSlices[sliceId];
   }
 
+  componentDidUpdate(prevProps) {
+    if (prevProps.editMode !== this.props.editMode ||
+      prevProps.showBuilderPane != this.props.showBuilderPane) {
+      this.props.rerenderCharts();
+    }
+  }
   render() {
-    const cells = this.props.dashboard.slices.map((slice) => {
-      const chartKey = `slice_${slice.slice_id}`;
-      const currentChart = this.props.charts[chartKey];
-      const queryResponse = currentChart.queryResponse || {};
-      return (
-        <div
-          id={'slice_' + slice.slice_id}
-          key={slice.slice_id}
-          data-slice-id={slice.slice_id}
-          className={`widget ${slice.form_data.viz_type}`}
-          ref={this.getWidgetId(slice)}
-        >
-          <GridCell
-            slice={slice}
-            chartKey={chartKey}
-            datasource={this.props.datasources[slice.form_data.datasource]}
-            filters={this.props.filters}
-            formData={this.props.getFormDataExtra(slice)}
-            timeout={this.props.timeout}
-            widgetHeight={this.getWidgetHeight(slice)}
-            widgetWidth={this.getWidgetWidth(slice)}
-            exploreChart={this.props.exploreChart}
-            exportCSV={this.props.exportCSV}
-            isExpanded={!!this.isExpanded(slice)}
-            isLoading={currentChart.chartStatus === 'loading'}
-            isCached={queryResponse.is_cached}
-            cachedDttm={queryResponse.cached_dttm}
-            toggleExpandSlice={this.props.toggleExpandSlice}
-            forceRefresh={this.forceRefresh}
-            removeSlice={this.removeSlice}
-            updateSliceName={this.updateSliceName}
-            addFilter={this.props.addFilter}
-            getFilters={this.props.getFilters}
-            clearFilter={this.props.clearFilter}
-            removeFilter={this.props.removeFilter}
-            editMode={this.props.editMode}
-            annotationQuery={currentChart.annotationQuery}
-            annotationError={currentChart.annotationError}
-          />
-        </div>);
+    const cells = {};
+    this.props.sliceIds.forEach((sliceId) => {
+      const key = sliceId;
+      const currentChart = this.props.charts[key];
+      const currentSlice = this.props.slices[key];
+      if (currentChart) {
+        const currentDatasource = this.props.datasources[currentChart.form_data.datasource];
+        const queryResponse = currentChart.queryResponse || {};
+        cells[key] = (
+          <div
+            id={key}
+            key={sliceId}
+            className={cx('widget', `${currentSlice.viz_type}`, { 'is-edit': this.props.editMode })}
+            ref={this.getWidgetId(sliceId)}
+          >
+            <GridCell
+              slice={currentSlice}
+              chart={currentChart}
+              datasource={currentDatasource}
+              filters={this.props.filters}
+              formData={this.props.getFormDataExtra(currentChart)}
+              timeout={this.props.timeout}
+              widgetHeight={this.getWidgetHeight(sliceId)}
+              widgetWidth={this.getWidgetWidth(sliceId)}
+              exploreChart={this.props.exploreChart}
+              exportCSV={this.props.exportCSV}
+              isExpanded={!!this.isExpanded(sliceId)}
+              isLoading={currentChart.chartStatus === 'loading'}
+              isCached={queryResponse.is_cached}
+              cachedDttm={queryResponse.cached_dttm}
+              toggleExpandSlice={this.props.toggleExpandSlice}
+              refreshChart={this.props.refreshChart}
+              updateSliceName={this.updateSliceName}
+              addFilter={this.props.addFilter}
+              getFilters={this.props.getFilters}
+              removeFilter={this.props.removeFilter}
+              editMode={this.props.editMode}
+              annotationQuery={currentChart.annotationQuery}
+              annotationError={currentChart.annotationError}
+            />
+          </div>
+        );
+      }
     });
 
     return (
-      <ResponsiveReactGridLayout
-        className="layout"
-        layouts={{ lg: this.props.dashboard.layout }}
-        onResizeStop={this.onResizeStop}
-        onDragStop={this.onDragStop}
-        cols={{ lg: 48, md: 48, sm: 40, xs: 32, xxs: 24 }}
-        rowHeight={10}
-        autoSize
-        margin={[20, 20]}
-        useCSSTransforms
-        draggableHandle=".drag"
-      >
-        {cells}
-      </ResponsiveReactGridLayout>
+      <DashboardBuilder
+        cells={cells}
+      />
     );
   }
 }
diff --git a/superset/assets/javascripts/dashboard/components/Header.jsx b/superset/assets/javascripts/dashboard/components/Header.jsx
index 52d3024ff9..a7efb600eb 100644
--- a/superset/assets/javascripts/dashboard/components/Header.jsx
+++ b/superset/assets/javascripts/dashboard/components/Header.jsx
@@ -1,5 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import { ButtonGroup, ButtonToolbar } from 'react-bootstrap';
 
 import Controls from './Controls';
 import EditableTitle from '../../components/EditableTitle';
@@ -9,38 +10,54 @@ import InfoTooltipWithTrigger from '../../components/InfoTooltipWithTrigger';
 import { t } from '../../locales';
 
 const propTypes = {
-  dashboard: PropTypes.object.isRequired,
+  dashboardInfo: PropTypes.object.isRequired,
+  dashboardTitle: PropTypes.string.isRequired,
+  layout: PropTypes.object.isRequired,
   filters: PropTypes.object.isRequired,
-  userId: PropTypes.string.isRequired,
-  isStarred: PropTypes.bool,
-  addSlicesToDashboard: PropTypes.func,
-  onSave: PropTypes.func,
-  onChange: PropTypes.func,
+  expandedSlices: PropTypes.object.isRequired,
+  isStarred: PropTypes.bool.isRequired,
+  onSave: PropTypes.func.isRequired,
+  onChange: PropTypes.func.isRequired,
   fetchFaveStar: PropTypes.func,
-  renderSlices: PropTypes.func,
+  fetchCharts: PropTypes.func.isRequired,
   saveFaveStar: PropTypes.func,
-  serialize: PropTypes.func,
-  startPeriodicRender: PropTypes.func,
-  updateDashboardTitle: PropTypes.func,
+  startPeriodicRender: PropTypes.func.isRequired,
+  updateDashboardTitle: PropTypes.func.isRequired,
   editMode: PropTypes.bool.isRequired,
   setEditMode: PropTypes.func.isRequired,
-  unsavedChanges: PropTypes.bool.isRequired,
+  showBuilderPane: PropTypes.bool.isRequired,
+  toggleBuilderPane: PropTypes.func.isRequired,
+  hasUnsavedChanges: PropTypes.bool.isRequired,
+
+  // redux
+  onUndo: PropTypes.func.isRequired,
+  onRedo: PropTypes.func.isRequired,
+  canUndo: PropTypes.bool.isRequired,
+  canRedo: PropTypes.bool.isRequired,
 };
 
 class Header extends React.PureComponent {
   constructor(props) {
     super(props);
-    this.handleSaveTitle = this.handleSaveTitle.bind(this);
+    this.handleChangeText = this.handleChangeText.bind(this);
     this.toggleEditMode = this.toggleEditMode.bind(this);
+    this.forceRefresh = this.forceRefresh.bind(this);
+  }
+  forceRefresh() {
+    return this.props.fetchCharts(Object.values(this.props.charts), true);
   }
-  handleSaveTitle(title) {
-    this.props.updateDashboardTitle(title);
+  handleChangeText(nextText) {
+    const { updateDashboardTitle, onChange } = this.props;
+    if (nextText && this.props.dashboardTitle !== nextText) {
+      updateDashboardTitle(nextText);
+      onChange();
+    }
   }
   toggleEditMode() {
     this.props.setEditMode(!this.props.editMode);
   }
   renderUnsaved() {
-    if (!this.props.unsavedChanges) {
+    if (!this.props.hasUnsavedChanges) {
       return null;
     }
     return (
@@ -53,60 +70,90 @@ class Header extends React.PureComponent {
       />
     );
   }
+  renderInsertButton() {
+    if (!this.props.editMode) {
+      return null;
+    }
+    const btnText = this.props.showBuilderPane ? t('Hide builder pane') : t('Insert components');
+    return (
+      <Button
+        bsSize="small"
+        onClick={this.props.toggleBuilderPane}
+      >
+        {btnText}
+      </Button>);
+  }
   renderEditButton() {
-    if (!this.props.dashboard.dash_save_perm) {
+    if (!this.props.dashboardInfo.dash_save_perm) {
       return null;
     }
-    const btnText = this.props.editMode ? 'Switch to View Mode' : 'Edit Dashboard';
+    const btnText = this.props.editMode ? t('Switch to View Mode') : t('Edit Dashboard');
     return (
       <Button
-        bsStyle="default"
-        className="m-r-5"
-        style={{ width: '150px' }}
+        bsSize="small"
         onClick={this.toggleEditMode}
       >
         {btnText}
       </Button>);
   }
   render() {
-    const dashboard = this.props.dashboard;
+    const {
+      dashboardTitle, layout, filters, expandedSlices,
+      onUndo, onRedo, canUndo, canRedo,
+      onChange, onSave, editMode,
+    } = this.props;
+
     return (
-      <div className="title">
-        <div className="pull-left">
-          <h1 className="outer-container pull-left">
+      <div className="dashboard-header">
+        <div className="dashboard-component-header header-large">
             <EditableTitle
-              title={dashboard.dashboard_title}
-              canEdit={dashboard.dash_save_perm && this.props.editMode}
-              onSaveTitle={this.handleSaveTitle}
-              showTooltip={this.props.editMode}
+              title={dashboardTitle}
+              canEdit={this.props.dashboardInfo.dash_save_perm && editMode}
+              onSaveTitle={this.handleChangeText}
+              showTooltip={editMode}
             />
             <span className="favstar m-r-5">
               <FaveStar
-                itemId={dashboard.id}
+                itemId={this.props.dashboardInfo.id}
                 fetchFaveStar={this.props.fetchFaveStar}
                 saveFaveStar={this.props.saveFaveStar}
                 isStarred={this.props.isStarred}
               />
             </span>
             {this.renderUnsaved()}
-          </h1>
         </div>
-        <div className="pull-right" style={{ marginTop: '35px' }}>
-          {this.renderEditButton()}
+        <ButtonToolbar>
+          <ButtonGroup>
+            <Button
+              bsSize="small"
+              onClick={onUndo}
+              disabled={!canUndo}
+            >
+              Undo
+            </Button>
+            <Button
+              bsSize="small"
+              onClick={onRedo}
+              disabled={!canRedo}
+            >
+              Redo
+            </Button>
+            {this.renderInsertButton()}
+            {this.renderEditButton()}
+          </ButtonGroup>
           <Controls
-            dashboard={dashboard}
-            filters={this.props.filters}
-            userId={this.props.userId}
-            addSlicesToDashboard={this.props.addSlicesToDashboard}
-            onSave={this.props.onSave}
-            onChange={this.props.onChange}
-            renderSlices={this.props.renderSlices}
-            serialize={this.props.serialize}
+            dashboardInfo={this.props.dashboardInfo}
+            dashboardTitle={dashboardTitle}
+            layout={layout}
+            filters={filters}
+            expandedSlices={expandedSlices}
+            onSave={onSave}
+            onChange={onChange}
+            forceRefreshAllCharts={this.forceRefresh}
             startPeriodicRender={this.props.startPeriodicRender}
-            editMode={this.props.editMode}
+            editMode={editMode}
           />
-        </div>
-        <div className="clearfix" />
+        </ButtonToolbar>
       </div>
     );
   }
diff --git a/superset/assets/javascripts/dashboard/components/RefreshIntervalModal.jsx b/superset/assets/javascripts/dashboard/components/RefreshIntervalModal.jsx
index 4cba010d95..17673c971b 100644
--- a/superset/assets/javascripts/dashboard/components/RefreshIntervalModal.jsx
+++ b/superset/assets/javascripts/dashboard/components/RefreshIntervalModal.jsx
@@ -43,8 +43,11 @@ class RefreshIntervalModal extends React.PureComponent {
               options={options}
               value={this.state.refreshFrequency}
               onChange={(opt) => {
-                this.setState({ refreshFrequency: opt.value });
-                this.props.onChange(opt.value);
+                const value = opt ? opt.value : options[0].value;
+                this.setState({
+                  refreshFrequency: value
+                });
+                this.props.onChange(value);
               }}
             />
           </div>
diff --git a/superset/assets/javascripts/dashboard/components/SaveModal.jsx b/superset/assets/javascripts/dashboard/components/SaveModal.jsx
index da465a0057..b56d66b697 100644
--- a/superset/assets/javascripts/dashboard/components/SaveModal.jsx
+++ b/superset/assets/javascripts/dashboard/components/SaveModal.jsx
@@ -1,31 +1,30 @@
 /* global notify */
 import React from 'react';
 import PropTypes from 'prop-types';
+import $ from 'jquery';
+
 import { Button, FormControl, FormGroup, Radio } from 'react-bootstrap';
 import { getAjaxErrorMsg } from '../../modules/utils';
 import ModalTrigger from '../../components/ModalTrigger';
 import { t } from '../../locales';
 import Checkbox from '../../components/Checkbox';
 
-const $ = window.$ = require('jquery');
-
 const propTypes = {
-  css: PropTypes.string,
-  dashboard: PropTypes.object.isRequired,
+  dashboardId: PropTypes.number.isRequired,
+  dashboardTitle: PropTypes.string.isRequired,
+  expandedSlices: PropTypes.object.isRequired,
+  layout: PropTypes.object.isRequired,
   triggerNode: PropTypes.node.isRequired,
   filters: PropTypes.object.isRequired,
-  serialize: PropTypes.func,
-  onSave: PropTypes.func,
+  onSave: PropTypes.func.isRequired,
 };
 
 class SaveModal extends React.PureComponent {
   constructor(props) {
     super(props);
     this.state = {
-      dashboard: props.dashboard,
-      css: props.css,
       saveType: 'overwrite',
-      newDashName: props.dashboard.dashboard_title + ' [copy]',
+      newDashName: props.dashboardTitle + ' [copy]',
       duplicateSlices: false,
     };
     this.modal = null;
@@ -74,19 +73,17 @@ class SaveModal extends React.PureComponent {
     });
   }
   saveDashboard(saveType, newDashboardTitle) {
-    const dashboard = this.props.dashboard;
-    const positions = this.props.serialize();
+    const { dashboardTitle, layout: positions, expandedSlices, filters, dashboardId } = this.props;
     const data = {
       positions,
-      css: this.state.css,
-      expanded_slices: dashboard.metadata.expanded_slices || {},
-      dashboard_title: dashboard.dashboard_title,
-      default_filters: JSON.stringify(this.props.filters),
+      expanded_slices: expandedSlices,
+      dashboard_title: dashboardTitle,
+      default_filters: JSON.stringify(filters),
       duplicate_slices: this.state.duplicateSlices,
     };
     let url = null;
     if (saveType === 'overwrite') {
-      url = `/superset/save_dash/${dashboard.id}/`;
+      url = `/superset/save_dash/${dashboardId}/`;
       this.saveDashboardRequest(data, url, saveType);
     } else if (saveType === 'newDashboard') {
       if (!newDashboardTitle) {
@@ -97,7 +94,7 @@ class SaveModal extends React.PureComponent {
         });
       } else {
         data.dashboard_title = newDashboardTitle;
-        url = `/superset/copy_dash/${dashboard.id}/`;
+        url = `/superset/copy_dash/${dashboardId}/`;
         this.saveDashboardRequest(data, url, saveType);
       }
     }
@@ -116,7 +113,7 @@ class SaveModal extends React.PureComponent {
               onChange={this.handleSaveTypeChange}
               checked={this.state.saveType === 'overwrite'}
             >
-              {t('Overwrite Dashboard [%s]', this.props.dashboard.dashboard_title)}
+              {t('Overwrite Dashboard [%s]', this.props.dashboardTitle)}
             </Radio>
             <hr />
             <Radio
diff --git a/superset/assets/javascripts/dashboard/components/SliceAdder.jsx b/superset/assets/javascripts/dashboard/components/SliceAdder.jsx
index d5be8caff6..ea7d0a025c 100644
--- a/superset/assets/javascripts/dashboard/components/SliceAdder.jsx
+++ b/superset/assets/javascripts/dashboard/components/SliceAdder.jsx
@@ -1,219 +1,215 @@
 import React from 'react';
-import $ from 'jquery';
 import PropTypes from 'prop-types';
-import { BootstrapTable, TableHeaderColumn } from 'react-bootstrap-table';
+import cx from 'classnames';
+import { DropdownButton, MenuItem } from 'react-bootstrap';
+import { List } from 'react-virtualized';
+import SearchInput, {createFilter} from 'react-search-input';
 
-import ModalTrigger from '../../components/ModalTrigger';
-import { t } from '../../locales';
-
-require('react-bootstrap-table/css/react-bootstrap-table.css');
+import DragDroppable from '../v2/components/dnd/DragDroppable';
+import { CHART_TYPE, NEW_COMPONENT_SOURCE_TYPE } from '../v2/util/componentTypes';
+import { NEW_CHART_ID, NEW_COMPONENTS_SOURCE_ID } from '../v2/util/constants';
+import { slicePropShape } from '../v2/util/propShapes';
 
 const propTypes = {
-  dashboard: PropTypes.object.isRequired,
-  triggerNode: PropTypes.node.isRequired,
+  actions: PropTypes.object,
+  isLoading: PropTypes.bool.isRequired,
+  slices: PropTypes.objectOf(slicePropShape).isRequired,
+  lastUpdated: PropTypes.number.isRequired,
+  errorMessage: PropTypes.string,
   userId: PropTypes.string.isRequired,
-  addSlicesToDashboard: PropTypes.func,
+  selectedSliceIds: PropTypes.object,
+  editMode: PropTypes.bool,
+};
+
+const defaultProps = {
+  selectedSliceIds: new Set(),
+  editMode: false,
 };
 
+const KEYS_TO_FILTERS = ['slice_name', 'viz_type', 'datasource_name'];
+const KEYS_TO_SORT = [
+  { key: 'slice_name', label: 'Name' },
+  { key: 'viz_type', label: 'Visualization' },
+  { key: 'datasource_name', label: 'Datasource' },
+  { key: 'changed_on', label: 'Recent' }
+];
+
 class SliceAdder extends React.Component {
   constructor(props) {
     super(props);
     this.state = {
-      slices: [],
-      slicesLoaded: false,
-      selectionMap: {},
-    };
-
-    this.options = {
-      defaultSortOrder: 'desc',
-      defaultSortName: 'modified',
-      sizePerPage: 10,
+      filteredSlices: [],
+      searchTerm: '',
+      sortBy: KEYS_TO_SORT.findIndex((item) => ('changed_on' === item.key)),
     };
 
-    this.addSlices = this.addSlices.bind(this);
-    this.toggleSlice = this.toggleSlice.bind(this);
+    this.rowRenderer = this.rowRenderer.bind(this);
+    this.searchUpdated = this.searchUpdated.bind(this);
+    this.handleKeyPress = this.handleKeyPress.bind(this);
+    this.handleSelect = this.handleSelect.bind(this);
+  }
 
-    this.selectRowProp = {
-      mode: 'checkbox',
-      clickToSelect: true,
-      onSelect: this.toggleSlice,
-    };
+  componentDidMount() {
+    this.slicesRequest = this.props.actions.fetchAllSlices(this.props.userId);
   }
 
   componentWillUnmount() {
-    if (this.slicesRequest) {
+    if (this.slicesRequest && this.slicesRequest.abort) {
       this.slicesRequest.abort();
     }
   }
 
-  onEnterModal() {
-    const uri = `/sliceaddview/api/read?_flt_0_created_by=${this.props.userId}`;
-    this.slicesRequest = $.ajax({
-      url: uri,
-      type: 'GET',
-      success: (response) => {
-        // Prepare slice data for table
-        const slices = response.result.map(slice => ({
-          id: slice.id,
-          sliceName: slice.slice_name,
-          vizType: slice.viz_type,
-          datasourceLink: slice.datasource_link,
-          modified: slice.modified,
-        }));
-
-        this.setState({
-          slices,
-          selectionMap: {},
-          slicesLoaded: true,
-        });
-      },
-      error: (error) => {
-        this.errored = true;
-        this.setState({
-          errorMsg: t('Sorry, there was an error fetching slices to this dashboard: ') +
-          this.getAjaxErrorMsg(error),
-        });
-      },
-    });
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.lastUpdated !== this.props.lastUpdated) {
+      this.setState({
+        filteredSlices: Object.values(nextProps.slices)
+          .filter(createFilter(this.state.searchTerm, KEYS_TO_FILTERS))
+          .sort(this.sortByComparator(KEYS_TO_SORT[this.state.sortBy].key)),
+      });
+    }
   }
 
-  getAjaxErrorMsg(error) {
-    const respJSON = error.responseJSON;
-    return (respJSON && respJSON.message) ? respJSON.message :
-      error.responseText;
+  sortByComparator(attr) {
+    const desc = 'changed_on' === attr ? -1 : 1;
+
+    return (a, b) => {
+      if (a[attr] < b[attr]) {
+        return -1 * desc;
+      } else if (a[attr] > b[attr]) {
+        return 1 * desc;
+      } else {
+        return 0;
+      }
+    };
   }
 
-  addSlices() {
-    const adder = this;
-    this.props.addSlicesToDashboard(Object.keys(this.state.selectionMap))
-      // if successful, page will be reloaded.
-      .fail((error) => {
-        adder.errored = true;
-        adder.setState({
-          errorMsg: t('Sorry, there was an error adding slices to this dashboard: ') +
-          this.getAjaxErrorMsg(error),
-        });
-      });
+  handleKeyPress(ev) {
+    if (ev.key === 'Enter') {
+      ev.preventDefault();
+
+      this.searchUpdated(ev.target.value);
+    }
   }
 
-  toggleSlice(slice) {
-    const selectionMap = Object.assign({}, this.state.selectionMap);
-    selectionMap[slice.id] = !selectionMap[slice.id];
-    this.setState({ selectionMap });
+  getFilteredSortedSlices(searchTerm, sortBy) {
+    return Object.values(this.props.slices)
+      .filter(createFilter(searchTerm, KEYS_TO_FILTERS))
+      .sort(this.sortByComparator(KEYS_TO_SORT[sortBy].key));
   }
 
-  modifiedDateComparator(a, b, order) {
-    if (order === 'desc') {
-      if (a.changed_on > b.changed_on) {
-        return -1;
-      } else if (a.changed_on < b.changed_on) {
-        return 1;
-      }
-      return 0;
-    }
+  searchUpdated(searchTerm) {
+    this.setState({
+      searchTerm,
+      filteredSlices: this.getFilteredSortedSlices(searchTerm, this.state.sortBy),
+    });
+  }
 
-    if (a.changed_on < b.changed_on) {
-      return -1;
-    } else if (a.changed_on > b.changed_on) {
-      return 1;
-    }
-    return 0;
+  handleSelect(sortBy) {
+    this.setState({
+      sortBy,
+      filteredSlices: this.getFilteredSortedSlices(this.state.searchTerm, sortBy),
+    })
   }
 
-  render() {
-    const hideLoad = this.state.slicesLoaded || this.errored;
-    let enableAddSlice = this.state.selectionMap && Object.keys(this.state.selectionMap);
-    if (enableAddSlice) {
-      enableAddSlice = enableAddSlice.some(function (key) {
-        return this.state.selectionMap[key];
-      }, this);
-    }
-    const modalContent = (
-      <div>
-        <img
-          src="/static/assets/images/loading.gif"
-          className={'loading ' + (hideLoad ? 'hidden' : '')}
-          alt={hideLoad ? '' : 'loading'}
-        />
-        <div className={this.errored ? '' : 'hidden'}>
-          {this.state.errorMsg}
+  rowRenderer({ key, index, style }) {
+    const cellData = this.state.filteredSlices[index];
+    const duration = cellData.modified ? cellData.modified.replace(/<[^>]*>/g, '') : '';
+    const isSelected = this.props.selectedSliceIds.has(cellData.slice_id);
+    const type = CHART_TYPE;
+    const id = NEW_CHART_ID;
+    const meta = {
+      chartId: cellData.slice_id,
+    };
+
+    return (
+      <DragDroppable
+        component={{ type, id, meta }}
+        parentComponent={{ id: NEW_COMPONENTS_SOURCE_ID, type: NEW_COMPONENT_SOURCE_TYPE }}
+        index={0}
+        depth={0}
+        disableDragDrop={isSelected}
+        editMode={this.props.editMode}
+      >
+        {({ dragSourceRef }) => (
+      <div
+        ref={dragSourceRef}
+        className="chart-card-container"
+        key={key}
+        style={style}
+      >
+        <div className={cx('chart-card', { 'is-selected': isSelected })}>
+          <div className="card-title">{cellData.slice_name}</div>
+          <div className="card-body">
+            <div className="item">
+              <label>Modified </label>
+              <span>{duration}</span>
+            </div>
+            <div className="item">
+              <label>Visualization </label>
+              <span>{cellData.viz_type}</span>
+            </div>
+            <div className="item">
+              <label>Data source </label>
+              <span dangerouslySetInnerHTML={{ __html: cellData.datasource_link }} />
+            </div>
+          </div>
         </div>
-        <div className={this.state.slicesLoaded ? '' : 'hidden'}>
-          <BootstrapTable
-            ref="table"
-            data={this.state.slices}
-            selectRow={this.selectRowProp}
-            options={this.options}
-            hover
-            search
-            pagination
-            condensed
-            height="auto"
+      </div>
+        )}
+      </DragDroppable>
+    )
+  }
+
+  render() {
+    return (
+      <div className="slice-adder-container">
+        <div className="controls">
+          <DropdownButton
+            title={KEYS_TO_SORT[this.state.sortBy].label}
+            onSelect={this.handleSelect}
+            id="slice-adder-sortby"
           >
-            <TableHeaderColumn
-              dataField="id"
-              isKey
-              dataSort
-              hidden
+            {KEYS_TO_SORT.map((item, index) => (
+              <MenuItem key={item.key} eventKey={index}>{item.label}</MenuItem>
+            ))}
+          </DropdownButton>
+
+          <SearchInput
+            onChange={this.searchUpdated}
+            onKeyPress={this.handleKeyPress}
+          />
+        </div>
+
+        {this.props.isLoading &&
+          <img
+            src="/static/assets/images/loading.gif"
+            className="loading"
+            alt="loading"
+          />
+        }
+        <div className={this.props.errorMessage ? '' : 'hidden'}>
+          {this.props.errorMessage}
+        </div>
+        <div className={!this.props.isLoading ? '' : 'hidden'}>
+          {this.state.filteredSlices.length > 0 &&
+            <List
+              width={376}
+              height={500}
+              rowCount={this.state.filteredSlices.length}
+              rowHeight={136}
+              rowRenderer={this.rowRenderer}
+              searchTerm={this.state.searchTerm}
+              sortBy={this.state.sortBy}
+              selectedSliceIds={this.props.selectedSliceIds}
             />
-            <TableHeaderColumn
-              dataField="sliceName"
-              dataSort
-            >
-              {t('Name')}
-            </TableHeaderColumn>
-            <TableHeaderColumn
-              dataField="vizType"
-              dataSort
-            >
-              {t('Viz')}
-            </TableHeaderColumn>
-            <TableHeaderColumn
-              dataField="datasourceLink"
-              dataSort
-              // Will cause react-bootstrap-table to interpret the HTML returned
-              dataFormat={datasourceLink => datasourceLink}
-            >
-              {t('Datasource')}
-            </TableHeaderColumn>
-            <TableHeaderColumn
-              dataField="modified"
-              dataSort
-              sortFunc={this.modifiedDateComparator}
-              // Will cause react-bootstrap-table to interpret the HTML returned
-              dataFormat={modified => modified}
-            >
-              {t('Modified')}
-            </TableHeaderColumn>
-          </BootstrapTable>
-          <button
-            type="button"
-            className="btn btn-default"
-            data-dismiss="modal"
-            onClick={this.addSlices}
-            disabled={!enableAddSlice}
-          >
-            {t('Add Slices')}
-          </button>
+          }
         </div>
       </div>
     );
-
-    return (
-      <ModalTrigger
-        triggerNode={this.props.triggerNode}
-        tooltip={t('Add a new slice to the dashboard')}
-        beforeOpen={this.onEnterModal.bind(this)}
-        isMenuItem
-        modalBody={modalContent}
-        bsSize="large"
-        setModalAsTriggerChildren
-        modalTitle={t('Add Slices to Dashboard')}
-      />
-    );
   }
 }
 
 SliceAdder.propTypes = propTypes;
+SliceAdder.defaultProps = defaultProps;
 
 export default SliceAdder;
diff --git a/superset/assets/javascripts/dashboard/components/SliceAdderContainer.jsx b/superset/assets/javascripts/dashboard/components/SliceAdderContainer.jsx
new file mode 100644
index 0000000000..ed90048f0b
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/components/SliceAdderContainer.jsx
@@ -0,0 +1,25 @@
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+
+import { fetchAllSlices } from '../actions/sliceEntities';
+import SliceAdder from './SliceAdder';
+
+function mapStateToProps({ sliceEntities, dashboardInfo, dashboardState }) {
+  return {
+    userId: dashboardInfo.userId,
+    selectedSliceIds: dashboardState.sliceIds,
+    slices: sliceEntities.slices,
+    isLoading: sliceEntities.isLoading,
+    errorMessage: sliceEntities.errorMessage,
+    lastUpdated: sliceEntities.lastUpdated,
+    editMode: dashboardState.editMode,
+  };
+}
+
+function mapDispatchToProps(dispatch) {
+  return {
+    actions: bindActionCreators({fetchAllSlices}, dispatch),
+  };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(SliceAdder);
diff --git a/superset/assets/javascripts/dashboard/components/SliceHeader.jsx b/superset/assets/javascripts/dashboard/components/SliceHeader.jsx
index 8abcc86d61..264542a108 100644
--- a/superset/assets/javascripts/dashboard/components/SliceHeader.jsx
+++ b/superset/assets/javascripts/dashboard/components/SliceHeader.jsx
@@ -1,17 +1,16 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import moment from 'moment';
 
 import { t } from '../../locales';
 import EditableTitle from '../../components/EditableTitle';
 import TooltipWrapper from '../../components/TooltipWrapper';
+import SliceHeaderControls from './SliceHeaderControls';
 
 const propTypes = {
   slice: PropTypes.object.isRequired,
   isExpanded: PropTypes.bool,
   isCached: PropTypes.bool,
   cachedDttm: PropTypes.string,
-  removeSlice: PropTypes.func,
   updateSliceName: PropTypes.func,
   toggleExpandSlice: PropTypes.func,
   forceRefresh: PropTypes.func,
@@ -37,11 +36,6 @@ class SliceHeader extends React.PureComponent {
     super(props);
 
     this.onSaveTitle = this.onSaveTitle.bind(this);
-    this.onToggleExpandSlice = this.onToggleExpandSlice.bind(this);
-    this.exportCSV = this.props.exportCSV.bind(this, this.props.slice);
-    this.exploreChart = this.props.exploreChart.bind(this, this.props.slice);
-    this.forceRefresh = this.props.forceRefresh.bind(this, this.props.slice.slice_id);
-    this.removeSlice = this.props.removeSlice.bind(this, this.props.slice);
   }
 
   onSaveTitle(newTitle) {
@@ -50,17 +44,12 @@ class SliceHeader extends React.PureComponent {
     }
   }
 
-  onToggleExpandSlice() {
-    this.props.toggleExpandSlice(this.props.slice, !this.props.isExpanded);
-  }
-
   render() {
-    const slice = this.props.slice;
-    const isCached = this.props.isCached;
-    const cachedWhen = moment.utc(this.props.cachedDttm).fromNow();
-    const refreshTooltip = isCached ?
-      t('Served from data cached %s . Click to force refresh.', cachedWhen) :
-      t('Force refresh data');
+    const {
+      slice, isExpanded, isCached, cachedDttm,
+      toggleExpandSlice, forceRefresh,
+      exploreChart, exportCSV,
+    } = this.props;
     const annoationsLoading = t('Annotation layers are still loading.');
     const annoationsError = t('One ore more annotation layers failed loading.');
 
@@ -92,79 +81,18 @@ class SliceHeader extends React.PureComponent {
                 <i className="fa fa-exclamation-circle danger" />
               </TooltipWrapper>
             }
-          </div>
-          <div className="chart-controls">
-            <div id={'controls_' + slice.slice_id} className="pull-right">
-              {this.props.editMode &&
-                <a>
-                  <TooltipWrapper
-                    placement="top"
-                    label="move"
-                    tooltip={t('Move chart')}
-                  >
-                    <i className="fa fa-arrows drag" />
-                  </TooltipWrapper>
-                </a>
-              }
-              <a className={`refresh ${isCached ? 'danger' : ''}`} onClick={this.forceRefresh}>
-                <TooltipWrapper
-                  placement="top"
-                  label="refresh"
-                  tooltip={refreshTooltip}
-                >
-                  <i className="fa fa-repeat" />
-                </TooltipWrapper>
-              </a>
-              {slice.description &&
-              <a onClick={this.onToggleExpandSlice}>
-                <TooltipWrapper
-                  placement="top"
-                  label="description"
-                  tooltip={t('Toggle chart description')}
-                >
-                  <i className="fa fa-info-circle slice_info" />
-                </TooltipWrapper>
-              </a>
-              }
-              <a href={slice.edit_url} target="_blank">
-                <TooltipWrapper
-                  placement="top"
-                  label="edit"
-                  tooltip={t('Edit chart')}
-                >
-                  <i className="fa fa-pencil" />
-                </TooltipWrapper>
-              </a>
-              <a className="exportCSV" onClick={this.exportCSV}>
-                <TooltipWrapper
-                  placement="top"
-                  label="exportCSV"
-                  tooltip={t('Export CSV')}
-                >
-                  <i className="fa fa-table" />
-                </TooltipWrapper>
-              </a>
-              <a className="exploreChart" onClick={this.exploreChart}>
-                <TooltipWrapper
-                  placement="top"
-                  label="exploreChart"
-                  tooltip={t('Explore chart')}
-                >
-                  <i className="fa fa-share" />
-                </TooltipWrapper>
-              </a>
-              {this.props.editMode &&
-                <a className="remove-chart" onClick={this.removeSlice}>
-                  <TooltipWrapper
-                    placement="top"
-                    label="close"
-                    tooltip={t('Remove chart from dashboard')}
-                  >
-                    <i className="fa fa-close" />
-                  </TooltipWrapper>
-                </a>
-              }
-            </div>
+            {!this.props.editMode &&
+              <SliceHeaderControls
+                slice={slice}
+                isCached={isCached}
+                isExpanded={isExpanded}
+                cachedDttm={cachedDttm}
+                toggleExpandSlice={toggleExpandSlice}
+                forceRefresh={forceRefresh}
+                exploreChart={exploreChart}
+                exportCSV={exportCSV}
+              />
+            }
           </div>
         </div>
       </div>
diff --git a/superset/assets/javascripts/dashboard/components/SliceHeaderControls.jsx b/superset/assets/javascripts/dashboard/components/SliceHeaderControls.jsx
new file mode 100644
index 0000000000..0e76c882df
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/components/SliceHeaderControls.jsx
@@ -0,0 +1,103 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+import moment from 'moment';
+import { DropdownButton } from 'react-bootstrap';
+
+import { ActionMenuItem } from './ActionMenuItem';
+import { t } from '../../locales';
+
+const propTypes = {
+  slice: PropTypes.object.isRequired,
+  isCached: PropTypes.bool,
+  isExpanded: PropTypes.bool,
+  cachedDttm: PropTypes.string,
+  toggleExpandSlice: PropTypes.func,
+  forceRefresh: PropTypes.func,
+  exploreChart: PropTypes.func,
+  exportCSV: PropTypes.func,
+};
+
+const defaultProps = {
+  forceRefresh: () => ({}),
+  toggleExpandSlice: () => ({}),
+  exploreChart: () => ({}),
+  exportCSV: () => ({}),
+};
+
+class SliceHeaderControls extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.exportCSV = this.props.exportCSV.bind(this, this.props.slice.slice_id);
+    this.exploreChart = this.props.exploreChart.bind(this, this.props.slice.slice_id);
+    this.toggleExpandSlice = this.props.toggleExpandSlice.bind(this, this.props.slice);
+    this.toggleControls = this.toggleControls.bind(this);
+
+    this.state = {
+      showControls: false,
+    };
+  }
+
+  toggleControls() {
+    this.setState({
+      showControls: !this.state.showControls,
+    });
+  }
+
+  render() {
+    const slice = this.props.slice;
+    const isCached = this.props.isCached;
+    const cachedWhen = moment.utc(this.props.cachedDttm).fromNow();
+    const refreshTooltip = isCached ?
+      t('Served from data cached %s . Click to force refresh.', cachedWhen) :
+      t('Force refresh data');
+
+    return (
+      <DropdownButton
+        title=""
+        id={`slice_${slice.slice_id}-controls`}
+        className={cx('slice-header-controls-trigger', 'fa fa-ellipsis-v', { 'is-cached': isCached })}
+        pullRight
+        noCaret
+      >
+        <ActionMenuItem
+          text={t('Force refresh data')}
+          tooltip={refreshTooltip}
+          onClick={this.props.forceRefresh}
+        />
+
+        {slice.description &&
+          <ActionMenuItem
+            text={t('Toggle chart description')}
+            tooltip={t('Toggle chart description')}
+            onClick={() => { this.toggleExpandSlice(!this.props.isExpanded) }}
+          />
+        }
+
+        <ActionMenuItem
+          text={t('Edit chart')}
+          tooltip={t('Edit the chart\'s properties')}
+          href={slice.edit_url}
+          target="_blank"
+        />
+
+        <ActionMenuItem
+          text={t('Export CSV')}
+          tooltip={t('Export CSV')}
+          onClick={this.exportCSV}
+        />
+
+        <ActionMenuItem
+          text={t('Explore chart')}
+          tooltip={t('Explore chart')}
+          onClick={this.exploreChart}
+        />
+      </DropdownButton>
+    );
+  }
+}
+
+SliceHeaderControls.propTypes = propTypes;
+SliceHeaderControls.defaultProps = defaultProps;
+
+export default SliceHeaderControls;
diff --git a/superset/assets/javascripts/dashboard/index.jsx b/superset/assets/javascripts/dashboard/index.jsx
index 774e07101f..84cca16d61 100644
--- a/superset/assets/javascripts/dashboard/index.jsx
+++ b/superset/assets/javascripts/dashboard/index.jsx
@@ -8,22 +8,22 @@ import { initEnhancer } from '../reduxUtils';
 import { appSetup } from '../common';
 import { initJQueryAjax } from '../modules/utils';
 import DashboardContainer from './components/DashboardContainer';
-import rootReducer, { getInitialState } from './reducers';
+import getInitialState from './reducers/initState';
+import rootReducer from './reducers/index';
 
 appSetup();
 initJQueryAjax();
 
 const appContainer = document.getElementById('app');
 const bootstrapData = JSON.parse(appContainer.getAttribute('data-bootstrap'));
-const initState = Object.assign({}, getInitialState(bootstrapData));
+const initState = getInitialState(bootstrapData);
 
 const store = createStore(
   rootReducer, initState, compose(applyMiddleware(thunk), initEnhancer(false)));
-
+console.log(store.getState())
 ReactDOM.render(
   <Provider store={store}>
     <DashboardContainer />
   </Provider>,
   appContainer,
 );
-
diff --git a/superset/assets/javascripts/dashboard/reducers.js b/superset/assets/javascripts/dashboard/reducers.js
deleted file mode 100644
index bf42532edb..0000000000
--- a/superset/assets/javascripts/dashboard/reducers.js
+++ /dev/null
@@ -1,213 +0,0 @@
-import { combineReducers } from 'redux';
-import d3 from 'd3';
-import shortid from 'shortid';
-
-import charts, { chart } from '../chart/chartReducer';
-import * as actions from './actions';
-import { getParam } from '../modules/utils';
-import { alterInArr, removeFromArr } from '../reduxUtils';
-import { applyDefaultFormData } from '../explore/stores/store';
-import { getColorFromScheme } from '../modules/colors';
-
-export function getInitialState(bootstrapData) {
-  const { user_id, datasources, common } = bootstrapData;
-  delete common.locale;
-  delete common.language_pack;
-
-  const dashboard = { ...bootstrapData.dashboard_data };
-  let filters = {};
-  try {
-    // allow request parameter overwrite dashboard metadata
-    filters = JSON.parse(getParam('preselect_filters') || dashboard.metadata.default_filters);
-  } catch (e) {
-    //
-  }
-
-  // Priming the color palette with user's label-color mapping provided in
-  // the dashboard's JSON metadata
-  if (dashboard.metadata && dashboard.metadata.label_colors) {
-    const colorMap = dashboard.metadata.label_colors;
-    for (const label in colorMap) {
-      getColorFromScheme(label, null, colorMap[label]);
-    }
-  }
-
-  dashboard.posDict = {};
-  dashboard.layout = [];
-  if (Array.isArray(dashboard.position_json)) {
-    dashboard.position_json.forEach((position) => {
-      dashboard.posDict[position.slice_id] = position;
-    });
-  } else {
-    dashboard.position_json = [];
-  }
-
-  const lastRowId = Math.max(0, Math.max.apply(null,
-    dashboard.position_json.map(pos => (pos.row + pos.size_y))));
-  let newSliceCounter = 0;
-  dashboard.slices.forEach((slice) => {
-    const sliceId = slice.slice_id;
-    let pos = dashboard.posDict[sliceId];
-    if (!pos) {
-      // append new slices to dashboard bottom, 3 slices per row
-      pos = {
-        col: (newSliceCounter % 3) * 16 + 1,
-        row: lastRowId + Math.floor(newSliceCounter / 3) * 16,
-        size_x: 16,
-        size_y: 16,
-      };
-      newSliceCounter++;
-    }
-
-    dashboard.layout.push({
-      i: String(sliceId),
-      x: pos.col - 1,
-      y: pos.row,
-      w: pos.size_x,
-      minW: 2,
-      h: pos.size_y,
-    });
-  });
-
-  // will use charts action/reducers to handle chart render
-  const initCharts = {};
-  dashboard.slices.forEach((slice) => {
-    const chartKey = 'slice_' + slice.slice_id;
-    initCharts[chartKey] = { ...chart,
-      chartKey,
-      slice_id: slice.slice_id,
-      form_data: slice.form_data,
-      formData: applyDefaultFormData(slice.form_data),
-    };
-  });
-
-  // also need to add formData for dashboard.slices
-  dashboard.slices = dashboard.slices.map(slice =>
-    ({ ...slice, formData: applyDefaultFormData(slice.form_data) }),
-  );
-
-  return {
-    charts: initCharts,
-    dashboard: { filters, dashboard, userId: user_id, datasources, common, editMode: false },
-  };
-}
-
-export const dashboard = function (state = {}, action) {
-  const actionHandlers = {
-    [actions.UPDATE_DASHBOARD_TITLE]() {
-      const newDashboard = { ...state.dashboard, dashboard_title: action.title };
-      return { ...state, dashboard: newDashboard };
-    },
-    [actions.UPDATE_DASHBOARD_LAYOUT]() {
-      const newDashboard = { ...state.dashboard, layout: action.layout };
-      return { ...state, dashboard: newDashboard };
-    },
-    [actions.REMOVE_SLICE]() {
-      const key = String(action.slice.slice_id);
-      const newLayout = state.dashboard.layout.filter(reactPos => (reactPos.i !== key));
-      const newDashboard = removeFromArr(state.dashboard, 'slices', action.slice, 'slice_id');
-      // if this slice is a filter
-      const newFilter = { ...state.filters };
-      let refresh = false;
-      if (state.filters[key]) {
-        delete newFilter[key];
-        refresh = true;
-      }
-      return {
-        ...state,
-        dashboard: { ...newDashboard, layout: newLayout },
-        filters: newFilter,
-        refresh,
-      };
-    },
-    [actions.TOGGLE_FAVE_STAR]() {
-      return { ...state, isStarred: action.isStarred };
-    },
-    [actions.SET_EDIT_MODE]() {
-      return { ...state, editMode: action.editMode };
-    },
-    [actions.TOGGLE_EXPAND_SLICE]() {
-      const updatedExpandedSlices = { ...state.dashboard.metadata.expanded_slices };
-      const sliceId = action.slice.slice_id;
-      if (action.isExpanded) {
-        updatedExpandedSlices[sliceId] = true;
-      } else {
-        delete updatedExpandedSlices[sliceId];
-      }
-      const metadata = { ...state.dashboard.metadata, expanded_slices: updatedExpandedSlices };
-      const newDashboard = { ...state.dashboard, metadata };
-      return { ...state, dashboard: newDashboard };
-    },
-
-    // filters
-    [actions.ADD_FILTER]() {
-      const selectedSlice = state.dashboard.slices
-        .find(slice => (slice.slice_id === action.sliceId));
-      if (!selectedSlice) {
-        return state;
-      }
-
-      let filters = state.filters;
-      const { sliceId, col, vals, merge, refresh } = action;
-      const filterKeys = ['__from', '__to', '__time_col',
-        '__time_grain', '__time_origin', '__granularity'];
-      if (filterKeys.indexOf(col) >= 0 ||
-        selectedSlice.formData.groupby.indexOf(col) !== -1) {
-        let newFilter = {};
-        if (!(sliceId in filters)) {
-          // Straight up set the filters if none existed for the slice
-          newFilter = { [col]: vals };
-        } else if (filters[sliceId] && !(col in filters[sliceId]) || !merge) {
-          newFilter = { ...filters[sliceId], [col]: vals };
-          // d3.merge pass in array of arrays while some value form filter components
-          // from and to filter box require string to be process and return
-        } else if (filters[sliceId][col] instanceof Array) {
-          newFilter[col] = d3.merge([filters[sliceId][col], vals]);
-        } else {
-          newFilter[col] = d3.merge([[filters[sliceId][col]], vals])[0] || '';
-        }
-        filters = { ...filters, [sliceId]: newFilter };
-      }
-      return { ...state, filters, refresh };
-    },
-    [actions.CLEAR_FILTER]() {
-      const newFilters = { ...state.filters };
-      delete newFilters[action.sliceId];
-      return { ...state, filter: newFilters, refresh: true };
-    },
-    [actions.REMOVE_FILTER]() {
-      const { sliceId, col, vals, refresh } = action;
-      const excluded = new Set(vals);
-      const valFilter = val => !excluded.has(val);
-
-      let filters = state.filters;
-      // Have to be careful not to modify the dashboard state so that
-      // the render actually triggers
-      if (sliceId in state.filters && col in state.filters[sliceId]) {
-        const newFilter = filters[sliceId][col].filter(valFilter);
-        filters = { ...filters, [sliceId]: newFilter };
-      }
-      return { ...state, filters, refresh };
-    },
-
-    // slice reducer
-    [actions.UPDATE_SLICE_NAME]() {
-      const newDashboard = alterInArr(
-        state.dashboard, 'slices',
-        action.slice, { slice_name: action.sliceName },
-        'slice_id');
-      return { ...state, dashboard: newDashboard };
-    },
-  };
-
-  if (action.type in actionHandlers) {
-    return actionHandlers[action.type]();
-  }
-  return state;
-};
-
-export default combineReducers({
-  charts,
-  dashboard,
-  impressionId: () => (shortid.generate()),
-});
diff --git a/superset/assets/javascripts/dashboard/reducers/dashboard.js b/superset/assets/javascripts/dashboard/reducers/dashboard.js
new file mode 100644
index 0000000000..37f54d0f11
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/reducers/dashboard.js
@@ -0,0 +1,115 @@
+import { merge as mergeArray } from 'd3';
+
+import * as actions from '../actions/dashboard';
+
+export default function(state = {}, action) {
+  const actionHandlers = {
+    [actions.UPDATE_DASHBOARD_TITLE]() {
+      return { ...state, title: action.title };
+    },
+    [actions.ADD_SLICE]() {
+      const updatedSliceIds = new Set(state.sliceIds);
+      updatedSliceIds.add(action.slice.slice_id);
+      return {
+        ...state,
+        sliceIds: updatedSliceIds,
+      };
+    },
+    [actions.REMOVE_SLICE]() {
+      const sliceId = action.sliceId;
+      const updatedSliceIds = new Set(state.sliceIds);
+      updatedSliceIds.delete(sliceId);
+
+      const key = sliceId;
+      // if this slice is a filter
+      const newFilter = { ...state.filters };
+      let refresh = false;
+      if (state.filters[key]) {
+        delete newFilter[key];
+        refresh = true;
+      }
+      return {
+        ...state,
+        sliceIds: updatedSliceIds,
+        filters: newFilter,
+        refresh,
+      };
+    },
+    [actions.TOGGLE_FAVE_STAR]() {
+      return { ...state, isStarred: action.isStarred };
+    },
+    [actions.SET_EDIT_MODE]() {
+      return { ...state, editMode: action.editMode };
+    },
+    [actions.TOGGLE_BUILDER_PANE]() {
+      return { ...state, showBuilderPane: !state.showBuilderPane };
+    },
+    [actions.TOGGLE_EXPAND_SLICE]() {
+      const updatedExpandedSlices = { ...state.expandedSlices };
+      const sliceId = action.slice.slice_id;
+      if (action.isExpanded) {
+        updatedExpandedSlices[sliceId] = true;
+      } else {
+        delete updatedExpandedSlices[sliceId];
+      }
+      return { ...state, expandedSlices: updatedExpandedSlices };
+    },
+    [actions.ON_CHANGE]() {
+      return { ...state, hasUnsavedChanges: true };
+    },
+    [actions.ON_SAVE]() {
+      return { ...state, hasUnsavedChanges: false };
+    },
+
+    // filters
+    [actions.ADD_FILTER]() {
+      const hasSelectedFilter = state.sliceIds.has(action.chart.id);
+      if (!hasSelectedFilter) {
+        return state;
+      }
+
+      let filters = state.filters;
+      const { chart, col, vals, merge, refresh } = action;
+      const sliceId = chart.id;
+      const filterKeys = ['__from', '__to', '__time_col',
+        '__time_grain', '__time_origin', '__granularity'];
+      if (filterKeys.indexOf(col) >= 0 ||
+        action.chart.formData.groupby.indexOf(col) !== -1) {
+        let newFilter = {};
+        if (!(sliceId in filters)) {
+          // Straight up set the filters if none existed for the slice
+          newFilter = { [col]: vals };
+        } else if (filters[sliceId] && !(col in filters[sliceId]) || !merge) {
+          newFilter = { ...filters[sliceId], [col]: vals };
+          // d3.merge pass in array of arrays while some value form filter components
+          // from and to filter box require string to be process and return
+        } else if (filters[sliceId][col] instanceof Array) {
+          newFilter[col] = mergeArray([filters[sliceId][col], vals]);
+        } else {
+          newFilter[col] = mergeArray([[filters[sliceId][col]], vals])[0] || '';
+        }
+        filters = { ...filters, [sliceId]: newFilter };
+      }
+      return { ...state, filters, refresh };
+    },
+    [actions.REMOVE_FILTER]() {
+      const { sliceId, col, vals, refresh } = action;
+      const excluded = new Set(vals);
+      const valFilter = val => !excluded.has(val);
+
+      let filters = state.filters;
+      // Have to be careful not to modify the dashboard state so that
+      // the render actually triggers
+      if (sliceId in state.filters && col in state.filters[sliceId]) {
+        const newFilter = filters[sliceId][col].filter(valFilter);
+        filters = { ...filters, [sliceId]: newFilter };
+      }
+      return { ...state, filters, refresh };
+    },
+  };
+
+  if (action.type in actionHandlers) {
+    return actionHandlers[action.type]();
+  }
+  return state;
+};
diff --git a/superset/assets/javascripts/dashboard/reducers/datasources.js b/superset/assets/javascripts/dashboard/reducers/datasources.js
new file mode 100644
index 0000000000..4df75071b8
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/reducers/datasources.js
@@ -0,0 +1,17 @@
+import * as actions from '../actions/datasources';
+
+export default function datasourceReducer(datasources = {}, action) {
+  const actionHandlers = {
+    [actions.SET_DATASOURCE]() {
+      return action.datasource;
+    },
+  };
+
+  if (action.type in actionHandlers) {
+    return {
+      ...datasources,
+      [action.key]: actionHandlers[action.type](datasources[action.key], action),
+    };
+  }
+  return datasources;
+}
diff --git a/superset/assets/javascripts/dashboard/reducers/index.js b/superset/assets/javascripts/dashboard/reducers/index.js
new file mode 100644
index 0000000000..5ecf13d508
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/reducers/index.js
@@ -0,0 +1,29 @@
+import { combineReducers } from 'redux';
+import shortid from 'shortid';
+
+import charts from '../../chart/chartReducer';
+import dashboardState from './dashboard';
+import datasources from './datasources';
+import sliceEntities from './sliceEntities';
+import dashboardLayout from '../v2/reducers/index';
+import messageToasts from '../v2/reducers/messageToasts';
+
+const dashboardInfo = (state = {}) => (state);
+
+const impressionId = (state = '') => {
+  if (!state) {
+    state = shortid.generate();
+  }
+  return state;
+};
+
+export default combineReducers({
+  charts,
+  datasources,
+  sliceEntities,
+  dashboardInfo,
+  dashboardState,
+  dashboardLayout,
+  messageToasts,
+  impressionId,
+});
diff --git a/superset/assets/javascripts/dashboard/reducers/initState.js b/superset/assets/javascripts/dashboard/reducers/initState.js
new file mode 100644
index 0000000000..468c42f3da
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/reducers/initState.js
@@ -0,0 +1,106 @@
+import { chart } from '../../chart/chartReducer';
+import { initSliceEntities } from './sliceEntities';
+import { getParam } from '../../modules/utils';
+import { applyDefaultFormData } from '../../explore/stores/store';
+import { getColorFromScheme } from '../../modules/colors';
+import layoutConverter from '../util/dashboardLayoutConverter';
+
+export default function(bootstrapData) {
+  const { user_id, datasources, common } = bootstrapData;
+  delete common.locale;
+  delete common.language_pack;
+
+  const dashboard = { ...bootstrapData.dashboard_data };
+  let filters = {};
+  try {
+    // allow request parameter overwrite dashboard metadata
+    filters = JSON.parse(getParam('preselect_filters') || dashboard.metadata.default_filters);
+  } catch (e) {
+    //
+  }
+
+  // Priming the color palette with user's label-color mapping provided in
+  // the dashboard's JSON metadata
+  if (dashboard.metadata && dashboard.metadata.label_colors) {
+    const colorMap = dashboard.metadata.label_colors;
+    for (const label in colorMap) {
+      getColorFromScheme(label, null, colorMap[label]);
+    }
+  }
+
+  // dashboard layout
+  const positionJson = dashboard.position_json;
+  let layout;
+  if (!positionJson || !positionJson['DASHBOARD_ROOT_ID']) {
+    layout = layoutConverter(dashboard);
+  } else {
+    layout = positionJson;
+  }
+
+  const dashboardLayout = {
+    past: [],
+    present: layout,
+    future: [],
+  };
+  delete dashboard.position_json;
+  delete dashboard.css;
+
+  // charts: dynamic queries
+  // slices: saved data from slices table
+  const charts = {},
+    slices = {},
+    sliceIds = new Set();
+  dashboard.slices.forEach((slice) => {
+    const key = slice.slice_id;
+    charts[key] = { ...chart,
+      id: key,
+      form_data: slice.form_data,
+      formData: applyDefaultFormData(slice.form_data),
+    };
+
+    slices[key] = {
+      slice_id: key,
+      slice_url: slice.slice_url,
+      slice_name: slice.slice_name,
+      form_data: slice.form_data,
+      edit_url: slice.edit_url,
+      viz_type: slice.form_data.viz_type,
+      datasource: slice.form_data.datasource,
+      description: slice.description,
+      description_markeddown: slice.description_markeddown,
+    };
+
+    sliceIds.add(key);
+  });
+
+  return {
+    datasources,
+    sliceEntities: { ...initSliceEntities, slices, isLoading: false },
+    charts,
+    dashboardInfo: {  /* readOnly props */
+      id: dashboard.id,
+      slug: dashboard.slug,
+      metadata: {
+        filter_immune_slice_fields: dashboard.metadata.filter_immune_slice_fields,
+        filter_immune_slices: dashboard.metadata.filter_immune_slices,
+        timed_refresh_immune_slices: dashboard.metadata.timed_refresh_immune_slices,
+      },
+      userId: user_id,
+      dash_edit_perm: dashboard.dash_edit_perm,
+      dash_save_perm: dashboard.dash_save_perm,
+      common,
+    },
+    dashboardState: {
+      title: dashboard.dashboard_title,
+      sliceIds,
+      refresh: false,
+      filters,
+      expandedSlices: dashboard.metadata.expanded_slices || {},
+      editMode: false,
+      showBuilderPane: false,
+      hasUnsavedChanges: false,
+    },
+    dashboardLayout,
+    messageToasts: [],
+  };
+}
diff --git a/superset/assets/javascripts/dashboard/reducers/sliceEntities.js b/superset/assets/javascripts/dashboard/reducers/sliceEntities.js
new file mode 100644
index 0000000000..a086eca07f
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/reducers/sliceEntities.js
@@ -0,0 +1,57 @@
+import * as actions from '../actions/sliceEntities';
+import { t } from '../../locales';
+
+export const initSliceEntities = {
+  slices: {},
+  isLoading: true,
+  errorMessage: null,
+  lastUpdated: 0,
+};
+
+export default function(state = initSliceEntities, action) {
+  const actionHandlers = {
+    [actions.UPDATE_SLICE_NAME]() {
+      const updatedSlice = {
+        ...state.slices[action.key],
+        slice_name: action.sliceName,
+      };
+      const updatedSlices = {
+        ...state.slices,
+        [action.key]: updatedSlice,
+      };
+      return { ...state, slices: updatedSlices };
+    },
+    [actions.FETCH_ALL_SLICES_STARTED]() {
+      return {
+        ...state,
+        isLoading: true,
+      }
+    },
+    [actions.SET_ALL_SLICES]() {
+      return {
+        ...state,
+        isLoading: false,
+        slices: { ...state.slices, ...action.slices }, // append more slices
+        lastUpdated: new Date().getTime(),
+      }
+    },
+    [actions.FETCH_ALL_SLICES_FAILED]() {
+      const respJSON = action.error.responseJSON;
+      const errorMessage =
+        t('Sorry, there was an error adding slices to this dashboard: ') +
+        (respJSON && respJSON.message) ? respJSON.message :
+          error.responseText;
+      return {
+        ...state,
+        isLoading: false,
+        errorMessage,
+        lastUpdated: new Date().getTime(),
+      }
+    }
+  };
+
+  if (action.type in actionHandlers) {
+    return actionHandlers[action.type]();
+  }
+  return state;
+}
\ No newline at end of file
diff --git a/superset/assets/javascripts/dashboard/util/dashboardHelper.js b/superset/assets/javascripts/dashboard/util/dashboardHelper.js
new file mode 100644
index 0000000000..35a803f7a5
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/util/dashboardHelper.js
@@ -0,0 +1,9 @@
+export function getChartIdsFromLayout(layout) {
+  return Object.values(layout)
+    .reduce((chartIds, value) => {
+      if (value && value.meta && value.meta.chartId) {
+        chartIds.push(value.meta.chartId);
+      }
+      return chartIds;
+    }, []);
+}
\ No newline at end of file
diff --git a/superset/assets/javascripts/dashboard/util/dashboardLayoutConverter.js b/superset/assets/javascripts/dashboard/util/dashboardLayoutConverter.js
new file mode 100644
index 0000000000..ade653f937
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/util/dashboardLayoutConverter.js
@@ -0,0 +1,331 @@
+import {
+  ROW_TYPE,
+  COLUMN_TYPE,
+  CHART_TYPE,
+  DASHBOARD_HEADER_TYPE,
+  DASHBOARD_ROOT_TYPE,
+  DASHBOARD_GRID_TYPE,
+} from '../v2/util/componentTypes';
+import {
+  DASHBOARD_GRID_ID,
+  DASHBOARD_HEADER_ID,
+  DASHBOARD_ROOT_ID,
+} from '../v2/util/constants';
+
+const MAX_RECURSIVE_LEVEL = 6;
+const GRID_RATIO = 4;
+const ROW_HEIGHT = 8;
+const generateId = function() {
+  let componentId = 1;
+  return () => (componentId++);
+}();
+
+/**
+ *
+ * @param positions: single array of slices
+ * @returns boundary object {top: number, bottom: number, left: number, right: number}
+ */
+function getBoundary(positions) {
+  let top = Number.MAX_VALUE, bottom = 0,
+    left = Number.MAX_VALUE, right = 1;
+  positions.forEach(item => {
+    const { row, col, size_x, size_y } = item;
+    if (row <= top) top = row;
+    if (col <= left ) left = col;
+    if (bottom <= row + size_y) bottom = row + size_y;
+    if (right <= col + size_x) right = col + size_x;
+  });
+
+  return {
+    top,
+    bottom,
+    left,
+    right
+  };
+}
+
+function getRowContainer() {
+  const id = 'DASHBOARD_ROW_TYPE-' + generateId();
+  return {
+    version: 'v2',
+    type: ROW_TYPE,
+    id,
+    children: [],
+    meta: {
+      background: 'BACKGROUND_TRANSPARENT',
+    },
+  };
+}
+
+function getColContainer() {
+  const id = 'DASHBOARD_COLUMN_TYPE-' + generateId();
+  return {
+    version: 'v2',
+    type: COLUMN_TYPE,
+    id,
+    children: [],
+    meta: {
+      background: 'BACKGROUND_TRANSPARENT',
+    },
+  };
+}
+
+function getChartHolder(item) {
+  const { row, col, size_x, size_y, slice_id } = item;
+  const converted = {
+    row: Math.round(row / GRID_RATIO),
+    col: Math.floor((col - 1) / GRID_RATIO) + 1,
+    size_x: Math.max(1, Math.floor(size_x / GRID_RATIO)),
+    size_y: Math.max(1, Math.round(size_y / GRID_RATIO)),
+    slice_id,
+  };
+
+  return {
+    version: 'v2',
+    type: CHART_TYPE,
+    id: 'DASHBOARD_CHART_TYPE-' + generateId(),
+    children: [],
+    meta: {
+      width: converted.size_x,
+      height: Math.round(converted.size_y * 100 / ROW_HEIGHT ),
+      chartId: slice_id,
+    },
+  };
+}
+
+function getChildrenMax(items, attr, layout) {
+  return Math.max.apply(null, items.map(child => {
+    return layout[child].meta[attr];
+  }));
+}
+
+function getChildrenSum(items, attr, layout) {
+  return items.reduce((preValue, child) => {
+    return preValue + layout[child].meta[attr];
+  }, 0);
+}
+
+function sortByRowId(item1, item2) {
+  return item1.row - item2.row;
+}
+
+function sortByColId(item1, item2) {
+  return item1.col - item2.col;
+}
+
+function hasOverlap(positions, xAxis = true) {
+  return positions.slice()
+    .sort(!!xAxis ? sortByColId : sortByRowId)
+    .some((item, index, arr) => {
+      if (index === arr.length - 1) {
+        return false;
+      }
+
+      if (!!xAxis) {
+        return (item.col + item.size_x) > arr[index + 1].col;
+      } else {
+        return (item.row + item.size_y) > arr[index + 1].row;
+      }
+    });
+}
+
+function doConvert(positions, level, parent, root) {
+  if (positions.length === 0) {
+    return;
+  }
+
+  if (positions.length === 1 || level >= MAX_RECURSIVE_LEVEL) {
+    // special treatment for single chart dash, always wrap chart inside a row
+    if (parent.type === 'DASHBOARD_GRID_TYPE') {
+      const rowContainer = getRowContainer();
+      root[rowContainer.id] = rowContainer;
+      parent.children.push(rowContainer.id);
+      parent = rowContainer;
+    }
+
+    const chartHolder = getChartHolder(positions[0]);
+    root[chartHolder.id] = chartHolder;
+    parent.children.push(chartHolder.id);
+    return;
+  }
+
+  let currentItems = positions.slice();
+  const { top, bottom, left, right } = getBoundary(positions);
+  // find row dividers
+  const layers = [];
+  let currentRow = top + 1;
+  while (currentItems.length && currentRow <= bottom) {
+    const upper = [],
+      lower = [];
+
+    const isRowDivider = currentItems.every(item => {
+      const { row, col, size_x, size_y } = item;
+      if (row + size_y <= currentRow) {
+        lower.push(item);
+        return true;
+      } else if (row >= currentRow) {
+        upper.push(item);
+        return true;
+      } else {
+        return false;
+      }
+    });
+
+    if (isRowDivider) {
+      currentItems = upper.slice();
+      layers.push(lower);
+    }
+    currentRow++;
+  }
+
+  layers.forEach((layer) => {
+    if (layer.length === 0) {
+      return;
+    }
+
+    if (layer.length === 1) {
+      const chartHolder = getChartHolder(layer[0]);
+      root[chartHolder.id] = chartHolder;
+      parent.children.push(chartHolder.id);
+      return;
+    }
+
+    // create a new row
+    const rowContainer = getRowContainer();
+    root[rowContainer.id] = rowContainer;
+    parent.children.push(rowContainer.id);
+
+    currentItems = layer.slice();
+    if (!hasOverlap(currentItems)) {
+      currentItems.sort(sortByColId).forEach(item => {
+        const chartHolder = getChartHolder(item);
+        root[chartHolder.id] = chartHolder;
+        rowContainer.children.push(chartHolder.id);
+      });
+    } else {
+      // find col dividers for each layer
+      let currentCol = left + 1;
+      while (currentItems.length && currentCol <= right) {
+        const upper = [],
+          lower = [];
+
+        const isColDivider = currentItems.every(item => {
+          const { row, col, size_x, size_y } = item;
+          if (col + size_x <= currentCol) {
+            lower.push(item);
+            return true;
+          } else if (col >= currentCol) {
+            upper.push(item);
+            return true;
+          } else {
+            return false;
+          }
+        });
+
+        if (isColDivider) {
+          if (lower.length === 1) {
+            const chartHolder = getChartHolder(lower[0]);
+            root[chartHolder.id] = chartHolder;
+            rowContainer.children.push(chartHolder.id);
+          } else {
+            // create a new column
+            const colContainer = getColContainer();
+            root[colContainer.id] = colContainer;
+            rowContainer.children.push(colContainer.id);
+
+            if (!hasOverlap(lower, false)) {
+              lower.sort(sortByRowId).forEach(item => {
+                const chartHolder = getChartHolder(item);
+                root[chartHolder.id] = chartHolder;
+                colContainer.children.push(chartHolder.id);
+              });
+            } else {
+              doConvert(lower, level+2, colContainer, root);
+            }
+
+            // add col meta
+            colContainer.meta.width = getChildrenMax(colContainer.children, 'width', root);
+            colContainer.meta.height = getChildrenSum(colContainer.children, 'height', root);
+          }
+
+          currentItems = upper.slice();
+        }
+        currentCol++;
+      }
+    }
+
+    rowContainer.meta.width = getChildrenSum(rowContainer.children, 'width', root);
+    rowContainer.meta.height = getChildrenMax(rowContainer.children, 'height', root);
+  });
+}
+
+export default function(dashboard) {
+  const positions = [];
+
+  // position data clean up. some dashboard didn't have position_json
+  let { position_json, posDict = {} } = dashboard;
+  if (Array.isArray(position_json)) {
+    position_json.forEach((position) => {
+      posDict[position.slice_id] = position;
+    });
+  } else {
+    position_json = [];
+  }
+
+  const lastRowId = Math.max(0, Math.max.apply(null,
+    position_json.map(pos => (pos.row + pos.size_y))));
+  let newSliceCounter = 0;
+  dashboard.slices.forEach((slice) => {
+    const sliceId = slice.slice_id;
+    let pos = posDict[sliceId];
+    if (!pos) {
+      // append new slices to dashboard bottom, 3 slices per row
+      pos = {
+        col: (newSliceCounter % 3) * 16 + 1,
+        row: lastRowId + Math.floor(newSliceCounter / 3) * 16,
+        size_x: 16,
+        size_y: 16,
+        slice_id: String(sliceId),
+      };
+      newSliceCounter++;
+    }
+
+    positions.push(pos);
+  });
+
+  const root = {
+    [DASHBOARD_ROOT_ID]: {
+      version: 'v2',
+      type: DASHBOARD_ROOT_TYPE,
+      id: DASHBOARD_ROOT_ID,
+      children: [DASHBOARD_GRID_ID],
+    },
+    [DASHBOARD_GRID_ID]: {
+      type: DASHBOARD_GRID_TYPE,
+      id: DASHBOARD_GRID_ID,
+      children: [],
+    },
+    [DASHBOARD_HEADER_ID]: {
+      type: DASHBOARD_HEADER_TYPE,
+      id: DASHBOARD_HEADER_ID,
+    },
+  };
+  doConvert(positions, 0, root[DASHBOARD_GRID_ID], root);
+
+  // remove row's width/height and col's height
+  Object.values(root).forEach(item => {
+    if (ROW_TYPE === item.type) {
+      const meta = item.meta;
+      delete meta.width;
+      delete meta.height;
+    }
+    if (COLUMN_TYPE === item.type) {
+      const meta = item.meta;
+      delete meta.height;
+    }
+  });
+
+  // console.log(JSON.stringify(root));
+  return root;
+}
+
diff --git a/superset/assets/javascripts/dashboard/v2/.eslintrc b/superset/assets/javascripts/dashboard/v2/.eslintrc
new file mode 100644
index 0000000000..70efc15a3a
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/.eslintrc
@@ -0,0 +1,29 @@
+{
+  "rules": {
+    "prefer-template": 2,
+    "new-cap": 2,
+    "no-restricted-syntax": 2,
+    "guard-for-in": 2,
+    "prefer-arrow-callback": 2,
+    "func-names": 2,
+    "react/jsx-no-bind": 2,
+    "no-confusing-arrow": 2,
+    "jsx-a11y/no-static-element-interactions": 2,
+    "jsx-a11y/anchor-has-content": 2,
+    "react/require-default-props": 2,
+    "no-plusplus": 2,
+    "no-mixed-operators": 2,
+    "no-continue": 2,
+    "no-bitwise": 2,
+    "no-undef": 2,
+    "no-multi-assign": 2,
+    "no-restricted-properties": 2,
+    "no-prototype-builtins": 2,
+    "jsx-a11y/href-no-hash": 2,
+    "class-methods-use-this": 2,
+    "import/no-named-as-default": 2,
+    "import/prefer-default-export": 2,
+    "react/no-unescaped-entities": 2,
+    "react/no-string-refs": 2,
+  }
+}
diff --git a/superset/assets/javascripts/dashboard/v2/actions/dashboardLayout.js b/superset/assets/javascripts/dashboard/v2/actions/dashboardLayout.js
new file mode 100644
index 0000000000..b6d41c44b8
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/actions/dashboardLayout.js
@@ -0,0 +1,157 @@
+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';
+
+// Component CRUD -------------------------------------------------------------
+export const UPDATE_COMPONENTS = 'UPDATE_COMPONENTS';
+export function updateComponents(nextComponents) {
+  return {
+    type: UPDATE_COMPONENTS,
+    payload: {
+      nextComponents,
+    },
+  };
+}
+
+export const DELETE_COMPONENT = 'DELETE_COMPONENT';
+export function deleteComponent(id, parentId) {
+  return {
+    type: DELETE_COMPONENT,
+    payload: {
+      id,
+      parentId,
+    },
+  };
+}
+
+export const CREATE_COMPONENT = 'CREATE_COMPONENT';
+export function createComponent(dropResult) {
+  return {
+    type: CREATE_COMPONENT,
+    payload: {
+      dropResult,
+    },
+  };
+}
+
+// Tabs -----------------------------------------------------------------------
+export const CREATE_TOP_LEVEL_TABS = 'CREATE_TOP_LEVEL_TABS';
+export function createTopLevelTabs(dropResult) {
+  return {
+    type: CREATE_TOP_LEVEL_TABS,
+    payload: {
+      dropResult,
+    },
+  };
+}
+
+export const DELETE_TOP_LEVEL_TABS = 'DELETE_TOP_LEVEL_TABS';
+export function deleteTopLevelTabs() {
+  return {
+    type: DELETE_TOP_LEVEL_TABS,
+    payload: {},
+  };
+}
+
+// Resize ---------------------------------------------------------------------
+export const RESIZE_COMPONENT = 'RESIZE_COMPONENT';
+export function resizeComponent({ id, width, height }) {
+  return (dispatch, getState) => {
+    const { dashboardLayout: undoableLayout } = getState();
+    const { present: dashboard } = undoableLayout;
+    const component = dashboard[id];
+
+    if (
+      component &&
+      (component.meta.width !== width || component.meta.height !== height)
+    ) {
+      // update the size of this component + any resizable children
+      const updatedComponents = {
+        [id]: {
+          ...component,
+          meta: {
+            ...component.meta,
+            width: width || component.meta.width,
+            height: height || component.meta.height,
+          },
+        },
+      };
+
+      component.children.forEach((childId) => {
+        const child = dashboard[childId];
+        if ([CHART_TYPE, MARKDOWN_TYPE].includes(child.type)) {
+          updatedComponents[childId] = {
+            ...child,
+            meta: {
+              ...child.meta,
+              width: width || child.meta.width,
+              height: height || child.meta.height,
+            },
+          };
+        }
+      });
+
+      dispatch(updateComponents(updatedComponents));
+    }
+  };
+}
+
+// Drag and drop --------------------------------------------------------------
+export const MOVE_COMPONENT = 'MOVE_COMPONENT';
+export function moveComponent(dropResult) {
+  return {
+    type: MOVE_COMPONENT,
+    payload: {
+      dropResult,
+    },
+  };
+}
+
+export const HANDLE_COMPONENT_DROP = 'HANDLE_COMPONENT_DROP';
+export function handleComponentDrop(dropResult) {
+  return (dispatch, getState) => {
+    const overflowsParent = dropOverflowsParent(dropResult, getState().dashboardLayout.present);
+
+    if (overflowsParent) {
+      return dispatch(addInfoToast(
+        `Parent does not have enough space for this component.
+         Try decreasing its width or add it to a new row.`,
+      ));
+    }
+
+    const { source, destination } = dropResult;
+    const droppedOnRoot = destination && destination.id === DASHBOARD_ROOT_ID;
+    const isNewComponent = source.id === NEW_COMPONENTS_SOURCE_ID;
+
+    if (droppedOnRoot) {
+      dispatch(createTopLevelTabs(dropResult));
+    } else if (destination && isNewComponent) {
+      dispatch(createComponent(dropResult));
+    } else if (
+      destination
+      && source
+      && !( // ensure it has moved
+        destination.id === source.id
+        && destination.index === source.index
+      )
+    ) {
+      dispatch(moveComponent(dropResult));
+    }
+
+    // if we moved a Tab and the parent Tabs no longer has children, delete it.
+    if (!isNewComponent) {
+      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: layout });
+        dispatch(deleteComponent(source.id, parentId));
+      }
+    }
+
+    return null;
+  };
+}
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 0000000000..0a849ea190
--- /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 0000000000..af10eadf04
--- /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
new file mode 100644
index 0000000000..8f20e1ab2d
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/BuilderComponentPane.jsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import NewColumn from './gridComponents/new/NewColumn';
+import NewDivider from './gridComponents/new/NewDivider';
+import NewHeader from './gridComponents/new/NewHeader';
+import NewRow from './gridComponents/new/NewRow';
+import NewTabs from './gridComponents/new/NewTabs';
+import SliceAdderContainer from '../../../dashboard/components/SliceAdderContainer';
+
+const propTypes = {
+  editMode: PropTypes.bool,
+};
+
+class BuilderComponentPane extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {
+      showSlices: false,
+    };
+
+    this.openSlicesPane = this.showSlices.bind(this, true);
+    this.closeSlicesPane = this.showSlices.bind(this, false);
+  }
+
+  showSlices(show) {
+    this.setState({
+      showSlices: show,
+    })
+  }
+
+  render() {
+    return (
+      <div className="dashboard-builder-sidepane">
+        <div className="dashboard-builder-sidepane-header">
+          Insert components
+          {this.state.showSlices &&
+            <i className="fa fa-times close trigger" onClick={this.closeSlicesPane}/>
+          }
+        </div>
+
+        <div className="component-layer">
+          <div className="dragdroppable dragdroppable-row" onClick={this.openSlicesPane}>
+            <div className="new-component static">
+              <div className="new-component-placeholder fa fa-area-chart" />
+              Chart
+              <i className="fa fa-arrow-right open trigger" />
+            </div>
+          </div>
+
+          <NewHeader />
+        <NewDivider />
+
+
+          <NewTabs />
+          <NewRow />
+          <NewColumn />
+        </div>
+
+        <div className={cx('slices-layer', this.state.showSlices && 'show')}>
+          <SliceAdderContainer />
+        </div>
+      </div>
+    );
+  }
+}
+
+BuilderComponentPane.propTypes = propTypes;
+
+export default BuilderComponentPane;
diff --git a/superset/assets/javascripts/dashboard/v2/components/Dashboard.jsx b/superset/assets/javascripts/dashboard/v2/components/Dashboard.jsx
new file mode 100644
index 0000000000..ffd1280f9a
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/Dashboard.jsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import DashboardBuilder from '../containers/DashboardBuilder';
+
+import '../stylesheets/index.less';
+
+const propTypes = {
+  actions: PropTypes.shape({
+    updateDashboardTitle: PropTypes.func.isRequired,
+    setEditMode: PropTypes.func.isRequired,
+  }),
+  editMode: PropTypes.bool,
+};
+
+const defaultProps = {
+  editMode: true,
+};
+
+class Dashboard extends React.Component {
+  render() {
+    // @TODO delete this component?
+    return <DashboardBuilder />;
+  }
+}
+
+Dashboard.propTypes = propTypes;
+Dashboard.defaultProps = defaultProps;
+
+export default Dashboard;
diff --git a/superset/assets/javascripts/dashboard/v2/components/DashboardBuilder.jsx b/superset/assets/javascripts/dashboard/v2/components/DashboardBuilder.jsx
new file mode 100644
index 0000000000..f3f58673da
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/DashboardBuilder.jsx
@@ -0,0 +1,134 @@
+import cx from 'classnames';
+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 '../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 {
+  DASHBOARD_GRID_ID,
+  DASHBOARD_ROOT_ID,
+  DASHBOARD_ROOT_DEPTH,
+} from '../util/constants';
+
+const propTypes = {
+  cells: PropTypes.object.isRequired,
+
+  // redux
+  dashboardLayout: PropTypes.object.isRequired,
+  deleteTopLevelTabs: PropTypes.func.isRequired,
+  editMode: PropTypes.bool.isRequired,
+  showBuilderPane: PropTypes.bool,
+  handleComponentDrop: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+  showBuilderPane: false,
+};
+
+class DashboardBuilder extends React.Component {
+  static shouldFocusTabs(event, container) {
+    // don't focus the tabs when we click on a tab
+    return event.target.tagName === 'UL' || (
+      /icon-button/.test(event.target.className) && container.contains(event.target)
+    );
+  }
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      tabIndex: 0, // top-level tabs
+    };
+    this.handleChangeTab = this.handleChangeTab.bind(this);
+  }
+
+  handleChangeTab({ tabIndex }) {
+    this.setState(() => ({ tabIndex }));
+  }
+
+  render() {
+    const { tabIndex } = this.state;
+    const { handleComponentDrop, dashboardLayout, deleteTopLevelTabs, editMode } = this.props;
+    const dashboardRoot = dashboardLayout[DASHBOARD_ROOT_ID];
+    const rootChildId = dashboardRoot.children[0];
+    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 = dashboardLayout[gridComponentId];
+
+    return (
+      <div className={cx('dashboard-v2', editMode && 'dashboard-v2--editing')}>
+        {topLevelTabs || !editMode ? ( // you cannot drop on/displace tabs if they already exist
+          <DashboardHeader />
+        ) : (
+          <DragDroppable
+            component={dashboardRoot}
+            parentComponent={null}
+            depth={DASHBOARD_ROOT_DEPTH}
+            index={0}
+            orientation="column"
+            onDrop={handleComponentDrop}
+            editMode
+          >
+            {({ dropIndicatorProps }) => (
+              <div>
+                <DashboardHeader />
+                {dropIndicatorProps && <div {...dropIndicatorProps} />}
+              </div>
+            )}
+          </DragDroppable>)}
+
+        {topLevelTabs &&
+          <WithPopoverMenu
+            shouldFocus={DashboardBuilder.shouldFocusTabs}
+            menuItems={[
+              <IconButton
+                className="fa fa-level-down"
+                label="Collapse tab content"
+                onClick={deleteTopLevelTabs}
+              />,
+            ]}
+            editMode={editMode}
+          >
+            <DashboardComponent
+              id={topLevelTabs.id}
+              parentId={DASHBOARD_ROOT_ID}
+              depth={DASHBOARD_ROOT_DEPTH + 1}
+              index={0}
+              renderTabContent={false}
+              onChangeTab={this.handleChangeTab}
+              cells={this.props.cells}
+            />
+          </WithPopoverMenu>}
+
+        <div className="dashboard-content">
+          <DashboardGrid
+            gridComponent={gridComponent}
+            depth={DASHBOARD_ROOT_DEPTH + 1}
+            cells={this.props.cells}
+          />
+          {this.props.editMode && this.props.showBuilderPane &&
+            <BuilderComponentPane />
+          }
+        </div>
+        <ToastPresenter />
+      </div>
+    );
+  }
+}
+
+DashboardBuilder.propTypes = propTypes;
+DashboardBuilder.defaultProps = defaultProps;
+
+export default DragDropContext(HTML5Backend)(DashboardBuilder);
diff --git a/superset/assets/javascripts/dashboard/v2/components/DashboardGrid.jsx b/superset/assets/javascripts/dashboard/v2/components/DashboardGrid.jsx
new file mode 100644
index 0000000000..2aa82af2d7
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/DashboardGrid.jsx
@@ -0,0 +1,150 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ParentSize from '@vx/responsive/build/components/ParentSize';
+
+import { componentShape } from '../util/propShapes';
+import DashboardComponent from '../containers/DashboardComponent';
+import DragDroppable from './dnd/DragDroppable';
+
+import {
+  GRID_GUTTER_SIZE,
+  GRID_COLUMN_COUNT,
+} from '../util/constants';
+
+const propTypes = {
+  depth: PropTypes.number.isRequired,
+  editMode: PropTypes.bool.isRequired,
+  gridComponent: componentShape.isRequired,
+  handleComponentDrop: PropTypes.func.isRequired,
+  resizeComponent: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+};
+
+class DashboardGrid extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {
+      isResizing: false,
+      rowGuideTop: null,
+    };
+
+    this.handleResizeStart = this.handleResizeStart.bind(this);
+    this.handleResize = this.handleResize.bind(this);
+    this.handleResizeStop = this.handleResizeStop.bind(this);
+    this.getRowGuidePosition = this.getRowGuidePosition.bind(this);
+  }
+
+  getRowGuidePosition(resizeRef) {
+    if (resizeRef && this.grid) {
+      return resizeRef.getBoundingClientRect().bottom - this.grid.getBoundingClientRect().top - 1;
+    }
+    return null;
+  }
+
+  handleResizeStart({ ref, direction }) {
+    let rowGuideTop = null;
+    if (direction === 'bottom' || direction === 'bottomRight') {
+      rowGuideTop = this.getRowGuidePosition(ref);
+    }
+
+    this.setState(() => ({
+      isResizing: true,
+      rowGuideTop,
+    }));
+  }
+
+  handleResize({ ref, direction }) {
+    if (direction === 'bottom' || direction === 'bottomRight') {
+      this.setState(() => ({ rowGuideTop: this.getRowGuidePosition(ref) }));
+    }
+  }
+
+  handleResizeStop({ id, widthMultiple: width, heightMultiple: height }) {
+    this.props.resizeComponent({ id, width, height });
+
+    this.setState(() => ({
+      isResizing: false,
+      rowGuideTop: null,
+    }));
+  }
+
+  render() {
+    const { gridComponent, handleComponentDrop, depth, editMode, cells } = this.props;
+    const { isResizing, rowGuideTop } = this.state;
+
+    return (
+      <div className="grid-container" ref={(ref) => { this.grid = ref; }}>
+        <ParentSize>
+          {({ width }) => {
+            // account for (COLUMN_COUNT - 1) gutters
+            const columnPlusGutterWidth = (width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT;
+            const columnWidth = columnPlusGutterWidth - GRID_GUTTER_SIZE;
+
+            return width < 50 ? null : (
+              <div className="grid-content">
+                {gridComponent.children.map((id, index) => (
+                  <DashboardComponent
+                    key={id}
+                    id={id}
+                    parentId={gridComponent.id}
+                    depth={depth + 1}
+                    index={index}
+                    availableColumnCount={GRID_COLUMN_COUNT}
+                    columnWidth={columnWidth}
+                    cells={cells}
+                    onResizeStart={this.handleResizeStart}
+                    onResize={this.handleResize}
+                    onResizeStop={this.handleResizeStop}
+                  />
+                ))}
+
+                {/* render an empty drop target */}
+                {editMode &&
+                  <DragDroppable
+                    component={gridComponent}
+                    depth={depth}
+                    parentComponent={null}
+                    index={gridComponent.children.length}
+                    orientation="column"
+                    onDrop={handleComponentDrop}
+                    className="empty-grid-droptarget"
+                    editMode
+                  >
+                    {({ dropIndicatorProps }) => dropIndicatorProps &&
+                      <div className="drop-indicator drop-indicator--top" />}
+                  </DragDroppable>}
+
+                {isResizing && Array(GRID_COLUMN_COUNT).fill(null).map((_, i) => (
+                  <div
+                    key={`grid-column-${i}`}
+                    className="grid-column-guide"
+                    style={{
+                      left: (i * GRID_GUTTER_SIZE) + (i * columnWidth),
+                      width: columnWidth,
+                    }}
+                  />
+                ))}
+
+                {isResizing && rowGuideTop &&
+                  <div
+                    className="grid-row-guide"
+                    style={{
+                      top: rowGuideTop,
+                      width,
+                    }}
+                  />}
+              </div>
+            );
+          }}
+        </ParentSize>
+      </div>
+    );
+  }
+}
+
+DashboardGrid.propTypes = propTypes;
+DashboardGrid.defaultProps = defaultProps;
+
+export default DashboardGrid;
diff --git a/superset/assets/javascripts/dashboard/v2/components/DashboardHeader.jsx b/superset/assets/javascripts/dashboard/v2/components/DashboardHeader.jsx
new file mode 100644
index 0000000000..d3ec7ac26a
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/DashboardHeader.jsx
@@ -0,0 +1,99 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { ButtonGroup, ButtonToolbar, DropdownButton, MenuItem } from 'react-bootstrap';
+
+import Button from '../../../components/Button';
+import { componentShape } from '../util/propShapes';
+import EditableTitle from '../../../components/EditableTitle';
+
+const propTypes = {
+  editMode: PropTypes.bool.isRequired,
+  component: componentShape.isRequired,
+
+  // redux
+  updateComponents: PropTypes.func.isRequired,
+  onUndo: PropTypes.func.isRequired,
+  onRedo: PropTypes.func.isRequired,
+  canUndo: PropTypes.bool.isRequired,
+  canRedo: PropTypes.bool.isRequired,
+  setEditMode: PropTypes.func.isRequired,
+};
+
+class DashboardHeader extends React.Component {
+  constructor(props) {
+    super(props);
+    this.handleChangeText = this.handleChangeText.bind(this);
+    this.toggleEditMode = this.toggleEditMode.bind(this);
+  }
+
+  toggleEditMode() {
+    this.props.setEditMode(!this.props.editMode);
+  }
+
+  handleChangeText(nextText) {
+    const { updateComponents, component } = this.props;
+    if (nextText && component.meta.text !== nextText) {
+      updateComponents({
+        [component.id]: {
+          ...component,
+          meta: {
+            ...component.meta,
+            text: nextText,
+          },
+        },
+      });
+    }
+  }
+
+  render() {
+    const { component, onUndo, onRedo, canUndo, canRedo, editMode } = this.props;
+
+    return (
+      <div className="dashboard-header">
+        <div className="dashboard-component-header header-large">
+          <EditableTitle
+            title={'Test title'}
+            onSaveTitle={this.handleChangeText}
+            showTooltip={false}
+            canEdit={editMode}
+          />
+        </div>
+        <ButtonToolbar>
+          <ButtonGroup>
+            <Button
+              bsSize="small"
+              onClick={onUndo}
+              disabled={!canUndo}
+            >
+              Undo
+            </Button>
+            <Button
+              bsSize="small"
+              onClick={onRedo}
+              disabled={!canRedo}
+            >
+              Redo
+            </Button>
+          </ButtonGroup>
+
+          <DropdownButton title="Actions" bsSize="small" id="btn-dashboard-actions">
+            <MenuItem>Action 1</MenuItem>
+            <MenuItem>Action 2</MenuItem>
+            <MenuItem>Action 3</MenuItem>
+          </DropdownButton>
+
+          <Button
+            bsStyle="primary"
+            onClick={this.toggleEditMode}
+          >
+            {editMode ? 'Save changes' : 'Edit dashboard'}
+          </Button>
+        </ButtonToolbar>
+      </div>
+    );
+  }
+}
+
+DashboardHeader.propTypes = propTypes;
+
+export default DashboardHeader;
diff --git a/superset/assets/javascripts/dashboard/v2/components/DeleteComponentButton.jsx b/superset/assets/javascripts/dashboard/v2/components/DeleteComponentButton.jsx
new file mode 100644
index 0000000000..18efff43ac
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/DeleteComponentButton.jsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import IconButton from './IconButton';
+
+const propTypes = {
+  onDelete: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+};
+
+export default class DeleteComponentButton extends React.PureComponent {
+  render() {
+    const { onDelete } = this.props;
+    return (
+      <IconButton onClick={onDelete} className="fa fa-trash" />
+    );
+  }
+}
+
+DeleteComponentButton.propTypes = propTypes;
+DeleteComponentButton.defaultProps = defaultProps;
diff --git a/superset/assets/javascripts/dashboard/v2/components/IconButton.jsx b/superset/assets/javascripts/dashboard/v2/components/IconButton.jsx
new file mode 100644
index 0000000000..18fd3b1030
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/IconButton.jsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const propTypes = {
+  onClick: PropTypes.func.isRequired,
+  className: PropTypes.string,
+  label: PropTypes.string,
+};
+
+const defaultProps = {
+  className: null,
+  label: null,
+};
+
+export default class IconButton extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick(event) {
+    event.preventDefault();
+    const { onClick } = this.props;
+    onClick(event);
+  }
+
+  render() {
+    const { className, label } = this.props;
+    return (
+      <div
+        className="icon-button"
+        onClick={this.handleClick}
+        tabIndex="0"
+        role="button"
+      >
+        <span className={className} />
+        {label && <span className="icon-button-label">{label}</span>}
+      </div>
+    );
+  }
+}
+
+IconButton.propTypes = propTypes;
+IconButton.defaultProps = defaultProps;
diff --git a/superset/assets/javascripts/dashboard/v2/components/StaticDashboard.jsx b/superset/assets/javascripts/dashboard/v2/components/StaticDashboard.jsx
new file mode 100644
index 0000000000..4fd239779d
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/StaticDashboard.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+// import PropTypes from 'prop-types';
+
+const propTypes = {
+};
+
+class StaticDashboard extends React.Component {
+  render() {
+    return (
+      <div>
+        Static dashboard ...
+      </div>
+    );
+  }
+}
+
+StaticDashboard.propTypes = propTypes;
+
+export default StaticDashboard;
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 0000000000..537388daa8
--- /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 0000000000..95a0251e01
--- /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
new file mode 100644
index 0000000000..775e092836
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/dnd/DragDroppable.jsx
@@ -0,0 +1,117 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { DragSource, DropTarget } from 'react-dnd';
+import cx from 'classnames';
+
+import { componentShape } from '../../util/propShapes';
+import { dragConfig, dropConfig } from './dragDroppableConfig';
+import { DROP_TOP, DROP_RIGHT, DROP_BOTTOM, DROP_LEFT } from '../../util/getDropPosition';
+
+const propTypes = {
+  children: PropTypes.func,
+  className: PropTypes.string,
+  component: componentShape.isRequired,
+  parentComponent: componentShape,
+  depth: PropTypes.number.isRequired,
+  disableDragDrop: PropTypes.bool,
+  orientation: PropTypes.oneOf(['row', 'column']),
+  index: PropTypes.number.isRequired,
+  style: PropTypes.object,
+  onDrop: PropTypes.func,
+  editMode: PropTypes.bool.isRequired,
+
+  // from react-dnd
+  isDragging: PropTypes.bool.isRequired,
+  isDraggingOver: PropTypes.bool.isRequired,
+  isDraggingOverShallow: PropTypes.bool.isRequired,
+  droppableRef: PropTypes.func.isRequired,
+  dragSourceRef: PropTypes.func.isRequired,
+  dragPreviewRef: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+  className: null,
+  style: null,
+  parentComponent: null,
+  disableDragDrop: false,
+  children() {},
+  onDrop() {},
+  orientation: 'row',
+};
+
+class DragDroppable extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      dropIndicator: null, // this gets set/modified by the react-dnd HOCs
+    };
+    this.setRef = this.setRef.bind(this);
+  }
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  setRef(ref) {
+    this.ref = ref;
+    this.props.dragPreviewRef(ref);
+    this.props.droppableRef(ref);
+  }
+
+  render() {
+    const {
+      children,
+      className,
+      orientation,
+      dragSourceRef,
+      isDragging,
+      isDraggingOver,
+      style,
+      editMode,
+    } = this.props;
+
+    if (!editMode) return children({});
+
+    const { dropIndicator } = this.state;
+
+    return (
+      <div
+        style={style}
+        ref={this.setRef}
+        className={cx(
+          'dragdroppable',
+          orientation === 'row' && 'dragdroppable-row',
+          orientation === 'column' && 'dragdroppable-column',
+          isDragging && 'dragdroppable--dragging',
+          className,
+        )}
+      >
+        {children({
+          dragSourceRef,
+          dropIndicatorProps: isDraggingOver && dropIndicator && {
+            className: cx(
+              'drop-indicator',
+              dropIndicator === DROP_TOP && 'drop-indicator--top',
+              dropIndicator === DROP_BOTTOM && 'drop-indicator--bottom',
+              dropIndicator === DROP_LEFT && 'drop-indicator--left',
+              dropIndicator === DROP_RIGHT && 'drop-indicator--right',
+            ),
+          },
+        })}
+      </div>
+    );
+  }
+}
+
+DragDroppable.propTypes = propTypes;
+DragDroppable.defaultProps = defaultProps;
+
+// note that the composition order here determines using
+// component.method() vs decoratedComponentInstance.method() in the drag/drop config
+export default DropTarget(...dropConfig)(
+  DragSource(...dragConfig)(DragDroppable),
+);
diff --git a/superset/assets/javascripts/dashboard/v2/components/dnd/DragHandle.jsx b/superset/assets/javascripts/dashboard/v2/components/dnd/DragHandle.jsx
new file mode 100644
index 0000000000..36d1e6beff
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/dnd/DragHandle.jsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+const propTypes = {
+  position: PropTypes.oneOf(['left', 'top']),
+  innerRef: PropTypes.func,
+  dotCount: PropTypes.number,
+};
+
+const defaultProps = {
+  position: 'left',
+  innerRef: null,
+  dotCount: 8,
+};
+
+export default class DragHandle extends React.PureComponent {
+  render() {
+    const { innerRef, position, dotCount } = this.props;
+    return (
+      <div
+        ref={innerRef}
+        className={cx(
+          'drag-handle',
+          position === 'left' && 'drag-handle--left',
+          position === 'top' && 'drag-handle--top',
+        )}
+      >
+        {Array(dotCount).fill(null).map((_, i) => (
+          <div key={`handle-dot-${i}`} className="drag-handle-dot" />
+        ))}
+      </div>
+    );
+  }
+}
+
+DragHandle.propTypes = propTypes;
+DragHandle.defaultProps = defaultProps;
diff --git a/superset/assets/javascripts/dashboard/v2/components/dnd/dragDroppableConfig.js b/superset/assets/javascripts/dashboard/v2/components/dnd/dragDroppableConfig.js
new file mode 100644
index 0000000000..54ce67e1a0
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/dnd/dragDroppableConfig.js
@@ -0,0 +1,71 @@
+import handleHover from './handleHover';
+import handleDrop from './handleDrop';
+
+// note: the 'type' hook is not useful for us as dropping is contigent on other properties
+const TYPE = 'DRAG_DROPPABLE';
+
+export const dragConfig = [
+  TYPE,
+  {
+    canDrag(props) {
+      return !props.disableDragDrop;
+    },
+
+    // this defines the dragging item object returned by monitor.getItem()
+    beginDrag(props /* , monitor, component */) {
+      const { component, index, parentComponent = {} } = props;
+      return {
+        type: component.type,
+        id: component.id,
+        meta: component.meta,
+        index,
+        parentId: parentComponent.id,
+        parentType: parentComponent.type,
+      };
+    },
+  },
+  function dragStateToProps(connect, monitor) {
+    return {
+      dragSourceRef: connect.dragSource(),
+      dragPreviewRef: connect.dragPreview(),
+      isDragging: monitor.isDragging(),
+    };
+  },
+];
+
+export const dropConfig = [
+  TYPE,
+  {
+    hover(props, monitor, component) {
+      if (
+        component
+        && component.decoratedComponentInstance
+        && component.decoratedComponentInstance.mounted
+      ) {
+        handleHover(
+          props,
+          monitor,
+          component.decoratedComponentInstance,
+        );
+      }
+    },
+    // note:
+    //  the react-dnd api requires that the drop() method return a result or undefined
+    //  monitor.didDrop() cannot be used because it returns true only for the most-nested target
+    drop(props, monitor, component) {
+      const Component = component.decoratedComponentInstance;
+      const dropResult = monitor.getDropResult();
+      if ((!dropResult || !dropResult.destination) && Component.mounted) {
+        return handleDrop(props, monitor, Component);
+      }
+      return undefined;
+    },
+  },
+  function dropStateToProps(connect, monitor) {
+    return {
+      droppableRef: connect.dropTarget(),
+      isDraggingOver: monitor.isOver(),
+      isDraggingOverShallow: monitor.isOver({ shallow: true }),
+    };
+  },
+];
diff --git a/superset/assets/javascripts/dashboard/v2/components/dnd/handleDrop.js b/superset/assets/javascripts/dashboard/v2/components/dnd/handleDrop.js
new file mode 100644
index 0000000000..7cb630d016
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/dnd/handleDrop.js
@@ -0,0 +1,70 @@
+import getDropPosition, { DROP_TOP, DROP_RIGHT, DROP_BOTTOM, DROP_LEFT } from '../../util/getDropPosition';
+
+export default function handleDrop(props, monitor, Component) {
+  // this may happen due to throttling
+  if (!Component.mounted) return undefined;
+
+  Component.setState(() => ({ dropIndicator: null }));
+  const dropPosition = getDropPosition(monitor, Component);
+
+  if (!dropPosition) {
+    return undefined;
+  }
+
+  const {
+    parentComponent,
+    component,
+    index: componentIndex,
+    onDrop,
+    orientation,
+  } = Component.props;
+
+  const draggingItem = monitor.getItem();
+
+  const dropAsChildOrSibling =
+    (orientation === 'row' && (dropPosition === DROP_TOP || dropPosition === DROP_BOTTOM)) ||
+    (orientation === 'column' && (dropPosition === DROP_LEFT || dropPosition === DROP_RIGHT))
+    ? 'sibling' : 'child';
+
+  const dropResult = {
+    source: {
+      id: draggingItem.parentId,
+      type: draggingItem.parentType,
+      index: draggingItem.index,
+    },
+    dragging: {
+      id: draggingItem.id,
+      type: draggingItem.type,
+      meta: draggingItem.meta,
+    },
+  };
+
+  // simplest case, append as child
+  if (dropAsChildOrSibling === 'child') {
+    dropResult.destination = {
+      id: component.id,
+      type: component.type,
+      index: component.children.length,
+    };
+  } else {
+    // if the item is in the same list with a smaller index, you must account for the
+    // "missing" index upon movement within the list
+    const sameParent = parentComponent && draggingItem.parentId === parentComponent.id;
+    const sameParentLowerIndex = sameParent && draggingItem.index < componentIndex;
+
+    let nextIndex = sameParentLowerIndex ? componentIndex - 1 : componentIndex;
+    if (dropPosition === DROP_BOTTOM || dropPosition === DROP_RIGHT) {
+      nextIndex += 1;
+    }
+
+    dropResult.destination = {
+      id: parentComponent.id,
+      type: parentComponent.type,
+      index: nextIndex,
+    };
+  }
+
+  onDrop(dropResult);
+
+  return dropResult;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/components/dnd/handleHover.js b/superset/assets/javascripts/dashboard/v2/components/dnd/handleHover.js
new file mode 100644
index 0000000000..a303e133f0
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/dnd/handleHover.js
@@ -0,0 +1,23 @@
+import throttle from 'lodash.throttle';
+import getDropPosition from '../../util/getDropPosition';
+
+const HOVER_THROTTLE_MS = 200;
+
+function handleHover(props, monitor, Component) {
+  // this may happen due to throttling
+  if (!Component.mounted) return;
+
+  const dropPosition = getDropPosition(monitor, Component);
+
+  if (!dropPosition) {
+    Component.setState(() => ({ dropIndicator: null }));
+    return;
+  }
+
+  Component.setState(() => ({
+    dropIndicator: dropPosition,
+  }));
+}
+
+// this is called very frequently by react-dnd
+export default throttle(handleHover, HOVER_THROTTLE_MS);
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/ChartHolder.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/ChartHolder.jsx
new file mode 100644
index 0000000000..ae304ad388
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/ChartHolder.jsx
@@ -0,0 +1,131 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import DeleteComponentButton from '../DeleteComponentButton';
+import DragDroppable from '../dnd/DragDroppable';
+import DragHandle from '../dnd/DragHandle';
+import HoverMenu from '../menu/HoverMenu';
+import ResizableContainer from '../resizable/ResizableContainer';
+import WithPopoverMenu from '../menu/WithPopoverMenu';
+import { componentShape } from '../../util/propShapes';
+import { ROW_TYPE } from '../../util/componentTypes';
+import { GRID_MIN_COLUMN_COUNT, GRID_MIN_ROW_UNITS } from '../../util/constants';
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  parentId: PropTypes.string.isRequired,
+  component: componentShape.isRequired,
+  parentComponent: componentShape.isRequired,
+  index: PropTypes.number.isRequired,
+  depth: PropTypes.number.isRequired,
+  editMode: PropTypes.bool.isRequired,
+  chart: PropTypes.object,
+
+  // 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 ChartHolder extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      isFocused: false,
+    };
+
+    this.handleChangeFocus = this.handleChangeFocus.bind(this);
+    this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+  }
+
+  handleChangeFocus(nextFocus) {
+    this.setState(() => ({ isFocused: nextFocus }));
+  }
+
+  handleDeleteComponent() {
+    const { deleteComponent, id, parentId } = this.props;
+    deleteComponent(id, parentId);
+  }
+
+  render() {
+    const { isFocused } = this.state;
+
+    const {
+      component,
+      parentComponent,
+      index,
+      depth,
+      availableColumnCount,
+      columnWidth,
+      onResizeStart,
+      onResize,
+      onResizeStop,
+      handleComponentDrop,
+      editMode,
+    } = this.props;
+
+    return (
+      <DragDroppable
+        component={component}
+        parentComponent={parentComponent}
+        orientation={depth % 2 === 1 ? 'column' : 'row'}
+        index={index}
+        depth={depth}
+        onDrop={handleComponentDrop}
+        disableDragDrop={isFocused}
+        editMode={editMode}
+      >
+        {({ dropIndicatorProps, dragSourceRef }) => (
+          <ResizableContainer
+            id={component.id}
+            adjustableWidth={parentComponent.type === ROW_TYPE}
+            adjustableHeight
+            widthStep={columnWidth}
+            widthMultiple={component.meta.width}
+            heightMultiple={component.meta.height}
+            minWidthMultiple={GRID_MIN_COLUMN_COUNT}
+            minHeightMultiple={GRID_MIN_ROW_UNITS}
+            maxWidthMultiple={availableColumnCount + (component.meta.width || 0)}
+            onResizeStart={onResizeStart}
+            onResize={onResize}
+            onResizeStop={onResizeStop}
+            editMode={editMode}
+          >
+            {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">
+                {this.props.chart}
+              </div>
+
+              {dropIndicatorProps && <div {...dropIndicatorProps} />}
+            </WithPopoverMenu>
+          </ResizableContainer>
+        )}
+      </DragDroppable>
+    );
+  }
+}
+
+ChartHolder.propTypes = propTypes;
+ChartHolder.defaultProps = defaultProps;
+
+export default ChartHolder;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Column.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Column.jsx
new file mode 100644
index 0000000000..490d7bdd37
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Column.jsx
@@ -0,0 +1,188 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import DashboardComponent from '../../containers/DashboardComponent';
+import DeleteComponentButton from '../DeleteComponentButton';
+import DragDroppable from '../dnd/DragDroppable';
+import DragHandle from '../dnd/DragHandle';
+import HoverMenu from '../menu/HoverMenu';
+import IconButton from '../IconButton';
+import ResizableContainer from '../resizable/ResizableContainer';
+import BackgroundStyleDropdown from '../menu/BackgroundStyleDropdown';
+import WithPopoverMenu from '../menu/WithPopoverMenu';
+
+import backgroundStyleOptions from '../../util/backgroundStyleOptions';
+import { componentShape } from '../../util/propShapes';
+
+import { BACKGROUND_TRANSPARENT } from '../../util/constants';
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  parentId: PropTypes.string.isRequired,
+  component: componentShape.isRequired,
+  parentComponent: componentShape.isRequired,
+  index: PropTypes.number.isRequired,
+  depth: PropTypes.number.isRequired,
+  editMode: PropTypes.bool.isRequired,
+  cells: PropTypes.object.isRequired,
+
+  // grid related
+  availableColumnCount: PropTypes.number.isRequired,
+  columnWidth: PropTypes.number.isRequired,
+  minColumnWidth: PropTypes.number.isRequired,
+  onResizeStart: PropTypes.func.isRequired,
+  onResize: PropTypes.func.isRequired,
+  onResizeStop: PropTypes.func.isRequired,
+
+  // dnd
+  deleteComponent: PropTypes.func.isRequired,
+  handleComponentDrop: PropTypes.func.isRequired,
+  updateComponents: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+};
+
+class Column extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {
+      isFocused: false,
+    };
+    this.handleChangeBackground = this.handleUpdateMeta.bind(this, 'background');
+    this.handleChangeFocus = this.handleChangeFocus.bind(this);
+    this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+  }
+
+  handleDeleteComponent() {
+    const { deleteComponent, id, parentId } = this.props;
+    deleteComponent(id, parentId);
+  }
+
+  handleChangeFocus(nextFocus) {
+    this.setState(() => ({ isFocused: Boolean(nextFocus) }));
+  }
+
+  handleUpdateMeta(metaKey, nextValue) {
+    const { updateComponents, component } = this.props;
+    if (nextValue && component.meta[metaKey] !== nextValue) {
+      updateComponents({
+        [component.id]: {
+          ...component,
+          meta: {
+            ...component.meta,
+            [metaKey]: nextValue,
+          },
+        },
+      });
+    }
+  }
+
+  render() {
+    const {
+      component: columnComponent,
+      parentComponent,
+      index,
+      availableColumnCount,
+      columnWidth,
+      minColumnWidth,
+      depth,
+      onResizeStart,
+      onResize,
+      onResizeStop,
+      handleComponentDrop,
+      editMode,
+      cells,
+    } = this.props;
+
+    const columnItems = columnComponent.children || [];
+    const backgroundStyle = backgroundStyleOptions.find(
+      opt => opt.value === (columnComponent.meta.background || BACKGROUND_TRANSPARENT),
+    );
+
+    return (
+      <DragDroppable
+        component={columnComponent}
+        parentComponent={parentComponent}
+        orientation="column"
+        index={index}
+        depth={depth}
+        onDrop={handleComponentDrop}
+        editMode={editMode}
+      >
+        {({ dropIndicatorProps, dragSourceRef }) => (
+          <ResizableContainer
+            id={columnComponent.id}
+            adjustableWidth
+            adjustableHeight={false}
+            widthStep={columnWidth}
+            widthMultiple={columnComponent.meta.width}
+            minWidthMultiple={minColumnWidth}
+            maxWidthMultiple={availableColumnCount + (columnComponent.meta.width || 0)}
+            onResizeStart={onResizeStart}
+            onResize={onResize}
+            onResizeStop={onResizeStop}
+            editMode={editMode}
+          >
+            <WithPopoverMenu
+              isFocused={this.state.isFocused}
+              onChangeFocus={this.handleChangeFocus}
+              disableClick
+              menuItems={[
+                <BackgroundStyleDropdown
+                  id={`${columnComponent.id}-background`}
+                  value={columnComponent.meta.background}
+                  onChange={this.handleChangeBackground}
+                />,
+              ]}
+              editMode={editMode}
+            >
+              <div
+                className={cx(
+                  'grid-column',
+                  columnItems.length === 0 && 'grid-column--empty',
+                  backgroundStyle.className,
+                )}
+              >
+                {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}
+                      cells={cells}
+                      onResizeStart={onResizeStart}
+                      onResize={onResize}
+                      onResizeStop={onResizeStop}
+                    />
+                  ))}
+
+                {dropIndicatorProps && <div {...dropIndicatorProps} />}
+              </div>
+            </WithPopoverMenu>
+          </ResizableContainer>
+        )}
+      </DragDroppable>
+
+    );
+  }
+}
+
+Column.propTypes = propTypes;
+Column.defaultProps = defaultProps;
+
+export default Column;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Divider.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Divider.jsx
new file mode 100644
index 0000000000..b3010e93a3
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Divider.jsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import DragDroppable from '../dnd/DragDroppable';
+import HoverMenu from '../menu/HoverMenu';
+import DeleteComponentButton from '../DeleteComponentButton';
+import { componentShape } from '../../util/propShapes';
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  parentId: PropTypes.string.isRequired,
+  component: componentShape.isRequired,
+  depth: PropTypes.number.isRequired,
+  parentComponent: componentShape.isRequired,
+  index: PropTypes.number.isRequired,
+  editMode: PropTypes.bool.isRequired,
+  handleComponentDrop: PropTypes.func.isRequired,
+  deleteComponent: PropTypes.func.isRequired,
+};
+
+class Divider extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+  }
+
+  handleDeleteComponent() {
+    const { deleteComponent, id, parentId } = this.props;
+    deleteComponent(id, parentId);
+  }
+
+  render() {
+    const {
+      component,
+      depth,
+      parentComponent,
+      index,
+      handleComponentDrop,
+      editMode,
+    } = this.props;
+
+    return (
+      <DragDroppable
+        component={component}
+        parentComponent={parentComponent}
+        orientation="row"
+        index={index}
+        depth={depth}
+        onDrop={handleComponentDrop}
+        editMode={editMode}
+      >
+        {({ dropIndicatorProps, dragSourceRef }) => (
+          <div ref={dragSourceRef}>
+            {editMode &&
+              <HoverMenu position="left">
+                <DeleteComponentButton onDelete={this.handleDeleteComponent} />
+              </HoverMenu>}
+
+            <div className="dashboard-component dashboard-component-divider" />
+
+            {dropIndicatorProps && <div {...dropIndicatorProps} />}
+          </div>
+        )}
+      </DragDroppable>
+    );
+  }
+}
+
+Divider.propTypes = propTypes;
+
+export default Divider;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Header.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Header.jsx
new file mode 100644
index 0000000000..97945a9664
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Header.jsx
@@ -0,0 +1,159 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import DragDroppable from '../dnd/DragDroppable';
+import DragHandle from '../dnd/DragHandle';
+import EditableTitle from '../../../../components/EditableTitle';
+import HoverMenu from '../menu/HoverMenu';
+import WithPopoverMenu from '../menu/WithPopoverMenu';
+import BackgroundStyleDropdown from '../menu/BackgroundStyleDropdown';
+import DeleteComponentButton from '../DeleteComponentButton';
+import PopoverDropdown from '../menu/PopoverDropdown';
+import headerStyleOptions from '../../util/headerStyleOptions';
+import backgroundStyleOptions from '../../util/backgroundStyleOptions';
+import { componentShape } from '../../util/propShapes';
+import { SMALL_HEADER, BACKGROUND_TRANSPARENT } from '../../util/constants';
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  parentId: PropTypes.string.isRequired,
+  component: componentShape.isRequired,
+  depth: PropTypes.number.isRequired,
+  parentComponent: componentShape.isRequired,
+  index: PropTypes.number.isRequired,
+  editMode: PropTypes.bool.isRequired,
+
+  // redux
+  handleComponentDrop: PropTypes.func.isRequired,
+  deleteComponent: PropTypes.func.isRequired,
+  updateComponents: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+};
+
+class Header extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {
+      isFocused: false,
+    };
+    this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+    this.handleChangeFocus = this.handleChangeFocus.bind(this);
+    this.handleUpdateMeta = this.handleUpdateMeta.bind(this);
+    this.handleChangeSize = this.handleUpdateMeta.bind(this, 'headerSize');
+    this.handleChangeBackground = this.handleUpdateMeta.bind(this, 'background');
+    this.handleChangeText = this.handleUpdateMeta.bind(this, 'text');
+  }
+
+  handleChangeFocus(nextFocus) {
+    this.setState(() => ({ isFocused: nextFocus }));
+  }
+
+  handleUpdateMeta(metaKey, nextValue) {
+    const { updateComponents, component } = this.props;
+    if (nextValue && component.meta[metaKey] !== nextValue) {
+      updateComponents({
+        [component.id]: {
+          ...component,
+          meta: {
+            ...component.meta,
+            [metaKey]: nextValue,
+          },
+        },
+      });
+    }
+  }
+
+  handleDeleteComponent() {
+    const { deleteComponent, id, parentId } = this.props;
+    deleteComponent(id, parentId);
+  }
+
+  render() {
+    const { isFocused } = this.state;
+
+    const {
+      component,
+      depth,
+      parentComponent,
+      index,
+      handleComponentDrop,
+      editMode,
+    } = this.props;
+
+    const headerStyle = headerStyleOptions.find(
+      opt => opt.value === (component.meta.headerSize || SMALL_HEADER),
+    );
+
+    const rowStyle = backgroundStyleOptions.find(
+      opt => opt.value === (component.meta.background || BACKGROUND_TRANSPARENT),
+    );
+
+    return (
+      <DragDroppable
+        component={component}
+        parentComponent={parentComponent}
+        orientation="row"
+        index={index}
+        depth={depth}
+        onDrop={handleComponentDrop}
+        disableDragDrop={isFocused}
+        editMode={editMode}
+      >
+        {({ dropIndicatorProps, dragSourceRef }) => (
+          <div ref={dragSourceRef}>
+            {editMode &&
+              <HoverMenu position="left">
+                <DragHandle position="left" />
+              </HoverMenu>}
+
+            <WithPopoverMenu
+              onChangeFocus={this.handleChangeFocus}
+              menuItems={[
+                <PopoverDropdown
+                  id={`${component.id}-header-style`}
+                  options={headerStyleOptions}
+                  value={component.meta.headerSize}
+                  onChange={this.handleChangeSize}
+                  renderTitle={option => `${option.label} header`}
+                />,
+                <BackgroundStyleDropdown
+                  id={`${component.id}-background`}
+                  value={component.meta.background}
+                  onChange={this.handleChangeBackground}
+                />,
+                <DeleteComponentButton onDelete={this.handleDeleteComponent} />,
+              ]}
+              editMode={editMode}
+            >
+              <div
+                className={cx(
+                  'dashboard-component',
+                  'dashboard-component-header',
+                  headerStyle.className,
+                  rowStyle.className,
+                )}
+              >
+                <EditableTitle
+                  title={component.meta.text}
+                  canEdit={editMode}
+                  onSaveTitle={this.handleChangeText}
+                  showTooltip={false}
+                />
+              </div>
+            </WithPopoverMenu>
+
+            {dropIndicatorProps && <div {...dropIndicatorProps} />}
+          </div>
+        )}
+      </DragDroppable>
+    );
+  }
+}
+
+Header.propTypes = propTypes;
+Header.defaultProps = defaultProps;
+
+export default Header;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Row.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Row.jsx
new file mode 100644
index 0000000000..8faaee110b
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Row.jsx
@@ -0,0 +1,174 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import DragDroppable from '../dnd/DragDroppable';
+import DragHandle from '../dnd/DragHandle';
+import DashboardComponent from '../../containers/DashboardComponent';
+import DeleteComponentButton from '../DeleteComponentButton';
+import HoverMenu from '../menu/HoverMenu';
+import IconButton from '../IconButton';
+import BackgroundStyleDropdown from '../menu/BackgroundStyleDropdown';
+import WithPopoverMenu from '../menu/WithPopoverMenu';
+
+import { componentShape } from '../../util/propShapes';
+import backgroundStyleOptions from '../../util/backgroundStyleOptions';
+import { BACKGROUND_TRANSPARENT } from '../../util/constants';
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  parentId: PropTypes.string.isRequired,
+  component: componentShape.isRequired,
+  parentComponent: componentShape.isRequired,
+  index: PropTypes.number.isRequired,
+  depth: PropTypes.number.isRequired,
+  editMode: PropTypes.bool.isRequired,
+  cells: PropTypes.object.isRequired,
+
+  // grid related
+  availableColumnCount: PropTypes.number.isRequired,
+  columnWidth: PropTypes.number.isRequired,
+  occupiedColumnCount: PropTypes.number.isRequired,
+  onResizeStart: PropTypes.func.isRequired,
+  onResize: PropTypes.func.isRequired,
+  onResizeStop: PropTypes.func.isRequired,
+
+  // dnd
+  handleComponentDrop: PropTypes.func.isRequired,
+  deleteComponent: PropTypes.func.isRequired,
+  updateComponents: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+  rowHeight: null,
+};
+
+class Row extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {
+      isFocused: false,
+    };
+    this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+    this.handleUpdateMeta = this.handleUpdateMeta.bind(this);
+    this.handleChangeBackground = this.handleUpdateMeta.bind(this, 'background');
+    this.handleChangeFocus = this.handleChangeFocus.bind(this);
+  }
+
+  handleChangeFocus(nextFocus) {
+    this.setState(() => ({ isFocused: Boolean(nextFocus) }));
+  }
+
+  handleUpdateMeta(metaKey, nextValue) {
+    const { updateComponents, component } = this.props;
+    if (nextValue && component.meta[metaKey] !== nextValue) {
+      updateComponents({
+        [component.id]: {
+          ...component,
+          meta: {
+            ...component.meta,
+            [metaKey]: nextValue,
+          },
+        },
+      });
+    }
+  }
+
+  handleDeleteComponent() {
+    const { deleteComponent, component, parentId } = this.props;
+    deleteComponent(component.id, parentId);
+  }
+
+  render() {
+    const {
+      component: rowComponent,
+      parentComponent,
+      index,
+      availableColumnCount,
+      columnWidth,
+      occupiedColumnCount,
+      depth,
+      onResizeStart,
+      onResize,
+      onResizeStop,
+      handleComponentDrop,
+      editMode,
+      cells,
+    } = this.props;
+
+    const rowItems = rowComponent.children || [];
+
+    const backgroundStyle = backgroundStyleOptions.find(
+      opt => opt.value === (rowComponent.meta.background || BACKGROUND_TRANSPARENT),
+    );
+
+    return (
+      <DragDroppable
+        component={rowComponent}
+        parentComponent={parentComponent}
+        orientation="row"
+        index={index}
+        depth={depth}
+        onDrop={handleComponentDrop}
+        editMode={editMode}
+      >
+        {({ dropIndicatorProps, dragSourceRef }) => (
+          <WithPopoverMenu
+            isFocused={this.state.isFocused}
+            onChangeFocus={this.handleChangeFocus}
+            disableClick
+            menuItems={[
+              <BackgroundStyleDropdown
+                id={`${rowComponent.id}-background`}
+                value={rowComponent.meta.background}
+                onChange={this.handleChangeBackground}
+              />,
+            ]}
+            editMode={editMode}
+          >
+            <div
+              className={cx(
+                'grid-row',
+                rowItems.length === 0 && 'grid-row--empty',
+                backgroundStyle.className,
+              )}
+            >
+              {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}
+                    cells={cells}onResizeStart={onResizeStart}
+                    onResize={onResize}
+                    onResizeStop={onResizeStop}
+                  />
+                ))}
+
+              {dropIndicatorProps && <div {...dropIndicatorProps} />}
+            </div>
+          </WithPopoverMenu>
+        )}
+      </DragDroppable>
+    );
+  }
+}
+
+Row.propTypes = propTypes;
+Row.defaultProps = defaultProps;
+
+export default Row;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tab.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tab.jsx
new file mode 100644
index 0000000000..218c4e77e5
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tab.jsx
@@ -0,0 +1,179 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import DashboardComponent from '../../containers/DashboardComponent';
+import DragDroppable from '../dnd/DragDroppable';
+import EditableTitle from '../../../../components/EditableTitle';
+import DeleteComponentButton from '../DeleteComponentButton';
+import WithPopoverMenu from '../menu/WithPopoverMenu';
+import { componentShape } from '../../util/propShapes';
+import { DASHBOARD_ROOT_DEPTH } from '../../util/constants';
+
+export const RENDER_TAB = 'RENDER_TAB';
+export const RENDER_TAB_CONTENT = 'RENDER_TAB_CONTENT';
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  parentId: PropTypes.string.isRequired,
+  component: componentShape.isRequired,
+  parentComponent: componentShape.isRequired,
+  index: PropTypes.number.isRequired,
+  depth: PropTypes.number.isRequired,
+  renderType: PropTypes.oneOf([RENDER_TAB, RENDER_TAB_CONTENT]).isRequired,
+  onDropOnTab: PropTypes.func,
+  onDeleteTab: PropTypes.func,
+  editMode: PropTypes.bool.isRequired,
+
+  // grid related
+  availableColumnCount: PropTypes.number,
+  columnWidth: PropTypes.number,
+  onResizeStart: PropTypes.func,
+  onResize: PropTypes.func,
+  onResizeStop: PropTypes.func,
+
+  // redux
+  handleComponentDrop: PropTypes.func.isRequired,
+  deleteComponent: PropTypes.func.isRequired,
+  updateComponents: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+  availableColumnCount: 0,
+  columnWidth: 0,
+  onDropOnTab() {},
+  onDeleteTab() {},
+  onResizeStart() {},
+  onResize() {},
+  onResizeStop() {},
+};
+
+export default class Tab extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {
+      isFocused: false,
+    };
+    this.handleChangeFocus = this.handleChangeFocus.bind(this);
+    this.handleChangeText = this.handleChangeText.bind(this);
+    this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+    this.handleDrop = this.handleDrop.bind(this);
+  }
+
+  handleChangeFocus(nextFocus) {
+    this.setState(() => ({ isFocused: nextFocus }));
+  }
+
+  handleChangeText(nextTabText) {
+    const { updateComponents, component } = this.props;
+    if (nextTabText && nextTabText !== component.meta.text) {
+      updateComponents({
+        [component.id]: {
+          ...component,
+          meta: {
+            ...component.meta,
+            text: nextTabText,
+          },
+        },
+      });
+    }
+  }
+
+  handleDeleteComponent() {
+    const { index, id, parentId } = this.props;
+    this.props.deleteComponent(id, parentId);
+    this.props.onDeleteTab(index);
+  }
+
+  handleDrop(dropResult) {
+    this.props.handleComponentDrop(dropResult);
+    this.props.onDropOnTab(dropResult);
+  }
+
+  renderTabContent() {
+    const {
+      component: tabComponent,
+      depth,
+      availableColumnCount,
+      columnWidth,
+      onResizeStart,
+      onResize,
+      onResizeStop,
+    } = this.props;
+
+    return (
+      <div className="dashboard-component-tabs-content">
+        {tabComponent.children.map((componentId, componentIndex) => (
+          <DashboardComponent
+            key={componentId}
+            id={componentId}
+            parentId={tabComponent.id}
+            depth={depth} // see isValidChild.js for why tabs don't increment child depth
+            index={componentIndex}
+            onDrop={this.handleDrop}
+            availableColumnCount={availableColumnCount}
+            columnWidth={columnWidth}
+            onResizeStart={onResizeStart}
+            onResize={onResize}
+            onResizeStop={onResizeStop}
+          />
+        ))}
+      </div>
+    );
+  }
+
+  renderTab() {
+    const { isFocused } = this.state;
+    const {
+      component,
+      parentComponent,
+      index,
+      depth,
+      editMode,
+    } = this.props;
+
+    return (
+      <DragDroppable
+        component={component}
+        parentComponent={parentComponent}
+        orientation="column"
+        index={index}
+        depth={depth}
+        onDrop={this.handleDrop}
+        // disable drag drop of top-level Tab's to prevent invalid nesting of a child in
+        // itself, e.g. if a top-level Tab has a Tabs child, dragging the Tab into the Tabs would
+        // reusult in circular children
+        disableDragDrop={isFocused || depth === DASHBOARD_ROOT_DEPTH + 1}
+        editMode={editMode}
+      >
+        {({ dropIndicatorProps, dragSourceRef }) => (
+          <div className="dragdroppable-tab" ref={dragSourceRef}>
+            <WithPopoverMenu
+              onChangeFocus={this.handleChangeFocus}
+              menuItems={parentComponent.children.length <= 1 ? [] : [
+                <DeleteComponentButton onDelete={this.handleDeleteComponent} />,
+              ]}
+              editMode={editMode}
+            >
+              <EditableTitle
+                title={component.meta.text}
+                canEdit={editMode && isFocused}
+                onSaveTitle={this.handleChangeText}
+                showTooltip={false}
+              />
+            </WithPopoverMenu>
+
+            {dropIndicatorProps && <div {...dropIndicatorProps} />}
+          </div>
+        )}
+      </DragDroppable>
+    );
+  }
+
+  render() {
+    const { renderType } = this.props;
+    return renderType === RENDER_TAB ? this.renderTab() : this.renderTabContent();
+  }
+}
+
+Tab.propTypes = propTypes;
+Tab.defaultProps = defaultProps;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tabs.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tabs.jsx
new file mode 100644
index 0000000000..1f5f0c68c0
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tabs.jsx
@@ -0,0 +1,231 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Tabs as BootstrapTabs, Tab as BootstrapTab } from 'react-bootstrap';
+
+import DragDroppable from '../dnd/DragDroppable';
+import DragHandle from '../dnd/DragHandle';
+import DashboardComponent from '../../containers/DashboardComponent';
+import DeleteComponentButton from '../DeleteComponentButton';
+import HoverMenu from '../menu/HoverMenu';
+import { componentShape } from '../../util/propShapes';
+import { NEW_TAB_ID, DASHBOARD_ROOT_ID } from '../../util/constants';
+import { RENDER_TAB, RENDER_TAB_CONTENT } from './Tab';
+import { TAB_TYPE } from '../../util/componentTypes';
+
+const NEW_TAB_INDEX = -1;
+const MAX_TAB_COUNT = 5;
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  parentId: PropTypes.string.isRequired,
+  component: componentShape.isRequired,
+  parentComponent: componentShape.isRequired,
+  index: PropTypes.number.isRequired,
+  depth: PropTypes.number.isRequired,
+  renderTabContent: PropTypes.bool, // whether to render tabs + content or just tabs
+  editMode: PropTypes.bool.isRequired,
+
+  // grid related
+  availableColumnCount: PropTypes.number,
+  columnWidth: PropTypes.number,
+  onResizeStart: PropTypes.func,
+  onResize: PropTypes.func,
+  onResizeStop: PropTypes.func,
+
+  // dnd
+  createComponent: PropTypes.func.isRequired,
+  handleComponentDrop: PropTypes.func.isRequired,
+  onChangeTab: PropTypes.func,
+  deleteComponent: PropTypes.func.isRequired,
+  updateComponents: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+  children: null,
+  renderTabContent: true,
+  availableColumnCount: 0,
+  columnWidth: 0,
+  onChangeTab() {},
+  onResizeStart() {},
+  onResize() {},
+  onResizeStop() {},
+};
+
+class Tabs extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {
+      tabIndex: 0,
+    };
+    this.handleClickTab = this.handleClickTab.bind(this);
+    this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+    this.handleDeleteTab = this.handleDeleteTab.bind(this);
+    this.handleDropOnTab = this.handleDropOnTab.bind(this);
+  }
+
+  componentWillReceiveProps(nextProps) {
+    const maxIndex = Math.max(0, nextProps.component.children.length - 1);
+    if (this.state.tabIndex > maxIndex) {
+      this.setState(() => ({ tabIndex: maxIndex }));
+    }
+  }
+
+  handleClickTab(tabIndex) {
+    const { component, createComponent } = this.props;
+
+    if (tabIndex === NEW_TAB_INDEX) {
+      createComponent({
+        destination: {
+          id: component.id,
+          type: component.type,
+          index: component.children.length,
+        },
+        dragging: {
+          id: NEW_TAB_ID,
+          type: TAB_TYPE,
+        },
+      });
+    } else if (tabIndex !== this.state.tabIndex) {
+      this.setState(() => ({ tabIndex }));
+      this.props.onChangeTab({ tabIndex, tabId: component.children[tabIndex] });
+    }
+  }
+
+  handleDeleteComponent() {
+    const { deleteComponent, id, parentId } = this.props;
+    deleteComponent(id, parentId);
+  }
+
+  handleDeleteTab(tabIndex) {
+    this.handleClickTab(Math.max(0, tabIndex - 1));
+  }
+
+  handleDropOnTab(dropResult) {
+    const { component } = this.props;
+
+    // Ensure dropped tab is visible
+    const { destination } = dropResult;
+    if (destination) {
+      const dropTabIndex = destination.id === component.id
+        ? destination.index // dropped ON tabs
+        : component.children.indexOf(destination.id); // dropped IN tab
+
+      if (dropTabIndex > -1) {
+        setTimeout(() => {
+          this.handleClickTab(dropTabIndex);
+        }, 30);
+      }
+    }
+  }
+
+  render() {
+    const {
+      depth,
+      component: tabsComponent,
+      parentComponent,
+      index,
+      availableColumnCount,
+      columnWidth,
+      onResizeStart,
+      onResize,
+      onResizeStop,
+      handleComponentDrop,
+      renderTabContent,
+      editMode,
+    } = this.props;
+
+    const { tabIndex: selectedTabIndex } = this.state;
+    const { children: tabIds } = tabsComponent;
+
+    return (
+      <DragDroppable
+        component={tabsComponent}
+        parentComponent={parentComponent}
+        orientation="row"
+        index={index}
+        depth={depth}
+        onDrop={handleComponentDrop}
+        editMode={editMode}
+      >
+        {({ dropIndicatorProps: tabsDropIndicatorProps, dragSourceRef: tabsDragSourceRef }) => (
+          <div className="dashboard-component dashboard-component-tabs">
+            {editMode &&
+              <HoverMenu innerRef={tabsDragSourceRef} position="left">
+                <DragHandle position="left" />
+                <DeleteComponentButton onDelete={this.handleDeleteComponent} />
+              </HoverMenu>}
+
+            <BootstrapTabs
+              id={tabsComponent.id}
+              activeKey={selectedTabIndex}
+              onSelect={this.handleClickTab}
+              animation={false}
+            >
+              {tabIds.map((tabId, tabIndex) => (
+                // react-bootstrap doesn't render a Tab if we move this to its own Tab.jsx so we
+                // use `renderType` to indicate what the DashboardComponent should render. This
+                // prevents us from passing the entire dashboard component lookup to render Tabs.jsx
+                <BootstrapTab
+                  key={tabId}
+                  eventKey={tabIndex}
+                  title={
+                    <DashboardComponent
+                      id={tabId}
+                      parentId={tabsComponent.id}
+                      depth={depth}
+                      index={tabIndex}
+                      renderType={RENDER_TAB}
+                      availableColumnCount={availableColumnCount}
+                      columnWidth={columnWidth}
+                      onDropOnTab={this.handleDropOnTab}
+                      onDeleteTab={this.handleDeleteTab}
+                    />
+                  }
+                >
+                  {/*
+                    react-bootstrap renders all children with display:none, so we don't
+                    render potentially-expensive charts (this also enables lazy loading
+                    their content)
+                  */}
+                  {tabIndex === selectedTabIndex && renderTabContent &&
+                    <DashboardComponent
+                      id={tabId}
+                      parentId={tabsComponent.id}
+                      depth={depth} // see isValidChild.js for why tabs don't increment child depth
+                      index={tabIndex}
+                      renderType={RENDER_TAB_CONTENT}
+                      availableColumnCount={availableColumnCount}
+                      columnWidth={columnWidth}
+                      onResizeStart={onResizeStart}
+                      onResize={onResize}
+                      onResizeStop={onResizeStop}
+                      onDropOnTab={this.handleDropOnTab}
+                    />}
+                </BootstrapTab>
+              ))}
+
+              {editMode &&
+                tabIds.length < MAX_TAB_COUNT &&
+                  <BootstrapTab
+                    eventKey={NEW_TAB_INDEX}
+                    title={<div className="fa fa-plus" />}
+                  />}
+
+            </BootstrapTabs>
+
+            {/* don't indicate that a drop on root is allowed when tabs already exist */}
+            {tabsDropIndicatorProps
+              && parentComponent.id !== DASHBOARD_ROOT_ID
+              && <div {...tabsDropIndicatorProps} />}
+
+          </div>
+        )}
+      </DragDroppable>
+    );
+  }
+}
+
+Tabs.propTypes = propTypes;
+Tabs.defaultProps = defaultProps;
+
+export default Tabs;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/index.js b/superset/assets/javascripts/dashboard/v2/components/gridComponents/index.js
new file mode 100644
index 0000000000..ef6d13f935
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/index.js
@@ -0,0 +1,37 @@
+import {
+  CHART_TYPE,
+  COLUMN_TYPE,
+  DIVIDER_TYPE,
+  HEADER_TYPE,
+  INVISIBLE_ROW_TYPE,
+  ROW_TYPE,
+  TAB_TYPE,
+  TABS_TYPE,
+} from '../../util/componentTypes';
+
+import ChartHolder from './ChartHolder';
+import Column from './Column';
+import Divider from './Divider';
+import Header from './Header';
+import Row from './Row';
+import Tab from './Tab';
+import Tabs from './Tabs';
+
+export { default as ChartHolder } from './ChartHolder';
+export { default as Column } from './Column';
+export { default as Divider } from './Divider';
+export { default as Header } from './Header';
+export { default as Row } from './Row';
+export { default as Tab } from './Tab';
+export { default as Tabs } from './Tabs';
+
+export default {
+  [CHART_TYPE]: ChartHolder,
+  [COLUMN_TYPE]: Column,
+  [DIVIDER_TYPE]: Divider,
+  [HEADER_TYPE]: Header,
+  [INVISIBLE_ROW_TYPE]: Row,
+  [ROW_TYPE]: Row,
+  [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
new file mode 100644
index 0000000000..eebd6e0b5f
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/DraggableNewComponent.jsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import DragDroppable from '../../dnd/DragDroppable';
+import { NEW_COMPONENTS_SOURCE_ID } from '../../../util/constants';
+import { NEW_COMPONENT_SOURCE_TYPE } from '../../../util/componentTypes';
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  type: PropTypes.string.isRequired,
+  label: PropTypes.string.isRequired,
+  className: PropTypes.string,
+};
+
+const defaultProps = {
+  className: null,
+};
+
+export default class DraggableNewComponent extends React.PureComponent {
+  render() {
+    const { label, id, type, className } = this.props;
+    return (
+      <DragDroppable
+        component={{ type, id }}
+        parentComponent={{ id: NEW_COMPONENTS_SOURCE_ID, type: NEW_COMPONENT_SOURCE_TYPE }}
+        index={0}
+        depth={0}
+        editMode
+      >
+        {({ dragSourceRef }) => (
+          <div ref={dragSourceRef} className="new-component">
+            <div className={cx('new-component-placeholder', className)} />
+            {label}
+          </div>
+        )}
+      </DragDroppable>
+    );
+  }
+}
+
+DraggableNewComponent.propTypes = propTypes;
+DraggableNewComponent.defaultProps = defaultProps;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewChart.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewChart.jsx
new file mode 100644
index 0000000000..0255755a88
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewChart.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+// import PropTypes from 'prop-types';
+
+import { CHART_TYPE } from '../../../util/componentTypes';
+import { NEW_CHART_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+const propTypes = {
+};
+
+export default class DraggableNewChart extends React.PureComponent {
+  render() {
+    return (
+      <DraggableNewComponent
+        id={NEW_CHART_ID}
+        type={CHART_TYPE}
+        label="Chart"
+        className="fa fa-area-chart"
+      />
+    );
+  }
+}
+
+DraggableNewChart.propTypes = propTypes;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewColumn.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewColumn.jsx
new file mode 100644
index 0000000000..654c60bd45
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewColumn.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+// import PropTypes from 'prop-types';
+
+import { COLUMN_TYPE } from '../../../util/componentTypes';
+import { NEW_COLUMN_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+const propTypes = {
+};
+
+export default class DraggableNewColumn extends React.PureComponent {
+  render() {
+    return (
+      <DraggableNewComponent
+        id={NEW_COLUMN_ID}
+        type={COLUMN_TYPE}
+        label="Column"
+        className="fa fa-long-arrow-down"
+      />
+    );
+  }
+}
+
+DraggableNewColumn.propTypes = propTypes;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewDivider.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewDivider.jsx
new file mode 100644
index 0000000000..5d70041a5d
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewDivider.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+// import PropTypes from 'prop-types';
+
+import { DIVIDER_TYPE } from '../../../util/componentTypes';
+import { NEW_DIVIDER_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+const propTypes = {
+};
+
+export default class DraggableNewDivider extends React.PureComponent {
+  render() {
+    return (
+      <DraggableNewComponent
+        id={NEW_DIVIDER_ID}
+        type={DIVIDER_TYPE}
+        label="Divider"
+        className="divider-placeholder"
+      />
+    );
+  }
+}
+
+DraggableNewDivider.propTypes = propTypes;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewHeader.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewHeader.jsx
new file mode 100644
index 0000000000..d207a9c9c8
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewHeader.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+// import PropTypes from 'prop-types';
+
+import { HEADER_TYPE } from '../../../util/componentTypes';
+import { NEW_HEADER_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+const propTypes = {
+};
+
+export default class DraggableNewHeader extends React.Component {
+  render() {
+    return (
+      <DraggableNewComponent
+        id={NEW_HEADER_ID}
+        type={HEADER_TYPE}
+        label="Header"
+        className="fa fa-header"
+      />
+    );
+  }
+}
+
+DraggableNewHeader.propTypes = propTypes;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewRow.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewRow.jsx
new file mode 100644
index 0000000000..1d9ab103a9
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewRow.jsx
@@ -0,0 +1,23 @@
+import React from 'react';
+
+import { ROW_TYPE } from '../../../util/componentTypes';
+import { NEW_ROW_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+const propTypes = {
+};
+
+export default class DraggableNewRow extends React.PureComponent {
+  render() {
+    return (
+      <DraggableNewComponent
+        id={NEW_ROW_ID}
+        type={ROW_TYPE}
+        label="Row"
+        className="fa fa-long-arrow-right"
+      />
+    );
+  }
+}
+
+DraggableNewRow.propTypes = propTypes;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewTabs.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewTabs.jsx
new file mode 100644
index 0000000000..a473281984
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewTabs.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+// import PropTypes from 'prop-types';
+
+import { TABS_TYPE } from '../../../util/componentTypes';
+import { NEW_TABS_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+const propTypes = {
+};
+
+export default class DraggableNewTabs extends React.PureComponent {
+  render() {
+    return (
+      <DraggableNewComponent
+        id={NEW_TABS_ID}
+        type={TABS_TYPE}
+        label="Tabs"
+        className="fa fa-window-restore"
+      />
+    );
+  }
+}
+
+DraggableNewTabs.propTypes = propTypes;
diff --git a/superset/assets/javascripts/dashboard/v2/components/menu/BackgroundStyleDropdown.jsx b/superset/assets/javascripts/dashboard/v2/components/menu/BackgroundStyleDropdown.jsx
new file mode 100644
index 0000000000..41cf1df72a
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/menu/BackgroundStyleDropdown.jsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import backgroundStyleOptions from '../../util/backgroundStyleOptions';
+import PopoverDropdown from './PopoverDropdown';
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  value: PropTypes.string.isRequired,
+  onChange: PropTypes.func.isRequired,
+};
+
+function renderButton(option) {
+  return (
+    <div className={cx('background-style-option', option.className)}>
+      {`${option.label} background`}
+    </div>
+  );
+}
+
+function renderOption(option) {
+  return (
+    <div className={cx('background-style-option', option.className)}>
+      {option.label}
+    </div>
+  );
+}
+
+export default class BackgroundStyleDropdown extends React.PureComponent {
+  render() {
+    const { id, value, onChange } = this.props;
+    return (
+      <PopoverDropdown
+        id={id}
+        options={backgroundStyleOptions}
+        value={value}
+        onChange={onChange}
+        renderButton={renderButton}
+        renderOption={renderOption}
+      />
+    );
+  }
+}
+
+BackgroundStyleDropdown.propTypes = propTypes;
diff --git a/superset/assets/javascripts/dashboard/v2/components/menu/HoverMenu.jsx b/superset/assets/javascripts/dashboard/v2/components/menu/HoverMenu.jsx
new file mode 100644
index 0000000000..c238d023d8
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/menu/HoverMenu.jsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+const propTypes = {
+  position: PropTypes.oneOf(['left', 'top']),
+  innerRef: PropTypes.func,
+  children: PropTypes.node,
+};
+
+const defaultProps = {
+  position: 'left',
+  innerRef: null,
+  children: null,
+};
+
+export default class HoverMenu extends React.PureComponent {
+  render() {
+    const { innerRef, position, children } = this.props;
+    return (
+      <div
+        ref={innerRef}
+        className={cx(
+          'hover-menu',
+          position === 'left' && 'hover-menu--left',
+          position === 'top' && 'hover-menu--top',
+        )}
+      >
+        {children}
+      </div>
+    );
+  }
+}
+
+HoverMenu.propTypes = propTypes;
+HoverMenu.defaultProps = defaultProps;
diff --git a/superset/assets/javascripts/dashboard/v2/components/menu/PopoverDropdown.jsx b/superset/assets/javascripts/dashboard/v2/components/menu/PopoverDropdown.jsx
new file mode 100644
index 0000000000..6a56eab239
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/menu/PopoverDropdown.jsx
@@ -0,0 +1,64 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { DropdownButton, MenuItem } from 'react-bootstrap';
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  options: PropTypes.arrayOf(
+    PropTypes.shape({
+      value: PropTypes.string.isRequired,
+      label: PropTypes.string.isRequired,
+      className: PropTypes.string,
+    }),
+  ).isRequired,
+  onChange: PropTypes.func.isRequired,
+  value: PropTypes.string.isRequired,
+  renderButton: PropTypes.func,
+  renderOption: PropTypes.func,
+};
+
+const defaultProps = {
+  renderButton: option => option.label,
+  renderOption: option => <div className={option.className}>{option.label}</div>,
+};
+
+class PopoverDropdown extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.handleSelect = this.handleSelect.bind(this);
+  }
+
+  handleSelect(nextValue) {
+    this.props.onChange(nextValue);
+  }
+
+  render() {
+    const { id, value, options, renderButton, renderOption } = this.props;
+    const selected = options.find(opt => opt.value === value);
+    return (
+      <DropdownButton
+        id={id}
+        bsSize="small"
+        title={renderButton(selected)}
+        className="popover-dropdown"
+      >
+        {options.map(option => (
+          <MenuItem
+            key={option.value}
+            eventKey={option.value}
+            active={option.value === value}
+            onSelect={this.handleSelect}
+            className="dropdown-item"
+          >
+            {renderOption(option)}
+          </MenuItem>
+        ))}
+      </DropdownButton>
+    );
+  }
+}
+
+PopoverDropdown.propTypes = propTypes;
+PopoverDropdown.defaultProps = defaultProps;
+
+export default PopoverDropdown;
diff --git a/superset/assets/javascripts/dashboard/v2/components/menu/WithPopoverMenu.jsx b/superset/assets/javascripts/dashboard/v2/components/menu/WithPopoverMenu.jsx
new file mode 100644
index 0000000000..f213442a7a
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/menu/WithPopoverMenu.jsx
@@ -0,0 +1,114 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+const propTypes = {
+  children: PropTypes.node,
+  disableClick: PropTypes.bool,
+  menuItems: PropTypes.arrayOf(PropTypes.node),
+  onChangeFocus: PropTypes.func,
+  isFocused: PropTypes.bool,
+  shouldFocus: PropTypes.func,
+  editMode: PropTypes.bool.isRequired,
+};
+
+const defaultProps = {
+  children: null,
+  disableClick: false,
+  onChangeFocus: null,
+  onPressDelete() {},
+  menuItems: [],
+  isFocused: false,
+  shouldFocus: (event, container) => container.contains(event.target),
+};
+
+class WithPopoverMenu extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {
+      isFocused: props.isFocused,
+    };
+    this.setRef = this.setRef.bind(this);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.editMode && nextProps.isFocused && !this.state.isFocused) {
+      document.addEventListener('click', this.handleClick, true);
+      document.addEventListener('drag', this.handleClick, true);
+      this.setState({ isFocused: true });
+    } else if (this.state.isFocused && !nextProps.editMode) {
+      document.removeEventListener('click', this.handleClick, true);
+      document.removeEventListener('drag', this.handleClick, true);
+      this.setState({ isFocused: false });
+    }
+  }
+
+  componentWillUnmount() {
+    document.removeEventListener('click', this.handleClick, true);
+    document.removeEventListener('drag', this.handleClick, true);
+  }
+
+  setRef(ref) {
+    this.container = ref;
+  }
+
+  handleClick(event) {
+    const { onChangeFocus, shouldFocus: shouldFocusFunc, disableClick, editMode } = this.props;
+    const shouldFocus = shouldFocusFunc(event, this.container);
+
+    if (!editMode) {
+      return;
+    }
+
+    if (!disableClick && shouldFocus && !this.state.isFocused) {
+      // if not focused, set focus and add a window event listener to capture outside clicks
+      // this enables us to not set a click listener for ever item on a dashboard
+      document.addEventListener('click', this.handleClick, true);
+      document.addEventListener('drag', this.handleClick, true);
+      this.setState(() => ({ isFocused: true }));
+      if (onChangeFocus) {
+        onChangeFocus(true);
+      }
+    } else if (!shouldFocus && this.state.isFocused) {
+      document.removeEventListener('click', this.handleClick, true);
+      document.removeEventListener('drag', this.handleClick, true);
+      this.setState(() => ({ isFocused: false }));
+      if (onChangeFocus) {
+        onChangeFocus(false);
+      }
+    }
+  }
+
+  render() {
+    const { children, menuItems, editMode } = this.props;
+    const { isFocused } = this.state;
+
+    return (
+      <div
+        ref={this.setRef}
+        onClick={this.handleClick}
+        role="none"
+        className={cx(
+          'with-popover-menu',
+          editMode && isFocused && 'with-popover-menu--focused',
+        )}
+      >
+        {children}
+        {editMode &&
+          isFocused &&
+          menuItems.length > 0 &&
+            <div className="popover-menu" >
+              {menuItems.map((node, i) => (
+                <div className="menu-item" key={`menu-item-${i}`}>{node}</div>
+              ))}
+            </div>}
+      </div>
+    );
+  }
+}
+
+WithPopoverMenu.propTypes = propTypes;
+WithPopoverMenu.defaultProps = defaultProps;
+
+export default WithPopoverMenu;
diff --git a/superset/assets/javascripts/dashboard/v2/components/resizable/ResizableContainer.jsx b/superset/assets/javascripts/dashboard/v2/components/resizable/ResizableContainer.jsx
new file mode 100644
index 0000000000..a532ff0416
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/resizable/ResizableContainer.jsx
@@ -0,0 +1,203 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Resizable from 're-resizable';
+import cx from 'classnames';
+
+import ResizableHandle from './ResizableHandle';
+import resizableConfig from '../../util/resizableConfig';
+import { GRID_BASE_UNIT, GRID_GUTTER_SIZE } from '../../util/constants';
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  children: PropTypes.node,
+  adjustableWidth: PropTypes.bool,
+  adjustableHeight: PropTypes.bool,
+  gutterWidth: PropTypes.number,
+  widthStep: PropTypes.number,
+  heightStep: PropTypes.number,
+  widthMultiple: PropTypes.number,
+  heightMultiple: PropTypes.number,
+  minWidthMultiple: PropTypes.number,
+  maxWidthMultiple: PropTypes.number,
+  minHeightMultiple: PropTypes.number,
+  maxHeightMultiple: PropTypes.number,
+  staticHeight: PropTypes.number,
+  staticHeightMultiple: PropTypes.number,
+  staticWidth: PropTypes.number,
+  staticWidthMultiple: PropTypes.number,
+  onResizeStop: PropTypes.func,
+  onResize: PropTypes.func,
+  onResizeStart: PropTypes.func,
+  editMode: PropTypes.bool.isRequired,
+};
+
+const defaultProps = {
+  children: null,
+  adjustableWidth: true,
+  adjustableHeight: true,
+  gutterWidth: GRID_GUTTER_SIZE,
+  widthStep: GRID_BASE_UNIT,
+  heightStep: GRID_BASE_UNIT,
+  widthMultiple: null,
+  heightMultiple: null,
+  minWidthMultiple: 1,
+  maxWidthMultiple: Infinity,
+  minHeightMultiple: 1,
+  maxHeightMultiple: Infinity,
+  staticHeight: null,
+  staticHeightMultiple: null,
+  staticWidth: null,
+  staticWidthMultiple: null,
+  onResizeStop: null,
+  onResize: null,
+  onResizeStart: null,
+};
+
+// because columns are not multiples of a single variable (width = n*cols + (n-1) * gutters)
+// we snap to the base unit and then snap to _actual_ column multiples on stop
+const SNAP_TO_GRID = [GRID_BASE_UNIT, GRID_BASE_UNIT];
+
+class ResizableContainer extends React.PureComponent {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isResizing: false,
+    };
+
+    this.handleResizeStart = this.handleResizeStart.bind(this);
+    this.handleResize = this.handleResize.bind(this);
+    this.handleResizeStop = this.handleResizeStop.bind(this);
+  }
+
+  handleResizeStart(event, direction, ref) {
+    const { id, onResizeStart } = this.props;
+
+    if (onResizeStart) {
+      onResizeStart({ id, direction, ref });
+    }
+
+    this.setState(() => ({ isResizing: true }));
+  }
+
+  handleResize(event, direction, ref) {
+    const { onResize, id } = this.props;
+    if (onResize) {
+      onResize({ id, direction, ref });
+    }
+  }
+
+  handleResizeStop(event, direction, ref, delta) {
+    const {
+      id,
+      onResizeStop,
+      widthStep,
+      heightStep,
+      widthMultiple,
+      heightMultiple,
+      adjustableHeight,
+      adjustableWidth,
+      gutterWidth,
+    } = this.props;
+
+    if (onResizeStop) {
+      const nextWidthMultiple =
+        widthMultiple + Math.round(delta.width / (widthStep + gutterWidth));
+      const nextHeightMultiple =
+        heightMultiple + Math.round(delta.height / heightStep);
+
+      onResizeStop({
+        id,
+        widthMultiple: adjustableWidth ? nextWidthMultiple : null,
+        heightMultiple: adjustableHeight ? nextHeightMultiple : null,
+      });
+
+      this.setState(() => ({ isResizing: false }));
+    }
+  }
+
+  render() {
+    const {
+      children,
+      adjustableWidth,
+      adjustableHeight,
+      widthStep,
+      heightStep,
+      widthMultiple,
+      heightMultiple,
+      staticHeight,
+      staticHeightMultiple,
+      staticWidth,
+      staticWidthMultiple,
+      minWidthMultiple,
+      maxWidthMultiple,
+      minHeightMultiple,
+      maxHeightMultiple,
+      gutterWidth,
+      editMode,
+    } = this.props;
+
+    const size = {
+      width: adjustableWidth
+        ? ((widthStep + gutterWidth) * widthMultiple) - gutterWidth
+        : (staticWidthMultiple && staticWidthMultiple * widthStep)
+          || staticWidth
+          || undefined,
+      height: adjustableHeight
+        ? heightStep * heightMultiple
+        : (staticHeightMultiple && staticHeightMultiple * heightStep)
+          || staticHeight
+          || undefined,
+    };
+
+    if (!editMode) {
+      return (
+        <div style={{ ...size }}>
+          {children}
+        </div>
+      );
+    }
+
+    let enableConfig = resizableConfig.notAdjustable;
+    if (adjustableWidth && adjustableHeight) enableConfig = resizableConfig.widthAndHeight;
+    else if (adjustableWidth) enableConfig = resizableConfig.widthOnly;
+    else if (adjustableHeight) enableConfig = resizableConfig.heightOnly;
+
+    const { isResizing } = this.state;
+
+    return (
+      <Resizable
+        enable={enableConfig}
+        grid={SNAP_TO_GRID}
+        minWidth={adjustableWidth
+          ? (minWidthMultiple * (widthStep + gutterWidth)) - gutterWidth
+          : undefined}
+        minHeight={adjustableHeight
+          ? (minHeightMultiple * heightStep)
+          : undefined}
+        maxWidth={adjustableWidth
+          ? Math.max(size.width, (maxWidthMultiple * (widthStep + gutterWidth)) - gutterWidth)
+          : undefined}
+        maxHeight={adjustableHeight
+          ? Math.max(size.height, maxHeightMultiple * heightStep)
+          : undefined}
+        size={size}
+        onResizeStart={this.handleResizeStart}
+        onResize={this.handleResize}
+        onResizeStop={this.handleResizeStop}
+        handleComponent={ResizableHandle}
+        className={cx(
+          'resizable-container',
+          isResizing && 'resizable-container--resizing',
+        )}
+      >
+        {children}
+      </Resizable>
+    );
+  }
+}
+
+ResizableContainer.propTypes = propTypes;
+ResizableContainer.defaultProps = defaultProps;
+
+export default ResizableContainer;
diff --git a/superset/assets/javascripts/dashboard/v2/components/resizable/ResizableHandle.jsx b/superset/assets/javascripts/dashboard/v2/components/resizable/ResizableHandle.jsx
new file mode 100644
index 0000000000..9536f6bbf8
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/resizable/ResizableHandle.jsx
@@ -0,0 +1,25 @@
+import React from 'react';
+
+export function BottomRightResizeHandle() {
+  return (
+    <div className="resize-handle resize-handle--bottom-right" />
+  );
+}
+
+export function RightResizeHandle() {
+  return (
+    <div className="resize-handle resize-handle--right" />
+  );
+}
+
+export function BottomResizeHandle() {
+  return (
+    <div className="resize-handle resize-handle--bottom" />
+  );
+}
+
+export default {
+  right: RightResizeHandle,
+  bottom: BottomResizeHandle,
+  bottomRight: BottomRightResizeHandle,
+};
diff --git a/superset/assets/javascripts/dashboard/v2/containers/DashboardBuilder.jsx b/superset/assets/javascripts/dashboard/v2/containers/DashboardBuilder.jsx
new file mode 100644
index 0000000000..62fc94a09e
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/containers/DashboardBuilder.jsx
@@ -0,0 +1,26 @@
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+import DashboardBuilder from '../components/DashboardBuilder';
+
+import {
+  deleteTopLevelTabs,
+  handleComponentDrop,
+} from '../actions/dashboardLayout';
+
+function mapStateToProps({ dashboardLayout: undoableLayout, dashboardState: dashboard }, ownProps) {
+  return {
+    dashboardLayout: undoableLayout.present,
+    cells: ownProps.cells,
+    editMode: dashboard.editMode,
+    showBuilderPane: dashboard.showBuilderPane,
+  };
+}
+
+function mapDispatchToProps(dispatch) {
+  return bindActionCreators({
+    deleteTopLevelTabs,
+    handleComponentDrop,
+  }, dispatch);
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(DashboardBuilder);
diff --git a/superset/assets/javascripts/dashboard/v2/containers/DashboardComponent.jsx b/superset/assets/javascripts/dashboard/v2/containers/DashboardComponent.jsx
new file mode 100644
index 0000000000..01f78052f4
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/containers/DashboardComponent.jsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+
+import ComponentLookup from '../components/gridComponents';
+import getTotalChildWidth from '../util/getChildWidth';
+import { componentShape } from '../util/propShapes';
+import { CHART_TYPE, COLUMN_TYPE, ROW_TYPE } from '../util/componentTypes';
+import { GRID_MIN_COLUMN_COUNT } from '../util/constants';
+
+import {
+  createComponent,
+  deleteComponent,
+  updateComponents,
+  handleComponentDrop,
+} from '../actions/dashboardLayout';
+
+const propTypes = {
+  component: componentShape.isRequired,
+  parentComponent: componentShape.isRequired,
+  createComponent: PropTypes.func.isRequired,
+  deleteComponent: PropTypes.func.isRequired,
+  updateComponents: PropTypes.func.isRequired,
+  handleComponentDrop: PropTypes.func.isRequired,
+};
+
+function mapStateToProps({ dashboardLayout: undoableLayout, dashboardState: dashboard }, ownProps) {
+  const dashboardLayout = undoableLayout.present;
+  const { id, parentId, cells } = ownProps;
+  const component = dashboardLayout[id];
+  const props = {
+    component,
+    parentComponent: dashboardLayout[parentId],
+    editMode: dashboard.editMode,
+  };
+
+  // rows and columns need more data about their child dimensions
+  // doing this allows us to not pass the entire component lookup to all Components
+  if (props.component.type === ROW_TYPE) {
+    props.occupiedColumnCount = getTotalChildWidth({ id, components: dashboardLayout });
+  } else if (props.component.type === COLUMN_TYPE) {
+    props.minColumnWidth = GRID_MIN_COLUMN_COUNT;
+
+    component.children.forEach((childId) => {
+      // rows don't have widths, so find the width of its children
+      if (dashboardLayout[childId].type === ROW_TYPE) {
+        props.minColumnWidth = Math.max(
+          props.minColumnWidth,
+          getTotalChildWidth({ id: childId, components: dashboardLayout }),
+        );
+      }
+    });
+  } else if (props.component.type === CHART_TYPE) {
+    const chartId = props.component.meta && props.component.meta.chartId;
+    if (chartId) {
+      props.chart = cells[chartId];
+    }
+  }
+
+  return props;
+}
+
+function mapDispatchToProps(dispatch) {
+  return bindActionCreators({
+    createComponent,
+    deleteComponent,
+    updateComponents,
+    handleComponentDrop,
+  }, dispatch);
+}
+
+class DashboardComponent extends React.PureComponent {
+  render() {
+    const { component } = this.props;
+    const Component = ComponentLookup[component.type];
+    return Component ? <Component {...this.props} /> : null;
+  }
+}
+
+DashboardComponent.propTypes = propTypes;
+
+export default connect(mapStateToProps, mapDispatchToProps)(DashboardComponent);
diff --git a/superset/assets/javascripts/dashboard/v2/containers/DashboardGrid.jsx b/superset/assets/javascripts/dashboard/v2/containers/DashboardGrid.jsx
new file mode 100644
index 0000000000..2adc390a5e
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/containers/DashboardGrid.jsx
@@ -0,0 +1,24 @@
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+import DashboardGrid from '../components/DashboardGrid';
+
+import {
+  handleComponentDrop,
+  resizeComponent,
+} from '../actions/dashboardLayout';
+
+function mapStateToProps({ dashboardState: dashboard }, ownProps) {
+  return {
+    editMode: dashboard.editMode,
+    cells: ownProps.cells,
+  };
+}
+
+function mapDispatchToProps(dispatch) {
+  return bindActionCreators({
+    handleComponentDrop,
+    resizeComponent,
+  }, dispatch);
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(DashboardGrid);
diff --git a/superset/assets/javascripts/dashboard/v2/containers/DashboardHeader.jsx b/superset/assets/javascripts/dashboard/v2/containers/DashboardHeader.jsx
new file mode 100644
index 0000000000..4bc8dd4c1d
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/containers/DashboardHeader.jsx
@@ -0,0 +1,58 @@
+import { ActionCreators as UndoActionCreators } from 'redux-undo';
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+
+import DashboardHeader from '../../components/Header';
+import {
+  setEditMode,
+  toggleBuilderPane,
+  fetchFaveStar,
+  saveFaveStar,
+  fetchCharts,
+  startPeriodicRender,
+  updateDashboardTitle,
+  onChange,
+  onSave,
+} from '../../actions/dashboard';
+import {
+  handleComponentDrop,
+} from '../actions/dashboardLayout';
+
+function mapStateToProps({ dashboardLayout: undoableLayout, dashboardState: dashboard,
+                           dashboardInfo, charts }) {
+  return {
+    dashboardInfo,
+    canUndo: undoableLayout.past.length > 0,
+    canRedo: undoableLayout.future.length > 0,
+    layout: undoableLayout.present,
+    filters: dashboard.filters,
+    dashboardTitle: dashboard.title,
+    expandedSlices: dashboard.expandedSlices,
+    charts,
+    userId: dashboardInfo.userId,
+    isStarred: !!dashboard.isStarred,
+    hasUnsavedChanges: !!dashboard.hasUnsavedChanges,
+    editMode: !!dashboard.editMode,
+    showBuilderPane: !!dashboard.showBuilderPane,
+  };
+}
+
+function mapDispatchToProps(dispatch) {
+  return bindActionCreators({
+    handleComponentDrop,
+    onUndo: UndoActionCreators.undo,
+    onRedo: UndoActionCreators.redo,
+    setEditMode,
+    toggleBuilderPane,
+    fetchFaveStar,
+    saveFaveStar,
+    fetchCharts,
+    startPeriodicRender,
+    updateDashboardTitle,
+    onChange,
+    onSave,
+  }, dispatch);
+}
+
+
+export default connect(mapStateToProps, mapDispatchToProps)(DashboardHeader);
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 0000000000..7e70abc594
--- /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/fixtures/emptyDashboardLayout.js b/superset/assets/javascripts/dashboard/v2/fixtures/emptyDashboardLayout.js
new file mode 100644
index 0000000000..7816cc2965
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/fixtures/emptyDashboardLayout.js
@@ -0,0 +1,36 @@
+import {
+  DASHBOARD_GRID_TYPE,
+  DASHBOARD_HEADER_TYPE,
+  DASHBOARD_ROOT_TYPE,
+} from '../util/componentTypes';
+
+import {
+  DASHBOARD_ROOT_ID,
+  DASHBOARD_HEADER_ID,
+  DASHBOARD_GRID_ID,
+} from '../util/constants';
+
+export default {
+  [DASHBOARD_ROOT_ID]: {
+    type: DASHBOARD_ROOT_TYPE,
+    id: DASHBOARD_ROOT_ID,
+    children: [
+      DASHBOARD_GRID_ID,
+    ],
+  },
+
+  [DASHBOARD_GRID_ID]: {
+    type: DASHBOARD_GRID_TYPE,
+    id: DASHBOARD_GRID_ID,
+    children: [],
+    meta: {},
+  },
+
+  [DASHBOARD_HEADER_ID]: {
+    type: DASHBOARD_HEADER_TYPE,
+    id: DASHBOARD_HEADER_ID,
+    meta: {
+      text: 'New dashboard',
+    },
+  },
+};
diff --git a/superset/assets/javascripts/dashboard/v2/reducers/dashboardLayout.js b/superset/assets/javascripts/dashboard/v2/reducers/dashboardLayout.js
new file mode 100644
index 0000000000..994ac47099
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/reducers/dashboardLayout.js
@@ -0,0 +1,233 @@
+import { DASHBOARD_ROOT_ID, DASHBOARD_GRID_ID, NEW_COMPONENTS_SOURCE_ID } from '../util/constants';
+import newComponentFactory from '../util/newComponentFactory';
+import newEntitiesFromDrop from '../util/newEntitiesFromDrop';
+import reorderItem from '../util/dnd-reorder';
+import shouldWrapChildInRow from '../util/shouldWrapChildInRow';
+import {
+  CHART_TYPE,
+  COLUMN_TYPE,
+  MARKDOWN_TYPE,
+  ROW_TYPE,
+  TAB_TYPE,
+  TABS_TYPE,
+} from '../util/componentTypes';
+
+import {
+  UPDATE_COMPONENTS,
+  DELETE_COMPONENT,
+  CREATE_COMPONENT,
+  MOVE_COMPONENT,
+  CREATE_TOP_LEVEL_TABS,
+  DELETE_TOP_LEVEL_TABS,
+} from '../actions/dashboardLayout';
+
+const actionHandlers = {
+  [UPDATE_COMPONENTS](state, action) {
+    const { payload: { nextComponents } } = action;
+    return {
+      ...state,
+      ...nextComponents,
+    };
+  },
+
+  [DELETE_COMPONENT](state, action) {
+    const { payload: { id, parentId } } = action;
+
+    if (!parentId || !id || !state[id] || !state[parentId]) return state;
+
+    const nextComponents = { ...state };
+
+    // recursively find children to remove
+    function recursivelyDeleteChildren(componentId, componentParentId) {
+      // delete child and it's children
+      const component = nextComponents[componentId];
+      delete nextComponents[componentId];
+
+      const { children = [] } = component;
+      children.forEach((childId) => { recursivelyDeleteChildren(childId, componentId); });
+
+      const parent = nextComponents[componentParentId];
+      if (parent) { // may have been deleted in another recursion
+        const componentIndex = (parent.children || []).indexOf(componentId);
+        if (componentIndex > -1) {
+          const nextChildren = [...parent.children];
+          nextChildren.splice(componentIndex, 1);
+          nextComponents[componentParentId] = {
+            ...parent,
+            children: nextChildren,
+          };
+        }
+      }
+    }
+
+    recursivelyDeleteChildren(id, parentId);
+
+    return nextComponents;
+  },
+
+  [CREATE_COMPONENT](state, action) {
+    const { payload: { dropResult } } = action;
+    const { destination, dragging } = dropResult;
+    const newEntities = newEntitiesFromDrop({ dropResult, components: state });
+
+    // inherit the width of a column parent
+    if (destination.type === COLUMN_TYPE && [CHART_TYPE, MARKDOWN_TYPE].includes(dragging.type)) {
+      const newEntitiesArray = Object.values(newEntities);
+      const component = newEntitiesArray.find(entity => entity.type === dragging.type);
+      const parentColumn = newEntities[destination.id];
+
+      newEntities[component.id] = {
+        ...component,
+        meta: {
+          ...component.meta,
+          width: parentColumn.meta.width,
+        },
+      };
+    }
+
+    return {
+      ...state,
+      ...newEntities,
+    };
+  },
+
+  [MOVE_COMPONENT](state, action) {
+    const { payload: { dropResult } } = action;
+    const { source, destination, dragging } = dropResult;
+
+    if (!source || !destination || !dragging) return state;
+
+    const nextEntities = reorderItem({
+      entitiesMap: state,
+      source,
+      destination,
+    });
+
+    // wrap the dragged component in a row depending on destination type
+    const wrapInRow = shouldWrapChildInRow({
+      parentType: destination.type,
+      childType: dragging.type,
+    });
+
+    if (wrapInRow) {
+      const destinationEntity = nextEntities[destination.id];
+      const destinationChildren = destinationEntity.children;
+      const newRow = newComponentFactory(ROW_TYPE);
+      newRow.children = [destinationChildren[destination.index]];
+      destinationChildren[destination.index] = newRow.id;
+      nextEntities[newRow.id] = newRow;
+    }
+
+    // inherit the width of a column parent
+    if (destination.type === COLUMN_TYPE && [CHART_TYPE, MARKDOWN_TYPE].includes(dragging.type)) {
+      const component = nextEntities[dragging.id];
+      const parentColumn = nextEntities[destination.id];
+      nextEntities[dragging.id] = {
+        ...component,
+        meta: {
+          ...component.meta,
+          width: parentColumn.meta.width,
+        },
+      };
+    }
+
+    return {
+      ...state,
+      ...nextEntities,
+    };
+  },
+
+  [CREATE_TOP_LEVEL_TABS](state, action) {
+    const { payload: { dropResult } } = action;
+    const { source, dragging } = dropResult;
+
+    // move children of current root to be children of the dragging tab
+    const rootComponent = state[DASHBOARD_ROOT_ID];
+    const topLevelId = rootComponent.children[0];
+    const topLevelComponent = state[topLevelId];
+
+    if (source.id !== NEW_COMPONENTS_SOURCE_ID) {
+      // component already exists
+      const draggingTabs = state[dragging.id];
+      const draggingTabId = draggingTabs.children[0];
+      const draggingTab = state[draggingTabId];
+
+      // move all children except the one that is dragging
+      const childrenToMove = [...topLevelComponent.children].filter(id => id !== dragging.id);
+
+      return {
+        ...state,
+        [DASHBOARD_ROOT_ID]: {
+          ...rootComponent,
+          children: [dragging.id],
+        },
+        [topLevelId]: {
+          ...topLevelComponent,
+          children: [],
+        },
+        [draggingTabId]: {
+          ...draggingTab,
+          children: [
+            ...draggingTab.children,
+            ...childrenToMove,
+          ],
+        },
+      };
+    }
+
+    // create new component
+    const newEntities = newEntitiesFromDrop({ dropResult, components: state });
+    const newEntitiesArray = Object.values(newEntities);
+    const tabComponent = newEntitiesArray.find(component => component.type === TAB_TYPE);
+    const tabsComponent = newEntitiesArray.find(component => component.type === TABS_TYPE);
+
+    tabComponent.children = [...topLevelComponent.children];
+    newEntities[topLevelId] = { ...topLevelComponent, children: [] };
+    newEntities[DASHBOARD_ROOT_ID] = { ...rootComponent, children: [tabsComponent.id] };
+
+    return {
+      ...state,
+      ...newEntities,
+    };
+  },
+
+  [DELETE_TOP_LEVEL_TABS](state) {
+    const rootComponent = state[DASHBOARD_ROOT_ID];
+    const topLevelId = rootComponent.children[0];
+    const topLevelTabs = state[topLevelId];
+
+    if (topLevelTabs.type !== TABS_TYPE) return state;
+
+    let childrenToMove = [];
+    const nextEntities = { ...state };
+
+    topLevelTabs.children.forEach((tabId) => {
+      const tabComponent = state[tabId];
+      childrenToMove = [...childrenToMove, ...tabComponent.children];
+      delete nextEntities[tabId];
+    });
+
+    delete nextEntities[topLevelId];
+
+    nextEntities[DASHBOARD_ROOT_ID] = {
+      ...rootComponent,
+      children: [DASHBOARD_GRID_ID],
+    };
+
+    nextEntities[DASHBOARD_GRID_ID] = {
+      ...(state[DASHBOARD_GRID_ID]),
+      children: childrenToMove,
+    };
+
+    return nextEntities;
+  },
+};
+
+export default function layoutReducer(state = {}, action) {
+  if (action.type in actionHandlers) {
+    const handler = actionHandlers[action.type];
+    return handler(state, action);
+  }
+
+  return state;
+}
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 0000000000..b1a1630187
--- /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
new file mode 100644
index 0000000000..061255db0a
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/reducers/index.js
@@ -0,0 +1,8 @@
+import undoable, { distinctState } from 'redux-undo';
+
+import dashboardLayout from './dashboardLayout';
+
+export default undoable(dashboardLayout, {
+  limit: 15,
+  filter: distinctState(),
+});
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 0000000000..1f5728ac43
--- /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
new file mode 100644
index 0000000000..2ff99a4da6
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/builder.less
@@ -0,0 +1,73 @@
+.dashboard-v2 {
+  //margin-top: -20px;
+  position: relative;
+  color: @almost-black;
+}
+
+.dashboard-header {
+  background: white;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 24px;
+  box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.1); /* @TODO color */
+}
+
+.dashboard-content {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: nowrap;
+  height: auto;
+}
+
+/* only top-level tabs have popover, give it more padding to match header + tabs */
+.dashboard-v2 > .with-popover-menu > .popover-menu {
+  left: 24px;
+}
+
+/* drop shadow for top-level tabs only */
+.dashboard-v2 .dashboard-component-tabs {
+  box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.1);
+  padding-left: 8px; /* note this is added to tab-level padding, to match header */
+}
+
+.dashboard-content .grid-container .dashboard-component-tabs {
+  box-shadow: none;
+  padding-left: 0;
+}
+
+.dashboard-content > div:first-child {
+  width: 100%;
+  flex-grow: 1;
+  position: relative;
+}
+
+.dashboard-builder-sidepane {
+  background: white;
+  flex: 0 0 376px;
+  border: 1px solid @gray-light;
+  z-index: 1;
+  position: relative;
+}
+
+.dashboard-builder-sidepane-header {
+  font-size: 15px;
+  font-weight: 700;
+  border-bottom: 1px solid @gray-light;
+  padding: 14px;
+}
+
+/* @TODO remove upon new theme */
+.btn.btn-primary {
+  background: @almost-black !important;
+  color: white !important;
+}
+
+.background--transparent {
+  background-color: transparent;
+}
+
+.background--white {
+  background-color: white;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/buttons.less b/superset/assets/javascripts/dashboard/v2/stylesheets/buttons.less
new file mode 100644
index 0000000000..41ca478978
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/buttons.less
@@ -0,0 +1,23 @@
+.icon-button {
+  color: @gray;
+  font-size: 1.2em;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: center;
+  outline: none;
+}
+
+.icon-button:hover,
+.icon-button:active,
+.icon-button:focus {
+  color: @almost-black;
+  outline: none;
+  text-decoration: none;
+}
+
+.icon-button-label {
+  color: @gray-dark;
+  padding-left: 8px;
+  font-size: 0.9em;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/components/chart.less b/superset/assets/javascripts/dashboard/v2/stylesheets/components/chart.less
new file mode 100644
index 0000000000..ce0379731b
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/components/chart.less
@@ -0,0 +1,20 @@
+.dashboard-component-chart {
+  width: 100%;
+  height: 100%;
+  color: @gray-dark;
+  background-color: white;
+  padding: 16px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  position: relative;
+}
+
+.dashboard-component-chart .fa {
+  //font-size: 100px;
+  opacity: 0.3;
+}
+
+.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
new file mode 100644
index 0000000000..95651124a7
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/components/column.less
@@ -0,0 +1,45 @@
+.grid-column {
+  width: 100%;
+}
+
+/* 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 > .hover-menu--top {
+  top: -20px;
+}
+
+.grid-column--empty {
+  min-height: 72px;
+}
+
+.grid-column--empty:before {
+  content: "Empty column";
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: @gray-light;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/components/divider.less b/superset/assets/javascripts/dashboard/v2/stylesheets/components/divider.less
new file mode 100644
index 0000000000..e4625d3495
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/components/divider.less
@@ -0,0 +1,24 @@
+.dashboard-component-divider {
+  width: 100%;
+  padding: 8px 0; /* this is padding not margin to enable a larger mouse target */
+  background-color: transparent;
+}
+
+.dashboard-component-divider:after {
+  content: "";
+  height: 1px;
+  width: 100%;
+  background-color: @gray-light;
+  display: block;
+}
+
+.new-component-placeholder.divider-placeholder:after {
+  content: "";
+  height: 2px;
+  width: 100%;
+  background-color: @gray-light;
+}
+
+.dragdroppable .dashboard-component-divider {
+  cursor: move;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/components/header.less b/superset/assets/javascripts/dashboard/v2/stylesheets/components/header.less
new file mode 100644
index 0000000000..37c759880f
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/components/header.less
@@ -0,0 +1,46 @@
+.dashboard-component-header {
+  width: 100%;
+  line-height: 1em;
+  font-weight: 700;
+  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: 24px;
+}
+
+.header-large {
+  font-size: 32px;
+}
+
+.background--white .dashboard-component-header,
+.dashboard-component-header.background--white,
+.dashboard-component-tabs .dashboard-component-header,
+.dashboard-component-tabs .dashboard-component-divider {
+  padding-left: 16px;
+  padding-right: 16px;
+}
+
+/*
+ * grids add margin between items, so don't double pad within columns
+ * we'll not worry about double padding on top as it can serve as a visual separator
+ */
+// .grid-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
new file mode 100644
index 0000000000..5a1803eb4d
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/components/index.less
@@ -0,0 +1,7 @@
+@import './chart.less';
+@import './column.less';
+@import './divider.less';
+@import './header.less';
+@import './new-component.less';
+@import './row.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
new file mode 100644
index 0000000000..decb1ad093
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/components/new-component.less
@@ -0,0 +1,27 @@
+.new-component {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: nowrap;
+  align-items: center;
+  padding: 16px;
+  background: white;
+  cursor: move;
+}
+
+.new-component-placeholder {
+  position: relative;
+  background: @gray-bg;
+  width: 40px;
+  height: 40px;
+  margin-right: 16px;
+  box-shadow: 0 0 1px white;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: @gray;
+  font-size: 1.5em;
+}
+
+.new-component-placeholder.fa-window-restore {
+  font-size: 1em;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/components/row.less b/superset/assets/javascripts/dashboard/v2/stylesheets/components/row.less
new file mode 100644
index 0000000000..956966db52
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/components/row.less
@@ -0,0 +1,48 @@
+.grid-row {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  align-items: flex-start;
+  width: 100%;
+  height: fit-content;
+}
+
+/* gutters between elements in a row */
+.grid-row > :not(:only-child):not(:last-child):not(.hover-menu) {
+  margin-right: 16px;
+}
+
+/* hover indicator */
+.dashboard-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-v2--editing .grid-row:hover:after {
+  border: 1px solid @gray-light;
+}
+
+.grid-row.grid-row--empty {
+  align-items: center; /* this centers the empty note content */
+  height: 80px;
+}
+
+.grid-row--empty:before {
+  position: absolute;
+  top: 0;
+  left: 0;
+  content: "Empty row";
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  height: 100%;
+  color: @gray;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/components/tabs.less b/superset/assets/javascripts/dashboard/v2/stylesheets/components/tabs.less
new file mode 100644
index 0000000000..f67c151007
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/components/tabs.less
@@ -0,0 +1,80 @@
+.dashboard-component-tabs {
+  width: 100%;
+  background-color: white;
+}
+
+.dashboard-component-tabs .dashboard-component-tabs-content {
+  min-height: 48px;
+  margin-top: 1px;
+}
+
+.dashboard-component-tabs .nav-tabs {
+  border-bottom: none;
+}
+
+/* by moving padding from <a/> to <li/> we can restrict the selected tab indicator to text width */
+.dashboard-component-tabs .nav-tabs > li {
+  margin: 0 16px;
+}
+
+.dashboard-component-tabs .nav-tabs > li > a {
+  color: @almost-black;
+  border: none;
+  padding: 12px 0 14px 0;
+  font-size: 15px;
+  margin-right: 0;
+}
+
+.dashboard-component-tabs .nav-tabs > li.active > a {
+  border: none;
+}
+
+.dashboard-component-tabs .nav-tabs > li.active > a:after {
+  content: "";
+  position: absolute;
+  height: 3px;
+  width: 100%;
+  bottom: 0;
+  background: linear-gradient(to right, #E32464, #2C2261);
+}
+
+.dashboard-component-tabs .nav-tabs > li > a:hover {
+  border: none;
+  background: inherit;
+  color: @almost-black;
+}
+
+.dashboard-component-tabs .nav-tabs > li > a:focus {
+  outline: none;
+  background: #fff;
+}
+
+.dashboard-component-tabs .nav-tabs > li .dragdroppable-tab {
+  cursor: move;
+}
+
+.dashboard-component-tabs .nav-tabs > li .drop-indicator {
+  top: -12px !important;
+  height: ~"calc(100% + 24px)" !important;
+}
+
+.dashboard-component-tabs .nav-tabs > li .drop-indicator--left {
+  left: -12px !important;
+}
+
+.dashboard-component-tabs .nav-tabs > li .drop-indicator--right {
+  right: -12px !important;
+}
+
+.dashboard-component-tabs .nav-tabs > li .drop-indicator--top,
+.dashboard-component-tabs .nav-tabs > li .drop-indicator--bottom {
+  left: -12px !important;
+  width: ~"calc(100% + 24px)" !important; /* escape for .less */
+  opacity: 0.4;
+}
+
+.dashboard-component-tabs li .fa-plus {
+  color: @gray-dark;
+  font-size: 14px;
+  margin-top: 3px;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/dnd.less b/superset/assets/javascripts/dashboard/v2/stylesheets/dnd.less
new file mode 100644
index 0000000000..45a9784721
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/dnd.less
@@ -0,0 +1,78 @@
+.dragdroppable {
+  position: relative;
+}
+
+.dragdroppable--dragging {
+  opacity: 0.15;
+}
+
+.dragdroppable-row {
+  width: 100%;
+}
+
+/* drop indicators */
+.drop-indicator {
+  margin: auto;
+  background-color: @indicator-color;
+  position: absolute;
+  z-index: 10;
+}
+
+.drop-indicator--top {
+  top: 0;
+  left: 0;
+  height: 4px;
+  width: 100%;
+  min-width: 16px;
+}
+
+.drop-indicator--bottom {
+  top: 100%;
+  left: 0;
+  height: 4px;
+  width: 100%;
+  min-width: 16px;
+}
+
+.drop-indicator--right {
+  top: 0;
+  left: 100%;
+  height: 100%;
+  width: 4px;
+  min-height: 16px;
+}
+
+.drop-indicator--left {
+  top: 0;
+  left: 0;
+  height: 100%;
+  width: 4px;
+  min-height: 16px;
+}
+
+/* drag handles */
+.drag-handle {
+  overflow: hidden;
+  width: 16px;
+  cursor: move;
+}
+
+.drag-handle--left {
+  width: 8px;
+}
+
+.drag-handle-dot {
+  float: left;
+  height: 2px;
+  margin: 1px;
+  width: 2px
+}
+
+.drag-handle-dot:after {
+  content: "";
+  background: #aaa;
+  float: left;
+  height: 2px;
+  margin: -1px;
+  width: 2px;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/grid.less b/superset/assets/javascripts/dashboard/v2/stylesheets/grid.less
new file mode 100644
index 0000000000..45b8a42b22
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/grid.less
@@ -0,0 +1,40 @@
+.grid-container {
+  position: relative;
+  margin: 24px;
+}
+
+.grid-content {
+  height: 100%;
+  display: flex;
+  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%;
+}
+
+/* Editing guides */
+.grid-column-guide {
+  position: absolute;
+  top: 0;
+  height: 100%;
+  background-color: rgba(68, 192, 255, 0.05);
+  pointer-events: none;
+  box-shadow: inset 0 0 0 1px rgba(68, 192, 255, 0.5);
+}
+
+.grid-row-guide {
+  position: absolute;
+  left: 0;
+  bottom: 2;
+  height: 2;
+  background-color: @indicator-color;
+  pointer-events: none;
+  z-index: 10;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/hover-menu.less b/superset/assets/javascripts/dashboard/v2/stylesheets/hover-menu.less
new file mode 100644
index 0000000000..77edb0675a
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/hover-menu.less
@@ -0,0 +1,44 @@
+.hover-menu {
+  opacity: 0;
+  position: absolute;
+  z-index: 2;
+}
+
+.hover-menu--left {
+  width: 24px;
+  height: 100%;
+  top: 0;
+  left: -24px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+}
+
+.hover-menu--left > :nth-child(n):not(:only-child):not(:last-child) {
+  margin-bottom: 12px;
+}
+
+.dragdroppable-row .dragdroppable-row .hover-menu--left {
+  left: 1px;
+}
+
+.hover-menu--top {
+  width: 100%;
+  height: 24px;
+  top: 0;
+  left: 0;
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+  align-items: center;
+}
+
+.hover-menu--top > :nth-child(n):not(:only-child):not(:last-child) {
+  margin-right: 12px;
+}
+
+div:hover > .hover-menu,
+.hover-menu:hover {
+  opacity: 1;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/index.less b/superset/assets/javascripts/dashboard/v2/stylesheets/index.less
new file mode 100644
index 0000000000..49ff5da09b
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/index.less
@@ -0,0 +1,11 @@
+@import './variables.less';
+
+@import './builder.less';
+@import './buttons.less';
+@import './dnd.less';
+@import './grid.less';
+@import './hover-menu.less';
+@import './popover-menu.less';
+@import './resizable.less';
+@import './components/index.less';
+@import './toast.less';
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/popover-menu.less b/superset/assets/javascripts/dashboard/v2/stylesheets/popover-menu.less
new file mode 100644
index 0000000000..848949b8ca
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/popover-menu.less
@@ -0,0 +1,130 @@
+.with-popover-menu {
+  position: relative;
+  outline: none;
+}
+
+.grid-row.grid-row--empty .with-popover-menu { /* drop indicator doesn't show up without this */
+  width: 100%;
+  height: 100%;
+}
+
+.with-popover-menu--focused:after {
+  content: "";
+  position: absolute;
+  top: 1;
+  left: -1;
+  width: 100%;
+  height: 100%;
+  box-shadow: inset 0 0 0 2px @indicator-color;
+  pointer-events: none;
+  z-index: 9;
+}
+
+.popover-menu {
+  position: absolute;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  flex-wrap: nowrap;
+  left: 1px;
+  top: -42px;
+  height: 40px;
+  padding: 0 16px;
+  background: white;
+  box-shadow: 0 1px 2px 1px rgba(0, 0, 0, 0.2);
+  font-size: 14px;
+  cursor: default;
+  z-index: 10;
+}
+
+/* the focus menu doesn't account for parent padding */
+.dashboard-component-tabs li .with-popover-menu--focused:after {
+  top: -12px;
+  left: -2px;
+  width: ~"calc(100% + 4px)"; /* escape for .less */
+  height: ~"calc(100% + 28px)";
+}
+
+.dashboard-component-tabs li .popover-menu {
+  top: -56px;
+}
+
+.popover-menu .menu-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+
+/* vertical spacer after each menu item */
+.popover-menu .menu-item:not(:only-child):not(:last-child):after {
+  content: "";
+  width: 1;
+  height: 100%;
+  background: @gray-light;
+  margin: 0 16px;
+}
+
+.popover-menu .popover-dropdown.btn {
+  border: none;
+  padding: 0;
+  font-size: inherit;
+  color: @almost-black;
+}
+
+.popover-menu .popover-dropdown.btn:hover,
+.popover-menu .popover-dropdown.btn:active,
+.popover-menu .popover-dropdown.btn:focus,
+.hover-dropdown .btn:hover,
+.hover-dropdown .btn:active,
+.hover-dropdown .btn:focus {
+  background: initial;
+  box-shadow: none;
+}
+
+.hover-dropdown li.dropdown-item:hover a,
+.popover-menu li.dropdown-item:hover a {
+  background: @gray-light;
+}
+
+.popover-dropdown .caret { /* without this the caret doesn't take up full width / is clipped */
+  width: auto;
+  border-top-color: transparent;
+}
+
+
+.hover-dropdown li.dropdown-item.active a,
+.popover-menu li.dropdown-item.active a {
+  background: white;
+  font-weight: bold;
+  color: @almost-black;
+}
+
+/* background style menu */
+.background-style-option {
+  display: inline-block;
+}
+
+.background-style-option:before {
+  content: "";
+  width: 1em;
+  height: 1em;
+  margin-right: 8px;
+  display: inline-block;
+  vertical-align: middle;
+}
+
+.background-style-option.background--white {
+  padding-left: 0;
+  background: transparent;
+}
+
+.background-style-option.background--white:before {
+  background: white;
+  border: 1px solid @gray-light;
+}
+
+.background-style-option.background--transparent:before {
+  background-image: linear-gradient(45deg, @gray 25%, transparent 25%), linear-gradient(-45deg, @gray 25%, transparent 25%), linear-gradient(45deg, transparent 75%, @gray 75%), linear-gradient(-45deg, transparent 75%, @gray 75%);
+  background-size: 8px 8px;
+  background-position: 0 0, 0 4px, 4px -4px, -4px 0px
+}
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/resizable.less b/superset/assets/javascripts/dashboard/v2/stylesheets/resizable.less
new file mode 100644
index 0000000000..7bdd5f8c81
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/resizable.less
@@ -0,0 +1,68 @@
+.resizable-container {
+  background-color: transparent;
+  position: relative;
+}
+
+/* after ensures border visibility on top of any children */
+.resizable-container--resizing:after {
+  content: "";
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  box-shadow: inset 0 0 0 2px @indicator-color;
+}
+
+.resize-handle {
+  opacity: 0;
+}
+
+  .resizable-container:hover .resize-handle,
+  .resizable-container--resizing .resize-handle {
+    opacity: 1;
+  }
+
+.resize-handle--bottom-right {
+  position: absolute;
+  border: solid;
+  border-width: 0 1.5px 1.5px 0;
+  border-right-color: @gray;
+  border-bottom-color: @gray;
+  right: 16px;
+  bottom: 16px;
+  width: 8px;
+  height: 8px;
+}
+
+.resize-handle--right {
+  width: 2px;
+  height: 20px;
+  right: 2px;
+  top: ~"calc(50% - 9px)"; /* escape for .less */
+  position: absolute;
+  border-left: 1px solid @gray;
+  border-right: 1px solid @gray;
+}
+
+.resize-handle--bottom {
+  height: 2px;
+  width: 20px;
+  bottom: 2px;
+  left: ~"calc(50% - 10px)"; /* escape for .less */
+  position: absolute;
+  border-top: 1px solid @gray;
+  border-bottom: 1px solid @gray;
+}
+
+.resizable-container--resizing > span .resize-handle {
+  border-color: @indicator-color;
+}
+
+/* re-resizable sets an empty div to 100% width and height, which doesn't
+  play well with many 100% height containers we need
+ */
+.resizable-container ~ div {
+  width: auto !important;
+  height: auto !important;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/stylesheets/toast.less b/superset/assets/javascripts/dashboard/v2/stylesheets/toast.less
new file mode 100644
index 0000000000..a5086379cb
--- /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
new file mode 100644
index 0000000000..254af23a4b
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/stylesheets/variables.less
@@ -0,0 +1,15 @@
+@indicator-color: #44C0FF;
+
+@almost-black: #263238;
+@gray-dark: #484848;
+@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/backgroundStyleOptions.js b/superset/assets/javascripts/dashboard/v2/util/backgroundStyleOptions.js
new file mode 100644
index 0000000000..cda678f6dd
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/backgroundStyleOptions.js
@@ -0,0 +1,7 @@
+import { t } from '../../../locales';
+import { BACKGROUND_TRANSPARENT, BACKGROUND_WHITE } from './constants';
+
+export default [
+  { value: BACKGROUND_TRANSPARENT, label: t('Transparent'), className: 'background--transparent' },
+  { value: BACKGROUND_WHITE, label: t('White'), className: 'background--white' },
+];
diff --git a/superset/assets/javascripts/dashboard/v2/util/componentIsResizable.js b/superset/assets/javascripts/dashboard/v2/util/componentIsResizable.js
new file mode 100644
index 0000000000..c0016f3ac1
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/componentIsResizable.js
@@ -0,0 +1,13 @@
+import {
+  COLUMN_TYPE,
+  CHART_TYPE,
+  MARKDOWN_TYPE,
+} from './componentTypes';
+
+export default function componentIsResizable(entity) {
+  return [
+    COLUMN_TYPE,
+    CHART_TYPE,
+    MARKDOWN_TYPE,
+  ].indexOf(entity.type) > -1;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/util/componentTypes.js b/superset/assets/javascripts/dashboard/v2/util/componentTypes.js
new file mode 100644
index 0000000000..286689888f
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/componentTypes.js
@@ -0,0 +1,27 @@
+export const CHART_TYPE = 'DASHBOARD_CHART_TYPE';
+export const COLUMN_TYPE = 'DASHBOARD_COLUMN_TYPE';
+export const DASHBOARD_GRID_TYPE = 'DASHBOARD_GRID_TYPE';
+export const DASHBOARD_HEADER_TYPE = 'DASHBOARD_DASHBOARD_HEADER_TYPE';
+export const DASHBOARD_ROOT_TYPE = 'DASHBOARD_ROOT_TYPE';
+export const DIVIDER_TYPE = 'DASHBOARD_DIVIDER_TYPE';
+export const HEADER_TYPE = 'DASHBOARD_HEADER_TYPE';
+export const MARKDOWN_TYPE = 'DASHBOARD_MARKDOWN_TYPE';
+export const NEW_COMPONENT_SOURCE_TYPE = 'NEW_COMPONENT_SOURCE_TYPE';
+export const ROW_TYPE = 'DASHBOARD_ROW_TYPE';
+export const TABS_TYPE = 'DASHBOARD_TABS_TYPE';
+export const TAB_TYPE = 'DASHBOARD_TAB_TYPE';
+
+export default {
+  CHART_TYPE,
+  COLUMN_TYPE,
+  DASHBOARD_GRID_TYPE,
+  DASHBOARD_HEADER_TYPE,
+  DASHBOARD_ROOT_TYPE,
+  DIVIDER_TYPE,
+  HEADER_TYPE,
+  MARKDOWN_TYPE,
+  NEW_COMPONENT_SOURCE_TYPE,
+  ROW_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+};
diff --git a/superset/assets/javascripts/dashboard/v2/util/constants.js b/superset/assets/javascripts/dashboard/v2/util/constants.js
new file mode 100644
index 0000000000..f35614c269
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/constants.js
@@ -0,0 +1,39 @@
+// Ids
+export const DASHBOARD_GRID_ID = 'DASHBOARD_GRID_ID';
+export const DASHBOARD_HEADER_ID = 'DASHBOARD_HEADER_ID';
+export const DASHBOARD_ROOT_ID = 'DASHBOARD_ROOT_ID';
+
+export const NEW_COMPONENTS_SOURCE_ID = 'NEW_COMPONENTS_SOURCE_ID';
+export const NEW_CHART_ID = 'NEW_CHART_ID';
+export const NEW_COLUMN_ID = 'NEW_COLUMN_ID';
+export const NEW_DIVIDER_ID = 'NEW_DIVIDER_ID';
+export const NEW_HEADER_ID = 'NEW_HEADER_ID';
+export const NEW_MARKDOWN_ID = 'NEW_MARKDOWN_ID';
+export const NEW_ROW_ID = 'NEW_ROW_ID';
+export const NEW_TAB_ID = 'NEW_TAB_ID';
+export const NEW_TABS_ID = 'NEW_TABS_ID';
+
+// grid constants
+export const DASHBOARD_ROOT_DEPTH = 0;
+export const GRID_BASE_UNIT = 8;
+export const GRID_GUTTER_SIZE = 2 * GRID_BASE_UNIT;
+export const GRID_COLUMN_COUNT = 12;
+export const GRID_MIN_COLUMN_COUNT = 2;
+export const GRID_MIN_ROW_UNITS = 5;
+export const GRID_MAX_ROW_UNITS = 100;
+export const GRID_MIN_ROW_HEIGHT = GRID_GUTTER_SIZE;
+
+// Header types
+export const SMALL_HEADER = 'SMALL_HEADER';
+export const MEDIUM_HEADER = 'MEDIUM_HEADER';
+export const LARGE_HEADER = 'LARGE_HEADER';
+
+// Style types
+export const BACKGROUND_WHITE = 'BACKGROUND_WHITE';
+export const BACKGROUND_TRANSPARENT = 'BACKGROUND_TRANSPARENT';
+
+// Toast types
+export const INFO_TOAST = 'INFO_TOAST';
+export const SUCCESS_TOAST = 'SUCCESS_TOAST';
+export const WARNING_TOAST = 'WARNING_TOAST';
+export const DANGER_TOAST = 'DANGER_TOAST';
diff --git a/superset/assets/javascripts/dashboard/v2/util/dnd-reorder.js b/superset/assets/javascripts/dashboard/v2/util/dnd-reorder.js
new file mode 100644
index 0000000000..9a0dedfd6d
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/dnd-reorder.js
@@ -0,0 +1,54 @@
+export function reorder(list, startIndex, endIndex) {
+  const result = [...list];
+  const [removed] = result.splice(startIndex, 1);
+  result.splice(endIndex, 0, removed);
+
+  return result;
+}
+
+export default function reorderItem({
+  entitiesMap,
+  source,
+  destination,
+}) {
+  const current = [...entitiesMap[source.id].children];
+  const next = [...entitiesMap[destination.id].children];
+  const target = current[source.index];
+
+  // moving to same list
+  if (source.id === destination.id) {
+    const reordered = reorder(
+      current,
+      source.index,
+      destination.index,
+    );
+
+    const result = {
+      ...entitiesMap,
+      [source.id]: {
+        ...entitiesMap[source.id],
+        children: reordered,
+      },
+    };
+
+    return result;
+  }
+
+  // moving to different list
+  current.splice(source.index, 1); // remove from original
+  next.splice(destination.index, 0, target); // insert into next
+
+  const result = {
+    ...entitiesMap,
+    [source.id]: {
+      ...entitiesMap[source.id],
+      children: current,
+    },
+    [destination.id]: {
+      ...entitiesMap[destination.id],
+      children: next,
+    },
+  };
+
+  return result;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/util/dropOverflowsParent.js b/superset/assets/javascripts/dashboard/v2/util/dropOverflowsParent.js
new file mode 100644
index 0000000000..0fd0c4e7ec
--- /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/findParentId.js b/superset/assets/javascripts/dashboard/v2/util/findParentId.js
new file mode 100644
index 0000000000..0ca15a66a9
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/findParentId.js
@@ -0,0 +1,15 @@
+export default function findParentId({ childId, components = {} }) {
+  let parentId = null;
+
+  const ids = Object.keys(components);
+  for (let i = 0; i < ids.length - 1; i += 1) {
+    const id = ids[i];
+    const component = components[id] || {};
+    if (id !== childId && component.children && component.children.includes(childId)) {
+      parentId = id;
+      break;
+    }
+  }
+
+  return parentId;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/util/getChildWidth.js b/superset/assets/javascripts/dashboard/v2/util/getChildWidth.js
new file mode 100644
index 0000000000..aa32b96c00
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/getChildWidth.js
@@ -0,0 +1,13 @@
+export default function getTotalChildWidth({ id, components }) {
+  const component = components[id];
+  if (!component) return 0;
+
+  let width = 0;
+
+  (component.children || []).forEach((childId) => {
+    const child = components[childId];
+    width += child.meta.width || 0;
+  });
+
+  return width;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/util/getDropPosition.js b/superset/assets/javascripts/dashboard/v2/util/getDropPosition.js
new file mode 100644
index 0000000000..9605db2651
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/getDropPosition.js
@@ -0,0 +1,98 @@
+import isValidChild from './isValidChild';
+import { TAB_TYPE, TABS_TYPE } from './componentTypes';
+
+export const DROP_TOP = 'DROP_TOP';
+export const DROP_RIGHT = 'DROP_RIGHT';
+export const DROP_BOTTOM = 'DROP_BOTTOM';
+export const DROP_LEFT = 'DROP_LEFT';
+
+// this defines how close the mouse must be to the edge of a component to display
+// a sibling type drop indicator
+const SIBLING_DROP_THRESHOLD = 15;
+
+export default function getDropPosition(monitor, Component) {
+  const {
+    depth: componentDepth,
+    parentComponent,
+    component,
+    orientation,
+    isDraggingOverShallow,
+  } = Component.props;
+
+  const draggingItem = monitor.getItem();
+
+  // if dropped self on self, do nothing
+  if (!draggingItem || draggingItem.id === component.id || !isDraggingOverShallow) {
+    return null;
+  }
+
+  const validChild = isValidChild({
+    parentType: component.type,
+    parentDepth: componentDepth,
+    childType: draggingItem.type,
+  });
+
+  const parentType = parentComponent && parentComponent.type;
+  const parentDepth = // see isValidChild.js for why tabs don't increment child depth
+    componentDepth + (parentType === TAB_TYPE || parentType === TABS_TYPE ? 0 : -1);
+
+  const validSibling = isValidChild({
+    parentType,
+    parentDepth,
+    childType: draggingItem.type,
+  });
+
+  if (!validChild && !validSibling) {
+    return null;
+  }
+
+  const hasChildren = (component.children || []).length > 0;
+  const childDropOrientation = orientation === 'row' ? 'vertical' : 'horizontal';
+  const siblingDropOrientation = orientation === 'row' ? 'horizontal' : 'vertical';
+
+  if (validChild && !validSibling) { // easiest case, insert as child
+    if (childDropOrientation === 'vertical') {
+      return hasChildren ? DROP_RIGHT : DROP_LEFT;
+    }
+    return hasChildren ? DROP_BOTTOM : DROP_TOP;
+  }
+
+  const refBoundingRect = Component.ref.getBoundingClientRect();
+  const clientOffset = monitor.getClientOffset();
+
+  // Drop based on mouse position relative to component center
+  if (validSibling && !validChild) {
+    if (siblingDropOrientation === 'vertical') {
+      const refMiddleX =
+        refBoundingRect.left + ((refBoundingRect.right - refBoundingRect.left) / 2);
+      return clientOffset.x < refMiddleX ? DROP_LEFT : DROP_RIGHT;
+    }
+    const refMiddleY = refBoundingRect.top + ((refBoundingRect.bottom - refBoundingRect.top) / 2);
+    return clientOffset.y < refMiddleY ? DROP_TOP : DROP_BOTTOM;
+  }
+
+  // either is valid, so choose location based on boundary deltas
+  if (validSibling && validChild) {
+    const deltaTop = Math.abs(clientOffset.y - refBoundingRect.top);
+    const deltaBottom = Math.abs(clientOffset.y - refBoundingRect.bottom);
+    const deltaLeft = Math.abs(clientOffset.x - refBoundingRect.left);
+    const deltaRight = Math.abs(clientOffset.x - refBoundingRect.right);
+
+    // if near enough to a sibling boundary, drop there
+    if (siblingDropOrientation === 'vertical') {
+      if (deltaLeft < SIBLING_DROP_THRESHOLD) return DROP_LEFT;
+      if (deltaRight < SIBLING_DROP_THRESHOLD) return DROP_RIGHT;
+    } else {
+      if (deltaTop < SIBLING_DROP_THRESHOLD) return DROP_TOP;
+      if (deltaBottom < SIBLING_DROP_THRESHOLD) return DROP_BOTTOM;
+    }
+
+    // drop as child
+    if (childDropOrientation === 'vertical') {
+      return hasChildren ? DROP_RIGHT : DROP_LEFT;
+    }
+    return hasChildren ? DROP_BOTTOM : DROP_TOP;
+  }
+
+  return null;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/util/headerStyleOptions.js b/superset/assets/javascripts/dashboard/v2/util/headerStyleOptions.js
new file mode 100644
index 0000000000..309d482ab6
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/headerStyleOptions.js
@@ -0,0 +1,8 @@
+import { t } from '../../../locales';
+import { SMALL_HEADER, MEDIUM_HEADER, LARGE_HEADER } from './constants';
+
+export default [
+  { value: SMALL_HEADER, label: t('Small'), className: 'header-small' },
+  { value: MEDIUM_HEADER, label: t('Medium'), className: 'header-medium' },
+  { value: LARGE_HEADER, label: t('Large'), className: 'header-large' },
+];
diff --git a/superset/assets/javascripts/dashboard/v2/util/isValidChild.js b/superset/assets/javascripts/dashboard/v2/util/isValidChild.js
new file mode 100644
index 0000000000..66942f03b4
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/isValidChild.js
@@ -0,0 +1,94 @@
+/* eslint max-len: 0 */
+/**
+  * When determining if a component is a valid child of another component we must consider both
+  *   - parent + child component types
+  *   - component depth, or depth of nesting of container components
+  *
+  * We consider types because some components aren't containers (e.g. a heading) and we consider
+  * depth to prevent infinite nesting of container components.
+  *
+  * The following example container nestings should be valid, which means that some containers
+  * don't increase the (depth) of their children, namely tabs and tab:
+  *   (a) root (0) > grid (1) >                         row (2) > column (3) > row (4) > non-container (5)
+  *   (b) root (0) > grid (1) >    tabs (2) > tab (2) > row (2) > column (3) > row (4) > non-container (5)
+  *   (c) root (0) > top-tab (1) >                      row (2) > column (3) > row (4) > non-container (5)
+  *   (d) root (0) > top-tab (1) > tabs (2) > tab (2) > row (2) > column (3) > row (4) > non-container (5)
+  */
+import {
+  CHART_TYPE,
+  COLUMN_TYPE,
+  DASHBOARD_GRID_TYPE,
+  DASHBOARD_ROOT_TYPE,
+  DIVIDER_TYPE,
+  HEADER_TYPE,
+  MARKDOWN_TYPE,
+  ROW_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+} from './componentTypes';
+
+import { DASHBOARD_ROOT_DEPTH as rootDepth } from './constants';
+
+const depthOne = rootDepth + 1;
+const depthTwo = rootDepth + 2;
+const depthThree = rootDepth + 3;
+const depthFour = rootDepth + 4;
+
+// when moving components around the depth of child is irrelevant, note these are parent depths
+const parentMaxDepthLookup = {
+  [DASHBOARD_ROOT_TYPE]: {
+    [TABS_TYPE]: rootDepth,
+    [DASHBOARD_GRID_TYPE]: rootDepth,
+  },
+
+  [DASHBOARD_GRID_TYPE]: {
+    [CHART_TYPE]: depthOne,
+    [COLUMN_TYPE]: depthOne,
+    [DIVIDER_TYPE]: depthOne,
+    [HEADER_TYPE]: depthOne,
+    [ROW_TYPE]: depthOne,
+    [TABS_TYPE]: depthOne,
+  },
+
+  [ROW_TYPE]: {
+    [CHART_TYPE]: depthFour,
+    [MARKDOWN_TYPE]: depthFour,
+    [COLUMN_TYPE]: depthTwo,
+  },
+
+  [TABS_TYPE]: {
+    [TAB_TYPE]: depthTwo,
+  },
+
+  [TAB_TYPE]: {
+    [CHART_TYPE]: depthTwo,
+    [COLUMN_TYPE]: depthTwo,
+    [DIVIDER_TYPE]: depthTwo,
+    [HEADER_TYPE]: depthTwo,
+    [ROW_TYPE]: depthTwo,
+    [TABS_TYPE]: depthTwo,
+  },
+
+  [COLUMN_TYPE]: {
+    [CHART_TYPE]: depthThree,
+    [HEADER_TYPE]: depthThree,
+    [MARKDOWN_TYPE]: depthThree,
+    [ROW_TYPE]: depthThree,
+  },
+
+  // these have no valid children
+  [CHART_TYPE]: {},
+  [DIVIDER_TYPE]: {},
+  [HEADER_TYPE]: {},
+  [MARKDOWN_TYPE]: {},
+};
+
+export default function isValidChild({ parentType, childType, parentDepth }) {
+  if (!parentType || !childType || typeof parentDepth !== 'number') {
+    return false;
+  }
+
+  const maxParentDepth = (parentMaxDepthLookup[parentType] || {})[childType];
+
+  return typeof maxParentDepth === 'number' && parentDepth <= maxParentDepth;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/util/newComponentFactory.js b/superset/assets/javascripts/dashboard/v2/util/newComponentFactory.js
new file mode 100644
index 0000000000..b428ddd35f
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/newComponentFactory.js
@@ -0,0 +1,48 @@
+import {
+  CHART_TYPE,
+  COLUMN_TYPE,
+  DIVIDER_TYPE,
+  HEADER_TYPE,
+  MARKDOWN_TYPE,
+  ROW_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+} from './componentTypes';
+
+import {
+  MEDIUM_HEADER,
+  BACKGROUND_TRANSPARENT,
+} from './constants';
+
+const typeToDefaultMetaData = {
+  [CHART_TYPE]: { width: 3, height: 30 },
+  [COLUMN_TYPE]: { width: 3, background: BACKGROUND_TRANSPARENT },
+  [DIVIDER_TYPE]: null,
+  [HEADER_TYPE]: {
+    text: 'New header',
+    headerSize: MEDIUM_HEADER,
+    background: BACKGROUND_TRANSPARENT,
+  },
+  [MARKDOWN_TYPE]: { width: 3, height: 30 },
+  [ROW_TYPE]: { background: BACKGROUND_TRANSPARENT },
+  [TABS_TYPE]: null,
+  [TAB_TYPE]: { text: 'New Tab' },
+};
+
+// @TODO this should be replaced by a more robust algorithm
+function uuid(type) {
+  return `${type}-${Math.random().toString(16)}`;
+}
+
+export default function entityFactory(type, meta) {
+  return {
+    version: 'v0',
+    type,
+    id: uuid(type),
+    children: [],
+    meta: {
+      ...typeToDefaultMetaData[type],
+      ...meta,
+    },
+  };
+}
diff --git a/superset/assets/javascripts/dashboard/v2/util/newEntitiesFromDrop.js b/superset/assets/javascripts/dashboard/v2/util/newEntitiesFromDrop.js
new file mode 100644
index 0000000000..7cccc5f1dc
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/newEntitiesFromDrop.js
@@ -0,0 +1,44 @@
+import shouldWrapChildInRow from './shouldWrapChildInRow';
+import newComponentFactory from './newComponentFactory';
+
+import {
+  ROW_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+} from './componentTypes';
+
+export default function newEntitiesFromDrop({ dropResult, components }) {
+  const { dragging, destination } = dropResult;
+
+  const dragType = dragging.type;
+  const dragMeta = dragging.meta;
+  const dropEntity = components[destination.id];
+  const dropType = dropEntity.type;
+  let newDropChild = newComponentFactory(dragType, dragMeta);
+  const wrapChildInRow = shouldWrapChildInRow({ parentType: dropType, childType: dragType });
+
+  const newEntities = {
+    [newDropChild.id]: newDropChild,
+  };
+
+  if (wrapChildInRow) {
+    const rowWrapper = newComponentFactory(ROW_TYPE);
+    rowWrapper.children = [newDropChild.id];
+    newEntities[rowWrapper.id] = rowWrapper;
+    newDropChild = rowWrapper;
+  } else if (dragType === TABS_TYPE) { // create a new tab component
+    const tabChild = newComponentFactory(TAB_TYPE);
+    newDropChild.children = [tabChild.id];
+    newEntities[tabChild.id] = tabChild;
+  }
+
+  const nextDropChildren = [...dropEntity.children];
+  nextDropChildren.splice(destination.index, 0, newDropChild.id);
+
+  newEntities[destination.id] = {
+    ...dropEntity,
+    children: nextDropChildren,
+  };
+
+  return newEntities;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/util/propShapes.jsx b/superset/assets/javascripts/dashboard/v2/util/propShapes.jsx
new file mode 100644
index 0000000000..b81aadbd45
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/propShapes.jsx
@@ -0,0 +1,79 @@
+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,
+  type: PropTypes.oneOf(
+    Object.values(componentTypes),
+  ).isRequired,
+  children: PropTypes.arrayOf(PropTypes.string),
+  meta: PropTypes.shape({
+    // Dimensions
+    width: PropTypes.number,
+    height: PropTypes.number,
+
+    // Header
+    headerSize: PropTypes.oneOf(headerStyleOptions.map(opt => opt.value)),
+
+    // Row
+    background: PropTypes.oneOf(backgroundStyleOptions.map(opt => opt.value)),
+  }),
+});
+
+export const toastShape = PropTypes.shape({
+  id: PropTypes.string.isRequired,
+  toastType: PropTypes.oneOf([INFO_TOAST, SUCCESS_TOAST, WARNING_TOAST, DANGER_TOAST]).isRequired,
+  text: PropTypes.string.isRequired,
+});
+
+export const chartPropShape = PropTypes.shape({
+  id: PropTypes.number.isRequired,
+  chartAlert: PropTypes.string,
+  chartStatus: PropTypes.string,
+  chartUpdateEndTime: PropTypes.number,
+  chartUpdateStartTime: PropTypes.number,
+  latestQueryFormData: PropTypes.object,
+  queryRequest: PropTypes.object,
+  queryResponse: PropTypes.object,
+  triggerQuery: PropTypes.bool,
+  lastRendered: PropTypes.number,
+});
+
+export const slicePropShape = PropTypes.shape({
+  slice_id: PropTypes.number.isRequired,
+  slice_url: PropTypes.string.isRequired,
+  slice_name: PropTypes.string.isRequired,
+  edit_url: PropTypes.string.isRequired,
+  datasource: PropTypes.string,
+  datasource_name: PropTypes.string,
+  datasource_link: PropTypes.string,
+  changedOn: PropTypes.number,
+  modified: PropTypes.string,
+  viz_type: PropTypes.string.isRequired,
+  description: PropTypes.string,
+  description_markeddown: PropTypes.string,
+});
+
+export const dashboardStatePropShape = PropTypes.shape({
+  title: PropTypes.string.isRequired,
+  sliceIds: PropTypes.object.isRequired,
+  refresh: PropTypes.bool.isRequired,
+  filters: PropTypes.object,
+  expandedSlices: PropTypes.object,
+  editMode: PropTypes.bool,
+  showBuilderPane: PropTypes.bool,
+  hasUnsavedChanges: PropTypes.bool,
+});
+
+export const dashboardInfoPropShape = PropTypes.shape({
+  id: PropTypes.number.isRequired,
+  metadata: PropTypes.object,
+  slug: PropTypes.string,
+  dash_edit_perm: PropTypes.bool.isRequired,
+  dash_save_perm: PropTypes.bool.isRequired,
+  common: PropTypes.object,
+  userId: PropTypes.string.isRequired,
+});
\ No newline at end of file
diff --git a/superset/assets/javascripts/dashboard/v2/util/resizableConfig.js b/superset/assets/javascripts/dashboard/v2/util/resizableConfig.js
new file mode 100644
index 0000000000..f94914ee31
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/resizableConfig.js
@@ -0,0 +1,35 @@
+// config for a ResizableContainer
+const adjustableWidthAndHeight = {
+  top: false,
+  right: false,
+  bottom: false,
+  left: false,
+  topRight: false,
+  bottomRight: true,
+  bottomLeft: false,
+  topLeft: false,
+};
+
+const adjustableWidth = {
+  ...adjustableWidthAndHeight,
+  right: true,
+  bottomRight: false,
+};
+
+const adjustableHeight = {
+  ...adjustableWidthAndHeight,
+  bottom: true,
+  bottomRight: false,
+};
+
+const notAdjustable = {
+  ...adjustableWidthAndHeight,
+  bottomRight: false,
+};
+
+export default {
+  widthAndHeight: adjustableWidthAndHeight,
+  widthOnly: adjustableWidth,
+  heightOnly: adjustableHeight,
+  notAdjustable,
+};
diff --git a/superset/assets/javascripts/dashboard/v2/util/shouldWrapChildInRow.js b/superset/assets/javascripts/dashboard/v2/util/shouldWrapChildInRow.js
new file mode 100644
index 0000000000..e7e648cf1b
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/shouldWrapChildInRow.js
@@ -0,0 +1,30 @@
+import {
+  DASHBOARD_GRID_TYPE,
+  CHART_TYPE,
+  COLUMN_TYPE,
+  MARKDOWN_TYPE,
+  TAB_TYPE,
+} from './componentTypes';
+
+const typeToWrapChildLookup = {
+  [DASHBOARD_GRID_TYPE]: {
+    [CHART_TYPE]: true,
+    [COLUMN_TYPE]: true,
+    [MARKDOWN_TYPE]: true,
+  },
+
+  [TAB_TYPE]: {
+    [CHART_TYPE]: true,
+    [COLUMN_TYPE]: true,
+    [MARKDOWN_TYPE]: true,
+  },
+};
+
+export default function shouldWrapChildInRow({ parentType, childType }) {
+  if (!parentType || !childType) return false;
+
+  const wrapChildLookup = typeToWrapChildLookup[parentType];
+  if (!wrapChildLookup) return false;
+
+  return Boolean(wrapChildLookup[childType]);
+}
diff --git a/superset/assets/javascripts/explore/components/ExploreChartHeader.jsx b/superset/assets/javascripts/explore/components/ExploreChartHeader.jsx
index 00231df4a2..b081edb3ba 100644
--- a/superset/assets/javascripts/explore/components/ExploreChartHeader.jsx
+++ b/superset/assets/javascripts/explore/components/ExploreChartHeader.jsx
@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import { chartPropType } from '../../chart/chartReducer';
+import { chartPropShape } from '../../dashboard/v2/util/propShapes';
 import ExploreActionButtons from './ExploreActionButtons';
 import RowCountLabel from './RowCountLabel';
 import EditableTitle from '../../components/EditableTitle';
@@ -28,13 +28,13 @@ const propTypes = {
   table_name: PropTypes.string,
   form_data: PropTypes.object,
   timeout: PropTypes.number,
-  chart: PropTypes.shape(chartPropType),
+  chart: chartPropShape,
 };
 
 class ExploreChartHeader extends React.PureComponent {
   runQuery() {
     this.props.actions.runQuery(this.props.form_data, true,
-      this.props.timeout, this.props.chart.chartKey);
+      this.props.timeout, this.props.chart.id);
   }
 
   updateChartTitleOrSaveSlice(newTitle) {
diff --git a/superset/assets/javascripts/explore/components/ExploreChartPanel.jsx b/superset/assets/javascripts/explore/components/ExploreChartPanel.jsx
index bfb24fff7f..21c6a644fb 100644
--- a/superset/assets/javascripts/explore/components/ExploreChartPanel.jsx
+++ b/superset/assets/javascripts/explore/components/ExploreChartPanel.jsx
@@ -3,7 +3,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { Panel } from 'react-bootstrap';
 
-import { chartPropType } from '../../chart/chartReducer';
+import { chartPropShape } from '../../dashboard/v2/util/propShapes';
 import ChartContainer from '../../chart/ChartContainer';
 import ExploreChartHeader from './ExploreChartHeader';
 
@@ -27,7 +27,7 @@ const propTypes = {
   standalone: PropTypes.bool,
   timeout: PropTypes.number,
   refreshOverlayVisible: PropTypes.bool,
-  chart: PropTypes.shape(chartPropType),
+  chart: chartPropShape,
   errorMessage: PropTypes.node,
 };
 
@@ -45,7 +45,7 @@ class ExploreChartPanel extends React.PureComponent {
         formData={this.props.form_data}
         height={this.getHeight()}
         slice={this.props.slice}
-        chartKey={this.props.chart.chartKey}
+        chartId={this.props.chart.id}
         setControlValue={this.props.actions.setControlValue}
         timeout={this.props.timeout}
         vizType={this.props.vizType}
diff --git a/superset/assets/javascripts/explore/components/ExploreViewContainer.jsx b/superset/assets/javascripts/explore/components/ExploreViewContainer.jsx
index e1b7acbee0..ae5e8680a9 100644
--- a/superset/assets/javascripts/explore/components/ExploreViewContainer.jsx
+++ b/superset/assets/javascripts/explore/components/ExploreViewContainer.jsx
@@ -11,7 +11,7 @@ import QueryAndSaveBtns from './QueryAndSaveBtns';
 import { getExploreUrlAndPayload, getExploreLongUrl } from '../exploreUtils';
 import { areObjectsEqual } from '../../reduxUtils';
 import { getFormDataFromControls } from '../stores/store';
-import { chartPropType } from '../../chart/chartReducer';
+import { chartPropShape } from '../../dashboard/v2/util/propShapes';
 import * as exploreActions from '../actions/exploreActions';
 import * as saveModalActions from '../actions/saveModalActions';
 import * as chartActions from '../../chart/chartAction';
@@ -22,7 +22,7 @@ const propTypes = {
   actions: PropTypes.object.isRequired,
   datasource_type: PropTypes.string.isRequired,
   isDatasourceMetaLoading: PropTypes.bool.isRequired,
-  chart: PropTypes.shape(chartPropType).isRequired,
+  chart: chartPropShape.isRequired,
   slice: PropTypes.object,
   controls: PropTypes.object.isRequired,
   forcedHeight: PropTypes.string,
@@ -72,7 +72,7 @@ class ExploreViewContainer extends React.Component {
     }
     if (np.controls.viz_type.value !== this.props.controls.viz_type.value) {
       this.props.actions.resetControls();
-      this.props.actions.triggerQuery(true, this.props.chart.chartKey);
+      this.props.actions.triggerQuery(true, this.props.chart.id);
     }
     if (np.controls.datasource.value !== this.props.controls.datasource.value) {
       this.props.actions.fetchDatasourceMetadata(np.form_data.datasource, true);
@@ -81,8 +81,8 @@ class ExploreViewContainer extends React.Component {
     const changedControlKeys = this.findChangedControlKeys(this.props.controls, np.controls);
     if (this.hasDisplayControlChanged(changedControlKeys, np.controls)) {
       this.props.actions.updateQueryFormData(
-        getFormDataFromControls(np.controls), this.props.chart.chartKey);
-      this.props.actions.renderTriggered(new Date().getTime(), this.props.chart.chartKey);
+        getFormDataFromControls(np.controls), this.props.chart.id);
+      this.props.actions.renderTriggered(new Date().getTime(), this.props.chart.id);
     }
     if (this.hasQueryControlChanged(changedControlKeys, np.controls)) {
       this.setState({ chartIsStale: true, refreshOverlayVisible: true });
@@ -107,7 +107,7 @@ class ExploreViewContainer extends React.Component {
   onQuery() {
     // remove alerts when query
     this.props.actions.removeControlPanelAlert();
-    this.props.actions.triggerQuery(true, this.props.chart.chartKey);
+    this.props.actions.triggerQuery(true, this.props.chart.id);
 
     this.setState({ chartIsStale: false, refreshOverlayVisible: false });
     this.addHistory({});
@@ -151,7 +151,7 @@ class ExploreViewContainer extends React.Component {
   triggerQueryIfNeeded() {
     if (this.props.chart.triggerQuery && !this.hasErrors()) {
       this.props.actions.runQuery(this.props.form_data, false,
-        this.props.timeout, this.props.chart.chartKey);
+        this.props.timeout, this.props.chart.id);
     }
   }
 
@@ -191,7 +191,7 @@ class ExploreViewContainer extends React.Component {
         formData,
         false,
         this.props.timeout,
-        this.props.chart.chartKey,
+        this.props.chart.id,
       );
     }
   }
diff --git a/superset/assets/javascripts/explore/exploreUtils.js b/superset/assets/javascripts/explore/exploreUtils.js
index 1c1271b045..fcab33f121 100644
--- a/superset/assets/javascripts/explore/exploreUtils.js
+++ b/superset/assets/javascripts/explore/exploreUtils.js
@@ -3,7 +3,7 @@ import URI from 'urijs';
 
 export function getChartKey(explore) {
   const slice = explore.slice;
-  return slice ? ('slice_' + slice.slice_id) : 'slice';
+  return slice ? (slice.slice_id) : 0;
 }
 
 export function getAnnotationJsonUrl(slice_id, form_data, isNative) {
diff --git a/superset/assets/javascripts/explore/index.jsx b/superset/assets/javascripts/explore/index.jsx
index 35eb68db97..0e655d8ba2 100644
--- a/superset/assets/javascripts/explore/index.jsx
+++ b/superset/assets/javascripts/explore/index.jsx
@@ -47,7 +47,7 @@ const chartKey = getChartKey(bootstrappedState);
 const initState = {
   charts: {
     [chartKey]: {
-      chartKey,
+      id: chartKey,
       chartAlert: null,
       chartStatus: 'loading',
       chartUpdateEndTime: null,
diff --git a/superset/assets/javascripts/explore/reducers/index.js b/superset/assets/javascripts/explore/reducers/index.js
index 13d0ed1b0b..129ec6a493 100644
--- a/superset/assets/javascripts/explore/reducers/index.js
+++ b/superset/assets/javascripts/explore/reducers/index.js
@@ -5,9 +5,16 @@ import charts from '../../chart/chartReducer';
 import saveModal from './saveModalReducer';
 import explore from './exploreReducer';
 
+const impressionId = (state = '') => {
+  if (!state) {
+    state = shortid.generate();
+  }
+  return state;
+};
+
 export default combineReducers({
   charts,
   saveModal,
   explore,
-  impressionId: () => (shortid.generate()),
+  impressionId,
 });
diff --git a/superset/assets/javascripts/modules/utils.js b/superset/assets/javascripts/modules/utils.js
index b5590f0e79..cb01c0b007 100644
--- a/superset/assets/javascripts/modules/utils.js
+++ b/superset/assets/javascripts/modules/utils.js
@@ -165,7 +165,6 @@ export const controllerInterface = {
   addFiler: () => {},
   setFilter: () => {},
   getFilters: () => false,
-  clearFilter: () => {},
   removeFilter: () => {},
   filters: {},
 };
diff --git a/superset/assets/package.json b/superset/assets/package.json
index 03a6f652b0..c3afd7a612 100644
--- a/superset/assets/package.json
+++ b/superset/assets/package.json
@@ -43,6 +43,7 @@
   "dependencies": {
     "@data-ui/event-flow": "^0.0.8",
     "@data-ui/sparkline": "^0.0.49",
+    "@vx/responsive": "0.0.153",
     "babel-register": "^6.24.1",
     "bootstrap": "^3.3.6",
     "bootstrap-slider": "^10.0.0",
@@ -77,6 +78,7 @@
     "nvd3": "1.8.6",
     "po2json": "^0.4.5",
     "prop-types": "^15.6.0",
+    "re-resizable": "^4.3.1",
     "react": "^15.6.2",
     "react-ace": "^5.0.1",
     "react-addons-css-transition-group": "^15.6.0",
@@ -84,15 +86,16 @@
     "react-alert": "^2.3.0",
     "react-bootstrap": "^0.31.5",
     "react-bootstrap-slider": "2.0.1",
-    "react-bootstrap-table": "^4.0.2",
     "react-color": "^2.13.8",
     "react-datetime": "2.9.0",
+    "react-dnd": "^2.5.4",
+    "react-dnd-html5-backend": "^2.5.4",
     "react-dom": "^15.6.2",
     "react-gravatar": "^2.6.1",
     "react-grid-layout": "^0.16.0",
     "react-map-gl": "^3.0.4",
     "react-redux": "^5.0.2",
-    "react-resizable": "^1.3.3",
+    "react-search-input": "^0.11.3",
     "react-select": "1.0.0-rc.10",
     "react-select-fast-filter-options": "^0.2.1",
     "react-sortable-hoc": "^0.6.7",
@@ -104,6 +107,7 @@
     "redux": "^3.5.2",
     "redux-localstorage": "^0.4.1",
     "redux-thunk": "^2.1.0",
+    "redux-undo": "^0.6.1",
     "shortid": "^2.2.6",
     "sprintf-js": "^1.1.1",
     "srcdoc-polyfill": "^1.0.0",
@@ -124,6 +128,7 @@
     "clean-webpack-plugin": "^0.1.16",
     "codeclimate-test-reporter": "^0.5.0",
     "css-loader": "^0.28.0",
+    "duplicate-package-checker-webpack-plugin": "^3.0.0",
     "enzyme": "^2.0.0",
     "eslint": "^3.19.0",
     "eslint-config-airbnb": "^15.0.1",
diff --git a/superset/assets/spec/javascripts/chart/Chart_spec.jsx b/superset/assets/spec/javascripts/chart/Chart_spec.jsx
index 9d45e85c90..45a73a1760 100644
--- a/superset/assets/spec/javascripts/chart/Chart_spec.jsx
+++ b/superset/assets/spec/javascripts/chart/Chart_spec.jsx
@@ -20,7 +20,7 @@ describe('Chart', () => {
   };
   const mockedProps = {
     ...chart,
-    chartKey: 'slice_223',
+    id: 223,
     containerId: 'slice-container-223',
     datasource: {},
     formData: {},
diff --git a/superset/assets/spec/javascripts/dashboard/Dashboard_spec.jsx b/superset/assets/spec/javascripts/dashboard/Dashboard_spec.jsx
index 1ac495992a..5eef17860e 100644
--- a/superset/assets/spec/javascripts/dashboard/Dashboard_spec.jsx
+++ b/superset/assets/spec/javascripts/dashboard/Dashboard_spec.jsx
@@ -4,58 +4,64 @@ import { describe, it } from 'mocha';
 import { expect } from 'chai';
 import sinon from 'sinon';
 
-import * as dashboardActions from '../../../javascripts/dashboard/actions';
+import * as sliceActions from '../../../javascripts/dashboard/actions/sliceEntities';
+import * as dashboardActions from '../../../javascripts/dashboard/actions/dashboard';
 import * as chartActions from '../../../javascripts/chart/chartAction';
 import Dashboard from '../../../javascripts/dashboard/components/Dashboard';
-import { defaultFilters, dashboard, charts } from './fixtures';
+import { defaultFilters, dashboardState, dashboardInfo, dashboardLayout,
+  charts, datasources, sliceEntities } from './fixtures';
 
 describe('Dashboard', () => {
   const mockedProps = {
-    actions: { ...chartActions, ...dashboardActions },
+    actions: { ...chartActions, ...dashboardActions, ...sliceActions },
     initMessages: [],
-    dashboard: dashboard.dashboard,
-    slices: charts,
-    filters: dashboard.filters,
-    datasources: dashboard.datasources,
-    refresh: false,
+    dashboardState,
+    dashboardInfo,
+    charts,
+    slices: sliceEntities.slices,
+    datasources,
+    layout: dashboardLayout.present,
     timeout: 60,
-    isStarred: false,
-    userId: dashboard.userId,
+    userId: dashboardInfo.userId,
   };
 
   it('should render', () => {
     const wrapper = shallow(<Dashboard {...mockedProps} />);
     expect(wrapper.find('#dashboard-container')).to.have.length(1);
-    expect(wrapper.instance().getAllSlices()).to.have.length(3);
+    expect(wrapper.instance().getAllCharts()).to.have.length(3);
   });
 
   it('should handle metadata default_filters', () => {
     const wrapper = shallow(<Dashboard {...mockedProps} />);
-    expect(wrapper.instance().props.filters).deep.equal(defaultFilters);
+    expect(wrapper.instance().props.dashboardState.filters).deep.equal(defaultFilters);
   });
 
   describe('getFormDataExtra', () => {
     let wrapper;
-    let selectedSlice;
+    let selectedChart;
     beforeEach(() => {
       wrapper = shallow(<Dashboard {...mockedProps} />);
-      selectedSlice = wrapper.instance().props.dashboard.slices[1];
+      selectedChart = charts[248];
     });
 
     it('should carry default_filters', () => {
-      const extraFilters = wrapper.instance().getFormDataExtra(selectedSlice).extra_filters;
+      const extraFilters = wrapper.instance().getFormDataExtra(selectedChart).extra_filters;
       expect(extraFilters[0]).to.deep.equal({ col: 'region', op: 'in', val: [] });
       expect(extraFilters[1]).to.deep.equal({ col: 'country_name', op: 'in', val: ['United States'] });
     });
 
     it('should carry updated filter', () => {
-      wrapper.setProps({
+      const dashboardState = {
+        ...wrapper.props('dashboardState'),
         filters: {
           256: { region: [] },
           257: { country_name: ['France'] },
         },
+      };
+      wrapper.setProps({
+        dashboardState,
       });
-      const extraFilters = wrapper.instance().getFormDataExtra(selectedSlice).extra_filters;
+      const extraFilters = wrapper.instance().getFormDataExtra(selectedChart).extra_filters;
       expect(extraFilters[1]).to.deep.equal({ col: 'country_name', op: 'in', val: ['France'] });
     });
   });
@@ -65,7 +71,7 @@ describe('Dashboard', () => {
     let spy;
     beforeEach(() => {
       wrapper = shallow(<Dashboard {...mockedProps} />);
-      spy = sinon.spy(wrapper.instance(), 'fetchSlices');
+      spy = sinon.spy(mockedProps.actions, 'runQuery');
     });
     afterEach(() => {
       spy.restore();
@@ -75,13 +81,13 @@ describe('Dashboard', () => {
       const filterKey = Object.keys(defaultFilters)[1];
       wrapper.instance().refreshExcept(filterKey);
       expect(spy.callCount).to.equal(1);
-      expect(spy.getCall(0).args[0].length).to.equal(1);
+      const slice_id = spy.getCall(0).args[0].slice_id;
+      expect(slice_id).to.equal(248);
     });
 
     it('should refresh all slices', () => {
       wrapper.instance().refreshExcept();
-      expect(spy.callCount).to.equal(1);
-      expect(spy.getCall(0).args[0].length).to.equal(3);
+      expect(spy.callCount).to.equal(3);
     });
   });
 
@@ -94,7 +100,7 @@ describe('Dashboard', () => {
       wrapper = shallow(<Dashboard {...mockedProps} />);
       prevProp = wrapper.instance().props;
       refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
-      fetchSlicesStub = sinon.stub(wrapper.instance(), 'fetchSlices');
+      fetchSlicesStub = sinon.stub(mockedProps.actions, 'fetchCharts');
     });
     afterEach(() => {
       fetchSlicesStub.restore();
@@ -106,48 +112,63 @@ describe('Dashboard', () => {
         refreshExceptSpy.reset();
       });
       it('no change', () => {
-        wrapper.setProps({
-          refresh: true,
+        const dashboardState = {
+          ...wrapper.props('dashboardState'),
           filters: {
             256: { region: [] },
             257: { country_name: ['United States'] },
           },
+        };
+        wrapper.setProps({
+          dashboardState,
         });
         wrapper.instance().componentDidUpdate(prevProp);
         expect(refreshExceptSpy.callCount).to.equal(0);
       });
 
       it('remove filter', () => {
-        wrapper.setProps({
+        const dashboardState = {
+          ...wrapper.props('dashboardState'),
           refresh: true,
           filters: {
             256: { region: [] },
           },
+        };
+        wrapper.setProps({
+          dashboardState,
         });
         wrapper.instance().componentDidUpdate(prevProp);
         expect(refreshExceptSpy.callCount).to.equal(1);
       });
 
       it('change filter', () => {
-        wrapper.setProps({
+        const dashboardState = {
+          ...wrapper.props('dashboardState'),
           refresh: true,
           filters: {
             256: { region: [] },
             257: { country_name: ['Canada'] },
           },
+        };
+        wrapper.setProps({
+          dashboardState,
         });
         wrapper.instance().componentDidUpdate(prevProp);
         expect(refreshExceptSpy.callCount).to.equal(1);
       });
 
       it('add filter', () => {
-        wrapper.setProps({
+        const dashboardState = {
+          ...wrapper.props('dashboardState'),
           refresh: true,
           filters: {
             256: { region: [] },
             257: { country_name: ['Canada'] },
             258: { another_filter: ['new'] },
           },
+        };
+        wrapper.setProps({
+          dashboardState,
         });
         wrapper.instance().componentDidUpdate(prevProp);
         expect(refreshExceptSpy.callCount).to.equal(1);
@@ -155,28 +176,36 @@ describe('Dashboard', () => {
     });
 
     it('should refresh if refresh flag is true', () => {
-      wrapper.setProps({
+      const dashboardState = {
+        ...wrapper.props('dashboardState'),
         refresh: true,
         filters: {
           256: { region: ['Asian'] },
         },
+      };
+      wrapper.setProps({
+        dashboardState,
       });
       wrapper.instance().componentDidUpdate(prevProp);
-      const fetchArgs = fetchSlicesStub.lastCall.args[0];
-      expect(fetchArgs).to.have.length(2);
+      expect(refreshExceptSpy.callCount).to.equal(1);
+      expect(refreshExceptSpy.lastCall.args[0]).to.equal('256');
     });
 
     it('should not refresh filter_immune_slices', () => {
-      wrapper.setProps({
+      const dashboardState = {
+        ...wrapper.props('dashboardState'),
         refresh: true,
         filters: {
           256: { region: [] },
           257: { country_name: ['Canada'] },
         },
+      };
+      wrapper.setProps({
+        dashboardState,
       });
       wrapper.instance().componentDidUpdate(prevProp);
-      const fetchArgs = fetchSlicesStub.lastCall.args[0];
-      expect(fetchArgs).to.have.length(1);
+      expect(refreshExceptSpy.callCount).to.equal(1);
+      expect(refreshExceptSpy.lastCall.args[0]).to.equal('257');
     });
   });
 });
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures.jsx b/superset/assets/spec/javascripts/dashboard/fixtures.jsx
index 1b9cf21b62..3c9936f171 100644
--- a/superset/assets/spec/javascripts/dashboard/fixtures.jsx
+++ b/superset/assets/spec/javascripts/dashboard/fixtures.jsx
@@ -1,4 +1,4 @@
-import { getInitialState } from '../../../javascripts/dashboard/reducers';
+import getInitialState from '../../../javascripts/dashboard/reducers/initState';
 
 export const defaultFilters = {
   256: { region: [] },
@@ -118,7 +118,6 @@ export const slice = {
   slice_url: '/superset/explore/table/2/?form_data=%7B%22slice_id%22%3A%20248%7D',
 };
 
-const datasources = {};
 const mockDashboardData = {
   css: '',
   dash_edit_perm: true,
@@ -152,10 +151,13 @@ const mockDashboardData = {
   slices: [regionFilter, slice, countryFilter],
   standalone_mode: false,
 };
-export const { dashboard, charts } = getInitialState({
+export const {
+  dashboardState, dashboardInfo,
+  charts, datasources, sliceEntities,
+  dashboardLayout } = getInitialState({
   common: {},
   dashboard_data: mockDashboardData,
-  datasources,
+  datasources: {},
   user_id: '1',
 });
 
diff --git a/superset/assets/spec/javascripts/dashboard/reducers_spec.js b/superset/assets/spec/javascripts/dashboard/reducers_spec.js
index 8022928ddf..1c49a9bc65 100644
--- a/superset/assets/spec/javascripts/dashboard/reducers_spec.js
+++ b/superset/assets/spec/javascripts/dashboard/reducers_spec.js
@@ -1,20 +1,23 @@
 import { describe, it } from 'mocha';
 import { expect } from 'chai';
 
-import { dashboard as reducers } from '../../../javascripts/dashboard/reducers';
-import * as actions from '../../../javascripts/dashboard/actions';
-import { defaultFilters, dashboard as initState } from './fixtures';
+import reducers from '../../../javascripts/dashboard/reducers/dashboard';
+import * as actions from '../../../javascripts/dashboard/actions/dashboard';
+import { defaultFilters, dashboardState as initState } from './fixtures';
 
 describe('Dashboard reducers', () => {
+  it('should initialized', () => {
+    expect(initState.sliceIds.size).to.equal(3);
+  });
+
   it('should remove slice', () => {
     const action = {
       type: actions.REMOVE_SLICE,
-      slice: initState.dashboard.slices[1],
+      sliceId: 248,
     };
-    expect(initState.dashboard.slices).to.have.length(3);
 
-    const { dashboard, filters, refresh } = reducers(initState, action);
-    expect(dashboard.slices).to.have.length(2);
+    const { sliceIds, filters, refresh } = reducers(initState, action);
+    expect(sliceIds.size).to.be.equal(2);
     expect(filters).to.deep.equal(defaultFilters);
     expect(refresh).to.equal(false);
   });
@@ -22,13 +25,13 @@ describe('Dashboard reducers', () => {
   it('should remove filter slice', () => {
     const action = {
       type: actions.REMOVE_SLICE,
-      slice: initState.dashboard.slices[0],
+      sliceId: 256,
     };
     const initFilters = Object.keys(initState.filters);
     expect(initFilters).to.have.length(2);
 
-    const { dashboard, filters, refresh } = reducers(initState, action);
-    expect(dashboard.slices).to.have.length(2);
+    const { sliceIds, filters, refresh } = reducers(initState, action);
+    expect(sliceIds.size).to.equal(2);
     expect(Object.keys(filters)).to.have.length(1);
     expect(refresh).to.equal(true);
   });
diff --git a/superset/assets/stylesheets/dashboard.css b/superset/assets/stylesheets/dashboard.css
deleted file mode 100644
index c1f08a7e38..0000000000
--- a/superset/assets/stylesheets/dashboard.css
+++ /dev/null
@@ -1,156 +0,0 @@
-.dashboard a i {
-  cursor: pointer;
-}
-.dashboard i.drag {
-  cursor: move !important;
-}
-.dashboard .slice-grid .preview-holder {
-  z-index: 1;
-  position: absolute;
-  background-color: #AAA;
-  border-color: #AAA;
-  opacity: 0.3;
-}
-div.widget .chart-controls {
-  background-clip: content-box;
-  position: absolute;
-  z-index: 100;
-  right: 0;
-  top: 5px;
-  padding: 5px 5px;
-  opacity: 0;
-  transition: opacity 0.5s ease-in-out;
-}
-div.widget:hover .chart-controls {
-  opacity: 0.75;
-  transition: opacity 0.5s ease-in-out;
-}
-.slice-grid div.widget {
-  border-radius: 0;
-  border: 0;
-  box-shadow: none;
-  background-color: #fff;
-  overflow: visible;
-}
-
-.slice-grid .slice_container {
-  background-color: #fff;
-}
-
-.dashboard .slice-grid .dragging,
-.dashboard .slice-grid .resizing {
-  opacity: 0.5;
-}
-.dashboard img.loading {
-  width: 20px;
-  margin: 5px;
-  position: absolute;
-}
-
-.dashboard .slice_title {
-  text-align: center;
-  font-weight: bold;
-  font-size: 14px;
-  padding: 5px;
-}
-.dashboard div.slice_content {
-  width: 100%;
-  height: 100%;
-}
-
-.modal img.loading {
-  width: 50px;
-  margin: 0;
-  position: relative;
-}
-
-.react-bs-container-body {
-  max-height: 400px;
-  overflow-y: auto;
-}
-
-.hidden, #pageDropDown {
-  display: none;
-}
-
-.slice-grid div.separator.widget {
- border: 1px solid transparent;
-  box-shadow: none;
-  z-index: 1;
-}
-.slice-grid div.separator.widget:hover {
-  border: 1px solid #EEE;
-}
-.slice-grid div.separator.widget .chart-header {
-  background-color: transparent;
-  color: transparent;
-}
-.slice-grid div.separator.widget h1,h2,h3,h4 {
-  margin-top: 0px;
-}
-
-.slice-cell {
-  box-shadow: 0px 0px 20px 5px rgba(0,0,0,0);
-  transition: box-shadow 1s ease-in;
-  height: 100%;
-}
-
-.slice-cell-highlight {
-  box-shadow: 0px 0px 20px 5px rgba(0,0,0,0.2);
-  height: 100%;
-}
-
-.slice-cell .editable-title input[type="button"] {
-  font-weight: bold;
-}
-
-.dashboard .separator.widget .slice_container {
-  padding: 0;
-  overflow: visible;
-}
-.dashboard .separator.widget .slice_container hr {
-  margin-top: 5px;
-  margin-bottom: 5px;
-}
-.separator .chart-container {
-  position: absolute;
-  left: 0;
-  right: 0;
-  top: 0;
-  bottom: 0;
-}
-
-.dashboard .title {
-  margin: 0 20px;
-}
-
-.dashboard .title .favstar {
-  font-size: 20px;
-  position: relative;
-  top: -5px;
-}
-
-.chart-header .header {
-  font-size: 16px;
-  margin: 0 -10px;
-}
-.ace_gutter {
-    z-index: 0;
-}
-.ace_content {
-    z-index: 0;
-}
-.ace_scrollbar {
-    z-index: 0;
-}
-.slice_container .alert {
-    margin: 10px;
-}
-
-i.danger {
-  color: red;
-}
-
-i.warning {
-  color: orange;
-}
diff --git a/superset/assets/stylesheets/dashboard.less b/superset/assets/stylesheets/dashboard.less
new file mode 100644
index 0000000000..f28f74c142
--- /dev/null
+++ b/superset/assets/stylesheets/dashboard.less
@@ -0,0 +1,260 @@
+@import "./less/cosmo/variables.less";
+
+.dashboard a i {
+  cursor: pointer;
+}
+.dashboard i.drag {
+  cursor: move !important;
+}
+.dashboard .slice-grid .preview-holder {
+  z-index: 1;
+  position: absolute;
+  background-color: #AAA;
+  border-color: #AAA;
+  opacity: 0.3;
+}
+.dashboard .widget {
+  position: absolute;
+  top: 16px;
+  left: 16px;
+  box-shadow: none;
+  background-color: transparent;
+  overflow: visible;
+}
+.dashboard .chart-header {
+  .dropdown.btn-group {
+    position: absolute;
+    top: 0;
+    right: 0;
+  }
+
+  .dropdown-menu.dropdown-menu-right {
+    right: 7px;
+    top: -3px
+  }
+}
+
+.slice-header-controls-trigger {
+  border: 0;
+  padding: 0 0 0 20px;
+  background: none;
+  outline: none;
+  box-shadow: none;
+  color: #263238;
+
+  &.is-cached {
+    color: red;
+  }
+
+  &:hover, &:focus {
+    background: none;
+    cursor: pointer;
+  }
+
+  .controls-container.dropdown-menu {
+    top: 0;
+    left: unset;
+    right: 10px;
+
+    &.is-open {
+      display: block;
+    }
+
+    & li {
+      white-space: nowrap;
+    }
+  }
+}
+.slice-grid .slice_container {
+  background-color: #fff;
+}
+
+.dashboard .slice-grid .dragging,
+.dashboard .slice-grid .resizing {
+  opacity: 0.5;
+}
+.dashboard img.loading {
+  width: 20px;
+  margin: 5px;
+  position: absolute;
+}
+
+.dashboard .slice_title {
+  text-align: center;
+  font-weight: bold;
+  font-size: 14px;
+  padding: 5px;
+}
+.dashboard div.slice_content {
+  width: 100%;
+  height: 100%;
+}
+
+.modal img.loading {
+  width: 50px;
+  margin: 0;
+  position: relative;
+}
+
+.react-bs-container-body {
+  max-height: 400px;
+  overflow-y: auto;
+}
+
+.hidden, #pageDropDown {
+  display: none;
+}
+
+.slice-cell {
+  box-shadow: 0px 0px 20px 5px rgba(0,0,0,0);
+  transition: box-shadow 1s ease-in;
+
+  .dropdown,
+  .dropdown-menu {
+    .fa {
+      font-size: 14px;
+    }
+  }
+}
+
+.slice-cell-highlight {
+  box-shadow: 0px 0px 20px 5px rgba(0,0,0,0.2);
+  height: 100%;
+}
+
+.slice-cell .editable-title input[type="button"] {
+  font-weight: bold;
+}
+
+.chart-container {
+  box-sizing: border-box;
+}
+
+.chart-header .header {
+  font-size: 16px;
+  margin: 0 -10px;
+}
+.ace_gutter {
+    z-index: 0;
+}
+.ace_content {
+    z-index: 0;
+}
+.ace_scrollbar {
+    z-index: 0;
+}
+.slice_container .alert {
+    margin: 10px;
+}
+
+i.danger {
+  color: red;
+}
+
+i.warning {
+  color: orange;
+}
+
+.dashboard-builder-sidepane {
+  .trigger {
+    height: 25px;
+    width: 25px;
+    color: #879399;
+    position: relative;
+
+    &.close {
+      top: 3px;
+    }
+
+    &.open {
+      position: absolute;
+      right: 14px;
+    }
+  }
+}
+
+.component-layer {
+  .new-component.static {
+    cursor: pointer;
+  }
+}
+
+.slices-layer {
+  position: absolute;
+  width: 2px;
+  top: 51px;
+  right: 1px;
+  background: #fff;
+  transition-property: width;
+  transition-duration: 1s;
+  transition-timing-function: ease;
+  overflow: hidden;
+
+  &.show {
+    width: 374px;
+  }
+}
+
+.dashboard .chart-card-container {
+  padding: 16px;
+  cursor: move;
+
+  .chart-card {
+    border: 1px solid #ccc;
+    height: 120px;
+    padding: 16px;
+    pointer-events: unset;
+  }
+
+  .chart-card.is-selected {
+    opacity: 0.45;
+    pointer-events: none;
+  }
+
+  .card-title {
+    margin-bottom: 8px;
+    font-weight: bold;
+  }
+
+  .card-body {
+    display: flex;
+    flex-direction: column;
+
+    .item {
+      height: 18px;
+    }
+
+    label {
+      margin-right: 5px;
+    }
+  }
+}
+
+.dashboard .slice-adder-container {
+  .controls {
+    display: flex;
+    padding: 16px;
+
+    .dropdown.btn-group button,
+    input {
+      font-size: 14px;
+      line-height: 16px;
+      padding: 7px 12px;
+      height: 32px;
+    }
+
+    input {
+      margin-left: 16px;
+      width: 169px;
+      border: 1px solid #b3b3b3;
+
+      &:focus {
+        outline: none;
+      }
+    }
+  }
+
+  .ReactVirtualized__Grid.ReactVirtualized__List:focus {
+    outline: none;
+  }
+}
\ No newline at end of file
diff --git a/superset/assets/stylesheets/superset.less b/superset/assets/stylesheets/superset.less
index 4ac5ba8ae2..8e8424b86c 100644
--- a/superset/assets/stylesheets/superset.less
+++ b/superset/assets/stylesheets/superset.less
@@ -162,7 +162,6 @@ li.widget:hover {
 div.widget .chart-header {
   padding-top: 8px;
   color: #333;
-  border-bottom: 1px solid #aaa;
   margin: 0 10px;
 }
 
@@ -177,10 +176,6 @@ div.widget .chart-header {
 }
 
 
-div.widget .chart-header a {
-  margin-left: 5px;
-}
-
 #is_cached {
   display: none;
 }
@@ -228,34 +223,42 @@ table.table-no-hover tr:hover {
 }
 
 .editable-title input {
-    padding: 2px 6px 3px 6px;
+  outline: none;
+  background: transparent;
+  border: none;
+  box-shadow: none;
+  padding: 0;
+  cursor: initial;
 }
 
 .editable-title input[type="button"] {
-    border-color: transparent;
-    background: transparent;
-    white-space: normal;
-    text-align: left;
+  border-color: transparent;
+  background: transparent;
+  font-size: inherit;
+  line-height: inherit;
+  white-space: normal;
+  text-align: left;
 }
 
-.editable-title input[type="button"]:hover {
-    cursor: text;
+.editable-title.editable-title--editable {
+  cursor: pointer;
 }
 
-.editable-title input[type="button"]:focus {
-    outline: none;
+.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;
@@ -449,3 +452,14 @@ g.annotation-container {
   color: @brand-primary;
   border-color: @brand-primary;
 }
+
+.fave-unfave-icon {
+  .fa-star-o,
+  .fa-star {
+    &,
+    &:hover,
+    &:active {
+      color: #263238;
+    }
+  }
+}
\ No newline at end of file
diff --git a/superset/assets/visualizations/table.css b/superset/assets/visualizations/table.css
index a5b8462c53..9af0c0e5f5 100644
--- a/superset/assets/visualizations/table.css
+++ b/superset/assets/visualizations/table.css
@@ -30,11 +30,10 @@ table.table thead th.sorting:after, table.table thead th.sorting_asc:after, tabl
   white-space: pre-wrap;
 }
 
+.widget.table {
+  width: auto;
+  max-width: unset;
+}
 .widget.table thead tr {
   height: 25px;
 }
-
-.dashboard .slice_container.table {
-  padding-left: 10px;
-  padding-right: 10px;
-}
diff --git a/superset/models/core.py b/superset/models/core.py
index b4dbada947..04019a9dc2 100644
--- a/superset/models/core.py
+++ b/superset/models/core.py
@@ -146,6 +146,11 @@ def datasource_link(self):
         datasource = self.datasource
         return datasource.link if datasource else None
 
+    def datasource_name_text(self):
+        # pylint: disable=no-member
+        datasource = self.datasource
+        return datasource.name if datasource else None
+
     @property
     def datasource_edit_url(self):
         # pylint: disable=no-member
@@ -338,14 +343,6 @@ def table_names(self):
 
     @property
     def url(self):
-        if self.json_metadata:
-            # add default_filters to the preselect_filters of dashboard
-            json_metadata = json.loads(self.json_metadata)
-            default_filters = json_metadata.get('default_filters')
-            if default_filters:
-                filters = parse.quote(default_filters.encode('utf8'))
-                return '/superset/dashboard/{}/?preselect_filters={}'.format(
-                    self.slug or self.id, filters)
         return '/superset/dashboard/{}/'.format(self.slug or self.id)
 
     @property
diff --git a/superset/templates/appbuilder/navbar.html b/superset/templates/appbuilder/navbar.html
index 27f2fee634..77248f03ac 100644
--- a/superset/templates/appbuilder/navbar.html
+++ b/superset/templates/appbuilder/navbar.html
@@ -12,7 +12,8 @@
       </button>
       <a class="navbar-brand" href="/superset/profile/{{ current_user.username }}/">
         <img
-          width="126" src="{{ appbuilder.app_icon }}"
+          width="126"
+          src="{{ appbuilder.app_icon }}"
           alt="{{ appbuilder.app_name }}"
         />
       </a>
@@ -28,23 +29,7 @@
       </ul>
       <ul class="nav navbar-nav navbar-right">
         {% include 'appbuilder/navbar_right.html' %}
-        <li>
-          <a href="/static/assets/version_info.json" title="Version info">
-            <i class="fa fa-code-fork"></i> &nbsp;
-          </a>
-        </li>
-        <li>
-          <a href="https://github.com/apache/incubator-superset" title="Superset's Github" target="_blank">
-            <i class="fa fa-github"></i> &nbsp;
-          </a>
-        </li>
-        <li>
-          <a href="https://superset.incubator.apache.org" title="Documentation" target="_blank">
-            <i class="fa fa-book"></i> &nbsp;
-          </a>
-        </li>
       </ul>
     </div>
   </div>
 </div>
-
diff --git a/superset/views/core.py b/superset/views/core.py
index 5b0ee5f3bc..8f09784616 100755
--- a/superset/views/core.py
+++ b/superset/views/core.py
@@ -506,9 +506,10 @@ class SliceAsync(SliceModelView):  # noqa
 
 class SliceAddView(SliceModelView):  # noqa
     list_columns = [
-        'id', 'slice_name', 'slice_link', 'viz_type',
-        'datasource_link', 'owners', 'modified', 'changed_on']
-    show_columns = list(set(SliceModelView.edit_columns + list_columns))
+        'id', 'slice_name', 'slice_url', 'edit_url', 'viz_type', 'params',
+        'description', 'description_markeddown',
+        'datasource_name_text', 'datasource_link',
+        'owners', 'modified', 'changed_on']
 
 
 appbuilder.add_view_no_menu(SliceAddView)
@@ -1581,9 +1582,17 @@ def save_dash(self, dashboard_id):
     @staticmethod
     def _set_dash_metadata(dashboard, data):
         positions = data['positions']
-        slice_ids = [int(d['slice_id']) for d in positions]
-        dashboard.slices = [o for o in dashboard.slices if o.id in slice_ids]
-        positions = sorted(data['positions'], key=lambda x: int(x['slice_id']))
+        # find slices in the position data
+        slice_ids = []
+        for value in positions.values():
+            if value.get('meta') and value.get('meta').get('chartId'):
+                slice_ids.append(int(value.get('meta').get('chartId')))
+        session = db.session()
+        Slice = models.Slice  # noqa
+        current_slices = session.query(Slice).filter(
+            Slice.id.in_(slice_ids)).all()
+
+        dashboard.slices = current_slices
         dashboard.position_json = json.dumps(positions, indent=4, sort_keys=True)
         md = dashboard.params_dict
         dashboard.css = data['css']
@@ -1596,7 +1605,11 @@ def _set_dash_metadata(dashboard, data):
         if 'filter_immune_slice_fields' not in md:
             md['filter_immune_slice_fields'] = {}
         md['expanded_slices'] = data['expanded_slices']
-        md['default_filters'] = data.get('default_filters', '')
+        default_filters_data = json.loads(data.get('default_filters', ''))
+        for key in default_filters_data.keys():
+            if int(key) not in slice_ids:
+                del default_filters_data[key]
+        md['default_filters'] = json.dumps(default_filters_data)
         dashboard.json_metadata = json.dumps(md, indent=4)
 
     @api


 

----------------------------------------------------------------
This is an automated message from the Apache Git Service.
To respond to the message, please log on GitHub and use the
URL above to go to the specific comment.
 
For queries about this service, please contact Infrastructure at:
users@infra.apache.org


With regards,
Apache Git Services