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'),
}