superset-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From ccwilli...@apache.org
Subject [incubator-superset] 01/26: [dashboard builder] Add dir structure for dashboard/v2, simplified Header, split pane, Draggable side panel
Date Fri, 22 Jun 2018 00:54:16 GMT
This is an automated email from the ASF dual-hosted git repository.

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

commit 1a03632281ff44b918bcdec4fc5e2526441f9c7a
Author: Chris Williams <chris.williams@airbnb.com>
AuthorDate: Tue Jan 16 14:50:08 2018 -0800

    [dashboard builder] Add dir structure for dashboard/v2, simplified Header, split pane, Draggable side panel
    
    [grid] add <DashboardGrid />, <ResizableContainer />, and initial grid components.
    
    [grid] gridComponents/ directory, add fixtures/ directory and test layout, add <Column />
    
    [grid] working grid with gutters
    
    [grid] design tweaks and polish, add <Tabs />
    
    [header] add gradient header logo and favicon
    
    [dnd] begin adding dnd functionality
    
    [dnd] add util/isValidChild.js
    
    [react-beautiful-dnd] iterate on dnd until blocked
    
    [dnd] refactor to use react-dnd
    
    [react-dnd] refactor to use composable <DashboardComponent /> structure
    
    [dnd] factor out DashboardComponent, let components render dropInidcator and set draggableRef, add draggable tabs
    
    [dnd] refactor to use redux, add DashboardComponent and DashboardGrid containers
    
    [dragdroppable] rename horizontal/vertical => row/column
    
    [builder] refactor into HoverMenu, add WithPopoverMenu
    
    [builder] add editable header and disableDragDrop prop for Dragdroppable's
    
    [builder] make tabs editable
    
    [builder] add generic popover dropdown and header row style editability
    
    [builder] add hover rowStyle dropdown, make row styles editable
    
    [builder] add some new component icons, add popover with delete to charts
    
    [builder] add preview icons, add popover menu to rows.
    
    [builder] add IconButton and RowStyleDropdown
    
    [resizable] use ResizableContainer instead of DimensionProvider, fix resize and delete bugs
    
    [builder] fix bug with spacer
    
    [builder] clean up, header.size => header.headerSize
    
    [builder] support more drag/drop combinations by wrapping some components in rows upon drop. fix within list drop index. refactor some utils.
    
    [builder][tabs] fix broken add tab button
    
    [dashboard builder] don't pass dashboard layout to all dashboard components, improve drop indicator logic, fix delete component pure component bug
    
    [dnd] refactor drop position logic
---
 superset/assets/images/favicon.png                 | Bin 6927 -> 18166 bytes
 superset/assets/images/superset-logo@2x.png        | Bin 4132 -> 0 bytes
 superset/assets/javascripts/dashboard/v2/.eslintrc |  29 ++
 .../javascripts/dashboard/v2/actions/index.js      |  69 ++++
 .../v2/components/BuilderComponentPane.jsx         |  39 ++
 .../dashboard/v2/components/Dashboard.jsx          |  44 ++
 .../dashboard/v2/components/DashboardBuilder.jsx   |  40 ++
 .../dashboard/v2/components/DashboardGrid.jsx      | 171 ++++++++
 .../dashboard/v2/components/DashboardHeader.jsx    |  62 +++
 .../v2/components/DeleteComponentButton.jsx        |  23 ++
 .../dashboard/v2/components/IconButton.jsx         |  40 ++
 .../dashboard/v2/components/StaticDashboard.jsx    |  19 +
 .../dashboard/v2/components/dnd/DragDroppable.jsx  | 100 +++++
 .../dashboard/v2/components/dnd/DragHandle.jsx     |  38 ++
 .../dashboard/v2/components/dnd/dnd.css            |  60 +++
 .../v2/components/dnd/dragDroppableConfig.js       |  67 +++
 .../dashboard/v2/components/dnd/handleDrop.js      |  63 +++
 .../dashboard/v2/components/dnd/handleHover.js     |  37 ++
 .../v2/components/gridComponents/Chart.jsx         | 126 ++++++
 .../v2/components/gridComponents/Column.jsx        | 144 +++++++
 .../v2/components/gridComponents/Divider.jsx       |  64 +++
 .../v2/components/gridComponents/Header.jsx        | 151 +++++++
 .../dashboard/v2/components/gridComponents/Row.jsx | 186 +++++++++
 .../v2/components/gridComponents/Spacer.jsx        | 113 +++++
 .../dashboard/v2/components/gridComponents/Tab.jsx | 162 ++++++++
 .../v2/components/gridComponents/Tabs.jsx          | 211 ++++++++++
 .../v2/components/gridComponents/components.css    | 455 +++++++++++++++++++++
 .../v2/components/gridComponents/grid.css          |  17 +
 .../v2/components/gridComponents/index.js          |  43 ++
 .../gridComponents/new/DraggableNewComponent.jsx   |  39 ++
 .../v2/components/gridComponents/new/NewChart.jsx  |  24 ++
 .../v2/components/gridComponents/new/NewColumn.jsx |  24 ++
 .../components/gridComponents/new/NewDivider.jsx   |  24 ++
 .../v2/components/gridComponents/new/NewHeader.jsx |  24 ++
 .../v2/components/gridComponents/new/NewRow.jsx    |  23 ++
 .../v2/components/gridComponents/new/NewSpacer.jsx |  24 ++
 .../v2/components/gridComponents/new/NewTabs.jsx   |  24 ++
 .../dashboard/v2/components/menu/HoverMenu.jsx     |  36 ++
 .../v2/components/menu/PopoverDropdown.jsx         |  64 +++
 .../v2/components/menu/RowStyleDropdown.jsx        |  46 +++
 .../v2/components/menu/WithPopoverMenu.jsx         | 100 +++++
 .../v2/components/resizable/ResizableContainer.jsx | 184 +++++++++
 .../v2/components/resizable/ResizableHandle.jsx    |  25 ++
 .../v2/components/resizable/resizable.css          |  72 ++++
 .../dashboard/v2/containers/DashboardComponent.jsx |  68 +++
 .../dashboard/v2/containers/DashboardGrid.jsx      |  23 ++
 .../dashboard/v2/fixtures/testLayout.js            | 161 ++++++++
 .../javascripts/dashboard/v2/reducers/dashboard.js | 112 +++++
 .../javascripts/dashboard/v2/reducers/index.js     |   6 +
 .../dashboard/v2/util/componentIsResizable.js      |  15 +
 .../dashboard/v2/util/componentTypes.js            |  23 ++
 .../javascripts/dashboard/v2/util/constants.js     |  30 ++
 .../dashboard/v2/util/countChildRowsAndColumns.js  |  14 +
 .../javascripts/dashboard/v2/util/dnd-reorder.js   |  54 +++
 .../dashboard/v2/util/getDropPosition.js           |  88 ++++
 .../dashboard/v2/util/headerStyleOptions.js        |   8 +
 .../javascripts/dashboard/v2/util/isValidChild.js  |  69 ++++
 .../dashboard/v2/util/newComponentFactory.js       |  45 ++
 .../dashboard/v2/util/newComponentIdToType.js      |  35 ++
 .../dashboard/v2/util/newEntitiesFromDrop.js       |  55 +++
 .../javascripts/dashboard/v2/util/propShapes.jsx   |  24 ++
 .../dashboard/v2/util/resizableConfig.js           |  30 ++
 .../dashboard/v2/util/rowStyleOptions.js           |   7 +
 .../dashboard/v2/util/shouldWrapChildInRow.js      |  30 ++
 superset/assets/package.json                       |   5 +-
 superset/assets/src/components/EditableTitle.jsx   |  36 +-
 .../dashboard/components/DashboardContainer.jsx    |   2 +-
 superset/assets/src/dashboard/index.jsx            |  21 +-
 superset/assets/stylesheets/dashboard-v2.css       |  42 ++
 superset/assets/stylesheets/superset.less          |  24 +-
 superset/config.py                                 |   2 +-
 superset/templates/appbuilder/navbar.html          |   5 +-
 superset/templates/superset/dashboard.html         |   1 -
 73 files changed, 4323 insertions(+), 18 deletions(-)

diff --git a/superset/assets/images/favicon.png b/superset/assets/images/favicon.png
index 55316fa..f03cd5c 100644
Binary files a/superset/assets/images/favicon.png and b/superset/assets/images/favicon.png differ
diff --git a/superset/assets/images/superset-logo@2x.png b/superset/assets/images/superset-logo@2x.png
deleted file mode 100644
index 839f617..0000000
Binary files a/superset/assets/images/superset-logo@2x.png and /dev/null differ
diff --git a/superset/assets/javascripts/dashboard/v2/.eslintrc b/superset/assets/javascripts/dashboard/v2/.eslintrc
new file mode 100644
index 0000000..70efc15
--- /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/index.js b/superset/assets/javascripts/dashboard/v2/actions/index.js
new file mode 100644
index 0000000..005a77e
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/actions/index.js
@@ -0,0 +1,69 @@
+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,
+    },
+  };
+}
+
+
+// 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) => {
+    if (
+      dropResult.destination
+      && dropResult.source
+      && !( // ensure it has moved
+        dropResult.destination.droppableId === dropResult.source.droppableId
+        && dropResult.destination.index === dropResult.source.index
+      )
+    ) {
+      return dispatch(moveComponent(dropResult));
+
+      // new components don't have a source
+    } else if (dropResult.destination && !dropResult.source) {
+      return dispatch(createComponent(dropResult));
+    }
+    return null;
+  };
+}
+
+// Resize ---------------------------------------------------------------------
+
+// export function dashboardComponentResizeStart() {}
+// export function dashboardComponentResize() {}
+// export function dashboardComponentResizeStop() {}
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 0000000..86f3788
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/BuilderComponentPane.jsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import NewChart from './gridComponents/new/NewChart';
+import NewColumn from './gridComponents/new/NewColumn';
+import NewDivider from './gridComponents/new/NewDivider';
+import NewHeader from './gridComponents/new/NewHeader';
+import NewRow from './gridComponents/new/NewRow';
+import NewSpacer from './gridComponents/new/NewSpacer';
+import NewTabs from './gridComponents/new/NewTabs';
+
+const propTypes = {
+  editMode: PropTypes.bool,
+};
+
+class BuilderComponentPane extends React.PureComponent {
+  render() {
+    return (
+      <div className="dashboard-builder-sidepane">
+        <div className="dashboard-builder-sidepane-header">
+          Insert components
+        </div>
+        <NewChart />
+        <NewHeader />
+
+        <NewDivider />
+        <NewSpacer />
+
+        <NewTabs />
+        <NewRow />
+        <NewColumn />
+      </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 0000000..5936006
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/Dashboard.jsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import DashboardBuilder from './DashboardBuilder';
+import StaticDashboard from './StaticDashboard';
+import DashboardHeader from './DashboardHeader';
+
+import '../../../../stylesheets/dashboard-v2.css';
+
+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() {
+    const { editMode, actions } = this.props;
+    const { setEditMode, updateDashboardTitle } = actions;
+    return (
+      <div className="dashboard-v2">
+        <DashboardHeader
+          editMode={true}
+          setEditMode={setEditMode}
+          updateDashboardTitle={updateDashboardTitle}
+        />
+
+        {true ?
+          <DashboardBuilder /> : <StaticDashboard />}
+      </div>
+    );
+  }
+}
+
+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 0000000..94069b7
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/DashboardBuilder.jsx
@@ -0,0 +1,40 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import HTML5Backend from 'react-dnd-html5-backend';
+import { DragDropContext } from 'react-dnd';
+import cx from 'classnames';
+
+import BuilderComponentPane from './BuilderComponentPane';
+import DashboardGrid from '../containers/DashboardGrid';
+
+import './dnd/dnd.css';
+
+const propTypes = {
+  editMode: PropTypes.bool,
+};
+
+const defaultProps = {
+  editMode: true,
+};
+
+class DashboardBuilder extends React.Component {
+  constructor(props) {
+    super(props);
+    // this component might control the state of the side pane etc. in the future
+    this.state = {};
+  }
+
+  render() {
+    return (
+      <div className={cx('dashboard-builder')}>
+        <DashboardGrid />
+        <BuilderComponentPane />
+      </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 0000000..6cbcee5
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/DashboardGrid.jsx
@@ -0,0 +1,171 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ParentSize from '@vx/responsive/build/components/ParentSize';
+import cx from 'classnames';
+
+import DragDroppable from './dnd/DragDroppable';
+import DashboardComponent from '../containers/DashboardComponent';
+
+import {
+  DASHBOARD_ROOT_ID,
+  GRID_GUTTER_SIZE,
+  GRID_COLUMN_COUNT,
+} from '../util/constants';
+
+import './gridComponents/grid.css';
+
+const propTypes = {
+  dashboard: PropTypes.object.isRequired,
+  updateComponents: PropTypes.func.isRequired,
+  handleComponentDrop: 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, heightMultiple }) {
+    const { dashboard: components, updateComponents } = this.props;
+    const component = components[id];
+    if (
+      component &&
+      (component.meta.width !== widthMultiple || component.meta.height !== heightMultiple)
+    ) {
+      updateComponents({
+        [id]: {
+          ...component,
+          meta: {
+            ...component.meta,
+            width: widthMultiple || component.meta.width,
+            height: heightMultiple || component.meta.height,
+          },
+        },
+      });
+    }
+    this.setState(() => ({
+      isResizing: false,
+      rowGuideTop: null,
+    }));
+  }
+
+  render() {
+    const { dashboard: components, handleComponentDrop } = this.props;
+    const { isResizing, rowGuideTop } = this.state;
+    const rootComponent = components[DASHBOARD_ROOT_ID];
+
+    return (
+      <div
+        ref={(ref) => { this.grid = ref; }}
+        className={cx(
+          'grid-container',
+          isResizing && 'grid-container--resizing',
+        )}
+      >
+        <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 style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
+                {(rootComponent.children || []).map((id, index) => (
+                  <DashboardComponent
+                    key={id}
+                    id={id}
+                    parentId={rootComponent.id}
+                    depth={0}
+                    index={index}
+                    availableColumnCount={GRID_COLUMN_COUNT}
+                    columnWidth={columnWidth}
+                    onResizeStart={this.handleResizeStart}
+                    onResize={this.handleResize}
+                    onResizeStop={this.handleResizeStop}
+                  />
+                ))}
+
+                {rootComponent.children.length === 0 &&
+                  <DragDroppable
+                    component={rootComponent}
+                    parentComponent={null}
+                    index={0}
+                    orientation="column"
+                    onDrop={handleComponentDrop}
+                  >
+                    {({ dropIndicatorProps }) => (
+                      <div style={{ width: '100%', height: '100%' }}>
+                        {dropIndicatorProps && <div {...dropIndicatorProps} />}
+                      </div>
+                    )}
+                  </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 0000000..8ffe677
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/DashboardHeader.jsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { ButtonToolbar, DropdownButton, MenuItem } from 'react-bootstrap';
+
+import Button from '../../../components/Button';
+import EditableTitle from '../../../components/EditableTitle';
+
+const propTypes = {
+  updateDashboardTitle: PropTypes.func,
+  editMode: PropTypes.bool.isRequired,
+  setEditMode: PropTypes.func.isRequired,
+};
+
+class Header extends React.Component {
+  constructor(props) {
+    super(props);
+    this.handleSaveTitle = this.handleSaveTitle.bind(this);
+    this.toggleEditMode = this.toggleEditMode.bind(this);
+  }
+
+  handleSaveTitle(title) {
+    this.props.updateDashboardTitle(title);
+  }
+
+  toggleEditMode() {
+    this.props.setEditMode(!this.props.editMode);
+  }
+
+  render() {
+    const { editMode } = this.props;
+    return (
+      <div className="dashboard-header">
+        <h1>
+          <EditableTitle
+            title={'Example header'}
+            canEdit={false}
+            onSaveTitle={() => {}}
+            showTooltip={false}
+          />
+        </h1>
+        <ButtonToolbar>
+          <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>
+    );
+  }
+}
+
+Header.propTypes = propTypes;
+
+export default Header;
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 0000000..18efff4
--- /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 0000000..98044c9
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/IconButton.jsx
@@ -0,0 +1,40 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+const propTypes = {
+  onClick: PropTypes.func.isRequired,
+  className: PropTypes.string,
+};
+
+const defaultProps = {
+  className: 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 } = this.props;
+    return (
+      <div
+        className={cx('icon-button', className)}
+        onClick={this.handleClick}
+        tabIndex="0"
+        role="button"
+      />
+    );
+  }
+}
+
+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 0000000..4fd2397
--- /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/dnd/DragDroppable.jsx b/superset/assets/javascripts/dashboard/v2/components/dnd/DragDroppable.jsx
new file mode 100644
index 0000000..320872b
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/dnd/DragDroppable.jsx
@@ -0,0 +1,100 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { DragSource, DropTarget } from 'react-dnd';
+import cx from 'classnames';
+
+import { dragConfig, dropConfig } from './dragDroppableConfig';
+import { componentShape } from '../../util/propShapes';
+
+
+const propTypes = {
+  children: PropTypes.func,
+  component: componentShape.isRequired,
+  parentComponent: componentShape,
+  disableDragDrop: PropTypes.bool,
+  orientation: PropTypes.oneOf(['row', 'column']),
+  index: PropTypes.number.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,
+
+  // from redux
+  onDrop: PropTypes.func,
+};
+
+const defaultProps = {
+  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
+    };
+  }
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  render() {
+    const {
+      children,
+      orientation,
+      droppableRef,
+      dragSourceRef,
+      dragPreviewRef,
+      isDragging,
+      isDraggingOver,
+    } = this.props;
+
+    const { dropIndicator } = this.state;
+
+    return (
+      <div
+        ref={(ref) => {
+          this.ref = ref;
+          dragPreviewRef(ref);
+          droppableRef(ref);
+        }}
+        className={cx(
+          'dragdroppable',
+          orientation === 'row' && 'dragdroppable-row',
+          orientation === 'column' && 'dragdroppable-column',
+          isDragging && 'dragdroppable--dragging',
+        )}
+      >
+        {children({
+          dragSourceRef,
+          dropIndicatorProps: isDraggingOver && dropIndicator && {
+            className: 'drop-indicator',
+            style: dropIndicator,
+          },
+        })}
+      </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 0000000..36d1e6b
--- /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/dnd.css b/superset/assets/javascripts/dashboard/v2/components/dnd/dnd.css
new file mode 100644
index 0000000..fb010e0
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/dnd/dnd.css
@@ -0,0 +1,60 @@
+.dragdroppable {
+  position: relative;
+}
+
+.dragdroppable--dragging {
+  opacity: 0.25;
+}
+
+.dragdroppable-row {
+  width: 100%;
+}
+
+.grid-container .dragdroppable-row:after,
+.grid-container .dragdroppable-column:after {
+  border: 1px dashed transparent;
+  content: "";
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 1px;
+  left: 0;
+  z-index: 1;
+  pointer-events: none;
+}
+
+  .grid-container .dragdroppable-row:hover:after,
+  .grid-container .dragdroppable-column:hover:after {
+    border: 1px dashed #aaa;
+  }
+
+/* Drag handle */
+.drag-handle {
+  overflow: hidden;
+  width: 16px;
+  cursor: move;
+}
+
+.drag-handle--left {
+  width: 8px;
+}
+
+.drag-handle--top {
+  /*margin: 10px auto;*/
+}
+
+.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/components/dnd/dragDroppableConfig.js b/superset/assets/javascripts/dashboard/v2/components/dnd/dragDroppableConfig.js
new file mode 100644
index 0000000..e6d5533
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/dnd/dragDroppableConfig.js
@@ -0,0 +1,67 @@
+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;
+    },
+    beginDrag(props /* , monitor, component */) {
+      const { component, index, parentComponent } = props;
+      return {
+        draggableId: component.id,
+        index,
+        parentId: parentComponent && parentComponent.id,
+        type: component.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 0000000..cf790da
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/dnd/handleDrop.js
@@ -0,0 +1,63 @@
+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: draggingItem.parentId ? {
+      droppableId: draggingItem.parentId,
+      index: draggingItem.index,
+    } : null,
+    draggableId: draggingItem.draggableId,
+  };
+
+  // simplest case, append as child
+  if (dropAsChildOrSibling === 'child') {
+    dropResult.destination = {
+      droppableId: component.id,
+      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 = {
+      droppableId: parentComponent.id,
+      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 0000000..1eadef4
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/dnd/handleHover.js
@@ -0,0 +1,37 @@
+import throttle from 'lodash.throttle';
+import getDropPosition, { DROP_TOP, DROP_RIGHT, DROP_BOTTOM, DROP_LEFT } 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;
+  }
+
+  // @TODO
+  // drop-indicator
+  // drop-indicator--top/right/bottom/left
+  Component.setState(() => ({
+    dropIndicator: {
+      top: dropPosition === DROP_BOTTOM ? '100%' : 0,
+      left: dropPosition === DROP_RIGHT ? '100%' : 0,
+      height: dropPosition === DROP_LEFT || dropPosition === DROP_RIGHT ? '100%' : 3,
+      width: dropPosition === DROP_TOP || dropPosition === DROP_BOTTOM ? '100%' : 3,
+      minHeight: dropPosition === DROP_LEFT || dropPosition === DROP_RIGHT ? 16 : null,
+      minWidth: dropPosition === DROP_TOP || dropPosition === DROP_BOTTOM ? 16 : null,
+      margin: 'auto',
+      backgroundColor: '#44C0FF',
+      position: 'absolute',
+      zIndex: 10,
+    },
+  }));
+}
+
+// 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/Chart.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Chart.jsx
new file mode 100644
index 0000000..9daa8cf
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Chart.jsx
@@ -0,0 +1,126 @@
+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 {
+  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,
+
+  // 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 Chart 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,
+    } = this.props;
+
+    return (
+      <DragDroppable
+        component={component}
+        parentComponent={parentComponent}
+        orientation={depth % 2 === 1 ? 'column' : 'row'}
+        index={index}
+        onDrop={handleComponentDrop}
+        disableDragDrop={isFocused}
+      >
+        {({ dropIndicatorProps, dragSourceRef }) => (
+          <ResizableContainer
+            id={component.id}
+            adjustableWidth={depth <= 1}
+            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}
+          >
+            <HoverMenu innerRef={dragSourceRef} position="top">
+              <DragHandle position="top" />
+            </HoverMenu>
+
+            <WithPopoverMenu
+              onChangeFocus={this.handleChangeFocus}
+              menuItems={[
+                <DeleteComponentButton onDelete={this.handleDeleteComponent} />,
+              ]}
+            >
+              <div className="dashboard-component dashboard-component-chart">
+                <div className="fa fa-area-chart" />
+              </div>
+
+              {dropIndicatorProps && <div {...dropIndicatorProps} />}
+            </WithPopoverMenu>
+          </ResizableContainer>
+        )}
+      </DragDroppable>
+    );
+  }
+}
+
+Chart.propTypes = propTypes;
+Chart.defaultProps = defaultProps;
+
+export default Chart;
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 0000000..8409bc1
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Column.jsx
@@ -0,0 +1,144 @@
+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 ResizableContainer from '../resizable/ResizableContainer';
+import { componentShape } from '../../util/propShapes';
+
+import { GRID_GUTTER_SIZE, GRID_MIN_COLUMN_COUNT } from '../../util/constants';
+
+const GUTTER = 'GUTTER';
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  parentId: PropTypes.string.isRequired,
+  component: componentShape.isRequired,
+  parentComponent: componentShape.isRequired,
+  index: PropTypes.number.isRequired,
+  depth: PropTypes.number.isRequired,
+  // occupiedRowCount: PropTypes.number,
+
+  // 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 = {
+  // occupiedRowCount: null,
+};
+
+class Column 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: columnComponent,
+      parentComponent,
+      index,
+      availableColumnCount,
+      columnWidth,
+      // occupiedRowCount,
+      depth,
+      onResizeStart,
+      onResize,
+      onResizeStop,
+      handleComponentDrop,
+    } = this.props;
+
+    const columnItems = [];
+
+    (columnComponent.children || []).forEach((id, childIndex) => {
+      columnItems.push(id);
+      if (childIndex < columnComponent.children.length - 1) {
+        columnItems.push(GUTTER);
+      }
+    });
+
+    return (
+      <DragDroppable
+        component={columnComponent}
+        parentComponent={parentComponent}
+        orientation="column"
+        index={index}
+        onDrop={handleComponentDrop}
+      >
+        {({ dropIndicatorProps, dragSourceRef }) => (
+          <ResizableContainer
+            id={columnComponent.id}
+            adjustableWidth
+            adjustableHeight={false}
+            widthStep={columnWidth}
+            widthMultiple={columnComponent.meta.width}
+            // heightMultiple={occupiedRowCount}
+            minWidthMultiple={GRID_MIN_COLUMN_COUNT}
+            maxWidthMultiple={availableColumnCount + (columnComponent.meta.width || 0)}
+            onResizeStart={onResizeStart}
+            onResize={onResize}
+            onResizeStop={onResizeStop}
+          >
+            <div
+              className={cx(
+                'grid-column',
+                columnItems.length === 0 && 'grid-column--empty',
+              )}
+            >
+              <HoverMenu innerRef={dragSourceRef} position="top">
+                <DragHandle position="top" />
+                <DeleteComponentButton onDelete={this.handleDeleteComponent} />
+              </HoverMenu>
+
+              {columnItems.map((componentId, itemIndex) => {
+                if (componentId === GUTTER) {
+                  return <div key={`gutter-${itemIndex}`} style={{ height: GRID_GUTTER_SIZE }} />;
+                }
+
+                return (
+                  <DashboardComponent
+                    key={componentId}
+                    id={componentId}
+                    parentId={columnComponent.id}
+                    depth={depth + 1}
+                    index={itemIndex / 2} // account for gutters!
+                    availableColumnCount={availableColumnCount}
+                    columnWidth={columnWidth}
+                    onResizeStart={onResizeStart}
+                    onResize={onResize}
+                    onResizeStop={onResizeStop}
+                  />
+                );
+              })}
+
+              {dropIndicatorProps && <div {...dropIndicatorProps} />}
+            </div>
+          </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 0000000..29437e1
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Divider.jsx
@@ -0,0 +1,64 @@
+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,
+  parentComponent: componentShape.isRequired,
+  index: PropTypes.number.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,
+      parentComponent,
+      index,
+      handleComponentDrop,
+    } = this.props;
+
+    return (
+      <DragDroppable
+        component={component}
+        parentComponent={parentComponent}
+        orientation="row"
+        index={index}
+        onDrop={handleComponentDrop}
+      >
+        {({ dropIndicatorProps, dragSourceRef }) => (
+          <div ref={dragSourceRef}>
+            <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 0000000..967b483
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Header.jsx
@@ -0,0 +1,151 @@
+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 RowStyleDropdown from '../menu/RowStyleDropdown';
+import DeleteComponentButton from '../DeleteComponentButton';
+import PopoverDropdown from '../menu/PopoverDropdown';
+import headerStyleOptions from '../../util/headerStyleOptions';
+import rowStyleOptions from '../../util/rowStyleOptions';
+import { componentShape } from '../../util/propShapes';
+import { SMALL_HEADER, ROW_TRANSPARENT } from '../../util/constants';
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  parentId: PropTypes.string.isRequired,
+  component: componentShape.isRequired,
+  parentComponent: componentShape.isRequired,
+  index: PropTypes.number.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.handleChangeRowStyle = this.handleUpdateMeta.bind(this, 'rowStyle');
+    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,
+      parentComponent,
+      index,
+      handleComponentDrop,
+    } = this.props;
+
+    const headerStyle = headerStyleOptions.find(
+      opt => opt.value === (component.meta.headerSize || SMALL_HEADER),
+    );
+
+    const rowStyle = rowStyleOptions.find(
+      opt => opt.value === (component.meta.rowStyle || ROW_TRANSPARENT),
+    );
+
+    return (
+      <DragDroppable
+        component={component}
+        parentComponent={parentComponent}
+        orientation="row"
+        index={index}
+        onDrop={handleComponentDrop}
+        disableDragDrop={isFocused}
+      >
+        {({ dropIndicatorProps, dragSourceRef }) => (
+          <div ref={dragSourceRef}>
+            <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`}
+                />,
+                <RowStyleDropdown
+                  id={`${component.id}-row-style`}
+                  value={component.meta.rowStyle}
+                  onChange={this.handleChangeRowStyle}
+                />,
+                <DeleteComponentButton onDelete={this.handleDeleteComponent} />,
+              ]}
+            >
+              <div
+                className={cx(
+                  'dashboard-component',
+                  'dashboard-component-header',
+                  headerStyle.className,
+                  rowStyle.className,
+                )}
+              >
+                <EditableTitle
+                  title={component.meta.text}
+                  canEdit={isFocused}
+                  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 0000000..632a3f3
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Row.jsx
@@ -0,0 +1,186 @@
+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 RowStyleDropdown from '../menu/RowStyleDropdown';
+import WithPopoverMenu from '../menu/WithPopoverMenu';
+
+import { componentShape } from '../../util/propShapes';
+import rowStyleOptions from '../../util/rowStyleOptions';
+import { GRID_GUTTER_SIZE, ROW_TRANSPARENT } from '../../util/constants';
+
+const GUTTER = 'GUTTER';
+
+const propTypes = {
+  id: PropTypes.string.isRequired,
+  parentId: PropTypes.string.isRequired,
+  component: componentShape.isRequired,
+  parentComponent: componentShape.isRequired,
+  index: PropTypes.number.isRequired,
+  depth: PropTypes.number.isRequired,
+
+  // grid related
+  availableColumnCount: PropTypes.number.isRequired,
+  columnWidth: PropTypes.number.isRequired,
+  occupiedColumnCount: PropTypes.number.isRequired,
+  occupiedRowCount: 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.handleChangeRowStyle = this.handleUpdateMeta.bind(this, 'rowStyle');
+    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,
+      occupiedRowCount,
+      depth,
+      onResizeStart,
+      onResize,
+      onResizeStop,
+      handleComponentDrop,
+    } = this.props;
+
+    const rowItems = [];
+    console.log('render row', rowComponent);
+
+    // this adds a gutter between each child in the row.
+    (rowComponent.children || []).forEach((id, childIndex) => {
+      rowItems.push(id);
+      if (childIndex < rowComponent.children.length - 1) {
+        rowItems.push(GUTTER);
+      }
+    });
+
+    const rowStyle = rowStyleOptions.find(
+      opt => opt.value === (rowComponent.meta.rowStyle || ROW_TRANSPARENT),
+    );
+
+    return (
+      <DragDroppable
+        component={rowComponent}
+        parentComponent={parentComponent}
+        orientation="row"
+        index={index}
+        onDrop={handleComponentDrop}
+      >
+        {({ dropIndicatorProps, dragSourceRef }) => (
+          <WithPopoverMenu
+            isFocused={this.state.isFocused}
+            onChangeFocus={this.handleChangeFocus}
+            disableClick
+            menuItems={[
+              <RowStyleDropdown
+                id={`${rowComponent.id}-row-style`}
+                value={rowComponent.meta.rowStyle}
+                onChange={this.handleChangeRowStyle}
+              />,
+            ]}
+          >
+
+            <div
+              className={cx(
+                'grid-row',
+                rowItems.length === 0 && 'grid-row--empty',
+                rowStyle.className,
+              )}
+            >
+              <HoverMenu innerRef={dragSourceRef} position="left">
+                <DragHandle position="left" />
+                <DeleteComponentButton onDelete={this.handleDeleteComponent} />
+                <IconButton
+                  onClick={this.handleChangeFocus}
+                  className="fa fa-cog"
+                />
+              </HoverMenu>
+
+              {rowItems.map((componentId, itemIndex) => {
+                if (componentId === GUTTER) {
+                  return <div key={`gutter-${itemIndex}`} style={{ width: GRID_GUTTER_SIZE }} />;
+                }
+
+                return (
+                  <DashboardComponent
+                    key={componentId}
+                    id={componentId}
+                    parentId={rowComponent.id}
+                    depth={depth + 1}
+                    index={itemIndex / 2} // account for gutters!
+                    availableColumnCount={availableColumnCount - occupiedColumnCount}
+                    occupiedRowCount={occupiedRowCount}
+                    columnWidth={columnWidth}
+                    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/Spacer.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Spacer.jsx
new file mode 100644
index 0000000..4b54edd
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Spacer.jsx
@@ -0,0 +1,113 @@
+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 { componentShape } from '../../util/propShapes';
+
+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,
+
+  // grid related
+  availableColumnCount: PropTypes.number.isRequired,
+  columnWidth: PropTypes.number.isRequired,
+  occupiedRowCount: PropTypes.number,
+  onResizeStart: PropTypes.func.isRequired,
+  onResize: PropTypes.func.isRequired,
+  onResizeStop: PropTypes.func.isRequired,
+
+  // dnd
+  deleteComponent: PropTypes.func.isRequired,
+  handleComponentDrop: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+  occupiedRowCount: null,
+};
+
+class Spacer extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+  }
+
+  handleDeleteComponent() {
+    const { deleteComponent, id, parentId } = this.props;
+    deleteComponent(id, parentId);
+  }
+
+  render() {
+    const {
+      component,
+      parentComponent,
+      index,
+      depth,
+      availableColumnCount,
+      columnWidth,
+      occupiedRowCount,
+      onResizeStart,
+      onResize,
+      onResizeStop,
+      handleComponentDrop,
+    } = this.props;
+
+    const orientation = depth % 2 === 0 ? 'row' : 'column';
+    const hoverMenuPosition = orientation === 'row' ? 'left' : 'top';
+    const adjustableWidth = orientation === 'column';
+    const adjustableHeight = orientation === 'row';
+
+    return (
+      <DragDroppable
+        component={component}
+        parentComponent={parentComponent}
+        orientation={orientation}
+        index={index}
+        onDrop={handleComponentDrop}
+      >
+        {({ dropIndicatorProps, dragSourceRef }) => (
+          <ResizableContainer
+            id={component.id}
+            adjustableWidth={adjustableWidth}
+            adjustableHeight={adjustableHeight}
+            widthStep={columnWidth}
+            widthMultiple={component.meta.width}
+            heightMultiple={adjustableHeight ? component.meta.height || 1 : undefined}
+            staticHeightMultiple={!adjustableHeight ? occupiedRowCount || 5 : undefined}
+            minWidthMultiple={1}
+            minHeightMultiple={1}
+            maxWidthMultiple={availableColumnCount + (component.meta.width || 0)}
+            onResizeStart={onResizeStart}
+            onResize={onResize}
+            onResizeStop={onResizeStop}
+          >
+            <HoverMenu position={hoverMenuPosition}>
+              <DeleteComponentButton onDelete={this.handleDeleteComponent} />
+            </HoverMenu>
+
+            <div ref={dragSourceRef} className="grid-spacer" />
+
+            {dropIndicatorProps && <div {...dropIndicatorProps} />}
+          </ResizableContainer>
+        )}
+      </DragDroppable>
+    );
+  }
+}
+
+Spacer.propTypes = propTypes;
+Spacer.defaultProps = defaultProps;
+
+export default Spacer;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tab.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tab.jsx
new file mode 100644
index 0000000..74cd9ae
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tab.jsx
@@ -0,0 +1,162 @@
+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';
+
+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,
+
+  // grid related
+  availableColumnCount: PropTypes.number.isRequired,
+  columnWidth: PropTypes.number.isRequired,
+  onResizeStart: PropTypes.func.isRequired,
+  onResize: PropTypes.func.isRequired,
+  onResizeStop: PropTypes.func.isRequired,
+
+  // redux
+  handleComponentDrop: PropTypes.func.isRequired,
+  deleteComponent: PropTypes.func.isRequired,
+  updateComponents: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+  onDropOnTab: null,
+};
+
+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 { deleteComponent, id, parentId } = this.props;
+    deleteComponent(id, parentId);
+  }
+
+  handleDrop(dropResult) {
+    const { handleComponentDrop, onDropOnTab } = this.props;
+    handleComponentDrop(dropResult);
+    if (onDropOnTab) 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}
+            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,
+    } = this.props;
+
+    return (
+      <DragDroppable
+        component={component}
+        parentComponent={parentComponent}
+        orientation="column"
+        index={index}
+        onDrop={this.handleDrop}
+        disableDragDrop={isFocused}
+      >
+        {({ dropIndicatorProps, dragSourceRef }) => (
+          <div className="dragdroppable-tab" ref={dragSourceRef}>
+            <WithPopoverMenu
+              onChangeFocus={this.handleChangeFocus}
+              menuItems={parentComponent.children.length <= 1 ? [] : [
+                <DeleteComponentButton onDelete={this.handleDeleteComponent} />,
+              ]}
+            >
+              <EditableTitle
+                title={component.meta.text}
+                canEdit={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 0000000..1e2e64c
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/Tabs.jsx
@@ -0,0 +1,211 @@
+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 } from '../../util/constants';
+import { RENDER_TAB, RENDER_TAB_CONTENT } from './Tab';
+
+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,
+
+  // grid related
+  availableColumnCount: PropTypes.number.isRequired,
+  columnWidth: PropTypes.number.isRequired,
+  onResizeStart: PropTypes.func.isRequired,
+  onResize: PropTypes.func.isRequired,
+  onResizeStop: PropTypes.func.isRequired,
+
+  // dnd
+  createComponent: PropTypes.func.isRequired,
+  handleComponentDrop: PropTypes.func.isRequired,
+  onChangeTab: PropTypes.func,
+  deleteComponent: PropTypes.func.isRequired,
+  updateComponents: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+  onChangeTab: null,
+  children: null,
+};
+
+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.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 { onChangeTab, component, createComponent } = this.props;
+
+    if (tabIndex !== NEW_TAB_INDEX && tabIndex !== this.state.tabIndex) {
+      this.setState(() => ({ tabIndex }));
+      if (onChangeTab) {
+        onChangeTab({ tabIndex, tabId: component.children[tabIndex] });
+      }
+    } else if (tabIndex === NEW_TAB_INDEX) {
+      createComponent({
+        destination: {
+          droppableId: component.id,
+          index: component.children.length,
+        },
+        draggableId: NEW_TAB_ID,
+      });
+    }
+  }
+
+  handleDeleteComponent() {
+    const { deleteComponent, id, parentId } = this.props;
+    deleteComponent(id, parentId);
+  }
+
+  handleDropOnTab(dropResult) {
+    const { component } = this.props;
+
+    // Ensure dropped tab is visible
+    const { destination } = dropResult;
+    if (destination) {
+      const dropTabIndex = destination.droppableId === component.id
+        ? destination.index // dropped ON tabs
+        : component.children.indexOf(destination.droppableId); // dropped IN tab
+
+      if (dropTabIndex > -1) {
+        setTimeout(() => {
+          this.handleClicKTab(dropTabIndex);
+        }, 30);
+      }
+    }
+  }
+
+  render() {
+    const {
+      depth,
+      component: tabsComponent,
+      parentComponent,
+      index,
+      availableColumnCount,
+      columnWidth,
+      onResizeStart,
+      onResize,
+      onResizeStop,
+      handleComponentDrop,
+    } = this.props;
+
+    const { tabIndex: selectedTabIndex } = this.state;
+    const { children: tabIds } = tabsComponent;
+
+    return (
+      <DragDroppable
+        component={tabsComponent}
+        parentComponent={parentComponent}
+        orientation="row"
+        index={index}
+        onDrop={handleComponentDrop}
+      >
+        {({ dropIndicatorProps: tabsDropIndicatorProps, dragSourceRef: tabsDragSourceRef }) => (
+          <div className="dashboard-component dashboard-component-tabs">
+            <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}
+                      onResizeStart={onResizeStart}
+                      onResize={onResize}
+                      onResizeStop={onResizeStop}
+                      onDropOnTab={this.handleDropOnTab}
+                    />
+                  }
+                >
+                  {/*
+                    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 &&
+                    <DashboardComponent
+                      id={tabId}
+                      parentId={tabsComponent.id}
+                      depth={depth}
+                      index={tabIndex}
+                      renderType={RENDER_TAB_CONTENT}
+                      availableColumnCount={availableColumnCount}
+                      columnWidth={columnWidth}
+                      onResizeStart={onResizeStart}
+                      onResize={onResize}
+                      onResizeStop={onResizeStop}
+                      onDropOnTab={this.handleDropOnTab}
+                    />}
+                </BootstrapTab>
+              ))}
+
+              {tabIds.length < MAX_TAB_COUNT &&
+                <BootstrapTab
+                  eventKey={NEW_TAB_INDEX}
+                  title={<div className="fa fa-plus-square" />}
+                />}
+
+            </BootstrapTabs>
+
+            {tabsDropIndicatorProps
+              && tabsDropIndicatorProps.style
+              && tabsDropIndicatorProps.style.width === '100%'
+              && <div {...tabsDropIndicatorProps} />}
+
+          </div>
+        )}
+      </DragDroppable>
+    );
+  }
+}
+
+Tabs.propTypes = propTypes;
+Tabs.defaultProps = defaultProps;
+
+export default Tabs;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/components.css b/superset/assets/javascripts/dashboard/v2/components/gridComponents/components.css
new file mode 100644
index 0000000..a88ea09
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/components.css
@@ -0,0 +1,455 @@
+/* Header */
+.dashboard-component-header {
+  width: 100%;
+  line-height: 1em;
+  font-weight: 700;
+  background-color: inherit;
+  padding: 16px 0;
+  color: #263238;
+}
+
+.header-small {
+  font-size: 16px;
+}
+
+.header-medium {
+  font-size: 22px;
+}
+
+.header-large {
+  font-size: 32px;
+}
+
+  .dragdroppable-row .dragdroppable-row .dashboard-component-header,
+  .dragdroppable-row .dragdroppable-row .dashboard-component-divider {
+    padding-left: 16px;
+    padding-right: 16px;
+  }
+
+/* Chart */
+.dashboard-component-chart {
+  width: 100%;
+  height: 100%;
+  color: #879399;
+  background-color: #fff;
+  padding: 16px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.dashboard-component-chart .fa {
+  font-size: 100px;
+  opacity: 0.3;
+}
+
+.grid-container--resizing .dashboard-component-chart,
+.dashboard-builder--dragging .dashboard-component-chart,
+.dashboard-component-chart:hover {
+  box-shadow: inset 0 0 0 1px #CFD8DC;
+}
+
+/* Divider */
+.dashboard-component-divider {
+  width: 100%;
+  padding: 24px 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: #CFD8DC;
+  display: block;
+}
+
+.new-component-placeholder.divider-placeholder:after {
+  content: "";
+  height: 2px;
+  width: 100%;
+  background-color: #CFD8DC;
+}
+
+.dragdroppable .dashboard-component-divider {
+  cursor: move;
+}
+
+/* Tabs -- this overwrites Superset bootstrap theme tab styling */
+.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 {
+  padding: 0 16px;
+}
+
+.dashboard-component-tabs .nav-tabs > li > a {
+  color: #263238;
+  border: none;
+  padding: 12px 0 14px 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: #000000;
+}
+
+
+.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 {
+  height: 40px !important;
+  top: -10px !important;
+  opacity: 0.5;
+}
+
+.dashboard-component-tabs .fa-plus-square {
+  background: linear-gradient(135deg, #E32464, #2C2261);
+  -webkit-background-clip: text;
+  -webkit-text-fill-color: transparent;
+  display: initial;
+  font-size: 16px;
+}
+
+/* New components */
+.new-component {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: nowrap;
+  align-items: center;
+  padding: 16px;
+  background: white;
+}
+
+.new-component-placeholder {
+  position: relative;
+  background: #f5f5f5;
+  width: 40px;
+  height: 40px;
+  margin-right: 16px;
+  box-shadow: 0 0 1px #fff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #aaa;
+  font-size: 1.5em;
+}
+
+/* Spacer */
+.grid-container {
+   flex-grow: 1;
+   min-width: 66%;
+   margin: 24px 32px;
+   height: 100%;
+   position: relative;
+}
+
+.new-component-placeholder.spacer-placeholder {
+  font-size: 1em;
+}
+
+.new-component-placeholder.fa-window-restore {
+  font-size: 1em;
+}
+
+.new-component-placeholder.spacer-placeholder:after {
+  content: "";
+  position: absolute;
+  height: 60%;
+  width: 60%;
+  border: 1px dashed #aaa;
+}
+
+/* columns and rows */
+.grid-column {
+  width: 100%;
+  min-height: 56px;
+}
+
+.grid-column > .hover-menu--top {
+  top: -20px;
+}
+
+.grid-row {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  align-items: flex-start;
+  width: 100%;
+  height: fit-content;
+  background-color: transparent;
+}
+
+.grid-row--transparent {
+  background-color: transparent;
+}
+
+.grid-row--white {
+  background-color: #fff;
+}
+
+.dashboard-component-header.grid-row--white {
+  padding-left: 16px;
+}
+
+.grid-row.grid-row--empty {
+  align-items: center; /* this centers the empty note content */
+  height: 80px;
+}
+
+.grid-row--empty:after {
+  position: absolute;
+  top: 0;
+  left: 0;
+  content: "Empty row";
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  height: 100%;
+  color: #aaa;
+}
+
+.grid-column--empty:after {
+  content: "Empty column";
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #CFD8DC;
+}
+
+/* spacer */
+.grid-spacer {
+  width: 100%;
+  height: 100%;
+  background-color: transparent;
+}
+
+.dragdroppable .grid-spacer {
+  cursor: move;
+}
+
+.dragdroppable:hover .grid-spacer {
+  box-shadow: inset 0 0 0 1px #CFD8DC;
+}
+
+/* popover menu */
+.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 #44C0FF;
+  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: #fff;
+  box-shadow: 0 1px 2px 1px rgba(0, 0, 0, 0.2);
+  font-size: 14px;
+  cursor: default;
+  z-index: 10;
+}
+
+.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: #CFD8DC;
+  margin: 0 16px;
+}
+
+.popover-menu .popover-dropdown.btn {
+  border: none;
+  padding: 0;
+  font-size: inherit;
+  color: #000;
+}
+
+.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: #CFD8DC;
+}
+
+.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: #fff;
+  font-weight: bold;
+  color: #000;
+}
+
+/* row style menu */
+.row-style-option {
+  display: inline-block;
+}
+
+.row-style-option:before {
+  content: "";
+  width: 1em;
+  height: 1em;
+  margin-right: 8px;
+  display: inline-block;
+  vertical-align: middle;
+}
+
+.row-style-option.grid-row--white {
+  padding-left: 0;
+  background: transparent;
+}
+
+.row-style-option.grid-row--white:before {
+  background: #fff;
+  border: 1px solid #CFD8DC;
+}
+
+.row-style-option.grid-row--transparent:before {
+  background: #CFD8DC;
+}
+
+/* hover menu */
+.hover-menu {
+  opacity: 0;
+  position: absolute;
+  z-index: 2;
+}
+
+.hover-menu--left {
+  width: 20px;
+  height: 100%;
+  top: 0;
+  left: -20px;
+  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: 8px;
+}
+
+.dragdroppable-row .dragdroppable-row .hover-menu--left {
+  left: 1px;
+}
+
+.hover-menu--top {
+  width: 100%;
+  height: 20px;
+  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: 8px;
+}
+
+.dragdroppable:hover .hover-menu,
+.dragdroppable .hover-menu:hover {
+  opacity: 1;
+}
+
+
+/* Menu fa buttons */
+.icon-button {
+  color: #879399;
+  font-size: 1em;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: center;
+  outline: none;
+}
+
+.icon-button:hover,
+.icon-button:active,
+.icon-button:focus {
+  color: #484848;
+  outline: none;
+  text-decoration: none;
+}
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/grid.css b/superset/assets/javascripts/dashboard/v2/components/gridComponents/grid.css
new file mode 100644
index 0000000..6119eab
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/grid.css
@@ -0,0 +1,17 @@
+/* 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;
+  height: 1;
+  background-color: #44C0FF;
+  pointer-events: none;
+}
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 0000000..c84864e
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/index.js
@@ -0,0 +1,43 @@
+import './components.css';
+
+import {
+  CHART_TYPE,
+  COLUMN_TYPE,
+  DIVIDER_TYPE,
+  HEADER_TYPE,
+  INVISIBLE_ROW_TYPE,
+  ROW_TYPE,
+  SPACER_TYPE,
+  TAB_TYPE,
+  TABS_TYPE,
+} from '../../util/componentTypes';
+
+import Chart from './Chart';
+import Column from './Column';
+import Divider from './Divider';
+import Header from './Header';
+import Row from './Row';
+import Spacer from './Spacer';
+import Tab from './Tab';
+import Tabs from './Tabs';
+
+export { default as Chart } from './Chart';
+export { default as Column } from './Column';
+export { default as Divider } from './Divider';
+export { default as Header } from './Header';
+export { default as Row } from './Row';
+export { default as Spacer } from './Spacer';
+export { default as Tab } from './Tab';
+export { default as Tabs } from './Tabs';
+
+export default {
+  [CHART_TYPE]: Chart,
+  [COLUMN_TYPE]: Column,
+  [DIVIDER_TYPE]: Divider,
+  [HEADER_TYPE]: Header,
+  [INVISIBLE_ROW_TYPE]: Row,
+  [ROW_TYPE]: Row,
+  [SPACER_TYPE]: Spacer,
+  [TAB_TYPE]: Tab,
+  [TABS_TYPE]: Tabs,
+};
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/DraggableNewComponent.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/DraggableNewComponent.jsx
new file mode 100644
index 0000000..c4d8d62
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/DraggableNewComponent.jsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import DragDroppable from '../../dnd/DragDroppable';
+
+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={null}
+        index={0}
+      >
+        {({ 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 0000000..0255755
--- /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 0000000..654c60b
--- /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 0000000..5d70041
--- /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 0000000..d207a9c
--- /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 0000000..1d9ab10
--- /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/NewSpacer.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewSpacer.jsx
new file mode 100644
index 0000000..7287770
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewSpacer.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+// import PropTypes from 'prop-types';
+
+import { SPACER_TYPE } from '../../../util/componentTypes';
+import { NEW_SPACER_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+const propTypes = {
+};
+
+export default class DraggableNewChart extends React.PureComponent {
+  render() {
+    return (
+      <DraggableNewComponent
+        id={NEW_SPACER_ID}
+        type={SPACER_TYPE}
+        label="Spacer"
+        className="spacer-placeholder fa fa-arrows"
+      />
+    );
+  }
+}
+
+DraggableNewChart.propTypes = propTypes;
diff --git a/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewTabs.jsx b/superset/assets/javascripts/dashboard/v2/components/gridComponents/new/NewTabs.jsx
new file mode 100644
index 0000000..a473281
--- /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/HoverMenu.jsx b/superset/assets/javascripts/dashboard/v2/components/menu/HoverMenu.jsx
new file mode 100644
index 0000000..c238d02
--- /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 0000000..6a56eab
--- /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/RowStyleDropdown.jsx b/superset/assets/javascripts/dashboard/v2/components/menu/RowStyleDropdown.jsx
new file mode 100644
index 0000000..d3c7eff
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/menu/RowStyleDropdown.jsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import rowStyleOptions from '../../util/rowStyleOptions';
+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('row-style-option', option.className)}>
+      {`${option.label} background`}
+    </div>
+  );
+}
+
+function renderOption(option) {
+  return (
+    <div className={cx('row-style-option', option.className)}>
+      {option.label}
+    </div>
+  );
+}
+
+export default class RowStyleDropdown extends React.PureComponent {
+  render() {
+    const { id, value, onChange } = this.props;
+    return (
+      <PopoverDropdown
+        id={id}
+        options={rowStyleOptions}
+        value={value}
+        onChange={onChange}
+        renderButton={renderButton}
+        renderOption={renderOption}
+      />
+    );
+  }
+}
+
+RowStyleDropdown.propTypes = propTypes;
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 0000000..7fb24cd
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/menu/WithPopoverMenu.jsx
@@ -0,0 +1,100 @@
+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,
+};
+
+const defaultProps = {
+  children: null,
+  disableClick: false,
+  onChangeFocus: null,
+  onPressDelete() {},
+  menuItems: [],
+  isFocused: false,
+};
+
+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.isFocused && !this.state.isFocused) {
+      document.addEventListener('click', this.handleClick, true);
+      document.addEventListener('drag', this.handleClick, true);
+      this.setState({ isFocused: true });
+    }
+  }
+
+  componentWillUnmount() {
+    document.removeEventListener('click', this.handleClick, true);
+    document.removeEventListener('drag', this.handleClick, true);
+  }
+
+  setRef(ref) {
+    this.container = ref;
+  }
+
+  handleClick(event) {
+    const { onChangeFocus } = this.props;
+    if (!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 (!this.container.contains(event.target)) {
+      document.removeEventListener('click', this.handleClick, true);
+      document.removeEventListener('drag', this.handleClick, true);
+      this.setState(() => ({ isFocused: false }));
+      if (onChangeFocus) {
+        onChangeFocus(false);
+      }
+    }
+  }
+
+  render() {
+    const { children, menuItems, disableClick } = this.props;
+    const { isFocused } = this.state;
+
+    return (
+      <div
+        ref={this.setRef}
+        onClick={!disableClick && this.handleClick}
+        role="button" // @TODO consider others?
+        tabIndex="0"
+        className={cx(
+          'with-popover-menu',
+          isFocused && 'with-popover-menu--focused',
+        )}
+      >
+        {children}
+        {isFocused && menuItems.length ?
+          <div className="popover-menu" >
+            {menuItems.map((node, i) => (
+              <div className="menu-item" key={`menu-item-${i}`}>{node}</div>
+            ))}
+          </div> : null}
+      </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 0000000..bd590ae
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/resizable/ResizableContainer.jsx
@@ -0,0 +1,184 @@
+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_ROW_HEIGHT_UNIT,
+  GRID_GUTTER_SIZE,
+} from '../../util/constants';
+
+import './resizable.css';
+
+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,
+  staticHeightMultiple: PropTypes.number,
+  onResizeStop: PropTypes.func,
+  onResize: PropTypes.func,
+  onResizeStart: PropTypes.func,
+};
+
+const defaultProps = {
+  children: null,
+  adjustableWidth: true,
+  adjustableHeight: true,
+  gutterWidth: GRID_GUTTER_SIZE,
+  widthStep: GRID_BASE_UNIT,
+  heightStep: GRID_ROW_HEIGHT_UNIT,
+  widthMultiple: null,
+  heightMultiple: null,
+  minWidthMultiple: 1,
+  maxWidthMultiple: Infinity,
+  minHeightMultiple: 1,
+  maxHeightMultiple: Infinity,
+  staticHeightMultiple: null,
+  onResizeStop: null,
+  onResize: null,
+  onResizeStart: null,
+};
+
+// because columns are not actually multiples of a single variable (width = n*cols + (n-1)*gutters)
+// we snap to the base unit and then snap to actual column multiples on stop
+const snapToGrid = [GRID_BASE_UNIT, GRID_BASE_UNIT];
+
+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 =
+        Math.round(widthMultiple + (delta.width / (widthStep + gutterWidth)));
+      const nextHeightMultiple =
+        Math.round(heightMultiple + (delta.height / heightStep));
+
+      onResizeStop({
+        id,
+        widthMultiple: adjustableWidth ? nextWidthMultiple : null,
+        heightMultiple: adjustableHeight ? nextHeightMultiple : null,
+      });
+
+      this.setState(() => ({ isResizing: false }));
+    }
+  }
+
+  render() {
+    const {
+      children,
+      adjustableWidth,
+      adjustableHeight,
+      widthStep,
+      heightStep,
+      staticHeightMultiple,
+      widthMultiple,
+      heightMultiple,
+      minWidthMultiple,
+      maxWidthMultiple,
+      minHeightMultiple,
+      maxHeightMultiple,
+      gutterWidth,
+    } = this.props;
+
+    const size = {
+      width: adjustableWidth
+        ? ((widthStep + gutterWidth) * widthMultiple) - gutterWidth : undefined,
+      height: adjustableHeight
+        ? heightStep * heightMultiple
+        : (staticHeightMultiple && staticHeightMultiple * heightStep) || undefined,
+    };
+
+    let enableConfig = resizableConfig.widthAndHeight;
+    if (!adjustableHeight) enableConfig = resizableConfig.widthOnly;
+    else if (!adjustableWidth) enableConfig = resizableConfig.heightOnly;
+
+    const { isResizing } = this.state;
+
+    return (
+      <Resizable
+        enable={enableConfig}
+        grid={snapToGrid}
+        minWidth={adjustableWidth
+          ? (minWidthMultiple * (widthStep + gutterWidth)) - gutterWidth
+          : size.width}
+        minHeight={adjustableHeight
+          ? (minHeightMultiple * heightStep)
+          : size.height}
+        maxWidth={adjustableWidth
+          ? (maxWidthMultiple * (widthStep + gutterWidth)) - gutterWidth
+          : size.width}
+        maxHeight={adjustableHeight
+          ? (maxHeightMultiple * heightStep)
+          : size.height}
+        size={size}
+        onResizeStart={this.handleResizeStart}
+        onResize={this.handleResize}
+        onResizeStop={this.handleResizeStop}
+        handleComponent={ResizableHandle}
+        className={cx(
+          'grid-resizable-container',
+          isResizing && 'grid-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 0000000..9536f6b
--- /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/components/resizable/resizable.css b/superset/assets/javascripts/dashboard/v2/components/resizable/resizable.css
new file mode 100644
index 0000000..1d5de72
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/components/resizable/resizable.css
@@ -0,0 +1,72 @@
+.grid-resizable-container {
+  background-color: transparent;
+  position: relative;
+}
+
+/* after ensures border visibility on top of any children */
+.grid-resizable-container--resizing:after {
+  content: "";
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  box-shadow: inset 0 0 0 2px #44C0FF;
+}
+
+.resize-handle {
+  opacity: 0;
+}
+
+  .grid-resizable-container:hover .resize-handle,
+  .grid-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: #879399;
+  border-bottom-color: #879399;
+  right: 16;
+  bottom: 16;
+  width: 8px;
+  height: 8px;
+}
+
+.resize-handle--right {
+  width: 2px;
+  height: 20px;
+  right: -2px;
+  top: 47%;
+  position: absolute;
+  border-left: 1px solid #879399;
+  border-right: 1px solid #879399;
+}
+
+  .grid-spacer + span .resize-handle--right {
+    right: 3px;
+  }
+
+.resize-handle--bottom {
+  height: 2px;
+  width: 20px;
+  bottom: 10px;
+  left: 47%;
+  position: absolute;
+  border-top: 1px solid #879399;
+  border-bottom: 1px solid #879399;
+}
+
+.grid-resizable-container--resizing > span .resize-handle {
+  border-color: #44C0FF;
+}
+
+/* re-resizable sets an empty div to 100% width and height, which doesn't
+  play well with many 100% height containers we need
+ */
+.grid-resizable-container ~ div {
+  width: auto !important;
+  height: auto !important;
+}
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 0000000..1340781
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/containers/DashboardComponent.jsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+
+import ComponentLookup from '../components/gridComponents';
+import countChildRowsAndColumns from '../util/countChildRowsAndColumns';
+import { componentShape } from '../util/propShapes';
+import { ROW_TYPE } from '../util/componentTypes';
+
+import {
+  createComponent,
+  deleteComponent,
+  updateComponents,
+  handleComponentDrop,
+} from '../actions';
+
+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({ dashboard = {} }, ownProps) {
+  const { id, parentId } = ownProps;
+  const props = {
+    component: dashboard[id],
+    parentComponent: dashboard[parentId],
+  };
+
+  // row is a special component that needs extra dims about its children
+  // doing this allows us to not pass the entire component lookup to all Components
+  if (props.component.type === ROW_TYPE) {
+    const { rowCount, columnCount } = countChildRowsAndColumns({
+      component: props.component,
+      components: dashboard,
+    });
+
+    props.occupiedRowCount = rowCount;
+    props.occupiedColumnCount = columnCount;
+  }
+
+  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 0000000..741151b
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/containers/DashboardGrid.jsx
@@ -0,0 +1,23 @@
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+import DashboardGrid from '../components/DashboardGrid';
+
+import {
+  updateComponents,
+  handleComponentDrop,
+} from '../actions';
+
+function mapStateToProps({ dashboard = {} }) {
+  return {
+    dashboard,
+  };
+}
+
+function mapDispatchToProps(dispatch) {
+  return bindActionCreators({
+    updateComponents,
+    handleComponentDrop,
+  }, dispatch);
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(DashboardGrid);
diff --git a/superset/assets/javascripts/dashboard/v2/fixtures/testLayout.js b/superset/assets/javascripts/dashboard/v2/fixtures/testLayout.js
new file mode 100644
index 0000000..c3ce897
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/fixtures/testLayout.js
@@ -0,0 +1,161 @@
+import {
+  COLUMN_TYPE,
+  HEADER_TYPE,
+  ROW_TYPE,
+  SPACER_TYPE,
+  TAB_TYPE,
+  TABS_TYPE,
+  CHART_TYPE,
+  DIVIDER_TYPE,
+  GRID_ROOT_TYPE,
+} from '../util/componentTypes';
+
+import { DASHBOARD_ROOT_ID } from '../util/constants';
+
+export default {
+  [DASHBOARD_ROOT_ID]: {
+    type: GRID_ROOT_TYPE,
+    id: DASHBOARD_ROOT_ID,
+    children: [
+      // 'header0',
+      // 'row0',
+      // 'divider0',
+      // 'row1',
+      // 'tabs0',
+      // 'divider1',
+    ],
+  },
+  // row0: {
+  //   id: 'row0',
+  //   type: INVISIBLE_ROW_TYPE,
+  //   children: [
+  //     // 'charta',
+  //     // 'chartb',
+  //     // 'chartc',
+  //   ],
+  // },
+  // row1: {
+  //   id: 'row1',
+  //   type: ROW_TYPE,
+  //   children: [
+  //     'header1',
+  //   ],
+  // },
+  // row2: {
+  //   id: 'row2',
+  //   type: ROW_TYPE,
+  //   children: [
+  //     'chartd',
+  //     'spacer0',
+  //     'charte',
+  //   ],
+  // },
+  // tabs0: {
+  //   id: 'tabs0',
+  //   type: TABS_TYPE,
+  //   children: [
+  //     'tab0',
+  //     'tab1',
+  //     'tab3',
+  //   ],
+  //   meta: {
+  //   },
+  // },
+  // tab0: {
+  //   id: 'tab0',
+  //   type: TAB_TYPE,
+  //   children: [
+  //     // 'row2',
+  //   ],
+  //   meta: {
+  //     text: 'Tab A',
+  //   },
+  // },
+  // tab1: {
+  //   id: 'tab1',
+  //   type: TAB_TYPE,
+  //   children: [
+  //   ],
+  //   meta: {
+  //     text: 'Tab B',
+  //   },
+  // },
+  // tab3: {
+  //   id: 'tab3',
+  //   type: TAB_TYPE,
+  //   children: [
+  //   ],
+  //   meta: {
+  //     text: 'Tab C',
+  //   },
+  // },
+  // header0: {
+  //   id: 'header0',
+  //   type: HEADER_TYPE,
+  //   meta: {
+  //     text: 'Header 1',
+  //   },
+  // },
+  // header1: {
+  //   id: 'header1',
+  //   type: HEADER_TYPE,
+  //   meta: {
+  //     text: 'Header 2',
+  //   },
+  // },
+  // divider0: {
+  //   id: 'divider0',
+  //   type: DIVIDER_TYPE,
+  // },
+  // divider1: {
+  //   id: 'divider1',
+  //   type: DIVIDER_TYPE,
+  // },
+  // charta: {
+  //   id: 'charta',
+  //   type: CHART_TYPE,
+  //   meta: {
+  //     width: 3,
+  //     height: 10,
+  //   },
+  // },
+  // chartb: {
+  //   id: 'chartb',
+  //   type: CHART_TYPE,
+  //   meta: {
+  //     width: 3,
+  //     height: 10,
+  //   },
+  // },
+  // chartc: {
+  //   id: 'chartc',
+  //   type: CHART_TYPE,
+  //   meta: {
+  //     width: 3,
+  //     height: 10,
+  //   },
+  // },
+  // chartd: {
+  //   id: 'chartd',
+  //   type: CHART_TYPE,
+  //   meta: {
+  //     width: 3,
+  //     height: 10,
+  //   },
+  // },
+  // charte: {
+  //   id: 'charte',
+  //   type: CHART_TYPE,
+  //   meta: {
+  //     width: 3,
+  //     height: 10,
+  //   },
+  // },
+  // spacer0: {
+  //   id: 'spacer0',
+  //   type: SPACER_TYPE,
+  //   meta: {
+  //     width: 1,
+  //   },
+  // },
+};
diff --git a/superset/assets/javascripts/dashboard/v2/reducers/dashboard.js b/superset/assets/javascripts/dashboard/v2/reducers/dashboard.js
new file mode 100644
index 0000000..19fa9d7
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/reducers/dashboard.js
@@ -0,0 +1,112 @@
+import newComponentFactory from '../util/newComponentFactory';
+import newEntitiesFromDrop from '../util/newEntitiesFromDrop';
+import reorderItem from '../util/dnd-reorder';
+import shouldWrapChildInRow from '../util/shouldWrapChildInRow';
+import { ROW_TYPE } from '../util/componentTypes';
+
+import {
+  UPDATE_COMPONENTS,
+  DELETE_COMPONENT,
+  CREATE_COMPONENT,
+  MOVE_COMPONENT,
+} from '../actions';
+
+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
+    let deleteCount = 0;
+    function recursivelyDeleteChildren(componentId, componentParentId) {
+      // delete child and it's children
+      const component = nextComponents[componentId];
+      delete nextComponents[componentId];
+      deleteCount += 1;
+      const { children = [] } = component;
+      children.forEach((childId) => { recursivelyDeleteChildren(childId, componentId); });
+
+      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);
+    console.log('deleted', deleteCount, 'total components', nextComponents);
+
+    return nextComponents;
+  },
+
+  [CREATE_COMPONENT](state, action) {
+    const { payload: { dropResult } } = action;
+    const newEntities = newEntitiesFromDrop({ dropResult, components: state });
+    return {
+      ...state,
+      ...newEntities,
+    };
+  },
+
+  [MOVE_COMPONENT](state, action) {
+    const { payload: { dropResult } } = action;
+    const { source, destination, draggableId } = dropResult;
+
+    if (!source || !destination || !draggableId) return state;
+
+    const nextEntities = reorderItem({
+      entitiesMap: state,
+      source,
+      destination,
+    });
+
+    // wrap the dragged component in a row depening on destination type
+    const destinationType = (state[destination.droppableId] || {}).type;
+    const draggableType = (state[draggableId] || {}).type;
+    const wrapInRow = shouldWrapChildInRow({
+      parentType: destinationType,
+      childType: draggableType,
+    });
+
+    if (wrapInRow) {
+      const destinationEntity = nextEntities[destination.droppableId];
+      const destinationChildren = destinationEntity.children;
+      const newRow = newComponentFactory(ROW_TYPE);
+      newRow.children = [destinationChildren[destination.index]];
+      destinationChildren[destination.index] = newRow.id;
+      nextEntities[newRow.id] = newRow;
+    }
+
+    return {
+      ...state,
+      ...nextEntities,
+    };
+  },
+};
+
+export default function dashboardReducer(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/index.js b/superset/assets/javascripts/dashboard/v2/reducers/index.js
new file mode 100644
index 0000000..103fda0
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/reducers/index.js
@@ -0,0 +1,6 @@
+import { combineReducers } from 'redux';
+import dashboard from './dashboard';
+
+export default combineReducers({
+  dashboard,
+});
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 0000000..ab701a7
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/componentIsResizable.js
@@ -0,0 +1,15 @@
+import {
+  SPACER_TYPE,
+  COLUMN_TYPE,
+  CHART_TYPE,
+  MARKDOWN_TYPE,
+} from './componentTypes';
+
+export default function componentIsResizable(entity) {
+  return [
+    SPACER_TYPE,
+    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 0000000..fd5d294
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/componentTypes.js
@@ -0,0 +1,23 @@
+export const CHART_TYPE = 'DASHBOARD_CHART_TYPE';
+export const COLUMN_TYPE = 'DASHBOARD_COLUMN_TYPE';
+export const DIVIDER_TYPE = 'DASHBOARD_DIVIDER_TYPE';
+export const GRID_ROOT_TYPE = 'DASHBOARD_GRID_ROOT_TYPE';
+export const HEADER_TYPE = 'DASHBOARD_HEADER_TYPE';
+export const MARKDOWN_TYPE = 'DASHBOARD_MARKDOWN_TYPE';
+export const ROW_TYPE = 'DASHBOARD_ROW_TYPE';
+export const SPACER_TYPE = 'DASHBOARD_SPACER_TYPE';
+export const TABS_TYPE = 'DASHBOARD_TABS_TYPE';
+export const TAB_TYPE = 'DASHBOARD_TAB_TYPE';
+
+export default {
+  CHART_TYPE,
+  COLUMN_TYPE,
+  DIVIDER_TYPE,
+  GRID_ROOT_TYPE,
+  HEADER_TYPE,
+  MARKDOWN_TYPE,
+  ROW_TYPE,
+  SPACER_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+};
diff --git a/superset/assets/javascripts/dashboard/v2/util/constants.js b/superset/assets/javascripts/dashboard/v2/util/constants.js
new file mode 100644
index 0000000..44a0f0e
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/constants.js
@@ -0,0 +1,30 @@
+// Ids
+export const DASHBOARD_ROOT_ID = 'DASHBOARD_ROOT_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_SPACER_ID = 'NEW_SPACER_ID';
+export const NEW_TAB_ID = 'NEW_TAB_ID';
+export const NEW_TABS_ID = 'NEW_TABS_ID';
+
+// grid constants
+export const GRID_BASE_UNIT = 8;
+export const GRID_GUTTER_SIZE = 2 * GRID_BASE_UNIT;
+export const GRID_ROW_HEIGHT_UNIT = 2 * GRID_BASE_UNIT;
+export const GRID_COLUMN_COUNT = 12;
+export const GRID_MIN_COLUMN_COUNT = 3;
+export const GRID_MIN_ROW_UNITS = 5;
+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';
+
+// Row types
+export const ROW_WHITE = 'ROW_WHITE';
+export const ROW_TRANSPARENT = 'ROW_TRANSPARENT';
diff --git a/superset/assets/javascripts/dashboard/v2/util/countChildRowsAndColumns.js b/superset/assets/javascripts/dashboard/v2/util/countChildRowsAndColumns.js
new file mode 100644
index 0000000..dbc63cd
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/countChildRowsAndColumns.js
@@ -0,0 +1,14 @@
+export default function countChildRowsAndColumns({ component, components }) {
+  let columnCount = 0;
+  let rowCount = 0;
+
+  (component.children || []).forEach((childId) => {
+    const childComponent = components[childId];
+    columnCount += (childComponent.meta || {}).width || 0;
+    if ((childComponent.meta || {}).height) {
+      rowCount = Math.max(rowCount, childComponent.meta.height);
+    }
+  });
+
+  return { columnCount, rowCount };
+}
diff --git a/superset/assets/javascripts/dashboard/v2/util/dnd-reorder.js b/superset/assets/javascripts/dashboard/v2/util/dnd-reorder.js
new file mode 100644
index 0000000..5ebca8c
--- /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.droppableId].children];
+  const next = [...entitiesMap[destination.droppableId].children];
+  const target = current[source.index];
+
+  // moving to same list
+  if (source.droppableId === destination.droppableId) {
+    const reordered = reorder(
+      current,
+      source.index,
+      destination.index,
+    );
+
+    const result = {
+      ...entitiesMap,
+      [source.droppableId]: {
+        ...entitiesMap[source.droppableId],
+        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.droppableId]: {
+      ...entitiesMap[source.droppableId],
+      children: current,
+    },
+    [destination.droppableId]: {
+      ...entitiesMap[destination.droppableId],
+      children: next,
+    },
+  };
+
+  return result;
+}
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 0000000..e1dfbd3
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/getDropPosition.js
@@ -0,0 +1,88 @@
+import isValidChild from './isValidChild';
+
+export const DROP_TOP = 'DROP_TOP';
+export const DROP_RIGHT = 'DROP_RIGHT';
+export const DROP_BOTTOM = 'DROP_BOTTOM';
+export const DROP_LEFT = 'DROP_LEFT';
+
+const SIBLING_DROP_THRESHOLD = 10;
+
+export default function getDropPosition(monitor, Component) {
+  const {
+    parentComponent,
+    component,
+    orientation,
+    isDraggingOverShallow,
+  } = Component.props;
+
+  const draggingItem = monitor.getItem();
+
+  // if dropped self on self, do nothing
+  if (!draggingItem || draggingItem.draggableId === component.id || !isDraggingOverShallow) {
+    return null;
+  }
+
+  const validChild = isValidChild({
+    parentType: component.type,
+    childType: draggingItem.type,
+  });
+
+  const validSibling = isValidChild({
+    parentType: parentComponent && parentComponent.type,
+    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 0000000..309d482
--- /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 0000000..c8921ec
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/isValidChild.js
@@ -0,0 +1,69 @@
+import {
+  CHART_TYPE,
+  COLUMN_TYPE,
+  DIVIDER_TYPE,
+  HEADER_TYPE,
+  GRID_ROOT_TYPE,
+  MARKDOWN_TYPE,
+  ROW_TYPE,
+  SPACER_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+} from './componentTypes';
+
+const typeToValidChildType = {
+  // while some components are wrapped in Rows, most types are valid root children
+  [GRID_ROOT_TYPE]: {
+    [CHART_TYPE]: true,
+    [COLUMN_TYPE]: true,
+    [DIVIDER_TYPE]: true,
+    [HEADER_TYPE]: true,
+    [ROW_TYPE]: true,
+    [SPACER_TYPE]: true,
+    [TABS_TYPE]: true,
+  },
+
+  [ROW_TYPE]: {
+    [CHART_TYPE]: true,
+    [MARKDOWN_TYPE]: true,
+    [COLUMN_TYPE]: true,
+    [SPACER_TYPE]: true,
+  },
+
+  [TABS_TYPE]: {
+    [TAB_TYPE]: true,
+  },
+
+  [TAB_TYPE]: {
+    [CHART_TYPE]: true,
+    [COLUMN_TYPE]: true,
+    [DIVIDER_TYPE]: true,
+    [HEADER_TYPE]: true,
+    [ROW_TYPE]: true,
+    [SPACER_TYPE]: true,
+  },
+
+  [COLUMN_TYPE]: {
+    [CHART_TYPE]: true,
+    [MARKDOWN_TYPE]: true,
+    [HEADER_TYPE]: true,
+    [SPACER_TYPE]: true,
+  },
+
+  // these have no valid children
+  [CHART_TYPE]: {},
+  [MARKDOWN_TYPE]: {},
+  [DIVIDER_TYPE]: {},
+  [HEADER_TYPE]: {},
+  [SPACER_TYPE]: {},
+};
+
+export default function isValidChild({ parentType, childType }) {
+  if (!parentType || !childType) return false;
+
+  const isValid = Boolean(
+    typeToValidChildType[parentType][childType],
+  );
+
+  return isValid;
+}
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 0000000..c1ed03e
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/newComponentFactory.js
@@ -0,0 +1,45 @@
+import {
+  CHART_TYPE,
+  COLUMN_TYPE,
+  DIVIDER_TYPE,
+  HEADER_TYPE,
+  MARKDOWN_TYPE,
+  ROW_TYPE,
+  SPACER_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+} from './componentTypes';
+
+import {
+  MEDIUM_HEADER,
+  ROW_TRANSPARENT,
+} from './constants';
+
+const typeToDefaultMetaData = {
+  [CHART_TYPE]: { width: 3, height: 15 },
+  [COLUMN_TYPE]: { width: 3 },
+  [DIVIDER_TYPE]: null,
+  [HEADER_TYPE]: { text: 'New header', headerSize: MEDIUM_HEADER, rowStyle: ROW_TRANSPARENT },
+  [MARKDOWN_TYPE]: { width: 3, height: 15 },
+  [ROW_TYPE]: { rowStyle: ROW_TRANSPARENT },
+  [SPACER_TYPE]: {},
+  [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) {
+  return {
+    version: 'v0',
+    type,
+    id: uuid(type),
+    children: [],
+    meta: {
+      ...typeToDefaultMetaData[type],
+    },
+  };
+}
diff --git a/superset/assets/javascripts/dashboard/v2/util/newComponentIdToType.js b/superset/assets/javascripts/dashboard/v2/util/newComponentIdToType.js
new file mode 100644
index 0000000..38d1c7c
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/newComponentIdToType.js
@@ -0,0 +1,35 @@
+import {
+  CHART_TYPE,
+  COLUMN_TYPE,
+  DIVIDER_TYPE,
+  HEADER_TYPE,
+  MARKDOWN_TYPE,
+  ROW_TYPE,
+  SPACER_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+} from './componentTypes';
+
+import {
+  NEW_CHART_ID,
+  NEW_COLUMN_ID,
+  NEW_DIVIDER_ID,
+  NEW_HEADER_ID,
+  NEW_MARKDOWN_ID,
+  NEW_ROW_ID,
+  NEW_SPACER_ID,
+  NEW_TABS_ID,
+  NEW_TAB_ID,
+} from './constants';
+
+export default {
+  [NEW_CHART_ID]: CHART_TYPE, // @TODO we will have to encode real chart ids => type in the future
+  [NEW_COLUMN_ID]: COLUMN_TYPE,
+  [NEW_DIVIDER_ID]: DIVIDER_TYPE,
+  [NEW_HEADER_ID]: HEADER_TYPE,
+  [NEW_MARKDOWN_ID]: MARKDOWN_TYPE,
+  [NEW_ROW_ID]: ROW_TYPE,
+  [NEW_SPACER_ID]: SPACER_TYPE,
+  [NEW_TABS_ID]: TABS_TYPE,
+  [NEW_TAB_ID]: TAB_TYPE,
+};
diff --git a/superset/assets/javascripts/dashboard/v2/util/newEntitiesFromDrop.js b/superset/assets/javascripts/dashboard/v2/util/newEntitiesFromDrop.js
new file mode 100644
index 0000000..a0d92fa
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/newEntitiesFromDrop.js
@@ -0,0 +1,55 @@
+import newComponentIdToType from './newComponentIdToType';
+import shouldWrapChildInRow from './shouldWrapChildInRow';
+import newComponentFactory from './newComponentFactory';
+
+import {
+  ROW_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+} from './componentTypes';
+
+export default function newEntitiesFromDrop({ dropResult, components }) {
+  const { draggableId, destination } = dropResult;
+
+  const dragType = newComponentIdToType[draggableId];
+  const dropEntity = components[destination.droppableId];
+
+  if (!dropEntity) {
+    console.warn('Drop target entity', destination.droppableId, 'not found');
+    return null;
+  }
+
+  if (!dragType) {
+    console.warn('Drag type not found for id', draggableId);
+    return null;
+  }
+
+  const dropType = dropEntity.type;
+  let newDropChild = newComponentFactory(dragType);
+  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.droppableId] = {
+    ...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 0000000..be84965
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/propShapes.jsx
@@ -0,0 +1,24 @@
+import PropTypes from 'prop-types';
+import componentTypes from './componentTypes';
+import rowStyleOptions from './rowStyleOptions';
+import headerStyleOptions from './headerStyleOptions';
+
+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
+    text: PropTypes.string,
+    headerSize: PropTypes.oneOf(headerStyleOptions.map(opt => opt.value)),
+
+    // Row
+    rowStyle: PropTypes.oneOf(rowStyleOptions.map(opt => opt.value)),
+  }),
+});
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 0000000..40e9af6
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/resizableConfig.js
@@ -0,0 +1,30 @@
+// 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,
+};
+
+export default {
+  widthAndHeight: adjustableWidthAndHeight,
+  widthOnly: adjustableWidth,
+  heightOnly: adjustableHeight,
+};
diff --git a/superset/assets/javascripts/dashboard/v2/util/rowStyleOptions.js b/superset/assets/javascripts/dashboard/v2/util/rowStyleOptions.js
new file mode 100644
index 0000000..ad42492
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/rowStyleOptions.js
@@ -0,0 +1,7 @@
+import { t } from '../../../locales';
+import { ROW_TRANSPARENT, ROW_WHITE } from './constants';
+
+export default [
+  { value: ROW_TRANSPARENT, label: t('Transparent'), className: 'grid-row--transparent' },
+  { value: ROW_WHITE, label: t('White'), className: 'grid-row--white' },
+];
diff --git a/superset/assets/javascripts/dashboard/v2/util/shouldWrapChildInRow.js b/superset/assets/javascripts/dashboard/v2/util/shouldWrapChildInRow.js
new file mode 100644
index 0000000..487e247
--- /dev/null
+++ b/superset/assets/javascripts/dashboard/v2/util/shouldWrapChildInRow.js
@@ -0,0 +1,30 @@
+import {
+  GRID_ROOT_TYPE,
+  CHART_TYPE,
+  COLUMN_TYPE,
+  MARKDOWN_TYPE,
+  TAB_TYPE,
+} from './componentTypes';
+
+const typeToWrapChildLookup = {
+  [GRID_ROOT_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/package.json b/superset/assets/package.json
index 1bcb5d6..b3379f3 100644
--- a/superset/assets/package.json
+++ b/superset/assets/package.json
@@ -41,9 +41,9 @@
   },
   "homepage": "http://superset.apache.org/",
   "dependencies": {
-    "//": "known issues with react-bootstrap>=0.32",
     "@data-ui/event-flow": "^0.0.54",
     "@data-ui/sparkline": "^0.0.54",
+    "@vx/responsive": "0.0.153",
     "babel-register": "^6.24.1",
     "bootstrap": "^3.3.6",
     "bootstrap-slider": "^10.0.0",
@@ -83,6 +83,7 @@
     "parse-iso-duration": "^1.0.0",
     "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",
@@ -93,6 +94,8 @@
     "react-bootstrap-table": "^4.3.1",
     "react-color": "^2.13.8",
     "react-datetime": "2.14.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.6",
diff --git a/superset/assets/src/components/EditableTitle.jsx b/superset/assets/src/components/EditableTitle.jsx
index b773340..1497676 100644
--- a/superset/assets/src/components/EditableTitle.jsx
+++ b/superset/assets/src/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.handleKeyDown = this.handleKeyDown.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);
     }
   }
+
+  handleKeyDown(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 = (
       <input
         required
         type={this.state.isEditing ? 'text' : 'button'}
         value={this.state.title}
+        onKeyDown={this.handleKeyDown}
         onChange={this.handleChange}
         onBlur={this.handleBlur}
         onClick={this.handleClick}
@@ -110,7 +140,9 @@ class EditableTitle extends React.PureComponent {
       );
     }
     return (
-      <span className="editable-title">{input}</span>
+      <span className={cx('editable-title', this.props.canEdit && 'editable-title--editable')}>
+        {input}
+      </span>
     );
   }
 }
diff --git a/superset/assets/src/dashboard/components/DashboardContainer.jsx b/superset/assets/src/dashboard/components/DashboardContainer.jsx
index a18a5d2..6df72ff 100644
--- a/superset/assets/src/dashboard/components/DashboardContainer.jsx
+++ b/superset/assets/src/dashboard/components/DashboardContainer.jsx
@@ -3,7 +3,7 @@ import { connect } from 'react-redux';
 
 import * as dashboardActions from '../actions';
 import * as chartActions from '../../chart/chartAction';
-import Dashboard from './Dashboard';
+import Dashboard from '../v2/components/Dashboard';
 
 function mapStateToProps({ charts, dashboard, impressionId }) {
   return {
diff --git a/superset/assets/src/dashboard/index.jsx b/superset/assets/src/dashboard/index.jsx
index 774e071..c9236bd 100644
--- a/superset/assets/src/dashboard/index.jsx
+++ b/superset/assets/src/dashboard/index.jsx
@@ -8,17 +8,29 @@ import { initEnhancer } from '../reduxUtils';
 import { appSetup } from '../common';
 import { initJQueryAjax } from '../modules/utils';
 import DashboardContainer from './components/DashboardContainer';
-import rootReducer, { getInitialState } from './reducers';
+// import rootReducer, { getInitialState } from './reducers';
+
+import testLayout from './v2/fixtures/testLayout';
+import rootReducer from './v2/reducers/';
 
 appSetup();
 initJQueryAjax();
 
 const appContainer = document.getElementById('app');
-const bootstrapData = JSON.parse(appContainer.getAttribute('data-bootstrap'));
-const initState = Object.assign({}, getInitialState(bootstrapData));
+// const bootstrapData = JSON.parse(appContainer.getAttribute('data-bootstrap'));
+// const initState = Object.assign({}, getInitialState(bootstrapData));
+const initState = {
+  dashboard: testLayout,
+};
 
 const store = createStore(
-  rootReducer, initState, compose(applyMiddleware(thunk), initEnhancer(false)));
+  rootReducer,
+  initState,
+  compose(
+    applyMiddleware(thunk),
+    initEnhancer(false),
+  ),
+);
 
 ReactDOM.render(
   <Provider store={store}>
@@ -26,4 +38,3 @@ ReactDOM.render(
   </Provider>,
   appContainer,
 );
-
diff --git a/superset/assets/stylesheets/dashboard-v2.css b/superset/assets/stylesheets/dashboard-v2.css
new file mode 100644
index 0000000..534a17e
--- /dev/null
+++ b/superset/assets/stylesheets/dashboard-v2.css
@@ -0,0 +1,42 @@
+.dashboard-v2 {
+  margin-top: -20px;
+  position: relative;
+  color: #263238;
+}
+
+.dashboard-header {
+  background: white;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 24px;
+  box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.1);
+  margin-bottom: 2px;
+}
+
+.dashboard-builder {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: nowrap;
+  height: auto;
+}
+
+.dashboard-builder-sidepane {
+  background: white;
+  flex: 0 0 376px;
+  box-shadow: 0 0 0 1px #ccc; /* @TODO color */
+}
+
+.dashboard-builder-sidepane-header {
+  font-size: 16;
+  font-weight: 700;
+  border-bottom: 1px solid #ccc;
+  padding: 16px;
+}
+
+/* @TODO remove upon new theme */
+.btn.btn-primary {
+  background: #263238 !important;
+  color: white !important;
+}
diff --git a/superset/assets/stylesheets/superset.less b/superset/assets/stylesheets/superset.less
index 035acce..6513b6f 100644
--- a/superset/assets/stylesheets/superset.less
+++ b/superset/assets/stylesheets/superset.less
@@ -208,6 +208,17 @@ div.widget {
     }
   }
 }
+/* brand icon */
+.navbar-brand > img.logo {
+  margin-left: 15px;
+  width: 36px;
+  display: inline;
+}
+.navbar-brand > span {
+  margin-left: 2px;
+  font-size: 15px;
+  font-weight: bold;
+}
 
 .navbar .alert {
     padding: 5px 10px;
@@ -228,23 +239,26 @@ table.table-no-hover tr:hover {
 }
 
 .editable-title input {
-    padding: 2px 6px 3px 6px;
+  outline: none;
+  background: transparent;
+  border: none;
+  box-shadow: none;
+  padding-left: 0;
 }
 
 .editable-title input[type="button"] {
     border-color: transparent;
     background: transparent;
+    font-size: inherit;
+    line-height: inherit;
     white-space: normal;
     text-align: left;
 }
 
-.editable-title input[type="button"]:hover {
+.editable-title--editable input[type="button"]:hover {
     cursor: text;
 }
 
-.editable-title input[type="button"]:focus {
-    outline: none;
-}
 .m-r-5 {
     margin-right: 5px;
 }
diff --git a/superset/config.py b/superset/config.py
index 530b126..ea5a520 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -97,7 +97,7 @@ ENABLE_PROXY_FIX = False
 APP_NAME = 'Superset'
 
 # Uncomment to setup an App icon
-APP_ICON = '/static/assets/images/superset-logo@2x.png'
+APP_ICON = '/static/assets/images/favicon.png'
 
 # Druid query timezone
 # tz.tzutc() : Using utc timezone
diff --git a/superset/templates/appbuilder/navbar.html b/superset/templates/appbuilder/navbar.html
index 27f2fee..c2f0668 100644
--- a/superset/templates/appbuilder/navbar.html
+++ b/superset/templates/appbuilder/navbar.html
@@ -12,9 +12,11 @@
       </button>
       <a class="navbar-brand" href="/superset/profile/{{ current_user.username }}/">
         <img
-          width="126" src="{{ appbuilder.app_icon }}"
+          class="logo"
+          src="{{ appbuilder.app_icon }}"
           alt="{{ appbuilder.app_name }}"
         />
+        <span>Superset</span>
       </a>
     </div>
     <div class="navbar-collapse collapse">
@@ -47,4 +49,3 @@
     </div>
   </div>
 </div>
-
diff --git a/superset/templates/superset/dashboard.html b/superset/templates/superset/dashboard.html
index 1a158d9..25633da 100644
--- a/superset/templates/superset/dashboard.html
+++ b/superset/templates/superset/dashboard.html
@@ -3,7 +3,6 @@
 {% block body %}
 <div
   id="app"
-  class="dashboard container-fluid"
   data-bootstrap="{{ bootstrap_data }}"
 >
 </div>


Mime
View raw message