[feat] Feature flag system via config (#5960)

* [feat] Feature flag system via config

Adding a feature flag system that is driven by superset_config.py. This change includes:
- Server side changes to specify a dedicated FEATURE_FLAG dictionary for listing feature flags. E.g.
```
FEATURE_FLAGS = { 'SCOPED_FILTER': true }
```
- Pass the new feature flags to client via bootstrap-data
- Client side changes to inject feature flags into the redux state tree for dashboard, explore view and SqlLab
- Client side refactor/clean up so the feature flags can be properly tested. Also avoid modifying incoming bootstrap data when creating initial state for the redux state tree
- Re-enable tests that were previously disabled for ExploreViewContainer

* Fix lint errors.

* Remove the partial attempt to get reference to src working in tests (so we don't have to write ../../../src and such in tests). This will in a separate PR.
This commit is contained in:
Christine Chambers 2018-10-01 11:47:06 -07:00 committed by Beto Dealmeida
parent 414a4bfc6f
commit 604524b671
17 changed files with 246 additions and 113 deletions

View File

@ -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(<Dashboard />, { context: { store } });
});
it('should set feature flags', () => {
expect(wrapper.prop('isFeatureEnabled')('FOO_BAR')).to.equal(true);
});
});

View File

@ -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(<ExploreViewContainer />)
// ).to.equal(true);
// });
// it('renders QueryAndSaveButtons', () => {
// const wrapper = shallow(<ExploreViewContainer />);
// expect(wrapper.find(QueryAndSaveBtns)).to.have.length(1);
// });
// it('renders ControlPanelsContainer', () => {
// const wrapper = shallow(<ExploreViewContainer />);
// expect(wrapper.find(ControlPanelsContainer)).to.have.length(1);
// });
// it('renders ChartContainer', () => {
// const wrapper = shallow(<ExploreViewContainer />);
// expect(wrapper.find(ChartContainer)).to.have.length(1);
// });
// });

View File

@ -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(<ExploreViewContainer />, {
context: { store },
disableLifecycleMethods: true,
});
});
it('should set feature flags', () => {
expect(wrapper.prop('isFeatureEnabled')('FOO_BAR')).to.equal(true);
});
it('renders', () => {
expect(
React.isValidElement(<ExploreViewContainer />),
).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);
});
});

View File

@ -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(<App />, { context: { store } }).dive();
wrapper = shallow(<App />, { 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);
});
});

View File

@ -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);

View File

@ -14,6 +14,7 @@ export default function getInitialState({ defaultDbId, ...restBootstrapData }) {
};
return {
featureFlags: restBootstrapData.common.feature_flags,
sqlLab: {
alerts: [],
queries: {},

View File

@ -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,
});

View File

@ -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,

View File

@ -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),

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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 || []),
};
}

View File

@ -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,

View File

@ -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;
}

View File

@ -170,6 +170,14 @@ LANGUAGES = {
'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
# ---------------------------------------------------

View File

@ -108,6 +108,7 @@ class BaseSupersetView(BaseView):
'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'),
}