Why Gemfury? Push, build, and install  RubyGems npm packages Python packages Maven artifacts PHP packages Go Modules Debian packages RPM packages NuGet packages

Repository URL to install this package:

Details    
@doodle/ab-connector / src / hoc / withAbTest.js
Size: Mime:
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';

import { optimizeActivate, mountVariant, unmountVariant } from '../state/actions/abActions';

const AB_TEST_TIMEOUT = 750; // Timeout until first render, if no experimentId is passed
const AB_TEST_INTERVAL = 50; // Interval for checking for google optimize
const AB_TEST_INTERVAL_COUNT = 15; // How often we should check max for google optimize

/**
 * Returns whether google_optimize is defined in the window object.
 *
 * @returns {boolean}
 */
function isGoogleOptimizeDefined() {
  return window.google_optimize !== undefined;
}

/**
 * Returns the variation for the given experimentId if google_optimize is
 * defined in the window object. The variation might be a string from 0 - n, where
 * n is the max number of defined variations.
 *
 * @param {string} experimentId - The Optimize experiment id
 */
function getGoogleOptimizeVariant(experimentId) {
  return isGoogleOptimizeDefined() && window.google_optimize.get(experimentId);
}

/**
 * If the variant returned by google_optimize is 0 or undefined, this returns true.
 *
 * The undefined case only appears if google_optimize is defined but the a/b test is
 * not running for this url.
 *
 * @param {string} experimentId
 */
function shouldRenderOriginal(experimentId) {
  const variant = getGoogleOptimizeVariant(experimentId);
  return Boolean(experimentId) && variant === '0';
}

/**
 *
 * @param {React.Component} Component
 * @param {String} experimentName
 * @param {Object} variantComponents
 */
export default function withAbTest(
  Component,
  experimentName,
  variantComponents,
  { debug = false, isLocal = false, experimentId = '' } = {}
) {
  // Debug info
  if (debug) {
    console.group('A/B - Connector [HOC]');
    console.log('Component:', Component);
    console.log('experimentName:', experimentName);
    console.log('variantComponents:', variantComponents);
    console.log('debug:', debug);
    console.log('isLocal:', isLocal);
    console.groupEnd();
  }
  class ABComponent extends React.Component {
    static propTypes = {
      experiments: PropTypes.object.isRequired,
      onActivateOptimize: PropTypes.func.isRequired,
      onActivateVariant: PropTypes.func.isRequired,
      onDeactivateVariant: PropTypes.func.isRequired,
      optimizePending: PropTypes.bool,
      optimizeLoaded: PropTypes.bool,
    };

    static defaultProps = {
      optimizePending: undefined,
      optimizeLoaded: undefined,
    };

    constructor(props) {
      super(props);
      const { optimizePending } = props;
      const variant = this.getExperimentVariant(experimentName);
      const pendingDefined = optimizePending !== undefined;
      const renderOriginalComponent = (isLocal || !pendingDefined || shouldRenderOriginal(experimentId)) && Component;
      const renderVariationComponent = variant && variantComponents[variant];
      this.state = {
        pendingDefined,
        // If Optimize is not even pending, it is not loaded due to the order of scripts loaded.
        // In this case we do not have to wait and just render the original component
        renderedComponent: renderVariationComponent || renderOriginalComponent,
      };
      this.getExperimentVariant = this.getExperimentVariant.bind(this);
      this.setRenderedVariant = this.setRenderedVariant.bind(this);
      this.resetTimer = this.resetTimer.bind(this);
      this.initializeTimer = this.initializeTimer.bind(this);

      // Debug info
      if (debug) {
        console.group('A/B - Connector [Constructor]');
        console.log('variant:', variant);
        console.log('pedingDefined:', pendingDefined);
        console.log('renderOriginalComponent:', Boolean(renderOriginalComponent));
        console.log('renderVariationComponent:', Boolean(renderVariationComponent));
        console.log('Optimize experimentId', experimentId);
        console.log('Optimize shouldRenderOriginal', shouldRenderOriginal(experimentId));
        console.groupEnd();
      }
    }

    componentDidMount() {
      const { onActivateOptimize, onActivateVariant } = this.props;
      const variant = this.getExperimentVariant(experimentName);
      // We only send this info the DataLayer if optimize actually was loaded
      if (this.state.pendingDefined) {
        onActivateOptimize();
      }
      if (!variant && !this.state.renderedComponent) {
        this.initializeTimer();
      } else if (!this.state.renderedComponent) {
        this.setRenderedVariant(variantComponents[variant]);
      }

      // Dispatch info about loaded experiment
      if (variant) {
        onActivateVariant(variant);
      }

      // Debug info
      if (debug) {
        console.group('A/B - Connector [componentDidMount]');
        console.log('variant:', variant);
        console.log('this.state.pendingDefined:', this.state.pendingDefined);
        console.log('this.state.renderedComponent:', this.state.renderedComponent);
        console.groupEnd();
      }
    }

    componentDidUpdate(_, prevState) {
      const { renderedComponent } = this.state;
      const { onActivateVariant } = this.props;
      const variant = this.getExperimentVariant(experimentName);
      const shouldChange =
        (isLocal || !renderedComponent) && variant && variantComponents[variant] !== prevState.renderedComponent;
      // We should allow multiple changes if it's local development
      if (shouldChange) {
        // We need add this to the active variants
        onActivateVariant(variant);
        this.setRenderedVariant(variantComponents[variant] || Component);
      }
      // Debug info
      if (debug) {
        console.group('A/B - Connector [componentDidUpdate]');
        console.log('experiments:', this.props.experiments);
        console.log('variant:', variant);
        console.log('isLocal:', isLocal);
        console.log('shouldChange:', shouldChange);
        console.log('this.state.renderedComponent:', this.state.renderedComponent);
        console.groupEnd();
      }
    }

    componentWillUnmount() {
      const { onDeactivateVariant } = this.props;
      const variant = this.getExperimentVariant(experimentName);

      // Unregister so setState is not called
      this.resetTimer();

      // Deactivate experiment in store
      if (variant) {
        onDeactivateVariant();
      }
    }

    /**
     * Safely accesses the experiments to to get the activated variant.
     *
     * @param {string} name - Name of the experiment
     */
    getExperimentVariant(name) {
      const { experiments } = this.props;
      return experiments[name] && experiments[name].variant;
    }

    setRenderedVariant(renderedComponent) {
      // Reset timer, since we know which component to render
      // and no longer need the actual timeout
      this.resetTimer();
      this.setState({
        renderedComponent,
      });
      // Debug info
      if (debug) {
        console.group('A/B - Connector [setRenderedVariant]');
        console.log('renderedComponent:', renderedComponent);
        console.groupEnd();
      }
    }

    initializeTimer() {
      if (experimentId) {
        let counter = 0;
        this.timer = setInterval(() => {
          // Either Optimize is defined but nothing loaded for this test,
          // or we received variation 0 (original) as experiment
          if (counter === AB_TEST_INTERVAL_COUNT || shouldRenderOriginal(experimentId)) {
            this.setRenderedVariant(Component);
          }
          counter += 1;
        }, AB_TEST_INTERVAL);
      } else {
        this.timer = setTimeout(() => {
          // Since we receive no variant from Optimize, lets
          // render the default Wrapped component
          this.setRenderedVariant(Component);
        }, AB_TEST_TIMEOUT);
      }
    }

    resetTimer() {
      if (experimentId) {
        clearInterval(this.timer);
      } else {
        clearTimeout(this.timer);
      }
      this.timer = null;
      // Debug info
      if (debug) {
        console.log('A/B - Connector [resetTimer]');
      }
    }

    render() {
      const RenderedComponent = this.state.renderedComponent;
      // If we still do not know which component should be rendered yet,
      // we render an empty component, after max AB_TEST_TIMEOUT we render
      // something, which by default shiuld be the original component
      if (RenderedComponent) {
        // Destructure ABComponent specific props, which should not be passed
        const {
          experiments,
          optimizePending,
          optimizeLoaded,
          onActivateOptimize,
          onActivateVariant,
          ...rest
        } = this.props;
        const abTestData = experiments[experimentName];
        return <RenderedComponent {...rest} abTestData={abTestData} />;
      }
      return null;
    }
  }

  const mapDispatchToProps = dispatch => ({
    onActivateOptimize: () => dispatch(optimizeActivate()),
    onActivateVariant: mountVariant(dispatch, experimentName),
    onDeactivateVariant: () => unmountVariant(dispatch, experimentName),
  });

  return connect(
    state => ({
      experiments: state.ab.experiments,
      optimizePending: state.ab.optimize.pending,
      optimizeLoaded: state.ab.optimize.loaded,
    }),
    mapDispatchToProps
  )(ABComponent);
}