From ddd69a542f80fbdde44134b0265f4a893be03089 Mon Sep 17 00:00:00 2001 From: Chris Williams Date: Mon, 25 Jun 2018 09:17:22 -0700 Subject: [PATCH] [wip] dashboard builder v2 (#4528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [dashboard builder] Add dir structure for dashboard/v2, simplified Header, split pane, Draggable side panel [grid] add , , and initial grid components. [grid] gridComponents/ directory, add fixtures/ directory and test layout, add [grid] working grid with gutters [grid] design tweaks and polish, add [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 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 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 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 (cherry picked from commit c065319) --- superset/assets/.eslintignore | 1 + superset/assets/images/loading.gif | Bin 16671 -> 79023 bytes superset/assets/package.json | 21 +- superset/assets/spec/helpers/browser.js | 4 +- .../spec/javascripts/chart/Chart_spec.jsx | 2 +- .../spec/javascripts/dashboard/.eslintrc | 33 + .../spec/javascripts/dashboard/.prettierrc | 4 + .../javascripts/dashboard/Dashboard_spec.jsx | 182 - .../dashboard/actions/dashboardLayout_spec.js | 454 ++ .../{ => components}/CodeModal_spec.jsx | 6 +- .../{ => components}/CssEditor_spec.jsx | 6 +- .../components/DashboardBuilder_spec.jsx | 138 + .../components/DashboardGrid_spec.jsx | 84 + .../dashboard/components/Dashboard_spec.jsx | 250 + .../RefreshIntervalModal_spec.jsx | 2 +- .../dashboard/components/SliceAdder_spec.jsx | 154 + .../components/ToastPresenter_spec.jsx | 41 + .../dashboard/components/Toast_spec.jsx | 43 + .../components/dnd/DragDroppable_spec.jsx | 90 + .../gridComponents/ChartHolder_spec.jsx | 112 + .../components/gridComponents/Chart_spec.jsx | 89 + .../components/gridComponents/Column_spec.jsx | 144 + .../gridComponents/Divider_spec.jsx | 70 + .../components/gridComponents/Header_spec.jsx | 100 + .../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 + .../gridComponents/new/NewRow_spec.jsx | 29 + .../gridComponents/new/NewTabs_spec.jsx | 29 + .../components/menu/HoverMenu_spec.jsx | 13 + .../components/menu/WithPopoverMenu_spec.jsx | 71 + .../resizable/ResizableContainer_spec.jsx | 20 + .../resizable/ResizableHandle_spec.jsx | 29 + .../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 + .../dashboard/fixtures/mockState.js | 18 + .../dashboard/fixtures/mockStore.js | 22 + .../dashboard/helpers/WithDragDropContext.jsx | 27 + .../reducers/dashboardLayout_spec.js | 443 ++ .../dashboard/reducers/dashboardState_spec.js | 241 + .../dashboard/reducers/messageToasts_spec.js | 32 + .../dashboard/reducers/sliceEntities_spec.js | 51 + .../javascripts/dashboard/reducers_spec.js | 35 - .../util/componentIsResizable_spec.js | 42 + .../dashboard/util/dnd-reorder_spec.js | 62 + .../util/dropOverflowsParent_spec.js | 227 + .../util/findFirstParentContainer_spec.js | 116 + .../dashboard/util/findParentId_spec.js | 29 + .../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 + .../util/newComponentFactory_spec.js | 51 + .../util/newEntitiesFromDrop_spec.js | 85 + .../assets/spec/javascripts/logger_spec.js | 143 + .../src/SqlLab/components/QuerySearch.jsx | 79 +- .../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 +- .../assets/src/components/ActionMenuItem.jsx | 65 + .../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 + .../src/dashboard/actions/dashboardState.js | 254 + .../src/dashboard/actions/datasources.js | 36 + .../src/dashboard/actions/messageToasts.js | 59 + .../src/dashboard/actions/sliceEntities.js | 73 + .../src/dashboard/components/AddSliceCard.jsx | 61 + .../components/BuilderComponentPane.jsx | 129 + .../src/dashboard/components/CodeModal.jsx | 10 +- .../src/dashboard/components/CssEditor.jsx | 11 +- .../src/dashboard/components/Dashboard.jsx | 457 +- .../dashboard/components/DashboardBuilder.jsx | 208 + .../dashboard/components/DashboardGrid.jsx | 198 + .../components/DeleteComponentButton.jsx | 20 + .../src/dashboard/components/Header.jsx | 377 +- .../components/HeaderActionsDropdown.jsx | 163 + .../src/dashboard/components/IconButton.jsx | 44 + .../components/RefreshIntervalModal.jsx | 11 +- .../src/dashboard/components/SaveModal.jsx | 155 +- .../src/dashboard/components/SliceAdder.jsx | 386 +- .../src/dashboard/components/SliceHeader.jsx | 220 +- .../components/SliceHeaderControls.jsx | 161 + .../assets/src/dashboard/components/Toast.jsx | 89 + .../dashboard/components/ToastPresenter.jsx | 36 + .../components/UndoRedoKeylisteners.jsx | 47 + .../components/dnd/AddSliceDragPreview.jsx | 75 + .../components/dnd/DragDroppable.jsx | 141 + .../dashboard/components/dnd/DragHandle.jsx | 40 + .../components/dnd/dragDroppableConfig.js | 67 + .../dashboard/components/dnd/handleDrop.js | 82 + .../dashboard/components/dnd/handleHover.js | 23 + .../components/gridComponents/Chart.jsx | 242 + .../components/gridComponents/ChartHolder.jsx | 159 + .../components/gridComponents/Column.jsx | 192 + .../components/gridComponents/Divider.jsx | 72 + .../components/gridComponents/Header.jsx | 164 + .../components/gridComponents/Markdown.jsx | 259 + .../components/gridComponents/Row.jsx | 177 + .../components/gridComponents/Tab.jsx | 235 + .../components/gridComponents/Tabs.jsx | 240 + .../components/gridComponents/index.js | 39 + .../new/DraggableNewComponent.jsx | 46 + .../gridComponents/new/NewColumn.jsx | 16 + .../gridComponents/new/NewDivider.jsx | 16 + .../gridComponents/new/NewHeader.jsx | 16 + .../gridComponents/new/NewMarkdown.jsx | 16 + .../components/gridComponents/new/NewRow.jsx | 16 + .../components/gridComponents/new/NewTabs.jsx | 16 + .../menu/BackgroundStyleDropdown.jsx | 46 + .../dashboard/components/menu/HoverMenu.jsx | 36 + .../components/menu/MarkdownModeDropdown.jsx | 39 + .../components/menu/PopoverDropdown.jsx | 66 + .../components/menu/WithPopoverMenu.jsx | 123 + .../resizable/ResizableContainer.jsx | 213 + .../components/resizable/ResizableHandle.jsx | 19 + .../assets/src/dashboard/containers/Chart.jsx | 64 + .../src/dashboard/containers/Dashboard.jsx | 56 + .../dashboard/containers/DashboardBuilder.jsx | 33 + .../containers/DashboardComponent.jsx | 81 + .../dashboard/containers/DashboardGrid.jsx | 29 + .../dashboard/containers/DashboardHeader.jsx | 85 + .../src/dashboard/containers/SliceAdder.jsx | 31 + .../dashboard/containers/ToastPresenter.jsx | 10 + .../deprecated/PromptV2ConversionModal.jsx | 102 + .../dashboard/deprecated/V2PreviewModal.jsx | 148 + .../src/dashboard/deprecated/chart/Chart.jsx | 259 + .../dashboard/deprecated/chart/ChartBody.jsx | 55 + .../deprecated/chart/ChartContainer.jsx | 29 + .../src/dashboard/deprecated/chart/chart.css | 4 + .../dashboard/deprecated/chart/chartAction.js | 195 + .../deprecated/chart/chartReducer.js | 158 + .../dashboard/{ => deprecated/v1}/actions.js | 2 +- .../deprecated/v1/components/CodeModal.jsx | 48 + .../v1}/components/Controls.jsx | 4 +- .../deprecated/v1/components/CssEditor.jsx | 91 + .../deprecated/v1/components/Dashboard.jsx | 423 ++ .../v1}/components/DashboardContainer.jsx | 0 .../v1}/components/GridCell.jsx | 2 +- .../v1}/components/GridLayout.jsx | 0 .../deprecated/v1/components/Header.jsx | 184 + .../v1/components/RefreshIntervalModal.jsx | 64 + .../deprecated/v1/components/SaveModal.jsx | 161 + .../deprecated/v1/components/SliceAdder.jsx | 216 + .../deprecated/v1/components/SliceHeader.jsx | 194 + .../src/dashboard/deprecated/v1/index.jsx | 28 + .../dashboard/{ => deprecated/v1}/reducers.js | 128 +- .../fixtures/emptyDashboardLayout.js | 34 + superset/assets/src/dashboard/index.jsx | 16 +- .../src/dashboard/reducers/dashboardLayout.js | 249 + .../src/dashboard/reducers/dashboardState.js | 152 + .../src/dashboard/reducers/datasources.js | 20 + .../src/dashboard/reducers/getInitialState.js | 183 + .../assets/src/dashboard/reducers/index.js | 22 + .../src/dashboard/reducers/messageToasts.js | 20 + .../src/dashboard/reducers/sliceEntities.js | 54 + .../reducers/undoableDashboardLayout.js | 30 + .../stylesheets/builder-sidepane.less | 163 + .../src/dashboard/stylesheets/builder.less | 62 + .../src/dashboard/stylesheets/buttons.less | 23 + .../stylesheets/components/chart.less | 100 + .../stylesheets/components/column.less | 50 + .../stylesheets/components/divider.less | 24 + .../stylesheets/components/header.less | 73 + .../stylesheets/components/index.less | 8 + .../stylesheets/components/markdown.less | 25 + .../stylesheets/components/new-component.less | 31 + .../dashboard/stylesheets/components/row.less | 60 + .../stylesheets/components/tabs.less | 84 + .../src/dashboard/stylesheets/dashboard.less | 213 + .../assets/src/dashboard/stylesheets/dnd.less | 111 + .../src/dashboard/stylesheets/grid.less | 42 + .../src/dashboard/stylesheets/hover-menu.less | 77 + .../src/dashboard/stylesheets/index.less | 13 + .../dashboard/stylesheets/popover-menu.less | 136 + .../src/dashboard/stylesheets/resizable.less | 86 + .../src/dashboard/stylesheets/toast.less | 58 + .../src/dashboard/stylesheets/variables.less | 19 + .../dashboard/util/backgroundStyleOptions.js | 15 + .../util/charts/getEffectiveExtraFilters.js | 42 + .../charts/getFormDataWithExtraFilters.js | 38 + .../dashboard/util/componentIsResizable.js | 5 + .../src/dashboard/util/componentTypes.js | 27 + .../assets/src/dashboard/util/constants.js | 47 + .../util/dashboardLayoutConverter.js | 513 ++ .../assets/src/dashboard/util/dnd-reorder.js | 46 + .../src/dashboard/util/dropOverflowsParent.js | 6 + .../util/findFirstParentContainer.js | 19 + .../assets/src/dashboard/util/findParentId.js | 31 + .../dashboard/util/getChartIdsFromLayout.js | 15 + .../util/getComponentWidthFromDrop.js | 57 + .../util/getDetailedComponentWidth.js | 76 + .../src/dashboard/util/getDragDropManager.js | 17 + .../src/dashboard/util/getDropPosition.js | 148 + .../src/dashboard/util/getEmptyLayout.js | 23 + .../src/dashboard/util/headerStyleOptions.js | 20 + .../src/dashboard/util/injectCustomCss.js | 17 + .../assets/src/dashboard/util/isValidChild.js | 98 + .../util/logging/childChartsDidLoad.js | 21 + .../util/logging/findNonTabChildChartIds.js | 45 + .../util/logging/findTopLevelComponentIds.js | 74 + .../getLoadStatsPerTopLevelComponent.js | 26 + .../src/dashboard/util/newComponentFactory.js | 45 + .../src/dashboard/util/newEntitiesFromDrop.js | 51 + .../assets/src/dashboard/util/propShapes.jsx | 100 + .../src/dashboard/util/resizableConfig.js | 35 + .../dashboard/util/shouldWrapChildInRow.js | 30 + .../explore/components/DisplayQueryButton.jsx | 14 +- .../explore/components/ExploreChartHeader.jsx | 6 +- .../explore/components/ExploreChartPanel.jsx | 17 +- .../components/ExploreViewContainer.jsx | 131 +- .../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 +- .../src/profile/components/TableLoader.jsx | 25 +- superset/assets/src/theme.js | 1 - .../visualizations/deckgl/layers/scatter.jsx | 4 +- .../deckgl/layers/screengrid.jsx | 4 +- .../assets/src/visualizations/nvd3_vis.js | 2 + superset/assets/src/visualizations/table.css | 9 +- .../assets/src/visualizations/time_table.jsx | 4 +- .../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 | 5137 +++++++---------- 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 +- .../superset/dashboard_v1_deprecated.html | 10 + superset/views/core.py | 174 +- tests/core_tests.py | 8 + tests/dashboard_tests.py | 78 +- 257 files changed, 21056 insertions(+), 4888 deletions(-) create mode 100644 superset/assets/spec/javascripts/dashboard/.eslintrc create mode 100644 superset/assets/spec/javascripts/dashboard/.prettierrc delete mode 100644 superset/assets/spec/javascripts/dashboard/Dashboard_spec.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/actions/dashboardLayout_spec.js rename superset/assets/spec/javascripts/dashboard/{ => components}/CodeModal_spec.jsx (72%) rename superset/assets/spec/javascripts/dashboard/{ => components}/CssEditor_spec.jsx (72%) create mode 100644 superset/assets/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/components/DashboardGrid_spec.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx rename superset/assets/spec/javascripts/dashboard/{ => components}/RefreshIntervalModal_spec.jsx (85%) create mode 100644 superset/assets/spec/javascripts/dashboard/components/SliceAdder_spec.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/components/ToastPresenter_spec.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/components/Toast_spec.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/components/dnd/DragDroppable_spec.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/components/gridComponents/ChartHolder_spec.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/components/gridComponents/Column_spec.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/components/gridComponents/Divider_spec.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/components/gridComponents/Header_spec.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/components/gridComponents/Markdown_spec.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/components/gridComponents/Row_spec.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/components/gridComponents/Tab_spec.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/components/gridComponents/Tabs_spec.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/components/gridComponents/new/DraggableNewComponent_spec.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewColumn_spec.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewDivider_spec.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewHeader_spec.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewRow_spec.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewTabs_spec.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/components/menu/HoverMenu_spec.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/components/menu/WithPopoverMenu_spec.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/components/resizable/ResizableContainer_spec.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/components/resizable/ResizableHandle_spec.jsx delete mode 100644 superset/assets/spec/javascripts/dashboard/fixtures.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/fixtures/mockChartQueries.js create mode 100644 superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardInfo.js create mode 100644 superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardLayout.js create mode 100644 superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardState.js create mode 100644 superset/assets/spec/javascripts/dashboard/fixtures/mockDatasource.js create mode 100644 superset/assets/spec/javascripts/dashboard/fixtures/mockMessageToasts.js create mode 100644 superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js create mode 100644 superset/assets/spec/javascripts/dashboard/fixtures/mockState.js create mode 100644 superset/assets/spec/javascripts/dashboard/fixtures/mockStore.js create mode 100644 superset/assets/spec/javascripts/dashboard/helpers/WithDragDropContext.jsx create mode 100644 superset/assets/spec/javascripts/dashboard/reducers/dashboardLayout_spec.js create mode 100644 superset/assets/spec/javascripts/dashboard/reducers/dashboardState_spec.js create mode 100644 superset/assets/spec/javascripts/dashboard/reducers/messageToasts_spec.js create mode 100644 superset/assets/spec/javascripts/dashboard/reducers/sliceEntities_spec.js delete mode 100644 superset/assets/spec/javascripts/dashboard/reducers_spec.js create mode 100644 superset/assets/spec/javascripts/dashboard/util/componentIsResizable_spec.js create mode 100644 superset/assets/spec/javascripts/dashboard/util/dnd-reorder_spec.js create mode 100644 superset/assets/spec/javascripts/dashboard/util/dropOverflowsParent_spec.js create mode 100644 superset/assets/spec/javascripts/dashboard/util/findFirstParentContainer_spec.js create mode 100644 superset/assets/spec/javascripts/dashboard/util/findParentId_spec.js create mode 100644 superset/assets/spec/javascripts/dashboard/util/getChartIdsFromLayout_spec.js create mode 100644 superset/assets/spec/javascripts/dashboard/util/getDetailedComponentWidth_spec.js create mode 100644 superset/assets/spec/javascripts/dashboard/util/getDropPosition_spec.js create mode 100644 superset/assets/spec/javascripts/dashboard/util/getFormDataWithExtraFilters_spec.js create mode 100644 superset/assets/spec/javascripts/dashboard/util/isValidChild_spec.js create mode 100644 superset/assets/spec/javascripts/dashboard/util/newComponentFactory_spec.js create mode 100644 superset/assets/spec/javascripts/dashboard/util/newEntitiesFromDrop_spec.js create mode 100644 superset/assets/spec/javascripts/logger_spec.js create mode 100644 superset/assets/src/components/ActionMenuItem.jsx create mode 100644 superset/assets/src/dashboard/.eslintrc create mode 100644 superset/assets/src/dashboard/.prettierrc create mode 100644 superset/assets/src/dashboard/actions/dashboardLayout.js create mode 100644 superset/assets/src/dashboard/actions/dashboardState.js create mode 100644 superset/assets/src/dashboard/actions/datasources.js create mode 100644 superset/assets/src/dashboard/actions/messageToasts.js create mode 100644 superset/assets/src/dashboard/actions/sliceEntities.js create mode 100644 superset/assets/src/dashboard/components/AddSliceCard.jsx create mode 100644 superset/assets/src/dashboard/components/BuilderComponentPane.jsx create mode 100644 superset/assets/src/dashboard/components/DashboardBuilder.jsx create mode 100644 superset/assets/src/dashboard/components/DashboardGrid.jsx create mode 100644 superset/assets/src/dashboard/components/DeleteComponentButton.jsx create mode 100644 superset/assets/src/dashboard/components/HeaderActionsDropdown.jsx create mode 100644 superset/assets/src/dashboard/components/IconButton.jsx create mode 100644 superset/assets/src/dashboard/components/SliceHeaderControls.jsx create mode 100644 superset/assets/src/dashboard/components/Toast.jsx create mode 100644 superset/assets/src/dashboard/components/ToastPresenter.jsx create mode 100644 superset/assets/src/dashboard/components/UndoRedoKeylisteners.jsx create mode 100644 superset/assets/src/dashboard/components/dnd/AddSliceDragPreview.jsx create mode 100644 superset/assets/src/dashboard/components/dnd/DragDroppable.jsx create mode 100644 superset/assets/src/dashboard/components/dnd/DragHandle.jsx create mode 100644 superset/assets/src/dashboard/components/dnd/dragDroppableConfig.js create mode 100644 superset/assets/src/dashboard/components/dnd/handleDrop.js create mode 100644 superset/assets/src/dashboard/components/dnd/handleHover.js create mode 100644 superset/assets/src/dashboard/components/gridComponents/Chart.jsx create mode 100644 superset/assets/src/dashboard/components/gridComponents/ChartHolder.jsx create mode 100644 superset/assets/src/dashboard/components/gridComponents/Column.jsx create mode 100644 superset/assets/src/dashboard/components/gridComponents/Divider.jsx create mode 100644 superset/assets/src/dashboard/components/gridComponents/Header.jsx create mode 100644 superset/assets/src/dashboard/components/gridComponents/Markdown.jsx create mode 100644 superset/assets/src/dashboard/components/gridComponents/Row.jsx create mode 100644 superset/assets/src/dashboard/components/gridComponents/Tab.jsx create mode 100644 superset/assets/src/dashboard/components/gridComponents/Tabs.jsx create mode 100644 superset/assets/src/dashboard/components/gridComponents/index.js create mode 100644 superset/assets/src/dashboard/components/gridComponents/new/DraggableNewComponent.jsx create mode 100644 superset/assets/src/dashboard/components/gridComponents/new/NewColumn.jsx create mode 100644 superset/assets/src/dashboard/components/gridComponents/new/NewDivider.jsx create mode 100644 superset/assets/src/dashboard/components/gridComponents/new/NewHeader.jsx create mode 100644 superset/assets/src/dashboard/components/gridComponents/new/NewMarkdown.jsx create mode 100644 superset/assets/src/dashboard/components/gridComponents/new/NewRow.jsx create mode 100644 superset/assets/src/dashboard/components/gridComponents/new/NewTabs.jsx create mode 100644 superset/assets/src/dashboard/components/menu/BackgroundStyleDropdown.jsx create mode 100644 superset/assets/src/dashboard/components/menu/HoverMenu.jsx create mode 100644 superset/assets/src/dashboard/components/menu/MarkdownModeDropdown.jsx create mode 100644 superset/assets/src/dashboard/components/menu/PopoverDropdown.jsx create mode 100644 superset/assets/src/dashboard/components/menu/WithPopoverMenu.jsx create mode 100644 superset/assets/src/dashboard/components/resizable/ResizableContainer.jsx create mode 100644 superset/assets/src/dashboard/components/resizable/ResizableHandle.jsx create mode 100644 superset/assets/src/dashboard/containers/Chart.jsx create mode 100644 superset/assets/src/dashboard/containers/Dashboard.jsx create mode 100644 superset/assets/src/dashboard/containers/DashboardBuilder.jsx create mode 100644 superset/assets/src/dashboard/containers/DashboardComponent.jsx create mode 100644 superset/assets/src/dashboard/containers/DashboardGrid.jsx create mode 100644 superset/assets/src/dashboard/containers/DashboardHeader.jsx create mode 100644 superset/assets/src/dashboard/containers/SliceAdder.jsx create mode 100644 superset/assets/src/dashboard/containers/ToastPresenter.jsx create mode 100644 superset/assets/src/dashboard/deprecated/PromptV2ConversionModal.jsx create mode 100644 superset/assets/src/dashboard/deprecated/V2PreviewModal.jsx create mode 100644 superset/assets/src/dashboard/deprecated/chart/Chart.jsx create mode 100644 superset/assets/src/dashboard/deprecated/chart/ChartBody.jsx create mode 100644 superset/assets/src/dashboard/deprecated/chart/ChartContainer.jsx create mode 100644 superset/assets/src/dashboard/deprecated/chart/chart.css create mode 100644 superset/assets/src/dashboard/deprecated/chart/chartAction.js create mode 100644 superset/assets/src/dashboard/deprecated/chart/chartReducer.js rename superset/assets/src/dashboard/{ => deprecated/v1}/actions.js (98%) create mode 100644 superset/assets/src/dashboard/deprecated/v1/components/CodeModal.jsx rename superset/assets/src/dashboard/{ => deprecated/v1}/components/Controls.jsx (97%) create mode 100644 superset/assets/src/dashboard/deprecated/v1/components/CssEditor.jsx create mode 100644 superset/assets/src/dashboard/deprecated/v1/components/Dashboard.jsx rename superset/assets/src/dashboard/{ => deprecated/v1}/components/DashboardContainer.jsx (100%) rename superset/assets/src/dashboard/{ => deprecated/v1}/components/GridCell.jsx (98%) rename superset/assets/src/dashboard/{ => deprecated/v1}/components/GridLayout.jsx (100%) create mode 100644 superset/assets/src/dashboard/deprecated/v1/components/Header.jsx create mode 100644 superset/assets/src/dashboard/deprecated/v1/components/RefreshIntervalModal.jsx create mode 100644 superset/assets/src/dashboard/deprecated/v1/components/SaveModal.jsx create mode 100644 superset/assets/src/dashboard/deprecated/v1/components/SliceAdder.jsx create mode 100644 superset/assets/src/dashboard/deprecated/v1/components/SliceHeader.jsx create mode 100644 superset/assets/src/dashboard/deprecated/v1/index.jsx rename superset/assets/src/dashboard/{ => deprecated/v1}/reducers.js (66%) create mode 100644 superset/assets/src/dashboard/fixtures/emptyDashboardLayout.js create mode 100644 superset/assets/src/dashboard/reducers/dashboardLayout.js create mode 100644 superset/assets/src/dashboard/reducers/dashboardState.js create mode 100644 superset/assets/src/dashboard/reducers/datasources.js create mode 100644 superset/assets/src/dashboard/reducers/getInitialState.js create mode 100644 superset/assets/src/dashboard/reducers/index.js create mode 100644 superset/assets/src/dashboard/reducers/messageToasts.js create mode 100644 superset/assets/src/dashboard/reducers/sliceEntities.js create mode 100644 superset/assets/src/dashboard/reducers/undoableDashboardLayout.js create mode 100644 superset/assets/src/dashboard/stylesheets/builder-sidepane.less create mode 100644 superset/assets/src/dashboard/stylesheets/builder.less create mode 100644 superset/assets/src/dashboard/stylesheets/buttons.less create mode 100644 superset/assets/src/dashboard/stylesheets/components/chart.less create mode 100644 superset/assets/src/dashboard/stylesheets/components/column.less create mode 100644 superset/assets/src/dashboard/stylesheets/components/divider.less create mode 100644 superset/assets/src/dashboard/stylesheets/components/header.less create mode 100644 superset/assets/src/dashboard/stylesheets/components/index.less create mode 100644 superset/assets/src/dashboard/stylesheets/components/markdown.less create mode 100644 superset/assets/src/dashboard/stylesheets/components/new-component.less create mode 100644 superset/assets/src/dashboard/stylesheets/components/row.less create mode 100644 superset/assets/src/dashboard/stylesheets/components/tabs.less create mode 100644 superset/assets/src/dashboard/stylesheets/dashboard.less create mode 100644 superset/assets/src/dashboard/stylesheets/dnd.less create mode 100644 superset/assets/src/dashboard/stylesheets/grid.less create mode 100644 superset/assets/src/dashboard/stylesheets/hover-menu.less create mode 100644 superset/assets/src/dashboard/stylesheets/index.less create mode 100644 superset/assets/src/dashboard/stylesheets/popover-menu.less create mode 100644 superset/assets/src/dashboard/stylesheets/resizable.less create mode 100644 superset/assets/src/dashboard/stylesheets/toast.less create mode 100644 superset/assets/src/dashboard/stylesheets/variables.less create mode 100644 superset/assets/src/dashboard/util/backgroundStyleOptions.js create mode 100644 superset/assets/src/dashboard/util/charts/getEffectiveExtraFilters.js create mode 100644 superset/assets/src/dashboard/util/charts/getFormDataWithExtraFilters.js create mode 100644 superset/assets/src/dashboard/util/componentIsResizable.js create mode 100644 superset/assets/src/dashboard/util/componentTypes.js create mode 100644 superset/assets/src/dashboard/util/constants.js create mode 100644 superset/assets/src/dashboard/util/dashboardLayoutConverter.js create mode 100644 superset/assets/src/dashboard/util/dnd-reorder.js create mode 100644 superset/assets/src/dashboard/util/dropOverflowsParent.js create mode 100644 superset/assets/src/dashboard/util/findFirstParentContainer.js create mode 100644 superset/assets/src/dashboard/util/findParentId.js create mode 100644 superset/assets/src/dashboard/util/getChartIdsFromLayout.js create mode 100644 superset/assets/src/dashboard/util/getComponentWidthFromDrop.js create mode 100644 superset/assets/src/dashboard/util/getDetailedComponentWidth.js create mode 100644 superset/assets/src/dashboard/util/getDragDropManager.js create mode 100644 superset/assets/src/dashboard/util/getDropPosition.js create mode 100644 superset/assets/src/dashboard/util/getEmptyLayout.js create mode 100644 superset/assets/src/dashboard/util/headerStyleOptions.js create mode 100644 superset/assets/src/dashboard/util/injectCustomCss.js create mode 100644 superset/assets/src/dashboard/util/isValidChild.js create mode 100644 superset/assets/src/dashboard/util/logging/childChartsDidLoad.js create mode 100644 superset/assets/src/dashboard/util/logging/findNonTabChildChartIds.js create mode 100644 superset/assets/src/dashboard/util/logging/findTopLevelComponentIds.js create mode 100644 superset/assets/src/dashboard/util/logging/getLoadStatsPerTopLevelComponent.js create mode 100644 superset/assets/src/dashboard/util/newComponentFactory.js create mode 100644 superset/assets/src/dashboard/util/newEntitiesFromDrop.js create mode 100644 superset/assets/src/dashboard/util/propShapes.jsx create mode 100644 superset/assets/src/dashboard/util/resizableConfig.js create mode 100644 superset/assets/src/dashboard/util/shouldWrapChildInRow.js rename superset/assets/stylesheets/{dashboard.css => dashboard_deprecated.css} (84%) create mode 100644 superset/templates/superset/dashboard_v1_deprecated.html diff --git a/superset/assets/.eslintignore b/superset/assets/.eslintignore index 7479173e66935..61262fc2e1f77 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 01ae3939c49bfd7c8c1086c69775c098dca2485e..d82fc5d9244e2b00a16c186bcee0503d71661683 100644 GIT binary patch literal 79023 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 z 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 literal 16671 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 diff --git a/superset/assets/package.json b/superset/assets/package.json index b165eb48efe48..fb96f73473742 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'", @@ -43,6 +43,7 @@ "dependencies": { "@data-ui/event-flow": "^0.0.8", "@data-ui/sparkline": "^0.0.49", + "@vx/responsive": "0.0.153", "babel-register": "^6.24.1", "bootstrap": "^3.3.6", "bootstrap-slider": "^10.0.0", @@ -60,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", @@ -82,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", @@ -92,16 +95,21 @@ "react-bootstrap-table": "^4.0.2", "react-color": "^2.13.8", "react-datetime": "2.9.0", + "react-dnd": "^2.5.4", + "react-dnd-html5-backend": "^2.5.4", "react-dom": "^15.6.2", "react-gravatar": "^2.6.1", "react-grid-layout": "0.16.5", "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.6.7", "react-split-pane": "^0.1.66", + "react-sticky": "^6.0.2", "react-syntax-highlighter": "^5.7.0", "react-virtualized": "9.3.0", "react-virtualized-select": "2.4.0", @@ -109,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", @@ -132,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", @@ -148,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 d465d864733ef..b30d3c79edc86 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(''); 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 b766d9f8f4387..29a2941870de5 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 0000000000000..a3f86e3a17a0c --- /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 0000000000000..a20502b7f06d8 --- /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 c6e94d87d9552..0000000000000 --- 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(); - 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(); - expect(wrapper.instance().props.filters).deep.equal(defaultFilters); - }); - - describe('getFormDataExtra', () => { - let wrapper; - let selectedSlice; - beforeEach(() => { - wrapper = shallow(); - 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(); - 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(); - 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 0000000000000..4b2848085c0f2 --- /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 a93c5573ed67a..d316dc3d385b7 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: , }; it('is valid', () => { - expect( - React.isValidElement(), - ).to.equal(true); + expect(React.isValidElement()).to.equal(true); }); it('renders the trigger node', () => { const wrapper = mount(); 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 c325dc1b78689..8c991fa489cb1 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: , }; it('is valid', () => { - expect( - React.isValidElement(), - ).to.equal(true); + expect(React.isValidElement()).to.equal(true); }); it('renders the trigger node', () => { const wrapper = mount(); 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 0000000000000..4c3185fecdebf --- /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 = ; + return useProvider + ? mount( + + {builder} + , + ) + : 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 0000000000000..d11c37f3315fa --- /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(); + 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 0000000000000..e8550095929ea --- /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(); + 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 3a2f7000dfb83..564857c0887e9 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 0000000000000..da0f7df754410 --- /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(); + wrapper.setState({ filteredSlices: Object.values(props.slices) }); + expect(wrapper.find(List)).to.have.length(1); + }); + + it('render error', () => { + const wrapper = shallow(); + 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(, { + 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(); + 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(); + 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 0000000000000..7545ad6b0986a --- /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(); + 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 0000000000000..6ed0bc5adf517 --- /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(); + 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 0000000000000..c7e2c2a8d3672 --- /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(); + 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 0000000000000..821b6371f70c1 --- /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( + + + + + , + ); + 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 0000000000000..dcd711947afc7 --- /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(); + 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 0000000000000..e97414b65e205 --- /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( + + + + + , + ); + 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 0000000000000..c8317f8459faf --- /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( + + + , + ); + 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 0000000000000..1d547756a896a --- /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( + +
+ , + ); + 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 0000000000000..ca71045de7f42 --- /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( + + + + + , + ); + 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 0000000000000..a718ff406a1af --- /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( + + + + + , + ); + 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 0000000000000..a984565b4c4fb --- /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( + + + + + , + ); + 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 0000000000000..d521fe50457ba --- /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( + + + + + , + ); + 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 0000000000000..4334b37ca455a --- /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( + + + , + ); + 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 0000000000000..cb07cb988a4e3 --- /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(); + } + + 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 0000000000000..71703b3900fc6 --- /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(); + } + + 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 0000000000000..a499fe8f6e836 --- /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(); + } + + 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 0000000000000..e91893d489dd1 --- /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(); + } + + 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 0000000000000..4e71c8ca42612 --- /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(); + } + + 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 0000000000000..1f8508574371d --- /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(); + 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 0000000000000..5add770a8cf2b --- /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:
, + disableClick: false, + menuItems: [