You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by cc...@apache.org on 2018/06/25 16:17:28 UTC

[incubator-superset] branch master updated: [wip] dashboard builder v2 (#4528)

This is an automated email from the ASF dual-hosted git repository.

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


The following commit(s) were added to refs/heads/master by this push:
     new c065319  [wip] dashboard builder v2 (#4528)
c065319 is described below

commit c06531950869d84a50e7914b945ba6ceb2a5c179
Author: Chris Williams <wi...@users.noreply.github.com>
AuthorDate: Mon Jun 25 09:17:22 2018 -0700

    [wip] dashboard builder v2 (#4528)
    
    * [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
    
    * fix rebase error, clean up css organization and use @less vars
    
    * [dashboard-builder] add top-level tabs + undo-redo (#4626)
    
    * [top-level-tabs] initial working version of top-level tabs
    
    * [top-level-tabs] simplify redux and disable ability to displace top-level tabs with other tabs
    
    * [top-level-tabs] improve tab drag and drop css
    
    * [undo-redo] add redux undo redo
    
    * [dnd] clean up dropResult shape, add new component source id + type, use css for drop indicator instead of styles and fix tab indicators.
    
    * [top-level-tabs] add 'Collapse tab content' to delete tabs button
    
    * [dnd] add depth validation to drag and drop logic
    
    * [dashboard-builder] add resize action, enforce minimum width of columns, column children inherit column size when necessary, meta.rowStyle => meta.background, add background to columns
    
    * [dashboard-builder] make sure getChildWidth returns a number
    
    * [dashboard builder] static layout + toasts (#4763)
    
    * [dashboard-builder] remove spacer component
    
    * [dashboard-builder] better transparent indicator, better grid gutter logic, no dragging top-level tabs, headers are multiples of grid unit, fix row height granularity, update redux state key dashboard => dashboardLayout
    
    * [dashboard-builder] don't blast column child dimensions on resize
    
    * [dashboard-builder] ResizableContainer min size can't be smaller than size, fix row style, role=none on WithPopoverMenu container
    
    * [edit mode] add edit mode to redux and propogate to all <DashboardComponent />s
    
    * [toasts] add Toast component, ToastPresenter container and component, and toast redux actions + reducers
    
    * [dashboard-builder] add info toast when dropResult overflows parent
    
    * [dashboard builder] git mv to src/ post-rebase
    
    * Dashboard builder rebased + linted (#4849)
    
    * define dashboard redux state
    
    * update dashboard state reducer
    
    * dashboard layout converter + grid render
    
    * builder pane + slice adder
    
    * Dashboard header + slice header controls
    
    * fix linting
    
    * 2nd code review comments
    
    * [dashboard builder] improve perf (#4855)
    
    * address major perf + css issues
    
    [dashboard builder] fix dashboard filters and some css
    
    [dashboard builder] use VIZ_TYPES, move stricter .eslintrc to dashboard/, more css fixes
    
    [builder] delete GridCell and GridLayout, remove some unused css. fix broken tabs.
    
    * [builder] fix errors post-rebase
    
    * [builder] add support for custom DragDroppable drag layer and add AddSliceDragPreview
    
    * [AddSliceDragPreview] fix type check
    
    * [dashboard builder] add prettier and update all files
    
    * [dashboard builder] merge v2/ directory int dashboard/
    
    * [dashboard builder] move component/*Container => containers/*
    
    * add sticky tabs + sidepane, better tabs perf, better container hierarchy, better chart header (#4893)
    
    * dashboard header, slice header UI improvement
    
    * add slider and sticky
    
    * dashboard header, slice header UI improvement
    
    * make builder pane floating
    
    * [dashboard builder] add sticky top-level tabs, refactor for performant tabs
    
    * [dashboard builder] visually distinct containers, icons for undo-redo, fix some isValidChild bugs
    
    * [dashboard builder] better undo redo <> save changes state, notify upon reaching undo limit
    
    * [dashboard builder] hook up edit + create component actions to saved-state pop.
    
    * [dashboard builder] visual refinement, refactor Dashboard header content and updates into layout for undo-redo, refactor save dashboard modal to use toasts instead of notify.
    
    * [dashboard builder] refactor chart name update logic to use layout for undo redo, save slice name changes on dashboard save
    
    * add slider and sticky
    
    * [dashboard builder] fix layout converter slice_id + chartId type casting, don't change grid size upon edit (perf)
    
    * [dashboard builder] don't set version key in getInitialState
    
    * [dashboard builder] make top level tabs addition/removal undoable, fix double sticky tabs + side panel.
    
    * [dashboard builder] fix sticky tabs offset bug
    
    * [dashboard builder] fix drag preview width, css polish, fix rebase issue
    
    * [dashboard builder] fix side pane labels and hove z-index
    
    * Markdown for dashboard (#4962)
    
    * fix dashboard server-side unit tests (#5009)
    
    * Dashboard save button (#4979)
    
    * save button
    
    * fix slices list height
    
    * save custom css
    
    * merge save-dash changes from dashboard v1
    https://github.com/apache/incubator-superset/pull/4900
    https://github.com/apache/incubator-superset/pull/5051
    
    * [dashboard v2] check for default_filters before json_loads-ing them (#5064)
    
    [dashboard v2] check for default_filters before json-loads-ing them
    
    * [dashboard v2] fix bugs from rebase
    
    * [dashboard v2] tests! (#5066)
    
    * [dashboard v2][tests] add tests for newComponentFactory, isValidChild, dropOverflowsParent, and dnd-reorder
    
    * [dashboard v2][tests] add tests for componentIsResizable, findParentId, getChartIdsFromLayout, newEntitiesFromDrop, and getDropPosition
    
    * [dashboard v2][tests] add mockStore, mockState, and tests for DragDroppable, DashboardBuilder, DashboardGrid, ToastPresenter, and Toast
    
    * [dashboard builder][tests] separate files for state tree fixtures, add ChartHolder, Chart, Divider, Header, Row tests and WithDragDropContext helper
    
    * [dashboard v2][tests] fix dragdrop context with util/getDragDropManager, add test for menu/* and resizable/*, and new components
    
    * [dashboard v2][tests] fix and re-write Dashboard tests, add getFormDataWithExtraFilters_spec
    
    * [dashboard v2][tests] add reducer tests, fix lint error
    
    * [dashboard-v2][tests] add actions/dashboardLayout_spec
    
    * [dashboard v2] fix some prop bugs, open side pane on edit, fix slice name bug
    
    * [dashboard v2] fix slice name save bug
    
    * [dashboard v2] fix lint errors
    
    * [dashboard v2] fix filters bug and add test
    
    * [dashboard v2] fix getFormDataWithExtraFilters_spec
    
    * [dashboard v2] logging updates (#5087)
    
    * [dashboard v2] initial logging refactor
    
    * [dashboard v2] clean up logger
    
    * [logger] update explore with new log events, add refresh dashboard + refresh dashboard chart actions
    
    * [logging] add logger_spec.js, fix reducers/dashboardState_spec + gridComponents/Chart_spec
    
    * [dashboard v2][logging] refactor for bulk logging in python
    
    * [logging] tweak python, fix and remove dup start_offset entries
    
    * [dashboard v2][logging] add dashboard_first_load event
    
    * [dashboard v2][logging] add slice_ids to dashboard pane load event
    
    * [tests] fix npm test script
    
    * Fix: update slices list when add/remove multiple slices (#5138)
    
    * [dashboard v2] add v1 switch (#5126)
    
    * [dashboard] copy all dashboard v1 into working v1 switch
    
    * [dashboard] add functional v1 <> v2 switch with messaging
    
    * [dashboard] add v2 logging to v1 dashboard, add read-v2-changes link, add client logging to track v1 <> v2 switches
    
    * [dashboard] Remove default values for feedback url + v2 auto convert date
    
    * [dashboard v2] fix misc UI/UX issues
    
    * [dashboard v2] fix Markdown persistance issues and css, fix copy dash title, don't enforce shallow hovering with drop indicator
    
    * [dashboard v2] improve non-shallow drop target UX, fix Markdown drop indicator, clarify slice adder filter/sort
    
    * [dashboard v2] delete empty rows on drag or delete events that leave them without children, add test
    
    * [dashboard v2] improve v1<>v2 switch modals, add convert to v2 badge in v1, fix unsaved changes issue in preview mode, don't auto convert column child widths for now
    
    * [dashboard v2][dnd] add drop position cache to fix non-shallow drops
    
    * [dashboard] fix test script with glob instead of recurse, fix tests, add temp fix for tab nesting, ignore v1 lint errors
    
    * [dashboard] v2 badge style tweaks, add back v1 _set_dash_metadata for v1 editing
    
    * [dashboard] fix python linting and tests
    
    * [dashboard] lint tests
    
    * add slice from explore view (#5141)
    
    * Fix dashboard position row data (#5131)
    
    * add slice_name to markdown
    
    (cherry picked from commit 14b01f1)
    
    * set min grid width be 1 column
    
    * remove empty column
    
    * check total columns count <= 12
    
    * scan position data and fix rows
    
    * fix dashboard url with default_filters
    
    * [dashboard v2]  better grid drop ux, fix tab bugs 🐛 (#5151)
    
    * [dashboard v2] add empty droptarget to dashboard grid for better ux and update test
    
    * [dashboard] reset tab index upon top-level tab deletion, fix findparentid bug
    
    * [dashboard] update v1<>v2 modal link for tracking
    
    * Fix: Should pass slice_can_edit flag down (#5159)
    
    * [dash builder fix] combine markdown and slice name, slice picker height (#5165)
    
    * combine markdown code and markdown slice name
    
    * allow dynamic height for slice picker cell
    
    * add word break for long datasource name
    
    * [fix] new dashboard state (#5213)
    
    * [dashboard v2] ui + ux fixes (#5208)
    
    * [dashboard v2] use <Loading /> throughout, small loading gif, improve row/column visual hierarchy, add cached data pop
    
    * [dashboard v2] lots of polish
    
    * [dashboard v2] remove markdown padding on edit, more opaque slice drag preview, unsavedChanges=true upon moving a component, fix initial load logging.
    
    * [dashboard v2] gray loading.gif, sticky header, undo/redo keyboard shortcuts, fix move component saved changes update, v0 double scrollbar fix
    
    * [dashboard v2] move UndoRedoKeylisteners into Header, render only in edit mode, show visual feedback for keyboard shortcut, hide hover menu in top-level tabs
    
    * [dashboard v2] fix grid + sidepane height issues
    
    * [dashboard v2] add auto-resize functionality, update tests. cache findParentId results.
    
    * [dashboard v2][tests] add getDetailedComponentWidth_spec.js
    
    * [dashboard v2] fix lint
    
    * [fix] layout converter fix (#5218)
    
    * [fix] layout converter fix
    
    * add changed_on into initial sliceEntity data
    
    * add unit tests for SliceAdder component
    
    * remove old fixtures file
    
    * [dashboard v2] remove webpack-cli, fresh yarn.lock post-rebase
    
    * [dashboard v2] lint javascript
    
    * [dashboard v2] fix python tests
    
    * [Fix] import/export dash in V2 (#5273)
    
    * [dashboard v2] add markdown tests (#5275)
    
    * [dashboard v2] add Markdown tests
    
    * [dashboard v2][mocks] fix markdown mock
---
 superset/assets/.eslintignore                      |    1 +
 superset/assets/images/loading.gif                 |  Bin 16671 -> 79023 bytes
 superset/assets/package.json                       |   22 +-
 superset/assets/spec/helpers/browser.js            |    4 +-
 .../assets/spec/javascripts/chart/Chart_spec.jsx   |    2 +-
 .../assets/spec/javascripts/dashboard/.eslintrc    |   33 +
 .../assets/spec/javascripts/dashboard/.prettierrc  |    4 +
 .../spec/javascripts/dashboard/Dashboard_spec.jsx  |  182 -
 .../dashboard/actions/dashboardLayout_spec.js      |  454 ++
 .../dashboard/{ => components}/CodeModal_spec.jsx  |    6 +-
 .../dashboard/{ => components}/CssEditor_spec.jsx  |    6 +-
 .../dashboard/components/DashboardBuilder_spec.jsx |  138 +
 .../dashboard/components/DashboardGrid_spec.jsx    |   84 +
 .../dashboard/components/Dashboard_spec.jsx        |  250 ++
 .../{ => components}/RefreshIntervalModal_spec.jsx |    2 +-
 .../dashboard/components/SliceAdder_spec.jsx       |  154 +
 .../dashboard/components/ToastPresenter_spec.jsx   |   41 +
 .../dashboard/components/Toast_spec.jsx            |   43 +
 .../components/dnd/DragDroppable_spec.jsx          |   90 +
 .../components/gridComponents/ChartHolder_spec.jsx |  112 +
 .../components/gridComponents/Chart_spec.jsx       |   89 +
 .../components/gridComponents/Column_spec.jsx      |  144 +
 .../components/gridComponents/Divider_spec.jsx     |   70 +
 .../components/gridComponents/Header_spec.jsx      |  100 +
 .../components/gridComponents/Markdown_spec.jsx    |  156 +
 .../components/gridComponents/Row_spec.jsx         |  120 +
 .../components/gridComponents/Tab_spec.jsx         |  126 +
 .../components/gridComponents/Tabs_spec.jsx        |  140 +
 .../new/DraggableNewComponent_spec.jsx             |   68 +
 .../gridComponents/new/NewColumn_spec.jsx          |   29 +
 .../gridComponents/new/NewDivider_spec.jsx         |   29 +
 .../gridComponents/new/NewHeader_spec.jsx          |   29 +
 .../components/gridComponents/new/NewRow_spec.jsx  |   29 +
 .../components/gridComponents/new/NewTabs_spec.jsx |   29 +
 .../dashboard/components/menu/HoverMenu_spec.jsx   |   13 +
 .../components/menu/WithPopoverMenu_spec.jsx       |   71 +
 .../resizable/ResizableContainer_spec.jsx          |   20 +
 .../components/resizable/ResizableHandle_spec.jsx  |   29 +
 .../assets/spec/javascripts/dashboard/fixtures.jsx |  161 -
 .../dashboard/fixtures/mockChartQueries.js         |   61 +
 .../dashboard/fixtures/mockDashboardInfo.js        |   12 +
 .../dashboard/fixtures/mockDashboardLayout.js      |  146 +
 .../dashboard/fixtures/mockDashboardState.js       |   15 +
 .../dashboard/fixtures/mockDatasource.js           |  206 +
 .../dashboard/fixtures/mockMessageToasts.js        |    9 +
 .../dashboard/fixtures/mockSliceEntities.js        |  177 +
 .../javascripts/dashboard/fixtures/mockState.js    |   18 +
 .../javascripts/dashboard/fixtures/mockStore.js    |   22 +
 .../dashboard/helpers/WithDragDropContext.jsx      |   27 +
 .../dashboard/reducers/dashboardLayout_spec.js     |  443 ++
 .../dashboard/reducers/dashboardState_spec.js      |  241 +
 .../dashboard/reducers/messageToasts_spec.js       |   32 +
 .../dashboard/reducers/sliceEntities_spec.js       |   51 +
 .../spec/javascripts/dashboard/reducers_spec.js    |   35 -
 .../dashboard/util/componentIsResizable_spec.js    |   42 +
 .../javascripts/dashboard/util/dnd-reorder_spec.js |   62 +
 .../dashboard/util/dropOverflowsParent_spec.js     |  227 +
 .../util/findFirstParentContainer_spec.js          |  116 +
 .../dashboard/util/findParentId_spec.js            |   29 +
 .../dashboard/util/getChartIdsFromLayout_spec.js   |   41 +
 .../util/getDetailedComponentWidth_spec.js         |  223 +
 .../dashboard/util/getDropPosition_spec.js         |  422 ++
 .../util/getFormDataWithExtraFilters_spec.js       |   70 +
 .../dashboard/util/isValidChild_spec.js            |  147 +
 .../dashboard/util/newComponentFactory_spec.js     |   51 +
 .../dashboard/util/newEntitiesFromDrop_spec.js     |   85 +
 superset/assets/spec/javascripts/logger_spec.js    |  143 +
 .../assets/src/SqlLab/components/QuerySearch.jsx   |   79 +-
 .../assets/src/SqlLab/components/ResultSet.jsx     |    3 +-
 superset/assets/src/chart/Chart.jsx                |  140 +-
 superset/assets/src/chart/ChartContainer.jsx       |   22 +-
 superset/assets/src/chart/chartAction.js           |   32 +-
 superset/assets/src/chart/chartReducer.js          |   40 +-
 superset/assets/src/components/ActionMenuItem.jsx  |   65 +
 superset/assets/src/components/EditableTitle.jsx   |   48 +-
 superset/assets/src/components/Loading.jsx         |   10 +-
 superset/assets/src/dashboard/.eslintrc            |   33 +
 superset/assets/src/dashboard/.prettierrc          |    4 +
 .../src/dashboard/actions/dashboardLayout.js       |  203 +
 .../assets/src/dashboard/actions/dashboardState.js |  254 ++
 .../assets/src/dashboard/actions/datasources.js    |   36 +
 .../assets/src/dashboard/actions/messageToasts.js  |   59 +
 .../assets/src/dashboard/actions/sliceEntities.js  |   73 +
 .../src/dashboard/components/AddSliceCard.jsx      |   61 +
 .../dashboard/components/BuilderComponentPane.jsx  |  129 +
 .../assets/src/dashboard/components/CodeModal.jsx  |   10 +-
 .../assets/src/dashboard/components/CssEditor.jsx  |   11 +-
 .../assets/src/dashboard/components/Dashboard.jsx  |  457 +-
 .../src/dashboard/components/DashboardBuilder.jsx  |  208 +
 .../src/dashboard/components/DashboardGrid.jsx     |  198 +
 .../dashboard/components/DeleteComponentButton.jsx |   20 +
 .../assets/src/dashboard/components/Header.jsx     |  382 +-
 .../dashboard/components/HeaderActionsDropdown.jsx |  163 +
 .../assets/src/dashboard/components/IconButton.jsx |   44 +
 .../dashboard/components/RefreshIntervalModal.jsx  |   11 +-
 .../assets/src/dashboard/components/SaveModal.jsx  |  155 +-
 .../assets/src/dashboard/components/SliceAdder.jsx |  386 +-
 .../src/dashboard/components/SliceHeader.jsx       |  220 +-
 .../dashboard/components/SliceHeaderControls.jsx   |  161 +
 superset/assets/src/dashboard/components/Toast.jsx |   89 +
 .../src/dashboard/components/ToastPresenter.jsx    |   36 +
 .../dashboard/components/UndoRedoKeylisteners.jsx  |   47 +
 .../components/dnd/AddSliceDragPreview.jsx         |   75 +
 .../src/dashboard/components/dnd/DragDroppable.jsx |  141 +
 .../src/dashboard/components/dnd/DragHandle.jsx    |   40 +
 .../components/dnd/dragDroppableConfig.js          |   67 +
 .../src/dashboard/components/dnd/handleDrop.js     |   82 +
 .../src/dashboard/components/dnd/handleHover.js    |   23 +
 .../dashboard/components/gridComponents/Chart.jsx  |  242 +
 .../components/gridComponents/ChartHolder.jsx      |  159 +
 .../dashboard/components/gridComponents/Column.jsx |  192 +
 .../components/gridComponents/Divider.jsx          |   72 +
 .../dashboard/components/gridComponents/Header.jsx |  164 +
 .../components/gridComponents/Markdown.jsx         |  259 ++
 .../dashboard/components/gridComponents/Row.jsx    |  177 +
 .../dashboard/components/gridComponents/Tab.jsx    |  235 +
 .../dashboard/components/gridComponents/Tabs.jsx   |  240 +
 .../dashboard/components/gridComponents/index.js   |   39 +
 .../gridComponents/new/DraggableNewComponent.jsx   |   46 +
 .../components/gridComponents/new/NewColumn.jsx    |   16 +
 .../components/gridComponents/new/NewDivider.jsx   |   16 +
 .../components/gridComponents/new/NewHeader.jsx    |   16 +
 .../components/gridComponents/new/NewMarkdown.jsx  |   16 +
 .../components/gridComponents/new/NewRow.jsx       |   16 +
 .../components/gridComponents/new/NewTabs.jsx      |   16 +
 .../components/menu/BackgroundStyleDropdown.jsx    |   46 +
 .../src/dashboard/components/menu/HoverMenu.jsx    |   36 +
 .../components/menu/MarkdownModeDropdown.jsx       |   39 +
 .../dashboard/components/menu/PopoverDropdown.jsx  |   66 +
 .../dashboard/components/menu/WithPopoverMenu.jsx  |  123 +
 .../components/resizable/ResizableContainer.jsx    |  213 +
 .../components/resizable/ResizableHandle.jsx       |   19 +
 superset/assets/src/dashboard/containers/Chart.jsx |   64 +
 .../assets/src/dashboard/containers/Dashboard.jsx  |   56 +
 .../src/dashboard/containers/DashboardBuilder.jsx  |   33 +
 .../dashboard/containers/DashboardComponent.jsx    |   81 +
 .../src/dashboard/containers/DashboardGrid.jsx     |   29 +
 .../src/dashboard/containers/DashboardHeader.jsx   |   85 +
 .../assets/src/dashboard/containers/SliceAdder.jsx |   31 +
 .../src/dashboard/containers/ToastPresenter.jsx    |   10 +
 .../deprecated/PromptV2ConversionModal.jsx         |  102 +
 .../src/dashboard/deprecated/V2PreviewModal.jsx    |  148 +
 .../src/{ => dashboard/deprecated}/chart/Chart.jsx |   20 +-
 .../src/dashboard/deprecated/chart/ChartBody.jsx   |   55 +
 .../deprecated}/chart/ChartContainer.jsx           |    0
 .../src/dashboard/deprecated/chart/chart.css       |    4 +
 .../deprecated}/chart/chartAction.js               |   21 +-
 .../deprecated}/chart/chartReducer.js              |    4 +-
 .../src/dashboard/{ => deprecated/v1}/actions.js   |    2 +-
 .../{ => deprecated/v1}/components/CodeModal.jsx   |    4 +-
 .../{ => deprecated/v1}/components/Controls.jsx    |    4 +-
 .../{ => deprecated/v1}/components/CssEditor.jsx   |    4 +-
 .../{ => deprecated/v1}/components/Dashboard.jsx   |  101 +-
 .../v1}/components/DashboardContainer.jsx          |    0
 .../{ => deprecated/v1}/components/GridCell.jsx    |    2 +-
 .../{ => deprecated/v1}/components/GridLayout.jsx  |    0
 .../{ => deprecated/v1}/components/Header.jsx      |   87 +-
 .../v1}/components/RefreshIntervalModal.jsx        |    4 +-
 .../{ => deprecated/v1}/components/SaveModal.jsx   |    8 +-
 .../{ => deprecated/v1}/components/SliceAdder.jsx  |   11 +-
 .../{ => deprecated/v1}/components/SliceHeader.jsx |    6 +-
 .../src/dashboard/{ => deprecated/v1}/index.jsx    |    7 +-
 .../src/dashboard/{ => deprecated/v1}/reducers.js  |  128 +-
 .../src/dashboard/fixtures/emptyDashboardLayout.js |   34 +
 superset/assets/src/dashboard/index.jsx            |   16 +-
 .../src/dashboard/reducers/dashboardLayout.js      |  249 ++
 .../src/dashboard/reducers/dashboardState.js       |  152 +
 .../assets/src/dashboard/reducers/datasources.js   |   20 +
 .../src/dashboard/reducers/getInitialState.js      |  183 +
 superset/assets/src/dashboard/reducers/index.js    |   22 +
 .../assets/src/dashboard/reducers/messageToasts.js |   20 +
 .../assets/src/dashboard/reducers/sliceEntities.js |   54 +
 .../dashboard/reducers/undoableDashboardLayout.js  |   30 +
 .../dashboard/stylesheets/builder-sidepane.less    |  163 +
 .../assets/src/dashboard/stylesheets/builder.less  |   62 +
 .../assets/src/dashboard/stylesheets/buttons.less  |   23 +
 .../dashboard/stylesheets/components/chart.less    |  100 +
 .../dashboard/stylesheets/components/column.less   |   50 +
 .../dashboard/stylesheets/components/divider.less  |   24 +
 .../dashboard/stylesheets/components/header.less   |   73 +
 .../dashboard/stylesheets/components/index.less    |    8 +
 .../dashboard/stylesheets/components/markdown.less |   25 +
 .../stylesheets/components/new-component.less      |   31 +
 .../src/dashboard/stylesheets/components/row.less  |   60 +
 .../src/dashboard/stylesheets/components/tabs.less |   84 +
 .../src/dashboard/stylesheets/dashboard.less       |  213 +
 superset/assets/src/dashboard/stylesheets/dnd.less |  111 +
 .../assets/src/dashboard/stylesheets/grid.less     |   42 +
 .../src/dashboard/stylesheets/hover-menu.less      |   77 +
 .../assets/src/dashboard/stylesheets/index.less    |   13 +
 .../src/dashboard/stylesheets/popover-menu.less    |  136 +
 .../src/dashboard/stylesheets/resizable.less       |   86 +
 .../assets/src/dashboard/stylesheets/toast.less    |   58 +
 .../src/dashboard/stylesheets/variables.less       |   19 +
 .../src/dashboard/util/backgroundStyleOptions.js   |   15 +
 .../util/charts/getEffectiveExtraFilters.js        |   42 +
 .../util/charts/getFormDataWithExtraFilters.js     |   38 +
 .../src/dashboard/util/componentIsResizable.js     |    5 +
 .../assets/src/dashboard/util/componentTypes.js    |   27 +
 superset/assets/src/dashboard/util/constants.js    |   47 +
 .../src/dashboard/util/dashboardLayoutConverter.js |  513 +++
 superset/assets/src/dashboard/util/dnd-reorder.js  |   46 +
 .../src/dashboard/util/dropOverflowsParent.js      |    6 +
 .../src/dashboard/util/findFirstParentContainer.js |   19 +
 superset/assets/src/dashboard/util/findParentId.js |   31 +
 .../src/dashboard/util/getChartIdsFromLayout.js    |   15 +
 .../dashboard/util/getComponentWidthFromDrop.js    |   57 +
 .../dashboard/util/getDetailedComponentWidth.js    |   76 +
 .../src/dashboard/util/getDragDropManager.js       |   17 +
 .../assets/src/dashboard/util/getDropPosition.js   |  148 +
 .../assets/src/dashboard/util/getEmptyLayout.js    |   23 +
 .../src/dashboard/util/headerStyleOptions.js       |   20 +
 .../assets/src/dashboard/util/injectCustomCss.js   |   17 +
 superset/assets/src/dashboard/util/isValidChild.js |   98 +
 .../dashboard/util/logging/childChartsDidLoad.js   |   21 +
 .../util/logging/findNonTabChildChartIds.js        |   45 +
 .../util/logging/findTopLevelComponentIds.js       |   74 +
 .../logging/getLoadStatsPerTopLevelComponent.js    |   26 +
 .../src/dashboard/util/newComponentFactory.js      |   45 +
 .../src/dashboard/util/newEntitiesFromDrop.js      |   51 +
 superset/assets/src/dashboard/util/propShapes.jsx  |  100 +
 .../assets/src/dashboard/util/resizableConfig.js   |   35 +
 .../src/dashboard/util/shouldWrapChildInRow.js     |   30 +
 .../src/explore/components/DisplayQueryButton.jsx  |   14 +-
 .../src/explore/components/ExploreChartHeader.jsx  |    6 +-
 .../src/explore/components/ExploreChartPanel.jsx   |   17 +-
 .../explore/components/ExploreViewContainer.jsx    |  131 +-
 .../assets/src/explore/components/SaveModal.jsx    |    2 +-
 .../components/controls/DatasourceControl.jsx      |   58 +-
 superset/assets/src/explore/exploreUtils.js        |    2 +-
 superset/assets/src/explore/index.jsx              |    2 +-
 superset/assets/src/explore/reducers/index.js      |    5 +-
 superset/assets/src/logger.js                      |  165 +-
 superset/assets/src/modules/utils.js               |    7 +-
 .../assets/src/profile/components/TableLoader.jsx  |   25 +-
 superset/assets/src/theme.js                       |    1 -
 .../src/visualizations/deckgl/layers/scatter.jsx   |    4 +-
 .../visualizations/deckgl/layers/screengrid.jsx    |    4 +-
 superset/assets/src/visualizations/nvd3_vis.js     |    2 +
 superset/assets/src/visualizations/table.css       |    9 +-
 superset/assets/src/visualizations/time_table.jsx  |    4 +-
 superset/assets/src/welcome/DashboardTable.jsx     |    8 +-
 .../{dashboard.css => dashboard_deprecated.css}    |   27 +-
 superset/assets/stylesheets/superset.less          |   54 +-
 superset/assets/stylesheets/welcome.css            |    2 +-
 superset/assets/webpack.config.js                  |    1 +
 superset/assets/yarn.lock                          | 4609 +++++---------------
 superset/config.py                                 |    8 +
 superset/connectors/sqla/models.py                 |    4 +-
 superset/models/core.py                            |  131 +-
 superset/models/helpers.py                         |    2 +-
 superset/templates/appbuilder/navbar.html          |   19 +-
 superset/templates/superset/dashboard.html         |    7 +-
 ...dashboard.html => dashboard_v1_deprecated.html} |    0
 superset/views/core.py                             |  174 +-
 tests/core_tests.py                                |    8 +
 tests/dashboard_tests.py                           |   78 +-
 257 files changed, 18394 insertions(+), 5245 deletions(-)

diff --git a/superset/assets/.eslintignore b/superset/assets/.eslintignore
index 7479173..61262fc 100644
--- a/superset/assets/.eslintignore
+++ b/superset/assets/.eslintignore
@@ -8,3 +8,4 @@ node_modules*/*
 stylesheets/*
 vendor/*
 docs/*
+src/dashboard/deprecated/*
diff --git a/superset/assets/images/loading.gif b/superset/assets/images/loading.gif
index 01ae393..d82fc5d 100644
Binary files a/superset/assets/images/loading.gif and b/superset/assets/images/loading.gif differ
diff --git a/superset/assets/package.json b/superset/assets/package.json
index 1bcb5d6..6a39354 100644
--- a/superset/assets/package.json
+++ b/superset/assets/package.json
@@ -8,8 +8,8 @@
     "test": "spec"
   },
   "scripts": {
-    "test": "mocha --require ignore-styles --compilers js:babel-core/register --require spec/helpers/browser.js --recursive spec/**/*_spec.*",
-    "cover": "babel-node node_modules/.bin/babel-istanbul cover _mocha -- --require ignore-styles spec/helpers/browser.js --recursive spec/**/*_spec.*",
+    "test": "mocha --require ignore-styles --compilers js:babel-core/register --require spec/helpers/browser.js 'spec/**/*_spec.*'",
+    "cover": "babel-node node_modules/.bin/babel-istanbul cover _mocha -- --require ignore-styles spec/helpers/browser.js 'spec/**/*_spec.*'",
     "dev": "NODE_ENV=dev webpack --watch --colors --progress --debug --output-pathinfo --devtool eval-cheap-source-map",
     "dev-slow": "NODE_ENV=dev webpack --watch --colors --progress --debug --output-pathinfo --devtool inline-source-map",
     "dev-fast": "echo 'dev-fast in now replaced by dev'",
@@ -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",
@@ -61,6 +61,7 @@
     "deck.gl": "^5.1.4",
     "deep-equal": "^1.0.1",
     "distributions": "^1.0.0",
+    "dnd-core": "^2.6.0",
     "dompurify": "^1.0.3",
     "fastdom": "^1.0.6",
     "geojson-extent": "^0.3.2",
@@ -83,8 +84,9 @@
     "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-ace": "^5.10.0",
     "react-addons-css-transition-group": "^15.6.0",
     "react-addons-shallow-compare": "^15.4.2",
     "react-alert": "^2.3.0",
@@ -93,16 +95,21 @@
     "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",
     "react-map-gl": "^3.0.4",
+    "react-markdown": "^3.3.0",
     "react-redux": "^5.0.2",
     "react-resizable": "^1.3.3",
+    "react-search-input": "^0.11.3",
     "react-select": "1.2.1",
     "react-select-fast-filter-options": "^0.2.1",
     "react-sortable-hoc": "^0.8.3",
     "react-split-pane": "^0.1.66",
+    "react-sticky": "^6.0.2",
     "react-syntax-highlighter": "^7.0.4",
     "react-virtualized": "9.19.1",
     "react-virtualized-select": "^2.4.0",
@@ -110,14 +117,14 @@
     "redux": "^3.5.2",
     "redux-localstorage": "^0.4.1",
     "redux-thunk": "^2.1.0",
+    "redux-undo": "^1.0.0-beta9-9-7",
     "shortid": "^2.2.6",
     "sprintf-js": "^1.1.1",
     "srcdoc-polyfill": "^1.0.0",
     "supercluster": "https://github.com/georgeke/supercluster/tarball/ac3492737e7ce98e07af679623aad452373bbc40",
     "underscore": "^1.8.3",
     "urijs": "^1.18.10",
-    "viewport-mercator-project": "^5.0.0",
-    "webpack-cli": "^2.1.4"
+    "viewport-mercator-project": "^5.0.0"
   },
   "devDependencies": {
     "babel-cli": "^6.14.0",
@@ -133,8 +140,10 @@
     "enzyme": "^2.0.0",
     "eslint": "^4.19.0",
     "eslint-config-airbnb": "^15.0.1",
+    "eslint-config-prettier": "^2.9.0",
     "eslint-plugin-import": "^2.2.0",
     "eslint-plugin-jsx-a11y": "^5.1.1",
+    "eslint-plugin-prettier": "^2.6.0",
     "eslint-plugin-react": "^7.0.1",
     "exports-loader": "^0.7.0",
     "extract-text-webpack-plugin": "3.0.2",
@@ -149,6 +158,7 @@
     "less-loader": "^4.0.3",
     "mocha": "^3.2.0",
     "npm-check-updates": "^2.14.0",
+    "prettier": "^1.12.1",
     "react-addons-test-utils": "^15.6.2",
     "react-test-renderer": "^15.6.2",
     "redux-mock-store": "^1.2.3",
diff --git a/superset/assets/spec/helpers/browser.js b/superset/assets/spec/helpers/browser.js
index d465d86..b30d3c7 100644
--- a/superset/assets/spec/helpers/browser.js
+++ b/superset/assets/spec/helpers/browser.js
@@ -10,6 +10,8 @@ const exposedProperties = ['window', 'navigator', 'document'];
 global.jsdom = jsdom.jsdom;
 global.document = global.jsdom('<!doctype html><html><body></body></html>');
 global.window = document.defaultView;
+global.HTMLElement = window.HTMLElement;
+
 Object.keys(document.defaultView).forEach((property) => {
   if (typeof global[property] === 'undefined') {
     exposedProperties.push(property);
@@ -38,5 +40,5 @@ global.sinon.useFakeXMLHttpRequest();
 
 global.window.XMLHttpRequest = global.XMLHttpRequest;
 global.window.location = { href: 'about:blank' };
-global.window.performance = { now: () => (new Date().getTime()) };
+global.window.performance = { now: () => new Date().getTime() };
 global.$ = require('jquery')(global.window);
diff --git a/superset/assets/spec/javascripts/chart/Chart_spec.jsx b/superset/assets/spec/javascripts/chart/Chart_spec.jsx
index b766d9f..29a2941 100644
--- a/superset/assets/spec/javascripts/chart/Chart_spec.jsx
+++ b/superset/assets/spec/javascripts/chart/Chart_spec.jsx
@@ -20,7 +20,7 @@ describe('Chart', () => {
   };
   const mockedProps = {
     ...chart,
-    chartKey: 'slice_223',
+    id: 223,
     containerId: 'slice-container-223',
     datasource: {},
     formData: {},
diff --git a/superset/assets/spec/javascripts/dashboard/.eslintrc b/superset/assets/spec/javascripts/dashboard/.eslintrc
new file mode 100644
index 0000000..a3f86e3
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/.eslintrc
@@ -0,0 +1,33 @@
+{
+  "extends": "prettier",
+  "plugins": ["prettier"],
+  "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": 0,
+    "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,
+    "react/jsx-indent": 0,
+    "prettier/prettier": "error"
+  }
+}
diff --git a/superset/assets/spec/javascripts/dashboard/.prettierrc b/superset/assets/spec/javascripts/dashboard/.prettierrc
new file mode 100644
index 0000000..a20502b
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/.prettierrc
@@ -0,0 +1,4 @@
+{
+  "singleQuote": true,
+  "trailingComma": "all"
+}
diff --git a/superset/assets/spec/javascripts/dashboard/Dashboard_spec.jsx b/superset/assets/spec/javascripts/dashboard/Dashboard_spec.jsx
deleted file mode 100644
index c6e94d8..0000000
--- a/superset/assets/spec/javascripts/dashboard/Dashboard_spec.jsx
+++ /dev/null
@@ -1,182 +0,0 @@
-import React from 'react';
-import { shallow } from 'enzyme';
-import { describe, it } from 'mocha';
-import { expect } from 'chai';
-import sinon from 'sinon';
-
-import * as dashboardActions from '../../../src/dashboard/actions';
-import * as chartActions from '../../../src/chart/chartAction';
-import Dashboard from '../../../src/dashboard/components/Dashboard';
-import { defaultFilters, dashboard, charts } from './fixtures';
-
-describe('Dashboard', () => {
-  const mockedProps = {
-    actions: { ...chartActions, ...dashboardActions },
-    initMessages: [],
-    dashboard: dashboard.dashboard,
-    slices: charts,
-    filters: dashboard.filters,
-    datasources: dashboard.datasources,
-    refresh: false,
-    timeout: 60,
-    isStarred: false,
-    userId: dashboard.userId,
-  };
-
-  it('should render', () => {
-    const wrapper = shallow(<Dashboard {...mockedProps} />);
-    expect(wrapper.find('#dashboard-container')).to.have.length(1);
-    expect(wrapper.instance().getAllSlices()).to.have.length(3);
-  });
-
-  it('should handle metadata default_filters', () => {
-    const wrapper = shallow(<Dashboard {...mockedProps} />);
-    expect(wrapper.instance().props.filters).deep.equal(defaultFilters);
-  });
-
-  describe('getFormDataExtra', () => {
-    let wrapper;
-    let selectedSlice;
-    beforeEach(() => {
-      wrapper = shallow(<Dashboard {...mockedProps} />);
-      selectedSlice = wrapper.instance().props.dashboard.slices[1];
-    });
-
-    it('should carry default_filters', () => {
-      const extraFilters = wrapper.instance().getFormDataExtra(selectedSlice).extra_filters;
-      expect(extraFilters[0]).to.deep.equal({ col: 'region', op: 'in', val: [] });
-      expect(extraFilters[1]).to.deep.equal({ col: 'country_name', op: 'in', val: ['United States'] });
-    });
-
-    it('should carry updated filter', () => {
-      wrapper.setProps({
-        filters: {
-          256: { region: [] },
-          257: { country_name: ['France'] },
-        },
-      });
-      const extraFilters = wrapper.instance().getFormDataExtra(selectedSlice).extra_filters;
-      expect(extraFilters[1]).to.deep.equal({ col: 'country_name', op: 'in', val: ['France'] });
-    });
-  });
-
-  describe('refreshExcept', () => {
-    let wrapper;
-    let spy;
-    beforeEach(() => {
-      wrapper = shallow(<Dashboard {...mockedProps} />);
-      spy = sinon.spy(wrapper.instance(), 'fetchSlices');
-    });
-    afterEach(() => {
-      spy.restore();
-    });
-
-    it('should not refresh filter slice', () => {
-      const filterKey = Object.keys(defaultFilters)[1];
-      wrapper.instance().refreshExcept(filterKey);
-      expect(spy.callCount).to.equal(1);
-      expect(spy.getCall(0).args[0].length).to.equal(1);
-    });
-
-    it('should refresh all slices', () => {
-      wrapper.instance().refreshExcept();
-      expect(spy.callCount).to.equal(1);
-      expect(spy.getCall(0).args[0].length).to.equal(3);
-    });
-  });
-
-  describe('componentDidUpdate', () => {
-    let wrapper;
-    let refreshExceptSpy;
-    let fetchSlicesStub;
-    let prevProp;
-    beforeEach(() => {
-      wrapper = shallow(<Dashboard {...mockedProps} />);
-      prevProp = wrapper.instance().props;
-      refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
-      fetchSlicesStub = sinon.stub(wrapper.instance(), 'fetchSlices');
-    });
-    afterEach(() => {
-      fetchSlicesStub.restore();
-      refreshExceptSpy.restore();
-    });
-
-    describe('should check if filter has change', () => {
-      beforeEach(() => {
-        refreshExceptSpy.reset();
-      });
-      it('no change', () => {
-        wrapper.setProps({
-          refresh: true,
-          filters: {
-            256: { region: [] },
-            257: { country_name: ['United States'] },
-          },
-        });
-        wrapper.instance().componentDidUpdate(prevProp);
-        expect(refreshExceptSpy.callCount).to.equal(0);
-      });
-
-      it('remove filter', () => {
-        wrapper.setProps({
-          refresh: true,
-          filters: {
-            256: { region: [] },
-          },
-        });
-        wrapper.instance().componentDidUpdate(prevProp);
-        expect(refreshExceptSpy.callCount).to.equal(1);
-      });
-
-      it('change filter', () => {
-        wrapper.setProps({
-          refresh: true,
-          filters: {
-            256: { region: [] },
-            257: { country_name: ['Canada'] },
-          },
-        });
-        wrapper.instance().componentDidUpdate(prevProp);
-        expect(refreshExceptSpy.callCount).to.equal(1);
-      });
-
-      it('add filter', () => {
-        wrapper.setProps({
-          refresh: true,
-          filters: {
-            256: { region: [] },
-            257: { country_name: ['Canada'] },
-            258: { another_filter: ['new'] },
-          },
-        });
-        wrapper.instance().componentDidUpdate(prevProp);
-        expect(refreshExceptSpy.callCount).to.equal(1);
-      });
-    });
-
-    it('should refresh if refresh flag is true', () => {
-      wrapper.setProps({
-        refresh: true,
-        filters: {
-          256: { region: ['Asian'] },
-        },
-      });
-      wrapper.instance().componentDidUpdate(prevProp);
-      const fetchArgs = fetchSlicesStub.lastCall.args[0];
-      expect(fetchArgs).to.have.length(2);
-    });
-
-    it('should not refresh filter_immune_slices', () => {
-      wrapper.setProps({
-        refresh: true,
-        filters: {
-          256: { region: [] },
-          257: { country_name: ['Canada'] },
-        },
-      });
-      wrapper.instance().componentDidUpdate(prevProp);
-      const fetchArgs = fetchSlicesStub.lastCall.args[0];
-      expect(fetchArgs).to.have.length(1);
-    });
-  });
-});
diff --git a/superset/assets/spec/javascripts/dashboard/actions/dashboardLayout_spec.js b/superset/assets/spec/javascripts/dashboard/actions/dashboardLayout_spec.js
new file mode 100644
index 0000000..4b28480
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/actions/dashboardLayout_spec.js
@@ -0,0 +1,454 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import { ActionCreators as UndoActionCreators } from 'redux-undo';
+
+import {
+  UPDATE_COMPONENTS,
+  updateComponents,
+  DELETE_COMPONENT,
+  deleteComponent,
+  CREATE_COMPONENT,
+  CREATE_TOP_LEVEL_TABS,
+  createTopLevelTabs,
+  DELETE_TOP_LEVEL_TABS,
+  deleteTopLevelTabs,
+  resizeComponent,
+  MOVE_COMPONENT,
+  handleComponentDrop,
+  updateDashboardTitle,
+  undoLayoutAction,
+  redoLayoutAction,
+} from '../../../../src/dashboard/actions/dashboardLayout';
+
+import { setUnsavedChanges } from '../../../../src/dashboard/actions/dashboardState';
+import { addInfoToast } from '../../../../src/dashboard/actions/messageToasts';
+
+import {
+  DASHBOARD_GRID_TYPE,
+  ROW_TYPE,
+  CHART_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+import {
+  DASHBOARD_HEADER_ID,
+  DASHBOARD_GRID_ID,
+  DASHBOARD_ROOT_ID,
+  NEW_COMPONENTS_SOURCE_ID,
+  NEW_ROW_ID,
+} from '../../../../src/dashboard/util/constants';
+
+describe('dashboardLayout actions', () => {
+  const mockState = {
+    dashboardState: {
+      hasUnsavedChanges: true, // don't dispatch setUnsavedChanges() after every action
+    },
+    dashboardInfo: {},
+    dashboardLayout: {
+      past: [],
+      present: {},
+      future: {},
+    },
+  };
+
+  function setup(stateOverrides) {
+    const state = { ...mockState, ...stateOverrides };
+    const getState = sinon.spy(() => state);
+    const dispatch = sinon.spy();
+
+    return { getState, dispatch, state };
+  }
+
+  describe('updateComponents', () => {
+    it('should dispatch an updateLayout action', () => {
+      const { getState, dispatch } = setup();
+      const nextComponents = { 1: {} };
+      const thunk = updateComponents(nextComponents);
+      thunk(dispatch, getState);
+      expect(dispatch.callCount).to.equal(1);
+      expect(dispatch.getCall(0).args[0]).to.deep.equal({
+        type: UPDATE_COMPONENTS,
+        payload: { nextComponents },
+      });
+    });
+
+    it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => {
+      const { getState, dispatch } = setup({
+        dashboardState: { hasUnsavedChanges: false },
+      });
+      const nextComponents = { 1: {} };
+      const thunk = updateComponents(nextComponents);
+      thunk(dispatch, getState);
+      expect(dispatch.callCount).to.equal(2);
+      expect(dispatch.getCall(1).args[0]).to.deep.equal(
+        setUnsavedChanges(true),
+      );
+    });
+  });
+
+  describe('deleteComponents', () => {
+    it('should dispatch an deleteComponent action', () => {
+      const { getState, dispatch } = setup();
+      const thunk = deleteComponent('id', 'parentId');
+      thunk(dispatch, getState);
+      expect(dispatch.callCount).to.equal(1);
+      expect(dispatch.getCall(0).args[0]).to.deep.equal({
+        type: DELETE_COMPONENT,
+        payload: { id: 'id', parentId: 'parentId' },
+      });
+    });
+
+    it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => {
+      const { getState, dispatch } = setup({
+        dashboardState: { hasUnsavedChanges: false },
+      });
+      const thunk = deleteComponent('id', 'parentId');
+      thunk(dispatch, getState);
+      expect(dispatch.callCount).to.equal(2);
+      expect(dispatch.getCall(1).args[0]).to.deep.equal(
+        setUnsavedChanges(true),
+      );
+    });
+  });
+
+  describe('updateDashboardTitle', () => {
+    it('should dispatch an updateComponent action for the header component', () => {
+      const { getState, dispatch } = setup();
+      const thunk1 = updateDashboardTitle('new text');
+      thunk1(dispatch, getState);
+
+      const thunk2 = dispatch.getCall(0).args[0];
+      thunk2(dispatch, getState);
+
+      expect(dispatch.getCall(1).args[0]).to.deep.equal({
+        type: UPDATE_COMPONENTS,
+        payload: {
+          nextComponents: {
+            [DASHBOARD_HEADER_ID]: {
+              meta: { text: 'new text' },
+            },
+          },
+        },
+      });
+
+      expect(dispatch.callCount).to.equal(2);
+    });
+  });
+
+  describe('createTopLevelTabs', () => {
+    it('should dispatch a createTopLevelTabs action', () => {
+      const { getState, dispatch } = setup();
+      const dropResult = {};
+      const thunk = createTopLevelTabs(dropResult);
+      thunk(dispatch, getState);
+      expect(dispatch.callCount).to.equal(1);
+      expect(dispatch.getCall(0).args[0]).to.deep.equal({
+        type: CREATE_TOP_LEVEL_TABS,
+        payload: { dropResult },
+      });
+    });
+
+    it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => {
+      const { getState, dispatch } = setup({
+        dashboardState: { hasUnsavedChanges: false },
+      });
+      const dropResult = {};
+      const thunk = createTopLevelTabs(dropResult);
+      thunk(dispatch, getState);
+      expect(dispatch.callCount).to.equal(2);
+      expect(dispatch.getCall(1).args[0]).to.deep.equal(
+        setUnsavedChanges(true),
+      );
+    });
+  });
+
+  describe('deleteTopLevelTabs', () => {
+    it('should dispatch a deleteTopLevelTabs action', () => {
+      const { getState, dispatch } = setup();
+      const dropResult = {};
+      const thunk = deleteTopLevelTabs(dropResult);
+      thunk(dispatch, getState);
+      expect(dispatch.callCount).to.equal(1);
+      expect(dispatch.getCall(0).args[0]).to.deep.equal({
+        type: DELETE_TOP_LEVEL_TABS,
+        payload: {},
+      });
+    });
+
+    it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => {
+      const { getState, dispatch } = setup({
+        dashboardState: { hasUnsavedChanges: false },
+      });
+      const dropResult = {};
+      const thunk = deleteTopLevelTabs(dropResult);
+      thunk(dispatch, getState);
+      expect(dispatch.callCount).to.equal(2);
+      expect(dispatch.getCall(1).args[0]).to.deep.equal(
+        setUnsavedChanges(true),
+      );
+    });
+  });
+
+  describe('resizeComponent', () => {
+    const dashboardLayout = {
+      ...mockState.dashboardLayout,
+      present: {
+        1: {
+          id: 1,
+          children: [],
+          meta: {
+            width: 1,
+            height: 1,
+          },
+        },
+      },
+    };
+
+    it('should update the size of the component', () => {
+      const { getState, dispatch } = setup({
+        dashboardLayout,
+      });
+
+      const thunk1 = resizeComponent({ id: 1, width: 10, height: 3 });
+      thunk1(dispatch, getState);
+
+      const thunk2 = dispatch.getCall(0).args[0];
+      thunk2(dispatch, getState);
+
+      expect(dispatch.callCount).to.equal(2);
+      expect(dispatch.getCall(1).args[0]).to.deep.equal({
+        type: UPDATE_COMPONENTS,
+        payload: {
+          nextComponents: {
+            1: {
+              id: 1,
+              children: [],
+              meta: {
+                width: 10,
+                height: 3,
+              },
+            },
+          },
+        },
+      });
+
+      expect(dispatch.callCount).to.equal(2);
+    });
+
+    it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => {
+      const { getState, dispatch } = setup({
+        dashboardState: { hasUnsavedChanges: false },
+        dashboardLayout,
+      });
+      const thunk1 = resizeComponent({ id: 1, width: 10, height: 3 });
+      thunk1(dispatch, getState);
+
+      const thunk2 = dispatch.getCall(0).args[0];
+      thunk2(dispatch, getState);
+
+      expect(dispatch.callCount).to.equal(3);
+    });
+  });
+
+  describe('handleComponentDrop', () => {
+    it('should create a component if it is new', () => {
+      const { getState, dispatch } = setup();
+      const dropResult = {
+        source: { id: NEW_COMPONENTS_SOURCE_ID },
+        destination: { id: DASHBOARD_GRID_ID, type: DASHBOARD_GRID_TYPE },
+        dragging: { id: NEW_ROW_ID, type: ROW_TYPE },
+      };
+
+      const handleComponentDropThunk = handleComponentDrop(dropResult);
+      handleComponentDropThunk(dispatch, getState);
+
+      const createComponentThunk = dispatch.getCall(0).args[0];
+      createComponentThunk(dispatch, getState);
+
+      expect(dispatch.getCall(1).args[0]).to.deep.equal({
+        type: CREATE_COMPONENT,
+        payload: {
+          dropResult,
+        },
+      });
+
+      expect(dispatch.callCount).to.equal(2);
+    });
+
+    it('should move a component if the component is not new', () => {
+      const { getState, dispatch } = setup({
+        dashboardLayout: {
+          // if 'dragging' is not only child will dispatch deleteComponent thunk
+          present: { id: { type: ROW_TYPE, children: ['_'] } },
+        },
+      });
+      const dropResult = {
+        source: { id: 'id', index: 0, type: ROW_TYPE },
+        destination: { id: DASHBOARD_GRID_ID, type: DASHBOARD_GRID_TYPE },
+        dragging: { id: 'dragging', type: ROW_TYPE },
+      };
+
+      const handleComponentDropThunk = handleComponentDrop(dropResult);
+      handleComponentDropThunk(dispatch, getState);
+
+      const moveComponentThunk = dispatch.getCall(0).args[0];
+      moveComponentThunk(dispatch, getState);
+
+      expect(dispatch.getCall(1).args[0]).to.deep.equal({
+        type: MOVE_COMPONENT,
+        payload: {
+          dropResult,
+        },
+      });
+
+      expect(dispatch.callCount).to.equal(2);
+    });
+
+    it('should dispatch a toast if the drop overflows the destination', () => {
+      const { getState, dispatch } = setup({
+        dashboardLayout: {
+          present: {
+            source: { type: ROW_TYPE },
+            destination: { type: ROW_TYPE, children: ['rowChild'] },
+            dragging: { type: CHART_TYPE, meta: { width: 1 } },
+            rowChild: { type: CHART_TYPE, meta: { width: 12 } },
+          },
+        },
+      });
+      const dropResult = {
+        source: { id: 'source', type: ROW_TYPE },
+        destination: { id: 'destination', type: ROW_TYPE },
+        dragging: { id: 'dragging', type: CHART_TYPE },
+      };
+
+      const thunk = handleComponentDrop(dropResult);
+      thunk(dispatch, getState);
+      expect(dispatch.getCall(0).args[0].type).to.deep.equal(
+        addInfoToast('').type,
+      );
+
+      expect(dispatch.callCount).to.equal(1);
+    });
+
+    it('should delete a parent Row or Tabs if the moved child was the only child', () => {
+      const { getState, dispatch } = setup({
+        dashboardLayout: {
+          present: {
+            parentId: { id: 'parentId', children: ['tabsId'] },
+            tabsId: { id: 'tabsId', type: TABS_TYPE, children: [] },
+            [DASHBOARD_GRID_ID]: {
+              id: DASHBOARD_GRID_ID,
+              type: DASHBOARD_GRID_TYPE,
+            },
+            tabId: { id: 'tabId', type: TAB_TYPE },
+          },
+        },
+      });
+
+      const dropResult = {
+        source: { id: 'tabsId', type: TABS_TYPE },
+        destination: { id: DASHBOARD_GRID_ID, type: DASHBOARD_GRID_TYPE },
+        dragging: { id: 'tabId', type: TAB_TYPE },
+      };
+
+      const moveThunk = handleComponentDrop(dropResult);
+      moveThunk(dispatch, getState);
+
+      // first call is move action which is not a thunk
+      const deleteThunk = dispatch.getCall(1).args[0];
+      deleteThunk(dispatch, getState);
+
+      expect(dispatch.getCall(2).args[0]).to.deep.equal({
+        type: DELETE_COMPONENT,
+        payload: {
+          id: 'tabsId',
+          parentId: 'parentId',
+        },
+      });
+
+      // move thunk, delete thunk, delete result actions
+      expect(dispatch.callCount).to.equal(3);
+    });
+
+    it('should create top-level tabs if dropped on root', () => {
+      const { getState, dispatch } = setup();
+      const dropResult = {
+        source: { id: NEW_COMPONENTS_SOURCE_ID },
+        destination: { id: DASHBOARD_ROOT_ID },
+        dragging: { id: NEW_ROW_ID, type: ROW_TYPE },
+      };
+
+      const thunk1 = handleComponentDrop(dropResult);
+      thunk1(dispatch, getState);
+
+      const thunk2 = dispatch.getCall(0).args[0];
+      thunk2(dispatch, getState);
+
+      expect(dispatch.getCall(1).args[0]).to.deep.equal({
+        type: CREATE_TOP_LEVEL_TABS,
+        payload: {
+          dropResult,
+        },
+      });
+
+      expect(dispatch.callCount).to.equal(2);
+    });
+  });
+
+  describe('undoLayoutAction', () => {
+    it('should dispatch a redux-undo .undo() action ', () => {
+      const { getState, dispatch } = setup({
+        dashboardLayout: { past: ['non-empty'] },
+      });
+      const thunk = undoLayoutAction();
+      thunk(dispatch, getState);
+
+      expect(dispatch.callCount).to.equal(1);
+      expect(dispatch.getCall(0).args[0]).to.deep.equal(
+        UndoActionCreators.undo(),
+      );
+    });
+
+    it('should dispatch a setUnsavedChanges(false) action history length is zero', () => {
+      const { getState, dispatch } = setup({
+        dashboardLayout: { past: [] },
+      });
+      const thunk = undoLayoutAction();
+      thunk(dispatch, getState);
+
+      expect(dispatch.callCount).to.equal(2);
+      expect(dispatch.getCall(1).args[0]).to.deep.equal(
+        setUnsavedChanges(false),
+      );
+    });
+  });
+
+  describe('redoLayoutAction', () => {
+    it('should dispatch a redux-undo .redo() action ', () => {
+      const { getState, dispatch } = setup();
+      const thunk = redoLayoutAction();
+      thunk(dispatch, getState);
+
+      expect(dispatch.callCount).to.equal(1);
+      expect(dispatch.getCall(0).args[0]).to.deep.equal(
+        UndoActionCreators.redo(),
+      );
+    });
+
+    it('should dispatch a setUnsavedChanges(true) action if hasUnsavedChanges=false', () => {
+      const { getState, dispatch } = setup({
+        dashboardState: { hasUnsavedChanges: false },
+      });
+      const thunk = redoLayoutAction();
+      thunk(dispatch, getState);
+
+      expect(dispatch.callCount).to.equal(2);
+      expect(dispatch.getCall(1).args[0]).to.deep.equal(
+        setUnsavedChanges(true),
+      );
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/CodeModal_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/CodeModal_spec.jsx
similarity index 72%
rename from superset/assets/spec/javascripts/dashboard/CodeModal_spec.jsx
rename to superset/assets/spec/javascripts/dashboard/components/CodeModal_spec.jsx
index a93c557..d316dc3 100644
--- a/superset/assets/spec/javascripts/dashboard/CodeModal_spec.jsx
+++ b/superset/assets/spec/javascripts/dashboard/components/CodeModal_spec.jsx
@@ -3,16 +3,14 @@ import { mount } from 'enzyme';
 import { describe, it } from 'mocha';
 import { expect } from 'chai';
 
-import CodeModal from '../../../src/dashboard/components/CodeModal';
+import CodeModal from '../../../../src/dashboard/components/CodeModal';
 
 describe('CodeModal', () => {
   const mockedProps = {
     triggerNode: <i className="fa fa-edit" />,
   };
   it('is valid', () => {
-    expect(
-      React.isValidElement(<CodeModal {...mockedProps} />),
-    ).to.equal(true);
+    expect(React.isValidElement(<CodeModal {...mockedProps} />)).to.equal(true);
   });
   it('renders the trigger node', () => {
     const wrapper = mount(<CodeModal {...mockedProps} />);
diff --git a/superset/assets/spec/javascripts/dashboard/CssEditor_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/CssEditor_spec.jsx
similarity index 72%
rename from superset/assets/spec/javascripts/dashboard/CssEditor_spec.jsx
rename to superset/assets/spec/javascripts/dashboard/components/CssEditor_spec.jsx
index c325dc1..8c991fa 100644
--- a/superset/assets/spec/javascripts/dashboard/CssEditor_spec.jsx
+++ b/superset/assets/spec/javascripts/dashboard/components/CssEditor_spec.jsx
@@ -3,16 +3,14 @@ import { mount } from 'enzyme';
 import { describe, it } from 'mocha';
 import { expect } from 'chai';
 
-import CssEditor from '../../../src/dashboard/components/CssEditor';
+import CssEditor from '../../../../src/dashboard/components/CssEditor';
 
 describe('CssEditor', () => {
   const mockedProps = {
     triggerNode: <i className="fa fa-edit" />,
   };
   it('is valid', () => {
-    expect(
-      React.isValidElement(<CssEditor {...mockedProps} />),
-    ).to.equal(true);
+    expect(React.isValidElement(<CssEditor {...mockedProps} />)).to.equal(true);
   });
   it('renders the trigger node', () => {
     const wrapper = mount(<CssEditor {...mockedProps} />);
diff --git a/superset/assets/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx
new file mode 100644
index 0000000..4c3185f
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx
@@ -0,0 +1,138 @@
+import { Provider } from 'react-redux';
+import React from 'react';
+import { shallow, mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import ParentSize from '@vx/responsive/build/components/ParentSize';
+import { Sticky, StickyContainer } from 'react-sticky';
+import { TabContainer, TabContent, TabPane } from 'react-bootstrap';
+
+import BuilderComponentPane from '../../../../src/dashboard/components/BuilderComponentPane';
+import DashboardBuilder from '../../../../src/dashboard/components/DashboardBuilder';
+import DashboardComponent from '../../../../src/dashboard/containers/DashboardComponent';
+import DashboardHeader from '../../../../src/dashboard/containers/DashboardHeader';
+import DashboardGrid from '../../../../src/dashboard/containers/DashboardGrid';
+import WithDragDropContext from '../helpers/WithDragDropContext';
+import {
+  dashboardLayout as undoableDashboardLayout,
+  dashboardLayoutWithTabs as undoableDashboardLayoutWithTabs,
+} from '../fixtures/mockDashboardLayout';
+
+import { mockStore, mockStoreWithTabs } from '../fixtures/mockStore';
+
+const dashboardLayout = undoableDashboardLayout.present;
+const layoutWithTabs = undoableDashboardLayoutWithTabs.present;
+
+describe('DashboardBuilder', () => {
+  const props = {
+    dashboardLayout,
+    deleteTopLevelTabs() {},
+    editMode: false,
+    showBuilderPane: false,
+    handleComponentDrop() {},
+    toggleBuilderPane() {},
+  };
+
+  function setup(overrideProps, useProvider = false, store = mockStore) {
+    const builder = <DashboardBuilder {...props} {...overrideProps} />;
+    return useProvider
+      ? mount(
+          <Provider store={store}>
+            <WithDragDropContext>{builder}</WithDragDropContext>
+          </Provider>,
+        )
+      : shallow(builder);
+  }
+
+  it('should render a StickyContainer with class "dashboard"', () => {
+    const wrapper = setup();
+    const stickyContainer = wrapper.find(StickyContainer);
+    expect(stickyContainer).to.have.length(1);
+    expect(stickyContainer.prop('className')).to.equal('dashboard');
+  });
+
+  it('should add the "dashboard--editing" class if editMode=true', () => {
+    const wrapper = setup({ editMode: true });
+    const stickyContainer = wrapper.find(StickyContainer);
+    expect(stickyContainer.prop('className')).to.equal(
+      'dashboard dashboard--editing',
+    );
+  });
+
+  it('should render a DragDroppable DashboardHeader', () => {
+    const wrapper = setup(null, true);
+    expect(wrapper.find(DashboardHeader)).to.have.length(1);
+  });
+
+  it('should render a Sticky top-level Tabs if the dashboard has tabs', () => {
+    const wrapper = setup(
+      { dashboardLayout: layoutWithTabs },
+      true,
+      mockStoreWithTabs,
+    );
+    const sticky = wrapper.find(Sticky);
+    const dashboardComponent = sticky.find(DashboardComponent);
+
+    const tabChildren = layoutWithTabs.TABS_ID.children;
+    expect(sticky).to.have.length(1);
+    expect(dashboardComponent).to.have.length(1 + tabChildren.length); // tab + tabs
+    expect(dashboardComponent.at(0).prop('id')).to.equal('TABS_ID');
+    tabChildren.forEach((tabId, i) => {
+      expect(dashboardComponent.at(i + 1).prop('id')).to.equal(tabId);
+    });
+  });
+
+  it('should render a TabContainer and TabContent', () => {
+    const wrapper = setup({ dashboardLayout: layoutWithTabs });
+    const parentSize = wrapper.find(ParentSize).dive();
+    expect(parentSize.find(TabContainer)).to.have.length(1);
+    expect(parentSize.find(TabContent)).to.have.length(1);
+  });
+
+  it('should set animation=true, mountOnEnter=true, and unmounOnExit=false on TabContainer for perf', () => {
+    const wrapper = setup({ dashboardLayout: layoutWithTabs });
+    const tabProps = wrapper
+      .find(ParentSize)
+      .dive()
+      .find(TabContainer)
+      .props();
+    expect(tabProps.animation).to.equal(true);
+    expect(tabProps.mountOnEnter).to.equal(true);
+    expect(tabProps.unmountOnExit).to.equal(false);
+  });
+
+  it('should render a TabPane and DashboardGrid for each Tab', () => {
+    const wrapper = setup({ dashboardLayout: layoutWithTabs });
+    const parentSize = wrapper.find(ParentSize).dive();
+
+    const expectedCount = layoutWithTabs.TABS_ID.children.length;
+    expect(parentSize.find(TabPane)).to.have.length(expectedCount);
+    expect(parentSize.find(DashboardGrid)).to.have.length(expectedCount);
+  });
+
+  it('should render a BuilderComponentPane if editMode=showBuilderPane=true', () => {
+    const wrapper = setup();
+    expect(wrapper.find(BuilderComponentPane)).to.have.length(0);
+
+    wrapper.setProps({ ...props, editMode: true, showBuilderPane: true });
+    expect(wrapper.find(BuilderComponentPane)).to.have.length(1);
+  });
+
+  it('should change tabs if a top-level Tab is clicked', () => {
+    const wrapper = setup(
+      { dashboardLayout: layoutWithTabs },
+      true,
+      mockStoreWithTabs,
+    );
+
+    expect(wrapper.find(TabContainer).prop('activeKey')).to.equal(0);
+
+    wrapper
+      .find('.dashboard-component-tabs .nav-tabs a')
+      .at(1)
+      .simulate('click');
+
+    expect(wrapper.find(TabContainer).prop('activeKey')).to.equal(1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/DashboardGrid_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/DashboardGrid_spec.jsx
new file mode 100644
index 0000000..d11c37f
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/DashboardGrid_spec.jsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import DashboardComponent from '../../../../src/dashboard/containers/DashboardComponent';
+import DashboardGrid from '../../../../src/dashboard/components/DashboardGrid';
+import DragDroppable from '../../../../src/dashboard/components/dnd/DragDroppable';
+import newComponentFactory from '../../../../src/dashboard/util/newComponentFactory';
+
+import { DASHBOARD_GRID_TYPE } from '../../../../src/dashboard/util/componentTypes';
+import { GRID_COLUMN_COUNT } from '../../../../src/dashboard/util/constants';
+
+describe('DashboardGrid', () => {
+  const props = {
+    depth: 1,
+    editMode: false,
+    gridComponent: {
+      ...newComponentFactory(DASHBOARD_GRID_TYPE),
+      children: ['a'],
+    },
+    handleComponentDrop() {},
+    resizeComponent() {},
+    width: 500,
+  };
+
+  function setup(overrideProps) {
+    const wrapper = shallow(<DashboardGrid {...props} {...overrideProps} />);
+    return wrapper;
+  }
+
+  it('should render a div with class "dashboard-grid"', () => {
+    const wrapper = setup();
+    expect(wrapper.find('.dashboard-grid')).to.have.length(1);
+  });
+
+  it('should render one DashboardComponent for each gridComponent child', () => {
+    const wrapper = setup({
+      gridComponent: { ...props.gridComponent, children: ['a', 'b'] },
+    });
+    expect(wrapper.find(DashboardComponent)).to.have.length(2);
+  });
+
+  it('should render two empty DragDroppables in editMode to increase the drop target zone', () => {
+    const viewMode = setup({ editMode: false });
+    const editMode = setup({ editMode: true });
+    expect(viewMode.find(DragDroppable)).to.have.length(0);
+    expect(editMode.find(DragDroppable)).to.have.length(2);
+  });
+
+  it('should render grid column guides when resizing', () => {
+    const wrapper = setup({ editMode: true });
+    expect(wrapper.find('.grid-column-guide')).to.have.length(0);
+
+    wrapper.setState({ isResizing: true });
+
+    expect(wrapper.find('.grid-column-guide')).to.have.length(
+      GRID_COLUMN_COUNT,
+    );
+  });
+
+  it('should render a grid row guide when resizing', () => {
+    const wrapper = setup();
+    expect(wrapper.find('.grid-row-guide')).to.have.length(0);
+    wrapper.setState({ isResizing: true, rowGuideTop: 10 });
+    expect(wrapper.find('.grid-row-guide')).to.have.length(1);
+  });
+
+  it('should call resizeComponent when a child DashboardComponent calls resizeStop', () => {
+    const resizeComponent = sinon.spy();
+    const args = { id: 'id', widthMultiple: 1, heightMultiple: 3 };
+    const wrapper = setup({ resizeComponent });
+    const dashboardComponent = wrapper.find(DashboardComponent).first();
+    dashboardComponent.prop('onResizeStop')(args);
+
+    expect(resizeComponent.callCount).to.equal(1);
+    expect(resizeComponent.getCall(0).args[0]).to.deep.equal({
+      id: 'id',
+      width: 1,
+      height: 3,
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx
new file mode 100644
index 0000000..e855009
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx
@@ -0,0 +1,250 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import Dashboard from '../../../../src/dashboard/components/Dashboard';
+import DashboardBuilder from '../../../../src/dashboard/containers/DashboardBuilder';
+
+// mock data
+import chartQueries, { sliceId as chartId } from '../fixtures/mockChartQueries';
+import datasources from '../fixtures/mockDatasource';
+import dashboardInfo from '../fixtures/mockDashboardInfo';
+import { dashboardLayout } from '../fixtures/mockDashboardLayout';
+import dashboardState from '../fixtures/mockDashboardState';
+import { sliceEntitiesForChart as sliceEntities } from '../fixtures/mockSliceEntities';
+
+import { CHART_TYPE } from '../../../../src/dashboard/util/componentTypes';
+import newComponentFactory from '../../../../src/dashboard/util/newComponentFactory';
+
+describe('Dashboard', () => {
+  const props = {
+    actions: {
+      addSliceToDashboard() {},
+      removeSliceFromDashboard() {},
+      runQuery() {},
+    },
+    initMessages: [],
+    dashboardState,
+    dashboardInfo,
+    charts: chartQueries,
+    slices: sliceEntities.slices,
+    datasources,
+    layout: dashboardLayout.present,
+    timeout: 60,
+    userId: dashboardInfo.userId,
+    impressionId: 'id',
+    loadStats: {},
+  };
+
+  function setup(overrideProps) {
+    const wrapper = shallow(<Dashboard {...props} {...overrideProps} />);
+    return wrapper;
+  }
+
+  it('should render a DashboardBuilder', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DashboardBuilder)).to.have.length(1);
+  });
+
+  describe('refreshExcept', () => {
+    const overrideCharts = {
+      ...chartQueries,
+      1001: {
+        ...chartQueries[chartId],
+        id: 1001,
+      },
+    };
+
+    const overrideSlices = {
+      ...props.slices,
+      1001: {
+        ...props.slices[chartId],
+        slice_id: 1001,
+      },
+    };
+
+    it('should call runQuery for all non-exempt slices', () => {
+      const wrapper = setup({ charts: overrideCharts, slices: overrideSlices });
+      const spy = sinon.spy(props.actions, 'runQuery');
+      wrapper.instance().refreshExcept('1001');
+      spy.restore();
+      expect(spy.callCount).to.equal(Object.keys(overrideCharts).length - 1);
+    });
+
+    it('should not call runQuery for filter_immune_slices', () => {
+      const wrapper = setup({
+        charts: overrideCharts,
+        dashboardInfo: {
+          ...dashboardInfo,
+          metadata: {
+            ...dashboardInfo.metadata,
+            filter_immune_slices: Object.keys(overrideCharts).map(id =>
+              Number(id),
+            ),
+          },
+        },
+      });
+      const spy = sinon.spy(props.actions, 'runQuery');
+      wrapper.instance().refreshExcept();
+      spy.restore();
+      expect(spy.callCount).to.equal(0);
+    });
+  });
+
+  describe('componentWillReceiveProps', () => {
+    const layoutWithExtraChart = {
+      ...props.layout,
+      1001: newComponentFactory(CHART_TYPE, { chartId: 1001 }),
+    };
+
+    it('should call addSliceToDashboard if a new slice is added to the layout', () => {
+      const wrapper = setup();
+      const spy = sinon.spy(props.actions, 'addSliceToDashboard');
+      wrapper.instance().componentWillReceiveProps({
+        ...props,
+        layout: layoutWithExtraChart,
+      });
+      spy.restore();
+      expect(spy.callCount).to.equal(1);
+    });
+
+    it('should call removeSliceFromDashboard if a slice is removed from the layout', () => {
+      const wrapper = setup({ layout: layoutWithExtraChart });
+      const spy = sinon.spy(props.actions, 'removeSliceFromDashboard');
+      const nextLayout = { ...layoutWithExtraChart };
+      delete nextLayout[1001];
+
+      wrapper.instance().componentWillReceiveProps({
+        ...props,
+        layout: nextLayout,
+      });
+      spy.restore();
+      expect(spy.callCount).to.equal(1);
+    });
+  });
+
+  describe('componentDidUpdate', () => {
+    const overrideDashboardState = {
+      ...dashboardState,
+      filters: {
+        1: { region: [] },
+        2: { country_name: ['USA'] },
+      },
+      refresh: true,
+    };
+
+    it('should not call refresh when there is no change', () => {
+      const wrapper = setup({ dashboardState: overrideDashboardState });
+      const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
+      const prevProps = wrapper.instance().props;
+      wrapper.setProps({
+        dashboardState: {
+          ...overrideDashboardState,
+        },
+      });
+      wrapper.instance().componentDidUpdate(prevProps);
+      refreshExceptSpy.restore();
+      expect(refreshExceptSpy.callCount).to.equal(0);
+    });
+
+    it('should call refresh if a filter is added', () => {
+      const wrapper = setup({ dashboardState: overrideDashboardState });
+      const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
+      const prevProps = wrapper.instance().props;
+      wrapper.setProps({
+        dashboardState: {
+          ...overrideDashboardState,
+          filters: {
+            ...overrideDashboardState.filters,
+            3: { another_filter: ['please'] },
+          },
+        },
+      });
+      wrapper.instance().componentDidUpdate(prevProps);
+      refreshExceptSpy.restore();
+      expect(refreshExceptSpy.callCount).to.equal(1);
+    });
+
+    it('should call refresh if a filter is removed', () => {
+      const wrapper = setup({ dashboardState: overrideDashboardState });
+      const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
+      const prevProps = wrapper.instance().props;
+      wrapper.setProps({
+        dashboardState: {
+          ...overrideDashboardState,
+          filters: {},
+        },
+      });
+      wrapper.instance().componentDidUpdate(prevProps);
+      refreshExceptSpy.restore();
+      expect(refreshExceptSpy.callCount).to.equal(1);
+    });
+
+    it('should call refresh if a filter is changed', () => {
+      const wrapper = setup({ dashboardState: overrideDashboardState });
+      const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
+      const prevProps = wrapper.instance().props;
+      wrapper.setProps({
+        dashboardState: {
+          ...overrideDashboardState,
+          filters: {
+            ...overrideDashboardState.filters,
+            2: { country_name: ['Canada'] },
+          },
+        },
+      });
+      wrapper.instance().componentDidUpdate(prevProps);
+      refreshExceptSpy.restore();
+      expect(refreshExceptSpy.callCount).to.equal(1);
+    });
+
+    it('should not call refresh if filters change and refresh is false', () => {
+      const wrapper = setup({ dashboardState: overrideDashboardState });
+      const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
+      const prevProps = wrapper.instance().props;
+      wrapper.setProps({
+        dashboardState: {
+          ...overrideDashboardState,
+          filters: {
+            ...overrideDashboardState.filters,
+            2: { country_name: ['Canada'] },
+          },
+          refresh: false,
+        },
+      });
+      wrapper.instance().componentDidUpdate(prevProps);
+      refreshExceptSpy.restore();
+      expect(refreshExceptSpy.callCount).to.equal(0);
+    });
+
+    it('should not refresh filter_immune_slices', () => {
+      const wrapper = setup({
+        dashboardState: overrideDashboardState,
+        dashboardInfo: {
+          ...dashboardInfo,
+          metadata: {
+            ...dashboardInfo.metadata,
+            filter_immune_slices: [chartId],
+          },
+        },
+      });
+      const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
+      const prevProps = wrapper.instance().props;
+      wrapper.setProps({
+        dashboardState: {
+          ...overrideDashboardState,
+          filters: {
+            ...overrideDashboardState.filters,
+            2: { country_name: ['Canada'] },
+          },
+          refresh: false,
+        },
+      });
+      wrapper.instance().componentDidUpdate(prevProps);
+      refreshExceptSpy.restore();
+      expect(refreshExceptSpy.callCount).to.equal(0);
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/RefreshIntervalModal_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/RefreshIntervalModal_spec.jsx
similarity index 85%
rename from superset/assets/spec/javascripts/dashboard/RefreshIntervalModal_spec.jsx
rename to superset/assets/spec/javascripts/dashboard/components/RefreshIntervalModal_spec.jsx
index 3a2f700..564857c 100644
--- a/superset/assets/spec/javascripts/dashboard/RefreshIntervalModal_spec.jsx
+++ b/superset/assets/spec/javascripts/dashboard/components/RefreshIntervalModal_spec.jsx
@@ -3,7 +3,7 @@ import { mount } from 'enzyme';
 import { describe, it } from 'mocha';
 import { expect } from 'chai';
 
-import RefreshIntervalModal from '../../../src/dashboard/components/RefreshIntervalModal';
+import RefreshIntervalModal from '../../../../src/dashboard/components/RefreshIntervalModal';
 
 describe('RefreshIntervalModal', () => {
   const mockedProps = {
diff --git a/superset/assets/spec/javascripts/dashboard/components/SliceAdder_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/SliceAdder_spec.jsx
new file mode 100644
index 0000000..da0f7df
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/SliceAdder_spec.jsx
@@ -0,0 +1,154 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it, beforeEach, afterEach } from 'mocha';
+import sinon from 'sinon';
+import { expect } from 'chai';
+
+import { List } from 'react-virtualized';
+
+import SliceAdder from '../../../../src/dashboard/components/SliceAdder';
+import { sliceEntitiesForDashboard as mockSliceEntities } from '../fixtures/mockSliceEntities';
+
+describe('SliceAdder', () => {
+  const mockEvent = {
+    key: 'Enter',
+    target: {
+      value: 'mock event target',
+    },
+    preventDefault: () => {},
+  };
+  const props = {
+    ...mockSliceEntities,
+    fetchAllSlices: () => {},
+    selectedSliceIds: [127, 128],
+    userId: '1',
+    height: 100,
+  };
+  const errorProps = {
+    ...props,
+    errorMessage: 'this is error',
+  };
+
+  describe('SliceAdder.sortByComparator', () => {
+    it('should sort by timestamp descending', () => {
+      const sortedTimestamps = Object.values(props.slices)
+        .sort(SliceAdder.sortByComparator('changed_on'))
+        .map(slice => slice.changed_on);
+      expect(
+        sortedTimestamps.every((currentTimestamp, index) => {
+          if (index === 0) {
+            return true;
+          }
+          return currentTimestamp < sortedTimestamps[index - 1];
+        }),
+      ).to.equal(true);
+    });
+
+    it('should sort by slice_name', () => {
+      const sortedNames = Object.values(props.slices)
+        .sort(SliceAdder.sortByComparator('slice_name'))
+        .map(slice => slice.slice_name);
+      const expectedNames = Object.values(props.slices)
+        .map(slice => slice.slice_name)
+        .sort();
+      expect(sortedNames).to.deep.equal(expectedNames);
+    });
+  });
+
+  it('render List', () => {
+    const wrapper = shallow(<SliceAdder {...props} />);
+    wrapper.setState({ filteredSlices: Object.values(props.slices) });
+    expect(wrapper.find(List)).to.have.length(1);
+  });
+
+  it('render error', () => {
+    const wrapper = shallow(<SliceAdder {...errorProps} />);
+    wrapper.setState({ filteredSlices: Object.values(props.slices) });
+    expect(wrapper.text()).to.have.string(errorProps.errorMessage);
+  });
+
+  it('componentDidMount', () => {
+    sinon.spy(SliceAdder.prototype, 'componentDidMount');
+    sinon.spy(props, 'fetchAllSlices');
+
+    shallow(<SliceAdder {...props} />, {
+      lifecycleExperimental: true,
+    });
+    expect(SliceAdder.prototype.componentDidMount.calledOnce).to.equal(true);
+    expect(props.fetchAllSlices.calledOnce).to.equal(true);
+
+    SliceAdder.prototype.componentDidMount.restore();
+    props.fetchAllSlices.restore();
+  });
+
+  describe('componentWillReceiveProps', () => {
+    let wrapper;
+    beforeEach(() => {
+      wrapper = shallow(<SliceAdder {...props} />);
+      wrapper.setState({ filteredSlices: Object.values(props.slices) });
+      sinon.spy(wrapper.instance(), 'setState');
+    });
+    afterEach(() => {
+      wrapper.instance().setState.restore();
+    });
+
+    it('fetch slices should update state', () => {
+      wrapper.instance().componentWillReceiveProps({
+        ...props,
+        lastUpdated: new Date().getTime(),
+      });
+      expect(wrapper.instance().setState.calledOnce).to.equal(true);
+
+      const stateKeys = Object.keys(
+        wrapper.instance().setState.lastCall.args[0],
+      );
+      expect(stateKeys).to.include('filteredSlices');
+    });
+
+    it('select slices should update state', () => {
+      wrapper.instance().componentWillReceiveProps({
+        ...props,
+        selectedSliceIds: [127],
+      });
+      expect(wrapper.instance().setState.calledOnce).to.equal(true);
+
+      const stateKeys = Object.keys(
+        wrapper.instance().setState.lastCall.args[0],
+      );
+      expect(stateKeys).to.include('selectedSliceIdsSet');
+    });
+  });
+
+  describe('should rerun filter and sort', () => {
+    let wrapper;
+    let spy;
+    beforeEach(() => {
+      wrapper = shallow(<SliceAdder {...props} />);
+      wrapper.setState({ filteredSlices: Object.values(props.slices) });
+      spy = sinon.spy(wrapper.instance(), 'getFilteredSortedSlices');
+    });
+    afterEach(() => {
+      spy.restore();
+    });
+
+    it('searchUpdated', () => {
+      const newSearchTerm = 'new search term';
+      wrapper.instance().searchUpdated(newSearchTerm);
+      expect(spy.calledOnce).to.equal(true);
+      expect(spy.lastCall.args[0]).to.equal(newSearchTerm);
+    });
+
+    it('handleSelect', () => {
+      const newSortBy = 1;
+      wrapper.instance().handleSelect(newSortBy);
+      expect(spy.calledOnce).to.equal(true);
+      expect(spy.lastCall.args[1]).to.equal(newSortBy);
+    });
+
+    it('handleKeyPress', () => {
+      wrapper.instance().handleKeyPress(mockEvent);
+      expect(spy.calledOnce).to.equal(true);
+      expect(spy.lastCall.args[0]).to.equal(mockEvent.target.value);
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/ToastPresenter_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/ToastPresenter_spec.jsx
new file mode 100644
index 0000000..7545ad6
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/ToastPresenter_spec.jsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import mockMessageToasts from '../fixtures/mockMessageToasts';
+import Toast from '../../../../src/dashboard/components/Toast';
+import ToastPresenter from '../../../../src/dashboard/components/ToastPresenter';
+
+describe('ToastPresenter', () => {
+  const props = {
+    toasts: mockMessageToasts,
+    removeToast() {},
+  };
+
+  function setup(overrideProps) {
+    const wrapper = shallow(<ToastPresenter {...props} {...overrideProps} />);
+    return wrapper;
+  }
+
+  it('should render a div with class toast-presenter', () => {
+    const wrapper = setup();
+    expect(wrapper.find('.toast-presenter')).to.have.length(1);
+  });
+
+  it('should render a Toast for each toast object', () => {
+    const wrapper = setup();
+    expect(wrapper.find(Toast)).to.have.length(props.toasts.length);
+  });
+
+  it('should pass removeToast to the Toast component', () => {
+    const removeToast = () => {};
+    const wrapper = setup({ removeToast });
+    expect(
+      wrapper
+        .find(Toast)
+        .first()
+        .prop('onCloseToast'),
+    ).to.equal(removeToast);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/Toast_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/Toast_spec.jsx
new file mode 100644
index 0000000..6ed0bc5
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/Toast_spec.jsx
@@ -0,0 +1,43 @@
+import { Alert } from 'react-bootstrap';
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import mockMessageToasts from '../fixtures/mockMessageToasts';
+import Toast from '../../../../src/dashboard/components/Toast';
+
+describe('Toast', () => {
+  const props = {
+    toast: mockMessageToasts[0],
+    onCloseToast() {},
+  };
+
+  function setup(overrideProps) {
+    const wrapper = shallow(<Toast {...props} {...overrideProps} />);
+    return wrapper;
+  }
+
+  it('should render an Alert', () => {
+    const wrapper = setup();
+    expect(wrapper.find(Alert)).to.have.length(1);
+  });
+
+  it('should render toastText within the alert', () => {
+    const wrapper = setup();
+    const alert = wrapper.find(Alert).dive();
+
+    expect(alert.childAt(1).text()).to.equal(props.toast.text);
+  });
+
+  it('should call onCloseToast upon alert dismissal', done => {
+    const onCloseToast = id => {
+      expect(id).to.equal(props.toast.id);
+      done();
+    };
+    const wrapper = setup({ onCloseToast });
+    const handleClosePress = wrapper.instance().handleClosePress;
+    expect(wrapper.find(Alert).prop('onDismiss')).to.equal(handleClosePress);
+    handleClosePress(); // there is a timeout for onCloseToast to be called
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/dnd/DragDroppable_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/dnd/DragDroppable_spec.jsx
new file mode 100644
index 0000000..c7e2c2a
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/dnd/DragDroppable_spec.jsx
@@ -0,0 +1,90 @@
+import React from 'react';
+import { shallow, mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import newComponentFactory from '../../../../../src/dashboard/util/newComponentFactory';
+import {
+  CHART_TYPE,
+  ROW_TYPE,
+} from '../../../../../src/dashboard/util/componentTypes';
+import { UnwrappedDragDroppable as DragDroppable } from '../../../../../src/dashboard/components/dnd/DragDroppable';
+
+describe('DragDroppable', () => {
+  const props = {
+    component: newComponentFactory(CHART_TYPE),
+    parentComponent: newComponentFactory(ROW_TYPE),
+    editMode: false,
+    depth: 1,
+    index: 0,
+    isDragging: false,
+    isDraggingOver: false,
+    isDraggingOverShallow: false,
+    droppableRef() {},
+    dragSourceRef() {},
+    dragPreviewRef() {},
+  };
+
+  function setup(overrideProps, shouldMount = false) {
+    const method = shouldMount ? mount : shallow;
+    const wrapper = method(<DragDroppable {...props} {...overrideProps} />);
+    return wrapper;
+  }
+
+  it('should render a div with class dragdroppable', () => {
+    const wrapper = setup();
+    expect(wrapper.find('.dragdroppable')).to.have.length(1);
+  });
+
+  it('should add class dragdroppable--dragging when dragging', () => {
+    const wrapper = setup({ isDragging: true });
+    expect(wrapper.find('.dragdroppable')).to.have.length(1);
+  });
+
+  it('should call its child function', () => {
+    const childrenSpy = sinon.spy();
+    setup({ children: childrenSpy });
+    expect(childrenSpy.callCount).to.equal(1);
+  });
+
+  it('should call its child function with "dragSourceRef" if editMode=true', () => {
+    const children = sinon.spy();
+    const dragSourceRef = () => {};
+    setup({ children, editMode: false, dragSourceRef });
+    setup({ children, editMode: true, dragSourceRef });
+
+    expect(children.getCall(0).args[0].dragSourceRef).to.equal(undefined);
+    expect(children.getCall(1).args[0].dragSourceRef).to.equal(dragSourceRef);
+  });
+
+  it('should call its child function with "dropIndicatorProps" dependent on editMode, isDraggingOver, state.dropIndicator is set', () => {
+    const children = sinon.spy();
+    const wrapper = setup({ children, editMode: false, isDraggingOver: false });
+    wrapper.setState({ dropIndicator: 'nonsense' });
+    wrapper.setProps({ ...props, editMode: true, isDraggingOver: true });
+
+    expect(children.callCount).to.equal(3); // initial + setState + setProps
+    expect(children.getCall(0).args[0].dropIndicatorProps).to.equal(undefined);
+    expect(children.getCall(2).args[0].dropIndicatorProps).to.deep.equal({
+      className: 'drop-indicator',
+    });
+  });
+
+  it('should call props.dragPreviewRef and props.droppableRef on mount', () => {
+    const dragPreviewRef = sinon.spy();
+    const droppableRef = sinon.spy();
+
+    setup({ dragPreviewRef, droppableRef }, true);
+    expect(dragPreviewRef.callCount).to.equal(1);
+    expect(droppableRef.callCount).to.equal(1);
+  });
+
+  it('should set this.mounted dependent on life cycle', () => {
+    const wrapper = setup({}, true);
+    const instance = wrapper.instance();
+    expect(instance.mounted).to.equal(true);
+    wrapper.unmount();
+    expect(instance.mounted).to.equal(false);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/ChartHolder_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/ChartHolder_spec.jsx
new file mode 100644
index 0000000..821b637
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/ChartHolder_spec.jsx
@@ -0,0 +1,112 @@
+import { Provider } from 'react-redux';
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import Chart from '../../../../../src/dashboard/containers/Chart';
+import ChartHolder from '../../../../../src/dashboard/components/gridComponents/ChartHolder';
+import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
+import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
+import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
+import ResizableContainer from '../../../../../src/dashboard/components/resizable/ResizableContainer';
+
+import { mockStore } from '../../fixtures/mockStore';
+import { sliceId } from '../../fixtures/mockSliceEntities';
+import { dashboardLayout as mockLayout } from '../../fixtures/mockDashboardLayout';
+import WithDragDropContext from '../../helpers/WithDragDropContext';
+
+describe('ChartHolder', () => {
+  const props = {
+    id: String(sliceId),
+    parentId: 'ROW_ID',
+    component: mockLayout.present.CHART_ID,
+    depth: 2,
+    parentComponent: mockLayout.present.ROW_ID,
+    index: 0,
+    editMode: false,
+    availableColumnCount: 12,
+    columnWidth: 50,
+    onResizeStart() {},
+    onResize() {},
+    onResizeStop() {},
+    handleComponentDrop() {},
+    updateComponents() {},
+    deleteComponent() {},
+  };
+
+  function setup(overrideProps) {
+    // We have to wrap provide DragDropContext for the underlying DragDroppable
+    // otherwise we cannot assert on DragDroppable children
+    const wrapper = mount(
+      <Provider store={mockStore}>
+        <WithDragDropContext>
+          <ChartHolder {...props} {...overrideProps} />
+        </WithDragDropContext>
+      </Provider>,
+    );
+    return wrapper;
+  }
+
+  it('should render a DragDroppable', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DragDroppable)).to.have.length(1);
+  });
+
+  it('should render a ResizableContainer', () => {
+    const wrapper = setup();
+    expect(wrapper.find(ResizableContainer)).to.have.length(1);
+  });
+
+  it('should only have an adjustableWidth if its parent is a Row', () => {
+    let wrapper = setup();
+    expect(wrapper.find(ResizableContainer).prop('adjustableWidth')).to.equal(
+      true,
+    );
+
+    wrapper = setup({ ...props, parentComponent: mockLayout.present.CHART_ID });
+    expect(wrapper.find(ResizableContainer).prop('adjustableWidth')).to.equal(
+      false,
+    );
+  });
+
+  it('should pass correct props to ResizableContainer', () => {
+    const wrapper = setup();
+    const resizableProps = wrapper.find(ResizableContainer).props();
+    expect(resizableProps.widthStep).to.equal(props.columnWidth);
+    expect(resizableProps.widthMultiple).to.equal(props.component.meta.width);
+    expect(resizableProps.heightMultiple).to.equal(props.component.meta.height);
+    expect(resizableProps.maxWidthMultiple).to.equal(
+      props.component.meta.width + props.availableColumnCount,
+    );
+  });
+
+  it('should render a div with class "dashboard-component-chart-holder"', () => {
+    const wrapper = setup();
+    expect(wrapper.find('.dashboard-component-chart-holder')).to.have.length(1);
+  });
+
+  it('should render a Chart', () => {
+    const wrapper = setup();
+    expect(wrapper.find(Chart)).to.have.length(1);
+  });
+
+  it('should render a HoverMenu with DeleteComponentButton in editMode', () => {
+    let wrapper = setup();
+    expect(wrapper.find(HoverMenu)).to.have.length(0);
+    expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
+
+    // we cannot set props on the Divider because of the WithDragDropContext wrapper
+    wrapper = setup({ editMode: true });
+    expect(wrapper.find(HoverMenu)).to.have.length(1);
+    expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
+  });
+
+  it('should call deleteComponent when deleted', () => {
+    const deleteComponent = sinon.spy();
+    const wrapper = setup({ editMode: true, deleteComponent });
+    wrapper.find(DeleteComponentButton).simulate('click');
+    expect(deleteComponent.callCount).to.equal(1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx
new file mode 100644
index 0000000..dcd7119
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import Chart from '../../../../../src/dashboard/components/gridComponents/Chart';
+import SliceHeader from '../../../../../src/dashboard/components/SliceHeader';
+import ChartContainer from '../../../../../src/chart/ChartContainer';
+
+import mockDatasource from '../../fixtures/mockDatasource';
+import {
+  sliceEntitiesForChart as sliceEntities,
+  sliceId,
+} from '../../fixtures/mockSliceEntities';
+import chartQueries, {
+  sliceId as queryId,
+} from '../../fixtures/mockChartQueries';
+
+describe('Chart', () => {
+  const props = {
+    id: sliceId,
+    width: 100,
+    height: 100,
+    updateSliceName() {},
+
+    // from redux
+    chart: chartQueries[queryId],
+    formData: chartQueries[queryId].formData,
+    datasource: mockDatasource[sliceEntities.slices[sliceId].datasource],
+    slice: {
+      ...sliceEntities.slices[sliceId],
+      description_markeddown: 'markdown',
+    },
+    sliceName: sliceEntities.slices[sliceId].slice_name,
+    timeout: 60,
+    filters: {},
+    refreshChart() {},
+    toggleExpandSlice() {},
+    addFilter() {},
+    editMode: false,
+    isExpanded: false,
+    supersetCanExplore: false,
+    sliceCanEdit: false,
+  };
+
+  function setup(overrideProps) {
+    const wrapper = shallow(<Chart {...props} {...overrideProps} />);
+    return wrapper;
+  }
+
+  it('should render a SliceHeader', () => {
+    const wrapper = setup();
+    expect(wrapper.find(SliceHeader)).to.have.length(1);
+  });
+
+  it('should render a ChartContainer', () => {
+    const wrapper = setup();
+    expect(wrapper.find(ChartContainer)).to.have.length(1);
+  });
+
+  it('should render a description if it has one and isExpanded=true', () => {
+    const wrapper = setup();
+    expect(wrapper.find('.slice_description')).to.have.length(0);
+
+    wrapper.setProps({ ...props, isExpanded: true });
+    expect(wrapper.find('.slice_description')).to.have.length(1);
+  });
+
+  it('should call refreshChart when SliceHeader calls forceRefresh', () => {
+    const refreshChart = sinon.spy();
+    const wrapper = setup({ refreshChart });
+    wrapper.instance().forceRefresh();
+    expect(refreshChart.callCount).to.equal(1);
+  });
+
+  it('should call addFilter when ChartContainer calls addFilter', () => {
+    const addFilter = sinon.spy();
+    const wrapper = setup({ addFilter });
+    wrapper.instance().addFilter();
+    expect(addFilter.callCount).to.equal(1);
+  });
+
+  it('should return props.filters when its getFilters method is called', () => {
+    const filters = { column: ['value'] };
+    const wrapper = setup({ filters });
+    expect(wrapper.instance().getFilters()).to.equal(filters);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Column_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Column_spec.jsx
new file mode 100644
index 0000000..e97414b
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Column_spec.jsx
@@ -0,0 +1,144 @@
+import { Provider } from 'react-redux';
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import BackgroundStyleDropdown from '../../../../../src/dashboard/components/menu/BackgroundStyleDropdown';
+import Column from '../../../../../src/dashboard/components/gridComponents/Column';
+import DashboardComponent from '../../../../../src/dashboard/containers/DashboardComponent';
+import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
+import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
+import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
+import IconButton from '../../../../../src/dashboard/components/IconButton';
+import ResizableContainer from '../../../../../src/dashboard/components/resizable/ResizableContainer';
+import WithPopoverMenu from '../../../../../src/dashboard/components/menu/WithPopoverMenu';
+
+import { mockStore } from '../../fixtures/mockStore';
+import { dashboardLayout as mockLayout } from '../../fixtures/mockDashboardLayout';
+import WithDragDropContext from '../../helpers/WithDragDropContext';
+
+describe('Column', () => {
+  const columnWithoutChildren = {
+    ...mockLayout.present.COLUMN_ID,
+    children: [],
+  };
+  const props = {
+    id: 'COLUMN_ID',
+    parentId: 'ROW_ID',
+    component: mockLayout.present.COLUMN_ID,
+    parentComponent: mockLayout.present.ROW_ID,
+    index: 0,
+    depth: 2,
+    editMode: false,
+    availableColumnCount: 12,
+    minColumnWidth: 2,
+    columnWidth: 50,
+    occupiedColumnCount: 6,
+    onResizeStart() {},
+    onResize() {},
+    onResizeStop() {},
+    handleComponentDrop() {},
+    deleteComponent() {},
+    updateComponents() {},
+  };
+
+  function setup(overrideProps) {
+    // We have to wrap provide DragDropContext for the underlying DragDroppable
+    // otherwise we cannot assert on DragDroppable children
+    const wrapper = mount(
+      <Provider store={mockStore}>
+        <WithDragDropContext>
+          <Column {...props} {...overrideProps} />
+        </WithDragDropContext>
+      </Provider>,
+    );
+    return wrapper;
+  }
+
+  it('should render a DragDroppable', () => {
+    // don't count child DragDroppables
+    const wrapper = setup({ component: columnWithoutChildren });
+    expect(wrapper.find(DragDroppable)).to.have.length(1);
+  });
+
+  it('should render a WithPopoverMenu', () => {
+    // don't count child DragDroppables
+    const wrapper = setup({ component: columnWithoutChildren });
+    expect(wrapper.find(WithPopoverMenu)).to.have.length(1);
+  });
+
+  it('should render a ResizableContainer', () => {
+    // don't count child DragDroppables
+    const wrapper = setup({ component: columnWithoutChildren });
+    expect(wrapper.find(ResizableContainer)).to.have.length(1);
+  });
+
+  it('should render a HoverMenu in editMode', () => {
+    let wrapper = setup({ component: columnWithoutChildren });
+    expect(wrapper.find(HoverMenu)).to.have.length(0);
+
+    // we cannot set props on the Row because of the WithDragDropContext wrapper
+    wrapper = setup({ component: columnWithoutChildren, editMode: true });
+    expect(wrapper.find(HoverMenu)).to.have.length(1);
+  });
+
+  it('should render a DeleteComponentButton in editMode', () => {
+    let wrapper = setup({ component: columnWithoutChildren });
+    expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
+
+    // we cannot set props on the Row because of the WithDragDropContext wrapper
+    wrapper = setup({ component: columnWithoutChildren, editMode: true });
+    expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
+  });
+
+  it('should render a BackgroundStyleDropdown when focused', () => {
+    let wrapper = setup({ component: columnWithoutChildren });
+    expect(wrapper.find(BackgroundStyleDropdown)).to.have.length(0);
+
+    // we cannot set props on the Row because of the WithDragDropContext wrapper
+    wrapper = setup({ component: columnWithoutChildren, editMode: true });
+    wrapper
+      .find(IconButton)
+      .at(1) // first one is delete button
+      .simulate('click');
+
+    expect(wrapper.find(BackgroundStyleDropdown)).to.have.length(1);
+  });
+
+  it('should call deleteComponent when deleted', () => {
+    const deleteComponent = sinon.spy();
+    const wrapper = setup({ editMode: true, deleteComponent });
+    wrapper.find(DeleteComponentButton).simulate('click');
+    expect(deleteComponent.callCount).to.equal(1);
+  });
+
+  it('should pass its own width as availableColumnCount to children', () => {
+    const wrapper = setup();
+    const dashboardComponent = wrapper.find(DashboardComponent).first();
+    expect(dashboardComponent.props().availableColumnCount).to.equal(
+      props.component.meta.width,
+    );
+  });
+
+  it('should pass appropriate dimensions to ResizableContainer', () => {
+    const wrapper = setup({ component: columnWithoutChildren });
+    const columnWidth = columnWithoutChildren.meta.width;
+    const resizableProps = wrapper.find(ResizableContainer).props();
+    expect(resizableProps.adjustableWidth).to.equal(true);
+    expect(resizableProps.adjustableHeight).to.equal(false);
+    expect(resizableProps.widthStep).to.equal(props.columnWidth);
+    expect(resizableProps.widthMultiple).to.equal(columnWidth);
+    expect(resizableProps.minWidthMultiple).to.equal(props.minColumnWidth);
+    expect(resizableProps.maxWidthMultiple).to.equal(
+      props.availableColumnCount + columnWidth,
+    );
+  });
+
+  it('should increment the depth of its children', () => {
+    const wrapper = setup();
+    const dashboardComponent = wrapper.find(DashboardComponent);
+    expect(dashboardComponent.props().depth).to.equal(props.depth + 1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Divider_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Divider_spec.jsx
new file mode 100644
index 0000000..c8317f8
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Divider_spec.jsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
+import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
+import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
+import Divider from '../../../../../src/dashboard/components/gridComponents/Divider';
+import newComponentFactory from '../../../../../src/dashboard/util/newComponentFactory';
+import {
+  DIVIDER_TYPE,
+  DASHBOARD_GRID_TYPE,
+} from '../../../../../src/dashboard/util/componentTypes';
+
+import WithDragDropContext from '../../helpers/WithDragDropContext';
+
+describe('Divider', () => {
+  const props = {
+    id: 'id',
+    parentId: 'parentId',
+    component: newComponentFactory(DIVIDER_TYPE),
+    depth: 1,
+    parentComponent: newComponentFactory(DASHBOARD_GRID_TYPE),
+    index: 0,
+    editMode: false,
+    handleComponentDrop() {},
+    deleteComponent() {},
+  };
+
+  function setup(overrideProps) {
+    // We have to wrap provide DragDropContext for the underlying DragDroppable
+    // otherwise we cannot assert on DragDroppable children
+    const wrapper = mount(
+      <WithDragDropContext>
+        <Divider {...props} {...overrideProps} />
+      </WithDragDropContext>,
+    );
+    return wrapper;
+  }
+
+  it('should render a DragDroppable', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DragDroppable)).to.have.length(1);
+  });
+
+  it('should render a div with class "dashboard-component-divider"', () => {
+    const wrapper = setup();
+    expect(wrapper.find('.dashboard-component-divider')).to.have.length(1);
+  });
+
+  it('should render a HoverMenu with DeleteComponentButton in editMode', () => {
+    let wrapper = setup();
+    expect(wrapper.find(HoverMenu)).to.have.length(0);
+    expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
+
+    // we cannot set props on the Divider because of the WithDragDropContext wrapper
+    wrapper = setup({ editMode: true });
+    expect(wrapper.find(HoverMenu)).to.have.length(1);
+    expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
+  });
+
+  it('should call deleteComponent when deleted', () => {
+    const deleteComponent = sinon.spy();
+    const wrapper = setup({ editMode: true, deleteComponent });
+    wrapper.find(DeleteComponentButton).simulate('click');
+    expect(deleteComponent.callCount).to.equal(1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Header_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Header_spec.jsx
new file mode 100644
index 0000000..1d54775
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Header_spec.jsx
@@ -0,0 +1,100 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
+import EditableTitle from '../../../../../src/components/EditableTitle';
+import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
+import WithPopoverMenu from '../../../../../src/dashboard/components/menu/WithPopoverMenu';
+import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
+import Header from '../../../../../src/dashboard/components/gridComponents/Header';
+import newComponentFactory from '../../../../../src/dashboard/util/newComponentFactory';
+import {
+  HEADER_TYPE,
+  DASHBOARD_GRID_TYPE,
+} from '../../../../../src/dashboard/util/componentTypes';
+
+import WithDragDropContext from '../../helpers/WithDragDropContext';
+
+describe('Header', () => {
+  const props = {
+    id: 'id',
+    parentId: 'parentId',
+    component: newComponentFactory(HEADER_TYPE),
+    depth: 1,
+    parentComponent: newComponentFactory(DASHBOARD_GRID_TYPE),
+    index: 0,
+    editMode: false,
+    handleComponentDrop() {},
+    deleteComponent() {},
+    updateComponents() {},
+  };
+
+  function setup(overrideProps) {
+    // We have to wrap provide DragDropContext for the underlying DragDroppable
+    // otherwise we cannot assert on DragDroppable children
+    const wrapper = mount(
+      <WithDragDropContext>
+        <Header {...props} {...overrideProps} />
+      </WithDragDropContext>,
+    );
+    return wrapper;
+  }
+
+  it('should render a DragDroppable', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DragDroppable)).to.have.length(1);
+  });
+
+  it('should render a WithPopoverMenu', () => {
+    const wrapper = setup();
+    expect(wrapper.find(WithPopoverMenu)).to.have.length(1);
+  });
+
+  it('should render a HoverMenu in editMode', () => {
+    let wrapper = setup();
+    expect(wrapper.find(HoverMenu)).to.have.length(0);
+
+    // we cannot set props on the Header because of the WithDragDropContext wrapper
+    wrapper = setup({ editMode: true });
+    expect(wrapper.find(HoverMenu)).to.have.length(1);
+  });
+
+  it('should render an EditableTitle with meta.text', () => {
+    const wrapper = setup();
+    expect(wrapper.find(EditableTitle)).to.have.length(1);
+    expect(wrapper.find('input').prop('value')).to.equal(
+      props.component.meta.text,
+    );
+  });
+
+  it('should call updateComponents when EditableTitle changes', () => {
+    const updateComponents = sinon.spy();
+    const wrapper = setup({ editMode: true, updateComponents });
+    wrapper.find(EditableTitle).prop('onSaveTitle')('New title');
+
+    const headerId = props.component.id;
+    expect(updateComponents.callCount).to.equal(1);
+    expect(updateComponents.getCall(0).args[0][headerId].meta.text).to.equal(
+      'New title',
+    );
+  });
+
+  it('should render a DeleteComponentButton when focused in editMode', () => {
+    const wrapper = setup({ editMode: true });
+    wrapper.find(WithPopoverMenu).simulate('click'); // focus
+
+    expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
+  });
+
+  it('should call deleteComponent when deleted', () => {
+    const deleteComponent = sinon.spy();
+    const wrapper = setup({ editMode: true, deleteComponent });
+    wrapper.find(WithPopoverMenu).simulate('click'); // focus
+    wrapper.find(DeleteComponentButton).simulate('click');
+
+    expect(deleteComponent.callCount).to.equal(1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Markdown_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Markdown_spec.jsx
new file mode 100644
index 0000000..ca71045
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Markdown_spec.jsx
@@ -0,0 +1,156 @@
+import { Provider } from 'react-redux';
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+import AceEditor from 'react-ace';
+import ReactMarkdown from 'react-markdown';
+
+import Markdown from '../../../../../src/dashboard/components/gridComponents/Markdown';
+import MarkdownModeDropdown from '../../../../../src/dashboard/components/menu/MarkdownModeDropdown';
+import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
+import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
+import WithPopoverMenu from '../../../../../src/dashboard/components/menu/WithPopoverMenu';
+import ResizableContainer from '../../../../../src/dashboard/components/resizable/ResizableContainer';
+
+import { mockStore } from '../../fixtures/mockStore';
+import { dashboardLayout as mockLayout } from '../../fixtures/mockDashboardLayout';
+import WithDragDropContext from '../../helpers/WithDragDropContext';
+
+describe('Markdown', () => {
+  const props = {
+    id: 'id',
+    parentId: 'parentId',
+    component: mockLayout.present.MARKDOWN_ID,
+    depth: 2,
+    parentComponent: mockLayout.present.ROW_ID,
+    index: 0,
+    editMode: false,
+    availableColumnCount: 12,
+    columnWidth: 50,
+    onResizeStart() {},
+    onResize() {},
+    onResizeStop() {},
+    handleComponentDrop() {},
+    updateComponents() {},
+    deleteComponent() {},
+  };
+
+  function setup(overrideProps) {
+    // We have to wrap provide DragDropContext for the underlying DragDroppable
+    // otherwise we cannot assert on DragDroppable children
+    const wrapper = mount(
+      <Provider store={mockStore}>
+        <WithDragDropContext>
+          <Markdown {...props} {...overrideProps} />
+        </WithDragDropContext>
+      </Provider>,
+    );
+    return wrapper;
+  }
+
+  it('should render a DragDroppable', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DragDroppable)).to.have.length(1);
+  });
+
+  it('should render a WithPopoverMenu', () => {
+    const wrapper = setup();
+    expect(wrapper.find(WithPopoverMenu)).to.have.length(1);
+  });
+
+  it('should render a ResizableContainer', () => {
+    const wrapper = setup();
+    expect(wrapper.find(ResizableContainer)).to.have.length(1);
+  });
+
+  it('should only have an adjustableWidth if its parent is a Row', () => {
+    let wrapper = setup();
+    expect(wrapper.find(ResizableContainer).prop('adjustableWidth')).to.equal(
+      true,
+    );
+
+    wrapper = setup({ ...props, parentComponent: mockLayout.present.CHART_ID });
+    expect(wrapper.find(ResizableContainer).prop('adjustableWidth')).to.equal(
+      false,
+    );
+  });
+
+  it('should pass correct props to ResizableContainer', () => {
+    const wrapper = setup();
+    const resizableProps = wrapper.find(ResizableContainer).props();
+    expect(resizableProps.widthStep).to.equal(props.columnWidth);
+    expect(resizableProps.widthMultiple).to.equal(props.component.meta.width);
+    expect(resizableProps.heightMultiple).to.equal(props.component.meta.height);
+    expect(resizableProps.maxWidthMultiple).to.equal(
+      props.component.meta.width + props.availableColumnCount,
+    );
+  });
+
+  it('should render an Markdown when NOT focused', () => {
+    const wrapper = setup();
+    expect(wrapper.find(AceEditor)).to.have.length(0);
+    expect(wrapper.find(ReactMarkdown)).to.have.length(1);
+  });
+
+  it('should render an AceEditor when focused and editMode=true and editorMode=edit', () => {
+    const wrapper = setup({ editMode: true });
+    expect(wrapper.find(AceEditor)).to.have.length(0);
+    expect(wrapper.find(ReactMarkdown)).to.have.length(1);
+    wrapper.find(WithPopoverMenu).simulate('click'); // focus + edit
+    expect(wrapper.find(AceEditor)).to.have.length(1);
+    expect(wrapper.find(ReactMarkdown)).to.have.length(0);
+  });
+
+  it('should render a ReactMarkdown when focused and editMode=true and editorMode=preview', () => {
+    const wrapper = setup({ editMode: true });
+    wrapper.find(WithPopoverMenu).simulate('click'); // focus + edit
+    expect(wrapper.find(AceEditor)).to.have.length(1);
+    expect(wrapper.find(ReactMarkdown)).to.have.length(0);
+
+    // we can't call setState on Markdown bc it's not the root component, so call
+    // the mode dropdown onchange instead
+    const dropdown = wrapper.find(MarkdownModeDropdown);
+    dropdown.prop('onChange')('preview');
+
+    expect(wrapper.find(AceEditor)).to.have.length(0);
+    expect(wrapper.find(ReactMarkdown)).to.have.length(1);
+  });
+
+  it('should call updateComponents when editMode changes from edit => preview, and there are markdownSource changes', () => {
+    const updateComponents = sinon.spy();
+    const wrapper = setup({ editMode: true, updateComponents });
+    wrapper.find(WithPopoverMenu).simulate('click'); // focus + edit
+
+    // we can't call setState on Markdown bc it's not the root component, so call
+    // the mode dropdown onchange instead
+    const dropdown = wrapper.find(MarkdownModeDropdown);
+    dropdown.prop('onChange')('preview');
+    expect(updateComponents.callCount).to.equal(0);
+
+    dropdown.prop('onChange')('edit');
+    // because we can't call setState on Markdown, change it through the editor
+    // then go back to preview mode to invoke updateComponents
+    const editor = wrapper.find(AceEditor);
+    editor.prop('onChange')('new markdown!');
+    dropdown.prop('onChange')('preview');
+    expect(updateComponents.callCount).to.equal(1);
+  });
+
+  it('should render a DeleteComponentButton when focused in editMode', () => {
+    const wrapper = setup({ editMode: true });
+    wrapper.find(WithPopoverMenu).simulate('click'); // focus
+
+    expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
+  });
+
+  it('should call deleteComponent when deleted', () => {
+    const deleteComponent = sinon.spy();
+    const wrapper = setup({ editMode: true, deleteComponent });
+    wrapper.find(WithPopoverMenu).simulate('click'); // focus
+    wrapper.find(DeleteComponentButton).simulate('click');
+
+    expect(deleteComponent.callCount).to.equal(1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Row_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Row_spec.jsx
new file mode 100644
index 0000000..a718ff4
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Row_spec.jsx
@@ -0,0 +1,120 @@
+import { Provider } from 'react-redux';
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import BackgroundStyleDropdown from '../../../../../src/dashboard/components/menu/BackgroundStyleDropdown';
+import DashboardComponent from '../../../../../src/dashboard/containers/DashboardComponent';
+import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
+import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
+import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
+import IconButton from '../../../../../src/dashboard/components/IconButton';
+import Row from '../../../../../src/dashboard/components/gridComponents/Row';
+import WithPopoverMenu from '../../../../../src/dashboard/components/menu/WithPopoverMenu';
+
+import { mockStore } from '../../fixtures/mockStore';
+import { DASHBOARD_GRID_ID } from '../../../../../src/dashboard/util/constants';
+import { dashboardLayout as mockLayout } from '../../fixtures/mockDashboardLayout';
+import WithDragDropContext from '../../helpers/WithDragDropContext';
+
+describe('Row', () => {
+  const rowWithoutChildren = { ...mockLayout.present.ROW_ID, children: [] };
+  const props = {
+    id: 'ROW_ID',
+    parentId: DASHBOARD_GRID_ID,
+    component: mockLayout.present.ROW_ID,
+    parentComponent: mockLayout.present[DASHBOARD_GRID_ID],
+    index: 0,
+    depth: 2,
+    editMode: false,
+    availableColumnCount: 12,
+    columnWidth: 50,
+    occupiedColumnCount: 6,
+    onResizeStart() {},
+    onResize() {},
+    onResizeStop() {},
+    handleComponentDrop() {},
+    deleteComponent() {},
+    updateComponents() {},
+  };
+
+  function setup(overrideProps) {
+    // We have to wrap provide DragDropContext for the underlying DragDroppable
+    // otherwise we cannot assert on DragDroppable children
+    const wrapper = mount(
+      <Provider store={mockStore}>
+        <WithDragDropContext>
+          <Row {...props} {...overrideProps} />
+        </WithDragDropContext>
+      </Provider>,
+    );
+    return wrapper;
+  }
+
+  it('should render a DragDroppable', () => {
+    // don't count child DragDroppables
+    const wrapper = setup({ component: rowWithoutChildren });
+    expect(wrapper.find(DragDroppable)).to.have.length(1);
+  });
+
+  it('should render a WithPopoverMenu', () => {
+    // don't count child DragDroppables
+    const wrapper = setup({ component: rowWithoutChildren });
+    expect(wrapper.find(WithPopoverMenu)).to.have.length(1);
+  });
+
+  it('should render a HoverMenu in editMode', () => {
+    let wrapper = setup({ component: rowWithoutChildren });
+    expect(wrapper.find(HoverMenu)).to.have.length(0);
+
+    // we cannot set props on the Row because of the WithDragDropContext wrapper
+    wrapper = setup({ component: rowWithoutChildren, editMode: true });
+    expect(wrapper.find(HoverMenu)).to.have.length(1);
+  });
+
+  it('should render a DeleteComponentButton in editMode', () => {
+    let wrapper = setup({ component: rowWithoutChildren });
+    expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
+
+    // we cannot set props on the Row because of the WithDragDropContext wrapper
+    wrapper = setup({ component: rowWithoutChildren, editMode: true });
+    expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
+  });
+
+  it('should render a BackgroundStyleDropdown when focused', () => {
+    let wrapper = setup({ component: rowWithoutChildren });
+    expect(wrapper.find(BackgroundStyleDropdown)).to.have.length(0);
+
+    // we cannot set props on the Row because of the WithDragDropContext wrapper
+    wrapper = setup({ component: rowWithoutChildren, editMode: true });
+    wrapper
+      .find(IconButton)
+      .at(1) // first one is delete button
+      .simulate('click');
+
+    expect(wrapper.find(BackgroundStyleDropdown)).to.have.length(1);
+  });
+
+  it('should call deleteComponent when deleted', () => {
+    const deleteComponent = sinon.spy();
+    const wrapper = setup({ editMode: true, deleteComponent });
+    wrapper.find(DeleteComponentButton).simulate('click');
+    expect(deleteComponent.callCount).to.equal(1);
+  });
+
+  it('should pass appropriate availableColumnCount to children', () => {
+    const wrapper = setup();
+    const dashboardComponent = wrapper.find(DashboardComponent).first();
+    expect(dashboardComponent.props().availableColumnCount).to.equal(
+      props.availableColumnCount - props.occupiedColumnCount,
+    );
+  });
+
+  it('should increment the depth of its children', () => {
+    const wrapper = setup();
+    const dashboardComponent = wrapper.find(DashboardComponent).first();
+    expect(dashboardComponent.props().depth).to.equal(props.depth + 1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Tab_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Tab_spec.jsx
new file mode 100644
index 0000000..a984565
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Tab_spec.jsx
@@ -0,0 +1,126 @@
+import { Provider } from 'react-redux';
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import DashboardComponent from '../../../../../src/dashboard/containers/DashboardComponent';
+import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
+import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
+import EditableTitle from '../../../../../src/components/EditableTitle';
+import WithPopoverMenu from '../../../../../src/dashboard/components/menu/WithPopoverMenu';
+import Tab, {
+  RENDER_TAB,
+  RENDER_TAB_CONTENT,
+} from '../../../../../src/dashboard/components/gridComponents/Tab';
+import WithDragDropContext from '../../helpers/WithDragDropContext';
+import { dashboardLayoutWithTabs } from '../../fixtures/mockDashboardLayout';
+import { mockStoreWithTabs } from '../../fixtures/mockStore';
+
+describe('Tabs', () => {
+  const props = {
+    id: 'TAB_ID',
+    parentId: 'TABS_ID',
+    component: dashboardLayoutWithTabs.present.TAB_ID,
+    parentComponent: dashboardLayoutWithTabs.present.TABS_ID,
+    index: 0,
+    depth: 1,
+    editMode: false,
+    renderType: RENDER_TAB,
+    onDropOnTab() {},
+    onDeleteTab() {},
+    availableColumnCount: 12,
+    columnWidth: 50,
+    onResizeStart() {},
+    onResize() {},
+    onResizeStop() {},
+    createComponent() {},
+    handleComponentDrop() {},
+    onChangeTab() {},
+    deleteComponent() {},
+    updateComponents() {},
+  };
+
+  function setup(overrideProps) {
+    // We have to wrap provide DragDropContext for the underlying DragDroppable
+    // otherwise we cannot assert on DragDroppable children
+    const wrapper = mount(
+      <Provider store={mockStoreWithTabs}>
+        <WithDragDropContext>
+          <Tab {...props} {...overrideProps} />
+        </WithDragDropContext>
+      </Provider>,
+    );
+    return wrapper;
+  }
+
+  describe('renderType=RENDER_TAB', () => {
+    it('should render a DragDroppable', () => {
+      const wrapper = setup();
+      expect(wrapper.find(DragDroppable)).to.have.length(1);
+    });
+
+    it('should render an EditableTitle with meta.text', () => {
+      const wrapper = setup();
+      const title = wrapper.find(EditableTitle);
+      expect(title).to.have.length(1);
+      expect(title.find('input').prop('value')).to.equal(
+        props.component.meta.text,
+      );
+    });
+
+    it('should call updateComponents when EditableTitle changes', () => {
+      const updateComponents = sinon.spy();
+      const wrapper = setup({ editMode: true, updateComponents });
+      wrapper.find(EditableTitle).prop('onSaveTitle')('New title');
+
+      expect(updateComponents.callCount).to.equal(1);
+      expect(updateComponents.getCall(0).args[0].TAB_ID.meta.text).to.equal(
+        'New title',
+      );
+    });
+
+    it('should render a WithPopoverMenu', () => {
+      const wrapper = setup();
+      expect(wrapper.find(WithPopoverMenu)).to.have.length(1);
+    });
+
+    it('should render a DeleteComponentButton when focused if its not the only tab', () => {
+      let wrapper = setup();
+      wrapper.find(WithPopoverMenu).simulate('click'); // focus
+      expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
+
+      wrapper = setup({ editMode: true });
+      wrapper.find(WithPopoverMenu).simulate('click');
+      expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
+
+      wrapper = setup({
+        editMode: true,
+        parentComponent: {
+          ...props.parentComponent,
+          children: props.parentComponent.children.slice(0, 1),
+        },
+      });
+      wrapper.find(WithPopoverMenu).simulate('click');
+      expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
+    });
+
+    it('should call deleteComponent when deleted', () => {
+      const deleteComponent = sinon.spy();
+      const wrapper = setup({ editMode: true, deleteComponent });
+      wrapper.find(WithPopoverMenu).simulate('click'); // focus
+      wrapper.find(DeleteComponentButton).simulate('click');
+
+      expect(deleteComponent.callCount).to.equal(1);
+    });
+  });
+
+  describe('renderType=RENDER_TAB_CONTENT', () => {
+    it('should render a DashboardComponent', () => {
+      const wrapper = setup({ renderType: RENDER_TAB_CONTENT });
+      // We expect 2 because this Tab has a Row child and the row has a Chart
+      expect(wrapper.find(DashboardComponent)).to.have.length(2);
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Tabs_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Tabs_spec.jsx
new file mode 100644
index 0000000..d521fe5
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Tabs_spec.jsx
@@ -0,0 +1,140 @@
+import { Provider } from 'react-redux';
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+import { Tabs as BootstrapTabs, Tab as BootstrapTab } from 'react-bootstrap';
+
+import DashboardComponent from '../../../../../src/dashboard/containers/DashboardComponent';
+import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
+import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
+import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
+import Tabs from '../../../../../src/dashboard/components/gridComponents/Tabs';
+import WithDragDropContext from '../../helpers/WithDragDropContext';
+import { dashboardLayoutWithTabs } from '../../fixtures/mockDashboardLayout';
+import { mockStoreWithTabs } from '../../fixtures/mockStore';
+import { DASHBOARD_ROOT_ID } from '../../../../../src/dashboard/util/constants';
+
+describe('Tabs', () => {
+  const props = {
+    id: 'TABS_ID',
+    parentId: DASHBOARD_ROOT_ID,
+    component: dashboardLayoutWithTabs.present.TABS_ID,
+    parentComponent: dashboardLayoutWithTabs.present[DASHBOARD_ROOT_ID],
+    index: 0,
+    depth: 1,
+    renderTabContent: true,
+    editMode: false,
+    availableColumnCount: 12,
+    columnWidth: 50,
+    onResizeStart() {},
+    onResize() {},
+    onResizeStop() {},
+    createComponent() {},
+    handleComponentDrop() {},
+    onChangeTab() {},
+    deleteComponent() {},
+    updateComponents() {},
+  };
+
+  function setup(overrideProps) {
+    // We have to wrap provide DragDropContext for the underlying DragDroppable
+    // otherwise we cannot assert on DragDroppable children
+    const wrapper = mount(
+      <Provider store={mockStoreWithTabs}>
+        <WithDragDropContext>
+          <Tabs {...props} {...overrideProps} />
+        </WithDragDropContext>
+      </Provider>,
+    );
+    return wrapper;
+  }
+
+  it('should render a DragDroppable', () => {
+    // test just Tabs with no children DragDroppables
+    const wrapper = setup({ component: { ...props.component, children: [] } });
+    expect(wrapper.find(DragDroppable)).to.have.length(1);
+  });
+
+  it('should render BootstrapTabs', () => {
+    const wrapper = setup();
+    expect(wrapper.find(BootstrapTabs)).to.have.length(1);
+  });
+
+  it('should set animation=true, mountOnEnter=true, and unmounOnExit=false on BootstrapTabs for perf', () => {
+    const wrapper = setup();
+    const tabProps = wrapper.find(BootstrapTabs).props();
+    expect(tabProps.animation).to.equal(true);
+    expect(tabProps.mountOnEnter).to.equal(true);
+    expect(tabProps.unmountOnExit).to.equal(false);
+  });
+
+  it('should render a BootstrapTab for each child', () => {
+    const wrapper = setup();
+    expect(wrapper.find(BootstrapTab)).to.have.length(
+      props.component.children.length,
+    );
+  });
+
+  it('should render an extra (+) BootstrapTab in editMode', () => {
+    const wrapper = setup({ editMode: true });
+    expect(wrapper.find(BootstrapTab)).to.have.length(
+      props.component.children.length + 1,
+    );
+  });
+
+  it('should render a DashboardComponent for each child', () => {
+    // note: this does not test Tab content
+    const wrapper = setup({ renderTabContent: false });
+    expect(wrapper.find(DashboardComponent)).to.have.length(
+      props.component.children.length,
+    );
+  });
+
+  it('should call createComponent if the (+) tab is clicked', () => {
+    const createComponent = sinon.spy();
+    const wrapper = setup({ editMode: true, createComponent });
+    wrapper
+      .find('.dashboard-component-tabs .nav-tabs a')
+      .last()
+      .simulate('click');
+
+    expect(createComponent.callCount).to.equal(1);
+  });
+
+  it('should call onChangeTab when a tab is clicked', () => {
+    const onChangeTab = sinon.spy();
+    const wrapper = setup({ editMode: true, onChangeTab });
+    wrapper
+      .find('.dashboard-component-tabs .nav-tabs a')
+      .at(1) // will not call if it is already selected
+      .simulate('click');
+
+    expect(onChangeTab.callCount).to.equal(1);
+  });
+
+  it('should render a HoverMenu in editMode', () => {
+    let wrapper = setup();
+    expect(wrapper.find(HoverMenu)).to.have.length(0);
+
+    wrapper = setup({ editMode: true });
+    expect(wrapper.find(HoverMenu)).to.have.length(1);
+  });
+
+  it('should render a DeleteComponentButton in editMode', () => {
+    let wrapper = setup();
+    expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
+
+    wrapper = setup({ editMode: true });
+    expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
+  });
+
+  it('should call deleteComponent when deleted', () => {
+    const deleteComponent = sinon.spy();
+    const wrapper = setup({ editMode: true, deleteComponent });
+    wrapper.find(DeleteComponentButton).simulate('click');
+
+    expect(deleteComponent.callCount).to.equal(1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/DraggableNewComponent_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/DraggableNewComponent_spec.jsx
new file mode 100644
index 0000000..4334b37
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/DraggableNewComponent_spec.jsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import DragDroppable from '../../../../../../src/dashboard/components/dnd/DragDroppable';
+import DraggableNewComponent from '../../../../../../src/dashboard/components/gridComponents/new/DraggableNewComponent';
+import WithDragDropContext from '../../../helpers/WithDragDropContext';
+
+import { NEW_COMPONENTS_SOURCE_ID } from '../../../../../../src/dashboard/util/constants';
+import {
+  NEW_COMPONENT_SOURCE_TYPE,
+  CHART_TYPE,
+} from '../../../../../../src/dashboard/util/componentTypes';
+
+describe('DraggableNewComponent', () => {
+  const props = {
+    id: 'id',
+    type: CHART_TYPE,
+    label: 'label!',
+    className: 'a_class',
+  };
+
+  function setup(overrideProps) {
+    // We have to wrap provide DragDropContext for the underlying DragDroppable
+    // otherwise we cannot assert on DragDroppable children
+    const wrapper = mount(
+      <WithDragDropContext>
+        <DraggableNewComponent {...props} {...overrideProps} />
+      </WithDragDropContext>,
+    );
+    return wrapper;
+  }
+
+  it('should render a DragDroppable', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DragDroppable)).to.have.length(1);
+  });
+
+  it('should pass component={ type, id } to DragDroppable', () => {
+    const wrapper = setup();
+    const dragdroppable = wrapper.find(DragDroppable);
+    expect(dragdroppable.prop('component')).to.deep.equal({
+      id: props.id,
+      type: props.type,
+    });
+  });
+
+  it('should pass appropriate parent source and id to DragDroppable', () => {
+    const wrapper = setup();
+    const dragdroppable = wrapper.find(DragDroppable);
+    expect(dragdroppable.prop('parentComponent')).to.deep.equal({
+      id: NEW_COMPONENTS_SOURCE_ID,
+      type: NEW_COMPONENT_SOURCE_TYPE,
+    });
+  });
+
+  it('should render the passed label', () => {
+    const wrapper = setup();
+    expect(wrapper.find('.new-component').text()).to.equal(props.label);
+  });
+
+  it('should add the passed className', () => {
+    const wrapper = setup();
+    const className = `.new-component-placeholder.${props.className}`;
+    expect(wrapper.find(className)).to.have.length(1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewColumn_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewColumn_spec.jsx
new file mode 100644
index 0000000..cb07cb9
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewColumn_spec.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import DraggableNewComponent from '../../../../../../src/dashboard/components/gridComponents/new/DraggableNewComponent';
+import NewColumn from '../../../../../../src/dashboard/components/gridComponents/new/NewColumn';
+
+import { NEW_COLUMN_ID } from '../../../../../../src/dashboard/util/constants';
+import { COLUMN_TYPE } from '../../../../../../src/dashboard/util/componentTypes';
+
+describe('NewColumn', () => {
+  function setup() {
+    return shallow(<NewColumn />);
+  }
+
+  it('should render a DraggableNewComponent', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DraggableNewComponent)).to.have.length(1);
+  });
+
+  it('should set appropriate type and id', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DraggableNewComponent).props()).to.include({
+      type: COLUMN_TYPE,
+      id: NEW_COLUMN_ID,
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewDivider_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewDivider_spec.jsx
new file mode 100644
index 0000000..71703b3
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewDivider_spec.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import DraggableNewComponent from '../../../../../../src/dashboard/components/gridComponents/new/DraggableNewComponent';
+import NewDivider from '../../../../../../src/dashboard/components/gridComponents/new/NewDivider';
+
+import { NEW_DIVIDER_ID } from '../../../../../../src/dashboard/util/constants';
+import { DIVIDER_TYPE } from '../../../../../../src/dashboard/util/componentTypes';
+
+describe('NewDivider', () => {
+  function setup() {
+    return shallow(<NewDivider />);
+  }
+
+  it('should render a DraggableNewComponent', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DraggableNewComponent)).to.have.length(1);
+  });
+
+  it('should set appropriate type and id', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DraggableNewComponent).props()).to.include({
+      type: DIVIDER_TYPE,
+      id: NEW_DIVIDER_ID,
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewHeader_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewHeader_spec.jsx
new file mode 100644
index 0000000..a499fe8
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewHeader_spec.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import DraggableNewComponent from '../../../../../../src/dashboard/components/gridComponents/new/DraggableNewComponent';
+import NewHeader from '../../../../../../src/dashboard/components/gridComponents/new/NewHeader';
+
+import { NEW_HEADER_ID } from '../../../../../../src/dashboard/util/constants';
+import { HEADER_TYPE } from '../../../../../../src/dashboard/util/componentTypes';
+
+describe('NewHeader', () => {
+  function setup() {
+    return shallow(<NewHeader />);
+  }
+
+  it('should render a DraggableNewComponent', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DraggableNewComponent)).to.have.length(1);
+  });
+
+  it('should set appropriate type and id', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DraggableNewComponent).props()).to.include({
+      type: HEADER_TYPE,
+      id: NEW_HEADER_ID,
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewRow_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewRow_spec.jsx
new file mode 100644
index 0000000..e91893d
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewRow_spec.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import DraggableNewComponent from '../../../../../../src/dashboard/components/gridComponents/new/DraggableNewComponent';
+import NewRow from '../../../../../../src/dashboard/components/gridComponents/new/NewRow';
+
+import { NEW_ROW_ID } from '../../../../../../src/dashboard/util/constants';
+import { ROW_TYPE } from '../../../../../../src/dashboard/util/componentTypes';
+
+describe('NewRow', () => {
+  function setup() {
+    return shallow(<NewRow />);
+  }
+
+  it('should render a DraggableNewComponent', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DraggableNewComponent)).to.have.length(1);
+  });
+
+  it('should set appropriate type and id', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DraggableNewComponent).props()).to.include({
+      type: ROW_TYPE,
+      id: NEW_ROW_ID,
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewTabs_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewTabs_spec.jsx
new file mode 100644
index 0000000..4e71c8c
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewTabs_spec.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import DraggableNewComponent from '../../../../../../src/dashboard/components/gridComponents/new/DraggableNewComponent';
+import NewTabs from '../../../../../../src/dashboard/components/gridComponents/new/NewTabs';
+
+import { NEW_TABS_ID } from '../../../../../../src/dashboard/util/constants';
+import { TABS_TYPE } from '../../../../../../src/dashboard/util/componentTypes';
+
+describe('NewTabs', () => {
+  function setup() {
+    return shallow(<NewTabs />);
+  }
+
+  it('should render a DraggableNewComponent', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DraggableNewComponent)).to.have.length(1);
+  });
+
+  it('should set appropriate type and id', () => {
+    const wrapper = setup();
+    expect(wrapper.find(DraggableNewComponent).props()).to.include({
+      type: TABS_TYPE,
+      id: NEW_TABS_ID,
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/menu/HoverMenu_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/menu/HoverMenu_spec.jsx
new file mode 100644
index 0000000..1f85085
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/menu/HoverMenu_spec.jsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
+
+describe('HoverMenu', () => {
+  it('should render a div.hover-menu', () => {
+    const wrapper = shallow(<HoverMenu />);
+    expect(wrapper.find('.hover-menu')).to.have.length(1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/menu/WithPopoverMenu_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/menu/WithPopoverMenu_spec.jsx
new file mode 100644
index 0000000..5add770
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/menu/WithPopoverMenu_spec.jsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import WithPopoverMenu from '../../../../../src/dashboard/components/menu/WithPopoverMenu';
+
+describe('WithPopoverMenu', () => {
+  const props = {
+    children: <div id="child" />,
+    disableClick: false,
+    menuItems: [<div id="menu1" />, <div id="menu2" />],
+    onChangeFocus() {},
+    shouldFocus: () => true, // needed for mock
+    isFocused: false,
+    editMode: false,
+  };
+
+  function setup(overrideProps) {
+    const wrapper = shallow(<WithPopoverMenu {...props} {...overrideProps} />);
+    return wrapper;
+  }
+
+  it('should render a div with class "with-popover-menu"', () => {
+    const wrapper = setup();
+    expect(wrapper.find('.with-popover-menu')).to.have.length(1);
+  });
+
+  it('should render the passed children', () => {
+    const wrapper = setup();
+    expect(wrapper.find('#child')).to.have.length(1);
+  });
+
+  it('should focus on click in editMode', () => {
+    const wrapper = setup();
+    expect(wrapper.state('isFocused')).to.equal(false);
+
+    wrapper.simulate('click');
+    expect(wrapper.state('isFocused')).to.equal(false);
+
+    wrapper.setProps({ ...props, editMode: true });
+    wrapper.simulate('click');
+    expect(wrapper.state('isFocused')).to.equal(true);
+  });
+
+  it('should render menuItems when focused', () => {
+    const wrapper = setup({ editMode: true });
+    expect(wrapper.find('#menu1')).to.have.length(0);
+    expect(wrapper.find('#menu2')).to.have.length(0);
+
+    wrapper.simulate('click');
+    expect(wrapper.find('#menu1')).to.have.length(1);
+    expect(wrapper.find('#menu2')).to.have.length(1);
+  });
+
+  it('should not focus when disableClick=true', () => {
+    const wrapper = setup({ disableClick: true, editMode: true });
+    expect(wrapper.state('isFocused')).to.equal(false);
+
+    wrapper.simulate('click');
+    expect(wrapper.state('isFocused')).to.equal(false);
+  });
+
+  it('should use the passed shouldFocus func to determine if it should focus', () => {
+    const wrapper = setup({ editMode: true, shouldFocus: () => false });
+    expect(wrapper.state('isFocused')).to.equal(false);
+
+    wrapper.simulate('click');
+    expect(wrapper.state('isFocused')).to.equal(false);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/resizable/ResizableContainer_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/resizable/ResizableContainer_spec.jsx
new file mode 100644
index 0000000..69fca76
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/resizable/ResizableContainer_spec.jsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import Resizable from 're-resizable';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import ResizableContainer from '../../../../../src/dashboard/components/resizable/ResizableContainer';
+
+describe('ResizableContainer', () => {
+  const props = { editMode: false, id: 'id' };
+
+  function setup(propOverrides) {
+    return shallow(<ResizableContainer {...props} {...propOverrides} />);
+  }
+
+  it('should render a Resizable', () => {
+    const wrapper = setup();
+    expect(wrapper.find(Resizable)).to.have.length(1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/resizable/ResizableHandle_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/resizable/ResizableHandle_spec.jsx
new file mode 100644
index 0000000..0c37855
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/resizable/ResizableHandle_spec.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import ResizableHandle from '../../../../../src/dashboard/components/resizable/ResizableHandle';
+
+describe('ResizableHandle', () => {
+  it('should render a right resize handle', () => {
+    const wrapper = shallow(<ResizableHandle.right />);
+    expect(wrapper.find('.resize-handle.resize-handle--right')).to.have.length(
+      1,
+    );
+  });
+
+  it('should render a bottom resize handle', () => {
+    const wrapper = shallow(<ResizableHandle.bottom />);
+    expect(wrapper.find('.resize-handle.resize-handle--bottom')).to.have.length(
+      1,
+    );
+  });
+
+  it('should render a bottomRight resize handle', () => {
+    const wrapper = shallow(<ResizableHandle.bottomRight />);
+    expect(
+      wrapper.find('.resize-handle.resize-handle--bottom-right'),
+    ).to.have.length(1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures.jsx b/superset/assets/spec/javascripts/dashboard/fixtures.jsx
deleted file mode 100644
index 371b02c..0000000
--- a/superset/assets/spec/javascripts/dashboard/fixtures.jsx
+++ /dev/null
@@ -1,161 +0,0 @@
-import { getInitialState } from '../../../src/dashboard/reducers';
-
-export const defaultFilters = {
-  256: { region: [] },
-  257: { country_name: ['United States'] },
-};
-export const regionFilter = {
-  datasource: null,
-  description: null,
-  description_markeddown: '',
-  edit_url: '/slicemodelview/edit/256',
-  form_data: {
-    datasource: '2__table',
-    date_filter: false,
-    filters: [{
-      col: 'country_name',
-      op: 'in',
-      val: ['United States', 'France', 'Japan'],
-    }],
-    granularity_sqla: null,
-    groupby: ['region', 'country_name'],
-    having: '',
-    instant_filtering: true,
-    metric: 'sum__SP_POP_TOTL',
-    show_druid_time_granularity: false,
-    show_druid_time_origin: false,
-    show_sqla_time_column: false,
-    show_sqla_time_granularity: false,
-    since: '100 years ago',
-    slice_id: 256,
-    time_grain_sqla: null,
-    until: 'now',
-    viz_type: 'filter_box',
-    where: '',
-  },
-  slice_id: 256,
-  slice_name: 'Region Filters',
-  slice_url: '/superset/explore/table/2/?form_data=%7B%22slice_id%22%3A%20256%7D',
-};
-export const countryFilter = {
-  datasource: null,
-  description: null,
-  description_markeddown: '',
-  edit_url: '/slicemodelview/edit/257',
-  form_data: {
-    datasource: '2__table',
-    date_filter: false,
-    filters: [],
-    granularity_sqla: null,
-    groupby: ['country_name'],
-    having: '',
-    instant_filtering: true,
-    metric: 'sum__SP_POP_TOTL',
-    show_druid_time_granularity: false,
-    show_druid_time_origin: false,
-    show_sqla_time_column: false,
-    show_sqla_time_granularity: false,
-    since: '100 years ago',
-    slice_id: 257,
-    time_grain_sqla: null,
-    until: 'now',
-    viz_type: 'filter_box',
-    where: '',
-  },
-  slice_id: 257,
-  slice_name: 'Country Filters',
-  slice_url: '/superset/explore/table/2/?form_data=%7B%22slice_id%22%3A%20257%7D',
-};
-export const slice = {
-  datasource: null,
-  description: null,
-  description_markeddown: '',
-  edit_url: '/slicemodelview/edit/248',
-  form_data: {
-    annotation_layers: [],
-    bottom_margin: 'auto',
-    color_scheme: 'bnbColors',
-    contribution: false,
-    datasource: '2__table',
-    filters: [],
-    granularity_sqla: null,
-    groupby: [],
-    having: '',
-    left_margin: 'auto',
-    limit: 50,
-    line_interpolation: 'linear',
-    metrics: ['sum__SP_POP_TOTL'],
-    num_period_compare: '',
-    order_desc: true,
-    period_ratio_type: 'growth',
-    resample_fillmethod: null,
-    resample_how: null,
-    resample_rule: null,
-    rich_tooltip: true,
-    rolling_type: 'None',
-    show_brush: false,
-    show_legend: true,
-    show_markers: false,
-    since: '1961-01-01T00:00:00',
-    slice_id: 248,
-    time_compare: null,
-    time_grain_sqla: null,
-    timeseries_limit_metric: null,
-    until: '2014-12-31T00:00:00',
-    viz_type: 'line',
-    where: '',
-    x_axis_format: 'smart_date',
-    x_axis_label: '',
-    x_axis_showminmax: true,
-    y_axis_bounds: [null, null],
-    y_axis_format: '.3s',
-    y_axis_label: '',
-    y_axis_showminmax: true,
-    y_log_scale: false,
-  },
-  slice_id: 248,
-  slice_name: 'Filtered Population',
-  slice_url: '/superset/explore/table/2/?form_data=%7B%22slice_id%22%3A%20248%7D',
-};
-
-const datasources = {};
-const mockDashboardData = {
-  css: '',
-  dash_edit_perm: true,
-  dash_save_perm: true,
-  dashboard_title: 'Births',
-  id: 2,
-  metadata: {
-    default_filters: JSON.stringify(defaultFilters),
-    filter_immune_slices: [256],
-    timed_refresh_immune_slices: [],
-    filter_immune_slice_fields: {},
-    expanded_slices: {},
-  },
-  position_json: [
-    {
-      size_x: 4,
-      slice_id: '256',
-      row: 0,
-      size_y: 4,
-      col: 5,
-    },
-    {
-      size_x: 4,
-      slice_id: '248',
-      row: 0,
-      size_y: 4,
-      col: 1,
-    },
-  ],
-  slug: 'births',
-  slices: [regionFilter, slice, countryFilter],
-  standalone_mode: false,
-};
-export const { dashboard, charts } = getInitialState({
-  common: {},
-  dashboard_data: mockDashboardData,
-  datasources,
-  user_id: '1',
-});
-
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockChartQueries.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockChartQueries.js
new file mode 100644
index 0000000..b5004a1
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockChartQueries.js
@@ -0,0 +1,61 @@
+import { datasourceId } from './mockDatasource';
+
+export const sliceId = 18;
+
+export default {
+  [sliceId]: {
+    id: sliceId,
+    chartAlert: null,
+    chartStatus: 'rendered',
+    chartUpdateEndTime: 1525852456388,
+    chartUpdateStartTime: 1525852454838,
+    latestQueryFormData: {},
+    queryRequest: {},
+    queryResponse: {},
+    triggerQuery: false,
+    lastRendered: 0,
+    form_data: {
+      slice_id: sliceId,
+      viz_type: 'pie',
+      row_limit: 50000,
+      metric: 'sum__num',
+      since: '100 years ago',
+      groupby: ['gender'],
+      metrics: ['sum__num'],
+      compare_lag: '10',
+      limit: '25',
+      until: 'now',
+      granularity: 'ds',
+      markup_type: 'markdown',
+      where: '',
+      compare_suffix: 'o10Y',
+      datasource: datasourceId,
+    },
+    formData: {
+      datasource: datasourceId,
+      viz_type: 'pie',
+      slice_id: sliceId,
+      granularity_sqla: null,
+      time_grain_sqla: null,
+      since: '100 years ago',
+      until: 'now',
+      metrics: ['sum__num'],
+      groupby: ['gender'],
+      limit: '25',
+      pie_label_type: 'key',
+      donut: false,
+      show_legend: true,
+      labels_outside: true,
+      color_scheme: 'bnbColors',
+      where: '',
+      having: '',
+      filters: [],
+      row_limit: 50000,
+      metric: 'sum__num',
+      compare_lag: '10',
+      granularity: 'ds',
+      markup_type: 'markdown',
+      compare_suffix: 'o10Y',
+    },
+  },
+};
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardInfo.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardInfo.js
new file mode 100644
index 0000000..4dd2670
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardInfo.js
@@ -0,0 +1,12 @@
+export default {
+  id: 1234,
+  slug: 'dashboardSlug',
+  metadata: {},
+  userId: 'mock_user_id',
+  dash_edit_perm: true,
+  dash_save_perm: true,
+  common: {
+    flash_messages: [],
+    conf: { ENABLE_JAVASCRIPT_CONTROLS: false, SUPERSET_WEBSERVER_TIMEOUT: 60 },
+  },
+};
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardLayout.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardLayout.js
new file mode 100644
index 0000000..7327d69
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardLayout.js
@@ -0,0 +1,146 @@
+import {
+  DASHBOARD_GRID_TYPE,
+  DASHBOARD_HEADER_TYPE,
+  DASHBOARD_ROOT_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+  CHART_TYPE,
+  ROW_TYPE,
+  COLUMN_TYPE,
+  MARKDOWN_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+import {
+  DASHBOARD_ROOT_ID,
+  DASHBOARD_HEADER_ID,
+  DASHBOARD_GRID_ID,
+} from '../../../../src/dashboard/util/constants';
+
+import newComponentFactory from '../../../../src/dashboard/util/newComponentFactory';
+
+import { sliceId as chartId } from './mockChartQueries';
+
+export const sliceId = chartId;
+
+export const dashboardLayout = {
+  past: [],
+  present: {
+    [DASHBOARD_ROOT_ID]: {
+      type: DASHBOARD_ROOT_TYPE,
+      id: DASHBOARD_ROOT_ID,
+      children: [DASHBOARD_GRID_ID],
+    },
+
+    [DASHBOARD_GRID_ID]: {
+      type: DASHBOARD_GRID_TYPE,
+      id: DASHBOARD_GRID_ID,
+      children: ['ROW_ID'],
+      meta: {},
+    },
+
+    [DASHBOARD_HEADER_ID]: {
+      type: DASHBOARD_HEADER_TYPE,
+      id: DASHBOARD_HEADER_ID,
+      meta: {
+        text: 'New dashboard',
+      },
+    },
+
+    ROW_ID: {
+      ...newComponentFactory(ROW_TYPE),
+      id: 'ROW_ID',
+      children: ['COLUMN_ID'],
+    },
+
+    COLUMN_ID: {
+      ...newComponentFactory(COLUMN_TYPE),
+      id: 'COLUMN_ID',
+      children: ['CHART_ID'],
+    },
+
+    CHART_ID: {
+      ...newComponentFactory(CHART_TYPE),
+      id: 'CHART_ID',
+      meta: {
+        chartId,
+        width: 3,
+        height: 10,
+        chartName: 'Mock chart name',
+      },
+    },
+
+    MARKDOWN_ID: {
+      ...newComponentFactory(MARKDOWN_TYPE),
+      id: 'MARKDOWN_ID',
+    },
+  },
+  future: [],
+};
+
+export const dashboardLayoutWithTabs = {
+  past: [],
+  present: {
+    [DASHBOARD_ROOT_ID]: {
+      type: DASHBOARD_ROOT_TYPE,
+      id: DASHBOARD_ROOT_ID,
+      children: ['TABS_ID'],
+    },
+
+    TABS_ID: {
+      id: 'TABS_ID',
+      type: TABS_TYPE,
+      children: ['TAB_ID', 'TAB_ID2'],
+    },
+
+    TAB_ID: {
+      id: 'TAB_ID',
+      type: TAB_TYPE,
+      children: ['ROW_ID'],
+      meta: {
+        text: 'tab1',
+      },
+    },
+
+    TAB_ID2: {
+      id: 'TAB_ID2',
+      type: TAB_TYPE,
+      children: [],
+      meta: {
+        text: 'tab2',
+      },
+    },
+
+    CHART_ID: {
+      ...newComponentFactory(CHART_TYPE),
+      id: 'CHART_ID',
+      meta: {
+        chartId,
+        width: 3,
+        height: 10,
+        chartName: 'Mock chart name',
+      },
+    },
+
+    ROW_ID: {
+      ...newComponentFactory(ROW_TYPE),
+      id: 'ROW_ID',
+      children: ['CHART_ID'],
+    },
+
+    [DASHBOARD_GRID_ID]: {
+      type: DASHBOARD_GRID_TYPE,
+      id: DASHBOARD_GRID_ID,
+      children: [],
+      meta: {},
+    },
+
+    [DASHBOARD_HEADER_ID]: {
+      type: DASHBOARD_HEADER_TYPE,
+      id: DASHBOARD_HEADER_ID,
+      meta: {
+        text: 'New dashboard',
+      },
+    },
+  },
+  future: [],
+};
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardState.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardState.js
new file mode 100644
index 0000000..fd640d1
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardState.js
@@ -0,0 +1,15 @@
+import { id as sliceId } from './mockChartQueries';
+
+export default {
+  sliceIds: [sliceId],
+  refresh: false,
+  filters: {},
+  expandedSlices: {},
+  editMode: false,
+  showBuilderPane: false,
+  hasUnsavedChanges: false,
+  maxUndoHistoryExceeded: false,
+  isStarred: true,
+  css: '',
+  isV2Preview: false, // @TODO remove upon v1 deprecation
+};
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDatasource.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockDatasource.js
new file mode 100644
index 0000000..1de7915
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockDatasource.js
@@ -0,0 +1,206 @@
+export const id = 7;
+export const datasourceId = `${id}__table`;
+
+export default {
+  [datasourceId]: {
+    verbose_map: {
+      count: 'COUNT(*)',
+      __timestamp: 'Time',
+      sum__sum_girls: 'sum__sum_girls',
+      name: 'name',
+      avg__sum_girls: 'avg__sum_girls',
+      gender: 'gender',
+      sum_girls: 'sum_girls',
+      ds: 'ds',
+      sum__sum_boys: 'sum__sum_boys',
+      state: 'state',
+      num: 'num',
+      sum__num: 'sum__num',
+      sum_boys: 'sum_boys',
+      avg__num: 'avg__num',
+      avg__sum_boys: 'avg__sum_boys',
+    },
+    gb_cols: [['gender', 'gender'], ['name', 'name'], ['state', 'state']],
+    metrics: [
+      {
+        expression: 'SUM(birth_names.num)',
+        warning_text: null,
+        verbose_name: 'sum__num',
+        metric_name: 'sum__num',
+        description: null,
+      },
+      {
+        expression: 'AVG(birth_names.num)',
+        warning_text: null,
+        verbose_name: 'avg__num',
+        metric_name: 'avg__num',
+        description: null,
+      },
+      {
+        expression: 'SUM(birth_names.sum_boys)',
+        warning_text: null,
+        verbose_name: 'sum__sum_boys',
+        metric_name: 'sum__sum_boys',
+        description: null,
+      },
+      {
+        expression: 'AVG(birth_names.sum_boys)',
+        warning_text: null,
+        verbose_name: 'avg__sum_boys',
+        metric_name: 'avg__sum_boys',
+        description: null,
+      },
+      {
+        expression: 'SUM(birth_names.sum_girls)',
+        warning_text: null,
+        verbose_name: 'sum__sum_girls',
+        metric_name: 'sum__sum_girls',
+        description: null,
+      },
+      {
+        expression: 'AVG(birth_names.sum_girls)',
+        warning_text: null,
+        verbose_name: 'avg__sum_girls',
+        metric_name: 'avg__sum_girls',
+        description: null,
+      },
+      {
+        expression: 'COUNT(*)',
+        warning_text: null,
+        verbose_name: 'COUNT(*)',
+        metric_name: 'count',
+        description: null,
+      },
+    ],
+    column_formats: {},
+    columns: [
+      {
+        type: 'DATETIME',
+        description: null,
+        filterable: false,
+        verbose_name: null,
+        is_dttm: true,
+        expression: '',
+        groupby: false,
+        column_name: 'ds',
+      },
+      {
+        type: 'VARCHAR(16)',
+        description: null,
+        filterable: true,
+        verbose_name: null,
+        is_dttm: false,
+        expression: '',
+        groupby: true,
+        column_name: 'gender',
+      },
+      {
+        type: 'VARCHAR(255)',
+        description: null,
+        filterable: true,
+        verbose_name: null,
+        is_dttm: false,
+        expression: '',
+        groupby: true,
+        column_name: 'name',
+      },
+      {
+        type: 'BIGINT',
+        description: null,
+        filterable: false,
+        verbose_name: null,
+        is_dttm: false,
+        expression: '',
+        groupby: false,
+        column_name: 'num',
+      },
+      {
+        type: 'VARCHAR(10)',
+        description: null,
+        filterable: true,
+        verbose_name: null,
+        is_dttm: false,
+        expression: '',
+        groupby: true,
+        column_name: 'state',
+      },
+      {
+        type: 'BIGINT',
+        description: null,
+        filterable: false,
+        verbose_name: null,
+        is_dttm: false,
+        expression: '',
+        groupby: false,
+        column_name: 'sum_boys',
+      },
+      {
+        type: 'BIGINT',
+        description: null,
+        filterable: false,
+        verbose_name: null,
+        is_dttm: false,
+        expression: '',
+        groupby: false,
+        column_name: 'sum_girls',
+      },
+    ],
+    id,
+    granularity_sqla: [['ds', 'ds']],
+    name: 'birth_names',
+    database: {
+      allow_multi_schema_metadata_fetch: null,
+      name: 'main',
+      backend: 'sqlite',
+    },
+    time_grain_sqla: [
+      [null, 'Time Column'],
+      ['PT1H', 'hour'],
+      ['P1D', 'day'],
+      ['P1W', 'week'],
+      ['P1M', 'month'],
+    ],
+    filterable_cols: [
+      ['gender', 'gender'],
+      ['name', 'name'],
+      ['state', 'state'],
+    ],
+    all_cols: [
+      ['ds', 'ds'],
+      ['gender', 'gender'],
+      ['name', 'name'],
+      ['num', 'num'],
+      ['state', 'state'],
+      ['sum_boys', 'sum_boys'],
+      ['sum_girls', 'sum_girls'],
+    ],
+    filter_select: true,
+    order_by_choices: [
+      ['["ds", true]', 'ds [asc]'],
+      ['["ds", false]', 'ds [desc]'],
+      ['["gender", true]', 'gender [asc]'],
+      ['["gender", false]', 'gender [desc]'],
+      ['["name", true]', 'name [asc]'],
+      ['["name", false]', 'name [desc]'],
+      ['["num", true]', 'num [asc]'],
+      ['["num", false]', 'num [desc]'],
+      ['["state", true]', 'state [asc]'],
+      ['["state", false]', 'state [desc]'],
+      ['["sum_boys", true]', 'sum_boys [asc]'],
+      ['["sum_boys", false]', 'sum_boys [desc]'],
+      ['["sum_girls", true]', 'sum_girls [asc]'],
+      ['["sum_girls", false]', 'sum_girls [desc]'],
+    ],
+    metrics_combo: [
+      ['count', 'COUNT(*)'],
+      ['avg__num', 'avg__num'],
+      ['avg__sum_boys', 'avg__sum_boys'],
+      ['avg__sum_girls', 'avg__sum_girls'],
+      ['sum__num', 'sum__num'],
+      ['sum__sum_boys', 'sum__sum_boys'],
+      ['sum__sum_girls', 'sum__sum_girls'],
+    ],
+    type: 'table',
+    edit_url: '/tablemodelview/edit/7',
+  },
+};
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockMessageToasts.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockMessageToasts.js
new file mode 100644
index 0000000..07726a8
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockMessageToasts.js
@@ -0,0 +1,9 @@
+import {
+  INFO_TOAST,
+  DANGER_TOAST,
+} from '../../../../src/dashboard/util/constants';
+
+export default [
+  { id: 'info_id', toastType: INFO_TOAST, text: 'info toast' },
+  { id: 'danger_id', toastType: DANGER_TOAST, text: 'danger toast' },
+];
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js
new file mode 100644
index 0000000..3644c10
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js
@@ -0,0 +1,177 @@
+import { sliceId as id } from './mockChartQueries';
+import { datasourceId } from './mockDatasource';
+
+export const sliceId = id;
+
+export const sliceEntitiesForChart = {
+  slices: {
+    [sliceId]: {
+      slice_id: sliceId,
+      slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%2018%7D',
+      slice_name: 'Genders',
+      form_data: {
+        slice_id: sliceId,
+        viz_type: 'pie',
+        row_limit: 50000,
+        metric: 'sum__num',
+        since: '100 years ago',
+        groupby: ['gender'],
+        metrics: ['sum__num'],
+        compare_lag: '10',
+        limit: '25',
+        until: 'now',
+        granularity: 'ds',
+        markup_type: 'markdown',
+        where: '',
+        compare_suffix: 'o10Y',
+        datasource: datasourceId,
+      },
+      edit_url: `/slicemodelview/edit/${sliceId}`,
+      viz_type: 'pie',
+      datasource: datasourceId,
+      description: null,
+      description_markeddown: '',
+    },
+  },
+  isLoading: false,
+  errorMessage: null,
+  lastUpdated: 0,
+};
+
+export const sliceEntitiesForDashboard = {
+  slices: {
+    127: {
+      slice_id: 127,
+      slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20127%7D',
+      slice_name: 'Region Filter',
+      form_data: {},
+      edit_url: '/slicemodelview/edit/127',
+      viz_type: 'filter_box',
+      datasource: '2__table',
+      description: null,
+      description_markeddown: '',
+      modified: '23 hours ago',
+      changed_on: 1529453332615,
+    },
+    128: {
+      slice_id: 128,
+      slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20128%7D',
+      slice_name: "World's Population",
+      form_data: {},
+      edit_url: '/slicemodelview/edit/128',
+      viz_type: 'big_number',
+      datasource: '2__table',
+      description: null,
+      description_markeddown: '',
+      modified: '23 hours ago',
+      changed_on: 1529453332628,
+    },
+    129: {
+      slice_id: 129,
+      slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20129%7D',
+      slice_name: 'Most Populated Countries',
+      form_data: {},
+      edit_url: '/slicemodelview/edit/129',
+      viz_type: 'table',
+      datasource: '2__table',
+      description: null,
+      description_markeddown: '',
+      modified: '23 hours ago',
+      changed_on: 1529453332637,
+    },
+    130: {
+      slice_id: 130,
+      slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20130%7D',
+      slice_name: 'Growth Rate',
+      form_data: {},
+      edit_url: '/slicemodelview/edit/130',
+      viz_type: 'line',
+      datasource: '2__table',
+      description: null,
+      description_markeddown: '',
+      modified: '23 hours ago',
+      changed_on: 1529453332645,
+    },
+    131: {
+      slice_id: 131,
+      slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20131%7D',
+      slice_name: '% Rural',
+      form_data: {},
+      edit_url: '/slicemodelview/edit/131',
+      viz_type: 'world_map',
+      datasource: '2__table',
+      description: null,
+      description_markeddown: '',
+      modified: '23 hours ago',
+      changed_on: 1529453332654,
+    },
+    132: {
+      slice_id: 132,
+      slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20132%7D',
+      slice_name: 'Life Expectancy VS Rural %',
+      form_data: {},
+      edit_url: '/slicemodelview/edit/132',
+      viz_type: 'bubble',
+      datasource: '2__table',
+      description: null,
+      description_markeddown: '',
+      modified: '23 hours ago',
+      changed_on: 1529453332663,
+    },
+    133: {
+      slice_id: 133,
+      slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20133%7D',
+      slice_name: 'Rural Breakdown',
+      form_data: {},
+      edit_url: '/slicemodelview/edit/133',
+      viz_type: 'sunburst',
+      datasource: '2__table',
+      description: null,
+      description_markeddown: '',
+      modified: '23 hours ago',
+      changed_on: 1529453332673,
+    },
+    134: {
+      slice_id: 134,
+      slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20134%7D',
+      slice_name: "World's Pop Growth",
+      form_data: {},
+      edit_url: '/slicemodelview/edit/134',
+      viz_type: 'area',
+      datasource: '2__table',
+      description: null,
+      description_markeddown: '',
+      modified: '23 hours ago',
+      changed_on: 1529453332680,
+    },
+    135: {
+      slice_id: 135,
+      slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20135%7D',
+      slice_name: 'Box plot',
+      form_data: {},
+      edit_url: '/slicemodelview/edit/135',
+      viz_type: 'box_plot',
+      datasource: '2__table',
+      description: null,
+      description_markeddown: '',
+      modified: '23 hours ago',
+      changed_on: 1529453332688,
+    },
+    136: {
+      slice_id: 136,
+      slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20136%7D',
+      slice_name: 'Treemap',
+      form_data: {},
+      edit_url: '/slicemodelview/edit/136',
+      viz_type: 'treemap',
+      datasource: '2__table',
+      description: null,
+      description_markeddown: '',
+      modified: '23 hours ago',
+      changed_on: 1529453332700,
+    },
+  },
+  isLoading: false,
+  errorMessage: null,
+  lastUpdated: 0,
+};
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockState.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockState.js
new file mode 100644
index 0000000..655f0bf
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockState.js
@@ -0,0 +1,18 @@
+import chartQueries from './mockChartQueries';
+import { dashboardLayout } from './mockDashboardLayout';
+import dashboardInfo from './mockDashboardInfo';
+import dashboardState from './mockDashboardState';
+import messageToasts from './mockMessageToasts';
+import datasources from './mockDatasource';
+import sliceEntities from './mockSliceEntities';
+
+export default {
+  datasources,
+  sliceEntities,
+  charts: chartQueries,
+  dashboardInfo,
+  dashboardState,
+  dashboardLayout,
+  messageToasts,
+  impressionId: 'mock_impression_id',
+};
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockStore.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockStore.js
new file mode 100644
index 0000000..97132ac
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockStore.js
@@ -0,0 +1,22 @@
+import { createStore, applyMiddleware, compose } from 'redux';
+import thunk from 'redux-thunk';
+
+import rootReducer from '../../../../src/dashboard/reducers/index';
+
+import mockState from './mockState';
+import { dashboardLayoutWithTabs } from './mockDashboardLayout';
+
+export const mockStore = createStore(
+  rootReducer,
+  mockState,
+  compose(applyMiddleware(thunk)),
+);
+
+export const mockStoreWithTabs = createStore(
+  rootReducer,
+  {
+    ...mockState,
+    dashboardLayout: dashboardLayoutWithTabs,
+  },
+  compose(applyMiddleware(thunk)),
+);
diff --git a/superset/assets/spec/javascripts/dashboard/helpers/WithDragDropContext.jsx b/superset/assets/spec/javascripts/dashboard/helpers/WithDragDropContext.jsx
new file mode 100644
index 0000000..3e892a6
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/helpers/WithDragDropContext.jsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import getDragDropManager from '../../../../src/dashboard/util/getDragDropManager';
+
+// A helper component that provides a DragDropContext for components that require it
+class WithDragDropContext extends React.Component {
+  getChildContext() {
+    return {
+      dragDropManager: this.context.dragDropManager || getDragDropManager(),
+    };
+  }
+
+  render() {
+    return this.props.children;
+  }
+}
+
+WithDragDropContext.propTypes = {
+  children: PropTypes.node.isRequired,
+};
+
+WithDragDropContext.childContextTypes = {
+  dragDropManager: PropTypes.object.isRequired,
+};
+
+export default WithDragDropContext;
diff --git a/superset/assets/spec/javascripts/dashboard/reducers/dashboardLayout_spec.js b/superset/assets/spec/javascripts/dashboard/reducers/dashboardLayout_spec.js
new file mode 100644
index 0000000..dd933ac
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/reducers/dashboardLayout_spec.js
@@ -0,0 +1,443 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import layoutReducer from '../../../../src/dashboard/reducers/dashboardLayout';
+
+import {
+  UPDATE_COMPONENTS,
+  DELETE_COMPONENT,
+  CREATE_COMPONENT,
+  MOVE_COMPONENT,
+  CREATE_TOP_LEVEL_TABS,
+  DELETE_TOP_LEVEL_TABS,
+} from '../../../../src/dashboard/actions/dashboardLayout';
+
+import {
+  CHART_TYPE,
+  DASHBOARD_GRID_TYPE,
+  DASHBOARD_ROOT_TYPE,
+  ROW_TYPE,
+  TAB_TYPE,
+  TABS_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+import {
+  DASHBOARD_ROOT_ID,
+  DASHBOARD_GRID_ID,
+  NEW_COMPONENTS_SOURCE_ID,
+  NEW_TABS_ID,
+  NEW_ROW_ID,
+} from '../../../../src/dashboard/util/constants';
+
+describe('dashboardLayout reducer', () => {
+  it('should return initial state for unrecognized actions', () => {
+    expect(layoutReducer(undefined, {})).to.deep.equal({});
+  });
+
+  it('should delete a component, remove its reference in its parent, and recursively all of its children', () => {
+    expect(
+      layoutReducer(
+        {
+          toDelete: {
+            id: 'toDelete',
+            children: ['child1'],
+          },
+          child1: {
+            id: 'child1',
+            children: ['child2'],
+          },
+          child2: {
+            id: 'child2',
+            children: [],
+          },
+          parentId: {
+            id: 'parentId',
+            type: ROW_TYPE,
+            children: ['toDelete', 'anotherId'],
+          },
+        },
+        {
+          type: DELETE_COMPONENT,
+          payload: { id: 'toDelete', parentId: 'parentId' },
+        },
+      ),
+    ).to.deep.equal({
+      parentId: {
+        id: 'parentId',
+        children: ['anotherId'],
+        type: ROW_TYPE,
+      },
+    });
+  });
+
+  it('should delete a parent if the parent was a row and no longer has children', () => {
+    expect(
+      layoutReducer(
+        {
+          grandparentId: {
+            id: 'grandparentId',
+            children: ['parentId'],
+          },
+          parentId: {
+            id: 'parentId',
+            type: ROW_TYPE,
+            children: ['toDelete'],
+          },
+          toDelete: {
+            id: 'toDelete',
+            children: ['child1'],
+          },
+          child1: {
+            id: 'child1',
+            children: [],
+          },
+        },
+        {
+          type: DELETE_COMPONENT,
+          payload: { id: 'toDelete', parentId: 'parentId' },
+        },
+      ),
+    ).to.deep.equal({
+      grandparentId: {
+        id: 'grandparentId',
+        children: [],
+      },
+    });
+  });
+
+  it('should update components', () => {
+    expect(
+      layoutReducer(
+        {
+          update: {
+            id: 'update',
+            children: [],
+          },
+          update2: {
+            id: 'update2',
+            children: [],
+          },
+          dontUpdate: {
+            id: 'dontUpdate',
+            something: 'something',
+            children: ['abcd'],
+          },
+        },
+        {
+          type: UPDATE_COMPONENTS,
+          payload: {
+            nextComponents: {
+              update: {
+                id: 'update',
+                newField: 'newField',
+              },
+              update2: {
+                id: 'update2',
+                newField: 'newField',
+              },
+            },
+          },
+        },
+      ),
+    ).to.deep.equal({
+      update: {
+        id: 'update',
+        newField: 'newField',
+      },
+      update2: {
+        id: 'update2',
+        newField: 'newField',
+      },
+      dontUpdate: {
+        id: 'dontUpdate',
+        something: 'something',
+        children: ['abcd'],
+      },
+    });
+  });
+
+  it('should move a component', () => {
+    const layout = {
+      source: {
+        id: 'source',
+        type: ROW_TYPE,
+        children: ['dontMove', 'toMove'],
+      },
+      destination: {
+        id: 'destination',
+        type: ROW_TYPE,
+        children: ['anotherChild'],
+      },
+      toMove: {
+        id: 'toMove',
+        type: CHART_TYPE,
+        children: [],
+      },
+    };
+
+    const dropResult = {
+      source: { id: 'source', type: ROW_TYPE, index: 1 },
+      destination: { id: 'destination', type: ROW_TYPE, index: 0 },
+      dragging: { id: 'toMove', type: CHART_TYPE },
+    };
+
+    expect(
+      layoutReducer(layout, {
+        type: MOVE_COMPONENT,
+        payload: { dropResult },
+      }),
+    ).to.deep.equal({
+      source: {
+        id: 'source',
+        type: ROW_TYPE,
+        children: ['dontMove'],
+      },
+      destination: {
+        id: 'destination',
+        type: ROW_TYPE,
+        children: ['toMove', 'anotherChild'],
+      },
+      toMove: {
+        id: 'toMove',
+        type: CHART_TYPE,
+        children: [],
+      },
+    });
+  });
+
+  it('should wrap a moved component in a row if need be', () => {
+    const layout = {
+      source: {
+        id: 'source',
+        type: ROW_TYPE,
+        children: ['dontMove', 'toMove'],
+      },
+      destination: {
+        id: 'destination',
+        type: DASHBOARD_GRID_TYPE,
+        children: [],
+      },
+      toMove: {
+        id: 'toMove',
+        type: CHART_TYPE,
+        children: [],
+      },
+    };
+
+    const dropResult = {
+      source: { id: 'source', type: ROW_TYPE, index: 1 },
+      destination: { id: 'destination', type: DASHBOARD_GRID_TYPE, index: 0 },
+      dragging: { id: 'toMove', type: CHART_TYPE },
+    };
+
+    const result = layoutReducer(layout, {
+      type: MOVE_COMPONENT,
+      payload: { dropResult },
+    });
+
+    const newRow = Object.values(result).find(
+      component =>
+        ['source', 'destination', 'toMove'].indexOf(component.id) === -1,
+    );
+
+    expect(newRow.children[0]).to.equal('toMove');
+    expect(result.destination.children[0]).to.equal(newRow.id);
+    expect(Object.keys(result)).to.have.length(4);
+  });
+
+  it('should add top-level tabs from a new tabs component, moving grid children to new tab', () => {
+    const layout = {
+      [DASHBOARD_ROOT_ID]: {
+        id: DASHBOARD_ROOT_ID,
+        children: [DASHBOARD_GRID_ID],
+      },
+      [DASHBOARD_GRID_ID]: {
+        id: DASHBOARD_GRID_ID,
+        children: ['child'],
+      },
+      child: {
+        id: 'child',
+        children: [],
+      },
+    };
+
+    const dropResult = {
+      source: { id: NEW_COMPONENTS_SOURCE_ID, type: '' },
+      destination: {
+        id: DASHBOARD_ROOT_ID,
+        type: DASHBOARD_ROOT_TYPE,
+        index: 0,
+      },
+      dragging: { id: NEW_TABS_ID, type: TABS_TYPE },
+    };
+
+    const result = layoutReducer(layout, {
+      type: CREATE_TOP_LEVEL_TABS,
+      payload: { dropResult },
+    });
+
+    const tabComponent = Object.values(result).find(
+      component => component.type === TAB_TYPE,
+    );
+
+    const tabsComponent = Object.values(result).find(
+      component => component.type === TABS_TYPE,
+    );
+
+    expect(Object.keys(result)).to.have.length(5); // initial + Tabs + Tab
+    expect(result[DASHBOARD_ROOT_ID].children[0]).to.equal(tabsComponent.id);
+    expect(result[tabsComponent.id].children[0]).to.equal(tabComponent.id);
+    expect(result[tabComponent.id].children[0]).to.equal('child');
+    expect(result[DASHBOARD_GRID_ID].children).to.have.length(0);
+  });
+
+  it('should add top-level tabs from an existing tabs component, moving grid children to new tab', () => {
+    const layout = {
+      [DASHBOARD_ROOT_ID]: {
+        id: DASHBOARD_ROOT_ID,
+        children: [DASHBOARD_GRID_ID],
+      },
+      [DASHBOARD_GRID_ID]: {
+        id: DASHBOARD_GRID_ID,
+        children: ['child', 'tabs', 'child2'],
+      },
+      child: {
+        id: 'child',
+        children: [],
+      },
+      child2: {
+        id: 'child2',
+        children: [],
+      },
+      tabs: {
+        id: 'tabs',
+        type: TABS_TYPE,
+        children: ['tab'],
+      },
+      tab: {
+        id: 'tab',
+        type: TAB_TYPE,
+        children: [],
+      },
+    };
+
+    const dropResult = {
+      source: { id: DASHBOARD_GRID_ID, type: DASHBOARD_GRID_TYPE, index: 1 },
+      destination: {
+        id: DASHBOARD_ROOT_ID,
+        type: DASHBOARD_ROOT_TYPE,
+        index: 0,
+      },
+      dragging: { id: 'tabs', type: TABS_TYPE },
+    };
+
+    const result = layoutReducer(layout, {
+      type: CREATE_TOP_LEVEL_TABS,
+      payload: { dropResult },
+    });
+
+    expect(Object.keys(result)).to.have.length(Object.keys(layout).length);
+    expect(result[DASHBOARD_ROOT_ID].children[0]).to.equal('tabs');
+    expect(result.tabs.children[0]).to.equal('tab');
+    expect(result.tab.children).to.deep.equal(['child', 'child2']);
+    expect(result[DASHBOARD_GRID_ID].children).to.have.length(0);
+  });
+
+  it('should remove top-level tabs, moving children to the grid', () => {
+    const layout = {
+      [DASHBOARD_ROOT_ID]: {
+        id: DASHBOARD_ROOT_ID,
+        children: ['tabs'],
+      },
+      [DASHBOARD_GRID_ID]: {
+        id: DASHBOARD_GRID_ID,
+        children: [],
+      },
+      child: {
+        id: 'child',
+        children: [],
+      },
+      child2: {
+        id: 'child2',
+        children: [],
+      },
+      tabs: {
+        id: 'tabs',
+        type: TABS_TYPE,
+        children: ['tab'],
+      },
+      tab: {
+        id: 'tab',
+        type: TAB_TYPE,
+        children: ['child', 'child2'],
+      },
+    };
+
+    const dropResult = {
+      source: { id: DASHBOARD_GRID_ID, type: DASHBOARD_GRID_TYPE, index: 1 },
+      destination: {
+        id: DASHBOARD_ROOT_ID,
+        type: DASHBOARD_ROOT_TYPE,
+        index: 0,
+      },
+      dragging: { id: 'tabs', type: TABS_TYPE },
+    };
+
+    const result = layoutReducer(layout, {
+      type: DELETE_TOP_LEVEL_TABS,
+      payload: { dropResult },
+    });
+
+    expect(result).to.deep.equal({
+      [DASHBOARD_ROOT_ID]: {
+        id: DASHBOARD_ROOT_ID,
+        children: [DASHBOARD_GRID_ID],
+      },
+      [DASHBOARD_GRID_ID]: {
+        id: DASHBOARD_GRID_ID,
+        children: ['child', 'child2'],
+      },
+      child: {
+        id: 'child',
+        children: [],
+      },
+      child2: {
+        id: 'child2',
+        children: [],
+      },
+    });
+  });
+
+  it('should create a component', () => {
+    const layout = {
+      [DASHBOARD_ROOT_ID]: {
+        id: DASHBOARD_ROOT_ID,
+        children: [DASHBOARD_GRID_ID],
+      },
+      [DASHBOARD_GRID_ID]: {
+        id: DASHBOARD_GRID_ID,
+        children: ['child'],
+      },
+      child: { id: 'child' },
+    };
+
+    const dropResult = {
+      source: { id: NEW_COMPONENTS_SOURCE_ID, type: '' },
+      destination: {
+        id: DASHBOARD_GRID_ID,
+        type: DASHBOARD_GRID_TYPE,
+        index: 1,
+      },
+      dragging: { id: NEW_ROW_ID, type: ROW_TYPE },
+    };
+
+    const result = layoutReducer(layout, {
+      type: CREATE_COMPONENT,
+      payload: { dropResult },
+    });
+
+    const newId = result[DASHBOARD_GRID_ID].children[1];
+    expect(result[DASHBOARD_GRID_ID].children).to.have.length(2);
+    expect(result[newId].type).to.equal(ROW_TYPE);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/reducers/dashboardState_spec.js b/superset/assets/spec/javascripts/dashboard/reducers/dashboardState_spec.js
new file mode 100644
index 0000000..f8095cd
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/reducers/dashboardState_spec.js
@@ -0,0 +1,241 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import {
+  ADD_SLICE,
+  CHANGE_FILTER,
+  ON_CHANGE,
+  ON_SAVE,
+  REMOVE_SLICE,
+  SET_EDIT_MODE,
+  SET_MAX_UNDO_HISTORY_EXCEEDED,
+  SET_UNSAVED_CHANGES,
+  TOGGLE_BUILDER_PANE,
+  TOGGLE_EXPAND_SLICE,
+  TOGGLE_FAVE_STAR,
+} from '../../../../src/dashboard/actions/dashboardState';
+
+import dashboardStateReducer from '../../../../src/dashboard/reducers/dashboardState';
+
+describe('dashboardState reducer', () => {
+  it('should return initial state', () => {
+    expect(dashboardStateReducer(undefined, {})).to.deep.equal({});
+  });
+
+  it('should add a slice', () => {
+    expect(
+      dashboardStateReducer(
+        { sliceIds: [1] },
+        { type: ADD_SLICE, slice: { slice_id: 2 } },
+      ),
+    ).to.deep.equal({ sliceIds: [1, 2] });
+  });
+
+  it('should remove a slice', () => {
+    expect(
+      dashboardStateReducer(
+        { sliceIds: [1, 2], filters: {} },
+        { type: REMOVE_SLICE, sliceId: 2 },
+      ),
+    ).to.deep.equal({ sliceIds: [1], refresh: false, filters: {} });
+  });
+
+  it('should reset filters if a removed slice is a filter', () => {
+    expect(
+      dashboardStateReducer(
+        { sliceIds: [1, 2], filters: { 2: {}, 1: {} } },
+        { type: REMOVE_SLICE, sliceId: 2 },
+      ),
+    ).to.deep.equal({ sliceIds: [1], filters: { 1: {} }, refresh: true });
+  });
+
+  it('should toggle fav star', () => {
+    expect(
+      dashboardStateReducer(
+        { isStarred: false },
+        { type: TOGGLE_FAVE_STAR, isStarred: true },
+      ),
+    ).to.deep.equal({ isStarred: true });
+  });
+
+  it('should toggle edit mode', () => {
+    expect(
+      dashboardStateReducer(
+        { editMode: false },
+        { type: SET_EDIT_MODE, editMode: true },
+      ),
+    ).to.deep.equal({ editMode: true, showBuilderPane: true });
+  });
+
+  it('should toggle builder pane', () => {
+    expect(
+      dashboardStateReducer(
+        { showBuilderPane: false },
+        { type: TOGGLE_BUILDER_PANE },
+      ),
+    ).to.deep.equal({ showBuilderPane: true });
+
+    expect(
+      dashboardStateReducer(
+        { showBuilderPane: true },
+        { type: TOGGLE_BUILDER_PANE },
+      ),
+    ).to.deep.equal({ showBuilderPane: false });
+  });
+
+  it('should toggle expanded slices', () => {
+    expect(
+      dashboardStateReducer(
+        { expandedSlices: { 1: true, 2: false } },
+        { type: TOGGLE_EXPAND_SLICE, sliceId: 1 },
+      ),
+    ).to.deep.equal({ expandedSlices: { 2: false } });
+
+    expect(
+      dashboardStateReducer(
+        { expandedSlices: { 1: true, 2: false } },
+        { type: TOGGLE_EXPAND_SLICE, sliceId: 2 },
+      ),
+    ).to.deep.equal({ expandedSlices: { 1: true, 2: true } });
+  });
+
+  it('should set hasUnsavedChanges', () => {
+    expect(dashboardStateReducer({}, { type: ON_CHANGE })).to.deep.equal({
+      hasUnsavedChanges: true,
+    });
+
+    expect(
+      dashboardStateReducer(
+        {},
+        { type: SET_UNSAVED_CHANGES, payload: { hasUnsavedChanges: false } },
+      ),
+    ).to.deep.equal({
+      hasUnsavedChanges: false,
+    });
+  });
+
+  it('should set maxUndoHistoryExceeded', () => {
+    expect(
+      dashboardStateReducer(
+        {},
+        {
+          type: SET_MAX_UNDO_HISTORY_EXCEEDED,
+          payload: { maxUndoHistoryExceeded: true },
+        },
+      ),
+    ).to.deep.equal({
+      maxUndoHistoryExceeded: true,
+    });
+  });
+
+  it('should set unsaved changes, max undo history, and editMode to false on save', () => {
+    expect(
+      dashboardStateReducer({ hasUnsavedChanges: true }, { type: ON_SAVE }),
+    ).to.deep.equal({
+      hasUnsavedChanges: false,
+      maxUndoHistoryExceeded: false,
+      editMode: false,
+      isV2Preview: false, // @TODO remove upon v1 deprecation
+    });
+  });
+
+  describe('change filter', () => {
+    it('should add a new filter if it does not exist', () => {
+      expect(
+        dashboardStateReducer(
+          {
+            filters: {},
+            sliceIds: [1],
+          },
+          {
+            type: CHANGE_FILTER,
+            chart: { id: 1, formData: { groupby: 'column' } },
+            col: 'column',
+            vals: ['b', 'a'],
+            refresh: true,
+            merge: true,
+          },
+        ),
+      ).to.deep.equal({
+        filters: { 1: { column: ['b', 'a'] } },
+        refresh: true,
+        sliceIds: [1],
+      });
+    });
+
+    it('should overwrite a filter if merge is false', () => {
+      expect(
+        dashboardStateReducer(
+          {
+            filters: {
+              1: { column: ['z'] },
+            },
+            sliceIds: [1],
+          },
+          {
+            type: CHANGE_FILTER,
+            chart: { id: 1, formData: { groupby: 'column' } },
+            col: 'column',
+            vals: ['b', 'a'],
+            refresh: true,
+            merge: false,
+          },
+        ),
+      ).to.deep.equal({
+        filters: { 1: { column: ['b', 'a'] } },
+        refresh: true,
+        sliceIds: [1],
+      });
+    });
+
+    it('should merge a filter if merge is true', () => {
+      expect(
+        dashboardStateReducer(
+          {
+            filters: {
+              1: { column: ['z'] },
+            },
+            sliceIds: [1],
+          },
+          {
+            type: CHANGE_FILTER,
+            chart: { id: 1, formData: { groupby: 'column' } },
+            col: 'column',
+            vals: ['b', 'a'],
+            refresh: true,
+            merge: true,
+          },
+        ),
+      ).to.deep.equal({
+        filters: { 1: { column: ['z', 'b', 'a'] } },
+        refresh: true,
+        sliceIds: [1],
+      });
+    });
+
+    it('should remove the filter if values are empty', () => {
+      expect(
+        dashboardStateReducer(
+          {
+            filters: {
+              1: { column: ['z'] },
+            },
+            sliceIds: [1],
+          },
+          {
+            type: CHANGE_FILTER,
+            chart: { id: 1, formData: { groupby: 'column' } },
+            col: 'column',
+            vals: [],
+            refresh: true,
+            merge: false,
+          },
+        ),
+      ).to.deep.equal({
+        filters: {},
+        refresh: true,
+        sliceIds: [1],
+      });
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/reducers/messageToasts_spec.js b/superset/assets/spec/javascripts/dashboard/reducers/messageToasts_spec.js
new file mode 100644
index 0000000..5280312
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/reducers/messageToasts_spec.js
@@ -0,0 +1,32 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import {
+  ADD_TOAST,
+  REMOVE_TOAST,
+} from '../../../../src/dashboard/actions/messageToasts';
+import messageToastsReducer from '../../../../src/dashboard/reducers/messageToasts';
+
+describe('messageToasts reducer', () => {
+  it('should return initial state', () => {
+    expect(messageToastsReducer(undefined, {})).to.deep.equal([]);
+  });
+
+  it('should add a toast', () => {
+    expect(
+      messageToastsReducer([], {
+        type: ADD_TOAST,
+        payload: { text: 'test', id: 'id', type: 'test_type' },
+      }),
+    ).to.deep.equal([{ text: 'test', id: 'id', type: 'test_type' }]);
+  });
+
+  it('should add a toast', () => {
+    expect(
+      messageToastsReducer([{ id: 'id' }, { id: 'id2' }], {
+        type: REMOVE_TOAST,
+        payload: { id: 'id' },
+      }),
+    ).to.deep.equal([{ id: 'id2' }]);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/reducers/sliceEntities_spec.js b/superset/assets/spec/javascripts/dashboard/reducers/sliceEntities_spec.js
new file mode 100644
index 0000000..7e3bb76
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/reducers/sliceEntities_spec.js
@@ -0,0 +1,51 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import {
+  FETCH_ALL_SLICES_FAILED,
+  FETCH_ALL_SLICES_STARTED,
+  SET_ALL_SLICES,
+} from '../../../../src/dashboard/actions/sliceEntities';
+
+import sliceEntitiesReducer from '../../../../src/dashboard/reducers/sliceEntities';
+
+describe('sliceEntities reducer', () => {
+  it('should return initial state', () => {
+    expect(sliceEntitiesReducer({}, {})).to.deep.equal({});
+  });
+
+  it('should set loading when fetching slices', () => {
+    expect(
+      sliceEntitiesReducer(
+        { isLoading: false },
+        { type: FETCH_ALL_SLICES_STARTED },
+      ).isLoading,
+    ).to.equal(true);
+  });
+
+  it('should set slices', () => {
+    const result = sliceEntitiesReducer(
+      { slices: { a: {} } },
+      { type: SET_ALL_SLICES, slices: { 1: {}, 2: {} } },
+    );
+
+    expect(result.slices).to.deep.equal({
+      1: {},
+      2: {},
+      a: {},
+    });
+    expect(result.isLoading).to.equal(false);
+  });
+
+  it('should set an error on error', () => {
+    const result = sliceEntitiesReducer(
+      {},
+      {
+        type: FETCH_ALL_SLICES_FAILED,
+        error: { responseJSON: { message: 'errorrr' } },
+      },
+    );
+    expect(result.isLoading).to.equal(false);
+    expect(result.errorMessage.indexOf('errorrr')).to.be.above(-1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/reducers_spec.js b/superset/assets/spec/javascripts/dashboard/reducers_spec.js
deleted file mode 100644
index 6421fec..0000000
--- a/superset/assets/spec/javascripts/dashboard/reducers_spec.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import { describe, it } from 'mocha';
-import { expect } from 'chai';
-
-import { dashboard as reducers } from '../../../src/dashboard/reducers';
-import * as actions from '../../../src/dashboard/actions';
-import { defaultFilters, dashboard as initState } from './fixtures';
-
-describe('Dashboard reducers', () => {
-  it('should remove slice', () => {
-    const action = {
-      type: actions.REMOVE_SLICE,
-      slice: initState.dashboard.slices[1],
-    };
-    expect(initState.dashboard.slices).to.have.length(3);
-
-    const { dashboard, filters, refresh } = reducers(initState, action);
-    expect(dashboard.slices).to.have.length(2);
-    expect(filters).to.deep.equal(defaultFilters);
-    expect(refresh).to.equal(false);
-  });
-
-  it('should remove filter slice', () => {
-    const action = {
-      type: actions.REMOVE_SLICE,
-      slice: initState.dashboard.slices[0],
-    };
-    const initFilters = Object.keys(initState.filters);
-    expect(initFilters).to.have.length(2);
-
-    const { dashboard, filters, refresh } = reducers(initState, action);
-    expect(dashboard.slices).to.have.length(2);
-    expect(Object.keys(filters)).to.have.length(1);
-    expect(refresh).to.equal(true);
-  });
-});
diff --git a/superset/assets/spec/javascripts/dashboard/util/componentIsResizable_spec.js b/superset/assets/spec/javascripts/dashboard/util/componentIsResizable_spec.js
new file mode 100644
index 0000000..b49a91f
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/componentIsResizable_spec.js
@@ -0,0 +1,42 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import componentIsResizable from '../../../../src/dashboard/util/componentIsResizable';
+import {
+  CHART_TYPE,
+  COLUMN_TYPE,
+  DASHBOARD_GRID_TYPE,
+  DASHBOARD_ROOT_TYPE,
+  DIVIDER_TYPE,
+  HEADER_TYPE,
+  MARKDOWN_TYPE,
+  ROW_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+const notResizable = [
+  DASHBOARD_GRID_TYPE,
+  DASHBOARD_ROOT_TYPE,
+  DIVIDER_TYPE,
+  HEADER_TYPE,
+  ROW_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+];
+
+const resizable = [COLUMN_TYPE, CHART_TYPE, MARKDOWN_TYPE];
+
+describe('componentIsResizable', () => {
+  resizable.forEach(type => {
+    it(`should return true for ${type}`, () => {
+      expect(componentIsResizable({ type })).to.equal(true);
+    });
+  });
+
+  notResizable.forEach(type => {
+    it(`should return false for ${type}`, () => {
+      expect(componentIsResizable({ type })).to.equal(false);
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/dnd-reorder_spec.js b/superset/assets/spec/javascripts/dashboard/util/dnd-reorder_spec.js
new file mode 100644
index 0000000..4ff6a52
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/dnd-reorder_spec.js
@@ -0,0 +1,62 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import reorderItem from '../../../../src/dashboard/util/dnd-reorder';
+
+describe('dnd-reorderItem', () => {
+  it('should remove the item from its source entity and add it to its destination entity', () => {
+    const result = reorderItem({
+      entitiesMap: {
+        a: {
+          id: 'a',
+          children: ['x', 'y', 'z'],
+        },
+        b: {
+          id: 'b',
+          children: ['banana'],
+        },
+      },
+      source: { id: 'a', index: 2 },
+      destination: { id: 'b', index: 1 },
+    });
+
+    expect(result.a.children).to.deep.equal(['x', 'y']);
+    expect(result.b.children).to.deep.equal(['banana', 'z']);
+  });
+
+  it('should correctly move elements within the same list', () => {
+    const result = reorderItem({
+      entitiesMap: {
+        a: {
+          id: 'a',
+          children: ['x', 'y', 'z'],
+        },
+      },
+      source: { id: 'a', index: 2 },
+      destination: { id: 'a', index: 0 },
+    });
+
+    expect(result.a.children).to.deep.equal(['z', 'x', 'y']);
+  });
+
+  it('should copy items that do not move into the result', () => {
+    const extraEntity = {};
+    const result = reorderItem({
+      entitiesMap: {
+        a: {
+          id: 'a',
+          children: ['x', 'y', 'z'],
+        },
+        b: {
+          id: 'b',
+          children: ['banana'],
+        },
+        iAmExtra: extraEntity,
+      },
+      source: { id: 'a', index: 2 },
+      destination: { id: 'b', index: 1 },
+    });
+
+    expect(result.iAmExtra === extraEntity).to.equal(true);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/dropOverflowsParent_spec.js b/superset/assets/spec/javascripts/dashboard/util/dropOverflowsParent_spec.js
new file mode 100644
index 0000000..8e6f889
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/dropOverflowsParent_spec.js
@@ -0,0 +1,227 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import dropOverflowsParent from '../../../../src/dashboard/util/dropOverflowsParent';
+import { NEW_COMPONENTS_SOURCE_ID } from '../../../../src/dashboard/util/constants';
+import {
+  CHART_TYPE,
+  COLUMN_TYPE,
+  ROW_TYPE,
+  HEADER_TYPE,
+  TAB_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+describe('dropOverflowsParent', () => {
+  it('returns true if a parent does NOT have adequate width for child', () => {
+    const dropResult = {
+      source: { id: '_' },
+      destination: { id: 'a' },
+      dragging: { id: 'z' },
+    };
+
+    const layout = {
+      a: {
+        id: 'a',
+        type: ROW_TYPE,
+        children: ['b', 'b', 'b', 'b'], // width = 4x bs = 12
+      },
+      b: {
+        id: 'b',
+        type: CHART_TYPE,
+        meta: {
+          width: 3,
+        },
+      },
+      z: {
+        id: 'z',
+        type: CHART_TYPE,
+        meta: {
+          width: 2,
+        },
+      },
+    };
+
+    expect(dropOverflowsParent(dropResult, layout)).to.equal(true);
+  });
+
+  it('returns false if a parent DOES have adequate width for child', () => {
+    const dropResult = {
+      source: { id: '_' },
+      destination: { id: 'a' },
+      dragging: { id: 'z' },
+    };
+
+    const layout = {
+      a: {
+        id: 'a',
+        type: ROW_TYPE,
+        children: ['b', 'b'],
+      },
+      b: {
+        id: 'b',
+        type: CHART_TYPE,
+        meta: {
+          width: 3,
+        },
+      },
+      z: {
+        id: 'z',
+        type: CHART_TYPE,
+        meta: {
+          width: 2,
+        },
+      },
+    };
+
+    expect(dropOverflowsParent(dropResult, layout)).to.equal(false);
+  });
+
+  it('returns false if a child CAN shrink to available parent space', () => {
+    const dropResult = {
+      source: { id: '_' },
+      destination: { id: 'a' },
+      dragging: { id: 'z' },
+    };
+
+    const layout = {
+      a: {
+        id: 'a',
+        type: ROW_TYPE,
+        children: ['b', 'b'], // 2x b = 10
+      },
+      b: {
+        id: 'b',
+        type: CHART_TYPE,
+        meta: {
+          width: 5,
+        },
+      },
+      z: {
+        id: 'z',
+        type: CHART_TYPE,
+        meta: {
+          width: 10,
+        },
+      },
+    };
+
+    expect(dropOverflowsParent(dropResult, layout)).to.equal(false);
+  });
+
+  it('returns true if a child CANNOT shrink to available parent space', () => {
+    const dropResult = {
+      source: { id: '_' },
+      destination: { id: 'a' },
+      dragging: { id: 'b' },
+    };
+
+    const layout = {
+      a: {
+        id: 'a',
+        type: COLUMN_TYPE,
+        meta: {
+          width: 6,
+        },
+      },
+      // rows with children cannot shrink
+      b: {
+        id: 'b',
+        type: ROW_TYPE,
+        children: ['bChild', 'bChild', 'bChild'],
+      },
+      bChild: {
+        id: 'bChild',
+        type: CHART_TYPE,
+        meta: {
+          width: 3,
+        },
+      },
+    };
+
+    expect(dropOverflowsParent(dropResult, layout)).to.equal(true);
+  });
+
+  it('returns true if a column has children that CANNOT shrink to available parent space', () => {
+    const dropResult = {
+      source: { id: '_' },
+      destination: { id: 'destination' },
+      dragging: { id: 'dragging' },
+    };
+
+    const layout = {
+      destination: {
+        id: 'destination',
+        type: ROW_TYPE,
+        children: ['b', 'b'], // 2x b = 10, 2 available
+      },
+      b: {
+        id: 'b',
+        type: CHART_TYPE,
+        meta: {
+          width: 5,
+        },
+      },
+      dragging: {
+        id: 'dragging',
+        type: COLUMN_TYPE,
+        meta: {
+          width: 10,
+        },
+        children: ['rowWithChildren'], // 2x b = width 10
+      },
+      rowWithChildren: {
+        id: 'rowWithChildren',
+        type: ROW_TYPE,
+        children: ['b', 'b'],
+      },
+    };
+
+    expect(dropOverflowsParent(dropResult, layout)).to.equal(true);
+    // remove children
+    expect(
+      dropOverflowsParent(dropResult, {
+        ...layout,
+        dragging: { ...layout.dragging, children: [] },
+      }),
+    ).to.equal(false);
+  });
+
+  it('should work with new components that are not in the layout', () => {
+    const dropResult = {
+      source: { id: NEW_COMPONENTS_SOURCE_ID },
+      destination: { id: 'a' },
+      dragging: { type: CHART_TYPE },
+    };
+
+    const layout = {
+      a: {
+        id: 'a',
+        type: ROW_TYPE,
+        children: [],
+      },
+    };
+
+    expect(dropOverflowsParent(dropResult, layout)).to.equal(false);
+  });
+
+  it('source/destination without widths should not overflow parent', () => {
+    const dropResult = {
+      source: { id: '_' },
+      destination: { id: 'tab' },
+      dragging: { id: 'header' },
+    };
+
+    const layout = {
+      tab: {
+        id: 'tab',
+        type: TAB_TYPE,
+      },
+      header: {
+        id: 'header',
+        type: HEADER_TYPE,
+      },
+    };
+
+    expect(dropOverflowsParent(dropResult, layout)).to.equal(false);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/findFirstParentContainer_spec.js b/superset/assets/spec/javascripts/dashboard/util/findFirstParentContainer_spec.js
new file mode 100644
index 0000000..4ab29bd
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/findFirstParentContainer_spec.js
@@ -0,0 +1,116 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import findFirstParentContainerId from '../../../../src/dashboard/util/findFirstParentContainer';
+import {
+  DASHBOARD_GRID_ID,
+  DASHBOARD_ROOT_ID,
+} from '../../../../src/dashboard/util/constants';
+
+describe('findFirstParentContainer', () => {
+  const mockGridLayout = {
+    DASHBOARD_VERSION_KEY: 'v2',
+    DASHBOARD_ROOT_ID: {
+      type: 'DASHBOARD_ROOT_TYPE',
+      id: 'DASHBOARD_ROOT_ID',
+      children: ['DASHBOARD_GRID_ID'],
+    },
+    DASHBOARD_GRID_ID: {
+      type: 'DASHBOARD_GRID_TYPE',
+      id: 'DASHBOARD_GRID_ID',
+      children: ['DASHBOARD_ROW_TYPE-Bk45URrlQ'],
+    },
+    'DASHBOARD_ROW_TYPE-Bk45URrlQ': {
+      type: 'DASHBOARD_ROW_TYPE',
+      id: 'DASHBOARD_ROW_TYPE-Bk45URrlQ',
+      children: ['DASHBOARD_CHART_TYPE-ryxVc8RHlX'],
+    },
+    'DASHBOARD_CHART_TYPE-ryxVc8RHlX': {
+      type: 'DASHBOARD_CHART_TYPE',
+      id: 'DASHBOARD_CHART_TYPE-ryxVc8RHlX',
+      children: [],
+    },
+    DASHBOARD_HEADER_ID: {
+      id: 'DASHBOARD_HEADER_ID',
+      type: 'DASHBOARD_HEADER_TYPE',
+    },
+  };
+  const mockTabsLayout = {
+    'DASHBOARD_CHART_TYPE-S1gilYABe7': {
+      children: [],
+      id: 'DASHBOARD_CHART_TYPE-S1gilYABe7',
+      type: 'DASHBOARD_CHART_TYPE',
+    },
+    'DASHBOARD_CHART_TYPE-SJli5K0HlQ': {
+      children: [],
+      id: 'DASHBOARD_CHART_TYPE-SJli5K0HlQ',
+      type: 'DASHBOARD_CHART_TYPE',
+    },
+    DASHBOARD_GRID_ID: {
+      children: [],
+      id: 'DASHBOARD_GRID_ID',
+      type: 'DASHBOARD_GRID_TYPE',
+    },
+    DASHBOARD_HEADER_ID: {
+      id: 'DASHBOARD_HEADER_ID',
+      type: 'DASHBOARD_HEADER_TYPE',
+    },
+    DASHBOARD_ROOT_ID: {
+      children: ['DASHBOARD_TABS_TYPE-SkgJ5t0Bem'],
+      id: 'DASHBOARD_ROOT_ID',
+      type: 'DASHBOARD_ROOT_TYPE',
+    },
+    'DASHBOARD_ROW_TYPE-S1B8-JLgX': {
+      children: ['DASHBOARD_CHART_TYPE-SJli5K0HlQ'],
+      id: 'DASHBOARD_ROW_TYPE-S1B8-JLgX',
+      type: 'DASHBOARD_ROW_TYPE',
+    },
+    'DASHBOARD_ROW_TYPE-S1bUb1Ilm': {
+      children: ['DASHBOARD_CHART_TYPE-S1gilYABe7'],
+      id: 'DASHBOARD_ROW_TYPE-S1bUb1Ilm',
+      type: 'DASHBOARD_ROW_TYPE',
+    },
+    'DASHBOARD_TABS_TYPE-ByeLSWyLe7': {
+      children: ['DASHBOARD_TAB_TYPE-BJbLSZ1UeQ'],
+      id: 'DASHBOARD_TABS_TYPE-ByeLSWyLe7',
+      type: 'DASHBOARD_TABS_TYPE',
+    },
+    'DASHBOARD_TABS_TYPE-SkgJ5t0Bem': {
+      children: [
+        'DASHBOARD_TAB_TYPE-HkWJcFCHxQ',
+        'DASHBOARD_TAB_TYPE-ByDBbkLlQ',
+      ],
+      id: 'DASHBOARD_TABS_TYPE-SkgJ5t0Bem',
+      meta: {},
+      type: 'DASHBOARD_TABS_TYPE',
+    },
+    'DASHBOARD_TAB_TYPE-BJbLSZ1UeQ': {
+      children: ['DASHBOARD_ROW_TYPE-S1bUb1Ilm'],
+      id: 'DASHBOARD_TAB_TYPE-BJbLSZ1UeQ',
+      type: 'DASHBOARD_TAB_TYPE',
+    },
+    'DASHBOARD_TAB_TYPE-ByDBbkLlQ': {
+      children: ['DASHBOARD_ROW_TYPE-S1B8-JLgX'],
+      id: 'DASHBOARD_TAB_TYPE-ByDBbkLlQ',
+      type: 'DASHBOARD_TAB_TYPE',
+    },
+    'DASHBOARD_TAB_TYPE-HkWJcFCHxQ': {
+      children: ['DASHBOARD_TABS_TYPE-ByeLSWyLe7'],
+      id: 'DASHBOARD_TAB_TYPE-HkWJcFCHxQ',
+      type: 'DASHBOARD_TAB_TYPE',
+    },
+    DASHBOARD_VERSION_KEY: 'v2',
+  };
+
+  it('should return grid root', () => {
+    expect(findFirstParentContainerId(mockGridLayout)).to.equal(
+      DASHBOARD_GRID_ID,
+    );
+  });
+
+  it('should return first tab', () => {
+    const tabsId = mockTabsLayout[DASHBOARD_ROOT_ID].children[0];
+    const firstTabId = mockTabsLayout[tabsId].children[0];
+    expect(findFirstParentContainerId(mockTabsLayout)).to.equal(firstTabId);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/findParentId_spec.js b/superset/assets/spec/javascripts/dashboard/util/findParentId_spec.js
new file mode 100644
index 0000000..71c8aec
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/findParentId_spec.js
@@ -0,0 +1,29 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import findParentId from '../../../../src/dashboard/util/findParentId';
+
+describe('findParentId', () => {
+  const layout = {
+    a: {
+      id: 'a',
+      children: ['b', 'r', 'k'],
+    },
+    b: {
+      id: 'b',
+      children: ['x', 'y', 'z'],
+    },
+    z: {
+      id: 'z',
+      children: [],
+    },
+  };
+  it('should return the correct parentId', () => {
+    expect(findParentId({ childId: 'b', layout })).to.equal('a');
+    expect(findParentId({ childId: 'z', layout })).to.equal('b');
+  });
+
+  it('should return null if the parent cannot be found', () => {
+    expect(findParentId({ childId: 'a', layout })).to.equal(null);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/getChartIdsFromLayout_spec.js b/superset/assets/spec/javascripts/dashboard/util/getChartIdsFromLayout_spec.js
new file mode 100644
index 0000000..71bbccd
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/getChartIdsFromLayout_spec.js
@@ -0,0 +1,41 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import getChartIdsFromLayout from '../../../../src/dashboard/util/getChartIdsFromLayout';
+import {
+  ROW_TYPE,
+  CHART_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+describe('getChartIdsFromLayout', () => {
+  const mockLayout = {
+    a: {
+      id: 'a',
+      type: CHART_TYPE,
+      meta: { chartId: 'A' },
+    },
+    b: {
+      id: 'b',
+      type: CHART_TYPE,
+      meta: { chartId: 'B' },
+    },
+    c: {
+      id: 'c',
+      type: ROW_TYPE,
+      meta: { chartId: 'C' },
+    },
+  };
+
+  it('should return an array of chartIds', () => {
+    const result = getChartIdsFromLayout(mockLayout);
+    expect(Array.isArray(result)).to.equal(true);
+    expect(result.includes('A')).to.equal(true);
+    expect(result.includes('B')).to.equal(true);
+  });
+
+  it('should return ids only from CHART_TYPE components', () => {
+    const result = getChartIdsFromLayout(mockLayout);
+    expect(result.length).to.equal(2);
+    expect(result.includes('C')).to.equal(false);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/getDetailedComponentWidth_spec.js b/superset/assets/spec/javascripts/dashboard/util/getDetailedComponentWidth_spec.js
new file mode 100644
index 0000000..99e2282
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/getDetailedComponentWidth_spec.js
@@ -0,0 +1,223 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import getDetailedComponentWidth from '../../../../src/dashboard/util/getDetailedComponentWidth';
+import * as types from '../../../../src/dashboard/util/componentTypes';
+import {
+  GRID_COLUMN_COUNT,
+  GRID_MIN_COLUMN_COUNT,
+} from '../../../../src/dashboard/util/constants';
+
+describe('getDetailedComponentWidth', () => {
+  it('should return an object with width, minimumWidth, and occupiedWidth', () => {
+    expect(
+      getDetailedComponentWidth({ id: '_', components: {} }),
+    ).to.have.all.keys(['minimumWidth', 'occupiedWidth', 'width']);
+  });
+
+  describe('width', () => {
+    it('should be undefined if the component is not resizable and has no defined width', () => {
+      const empty = {
+        width: undefined,
+        occupiedWidth: undefined,
+        minimumWidth: undefined,
+      };
+
+      expect(
+        getDetailedComponentWidth({
+          component: { id: '', type: types.HEADER_TYPE },
+        }),
+      ).to.deep.equal(empty);
+
+      expect(
+        getDetailedComponentWidth({
+          component: { id: '', type: types.DIVIDER_TYPE },
+        }),
+      ).to.deep.equal(empty);
+
+      expect(
+        getDetailedComponentWidth({
+          component: { id: '', type: types.TAB_TYPE },
+        }),
+      ).to.deep.equal(empty);
+    });
+
+    it('should match component meta width for resizeable components', () => {
+      expect(
+        getDetailedComponentWidth({
+          component: { id: '', type: types.CHART_TYPE, meta: { width: 1 } },
+        }),
+      ).to.deep.equal({ width: 1, occupiedWidth: 1, minimumWidth: 1 });
+
+      expect(
+        getDetailedComponentWidth({
+          component: { id: '', type: types.MARKDOWN_TYPE, meta: { width: 2 } },
+        }),
+      ).to.deep.equal({ width: 2, occupiedWidth: 2, minimumWidth: 1 });
+
+      expect(
+        getDetailedComponentWidth({
+          component: { id: '', type: types.COLUMN_TYPE, meta: { width: 3 } },
+        }),
+        // note: occupiedWidth is zero for colunns/see test below
+      ).to.deep.equal({ width: 3, occupiedWidth: 0, minimumWidth: 1 });
+    });
+
+    it('should be GRID_COLUMN_COUNT for row components WITHOUT parents', () => {
+      expect(
+        getDetailedComponentWidth({
+          id: 'row',
+          components: { row: { id: 'row', type: types.ROW_TYPE } },
+        }),
+      ).to.deep.equal({
+        width: GRID_COLUMN_COUNT,
+        occupiedWidth: 0,
+        minimumWidth: GRID_MIN_COLUMN_COUNT,
+      });
+    });
+
+    it('should match parent width for row components WITH parents', () => {
+      expect(
+        getDetailedComponentWidth({
+          id: 'row',
+          components: {
+            row: { id: 'row', type: types.ROW_TYPE },
+            parent: {
+              id: 'parent',
+              type: types.COLUMN_TYPE,
+              children: ['row'],
+              meta: { width: 7 },
+            },
+          },
+        }),
+      ).to.deep.equal({
+        width: 7,
+        occupiedWidth: 0,
+        minimumWidth: GRID_MIN_COLUMN_COUNT,
+      });
+    });
+
+    it('should use either id or component (to support new components)', () => {
+      expect(
+        getDetailedComponentWidth({
+          id: 'id',
+          components: {
+            id: { id: 'id', type: types.CHART_TYPE, meta: { width: 6 } },
+          },
+        }).width,
+      ).to.equal(6);
+
+      expect(
+        getDetailedComponentWidth({
+          component: { id: 'id', type: types.CHART_TYPE, meta: { width: 6 } },
+        }).width,
+      ).to.equal(6);
+    });
+  });
+
+  describe('occupiedWidth', () => {
+    it('should reflect the sum of child widths for row components', () => {
+      expect(
+        getDetailedComponentWidth({
+          id: 'row',
+          components: {
+            row: {
+              id: 'row',
+              type: types.ROW_TYPE,
+              children: ['child', 'child'],
+            },
+            child: { id: 'child', meta: { width: 3.5 } },
+          },
+        }),
+      ).to.deep.equal({ width: 12, occupiedWidth: 7, minimumWidth: 7 });
+    });
+
+    it('should always be zero for column components', () => {
+      expect(
+        getDetailedComponentWidth({
+          component: { id: '', type: types.COLUMN_TYPE, meta: { width: 2 } },
+        }),
+      ).to.deep.equal({ width: 2, occupiedWidth: 0, minimumWidth: 1 });
+    });
+  });
+
+  describe('minimumWidth', () => {
+    it('should equal GRID_MIN_COLUMN_COUNT for resizable components', () => {
+      expect(
+        getDetailedComponentWidth({
+          component: { id: '', type: types.CHART_TYPE, meta: { width: 1 } },
+        }),
+      ).to.deep.equal({
+        width: 1,
+        minimumWidth: GRID_MIN_COLUMN_COUNT,
+        occupiedWidth: 1,
+      });
+
+      expect(
+        getDetailedComponentWidth({
+          component: { id: '', type: types.MARKDOWN_TYPE, meta: { width: 2 } },
+        }),
+      ).to.deep.equal({
+        width: 2,
+        minimumWidth: GRID_MIN_COLUMN_COUNT,
+        occupiedWidth: 2,
+      });
+
+      expect(
+        getDetailedComponentWidth({
+          component: { id: '', type: types.COLUMN_TYPE, meta: { width: 3 } },
+        }),
+      ).to.deep.equal({
+        width: 3,
+        minimumWidth: GRID_MIN_COLUMN_COUNT,
+        occupiedWidth: 0,
+      });
+    });
+
+    it('should equal the width of row children for column components with row children', () => {
+      expect(
+        getDetailedComponentWidth({
+          id: 'column',
+          components: {
+            column: {
+              id: 'column',
+              type: types.COLUMN_TYPE,
+              children: ['rowChild', 'ignoredChartChild'],
+              meta: { width: 12 },
+            },
+            rowChild: {
+              id: 'rowChild',
+              type: types.ROW_TYPE,
+              children: ['rowChildChild', 'rowChildChild'],
+            },
+            rowChildChild: {
+              id: 'rowChildChild',
+              meta: { width: 3.5 },
+            },
+            ignoredChartChild: {
+              id: 'ignoredChartChild',
+              meta: { width: 100 },
+            },
+          },
+        }),
+        // occupiedWidth is zero for colunns/see test below
+      ).to.deep.equal({ width: 12, occupiedWidth: 0, minimumWidth: 7 });
+    });
+
+    it('should equal occupiedWidth for row components', () => {
+      expect(
+        getDetailedComponentWidth({
+          id: 'row',
+          components: {
+            row: {
+              id: 'row',
+              type: types.ROW_TYPE,
+              children: ['child', 'child'],
+            },
+            child: { id: 'child', meta: { width: 3.5 } },
+          },
+        }),
+      ).to.deep.equal({ width: 12, occupiedWidth: 7, minimumWidth: 7 });
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/getDropPosition_spec.js b/superset/assets/spec/javascripts/dashboard/util/getDropPosition_spec.js
new file mode 100644
index 0000000..287b7a6
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/getDropPosition_spec.js
@@ -0,0 +1,422 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import getDropPosition, {
+  DROP_TOP,
+  DROP_RIGHT,
+  DROP_BOTTOM,
+  DROP_LEFT,
+} from '../../../../src/dashboard/util/getDropPosition';
+
+import {
+  CHART_TYPE,
+  DASHBOARD_GRID_TYPE,
+  DASHBOARD_ROOT_TYPE,
+  HEADER_TYPE,
+  ROW_TYPE,
+  TAB_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+describe('getDropPosition', () => {
+  // helper to easily configure test
+  function getMocks({
+    parentType,
+    componentType,
+    draggingType,
+    depth = 1,
+    hasChildren = false,
+    orientation = 'row',
+    clientOffset = { x: 0, y: 0 },
+    boundingClientRect = {
+      top: 0,
+      right: 0,
+      bottom: 0,
+      left: 0,
+    },
+    isDraggingOverShallow = true,
+  }) {
+    const monitorMock = {
+      getItem: () => ({
+        id: 'id',
+        type: draggingType,
+      }),
+      getClientOffset: () => clientOffset,
+    };
+
+    const ComponentMock = {
+      props: {
+        depth,
+        parentComponent: {
+          type: parentType,
+        },
+        component: {
+          type: componentType,
+          children: hasChildren ? [''] : [],
+        },
+        orientation,
+        isDraggingOverShallow,
+      },
+      ref: {
+        getBoundingClientRect: () => boundingClientRect,
+      },
+    };
+
+    return [monitorMock, ComponentMock];
+  }
+
+  describe('invalid child + invalid sibling', () => {
+    it('should return null', () => {
+      const result = getDropPosition(
+        // TAB is an invalid child + sibling of GRID > ROW
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: TAB_TYPE,
+        }),
+      );
+      expect(result).to.equal(null);
+    });
+  });
+
+  describe('valid child + invalid sibling', () => {
+    it('should return DROP_LEFT if component has NO children, and orientation is "row"', () => {
+      // HEADER is a valid child + invalid sibling of ROOT > GRID
+      const result = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_ROOT_TYPE,
+          componentType: DASHBOARD_GRID_TYPE,
+          draggingType: HEADER_TYPE,
+        }),
+      );
+      expect(result).to.equal(DROP_LEFT);
+    });
+
+    it('should return DROP_RIGHT if component HAS children, and orientation is "row"', () => {
+      const result = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_ROOT_TYPE,
+          componentType: DASHBOARD_GRID_TYPE,
+          draggingType: HEADER_TYPE,
+          hasChildren: true,
+        }),
+      );
+      expect(result).to.equal(DROP_RIGHT);
+    });
+
+    it('should return DROP_TOP if component has NO children, and orientation is "column"', () => {
+      const result = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_ROOT_TYPE,
+          componentType: DASHBOARD_GRID_TYPE,
+          draggingType: HEADER_TYPE,
+          orientation: 'column',
+        }),
+      );
+      expect(result).to.equal(DROP_TOP);
+    });
+
+    it('should return DROP_BOTTOM if component HAS children, and orientation is "column"', () => {
+      const result = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_ROOT_TYPE,
+          componentType: DASHBOARD_GRID_TYPE,
+          draggingType: HEADER_TYPE,
+          orientation: 'column',
+          hasChildren: true,
+        }),
+      );
+      expect(result).to.equal(DROP_BOTTOM);
+    });
+  });
+
+  describe('invalid child + valid sibling', () => {
+    it('should return DROP_TOP if orientation="row" and clientOffset is closer to component top than bottom', () => {
+      const result = getDropPosition(
+        // HEADER is an invalid child but valid sibling of GRID > ROW
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: HEADER_TYPE,
+          clientOffset: { y: 10 },
+          boundingClientRect: {
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      expect(result).to.equal(DROP_TOP);
+    });
+
+    it('should return DROP_BOTTOM if orientation="row" and clientOffset is closer to component bottom than top', () => {
+      const result = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: HEADER_TYPE,
+          clientOffset: { y: 55 },
+          boundingClientRect: {
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      expect(result).to.equal(DROP_BOTTOM);
+    });
+
+    it('should return DROP_LEFT if orientation="column" and clientOffset is closer to component left than right', () => {
+      const result = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: HEADER_TYPE,
+          orientation: 'column',
+          clientOffset: { x: 45 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+          },
+        }),
+      );
+      expect(result).to.equal(DROP_LEFT);
+    });
+
+    it('should return DROP_RIGHT if orientation="column" and clientOffset is closer to component right than left', () => {
+      const result = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: HEADER_TYPE,
+          orientation: 'column',
+          clientOffset: { x: 55 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+          },
+        }),
+      );
+      expect(result).to.equal(DROP_RIGHT);
+    });
+  });
+
+  describe('child + valid sibling (row orientation)', () => {
+    it('should return DROP_LEFT if component has NO children, and clientOffset is NOT near top/bottom sibling boundary', () => {
+      const result = getDropPosition(
+        // CHART is a valid child + sibling of GRID > ROW
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: CHART_TYPE,
+          clientOffset: { x: 10, y: 50 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      expect(result).to.equal(DROP_LEFT);
+    });
+
+    it('should return DROP_RIGHT if component HAS children, and clientOffset is NOT near top/bottom sibling boundary', () => {
+      const result = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: CHART_TYPE,
+          hasChildren: true,
+          clientOffset: { x: 10, y: 50 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      expect(result).to.equal(DROP_RIGHT);
+    });
+
+    it('should return DROP_TOP regardless of component children if clientOffset IS near top sibling boundary', () => {
+      const noChildren = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: CHART_TYPE,
+          clientOffset: { x: 10, y: 2 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      const withChildren = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: CHART_TYPE,
+          hasChildren: true,
+          clientOffset: { x: 10, y: 2 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      expect(noChildren).to.equal(DROP_TOP);
+      expect(withChildren).to.equal(DROP_TOP);
+    });
+
+    it('should return DROP_BOTTOM regardless of component children if clientOffset IS near bottom sibling boundary', () => {
+      const noChildren = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: CHART_TYPE,
+          clientOffset: { x: 10, y: 95 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      const withChildren = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: CHART_TYPE,
+          hasChildren: true,
+          clientOffset: { x: 10, y: 95 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      expect(noChildren).to.equal(DROP_BOTTOM);
+      expect(withChildren).to.equal(DROP_BOTTOM);
+    });
+  });
+
+  describe('child + valid sibling (column orientation)', () => {
+    it('should return DROP_TOP if component has NO children, and clientOffset is NOT near left/right sibling boundary', () => {
+      const result = getDropPosition(
+        // CHART is a valid child + sibling of GRID > ROW
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: CHART_TYPE,
+          orientation: 'column',
+          clientOffset: { x: 50, y: 0 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      expect(result).to.equal(DROP_TOP);
+    });
+
+    it('should return DROP_BOTTOM if component HAS children, and clientOffset is NOT near left/right sibling boundary', () => {
+      const result = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: CHART_TYPE,
+          orientation: 'column',
+          hasChildren: true,
+          clientOffset: { x: 50, y: 0 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      expect(result).to.equal(DROP_BOTTOM);
+    });
+
+    it('should return DROP_LEFT regardless of component children if clientOffset IS near left sibling boundary', () => {
+      const noChildren = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: CHART_TYPE,
+          orientation: 'column',
+          clientOffset: { x: 10, y: 2 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      const withChildren = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: CHART_TYPE,
+          orientation: 'column',
+          hasChildren: true,
+          clientOffset: { x: 10, y: 2 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      expect(noChildren).to.equal(DROP_LEFT);
+      expect(withChildren).to.equal(DROP_LEFT);
+    });
+
+    it('should return DROP_RIGHT regardless of component children if clientOffset IS near right sibling boundary', () => {
+      const noChildren = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: CHART_TYPE,
+          orientation: 'column',
+          clientOffset: { x: 90, y: 95 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      const withChildren = getDropPosition(
+        ...getMocks({
+          parentType: DASHBOARD_GRID_TYPE,
+          componentType: ROW_TYPE,
+          draggingType: CHART_TYPE,
+          orientation: 'column',
+          hasChildren: true,
+          clientOffset: { x: 90, y: 95 },
+          boundingClientRect: {
+            left: 0,
+            right: 100,
+            top: 0,
+            bottom: 100,
+          },
+        }),
+      );
+      expect(noChildren).to.equal(DROP_RIGHT);
+      expect(withChildren).to.equal(DROP_RIGHT);
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/getFormDataWithExtraFilters_spec.js b/superset/assets/spec/javascripts/dashboard/util/getFormDataWithExtraFilters_spec.js
new file mode 100644
index 0000000..388630b
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/getFormDataWithExtraFilters_spec.js
@@ -0,0 +1,70 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import getFormDataWithExtraFilters from '../../../../src/dashboard/util/charts/getFormDataWithExtraFilters';
+
+describe('getFormDataWithExtraFilters', () => {
+  const chartId = 'chartId';
+  const mockArgs = {
+    chart: {
+      id: chartId,
+      formData: {
+        filters: [
+          {
+            col: 'country_name',
+            op: 'in',
+            val: ['United States'],
+          },
+        ],
+      },
+    },
+    dashboardMetadata: {
+      filter_immune_slices: [],
+      filter_immune_slice_fields: {},
+    },
+    filters: {
+      filterId: {
+        region: ['Spain'],
+        color: ['pink', 'purple'],
+      },
+    },
+    sliceId: chartId,
+  };
+
+  it('should include filters from the passed filters', () => {
+    const result = getFormDataWithExtraFilters(mockArgs);
+    expect(result.extra_filters).to.have.length(2);
+    expect(result.extra_filters[0]).to.deep.equal({
+      col: 'region',
+      op: 'in',
+      val: ['Spain'],
+    });
+    expect(result.extra_filters[1]).to.deep.equal({
+      col: 'color',
+      op: 'in',
+      val: ['pink', 'purple'],
+    });
+  });
+
+  it('should not add additional filters if the slice is immune to them', () => {
+    const result = getFormDataWithExtraFilters({
+      ...mockArgs,
+      dashboardMetadata: {
+        filter_immune_slices: [chartId],
+      },
+    });
+    expect(result.extra_filters).to.have.length(0);
+  });
+
+  it('should not add additional filters for fields to which the slice is immune', () => {
+    const result = getFormDataWithExtraFilters({
+      ...mockArgs,
+      dashboardMetadata: {
+        filter_immune_slice_fields: {
+          [chartId]: ['region'],
+        },
+      },
+    });
+    expect(result.extra_filters).to.have.length(1);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/isValidChild_spec.js b/superset/assets/spec/javascripts/dashboard/util/isValidChild_spec.js
new file mode 100644
index 0000000..3563059
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/isValidChild_spec.js
@@ -0,0 +1,147 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import isValidChild from '../../../../src/dashboard/util/isValidChild';
+
+import {
+  CHART_TYPE as CHART,
+  COLUMN_TYPE as COLUMN,
+  DASHBOARD_GRID_TYPE as GRID,
+  DASHBOARD_ROOT_TYPE as ROOT,
+  DIVIDER_TYPE as DIVIDER,
+  HEADER_TYPE as HEADER,
+  MARKDOWN_TYPE as MARKDOWN,
+  ROW_TYPE as ROW,
+  TABS_TYPE as TABS,
+  TAB_TYPE as TAB,
+} from '../../../../src/dashboard/util/componentTypes';
+
+const getIndentation = depth =>
+  Array(depth * 3)
+    .fill('')
+    .join('-');
+
+describe('isValidChild', () => {
+  describe('valid calls', () => {
+    // these are representations of nested structures for easy testing
+    //  [ROOT (depth 0) > GRID (depth 1) > HEADER (depth 2)]
+    // every unique parent > child relationship is tested, but because this
+    // test representation WILL result in duplicates, we hash each test
+    // to keep track of which we've run
+    const didTest = {};
+    const validExamples = [
+      [ROOT, GRID, CHART], // chart is valid because it is wrapped in a row
+      [ROOT, GRID, MARKDOWN], // markdown is valid because it is wrapped in a row
+      [ROOT, GRID, COLUMN], // column is valid because it is wrapped in a row
+      [ROOT, GRID, HEADER],
+      [ROOT, GRID, ROW, MARKDOWN],
+      [ROOT, GRID, ROW, CHART],
+
+      [ROOT, GRID, ROW, COLUMN, HEADER],
+      [ROOT, GRID, ROW, COLUMN, DIVIDER],
+      [ROOT, GRID, ROW, COLUMN, CHART],
+      [ROOT, GRID, ROW, COLUMN, MARKDOWN],
+
+      [ROOT, GRID, ROW, COLUMN, ROW, CHART],
+      [ROOT, GRID, ROW, COLUMN, ROW, MARKDOWN],
+
+      [ROOT, GRID, ROW, COLUMN, ROW, COLUMN, CHART],
+      [ROOT, GRID, ROW, COLUMN, ROW, COLUMN, MARKDOWN],
+      [ROOT, GRID, TABS, TAB, ROW, COLUMN, ROW, COLUMN, MARKDOWN],
+
+      // tab equivalents
+      [ROOT, TABS, TAB, CHART],
+      [ROOT, TABS, TAB, MARKDOWN],
+      [ROOT, TABS, TAB, COLUMN],
+      [ROOT, TABS, TAB, HEADER],
+      [ROOT, TABS, TAB, ROW, MARKDOWN],
+      [ROOT, TABS, TAB, ROW, CHART],
+
+      [ROOT, TABS, TAB, ROW, COLUMN, HEADER],
+      [ROOT, TABS, TAB, ROW, COLUMN, DIVIDER],
+      [ROOT, TABS, TAB, ROW, COLUMN, CHART],
+      [ROOT, TABS, TAB, ROW, COLUMN, MARKDOWN],
+
+      [ROOT, TABS, TAB, ROW, COLUMN, ROW, CHART],
+      [ROOT, TABS, TAB, ROW, COLUMN, ROW, MARKDOWN],
+
+      [ROOT, TABS, TAB, ROW, COLUMN, ROW, COLUMN, CHART],
+      [ROOT, TABS, TAB, ROW, COLUMN, ROW, COLUMN, MARKDOWN],
+      [ROOT, TABS, TAB, TABS, TAB, ROW, COLUMN, ROW, COLUMN, MARKDOWN],
+    ];
+
+    validExamples.forEach((example, exampleIdx) => {
+      let childDepth = 0;
+      example.forEach((childType, i) => {
+        const parentDepth = childDepth - 1;
+        const parentType = example[i - 1];
+        const testKey = `${parentType}-${childType}-${parentDepth}`;
+
+        if (i > 0 && !didTest[testKey]) {
+          didTest[testKey] = true;
+
+          it(`(${exampleIdx})${getIndentation(
+            childDepth,
+          )}${parentType} (depth ${parentDepth}) > ${childType} ✅`, () => {
+            expect(
+              isValidChild({
+                parentDepth,
+                parentType,
+                childType,
+              }),
+            ).to.equal(true);
+          });
+        }
+        // see isValidChild.js for why tabs do not increment the depth of their children
+        childDepth += childType !== TABS && childType !== TAB ? 1 : 0;
+      });
+    });
+  });
+
+  describe('invalid calls', () => {
+    // In order to assert that a parent > child hierarchy at a given depth is invalid
+    // we also define some valid hierarchies in doing so. we indicate which
+    // parent > [child] relationships should be asserted as invalid using a nested array
+    const invalidExamples = [
+      [ROOT, [DIVIDER]],
+      [ROOT, [CHART]],
+      [ROOT, [MARKDOWN]],
+      [ROOT, GRID, [TAB]],
+      [ROOT, GRID, TABS, [ROW]],
+      // [ROOT, GRID, TABS, TAB, [TABS]], // @TODO this needs to be fixed
+      [ROOT, GRID, ROW, [TABS]],
+      [ROOT, GRID, ROW, [TAB]],
+      [ROOT, GRID, ROW, [DIVIDER]],
+      [ROOT, GRID, ROW, COLUMN, [TABS]],
+      [ROOT, GRID, ROW, COLUMN, [TAB]],
+      [ROOT, GRID, ROW, COLUMN, ROW, [DIVIDER]],
+      [ROOT, GRID, ROW, COLUMN, ROW, COLUMN, [ROW]], // too nested
+    ];
+
+    invalidExamples.forEach((example, exampleIdx) => {
+      let childDepth = 0;
+      example.forEach((childType, i) => {
+        const shouldTestChild = Array.isArray(childType);
+
+        if (i > 0 && shouldTestChild) {
+          const parentDepth = childDepth - 1;
+          const parentType = example[i - 1];
+
+          it(`(${exampleIdx})${getIndentation(
+            childDepth,
+          )}${parentType} (depth ${parentDepth}) > ${childType} ❌`, () => {
+            expect(
+              isValidChild({
+                parentDepth,
+                parentType,
+                childType,
+              }),
+            ).to.equal(false);
+          });
+        }
+        // see isValidChild.js for why tabs do not increment the depth of their children
+        childDepth += childType !== TABS && childType !== TAB ? 1 : 0;
+      });
+    });
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/newComponentFactory_spec.js b/superset/assets/spec/javascripts/dashboard/util/newComponentFactory_spec.js
new file mode 100644
index 0000000..f52eba9
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/newComponentFactory_spec.js
@@ -0,0 +1,51 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import newComponentFactory from '../../../../src/dashboard/util/newComponentFactory';
+
+import {
+  CHART_TYPE,
+  COLUMN_TYPE,
+  DASHBOARD_GRID_TYPE,
+  DASHBOARD_ROOT_TYPE,
+  DIVIDER_TYPE,
+  HEADER_TYPE,
+  MARKDOWN_TYPE,
+  NEW_COMPONENT_SOURCE_TYPE,
+  ROW_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+const types = [
+  CHART_TYPE,
+  COLUMN_TYPE,
+  DASHBOARD_GRID_TYPE,
+  DASHBOARD_ROOT_TYPE,
+  DIVIDER_TYPE,
+  HEADER_TYPE,
+  MARKDOWN_TYPE,
+  NEW_COMPONENT_SOURCE_TYPE,
+  ROW_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+];
+
+describe('newEntityFactory', () => {
+  types.forEach(type => {
+    it(`returns a new ${type}`, () => {
+      const result = newComponentFactory(type);
+
+      expect(result.type).to.equal(type);
+      expect(typeof result.id).to.equal('string');
+      expect(typeof result.meta).to.equal('object');
+      expect(Array.isArray(result.children)).to.equal(true);
+    });
+  });
+
+  it('adds passed meta data to the entity', () => {
+    const banana = 'banana';
+    const result = newComponentFactory(CHART_TYPE, { banana });
+    expect(result.meta.banana).to.equal(banana);
+  });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/newEntitiesFromDrop_spec.js b/superset/assets/spec/javascripts/dashboard/util/newEntitiesFromDrop_spec.js
new file mode 100644
index 0000000..8d00c18
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/newEntitiesFromDrop_spec.js
@@ -0,0 +1,85 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import newEntitiesFromDrop from '../../../../src/dashboard/util/newEntitiesFromDrop';
+import {
+  CHART_TYPE,
+  DASHBOARD_GRID_TYPE,
+  ROW_TYPE,
+  TABS_TYPE,
+  TAB_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+describe('newEntitiesFromDrop', () => {
+  it('should return a new Entity of appropriate type, and add it to the drop target children', () => {
+    const result = newEntitiesFromDrop({
+      dropResult: {
+        destination: { id: 'a', index: 0 },
+        dragging: { type: CHART_TYPE },
+        source: { id: 'b', index: 0 },
+      },
+      layout: {
+        a: {
+          id: 'a',
+          type: ROW_TYPE,
+          children: [],
+        },
+      },
+    });
+
+    const newId = result.a.children[0];
+    expect(result.a.children.length).to.equal(1);
+    expect(Object.keys(result).length).to.equal(2);
+    expect(result[newId].type).to.equal(CHART_TYPE);
+  });
+
+  it('should create Tab AND Tabs components if the drag entity is Tabs', () => {
+    const result = newEntitiesFromDrop({
+      dropResult: {
+        destination: { id: 'a', index: 0 },
+        dragging: { type: TABS_TYPE },
+        source: { id: 'b', index: 0 },
+      },
+      layout: {
+        a: {
+          id: 'a',
+          type: DASHBOARD_GRID_TYPE,
+          children: [],
+        },
+      },
+    });
+
+    const newTabsId = result.a.children[0];
+    const newTabId = result[newTabsId].children[0];
+
+    expect(result.a.children.length).to.equal(1);
+    expect(Object.keys(result).length).to.equal(3);
+    expect(result[newTabsId].type).to.equal(TABS_TYPE);
+    expect(result[newTabId].type).to.equal(TAB_TYPE);
+  });
+
+  it('should create a Row if the drag entity should be wrapped in a row', () => {
+    const result = newEntitiesFromDrop({
+      dropResult: {
+        destination: { id: 'a', index: 0 },
+        dragging: { type: CHART_TYPE },
+        source: { id: 'b', index: 0 },
+      },
+      layout: {
+        a: {
+          id: 'a',
+          type: DASHBOARD_GRID_TYPE,
+          children: [],
+        },
+      },
+    });
+
+    const newRowId = result.a.children[0];
+    const newChartId = result[newRowId].children[0];
+
+    expect(result.a.children.length).to.equal(1);
+    expect(Object.keys(result).length).to.equal(3);
+    expect(result[newRowId].type).to.equal(ROW_TYPE);
+    expect(result[newChartId].type).to.equal(CHART_TYPE);
+  });
+});
diff --git a/superset/assets/spec/javascripts/logger_spec.js b/superset/assets/spec/javascripts/logger_spec.js
new file mode 100644
index 0000000..64580b4
--- /dev/null
+++ b/superset/assets/spec/javascripts/logger_spec.js
@@ -0,0 +1,143 @@
+import $ from 'jquery';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import { Logger, ActionLog } from '../../src/logger';
+
+describe('ActionLog', () => {
+  it('should be a constructor', () => {
+    const newLogger = new ActionLog({});
+    expect(newLogger instanceof ActionLog).to.equal(true);
+  });
+
+  it('should set the eventNames, impressionId, source, sourceId, and sendNow init parameters', () => {
+    const eventNames = [];
+    const impressionId = 'impressionId';
+    const source = 'source';
+    const sourceId = 'sourceId';
+    const sendNow = true;
+
+    const log = new ActionLog({ eventNames, impressionId, source, sourceId, sendNow });
+    expect(log.eventNames).to.equal(eventNames);
+    expect(log.impressionId).to.equal(impressionId);
+    expect(log.source).to.equal(source);
+    expect(log.sourceId).to.equal(sourceId);
+    expect(log.sendNow).to.equal(sendNow);
+  });
+
+  it('should set attributes with the setAttribute method', () => {
+    const log = new ActionLog({});
+    expect(log.test).to.equal(undefined);
+    log.setAttribute('test', 'testValue');
+    expect(log.test).to.equal('testValue');
+  });
+
+  it('should track added events', () => {
+    const log = new ActionLog({});
+    const eventName = 'myEventName';
+    const eventBody = { test: 'event' };
+    expect(log.events[eventName]).to.equal(undefined);
+
+    log.addEvent(eventName, eventBody);
+    expect(log.events[eventName]).to.have.length(1);
+    expect(log.events[eventName][0]).to.deep.include(eventBody);
+  });
+});
+
+describe('Logger', () => {
+  it('should add events when .append(eventName, eventBody) is called', () => {
+    const eventName = 'testEvent';
+    const eventBody = { test: 'event' };
+    const log = new ActionLog({ eventNames: [eventName] });
+    Logger.start(log);
+    Logger.append(eventName, eventBody);
+    expect(log.events[eventName]).to.have.length(1);
+    expect(log.events[eventName][0]).to.deep.include(eventBody);
+    Logger.end(log);
+  });
+
+  describe('.send()', () => {
+    beforeEach(() => {
+      sinon.spy($, 'ajax');
+    });
+    afterEach(() => {
+      $.ajax.restore();
+    });
+
+    const eventNames = ['test'];
+
+    function setup(overrides = {}) {
+      const log = new ActionLog({ eventNames, ...overrides });
+      return log;
+    }
+
+    it('should POST an event to /superset/log/ when called', () => {
+      const log = setup();
+      Logger.start(log);
+      Logger.append(eventNames[0], { test: 'event' });
+      expect(log.events[eventNames[0]]).to.have.length(1);
+      Logger.end(log);
+      expect($.ajax.calledOnce).to.equal(true);
+      const args = $.ajax.getCall(0).args[0];
+      expect(args.url).to.equal('/superset/log/');
+      expect(args.method).to.equal('POST');
+    });
+
+    it("should flush the log's events", () => {
+      const log = setup();
+      Logger.start(log);
+      Logger.append(eventNames[0], { test: 'event' });
+      const event = log.events[eventNames[0]][0];
+      expect(event).to.deep.include({ test: 'event' });
+      Logger.end(log);
+      expect(log.events).to.deep.equal({});
+    });
+
+    it('should include ts, start_offset, event_name, impression_id, source, and source_id in every event', () => {
+      const config = {
+        eventNames: ['event1', 'event2'],
+        impressionId: 'impress_me',
+        source: 'superset',
+        sourceId: 'lolz',
+      };
+      const log = setup(config);
+
+      Logger.start(log);
+      Logger.append('event1', { key: 'value' });
+      Logger.append('event2', { foo: 'bar' });
+      Logger.end(log);
+
+      const args = $.ajax.getCall(0).args[0];
+      const events = JSON.parse(args.data.events);
+
+      expect(events).to.have.length(2);
+      expect(events[0]).to.deep.include({
+        key: 'value',
+        event_name: 'event1',
+        impression_id: config.impressionId,
+        source: config.source,
+        source_id: config.sourceId,
+      });
+      expect(events[1]).to.deep.include({
+        foo: 'bar',
+        event_name: 'event2',
+        impression_id: config.impressionId,
+        source: config.source,
+        source_id: config.sourceId,
+      });
+      expect(typeof events[0].ts).to.equal('number');
+      expect(typeof events[1].ts).to.equal('number');
+      expect(typeof events[0].start_offset).to.equal('number');
+      expect(typeof events[1].start_offset).to.equal('number');
+    });
+
+    it('should send() a log immediately if .append() is called with sendNow=true', () => {
+      const log = setup();
+      Logger.start(log);
+      Logger.append(eventNames[0], { test: 'event' }, true);
+      expect($.ajax.calledOnce).to.equal(true);
+      Logger.end(log);
+    });
+  });
+});
diff --git a/superset/assets/src/SqlLab/components/QuerySearch.jsx b/superset/assets/src/SqlLab/components/QuerySearch.jsx
index 9d36d85..45924e3 100644
--- a/superset/assets/src/SqlLab/components/QuerySearch.jsx
+++ b/superset/assets/src/SqlLab/components/QuerySearch.jsx
@@ -2,14 +2,19 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { Button } from 'react-bootstrap';
 import Select from 'react-select';
+import Loading from '../../components/Loading';
 import QueryTable from './QueryTable';
-import { now, epochTimeXHoursAgo,
-  epochTimeXDaysAgo, epochTimeXYearsAgo } from '../../modules/dates';
+import {
+  now,
+  epochTimeXHoursAgo,
+  epochTimeXDaysAgo,
+  epochTimeXYearsAgo,
+} from '../../modules/dates';
 import { STATUS_OPTIONS, TIME_OPTIONS } from '../constants';
 import AsyncSelect from '../../components/AsyncSelect';
 import { t } from '../../locales';
 
-const $ = window.$ = require('jquery');
+const $ = (window.$ = require('jquery'));
 
 const propTypes = {
   actions: PropTypes.object.isRequired,
@@ -47,13 +52,17 @@ class QuerySearch extends React.PureComponent {
     this.refreshQueries();
   }
   onUserClicked(userId) {
-    this.setState({ userId }, () => { this.refreshQueries(); });
+    this.setState({ userId }, () => {
+      this.refreshQueries();
+    });
   }
   onDbClicked(dbId) {
-    this.setState({ databaseId: dbId }, () => { this.refreshQueries(); });
+    this.setState({ databaseId: dbId }, () => {
+      this.refreshQueries();
+    });
   }
   onChange(db) {
-    const val = (db) ? db.value : null;
+    const val = db ? db.value : null;
     this.setState({ databaseId: val });
   }
   getTimeFromSelection(selection) {
@@ -77,25 +86,25 @@ class QuerySearch extends React.PureComponent {
     }
   }
   changeFrom(user) {
-    const val = (user) ? user.value : null;
+    const val = user ? user.value : null;
     this.setState({ from: val });
   }
   changeTo(status) {
-    const val = (status) ? status.value : null;
+    const val = status ? status.value : null;
     this.setState({ to: val });
   }
   changeUser(user) {
-    const val = (user) ? user.value : null;
+    const val = user ? user.value : null;
     this.setState({ userId: val });
   }
   insertParams(baseUrl, params) {
-    const validParams = params.filter(
-      function (p) { return p !== ''; },
-    );
+    const validParams = params.filter(function (p) {
+      return p !== '';
+    });
     return baseUrl + '?' + validParams.join('&');
   }
   changeStatus(status) {
-    const val = (status) ? status.value : null;
+    const val = status ? status.value : null;
     this.setState({ status: val });
   }
   changeSearch(event) {
@@ -120,7 +129,7 @@ class QuerySearch extends React.PureComponent {
     if (data.result.length === 0) {
       this.props.actions.addAlert({
         bsStyle: 'danger',
-        msg: t('It seems you don\'t have access to any database'),
+        msg: t("It seems you don't have access to any database"),
       });
     }
     return options;
@@ -175,8 +184,10 @@ class QuerySearch extends React.PureComponent {
             <Select
               name="select-from"
               placeholder={t('[From]-')}
-              options={TIME_OPTIONS
-                .slice(1, TIME_OPTIONS.length).map(xt => ({ value: xt, label: xt }))}
+              options={TIME_OPTIONS.slice(1, TIME_OPTIONS.length).map(xt => ({
+                value: xt,
+                label: xt,
+              }))}
               value={this.state.from}
               autosize={false}
               onChange={this.changeFrom}
@@ -206,29 +217,21 @@ class QuerySearch extends React.PureComponent {
             </Button>
           </div>
         </div>
-        {this.state.queriesLoading ?
-          (<img className="loading" alt="Loading..." src="/static/assets/images/loading.gif" />)
-          :
-          (
-            <div className="scrollbar-container">
-              <div
-                className="scrollbar-content"
-                style={{ height: this.props.height }}
-              >
-                <QueryTable
-                  columns={[
-                    'state', 'db', 'user', 'time',
-                    'progress', 'rows', 'sql', 'querylink',
-                  ]}
-                  onUserClicked={this.onUserClicked}
-                  onDbClicked={this.onDbClicked}
-                  queries={this.state.queriesArray}
-                  actions={this.props.actions}
-                />
-              </div>
+        {this.state.queriesLoading ? (
+          <Loading />
+        ) : (
+          <div className="scrollbar-container">
+            <div className="scrollbar-content" style={{ height: this.props.height }}>
+              <QueryTable
+                columns={['state', 'db', 'user', 'time', 'progress', 'rows', 'sql', 'querylink']}
+                onUserClicked={this.onUserClicked}
+                onDbClicked={this.onDbClicked}
+                queries={this.state.queriesArray}
+                actions={this.props.actions}
+              />
             </div>
-          )
-        }
+          </div>
+        )}
       </div>
     );
   }
diff --git a/superset/assets/src/SqlLab/components/ResultSet.jsx b/superset/assets/src/SqlLab/components/ResultSet.jsx
index 4959921..67c8fd5 100644
--- a/superset/assets/src/SqlLab/components/ResultSet.jsx
+++ b/superset/assets/src/SqlLab/components/ResultSet.jsx
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
 import { Alert, Button, ButtonGroup, ProgressBar } from 'react-bootstrap';
 import shortid from 'shortid';
 
+import Loading from '../../components/Loading';
 import VisualizeModal from './VisualizeModal';
 import HighlightedSql from './HighlightedSql';
 import FilterableTable from '../../components/FilterableTable/FilterableTable';
@@ -238,7 +239,7 @@ export default class ResultSet extends React.PureComponent {
     }
     return (
       <div>
-        <img className="loading" alt={t('Loading...')} src="/static/assets/images/loading.gif" />
+        <Loading />
         <QueryStateLabel query={query} />
         {progressBar}
         <div>
diff --git a/superset/assets/src/chart/Chart.jsx b/superset/assets/src/chart/Chart.jsx
index f223174..1718fc7 100644
--- a/superset/assets/src/chart/Chart.jsx
+++ b/superset/assets/src/chart/Chart.jsx
@@ -7,7 +7,7 @@ import { Tooltip } from 'react-bootstrap';
 import { d3format } from '../modules/utils';
 import ChartBody from './ChartBody';
 import Loading from '../components/Loading';
-import { Logger, LOG_ACTIONS_RENDER_EVENT } from '../logger';
+import { Logger, LOG_ACTIONS_RENDER_CHART } from '../logger';
 import StackTraceMessage from '../components/StackTraceMessage';
 import RefreshChartOverlay from '../components/RefreshChartOverlay';
 import visMap from '../visualizations';
@@ -17,7 +17,7 @@ import './chart.css';
 const propTypes = {
   annotationData: PropTypes.object,
   actions: PropTypes.object,
-  chartKey: PropTypes.string.isRequired,
+  chartId: PropTypes.number.isRequired,
   containerId: PropTypes.string.isRequired,
   datasource: PropTypes.object.isRequired,
   formData: PropTypes.object.isRequired,
@@ -42,8 +42,6 @@ const propTypes = {
   // dashboard callbacks
   addFilter: PropTypes.func,
   getFilters: PropTypes.func,
-  clearFilter: PropTypes.func,
-  removeFilter: PropTypes.func,
   onQuery: PropTypes.func,
   onDismissRefreshOverlay: PropTypes.func,
 };
@@ -51,8 +49,6 @@ const propTypes = {
 const defaultProps = {
   addFilter: () => ({}),
   getFilters: () => ({}),
-  clearFilter: () => ({}),
-  removeFilter: () => ({}),
 };
 
 class Chart extends React.PureComponent {
@@ -67,8 +63,6 @@ class Chart extends React.PureComponent {
     this.datasource = props.datasource;
     this.addFilter = this.addFilter.bind(this);
     this.getFilters = this.getFilters.bind(this);
-    this.clearFilter = this.clearFilter.bind(this);
-    this.removeFilter = this.removeFilter.bind(this);
     this.headerHeight = this.headerHeight.bind(this);
     this.height = this.height.bind(this);
     this.width = this.width.bind(this);
@@ -76,10 +70,11 @@ class Chart extends React.PureComponent {
 
   componentDidMount() {
     if (this.props.triggerQuery) {
-      this.props.actions.runQuery(this.props.formData, false,
-        this.props.timeout,
-        this.props.chartKey,
-      );
+      const { formData } = this.props;
+      this.props.actions.runQuery(formData, false, this.props.timeout, this.props.chartId);
+    } else {
+      // when drag/dropping in a dashboard, a chart may be unmounted/remounted but still have data
+      this.renderViz();
     }
   }
 
@@ -93,10 +88,10 @@ class Chart extends React.PureComponent {
 
   componentDidUpdate(prevProps) {
     if (
-        this.props.queryResponse &&
-        ['success', 'rendered'].indexOf(this.props.chartStatus) > -1 &&
-        !this.props.queryResponse.error && (
-        prevProps.annotationData !== this.props.annotationData ||
+      this.props.queryResponse &&
+      ['success', 'rendered'].indexOf(this.props.chartStatus) > -1 &&
+      !this.props.queryResponse.error &&
+      (prevProps.annotationData !== this.props.annotationData ||
         prevProps.queryResponse !== this.props.queryResponse ||
         prevProps.height !== this.props.height ||
         prevProps.width !== this.props.width ||
@@ -118,20 +113,14 @@ class Chart extends React.PureComponent {
     this.props.addFilter(col, vals, merge, refresh);
   }
 
-  clearFilter() {
-    this.props.clearFilter();
-  }
-
-  removeFilter(col, vals, refresh = true) {
-    this.props.removeFilter(col, vals, refresh);
-  }
-
   clearError() {
     this.setState({ errorMsg: null });
   }
 
   width() {
-    return this.props.width || this.container.el.offsetWidth;
+    return (
+      this.props.width || (this.container && this.container.el && this.container.el.offsetWidth)
+    );
   }
 
   headerHeight() {
@@ -139,7 +128,9 @@ class Chart extends React.PureComponent {
   }
 
   height() {
-    return this.props.height || this.container.el.offsetHeight;
+    return (
+      this.props.height || (this.container && this.container.el && this.container.el.offsetHeight)
+    );
   }
 
   d3format(col, number) {
@@ -150,7 +141,7 @@ class Chart extends React.PureComponent {
   }
 
   error(e) {
-    this.props.actions.chartRenderingFailed(e, this.props.chartKey);
+    this.props.actions.chartRenderingFailed(e, this.props.chartId);
   }
 
   verboseMetricName(metric) {
@@ -167,7 +158,6 @@ class Chart extends React.PureComponent {
 
   renderTooltip() {
     if (this.state.tooltip) {
-      /* eslint-disable react/no-danger */
       return (
         <Tooltip
           className="chart-tooltip"
@@ -177,77 +167,83 @@ class Chart extends React.PureComponent {
           positionLeft={this.state.tooltip.x + 30}
           arrowOffsetTop={10}
         >
-          <div dangerouslySetInnerHTML={{ __html: this.state.tooltip.content }} />
+          <div // eslint-disable-next-line react/no-danger
+            dangerouslySetInnerHTML={{ __html: this.state.tooltip.content }}
+          />
         </Tooltip>
       );
-      /* eslint-enable react/no-danger */
     }
     return null;
   }
 
   renderViz() {
-    const viz = visMap[this.props.vizType];
-    const fd = this.props.formData;
-    const qr = this.props.queryResponse;
+    const { vizType, formData, queryResponse, setControlValue, chartId, chartStatus } = this.props;
+    const visRenderer = visMap[vizType];
     const renderStart = Logger.getTimestamp();
     try {
       // Executing user-defined data mutator function
-      if (fd.js_data) {
-        qr.data = sandboxedEval(fd.js_data)(qr.data);
+      if (formData.js_data) {
+        queryResponse.data = sandboxedEval(formData.js_data)(queryResponse.data);
+      }
+      visRenderer(this, queryResponse, setControlValue);
+      if (chartStatus !== 'rendered') {
+        this.props.actions.chartRenderingSucceeded(chartId);
       }
-      // [re]rendering the visualization
-      viz(this, qr, this.props.setControlValue);
-      Logger.append(LOG_ACTIONS_RENDER_EVENT, {
-        label: this.props.chartKey,
-        vis_type: this.props.vizType,
+      Logger.append(LOG_ACTIONS_RENDER_CHART, {
+        slice_id: 'slice_' + chartId,
+        viz_type: vizType,
         start_offset: renderStart,
         duration: Logger.getTimestamp() - renderStart,
       });
-      this.props.actions.chartRenderingSucceeded(this.props.chartKey);
+      this.props.actions.chartRenderingSucceeded(chartId);
     } catch (e) {
-      this.props.actions.chartRenderingFailed(e, this.props.chartKey);
+      console.error(e); // eslint-disable-line no-console
+      this.props.actions.chartRenderingFailed(e, chartId);
     }
   }
 
   render() {
     const isLoading = this.props.chartStatus === 'loading';
+
+    // this allows <Loading /> to be positioned in the middle of the chart
+    const containerStyles = isLoading ? { height: this.height(), width: this.width() } : null;
     return (
-      <div className={`token col-md-12 ${isLoading ? 'is-loading' : ''}`}>
+      <div className={`chart-container ${isLoading ? 'is-loading' : ''}`} style={containerStyles}>
         {this.renderTooltip()}
-        {isLoading &&
-          <Loading size={25} />
-        }
-        {this.props.chartAlert &&
-        <StackTraceMessage
-          message={this.props.chartAlert}
-          queryResponse={this.props.queryResponse}
-        />
-        }
+        {isLoading && <Loading size={75} />}
+        {this.props.chartAlert && (
+          <StackTraceMessage
+            message={this.props.chartAlert}
+            queryResponse={this.props.queryResponse}
+          />
+        )}
 
         {!isLoading &&
           !this.props.chartAlert &&
           this.props.refreshOverlayVisible &&
           !this.props.errorMessage &&
-          this.container &&
-          <RefreshChartOverlay
-            height={this.height()}
-            width={this.width()}
-            onQuery={this.props.onQuery}
-            onDismiss={this.props.onDismissRefreshOverlay}
-          />
-        }
-        {!isLoading && !this.props.chartAlert &&
-          <ChartBody
-            containerId={this.containerId}
-            vizType={this.props.vizType}
-            height={this.height}
-            width={this.width}
-            faded={this.props.refreshOverlayVisible && !this.props.errorMessage}
-            ref={(inner) => {
-              this.container = inner;
-            }}
-          />
-        }
+          this.container && (
+            <RefreshChartOverlay
+              height={this.height()}
+              width={this.width()}
+              onQuery={this.props.onQuery}
+              onDismiss={this.props.onDismissRefreshOverlay}
+            />
+          )}
+
+        {!isLoading &&
+          !this.props.chartAlert && (
+            <ChartBody
+              containerId={this.containerId}
+              vizType={this.props.vizType}
+              height={this.height}
+              width={this.width}
+              faded={this.props.refreshOverlayVisible && !this.props.errorMessage}
+              ref={(inner) => {
+                this.container = inner;
+              }}
+            />
+          )}
       </div>
     );
   }
diff --git a/superset/assets/src/chart/ChartContainer.jsx b/superset/assets/src/chart/ChartContainer.jsx
index b731412..b66fe5d 100644
--- a/superset/assets/src/chart/ChartContainer.jsx
+++ b/superset/assets/src/chart/ChartContainer.jsx
@@ -1,29 +1,13 @@
 import { connect } from 'react-redux';
 import { bindActionCreators } from 'redux';
 
-import * as Actions from './chartAction';
+import * as actions from './chartAction';
 import Chart from './Chart';
 
-function mapStateToProps({ charts }, ownProps) {
-  const chart = charts[ownProps.chartKey];
-  return {
-    annotationData: chart.annotationData,
-    chartAlert: chart.chartAlert,
-    chartStatus: chart.chartStatus,
-    chartUpdateEndTime: chart.chartUpdateEndTime,
-    chartUpdateStartTime: chart.chartUpdateStartTime,
-    latestQueryFormData: chart.latestQueryFormData,
-    lastRendered: chart.lastRendered,
-    queryResponse: chart.queryResponse,
-    queryRequest: chart.queryRequest,
-    triggerQuery: chart.triggerQuery,
-  };
-}
-
 function mapDispatchToProps(dispatch) {
   return {
-    actions: bindActionCreators(Actions, dispatch),
+    actions: bindActionCreators(actions, dispatch),
   };
 }
 
-export default connect(mapStateToProps, mapDispatchToProps)(Chart);
+export default connect(null, mapDispatchToProps)(Chart);
diff --git a/superset/assets/src/chart/chartAction.js b/superset/assets/src/chart/chartAction.js
index cb24f65..82c4250 100644
--- a/superset/assets/src/chart/chartAction.js
+++ b/superset/assets/src/chart/chartAction.js
@@ -1,10 +1,10 @@
 import { getExploreUrlAndPayload, getAnnotationJsonUrl } from '../explore/exploreUtils';
 import { requiresQuery, ANNOTATION_SOURCE_TYPES } from '../modules/AnnotationTypes';
-import { Logger, LOG_ACTIONS_LOAD_EVENT } from '../logger';
+import { Logger, LOG_ACTIONS_LOAD_CHART } from '../logger';
 import { COMMON_ERR_MESSAGES } from '../common';
 import { t } from '../locales';
 
-const $ = window.$ = require('jquery');
+const $ = (window.$ = require('jquery'));
 
 export const CHART_UPDATE_STARTED = 'CHART_UPDATE_STARTED';
 export function chartUpdateStarted(queryRequest, latestQueryFormData, key) {
@@ -74,11 +74,13 @@ export function runAnnotationQuery(annotation, timeout = 60, formData = null, ke
     fd.time_grain_sqla = granularity;
     fd.granularity = granularity;
 
-    const sliceFormData = Object.keys(annotation.overrides)
-      .reduce((d, k) => ({
+    const sliceFormData = Object.keys(annotation.overrides).reduce(
+      (d, k) => ({
         ...d,
         [k]: annotation.overrides[k] || fd[k],
-      }), {});
+      }),
+      {},
+    );
     const isNative = annotation.sourceType === ANNOTATION_SOURCE_TYPES.NATIVE;
     const url = getAnnotationJsonUrl(annotation.value, sliceFormData, isNative);
     const queryRequest = $.ajax({
@@ -117,6 +119,11 @@ export function updateQueryFormData(value, key) {
   return { type: UPDATE_QUERY_FORM_DATA, value, key };
 }
 
+export const ADD_CHART = 'ADD_CHART';
+export function addChart(chart, key) {
+  return { type: ADD_CHART, chart, key };
+}
+
 export const RUN_QUERY = 'RUN_QUERY';
 export function runQuery(formData, force = false, timeout = 60, key) {
   return (dispatch) => {
@@ -138,19 +145,22 @@ export function runQuery(formData, force = false, timeout = 60, key) {
     const queryPromise = Promise.resolve(dispatch(chartUpdateStarted(queryRequest, payload, key)))
       .then(() => queryRequest)
       .then((queryResponse) => {
-        Logger.append(LOG_ACTIONS_LOAD_EVENT, {
-          label: key,
+        Logger.append(LOG_ACTIONS_LOAD_CHART, {
+          slice_id: 'slice_' + key,
           is_cached: queryResponse.is_cached,
+          force_refresh: force,
           row_count: queryResponse.rowcount,
           datasource: formData.datasource,
           start_offset: logStart,
           duration: Logger.getTimestamp() - logStart,
+          has_extra_filters: formData.extra_filters && formData.extra_filters.length > 0,
+          viz_type: formData.viz_type,
         });
         return dispatch(chartUpdateSucceeded(queryResponse, key));
       })
       .catch((err) => {
-        Logger.append(LOG_ACTIONS_LOAD_EVENT, {
-          label: key,
+        Logger.append(LOG_ACTIONS_LOAD_CHART, {
+          slice_id: 'slice_' + key,
           has_err: true,
           datasource: formData.datasource,
           start_offset: logStart,
@@ -190,3 +200,7 @@ export function runQuery(formData, force = false, timeout = 60, key) {
     ]);
   };
 }
+
+export function refreshChart(chart, force, timeout) {
+  return dispatch => dispatch(runQuery(chart.latestQueryFormData, force, timeout, chart.id));
+}
diff --git a/superset/assets/src/chart/chartReducer.js b/superset/assets/src/chart/chartReducer.js
index f68a5b8..ea8de8b 100644
--- a/superset/assets/src/chart/chartReducer.js
+++ b/superset/assets/src/chart/chartReducer.js
@@ -1,25 +1,10 @@
 /* eslint camelcase: 0 */
-import PropTypes from 'prop-types';
-
 import { now } from '../modules/dates';
 import * as actions from './chartAction';
 import { t } from '../locales';
 
-export const chartPropType = {
-  chartKey: PropTypes.string.isRequired,
-  chartAlert: PropTypes.string,
-  chartStatus: PropTypes.string,
-  chartUpdateEndTime: PropTypes.number,
-  chartUpdateStartTime: PropTypes.number,
-  latestQueryFormData: PropTypes.object,
-  queryRequest: PropTypes.object,
-  queryResponse: PropTypes.object,
-  triggerQuery: PropTypes.bool,
-  lastRendered: PropTypes.number,
-};
-
 export const chart = {
-  chartKey: '',
+  id: 0,
   chartAlert: null,
   chartStatus: 'loading',
   chartUpdateEndTime: null,
@@ -33,6 +18,12 @@ export const chart = {
 
 export default function chartReducer(charts = {}, action) {
   const actionHandlers = {
+    [actions.ADD_CHART]() {
+      return {
+        ...chart,
+        ...action.chart,
+      };
+    },
     [actions.CHART_UPDATE_SUCCEEDED](state) {
       return { ...state,
         chartStatus: 'success',
@@ -70,12 +61,12 @@ export default function chartReducer(charts = {}, action) {
       return { ...state,
         chartStatus: 'failed',
         chartAlert: (
-            `${t('Query timeout')} - ` +
-            t(`visualization queries are set to timeout at ${action.timeout} seconds. `) +
-            t('Perhaps your data has grown, your database is under unusual load, ' +
-                'or you are simply querying a data source that is too large ' +
-                'to be processed within the timeout range. ' +
-                'If that is the case, we recommend that you summarize your data further.')),
+          `${t('Query timeout')} - ` +
+          t(`visualization queries are set to timeout at ${action.timeout} seconds. `) +
+          t('Perhaps your data has grown, your database is under unusual load, ' +
+            'or you are simply querying a data source that is too large ' +
+            'to be processed within the timeout range. ' +
+            'If that is the case, we recommend that you summarize your data further.')),
       };
     },
     [actions.CHART_UPDATE_FAILED](state) {
@@ -151,7 +142,10 @@ export default function chartReducer(charts = {}, action) {
   }
 
   if (action.type in actionHandlers) {
-    return { ...charts, [action.key]: actionHandlers[action.type](charts[action.key], action) };
+    return {
+      ...charts,
+      [action.key]: actionHandlers[action.type](charts[action.key], action),
+    };
   }
 
   return charts;
diff --git a/superset/assets/src/components/ActionMenuItem.jsx b/superset/assets/src/components/ActionMenuItem.jsx
new file mode 100644
index 0000000..e6c4447
--- /dev/null
+++ b/superset/assets/src/components/ActionMenuItem.jsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { MenuItem } from 'react-bootstrap';
+
+import InfoTooltipWithTrigger from './InfoTooltipWithTrigger';
+
+export function MenuItemContent({ faIcon, text, tooltip, children }) {
+  return (
+    <span>
+      {faIcon && <i className={`fa fa-${faIcon}`}>&nbsp;</i>}
+      {text} {''}
+      <InfoTooltipWithTrigger
+        tooltip={tooltip}
+        label={faIcon ? `dash-${faIcon}` : ''}
+        placement="top"
+      />
+      {children}
+    </span>
+  );
+}
+
+MenuItemContent.propTypes = {
+  faIcon: PropTypes.string,
+  text: PropTypes.string,
+  tooltip: PropTypes.string,
+  children: PropTypes.node,
+};
+
+MenuItemContent.defaultProps = {
+  faIcon: '',
+  text: '',
+  tooltip: null,
+  children: null,
+};
+
+export function ActionMenuItem({
+  onClick,
+  href,
+  target,
+  text,
+  tooltip,
+  children,
+  faIcon,
+}) {
+  return (
+    <MenuItem onClick={onClick} href={href} target={target}>
+      <MenuItemContent faIcon={faIcon} text={text} tooltip={tooltip}>
+        {children}
+      </MenuItemContent>
+    </MenuItem>
+  );
+}
+
+ActionMenuItem.propTypes = {
+  onClick: PropTypes.func,
+  href: PropTypes.string,
+  target: PropTypes.string,
+  ...MenuItemContent.propTypes,
+};
+
+ActionMenuItem.defaultProps = {
+  onClick() {},
+  href: null,
+  target: null,
+};
diff --git a/superset/assets/src/components/EditableTitle.jsx b/superset/assets/src/components/EditableTitle.jsx
index b773340..45fea1d 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.handleKeyUp = this.handleKeyUp.bind(this);
     this.handleKeyPress = this.handleKeyPress.bind(this);
   }
+
   componentWillReceiveProps(nextProps) {
     if (nextProps.title !== this.state.title) {
       this.setState({
@@ -37,8 +40,9 @@ class EditableTitle extends React.PureComponent {
       });
     }
   }
+
   handleClick() {
-    if (!this.props.canEdit) {
+    if (!this.props.canEdit || this.state.isEditing) {
       return;
     }
 
@@ -46,6 +50,7 @@ class EditableTitle extends React.PureComponent {
       isEditing: true,
     });
   }
+
   handleBlur() {
     if (!this.props.canEdit) {
       return;
@@ -67,9 +72,31 @@ class EditableTitle extends React.PureComponent {
       this.setState({
         lastTitle: this.state.title,
       });
+    }
+
+    if (this.props.title !== this.state.title) {
       this.props.onSaveTitle(this.state.title);
     }
   }
+
+  handleKeyUp(ev) {
+    // this entire method exists to support using EditableTitle as the title of a
+    // react-bootstrap Tab, as a workaround for this line in react-bootstrap https://goo.gl/ZVLmv4
+    //
+    // tl;dr when a Tab EditableTitle is being edited, typically the Tab it's within has been
+    // clicked and is focused/active. for accessibility, when focused the Tab <a /> intercepts
+    // the ' ' key (among others, including all arrows) and onChange() doesn't fire. somehow
+    // keydown is still called so we can detect this and manually add a ' ' to the current title
+    if (ev.key === ' ') {
+      let title = ev.target.value;
+      const titleLength = (title || '').length;
+      if (title && title[titleLength - 1] !== ' ') {
+        title = `${title} `;
+        this.setState(() => ({ title }));
+      }
+    }
+  }
+
   handleChange(ev) {
     if (!this.props.canEdit) {
       return;
@@ -79,6 +106,7 @@ class EditableTitle extends React.PureComponent {
       title: ev.target.value,
     });
   }
+
   handleKeyPress(ev) {
     if (ev.key === 'Enter') {
       ev.preventDefault();
@@ -86,12 +114,14 @@ class EditableTitle extends React.PureComponent {
       this.handleBlur();
     }
   }
+
   render() {
-    let input = (
+    let content = (
       <input
         required
         type={this.state.isEditing ? 'text' : 'button'}
         value={this.state.title}
+        onKeyUp={this.handleKeyUp}
         onChange={this.handleChange}
         onBlur={this.handleBlur}
         onClick={this.handleClick}
@@ -99,18 +129,26 @@ class EditableTitle extends React.PureComponent {
       />
     );
     if (this.props.showTooltip) {
-      input = (
+      content = (
         <TooltipWrapper
           label="title"
           tooltip={this.props.canEdit ? t('click to edit title') :
               this.props.noPermitTooltip || t('You don\'t have the rights to alter this title.')}
         >
-          {input}
+          {content}
         </TooltipWrapper>
       );
     }
     return (
-      <span className="editable-title">{input}</span>
+      <span
+        className={cx(
+          'editable-title',
+          this.props.canEdit && 'editable-title--editable',
+          this.state.isEditing && 'editable-title--editing',
+        )}
+      >
+        {content}
+      </span>
     );
   }
 }
diff --git a/superset/assets/src/components/Loading.jsx b/superset/assets/src/components/Loading.jsx
index 416e770..953e702 100644
--- a/superset/assets/src/components/Loading.jsx
+++ b/superset/assets/src/components/Loading.jsx
@@ -5,7 +5,7 @@ const propTypes = {
   size: PropTypes.number,
 };
 const defaultProps = {
-  size: 25,
+  size: 50,
 };
 
 export default function Loading(props) {
@@ -15,14 +15,18 @@ export default function Loading(props) {
       alt="Loading..."
       src="/static/assets/images/loading.gif"
       style={{
-        width: props.size,
-        height: props.size,
+        width: Math.min(props.size, 50),
+        // height is auto
         padding: 0,
         margin: 0,
         position: 'absolute',
+        left: '50%',
+        top: '50%',
+        transform: 'translate(-50%, -50%)',
       }}
     />
   );
 }
+
 Loading.propTypes = propTypes;
 Loading.defaultProps = defaultProps;
diff --git a/superset/assets/src/dashboard/.eslintrc b/superset/assets/src/dashboard/.eslintrc
new file mode 100644
index 0000000..a3f86e3
--- /dev/null
+++ b/superset/assets/src/dashboard/.eslintrc
@@ -0,0 +1,33 @@
+{
+  "extends": "prettier",
+  "plugins": ["prettier"],
+  "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": 0,
+    "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,
+    "react/jsx-indent": 0,
+    "prettier/prettier": "error"
+  }
+}
diff --git a/superset/assets/src/dashboard/.prettierrc b/superset/assets/src/dashboard/.prettierrc
new file mode 100644
index 0000000..a20502b
--- /dev/null
+++ b/superset/assets/src/dashboard/.prettierrc
@@ -0,0 +1,4 @@
+{
+  "singleQuote": true,
+  "trailingComma": "all"
+}
diff --git a/superset/assets/src/dashboard/actions/dashboardLayout.js b/superset/assets/src/dashboard/actions/dashboardLayout.js
new file mode 100644
index 0000000..bd01146
--- /dev/null
+++ b/superset/assets/src/dashboard/actions/dashboardLayout.js
@@ -0,0 +1,203 @@
+import { ActionCreators as UndoActionCreators } from 'redux-undo';
+
+import { addInfoToast } from './messageToasts';
+import { setUnsavedChanges } from './dashboardState';
+import { TABS_TYPE, ROW_TYPE } from '../util/componentTypes';
+import {
+  DASHBOARD_ROOT_ID,
+  NEW_COMPONENTS_SOURCE_ID,
+  DASHBOARD_HEADER_ID,
+} from '../util/constants';
+import dropOverflowsParent from '../util/dropOverflowsParent';
+import findParentId from '../util/findParentId';
+
+// this is a helper that takes an action as input and dispatches
+// an additional setUnsavedChanges(true) action after the dispatch in the case
+// that dashboardState.hasUnsavedChanges is false.
+function setUnsavedChangesAfterAction(action) {
+  return (...args) => (dispatch, getState) => {
+    const result = action(...args);
+    if (typeof result === 'function') {
+      dispatch(result(dispatch, getState));
+    } else {
+      dispatch(result);
+    }
+
+    if (!getState().dashboardState.hasUnsavedChanges) {
+      dispatch(setUnsavedChanges(true));
+    }
+  };
+}
+
+// Component CRUD -------------------------------------------------------------
+export const UPDATE_COMPONENTS = 'UPDATE_COMPONENTS';
+
+export const updateComponents = setUnsavedChangesAfterAction(
+  nextComponents => ({
+    type: UPDATE_COMPONENTS,
+    payload: {
+      nextComponents,
+    },
+  }),
+);
+
+export function updateDashboardTitle(text) {
+  return (dispatch, getState) => {
+    const { dashboardLayout } = getState();
+    dispatch(
+      updateComponents({
+        [DASHBOARD_HEADER_ID]: {
+          ...dashboardLayout.present[DASHBOARD_HEADER_ID],
+          meta: {
+            text,
+          },
+        },
+      }),
+    );
+  };
+}
+
+export const DELETE_COMPONENT = 'DELETE_COMPONENT';
+export const deleteComponent = setUnsavedChangesAfterAction((id, parentId) => ({
+  type: DELETE_COMPONENT,
+  payload: {
+    id,
+    parentId,
+  },
+}));
+
+export const CREATE_COMPONENT = 'CREATE_COMPONENT';
+export const createComponent = setUnsavedChangesAfterAction(dropResult => ({
+  type: CREATE_COMPONENT,
+  payload: {
+    dropResult,
+  },
+}));
+
+// Tabs -----------------------------------------------------------------------
+export const CREATE_TOP_LEVEL_TABS = 'CREATE_TOP_LEVEL_TABS';
+export const createTopLevelTabs = setUnsavedChangesAfterAction(dropResult => ({
+  type: CREATE_TOP_LEVEL_TABS,
+  payload: {
+    dropResult,
+  },
+}));
+
+export const DELETE_TOP_LEVEL_TABS = 'DELETE_TOP_LEVEL_TABS';
+export const deleteTopLevelTabs = setUnsavedChangesAfterAction(() => ({
+  type: DELETE_TOP_LEVEL_TABS,
+  payload: {},
+}));
+
+// Resize ---------------------------------------------------------------------
+export const RESIZE_COMPONENT = 'RESIZE_COMPONENT';
+export function resizeComponent({ id, width, height }) {
+  return (dispatch, getState) => {
+    const { dashboardLayout: undoableLayout } = getState();
+    const { present: dashboard } = undoableLayout;
+    const component = dashboard[id];
+    const widthChanged = width && component.meta.width !== width;
+    const heightChanged = height && component.meta.height !== height;
+    if (component && (widthChanged || heightChanged)) {
+      // update the size of this component
+      const updatedComponents = {
+        [id]: {
+          ...component,
+          meta: {
+            ...component.meta,
+            width: width || component.meta.width,
+            height: height || component.meta.height,
+          },
+        },
+      };
+
+      dispatch(updateComponents(updatedComponents));
+    }
+  };
+}
+
+// Drag and drop --------------------------------------------------------------
+export const MOVE_COMPONENT = 'MOVE_COMPONENT';
+const moveComponent = setUnsavedChangesAfterAction(dropResult => ({
+  type: MOVE_COMPONENT,
+  payload: {
+    dropResult,
+  },
+}));
+
+export const HANDLE_COMPONENT_DROP = 'HANDLE_COMPONENT_DROP';
+export function handleComponentDrop(dropResult) {
+  return (dispatch, getState) => {
+    const overflowsParent = dropOverflowsParent(
+      dropResult,
+      getState().dashboardLayout.present,
+    );
+
+    if (overflowsParent) {
+      return dispatch(
+        addInfoToast(
+          `There is not enough space for this component. Try decreasing its width, or increasing the destination width.`,
+        ),
+      );
+    }
+
+    const { source, destination } = dropResult;
+    const droppedOnRoot = destination && destination.id === DASHBOARD_ROOT_ID;
+    const isNewComponent = source.id === NEW_COMPONENTS_SOURCE_ID;
+
+    if (droppedOnRoot) {
+      dispatch(createTopLevelTabs(dropResult));
+    } else if (destination && isNewComponent) {
+      dispatch(createComponent(dropResult));
+    } else if (
+      destination &&
+      source &&
+      !// ensure it has moved
+      (destination.id === source.id && destination.index === source.index)
+    ) {
+      dispatch(moveComponent(dropResult));
+    }
+
+    const { dashboardLayout: undoableLayout } = getState();
+
+    // if we moved a child from a Tab or Row parent and it was the only child, delete the parent.
+    if (!isNewComponent) {
+      const { present: layout } = undoableLayout;
+      const sourceComponent = layout[source.id];
+      if (
+        (sourceComponent.type === TABS_TYPE ||
+          sourceComponent.type === ROW_TYPE) &&
+        sourceComponent.children &&
+        sourceComponent.children.length === 0
+      ) {
+        const parentId = findParentId({
+          childId: source.id,
+          layout,
+        });
+        dispatch(deleteComponent(source.id, parentId));
+      }
+    }
+
+    return null;
+  };
+}
+
+// Undo redo ------------------------------------------------------------------
+export function undoLayoutAction() {
+  return (dispatch, getState) => {
+    dispatch(UndoActionCreators.undo());
+
+    const { dashboardLayout, dashboardState } = getState();
+
+    if (
+      dashboardLayout.past.length === 0 &&
+      !dashboardState.maxUndoHistoryExceeded
+    ) {
+      dispatch(setUnsavedChanges(false));
+    }
+  };
+}
+
+export const redoLayoutAction = setUnsavedChangesAfterAction(
+  UndoActionCreators.redo,
+);
diff --git a/superset/assets/src/dashboard/actions/dashboardState.js b/superset/assets/src/dashboard/actions/dashboardState.js
new file mode 100644
index 0000000..688f5b0
--- /dev/null
+++ b/superset/assets/src/dashboard/actions/dashboardState.js
@@ -0,0 +1,254 @@
+/* eslint camelcase: 0 */
+import $ from 'jquery';
+import { ActionCreators as UndoActionCreators } from 'redux-undo';
+
+import { addChart, removeChart, refreshChart } from '../../chart/chartAction';
+import { chart as initChart } from '../../chart/chartReducer';
+import { fetchDatasourceMetadata } from '../../dashboard/actions/datasources';
+import { applyDefaultFormData } from '../../explore/store';
+import { getAjaxErrorMsg } from '../../modules/utils';
+import {
+  Logger,
+  LOG_ACTIONS_CHANGE_DASHBOARD_FILTER,
+  LOG_ACTIONS_REFRESH_DASHBOARD,
+} from '../../logger';
+import { SAVE_TYPE_OVERWRITE } from '../util/constants';
+import { t } from '../../locales';
+
+import {
+  addSuccessToast,
+  addWarningToast,
+  addDangerToast,
+} from './messageToasts';
+
+export const SET_UNSAVED_CHANGES = 'SET_UNSAVED_CHANGES';
+export function setUnsavedChanges(hasUnsavedChanges) {
+  return { type: SET_UNSAVED_CHANGES, payload: { hasUnsavedChanges } };
+}
+
+export const CHANGE_FILTER = 'CHANGE_FILTER';
+export function changeFilter(chart, col, vals, merge = true, refresh = true) {
+  Logger.append(LOG_ACTIONS_CHANGE_DASHBOARD_FILTER, {
+    id: chart.id,
+    column: col,
+    value_count: Array.isArray(vals) ? vals.length : (vals && 1) || 0,
+    merge,
+    refresh,
+  });
+  return { type: CHANGE_FILTER, chart, col, vals, merge, refresh };
+}
+
+export const ADD_SLICE = 'ADD_SLICE';
+export function addSlice(slice) {
+  return { type: ADD_SLICE, slice };
+}
+
+export const REMOVE_SLICE = 'REMOVE_SLICE';
+export function removeSlice(sliceId) {
+  return { type: REMOVE_SLICE, sliceId };
+}
+
+const FAVESTAR_BASE_URL = '/superset/favstar/Dashboard';
+export const TOGGLE_FAVE_STAR = 'TOGGLE_FAVE_STAR';
+export function toggleFaveStar(isStarred) {
+  return { type: TOGGLE_FAVE_STAR, isStarred };
+}
+
+export const FETCH_FAVE_STAR = 'FETCH_FAVE_STAR';
+export function fetchFaveStar(id) {
+  return function fetchFaveStarThunk(dispatch) {
+    const url = `${FAVESTAR_BASE_URL}/${id}/count`;
+    return $.get(url).done(data => {
+      if (data.count > 0) {
+        dispatch(toggleFaveStar(true));
+      }
+    });
+  };
+}
+
+export const SAVE_FAVE_STAR = 'SAVE_FAVE_STAR';
+export function saveFaveStar(id, isStarred) {
+  return function saveFaveStarThunk(dispatch) {
+    const urlSuffix = isStarred ? 'unselect' : 'select';
+    const url = `${FAVESTAR_BASE_URL}/${id}/${urlSuffix}/`;
+    $.get(url);
+    dispatch(toggleFaveStar(!isStarred));
+  };
+}
+
+export const TOGGLE_EXPAND_SLICE = 'TOGGLE_EXPAND_SLICE';
+export function toggleExpandSlice(sliceId) {
+  return { type: TOGGLE_EXPAND_SLICE, sliceId };
+}
+
+export const UPDATE_CSS = 'UPDATE_CSS';
+export function updateCss(css) {
+  return { type: UPDATE_CSS, css };
+}
+
+export const SET_EDIT_MODE = 'SET_EDIT_MODE';
+export function setEditMode(editMode) {
+  return { type: SET_EDIT_MODE, editMode };
+}
+
+export const ON_CHANGE = 'ON_CHANGE';
+export function onChange() {
+  return { type: ON_CHANGE };
+}
+
+export const ON_SAVE = 'ON_SAVE';
+export function onSave() {
+  return { type: ON_SAVE };
+}
+
+export function saveDashboardRequestSuccess() {
+  return dispatch => {
+    dispatch(onSave());
+    // clear layout undo history
+    dispatch(UndoActionCreators.clearHistory());
+  };
+}
+
+export function saveDashboardRequest(data, id, saveType) {
+  const path = saveType === SAVE_TYPE_OVERWRITE ? 'save_dash' : 'copy_dash';
+  const url = `/superset/${path}/${id}/`;
+  return dispatch =>
+    $.ajax({
+      type: 'POST',
+      url,
+      data: {
+        data: JSON.stringify(data),
+      },
+      success: () => {
+        dispatch(saveDashboardRequestSuccess());
+        dispatch(addSuccessToast(t('This dashboard was saved successfully.')));
+      },
+      error: error => {
+        const errorMsg = getAjaxErrorMsg(error);
+        dispatch(
+          addDangerToast(
+            `${t('Sorry, there was an error saving this dashboard: ')}
+          ${errorMsg}`,
+          ),
+        );
+      },
+    });
+}
+
+export function fetchCharts(chartList = [], force = false, interval = 0) {
+  return (dispatch, getState) => {
+    Logger.append(LOG_ACTIONS_REFRESH_DASHBOARD, {
+      force,
+      interval,
+      chartCount: chartList.length,
+    });
+    const timeout = getState().dashboardInfo.common.conf
+      .SUPERSET_WEBSERVER_TIMEOUT;
+    if (!interval) {
+      chartList.forEach(chart => dispatch(refreshChart(chart, force, timeout)));
+      return;
+    }
+
+    const { metadata: meta } = getState().dashboardInfo;
+    const refreshTime = Math.max(interval, meta.stagger_time || 5000); // default 5 seconds
+    if (typeof meta.stagger_refresh !== 'boolean') {
+      meta.stagger_refresh =
+        meta.stagger_refresh === undefined
+          ? true
+          : meta.stagger_refresh === 'true';
+    }
+    const delay = meta.stagger_refresh
+      ? refreshTime / (chartList.length - 1)
+      : 0;
+    chartList.forEach((chart, i) => {
+      setTimeout(
+        () => dispatch(refreshChart(chart, force, timeout)),
+        delay * i,
+      );
+    });
+  };
+}
+
+let refreshTimer = null;
+export function startPeriodicRender(interval) {
+  const stopPeriodicRender = () => {
+    if (refreshTimer) {
+      clearTimeout(refreshTimer);
+      refreshTimer = null;
+    }
+  };
+
+  return (dispatch, getState) => {
+    stopPeriodicRender();
+
+    const { metadata } = getState().dashboardInfo;
+    const immune = metadata.timed_refresh_immune_slices || [];
+    const refreshAll = () => {
+      const affected = Object.values(getState().charts).filter(
+        chart => immune.indexOf(chart.id) === -1,
+      );
+      return dispatch(fetchCharts(affected, true, interval * 0.2));
+    };
+    const fetchAndRender = () => {
+      refreshAll();
+      if (interval > 0) {
+        refreshTimer = setTimeout(fetchAndRender, interval);
+      }
+    };
+
+    fetchAndRender();
+  };
+}
+
+export const TOGGLE_BUILDER_PANE = 'TOGGLE_BUILDER_PANE';
+export function toggleBuilderPane() {
+  return { type: TOGGLE_BUILDER_PANE };
+}
+
+export function addSliceToDashboard(id) {
+  return (dispatch, getState) => {
+    const { sliceEntities } = getState();
+    const selectedSlice = sliceEntities.slices[id];
+    const form_data = selectedSlice.form_data;
+    const newChart = {
+      ...initChart,
+      id,
+      form_data,
+      formData: applyDefaultFormData(form_data),
+    };
+
+    return Promise.all([
+      dispatch(addChart(newChart, id)),
+      dispatch(fetchDatasourceMetadata(form_data.datasource)),
+    ]).then(() => dispatch(addSlice(selectedSlice)));
+  };
+}
+
+export function removeSliceFromDashboard(id) {
+  return dispatch => {
+    dispatch(removeSlice(id));
+    dispatch(removeChart(id));
+  };
+}
+
+// Undo history ---------------------------------------------------------------
+export const SET_MAX_UNDO_HISTORY_EXCEEDED = 'SET_MAX_UNDO_HISTORY_EXCEEDED';
+export function setMaxUndoHistoryExceeded(maxUndoHistoryExceeded = true) {
+  return {
+    type: SET_MAX_UNDO_HISTORY_EXCEEDED,
+    payload: { maxUndoHistoryExceeded },
+  };
+}
+
+export function maxUndoHistoryToast() {
+  return (dispatch, getState) => {
+    const { dashboardLayout } = getState();
+    const historyLength = dashboardLayout.past.length;
+
+    return dispatch(
+      addWarningToast(
+        `You have used all ${historyLength} undo slots and will not be able to fully undo subsequent actions. You may save your current state to reset the history.`,
+      ),
+    );
+  };
+}
diff --git a/superset/assets/src/dashboard/actions/datasources.js b/superset/assets/src/dashboard/actions/datasources.js
new file mode 100644
index 0000000..d97296e
--- /dev/null
+++ b/superset/assets/src/dashboard/actions/datasources.js
@@ -0,0 +1,36 @@
+import $ from 'jquery';
+
+export const SET_DATASOURCE = 'SET_DATASOURCE';
+export function setDatasource(datasource, key) {
+  return { type: SET_DATASOURCE, datasource, key };
+}
+
+export const FETCH_DATASOURCE_STARTED = 'FETCH_DATASOURCE_STARTED';
+export function fetchDatasourceStarted(key) {
+  return { type: FETCH_DATASOURCE_STARTED, key };
+}
+
+export const FETCH_DATASOURCE_FAILED = 'FETCH_DATASOURCE_FAILED';
+export function fetchDatasourceFailed(error, key) {
+  return { type: FETCH_DATASOURCE_FAILED, error, key };
+}
+
+export function fetchDatasourceMetadata(key) {
+  return (dispatch, getState) => {
+    const { datasources } = getState();
+    const datasource = datasources[key];
+
+    if (datasource) {
+      return dispatch(setDatasource(datasource, key));
+    }
+
+    const url = `/superset/fetch_datasource_metadata?datasourceKey=${key}`;
+    return $.ajax({
+      type: 'GET',
+      url,
+      success: data => dispatch(setDatasource(data, key)),
+      error: error =>
+        dispatch(fetchDatasourceFailed(error.responseJSON.error, key)),
+    });
+  };
+}
diff --git a/superset/assets/src/dashboard/actions/messageToasts.js b/superset/assets/src/dashboard/actions/messageToasts.js
new file mode 100644
index 0000000..e5c04e6
--- /dev/null
+++ b/superset/assets/src/dashboard/actions/messageToasts.js
@@ -0,0 +1,59 @@
+import shortid from 'shortid';
+
+import {
+  INFO_TOAST,
+  SUCCESS_TOAST,
+  WARNING_TOAST,
+  DANGER_TOAST,
+} from '../util/constants';
+
+function getToastUuid(type) {
+  return `${type}-${shortid.generate()}`;
+}
+
+export const ADD_TOAST = 'ADD_TOAST';
+export function addToast({ toastType, text, duration }) {
+  return {
+    type: ADD_TOAST,
+    payload: {
+      id: getToastUuid(toastType),
+      toastType,
+      text,
+      duration,
+    },
+  };
+}
+
+export const REMOVE_TOAST = 'REMOVE_TOAST';
+export function removeToast(id) {
+  return {
+    type: REMOVE_TOAST,
+    payload: {
+      id,
+    },
+  };
+}
+
+// Different types of toasts
+export const ADD_INFO_TOAST = 'ADD_INFO_TOAST';
+export function addInfoToast(text) {
+  return dispatch =>
+    dispatch(addToast({ text, toastType: INFO_TOAST, duration: 4000 }));
+}
+
+export const ADD_SUCCESS_TOAST = 'ADD_SUCCESS_TOAST';
+export function addSuccessToast(text) {
+  return dispatch =>
+    dispatch(addToast({ text, toastType: SUCCESS_TOAST, duration: 4000 }));
+}
+
+export const ADD_WARNING_TOAST = 'ADD_WARNING_TOAST';
+export function addWarningToast(text) {
+  return dispatch =>
+    dispatch(addToast({ text, toastType: WARNING_TOAST, duration: 4000 }));
+}
+
+export const ADD_DANGER_TOAST = 'ADD_DANGER_TOAST';
+export function addDangerToast(text) {
+  return dispatch => dispatch(addToast({ text, toastType: DANGER_TOAST }));
+}
diff --git a/superset/assets/src/dashboard/actions/sliceEntities.js b/superset/assets/src/dashboard/actions/sliceEntities.js
new file mode 100644
index 0000000..516a514
--- /dev/null
+++ b/superset/assets/src/dashboard/actions/sliceEntities.js
@@ -0,0 +1,73 @@
+/* eslint camelcase: 0 */
+import $ from 'jquery';
+
+import { getDatasourceParameter } from '../../modules/utils';
+
+export const SET_ALL_SLICES = 'SET_ALL_SLICES';
+export function setAllSlices(slices) {
+  return { type: SET_ALL_SLICES, slices };
+}
+
+export const FETCH_ALL_SLICES_STARTED = 'FETCH_ALL_SLICES_STARTED';
+export function fetchAllSlicesStarted() {
+  return { type: FETCH_ALL_SLICES_STARTED };
+}
+
+export const FETCH_ALL_SLICES_FAILED = 'FETCH_ALL_SLICES_FAILED';
+export function fetchAllSlicesFailed(error) {
+  return { type: FETCH_ALL_SLICES_FAILED, error };
+}
+
+export function fetchAllSlices(userId) {
+  return (dispatch, getState) => {
+    const { sliceEntities } = getState();
+    if (sliceEntities.lastUpdated === 0) {
+      dispatch(fetchAllSlicesStarted());
+
+      const uri = `/sliceaddview/api/read?_flt_0_created_by=${userId}`;
+      return $.ajax({
+        url: uri,
+        type: 'GET',
+        success: response => {
+          const slices = {};
+          response.result.forEach(slice => {
+            let form_data = JSON.parse(slice.params);
+            let datasource = form_data.datasource;
+            if (!datasource) {
+              datasource = getDatasourceParameter(
+                slice.datasource_id,
+                slice.datasource_type,
+              );
+              form_data = {
+                ...form_data,
+                datasource,
+              };
+            }
+            if (['markup', 'separator'].indexOf(slice.viz_type) === -1) {
+              slices[slice.id] = {
+                slice_id: slice.id,
+                slice_url: slice.slice_url,
+                slice_name: slice.slice_name,
+                edit_url: slice.edit_url,
+                form_data,
+                datasource_name: slice.datasource_name_text,
+                datasource_link: slice.datasource_link,
+                changed_on: new Date(slice.changed_on).getTime(),
+                description: slice.description,
+                description_markdown: slice.description_markeddown,
+                viz_type: slice.viz_type,
+                modified: slice.modified
+                  ? slice.modified.replace(/<[^>]*>/g, '')
+                  : '',
+              };
+            }
+          });
+          return dispatch(setAllSlices(slices));
+        },
+        error: error => dispatch(fetchAllSlicesFailed(error)),
+      });
+    }
+
+    return dispatch(setAllSlices(sliceEntities.slices));
+  };
+}
diff --git a/superset/assets/src/dashboard/components/AddSliceCard.jsx b/superset/assets/src/dashboard/components/AddSliceCard.jsx
new file mode 100644
index 0000000..c8266ad
--- /dev/null
+++ b/superset/assets/src/dashboard/components/AddSliceCard.jsx
@@ -0,0 +1,61 @@
+import cx from 'classnames';
+import React from 'react';
+import PropTypes from 'prop-types';
+import { t } from '../../locales';
+
+const propTypes = {
+  datasourceLink: PropTypes.string,
+  innerRef: PropTypes.func,
+  isSelected: PropTypes.bool,
+  lastModified: PropTypes.string.isRequired,
+  sliceName: PropTypes.string.isRequired,
+  style: PropTypes.object,
+  visType: PropTypes.string.isRequired,
+};
+
+const defaultProps = {
+  datasourceLink: '—',
+  innerRef: null,
+  isSelected: false,
+  style: null,
+};
+
+function AddSliceCard({
+  datasourceLink,
+  innerRef,
+  isSelected,
+  lastModified,
+  sliceName,
+  style,
+  visType,
+}) {
+  return (
+    <div ref={innerRef} className="chart-card-container" style={style}>
+      <div className={cx('chart-card', isSelected && 'is-selected')}>
+        <div className="card-title">{sliceName}</div>
+        <div className="card-body">
+          <div className="item">
+            <span>Modified </span>
+            <span>{lastModified}</span>
+          </div>
+          <div className="item">
+            <span>Visualization </span>
+            <span>{visType}</span>
+          </div>
+          <div className="item">
+            <span>Data source </span>
+            <span // eslint-disable-next-line react/no-danger
+              dangerouslySetInnerHTML={{ __html: datasourceLink }}
+            />
+          </div>
+        </div>
+      </div>
+      {isSelected && <div className="is-added-label">{t('Added')}</div>}
+    </div>
+  );
+}
+
+AddSliceCard.propTypes = propTypes;
+AddSliceCard.defaultProps = defaultProps;
+
+export default AddSliceCard;
diff --git a/superset/assets/src/dashboard/components/BuilderComponentPane.jsx b/superset/assets/src/dashboard/components/BuilderComponentPane.jsx
new file mode 100644
index 0000000..aafee5d
--- /dev/null
+++ b/superset/assets/src/dashboard/components/BuilderComponentPane.jsx
@@ -0,0 +1,129 @@
+/* eslint-env browser */
+import PropTypes from 'prop-types';
+import React from 'react';
+import cx from 'classnames';
+import { StickyContainer, Sticky } from 'react-sticky';
+import ParentSize from '@vx/responsive/build/components/ParentSize';
+
+import NewColumn from './gridComponents/new/NewColumn';
+import NewDivider from './gridComponents/new/NewDivider';
+import NewHeader from './gridComponents/new/NewHeader';
+import NewRow from './gridComponents/new/NewRow';
+import NewTabs from './gridComponents/new/NewTabs';
+import NewMarkdown from './gridComponents/new/NewMarkdown';
+import SliceAdder from '../containers/SliceAdder';
+import { t } from '../../locales';
+
+const SUPERSET_HEADER_HEIGHT = 59;
+
+const propTypes = {
+  topOffset: PropTypes.number,
+  toggleBuilderPane: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+  topOffset: 0,
+};
+
+class BuilderComponentPane extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {
+      slideDirection: 'slide-out',
+    };
+
+    this.openSlicesPane = this.slide.bind(this, 'slide-in');
+    this.closeSlicesPane = this.slide.bind(this, 'slide-out');
+  }
+
+  slide(direction) {
+    this.setState({
+      slideDirection: direction,
+    });
+  }
+
+  render() {
+    const { topOffset } = this.props;
+    return (
+      <div
+        className="dashboard-builder-sidepane"
+        style={{
+          height: `calc(100vh - ${topOffset + SUPERSET_HEADER_HEIGHT}px)`,
+        }}
+      >
+        <ParentSize>
+          {({ height }) => (
+            <StickyContainer>
+              <Sticky topOffset={-topOffset} bottomOffset={Infinity}>
+                {({ style, isSticky }) => (
+                  <div
+                    className="viewport"
+                    style={isSticky ? { ...style, top: topOffset } : null}
+                  >
+                    <div
+                      className={cx(
+                        'slider-container',
+                        this.state.slideDirection,
+                      )}
+                    >
+                      <div className="component-layer slide-content">
+                        <div className="dashboard-builder-sidepane-header">
+                          <span>{t('Insert')}</span>
+                          <i
+                            className="fa fa-times trigger"
+                            onClick={this.props.toggleBuilderPane}
+                            role="none"
+                          />
+                        </div>
+                        <div
+                          className="new-component static"
+                          role="none"
+                          onClick={this.openSlicesPane}
+                        >
+                          <div className="new-component-placeholder fa fa-area-chart" />
+                          <div className="new-component-label">
+                            {t('Your charts & filters')}
+                          </div>
+
+                          <i className="fa fa-arrow-right trigger" />
+                        </div>
+
+                        <NewTabs />
+                        <NewRow />
+                        <NewColumn />
+
+                        <NewHeader />
+                        <NewMarkdown />
+                        <NewDivider />
+                      </div>
+                      <div className="slices-layer slide-content">
+                        <div
+                          className="dashboard-builder-sidepane-header"
+                          onClick={this.closeSlicesPane}
+                          role="none"
+                        >
+                          <i className="fa fa-arrow-left trigger" />
+                          <span>{t('All components')}</span>
+                        </div>
+                        <SliceAdder
+                          height={
+                            height + (isSticky ? SUPERSET_HEADER_HEIGHT : 0)
+                          }
+                        />
+                      </div>
+                    </div>
+                  </div>
+                )}
+              </Sticky>
+            </StickyContainer>
+          )}
+        </ParentSize>
+      </div>
+    );
+  }
+}
+
+BuilderComponentPane.propTypes = propTypes;
+BuilderComponentPane.defaultProps = defaultProps;
+
+export default BuilderComponentPane;
diff --git a/superset/assets/src/dashboard/components/CodeModal.jsx b/superset/assets/src/dashboard/components/CodeModal.jsx
index 0e84ad1..cc0c9f2 100644
--- a/superset/assets/src/dashboard/components/CodeModal.jsx
+++ b/superset/assets/src/dashboard/components/CodeModal.jsx
@@ -12,13 +12,16 @@ const propTypes = {
 
 const defaultProps = {
   codeCallback: () => {},
+  code: '',
 };
 
 export default class CodeModal extends React.PureComponent {
   constructor(props) {
     super(props);
     this.state = { code: props.code };
+    this.beforeOpen = this.beforeOpen.bind(this);
   }
+
   beforeOpen() {
     let code = this.props.code;
     if (!code && this.props.codeCallback) {
@@ -26,18 +29,17 @@ export default class CodeModal extends React.PureComponent {
     }
     this.setState({ code });
   }
+
   render() {
     return (
       <ModalTrigger
         triggerNode={this.props.triggerNode}
         isButton
-        beforeOpen={this.beforeOpen.bind(this)}
+        beforeOpen={this.beforeOpen}
         modalTitle={t('Active Dashboard Filters')}
         modalBody={
           <div className="CodeModal">
-            <pre>
-              {this.state.code}
-            </pre>
+            <pre>{this.state.code}</pre>
           </div>
         }
       />
diff --git a/superset/assets/src/dashboard/components/CssEditor.jsx b/superset/assets/src/dashboard/components/CssEditor.jsx
index 5abf5f8..45ef86d 100644
--- a/superset/assets/src/dashboard/components/CssEditor.jsx
+++ b/superset/assets/src/dashboard/components/CssEditor.jsx
@@ -29,15 +29,20 @@ class CssEditor extends React.PureComponent {
       css: props.initialCss,
       cssTemplateOptions: [],
     };
+    this.changeCss = this.changeCss.bind(this);
+    this.changeCssTemplate = this.changeCssTemplate.bind(this);
   }
+
   changeCss(css) {
     this.setState({ css }, () => {
       this.props.onChange(css);
     });
   }
+
   changeCssTemplate(opt) {
     this.changeCss(opt.css);
   }
+
   renderTemplateSelector() {
     if (this.props.templates) {
       return (
@@ -46,13 +51,14 @@ class CssEditor extends React.PureComponent {
           <Select
             options={this.props.templates}
             placeholder={t('Load a CSS template')}
-            onChange={this.changeCssTemplate.bind(this)}
+            onChange={this.changeCssTemplate}
           />
         </div>
       );
     }
     return null;
   }
+
   render() {
     return (
       <ModalTrigger
@@ -70,7 +76,7 @@ class CssEditor extends React.PureComponent {
                   theme="github"
                   minLines={8}
                   maxLines={30}
-                  onChange={this.changeCss.bind(this)}
+                  onChange={this.changeCss}
                   height="200px"
                   width="100%"
                   editorProps={{ $blockScrolling: true }}
@@ -85,6 +91,7 @@ class CssEditor extends React.PureComponent {
     );
   }
 }
+
 CssEditor.propTypes = propTypes;
 CssEditor.defaultProps = defaultProps;
 
diff --git a/superset/assets/src/dashboard/components/Dashboard.jsx b/superset/assets/src/dashboard/components/Dashboard.jsx
index 2cb08c3..5f5479e 100644
--- a/superset/assets/src/dashboard/components/Dashboard.jsx
+++ b/superset/assets/src/dashboard/components/Dashboard.jsx
@@ -1,348 +1,229 @@
+/* global window */
 import React from 'react';
 import PropTypes from 'prop-types';
 
 import AlertsWrapper from '../../components/AlertsWrapper';
-import GridLayout from './GridLayout';
-import Header from './Header';
-import { exportChart } from '../../explore/exploreUtils';
+import getChartIdsFromLayout from '../util/getChartIdsFromLayout';
+import DashboardBuilder from '../containers/DashboardBuilder';
+import {
+  chartPropShape,
+  slicePropShape,
+  dashboardInfoPropShape,
+  dashboardStatePropShape,
+  loadStatsPropShape,
+} from '../util/propShapes';
 import { areObjectsEqual } from '../../reduxUtils';
-import { Logger, ActionLog, LOG_ACTIONS_PAGE_LOAD,
-  LOG_ACTIONS_LOAD_EVENT, LOG_ACTIONS_RENDER_EVENT } from '../../logger';
+import getFormDataWithExtraFilters from '../util/charts/getFormDataWithExtraFilters';
+import {
+  Logger,
+  ActionLog,
+  DASHBOARD_EVENT_NAMES,
+  LOG_ACTIONS_MOUNT_DASHBOARD,
+  LOG_ACTIONS_LOAD_DASHBOARD_PANE,
+  LOG_ACTIONS_FIRST_DASHBOARD_LOAD,
+} from '../../logger';
+
 import { t } from '../../locales';
 
-import '../../../stylesheets/dashboard.css';
+import '../stylesheets/index.less';
 
 const propTypes = {
-  actions: PropTypes.object,
+  actions: PropTypes.shape({
+    addSliceToDashboard: PropTypes.func.isRequired,
+    removeSliceFromDashboard: PropTypes.func.isRequired,
+    runQuery: PropTypes.func.isRequired,
+  }).isRequired,
+  dashboardInfo: dashboardInfoPropShape.isRequired,
+  dashboardState: dashboardStatePropShape.isRequired,
+  charts: PropTypes.objectOf(chartPropShape).isRequired,
+  slices: PropTypes.objectOf(slicePropShape).isRequired,
+  datasources: PropTypes.object.isRequired,
+  loadStats: loadStatsPropShape.isRequired,
+  layout: PropTypes.object.isRequired,
+  impressionId: PropTypes.string.isRequired,
   initMessages: PropTypes.array,
-  dashboard: PropTypes.object.isRequired,
-  slices: PropTypes.object,
-  datasources: PropTypes.object,
-  filters: PropTypes.object,
-  refresh: PropTypes.bool,
   timeout: PropTypes.number,
   userId: PropTypes.string,
-  isStarred: PropTypes.bool,
-  editMode: PropTypes.bool,
-  impressionId: PropTypes.string,
 };
 
 const defaultProps = {
   initMessages: [],
-  dashboard: {},
-  slices: {},
-  datasources: {},
-  filters: {},
-  refresh: false,
   timeout: 60,
   userId: '',
-  isStarred: false,
-  editMode: false,
 };
 
 class Dashboard extends React.PureComponent {
+  // eslint-disable-next-line react/sort-comp
+  static onBeforeUnload(hasChanged) {
+    if (hasChanged) {
+      window.addEventListener('beforeunload', Dashboard.unload);
+    } else {
+      window.removeEventListener('beforeunload', Dashboard.unload);
+    }
+  }
+
+  static unload() {
+    const message = t('You have unsaved changes.');
+    window.event.returnValue = message; // Gecko + IE
+    return message; // Gecko + Webkit, Safari, Chrome etc.
+  }
+
   constructor(props) {
     super(props);
-    this.refreshTimer = null;
-    this.firstLoad = true;
-    this.loadingLog = new ActionLog({
+    this.isFirstLoad = true;
+    this.actionLog = new ActionLog({
       impressionId: props.impressionId,
-      actionType: LOG_ACTIONS_PAGE_LOAD,
       source: 'dashboard',
-      sourceId: props.dashboard.id,
-      eventNames: [LOG_ACTIONS_LOAD_EVENT, LOG_ACTIONS_RENDER_EVENT],
+      sourceId: props.dashboardInfo.id,
+      eventNames: DASHBOARD_EVENT_NAMES,
     });
-    Logger.start(this.loadingLog);
-
-    // alert for unsaved changes
-    this.state = { unsavedChanges: false };
-
-    this.rerenderCharts = this.rerenderCharts.bind(this);
-    this.updateDashboardTitle = this.updateDashboardTitle.bind(this);
-    this.onSave = this.onSave.bind(this);
-    this.onChange = this.onChange.bind(this);
-    this.serialize = this.serialize.bind(this);
-    this.fetchAllSlices = this.fetchSlices.bind(this, this.getAllSlices());
-    this.startPeriodicRender = this.startPeriodicRender.bind(this);
-    this.addSlicesToDashboard = this.addSlicesToDashboard.bind(this);
-    this.fetchSlice = this.fetchSlice.bind(this);
-    this.getFormDataExtra = this.getFormDataExtra.bind(this);
-    this.exploreChart = this.exploreChart.bind(this);
-    this.exportCSV = this.exportCSV.bind(this);
-    this.props.actions.fetchFaveStar = this.props.actions.fetchFaveStar.bind(this);
-    this.props.actions.saveFaveStar = this.props.actions.saveFaveStar.bind(this);
-    this.props.actions.saveSlice = this.props.actions.saveSlice.bind(this);
-    this.props.actions.removeSlice = this.props.actions.removeSlice.bind(this);
-    this.props.actions.removeChart = this.props.actions.removeChart.bind(this);
-    this.props.actions.updateDashboardLayout = this.props.actions.updateDashboardLayout.bind(this);
-    this.props.actions.toggleExpandSlice = this.props.actions.toggleExpandSlice.bind(this);
-    this.props.actions.addFilter = this.props.actions.addFilter.bind(this);
-    this.props.actions.clearFilter = this.props.actions.clearFilter.bind(this);
-    this.props.actions.removeFilter = this.props.actions.removeFilter.bind(this);
+    Logger.start(this.actionLog);
+    this.initTs = new Date().getTime();
   }
 
   componentDidMount() {
-    window.addEventListener('resize', this.rerenderCharts);
+    Logger.append(LOG_ACTIONS_MOUNT_DASHBOARD);
   }
 
   componentWillReceiveProps(nextProps) {
-    if (this.firstLoad &&
-      Object.values(nextProps.slices)
-        .every(slice => (['rendered', 'failed', 'stopped'].indexOf(slice.chartStatus) > -1))
-    ) {
-      Logger.end(this.loadingLog);
-      this.firstLoad = false;
+    if (!nextProps.dashboardState.editMode) {
+      const version = nextProps.dashboardState.isV2Preview
+        ? 'v2-preview'
+        : 'v2';
+      // log pane loads
+      const loadedPaneIds = [];
+      let minQueryStartTime = Infinity;
+      const allVisiblePanesDidLoad = Object.entries(nextProps.loadStats).every(
+        ([paneId, stats]) => {
+          const {
+            didLoad,
+            minQueryStartTime: paneMinQueryStart,
+            ...restStats
+          } = stats;
+          if (
+            didLoad &&
+            this.props.loadStats[paneId] &&
+            !this.props.loadStats[paneId].didLoad
+          ) {
+            Logger.append(LOG_ACTIONS_LOAD_DASHBOARD_PANE, {
+              ...restStats,
+              duration: new Date().getTime() - paneMinQueryStart,
+              version,
+            });
+
+            if (!this.isFirstLoad) {
+              Logger.send(this.actionLog);
+            }
+          }
+          if (this.isFirstLoad && didLoad && stats.slice_ids.length > 0) {
+            loadedPaneIds.push(paneId);
+            minQueryStartTime = Math.min(minQueryStartTime, paneMinQueryStart);
+          }
+
+          // return true if it is loaded, or it's index is not 0
+          return didLoad || stats.index !== 0;
+        },
+      );
+
+      if (allVisiblePanesDidLoad && this.isFirstLoad) {
+        Logger.append(LOG_ACTIONS_FIRST_DASHBOARD_LOAD, {
+          pane_ids: loadedPaneIds,
+          duration: new Date().getTime() - minQueryStartTime,
+          version,
+        });
+        Logger.send(this.actionLog);
+        this.isFirstLoad = false;
+      }
+    }
+
+    const currentChartIds = getChartIdsFromLayout(this.props.layout);
+    const nextChartIds = getChartIdsFromLayout(nextProps.layout);
+
+    if (currentChartIds.length < nextChartIds.length) {
+      const newChartIds = nextChartIds.filter(
+        key => currentChartIds.indexOf(key) === -1,
+      );
+      newChartIds.forEach(newChartId =>
+        this.props.actions.addSliceToDashboard(newChartId),
+      );
+    } else if (currentChartIds.length > nextChartIds.length) {
+      // remove chart
+      const removedChartIds = currentChartIds.filter(
+        key => nextChartIds.indexOf(key) === -1,
+      );
+      removedChartIds.forEach(removedChartId =>
+        this.props.actions.removeSliceFromDashboard(removedChartId),
+      );
     }
   }
 
   componentDidUpdate(prevProps) {
-    if (this.props.refresh) {
-      let changedFilterKey;
-      const prevFiltersKeySet = new Set(Object.keys(prevProps.filters));
-      Object.keys(this.props.filters).some((key) => {
-        prevFiltersKeySet.delete(key);
-        if (prevProps.filters[key] === undefined ||
-          !areObjectsEqual(prevProps.filters[key], this.props.filters[key])) {
+    const { refresh, filters, hasUnsavedChanges } = this.props.dashboardState;
+    if (refresh) {
+      // refresh charts if a filter was removed, added, or changed
+      let changedFilterKey = null;
+      const currFilterKeys = Object.keys(filters);
+      const prevFilterKeys = Object.keys(prevProps.dashboardState.filters);
+
+      currFilterKeys.forEach(key => {
+        const prevFilter = prevProps.dashboardState.filters[key];
+        if (
+          // filter was added or changed
+          typeof prevFilter === 'undefined' ||
+          !areObjectsEqual(prevFilter, filters[key])
+        ) {
           changedFilterKey = key;
-          return true;
         }
-        return false;
       });
-      // has changed filter or removed a filter?
-      if (!!changedFilterKey || prevFiltersKeySet.size) {
+
+      if (
+        !!changedFilterKey ||
+        currFilterKeys.length !== prevFilterKeys.length
+      ) {
         this.refreshExcept(changedFilterKey);
       }
     }
-  }
 
-  componentWillUnmount() {
-    window.removeEventListener('resize', this.rerenderCharts);
-  }
-
-  onBeforeUnload(hasChanged) {
-    if (hasChanged) {
-      window.addEventListener('beforeunload', this.unload);
+    if (hasUnsavedChanges) {
+      Dashboard.onBeforeUnload(true);
     } else {
-      window.removeEventListener('beforeunload', this.unload);
+      Dashboard.onBeforeUnload(false);
     }
   }
 
-  onChange() {
-    this.onBeforeUnload(true);
-    this.setState({ unsavedChanges: true });
-  }
-
-  onSave() {
-    this.onBeforeUnload(false);
-    this.setState({ unsavedChanges: false });
-  }
-
   // return charts in array
-  getAllSlices() {
-    return Object.values(this.props.slices);
-  }
-
-  getFormDataExtra(slice) {
-    const formDataExtra = Object.assign({}, slice.formData);
-    formDataExtra.extra_filters = this.effectiveExtraFilters(slice.slice_id);
-    return formDataExtra;
-  }
-
-  getFilters(sliceId) {
-    return this.props.filters[sliceId];
-  }
-
-  unload() {
-    const message = t('You have unsaved changes.');
-    window.event.returnValue = message; // Gecko + IE
-    return message; // Gecko + Webkit, Safari, Chrome etc.
-  }
-
-  effectiveExtraFilters(sliceId) {
-    const metadata = this.props.dashboard.metadata;
-    const filters = this.props.filters;
-    const f = [];
-    const immuneSlices = metadata.filter_immune_slices || [];
-    if (sliceId && immuneSlices.includes(sliceId)) {
-      // The slice is immune to dashboard filters
-      return f;
-    }
-
-    // Building a list of fields the slice is immune to filters on
-    let immuneToFields = [];
-    if (
-      sliceId &&
-      metadata.filter_immune_slice_fields &&
-      metadata.filter_immune_slice_fields[sliceId]) {
-      immuneToFields = metadata.filter_immune_slice_fields[sliceId];
-    }
-    for (const filteringSliceId in filters) {
-      if (filteringSliceId === sliceId.toString()) {
-        // Filters applied by the slice don't apply to itself
-        continue;
-      }
-      for (const field in filters[filteringSliceId]) {
-        if (!immuneToFields.includes(field)) {
-          f.push({
-            col: field,
-            op: 'in',
-            val: filters[filteringSliceId][field],
-          });
-        }
-      }
-    }
-    return f;
+  getAllCharts() {
+    return Object.values(this.props.charts);
   }
 
   refreshExcept(filterKey) {
-    const immune = this.props.dashboard.metadata.filter_immune_slices || [];
-    let slices = this.getAllSlices();
-    if (filterKey) {
-      slices = slices.filter(slice => (
-        String(slice.slice_id) !== filterKey &&
-        immune.indexOf(slice.slice_id) === -1
-      ));
-    }
-    this.fetchSlices(slices);
-  }
-
-  stopPeriodicRender() {
-    if (this.refreshTimer) {
-      clearTimeout(this.refreshTimer);
-      this.refreshTimer = null;
-    }
-  }
-
-  startPeriodicRender(interval) {
-    this.stopPeriodicRender();
-    const immune = this.props.dashboard.metadata.timed_refresh_immune_slices || [];
-    const refreshAll = () => {
-      const affectedSlices = this.getAllSlices()
-        .filter(slice => immune.indexOf(slice.slice_id) === -1);
-      this.fetchSlices(affectedSlices, true, interval * 0.2);
-    };
-    const fetchAndRender = () => {
-      refreshAll();
-      if (interval > 0) {
-        this.refreshTimer = setTimeout(fetchAndRender, interval);
+    const immune = this.props.dashboardInfo.metadata.filter_immune_slices || [];
+
+    this.getAllCharts().forEach(chart => {
+      // filterKey is a string, immune array contains numbers
+      if (String(chart.id) !== filterKey && immune.indexOf(chart.id) === -1) {
+        const updatedFormData = getFormDataWithExtraFilters({
+          chart,
+          dashboardMetadata: this.props.dashboardInfo.metadata,
+          filters: this.props.dashboardState.filters,
+          sliceId: chart.id,
+        });
+
+        this.props.actions.runQuery(
+          updatedFormData,
+          false,
+          this.props.timeout,
+          chart.id,
+        );
       }
-    };
-
-    fetchAndRender();
-  }
-
-  updateDashboardTitle(title) {
-    this.props.actions.updateDashboardTitle(title);
-    this.onChange();
-  }
-
-  serialize() {
-    return this.props.dashboard.layout.map(reactPos => ({
-      slice_id: reactPos.i,
-      col: reactPos.x + 1,
-      row: reactPos.y,
-      size_x: reactPos.w,
-      size_y: reactPos.h,
-    }));
-  }
-
-  addSlicesToDashboard(sliceIds) {
-    return this.props.actions.addSlicesToDashboard(this.props.dashboard.id, sliceIds);
-  }
-
-  fetchSlice(slice, force = false) {
-    return this.props.actions.runQuery(
-      this.getFormDataExtra(slice), force, this.props.timeout, slice.chartKey,
-    );
-  }
-
-  // fetch and render an list of slices
-  fetchSlices(slc, force = false, interval = 0) {
-    const slices = slc || this.getAllSlices();
-    if (!interval) {
-      slices.forEach((slice) => { this.fetchSlice(slice, force); });
-      return;
-    }
-
-    const meta = this.props.dashboard.metadata;
-    const refreshTime = Math.max(interval, meta.stagger_time || 5000); // default 5 seconds
-    if (typeof meta.stagger_refresh !== 'boolean') {
-      meta.stagger_refresh = meta.stagger_refresh === undefined ?
-        true : meta.stagger_refresh === 'true';
-    }
-    const delay = meta.stagger_refresh ? refreshTime / (slices.length - 1) : 0;
-    slices.forEach((slice, i) => {
-      setTimeout(() => { this.fetchSlice(slice, force); }, delay * i);
-    });
-  }
-
-  exploreChart(slice) {
-    const formData = this.getFormDataExtra(slice);
-    exportChart(formData);
-  }
-
-  exportCSV(slice) {
-    const formData = this.getFormDataExtra(slice);
-    exportChart(formData, 'csv');
-  }
-
-  // re-render chart without fetch
-  rerenderCharts() {
-    this.getAllSlices().forEach((slice) => {
-      setTimeout(() => {
-        this.props.actions.renderTriggered(new Date().getTime(), slice.chartKey);
-      }, 50);
     });
   }
 
   render() {
     return (
-      <div id="dashboard-container">
-        <div id="dashboard-header">
-          <AlertsWrapper initMessages={this.props.initMessages} />
-          <Header
-            dashboard={this.props.dashboard}
-            unsavedChanges={this.state.unsavedChanges}
-            filters={this.props.filters}
-            userId={this.props.userId}
-            isStarred={this.props.isStarred}
-            updateDashboardTitle={this.updateDashboardTitle}
-            onSave={this.onSave}
-            onChange={this.onChange}
-            serialize={this.serialize}
-            fetchFaveStar={this.props.actions.fetchFaveStar}
-            saveFaveStar={this.props.actions.saveFaveStar}
-            renderSlices={this.fetchAllSlices}
-            startPeriodicRender={this.startPeriodicRender}
-            addSlicesToDashboard={this.addSlicesToDashboard}
-            editMode={this.props.editMode}
-            setEditMode={this.props.actions.setEditMode}
-          />
-        </div>
-        <div id="grid-container" className="slice-grid gridster">
-          <GridLayout
-            dashboard={this.props.dashboard}
-            datasources={this.props.datasources}
-            filters={this.props.filters}
-            charts={this.props.slices}
-            timeout={this.props.timeout}
-            onChange={this.onChange}
-            getFormDataExtra={this.getFormDataExtra}
-            exploreChart={this.exploreChart}
-            exportCSV={this.exportCSV}
-            fetchSlice={this.fetchSlice}
-            saveSlice={this.props.actions.saveSlice}
-            removeSlice={this.props.actions.removeSlice}
-            removeChart={this.props.actions.removeChart}
-            updateDashboardLayout={this.props.actions.updateDashboardLayout}
-            toggleExpandSlice={this.props.actions.toggleExpandSlice}
-            addFilter={this.props.actions.addFilter}
-            getFilters={this.getFilters}
-            clearFilter={this.props.actions.clearFilter}
-            removeFilter={this.props.actions.removeFilter}
-            editMode={this.props.editMode}
-          />
-        </div>
+      <div>
+        <AlertsWrapper initMessages={this.props.initMessages} />
+        <DashboardBuilder />
       </div>
     );
   }
diff --git a/superset/assets/src/dashboard/components/DashboardBuilder.jsx b/superset/assets/src/dashboard/components/DashboardBuilder.jsx
new file mode 100644
index 0000000..9621a49
--- /dev/null
+++ b/superset/assets/src/dashboard/components/DashboardBuilder.jsx
@@ -0,0 +1,208 @@
+/* eslint-env browser */
+import cx from 'classnames';
+// ParentSize uses resize observer so the dashboard will update size
+// when its container size changes, due to e.g., builder side panel opening
+import ParentSize from '@vx/responsive/build/components/ParentSize';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Sticky, StickyContainer } from 'react-sticky';
+import { TabContainer, TabContent, TabPane } from 'react-bootstrap';
+
+import BuilderComponentPane from './BuilderComponentPane';
+import DashboardHeader from '../containers/DashboardHeader';
+import DashboardGrid from '../containers/DashboardGrid';
+import IconButton from './IconButton';
+import DragDroppable from './dnd/DragDroppable';
+import DashboardComponent from '../containers/DashboardComponent';
+import ToastPresenter from '../containers/ToastPresenter';
+import WithPopoverMenu from './menu/WithPopoverMenu';
+
+import getDragDropManager from '../util/getDragDropManager';
+
+import {
+  DASHBOARD_GRID_ID,
+  DASHBOARD_ROOT_ID,
+  DASHBOARD_ROOT_DEPTH,
+} from '../util/constants';
+
+const TABS_HEIGHT = 47;
+const HEADER_HEIGHT = 67;
+
+const propTypes = {
+  // redux
+  dashboardLayout: PropTypes.object.isRequired,
+  deleteTopLevelTabs: PropTypes.func.isRequired,
+  editMode: PropTypes.bool.isRequired,
+  showBuilderPane: PropTypes.bool,
+  handleComponentDrop: PropTypes.func.isRequired,
+  toggleBuilderPane: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+  showBuilderPane: false,
+};
+
+class DashboardBuilder extends React.Component {
+  static shouldFocusTabs(event, container) {
+    // don't focus the tabs when we click on a tab
+    return (
+      event.target.tagName === 'UL' ||
+      (/icon-button/.test(event.target.className) &&
+        container.contains(event.target))
+    );
+  }
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      tabIndex: 0, // top-level tabs
+    };
+    this.handleChangeTab = this.handleChangeTab.bind(this);
+    this.handleDeleteTopLevelTabs = this.handleDeleteTopLevelTabs.bind(this);
+  }
+
+  getChildContext() {
+    return {
+      dragDropManager: this.context.dragDropManager || getDragDropManager(),
+    };
+  }
+
+  handleDeleteTopLevelTabs() {
+    this.props.deleteTopLevelTabs();
+    this.setState({ tabIndex: 0 });
+  }
+
+  handleChangeTab({ tabIndex }) {
+    this.setState(() => ({ tabIndex }));
+    setTimeout(() => {
+      if (window)
+        window.scrollTo({
+          top: 0,
+          behavior: 'smooth',
+        });
+    }, 100);
+  }
+
+  render() {
+    const { handleComponentDrop, dashboardLayout, editMode } = this.props;
+    const { tabIndex } = this.state;
+    const dashboardRoot = dashboardLayout[DASHBOARD_ROOT_ID];
+    const rootChildId = dashboardRoot.children[0];
+    const topLevelTabs =
+      rootChildId !== DASHBOARD_GRID_ID && dashboardLayout[rootChildId];
+
+    const childIds = topLevelTabs ? topLevelTabs.children : [DASHBOARD_GRID_ID];
+
+    return (
+      <StickyContainer
+        className={cx('dashboard', editMode && 'dashboard--editing')}
+      >
+        <Sticky>
+          {({ style }) => (
+            <DragDroppable
+              component={dashboardRoot}
+              parentComponent={null}
+              depth={DASHBOARD_ROOT_DEPTH}
+              index={0}
+              orientation="column"
+              onDrop={handleComponentDrop}
+              editMode={editMode}
+              // you cannot drop on/displace tabs if they already exist
+              disableDragdrop={!!topLevelTabs}
+              style={{ zIndex: 100, ...style }}
+            >
+              {({ dropIndicatorProps }) => (
+                <div>
+                  <DashboardHeader />
+                  {dropIndicatorProps && <div {...dropIndicatorProps} />}
+                  {topLevelTabs && (
+                    <WithPopoverMenu
+                      shouldFocus={DashboardBuilder.shouldFocusTabs}
+                      menuItems={[
+                        <IconButton
+                          className="fa fa-level-down"
+                          label="Collapse tab content"
+                          onClick={this.handleDeleteTopLevelTabs}
+                        />,
+                      ]}
+                      editMode={editMode}
+                    >
+                      <DashboardComponent
+                        id={topLevelTabs.id}
+                        parentId={DASHBOARD_ROOT_ID}
+                        depth={DASHBOARD_ROOT_DEPTH + 1}
+                        index={0}
+                        renderTabContent={false}
+                        renderHoverMenu={false}
+                        onChangeTab={this.handleChangeTab}
+                      />
+                    </WithPopoverMenu>
+                  )}
+                </div>
+              )}
+            </DragDroppable>
+          )}
+        </Sticky>
+
+        <div className="dashboard-content">
+          <div className="grid-container">
+            <ParentSize>
+              {({ width }) => (
+                /*
+                  We use a TabContainer irrespective of whether top-level tabs exist to maintain
+                  a consistent React component tree. This avoids expensive mounts/unmounts of
+                  the entire dashboard upon adding/removing top-level tabs, which would otherwise
+                  happen because of React's diffing algorithm
+                */
+                <TabContainer
+                  id={DASHBOARD_GRID_ID}
+                  activeKey={Math.min(tabIndex, childIds.length - 1)}
+                  onSelect={this.handleChangeTab}
+                  animation
+                  mountOnEnter
+                  unmountOnExit={false}
+                >
+                  <TabContent>
+                    {childIds.map((id, index) => (
+                      // Matching the key of the first TabPane irrespective of topLevelTabs
+                      // lets us keep the same React component tree when !!topLevelTabs changes.
+                      // This avoids expensive mounts/unmounts of the entire dashboard.
+                      <TabPane
+                        key={index === 0 ? DASHBOARD_GRID_ID : id}
+                        eventKey={index}
+                      >
+                        <DashboardGrid
+                          gridComponent={dashboardLayout[id]}
+                          // see isValidChild for why tabs do not increment the depth of their children
+                          depth={DASHBOARD_ROOT_DEPTH + 1} // (topLevelTabs ? 0 : 1)}
+                          width={width}
+                        />
+                      </TabPane>
+                    ))}
+                  </TabContent>
+                </TabContainer>
+              )}
+            </ParentSize>
+          </div>
+
+          {this.props.editMode &&
+            this.props.showBuilderPane && (
+              <BuilderComponentPane
+                topOffset={HEADER_HEIGHT + (topLevelTabs ? TABS_HEIGHT : 0)}
+                toggleBuilderPane={this.props.toggleBuilderPane}
+              />
+            )}
+        </div>
+        <ToastPresenter />
+      </StickyContainer>
+    );
+  }
+}
+
+DashboardBuilder.propTypes = propTypes;
+DashboardBuilder.defaultProps = defaultProps;
+DashboardBuilder.childContextTypes = {
+  dragDropManager: PropTypes.object.isRequired,
+};
+
+export default DashboardBuilder;
diff --git a/superset/assets/src/dashboard/components/DashboardGrid.jsx b/superset/assets/src/dashboard/components/DashboardGrid.jsx
new file mode 100644
index 0000000..d85015e
--- /dev/null
+++ b/superset/assets/src/dashboard/components/DashboardGrid.jsx
@@ -0,0 +1,198 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { componentShape } from '../util/propShapes';
+import DashboardComponent from '../containers/DashboardComponent';
+import DragDroppable from './dnd/DragDroppable';
+
+import { GRID_GUTTER_SIZE, GRID_COLUMN_COUNT } from '../util/constants';
+
+const propTypes = {
+  depth: PropTypes.number.isRequired,
+  editMode: PropTypes.bool.isRequired,
+  gridComponent: componentShape.isRequired,
... 21360 lines suppressed ...