redux-ab-test
Version:
A/B testing React components with Redux and debug tools. Isomorphic with a simple, universal interface. Well documented and lightweight. Tested in popular browsers and Node.js. Includes helpers for React, Redux, and Segment.io
292 lines (253 loc) • 12.3 kB
JavaScript
/** @flow */
import Immutable from 'immutable';
import _flattenDeep from 'lodash/flattenDeep';
import _compact from 'lodash/compact';
import { createAction, handleActions } from 'redux-actions';
import { cacheStore } from '../utils/create-cache-store';
import availableExperiments from '../utils/available-experiments';
import getKey from '../utils/get-key';
import generateWinActions from '../utils/generate-win-actions';
import { immutableExperiment, immutableExperimentVariation } from '../utils/wraps-immutable';
//
// Redux Action Types
//
export const RESET = 'redux-ab-test/RESET';
export const LOAD = 'redux-ab-test/LOAD';
export const SET_AUDIENCE = 'redux-ab-test/SET_AUDIENCE';
export const SET_LOCATION = 'redux-ab-test/SET_LOCATION';
export const SET_ACTIVE = 'redux-ab-test/SET_ACTIVE';
export const ACTIVATE = 'redux-ab-test/ACTIVATE';
export const DEACTIVATE = 'redux-ab-test/DEACTIVATE';
export const PLAY = 'redux-ab-test/PLAY';
export const WIN = 'redux-ab-test/WIN';
export const FULFILLED = 'redux-ab-test/FULFILLED';
//
// Redux Action Creators:
//
export const reset = createAction(RESET, () => Immutable.fromJS({}));
export const load = createAction(LOAD, (opts = {}) => Immutable.fromJS(opts) );
export const setAudience = createAction(SET_AUDIENCE, (audience = {}) => Immutable.fromJS({audience}));
export const setActive = createAction(SET_ACTIVE, (active = {}) => Immutable.fromJS({active}));
export const setLocation = createAction(SET_LOCATION, (locationBeforeTransitions = {}) => Immutable.fromJS({locationBeforeTransitions}));
export const activate = createAction(ACTIVATE, immutableExperiment );
export const deactivate = createAction(DEACTIVATE, immutableExperiment );
export const play = createAction(PLAY, immutableExperimentVariation );
export const win = createAction(WIN, ({experiment, variation, actionType, actionPayload}) => Immutable.fromJS({experiment, variation, actionType, actionPayload}) );
export const fulfilled = createAction(FULFILLED, ({experiment, variation, actionType, actionPayload}) => Immutable.fromJS({experiment, variation, actionType, actionPayload}) );
export const initialState = Immutable.fromJS({
experiments: [ /** Array of "experiment objects" */ ],
fulfilled: [ /** Array of "experiment keys" */ ],
availableExperiments: { /** Hash of "experiment key" => "experiment objects" */ },
running: { /** "experiment id" => counter */ },
active: { /** "experiment id" => "variation id" */ },
winners: { /** "experiment id" => "variation id" */ },
audience: { /** Any props you want to use for user/session targeting */ },
route: { pathName: null, search: null, query: {} },
key_path: ['key'],
fulfilled_path: ['fulfilled_action_types'],
types_path: ['win_action_types'],
props_path: ['componentProps'],
audience_path: ['audienceProps'],
persistent_path: ['persistentExperience'],
single_success_path: ['singleSuccess'], // Should the experiment be removed from availableExperiments once it has been won?
route_path: ['routeProps'],
win_action_types: { /** Array of Redux Action Types */ },
});
export const generateFulfilledActions = (winActions, state) => {
return winActions.map(winAction => {
const { payload } = winAction;
// Get the action types from the experiment
const experiment = payload.get('experiment');
const variation = payload.get('variation');
const actionType = payload.get('actionType');
const actionPayload = payload.get('actionPayload');
const types = flattenCompact(experiment.getIn(state.get('fulfilled_path')));
if (!types.isEmpty() && actionType && types.includes(actionType)) {
return fulfilled({ experiment, variation, actionType, actionPayload });
}
return null;
}).filter( action => action );
};
export const middleware = (store:Object) => (next:Function) => (action:Object) => {
// Process the input action
const output = next(action);
// Multi-plex the action output if we are listening for any wins
const reduxAbTest = store.getState().reduxAbTest;
if (reduxAbTest) {
const winActions = generateWinActions({
reduxAbTest,
win,
actionType: action.type,
actionPayload: action.payload,
});
const fulfilledActions = generateFulfilledActions(winActions, reduxAbTest);
winActions.forEach( action => next(action) );
fulfilledActions.forEach( action => next(action) );
}
switch(action.type) {
/**
* Intercept the react-router events, this is for v4.x.x react-router integration
*/
case '@@router/LOCATION_CHANGE': {
const { pathname, search, query } = action.payload || {};
const locationBeforeTransitions = { pathname, search, query };
next(setLocation(locationBeforeTransitions));
break;
}
/**
* Intercept the redux-router events, this is specifically for react-redux-router integration
*/
case '@@reduxReactRouter/routerDidChange': {
const { location = {} } = action.payload || {};
const { pathname, search, query = {} } = location;
const locationBeforeTransitions = { pathname, search, query };
next(setLocation(locationBeforeTransitions));
break;
}
}
// Return the original action's output
return output;
};
export const flattenCompact = (list) => Immutable.List( _compact(_flattenDeep(Immutable.fromJS([list]).toJS())) );
const computeAvailableExperiments = (state) => state.set('availableExperiments', availableExperiments({
experiments: state.get('experiments'),
fulfilled: state.get('fulfilled'),
key_path: state.get('key_path'),
active: state.get('active'),
winners: state.get('winners'),
persistent_path: state.get('persistent_path'),
audience_path: state.get('audience_path'),
audience: state.get('audience'),
route: state.get('route'),
route_path: state.get('route_path'),
single_success_path: state.get('single_success_path'),
}));
const reducers = {
/**
* RESET the experiments state.
*/
[RESET]: () => {
cacheStore.clear();
return initialState;
},
/**
* LOAD the available experiments. and reset the state of the server
*/
[LOAD]: (state, { payload }) => {
const key_path = flattenCompact(payload.get('key_path', state.get('key_path')));
const fulfilled_path = flattenCompact(payload.get('fulfilled_path', state.get('fulfilled_path')));
const types_path = flattenCompact(payload.get('types_path', state.get('types_path')));
const props_path = flattenCompact(payload.get('props_path', state.get('props_path')));
const audience_path = flattenCompact(payload.get('audience_path', state.get('audience_path')));
const persistent_path = flattenCompact(payload.get('persistent_path', state.get('persistent_path')));
const single_success_path = flattenCompact(payload.get('single_success_path', state.get('single_success_path')));
const route_path = flattenCompact(payload.get('route_path', state.get('route_path')));
const experiments = payload.has('experiments') ? payload.get('experiments') : state.get('experiments');
const active = payload.has('active') ? payload.get('active') : state.get('active');
const winners = payload.has('winners') ? payload.get('winners') : state.get('winners');
const audience = payload.has('audience') ? payload.get('audience') : state.get('audience');
const route = payload.has('route') ? payload.get('route') : state.get('route');
const fulfilled = payload.has('fulfilled') ? payload.get('fulfilled') : state.get('fulfilled');
const win_action_types = experiments.reduce(
(map, experiment) => {
const key = getKey(experiment);
const types = flattenCompact([experiment.getIn(types_path), experiment.getIn(fulfilled_path)]);
types.forEach(type => {
const list = map[type] || [];
list.push(key);
map[type] = list;
});
return map;
},
{}
);
return computeAvailableExperiments(initialState.merge({
experiments,
fulfilled,
fulfilled_path,
audience,
route,
active,
winners,
key_path,
types_path,
props_path,
persistent_path,
audience_path,
route_path,
win_action_types,
single_success_path,
}));
},
/**
* Set the Audience for the experiments
*/
[SET_AUDIENCE]: (state, { payload }) => {
const audience = payload.get('audience');
return computeAvailableExperiments(state.set('audience', audience));
},
/**
* Set the Audience for the experiments
*/
[SET_LOCATION]: (state, { payload }) => {
const pathName = payload.getIn(['locationBeforeTransitions', 'pathname']);
const search = payload.getIn(['locationBeforeTransitions', 'search']);
const query = payload.getIn(['locationBeforeTransitions', 'query'], Immutable.Map());
const route = Immutable.Map({ pathName, search, query });
return computeAvailableExperiments(state.set('route', route));
},
/**
* Set the Active variations
*/
[SET_ACTIVE]: (state, { payload }) => {
return computeAvailableExperiments(state.set('active', payload.get('active')));
},
/**
* ACTIVATE the experiment
*/
[ACTIVATE]: (state, { payload }) => {
const experimentKey = getKey(payload.get('experiment'));
const counter = (state.get('running').get(experimentKey) || 0) + 1;
const running = state.get('running').set(experimentKey, counter);
return computeAvailableExperiments(state.set('running', running));
},
/**
* DEACTIVATE the experiment
*/
[DEACTIVATE]: (state, { payload }) => {
const experimentKey = getKey(payload.get('experiment'));
const counter = (state.get('running').get(experimentKey) || 0) - 1;
const running = (counter <= 0) ? state.get('running').delete(experimentKey) : state.get('running').set(experimentKey, counter);
return computeAvailableExperiments(state.set('running', running));
},
/**
* A user saw an experiment
* @payload { experiment:ExperimentType, variation:VariationType }
*/
[PLAY]: (state, { payload }) => {
const experimentKey = getKey(payload.get('experiment'));
const variationKey = getKey(payload.get('variation'));
const active = state.get('active').set(experimentKey, variationKey);
cacheStore.removeItem(experimentKey);
return computeAvailableExperiments(state.set('active', active));
},
/**
* A user interacted with the variation
* @payload { experiment:ExperimentType, variation:VariationType }
*/
[WIN]: (state, { payload }) => {
const experimentKey = getKey(payload.get('experiment'));
const variationKey = getKey(payload.get('variation'));
const winners = state.get('winners').set(experimentKey, variationKey);
let fulfilled = state.get('fulfilled');
// Get the action types from the experiment
const actionType = payload.get('actionType');
const experiment = payload.get('experiment');
const types = flattenCompact(experiment.getIn(state.get('fulfilled_path')));
if (!types.isEmpty() && actionType && types.includes(actionType)) {
fulfilled = Immutable.List([...Immutable.fromJS(fulfilled).toJS(), experimentKey]);
}
return computeAvailableExperiments(state.set('winners', winners).set('fulfilled', fulfilled));
},
};
export default handleActions(reducers, initialState);