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 01ae3939c49bf..d82fc5d9244e2 100644 Binary files a/superset/assets/images/loading.gif and b/superset/assets/images/loading.gif differ diff --git a/superset/assets/package.json b/superset/assets/package.json index 1bcb5d61b93dc..6a3935422a40d 100644 --- a/superset/assets/package.json +++ b/superset/assets/package.json @@ -8,8 +8,8 @@ "test": "spec" }, "scripts": { - "test": "mocha --require ignore-styles --compilers js:babel-core/register --require spec/helpers/browser.js --recursive spec/**/*_spec.*", - "cover": "babel-node node_modules/.bin/babel-istanbul cover _mocha -- --require ignore-styles spec/helpers/browser.js --recursive spec/**/*_spec.*", + "test": "mocha --require ignore-styles --compilers js:babel-core/register --require spec/helpers/browser.js 'spec/**/*_spec.*'", + "cover": "babel-node node_modules/.bin/babel-istanbul cover _mocha -- --require ignore-styles spec/helpers/browser.js 'spec/**/*_spec.*'", "dev": "NODE_ENV=dev webpack --watch --colors --progress --debug --output-pathinfo --devtool eval-cheap-source-map", "dev-slow": "NODE_ENV=dev webpack --watch --colors --progress --debug --output-pathinfo --devtool inline-source-map", "dev-fast": "echo 'dev-fast in now replaced by dev'", @@ -41,9 +41,9 @@ }, "homepage": "http://superset.apache.org/", "dependencies": { - "//": "known issues with react-bootstrap>=0.32", "@data-ui/event-flow": "^0.0.54", "@data-ui/sparkline": "^0.0.54", + "@vx/responsive": "0.0.153", "babel-register": "^6.24.1", "bootstrap": "^3.3.6", "bootstrap-slider": "^10.0.0", @@ -61,6 +61,7 @@ "deck.gl": "^5.1.4", "deep-equal": "^1.0.1", "distributions": "^1.0.0", + "dnd-core": "^2.6.0", "dompurify": "^1.0.3", "fastdom": "^1.0.6", "geojson-extent": "^0.3.2", @@ -83,8 +84,9 @@ "parse-iso-duration": "^1.0.0", "po2json": "^0.4.5", "prop-types": "^15.6.0", + "re-resizable": "^4.3.1", "react": "^15.6.2", - "react-ace": "^5.0.1", + "react-ace": "^5.10.0", "react-addons-css-transition-group": "^15.6.0", "react-addons-shallow-compare": "^15.4.2", "react-alert": "^2.3.0", @@ -93,16 +95,21 @@ "react-bootstrap-table": "^4.3.1", "react-color": "^2.13.8", "react-datetime": "2.14.0", + "react-dnd": "^2.5.4", + "react-dnd-html5-backend": "^2.5.4", "react-dom": "^15.6.2", "react-gravatar": "^2.6.1", "react-grid-layout": "0.16.6", "react-map-gl": "^3.0.4", + "react-markdown": "^3.3.0", "react-redux": "^5.0.2", "react-resizable": "^1.3.3", + "react-search-input": "^0.11.3", "react-select": "1.2.1", "react-select-fast-filter-options": "^0.2.1", "react-sortable-hoc": "^0.8.3", "react-split-pane": "^0.1.66", + "react-sticky": "^6.0.2", "react-syntax-highlighter": "^7.0.4", "react-virtualized": "9.19.1", "react-virtualized-select": "^2.4.0", @@ -110,14 +117,14 @@ "redux": "^3.5.2", "redux-localstorage": "^0.4.1", "redux-thunk": "^2.1.0", + "redux-undo": "^1.0.0-beta9-9-7", "shortid": "^2.2.6", "sprintf-js": "^1.1.1", "srcdoc-polyfill": "^1.0.0", "supercluster": "https://github.com/georgeke/supercluster/tarball/ac3492737e7ce98e07af679623aad452373bbc40", "underscore": "^1.8.3", "urijs": "^1.18.10", - "viewport-mercator-project": "^5.0.0", - "webpack-cli": "^2.1.4" + "viewport-mercator-project": "^5.0.0" }, "devDependencies": { "babel-cli": "^6.14.0", @@ -133,8 +140,10 @@ "enzyme": "^2.0.0", "eslint": "^4.19.0", "eslint-config-airbnb": "^15.0.1", + "eslint-config-prettier": "^2.9.0", "eslint-plugin-import": "^2.2.0", "eslint-plugin-jsx-a11y": "^5.1.1", + "eslint-plugin-prettier": "^2.6.0", "eslint-plugin-react": "^7.0.1", "exports-loader": "^0.7.0", "extract-text-webpack-plugin": "3.0.2", @@ -149,6 +158,7 @@ "less-loader": "^4.0.3", "mocha": "^3.2.0", "npm-check-updates": "^2.14.0", + "prettier": "^1.12.1", "react-addons-test-utils": "^15.6.2", "react-test-renderer": "^15.6.2", "redux-mock-store": "^1.2.3", diff --git a/superset/assets/spec/helpers/browser.js b/superset/assets/spec/helpers/browser.js index 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: [