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
309 lines (275 loc) • 9.86 kB
JavaScript
/** @flow */
import React from 'react';
import Immutable from 'immutable';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import getKey from '../../utils/get-key';
import selectVariation from '../../utils/select-variation';
import { activate, deactivate, play, win } from '../../module';
import Variation, { isVariation } from '../../components/variation';
import { logger } from '../../utils/logger';
type Props = {
/**
* Redux State:
* ```js
* reduxAbTest = {
* experiments: [],
* active: {
* "experiment.name" => "variation.name",
* ...
* }
* ...
* }
* ```
*/
reduxAbTest: Immutable.Map,
/**
* Selector key for choosing an experiment from the redux store.
* This can be used as such:
* <Experiment selector=`Homepage Header Experiment`> ... </Experiment>
*/
selector: ?string,
/**
* ID of the experiment, this is used to pick a specific experient from the redux store.
* We use getKey to generate the `key` for this experiment.
*/
id: ?string,
/**
* Name of the experiment, this is used to pick a specific experient from the redux store.
* This should only be used as a fallback if `id` is unavailable
*/
name: string,
/**
* Name of the default variation.
* > When defined, this value is used to choose a variation if a stored value is not present.
* > This property may be useful for server side rendering but is otherwise not recommended.
*/
defaultVariationName: ?string,
/**
* Action Creator callback: Function({experiment:ExperimentType})
*/
dispatchActivate: Function,
/**
* Action Creator callback: Function({experiment:ExperimentType})
*/
dispatchDeactivate: Function,
/**
* Action Creator callback: Function({experiment:ExperimentType, variation:VariationType})
*/
dispatchPlay: Function,
/**
* Action Creator callback: Function({experiment:ExperimentType, variation:VariationType})
*/
dispatchWin: Function,
/**
* Variation Children to render
*/
children: any,
};
type State = {
/**
* The currenly active experiment
*/
experiment: ?Immutable.Map,
/**
* The currenly active variation
*/
variation: ?Immutable.Map,
/**
* The experient was played
*/
played: ?bool,
};
const isValid = (child) => isVariation(child);
export class Experiment extends React.Component {
props: Props;
state: State;
static defaultProps = {
reduxAbTest: Immutable.Map({}),
id: null,
name: null,
selector: null,
experiment: null,
variation: null,
defaultVariationName: null,
dispatchActivate: () => {},
dispatchDeactivate: () => {},
dispatchPlay: () => {},
dispatchWin: () => {},
};
state = {
played: false,
mounted: false,
};
/**
* Activate the variation
*/
componentWillMount() {
const { experiment, variation, dispatchActivate, dispatchPlay } = this.props;
const { mounted } = this.state;
let { played } = this.state;
logger(`${__filename} componentWillMount experiment, selector='${this.props.selector}', experiment.name='${experiment && experiment.get('name')}'`);
// If the experiment is unavailable, then record it wasn't played and move on
if (!experiment) {
played = false;
this.setState({ played, mounted });
return;
}
// These will trigger `componentWillReceiveProps`
dispatchActivate({experiment});
if (mounted) {
played = true;
dispatchPlay({experiment, variation});
}
this.setState({ played, mounted });
}
/**
* Update the component's state with the new properties
*/
componentWillReceiveProps(nextProps:Props) {
const { experiment, variation, dispatchActivate, dispatchPlay } = nextProps;
const { mounted } = this.state;
logger(`${__filename} componentWillReceiveProps experiment, selector='${this.props.selector}', experiment.name='${experiment && experiment.get('name')}'`);
if (!experiment) {
// If we no-longer have an experiment anymore, then update the internal state
if (this.props.experiment) {
this.setState({ played: false, mounted });
}
return;
}
if (!experiment.equals(this.props.experiment) || !variation.equals(this.props.variation)) {
// These will trigger `componentWillReceiveProps`
if (!this.state.played && mounted) {
dispatchActivate({experiment});
dispatchPlay({experiment, variation});
this.setState({ played: true, mounted });
}
}
}
/**
* Once the experiment is mounted, dispatch the play event
*/
componentDidMount() {
const { experiment, variation, dispatchPlay } = this.props;
const { played } = this.state;
logger(`${__filename} componentDidMount experiment, selector='${this.props.selector}', experiment.name='${experiment && experiment.get('name')}'`);
if (played || !experiment || !variation) {
return;
}
dispatchPlay({experiment, variation});
this.setState({ played: true, mounted: true });
}
/**
* Deactivate the variation from the state
*/
componentWillUnmount() {
const { experiment, dispatchDeactivate } = this.props;
logger(`${__filename} componentWillUnmount experiment, selector='${this.props.selector}', experiment.name='${experiment && experiment.get('name')}'`);
// Dispatch the deactivation event
if (experiment) {
dispatchDeactivate({experiment});
}
}
/**
* Render one of the variations or `null`
*/
render() {
const { children, defaultVariationName } = this.props;
const experiment = this.props.experiment || Immutable.Map({ name: null, id: null });
const variation = this.props.variation || Immutable.Map({ name: defaultVariationName, id: null });
const childrenArray = React.Children.toArray(children);
// If there are no children, render nothing
if (childrenArray.length === 0) {
logger(`${__filename} No children for experiment selector='${this.props.selector}', experiment.name='${experiment.get('name')}'`);
return null;
}
// If the first child is text or an unknown component, simply wrap it in a Variation
if (childrenArray.length === 1 && !isValid(childrenArray[0])) {
return (
<Variation id={variation.get('id')} name={variation.get('name')} experiment={experiment} variation={variation}>
{children}
</Variation>
);
}
const selectedChild = [
childrenArray.find( child => (variation.get('id') && child.props.id === variation.get('id')) ),
childrenArray.find( child => (variation.get('name') && child.props.name === variation.get('name')) ),
].filter( value => value ).find( value => value );
if (!selectedChild) {
throw new Error(`Expected to find a Variation child matching id=${variation.get('id')} or name=${variation.get('name')}`);
}
logger(`${__filename} Rendered Experiment selector='${this.props.selector}', experiment.name='${experiment.get('name')}'`);
// Inject the helper `handleWin` into the child element
return React.cloneElement(selectedChild, {
experiment,
variation,
id: variation.get('id'),
name: variation.get('name'),
});
}
}
const getExperiment = (reduxAbTest, selector, id, name) => {
// Select the experiment from the redux store
const groupByName = {};
const groupById = {};
reduxAbTest.get('experiments').forEach(experiment => {
logger(`${__filename} getExperiment forEach experiment.id='${experiment.get('id')}'`);
logger(`${__filename} getExperiment forEach experiment.name='${experiment.get('name')}'`);
if (experiment.get('name')) {
groupByName[experiment.get('name')] = experiment;
}
if (experiment.get('id')) {
groupById[experiment.get('id')] = experiment;
}
});
const experimentById = groupById[id];
const experimentByName = groupByName[name];
const experimentBySelector = groupByName[selector] || groupById[selector];
const experiment = experimentById || experimentByName || experimentBySelector;
logger(`${__filename} getExperiment selector='${selector}'`);
logger(`${__filename} getExperiment id='${id}'`);
logger(`${__filename} getExperiment name='${name}'`);
logger(`${__filename} getExperiment experiment.name='${experiment && experiment.get('name')}'`);
// Return the resulting experiment
return experiment;
};
// Map the Redux Store to the Experiment's props
export const mapStateToProps = ({ reduxAbTest }) => ({ reduxAbTest });
// Map the action creators to the the Experiment's props.
export const mapDispatchToProps = (dispatch:Function) => bindActionCreators(
{
dispatchActivate: activate,
dispatchDeactivate: deactivate,
dispatchPlay: play,
dispatchWin: win,
},
dispatch
);
// Export the new React Container.
export default connect(mapStateToProps, mapDispatchToProps)((props) => {
const experiment = getExperiment(props.reduxAbTest, props.selector, props.id, props.name);
let variation = null;
if (experiment) {
variation = selectVariation({
experiment,
active: props.reduxAbTest.get('active'),
defaultVariationName: props.defaultVariationName,
});
}
return (
<Experiment
name={props.name}
id={props.id}
selector={props.selector}
experiment={experiment}
variation={variation}
defaultVariationName={props.defaultVariationName}
dispatchActivate={props.dispatchActivate}
dispatchDeactivate={props.dispatchDeactivate}
dispatchPlay={props.dispatchPlay}
dispatchWin={props.dispatchWin}
>
{props.children}
</Experiment>
);
});