diff --git a/superset/assets/spec/javascripts/dashboard/containers/Dashboard_spec.jsx b/superset/assets/spec/javascripts/dashboard/containers/Dashboard_spec.jsx new file mode 100644 index 0000000000000..78d781ba475e1 --- /dev/null +++ b/superset/assets/spec/javascripts/dashboard/containers/Dashboard_spec.jsx @@ -0,0 +1,39 @@ +import React from 'react'; +import configureStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { shallow } from 'enzyme'; +import { expect } from 'chai'; + +import Dashboard from '../../../../src/dashboard/containers/Dashboard'; +import getInitialState from '../../../../src/dashboard/reducers/getInitialState'; + +describe('Dashboard Container', () => { + const middlewares = [thunk]; + const mockStore = configureStore(middlewares); + let store; + let wrapper; + + before(() => { + const bootstrapData = { + dashboard_data: { + slices: [], + metadata: {}, + }, + common: { + feature_flags: { + FOO_BAR: true, + }, + conf: {}, + }, + }; + store = mockStore(getInitialState(bootstrapData), {}); + }); + + beforeEach(() => { + wrapper = shallow(, { context: { store } }); + }); + + it('should set feature flags', () => { + expect(wrapper.prop('isFeatureEnabled')('FOO_BAR')).to.equal(true); + }); +}); diff --git a/superset/assets/spec/javascripts/explore/components/ExploreViewContainer_spec.js b/superset/assets/spec/javascripts/explore/components/ExploreViewContainer_spec.js deleted file mode 100644 index e6340f2aea03b..0000000000000 --- a/superset/assets/spec/javascripts/explore/components/ExploreViewContainer_spec.js +++ /dev/null @@ -1,38 +0,0 @@ -// this test must be commented out because ChartContainer is now importing files -// from visualizations/*.js which are also importing css files which breaks in the testing env. - -// import React from 'react'; -// import { expect } from 'chai'; -// // import { shallow } from 'enzyme'; - -// import ExploreViewContainer -// from '../../../../src/explore/components/ExploreViewContainer'; -// import QueryAndSaveBtns -// from '../../../../src/explore/components/QueryAndSaveBtns'; -// import ControlPanelsContainer -// from '../../../../src/explore/components/ControlPanelsContainer'; -// import ChartContainer -// from '../../../../src/explore/components/ChartContainer'; - -// describe('ExploreViewContainer', () => { -// it('renders', () => { -// expect( -// React.isValidElement() -// ).to.equal(true); -// }); - -// it('renders QueryAndSaveButtons', () => { -// const wrapper = shallow(); -// expect(wrapper.find(QueryAndSaveBtns)).to.have.length(1); -// }); - -// it('renders ControlPanelsContainer', () => { -// const wrapper = shallow(); -// expect(wrapper.find(ControlPanelsContainer)).to.have.length(1); -// }); - -// it('renders ChartContainer', () => { -// const wrapper = shallow(); -// expect(wrapper.find(ChartContainer)).to.have.length(1); -// }); -// }); diff --git a/superset/assets/spec/javascripts/explore/components/ExploreViewContainer_spec.jsx b/superset/assets/spec/javascripts/explore/components/ExploreViewContainer_spec.jsx new file mode 100644 index 0000000000000..525a85b8d5f6d --- /dev/null +++ b/superset/assets/spec/javascripts/explore/components/ExploreViewContainer_spec.jsx @@ -0,0 +1,69 @@ +import React from 'react'; +import configureStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { expect } from 'chai'; +import { shallow } from 'enzyme'; + +import getInitialState from '../../../../src/explore/reducers/getInitialState'; +import ExploreViewContainer + from '../../../../src/explore/components/ExploreViewContainer'; +import QueryAndSaveBtns + from '../../../../src/explore/components/QueryAndSaveBtns'; +import ControlPanelsContainer + from '../../../../src/explore/components/ControlPanelsContainer'; +import ChartContainer + from '../../../../src/explore/components/ExploreChartPanel'; + +describe('ExploreViewContainer', () => { + const middlewares = [thunk]; + const mockStore = configureStore(middlewares); + let store; + let wrapper; + + before(() => { + const bootstrapData = { + common: { + feature_flags: { + FOO_BAR: true, + }, + conf: {}, + }, + datasource: { + columns: [], + }, + form_data: { + datasource: {}, + }, + }; + store = mockStore(getInitialState(bootstrapData), {}); + }); + + beforeEach(() => { + wrapper = shallow(, { + context: { store }, + disableLifecycleMethods: true, + }); + }); + + it('should set feature flags', () => { + expect(wrapper.prop('isFeatureEnabled')('FOO_BAR')).to.equal(true); + }); + + it('renders', () => { + expect( + React.isValidElement(), + ).to.equal(true); + }); + + it('renders QueryAndSaveButtons', () => { + expect(wrapper.dive().find(QueryAndSaveBtns)).to.have.length(1); + }); + + it('renders ControlPanelsContainer', () => { + expect(wrapper.dive().find(ControlPanelsContainer)).to.have.length(1); + }); + + it('renders ChartContainer', () => { + expect(wrapper.dive().find(ChartContainer)).to.have.length(1); + }); +}); diff --git a/superset/assets/spec/javascripts/sqllab/App_spec.jsx b/superset/assets/spec/javascripts/sqllab/App_spec.jsx index 4e64d179ca77f..bee2569afc52c 100644 --- a/superset/assets/spec/javascripts/sqllab/App_spec.jsx +++ b/superset/assets/spec/javascripts/sqllab/App_spec.jsx @@ -8,16 +8,31 @@ import sinon from 'sinon'; import App from '../../../src/SqlLab/components/App'; import TabbedSqlEditors from '../../../src/SqlLab/components/TabbedSqlEditors'; -import { sqlLabReducer } from '../../../src/SqlLab/reducers'; +import getInitialState from '../../../src/SqlLab/getInitialState'; -describe('App', () => { +describe('SqlLab App', () => { const middlewares = [thunk]; const mockStore = configureStore(middlewares); - const store = mockStore({ sqlLab: sqlLabReducer(undefined, {}), messageToasts: [] }); - + let store; let wrapper; + + before(() => { + const bootstrapData = { + common: { + feature_flags: { + FOO_BAR: true, + }, + }, + }; + store = mockStore(getInitialState(bootstrapData), {}); + }); + beforeEach(() => { - wrapper = shallow(, { context: { store } }).dive(); + wrapper = shallow(, { context: { store } }); + }); + + it('should set feature flags', () => { + expect(wrapper.prop('isFeatureEnabled')('FOO_BAR')).to.equal(true); }); it('is valid', () => { @@ -25,14 +40,16 @@ describe('App', () => { }); it('should handler resize', () => { - sinon.spy(wrapper.instance(), 'getHeight'); - wrapper.instance().handleResize(); - expect(wrapper.instance().getHeight.callCount).to.equal(1); - wrapper.instance().getHeight.restore(); + const inner = wrapper.dive(); + sinon.spy(inner.instance(), 'getHeight'); + inner.instance().handleResize(); + expect(inner.instance().getHeight.callCount).to.equal(1); + inner.instance().getHeight.restore(); }); it('should render', () => { - expect(wrapper.find('.SqlLab')).to.have.length(1); - expect(wrapper.find(TabbedSqlEditors)).to.have.length(1); + const inner = wrapper.dive(); + expect(inner.find('.SqlLab')).to.have.length(1); + expect(inner.find(TabbedSqlEditors)).to.have.length(1); }); }); diff --git a/superset/assets/src/SqlLab/components/App.jsx b/superset/assets/src/SqlLab/components/App.jsx index 8a0c084373894..19f8848010d96 100644 --- a/superset/assets/src/SqlLab/components/App.jsx +++ b/superset/assets/src/SqlLab/components/App.jsx @@ -9,6 +9,7 @@ import QueryAutoRefresh from './QueryAutoRefresh'; import QuerySearch from './QuerySearch'; import ToastPresenter from '../../messageToasts/containers/ToastPresenter'; import * as Actions from '../actions'; +import { isFeatureEnabledCreator } from '../../featureFlags'; class App extends React.PureComponent { constructor(props) { @@ -83,6 +84,10 @@ App.propTypes = { actions: PropTypes.object, }; +const mapStateToProps = state => ({ + isFeatureEnabled: isFeatureEnabledCreator(state), +}); + function mapDispatchToProps(dispatch) { return { actions: bindActionCreators(Actions, dispatch), @@ -91,6 +96,6 @@ function mapDispatchToProps(dispatch) { export { App }; export default connect( - null, + mapStateToProps, mapDispatchToProps, )(App); diff --git a/superset/assets/src/SqlLab/getInitialState.js b/superset/assets/src/SqlLab/getInitialState.js index c33ff1e2f4c6d..b914220580ebd 100644 --- a/superset/assets/src/SqlLab/getInitialState.js +++ b/superset/assets/src/SqlLab/getInitialState.js @@ -14,6 +14,7 @@ export default function getInitialState({ defaultDbId, ...restBootstrapData }) { }; return { + featureFlags: restBootstrapData.common.feature_flags, sqlLab: { alerts: [], queries: {}, diff --git a/superset/assets/src/SqlLab/reducers.js b/superset/assets/src/SqlLab/reducers.js index 537f4befbbd42..7916b72d27dbd 100644 --- a/superset/assets/src/SqlLab/reducers.js +++ b/superset/assets/src/SqlLab/reducers.js @@ -13,6 +13,7 @@ import { getFromArr, addToArr, } from '../reduxUtils'; +import featureFlags from '../featureFlags'; import { t } from '../locales'; export const sqlLabReducer = function (state = {}, action) { @@ -267,6 +268,7 @@ export const sqlLabReducer = function (state = {}, action) { }; export default combineReducers({ + featureFlags, sqlLab: sqlLabReducer, messageToasts, }); diff --git a/superset/assets/src/dashboard/containers/Dashboard.jsx b/superset/assets/src/dashboard/containers/Dashboard.jsx index d43e448793816..9507dcde155d3 100644 --- a/superset/assets/src/dashboard/containers/Dashboard.jsx +++ b/superset/assets/src/dashboard/containers/Dashboard.jsx @@ -1,6 +1,7 @@ import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; +import { isFeatureEnabledCreator } from '../../featureFlags'; import Dashboard from '../components/Dashboard'; import { @@ -10,16 +11,19 @@ import { import { runQuery } from '../../chart/chartAction'; import getLoadStatsPerTopLevelComponent from '../util/logging/getLoadStatsPerTopLevelComponent'; -function mapStateToProps({ - datasources, - sliceEntities, - charts, - dashboardInfo, - dashboardState, - dashboardLayout, - impressionId, -}) { +function mapStateToProps(state) { + const { + datasources, + sliceEntities, + charts, + dashboardInfo, + dashboardState, + dashboardLayout, + impressionId, + } = state; + return { + isFeatureEnabled: isFeatureEnabledCreator(state), initMessages: dashboardInfo.common.flash_messages, timeout: dashboardInfo.common.conf.SUPERSET_WEBSERVER_TIMEOUT, userId: dashboardInfo.userId, diff --git a/superset/assets/src/dashboard/reducers/getInitialState.js b/superset/assets/src/dashboard/reducers/getInitialState.js index 2a4a5e26fa310..523db9f251ca7 100644 --- a/superset/assets/src/dashboard/reducers/getInitialState.js +++ b/superset/assets/src/dashboard/reducers/getInitialState.js @@ -22,8 +22,6 @@ import { getScale } from '../../modules/CategoricalColorNamespace'; export default function(bootstrapData) { const { user_id, datasources, common, editMode } = bootstrapData; - delete common.locale; - delete common.language_pack; const dashboard = { ...bootstrapData.dashboard_data }; let filters = {}; @@ -140,6 +138,7 @@ export default function(bootstrapData) { }; return { + featureFlags: common.feature_flags, datasources, sliceEntities: { ...initSliceEntities, slices, isLoading: false }, charts: chartQueries, @@ -159,7 +158,10 @@ export default function(bootstrapData) { dash_save_perm: dashboard.dash_save_perm, superset_can_explore: dashboard.superset_can_explore, slice_can_edit: dashboard.slice_can_edit, - common, + common: { + flash_messages: common.flash_messages, + conf: common.conf, + }, }, dashboardState: { sliceIds: Array.from(sliceIds), diff --git a/superset/assets/src/dashboard/reducers/index.js b/superset/assets/src/dashboard/reducers/index.js index a5be96fdf4db5..28751ea068338 100644 --- a/superset/assets/src/dashboard/reducers/index.js +++ b/superset/assets/src/dashboard/reducers/index.js @@ -5,12 +5,14 @@ import dashboardState from './dashboardState'; import datasources from './datasources'; import sliceEntities from './sliceEntities'; import dashboardLayout from '../reducers/undoableDashboardLayout'; +import featureFlags from '../../featureFlags'; import messageToasts from '../../messageToasts/reducers'; const dashboardInfo = (state = {}) => state; const impressionId = (state = '') => state; export default combineReducers({ + featureFlags, charts, datasources, dashboardInfo, diff --git a/superset/assets/src/explore/App.jsx b/superset/assets/src/explore/App.jsx index d46b49458ecd8..002eb261a6b5d 100644 --- a/superset/assets/src/explore/App.jsx +++ b/superset/assets/src/explore/App.jsx @@ -4,16 +4,12 @@ import { createStore, applyMiddleware, compose } from 'redux'; import { Provider } from 'react-redux'; import thunk from 'redux-thunk'; -import shortid from 'shortid'; -import { now } from '../modules/dates'; import { initEnhancer } from '../reduxUtils'; -import { getChartKey } from './exploreUtils'; import ToastPresenter from '../messageToasts/containers/ToastPresenter'; -import { getControlsState, getFormDataFromControls } from './store'; import { initJQueryAjax } from '../modules/utils'; import ExploreViewContainer from './components/ExploreViewContainer'; +import getInitialState from './reducers/getInitialState'; import rootReducer from './reducers/index'; -import getToastsFromPyFlashMessages from '../messageToasts/utils/getToastsFromPyFlashMessages'; import { appSetup } from '../common'; import './main.css'; @@ -24,51 +20,7 @@ initJQueryAjax(); const exploreViewContainer = document.getElementById('app'); const bootstrapData = JSON.parse(exploreViewContainer.getAttribute('data-bootstrap')); -const controls = getControlsState(bootstrapData, bootstrapData.form_data); -const rawFormData = { ...bootstrapData.form_data }; - -delete bootstrapData.form_data; -delete bootstrapData.common.locale; -delete bootstrapData.common.language_pack; - -// Initial state -const bootstrappedState = { - ...bootstrapData, - rawFormData, - controls, - filterColumnOpts: [], - isDatasourceMetaLoading: false, - isStarred: false, -}; -const slice = bootstrappedState.slice; -const sliceFormData = slice - ? getFormDataFromControls(getControlsState(bootstrapData, slice.form_data)) - : null; -const chartKey = getChartKey(bootstrappedState); -const initState = { - charts: { - [chartKey]: { - id: chartKey, - chartAlert: null, - chartStatus: 'loading', - chartUpdateEndTime: null, - chartUpdateStartTime: now(), - latestQueryFormData: getFormDataFromControls(controls), - sliceFormData, - queryRequest: null, - queryResponse: null, - triggerQuery: true, - lastRendered: 0, - }, - }, - saveModal: { - dashboards: [], - saveModalAlert: null, - }, - explore: bootstrappedState, - impressionId: shortid.generate(), - messageToasts: getToastsFromPyFlashMessages((bootstrapData.common || {}).flash_messages || []), -}; +const initState = getInitialState(bootstrapData); const store = createStore( rootReducer, diff --git a/superset/assets/src/explore/components/ExploreViewContainer.jsx b/superset/assets/src/explore/components/ExploreViewContainer.jsx index 2476615187047..34f165dc2b69f 100644 --- a/superset/assets/src/explore/components/ExploreViewContainer.jsx +++ b/superset/assets/src/explore/components/ExploreViewContainer.jsx @@ -15,6 +15,7 @@ import { chartPropShape } from '../../dashboard/util/propShapes'; import * as exploreActions from '../actions/exploreActions'; import * as saveModalActions from '../actions/saveModalActions'; import * as chartActions from '../../chart/chartAction'; +import { isFeatureEnabledCreator } from '../../featureFlags'; import { Logger, ActionLog, EXPLORE_EVENT_NAMES, LOG_ACTIONS_MOUNT_EXPLORER } from '../../logger'; const propTypes = { @@ -296,11 +297,13 @@ class ExploreViewContainer extends React.Component { ExploreViewContainer.propTypes = propTypes; -function mapStateToProps({ explore, charts, impressionId }) { +function mapStateToProps(state) { + const { explore, charts, impressionId } = state; const form_data = getFormDataFromControls(explore.controls); const chartKey = Object.keys(charts)[0]; const chart = charts[chartKey]; return { + isFeatureEnabled: isFeatureEnabledCreator(state), isDatasourceMetaLoading: explore.isDatasourceMetaLoading, datasource: explore.datasource, datasource_type: explore.datasource.type, diff --git a/superset/assets/src/explore/reducers/getInitialState.js b/superset/assets/src/explore/reducers/getInitialState.js new file mode 100644 index 0000000000000..28910bf410192 --- /dev/null +++ b/superset/assets/src/explore/reducers/getInitialState.js @@ -0,0 +1,53 @@ +import shortid from 'shortid'; + +import getToastsFromPyFlashMessages from '../../messageToasts/utils/getToastsFromPyFlashMessages'; +import { now } from '../../modules/dates'; +import { getChartKey } from '../exploreUtils'; +import { getControlsState, getFormDataFromControls } from '../store'; + +export default function (bootstrapData) { + const controls = getControlsState(bootstrapData, bootstrapData.form_data); + const rawFormData = { ...bootstrapData.form_data }; + const bootstrappedState = { + ...bootstrapData, + common: { + flash_messages: bootstrapData.common.flash_messages, + conf: bootstrapData.common.conf, + }, + rawFormData, + controls, + filterColumnOpts: [], + isDatasourceMetaLoading: false, + isStarred: false, + }; + const slice = bootstrappedState.slice; + const sliceFormData = slice + ? getFormDataFromControls(getControlsState(bootstrapData, slice.form_data)) + : null; + const chartKey = getChartKey(bootstrappedState); + return { + featureFlags: bootstrapData.common.feature_flags, + charts: { + [chartKey]: { + id: chartKey, + chartAlert: null, + chartStatus: 'loading', + chartUpdateEndTime: null, + chartUpdateStartTime: now(), + latestQueryFormData: getFormDataFromControls(controls), + sliceFormData, + queryRequest: null, + queryResponse: null, + triggerQuery: true, + lastRendered: 0, + }, + }, + saveModal: { + dashboards: [], + saveModalAlert: null, + }, + explore: bootstrappedState, + impressionId: shortid.generate(), + messageToasts: getToastsFromPyFlashMessages((bootstrapData.common || {}).flash_messages || []), + }; +} diff --git a/superset/assets/src/explore/reducers/index.js b/superset/assets/src/explore/reducers/index.js index 461eb0f86baaf..1226f17efaa7e 100644 --- a/superset/assets/src/explore/reducers/index.js +++ b/superset/assets/src/explore/reducers/index.js @@ -3,11 +3,13 @@ import { combineReducers } from 'redux'; import charts from '../../chart/chartReducer'; import saveModal from './saveModalReducer'; import explore from './exploreReducer'; +import featureFlags from '../../featureFlags'; import messageToasts from '../../messageToasts/reducers'; const impressionId = (state = '') => state; export default combineReducers({ + featureFlags, charts, saveModal, explore, diff --git a/superset/assets/src/featureFlags.js b/superset/assets/src/featureFlags.js new file mode 100644 index 0000000000000..1dc6635eb3134 --- /dev/null +++ b/superset/assets/src/featureFlags.js @@ -0,0 +1,11 @@ +// A higher-order function that takes the redux state tree and returns a +// `isFeatureEnabled` function which takes a feature and returns whether it is enabled. +// Note that we assume the featureFlags subtree is at the root of the redux state tree. +export function isFeatureEnabledCreator(state) { + return feature => !!state.featureFlags[feature]; +} + +// Feature flags are not altered throughout the life time of the app +export default function featureFlagsReducer(state = {}) { + return state; +} diff --git a/superset/config.py b/superset/config.py index 80f6f85e23a3d..d00bfd518403c 100644 --- a/superset/config.py +++ b/superset/config.py @@ -170,6 +170,14 @@ 'pt_BR': {'flag': 'br', 'name': 'Brazilian Portuguese'}, 'ru': {'flag': 'ru', 'name': 'Russian'}, } + +# --------------------------------------------------- +# Feature flags +# --------------------------------------------------- +# Feature flags that are on by default go here. Their +# values can be overridden by those in super_config.py +FEATURE_FLAGS = {} + # --------------------------------------------------- # Image and file configuration # --------------------------------------------------- diff --git a/superset/views/base.py b/superset/views/base.py index 2f56ae7da9739..42de2258547a2 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -108,6 +108,7 @@ def common_bootsrap_payload(self): 'conf': {k: conf.get(k) for k in FRONTEND_CONF_KEYS}, 'locale': locale, 'language_pack': get_language_pack(locale), + 'feature_flags': conf.get('FEATURE_FLAGS'), }