[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:
parent
414a4bfc6f
commit
604524b671
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
// });
|
||||
// });
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export default function getInitialState({ defaultDbId, ...restBootstrapData }) {
|
|||
};
|
||||
|
||||
return {
|
||||
featureFlags: restBootstrapData.common.feature_flags,
|
||||
sqlLab: {
|
||||
alerts: [],
|
||||
queries: {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 || []),
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
# ---------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue