e1-bsd/omni-common-ui

View on GitHub
src/containers/SaveBarHandler/index.jsx

Summary

Maintainability
A
3 hrs
Test Coverage
import styles from './style.postcss';

import React, { PureComponent } from 'react';
import { push } from 'react-router-redux';
import { Map } from 'immutable';
import is from 'is_js';
import log from 'domain/log';
import ReactGA from 'react-ga';
import classnames from 'classnames';
import PropTypes from 'prop-types';

import connect from 'domain/connect';
import ApiCall from 'containers/ApiCalls';
import Config from 'domain/Config';
import testClass from 'domain/testClass';
import SaveBarButton from './SaveBarButton';

const HTTP_METHOD_TRIGGERS = ['POST', 'PUT', 'DELETE'];
const EDIT_MODE_MATCH_REGEXP = new RegExp('^edit$', 'i');

// config feature flag
const IS_ACTIVE = !! Config.get('showSavingBarForApiPostsPuts');

// per-route action button settings may be supplied as functions
const fnToValue = (any, props) => {
  if (is.function(any)) {
    return any(props);
  }
  return any;
};

class SaveBarHandler extends PureComponent {
  constructor() {
    super();
    this.state = { isVisible: false };
  }

  componentWillReceiveProps(nextProps) {
    const {
      params: { mode: nextMode },
      isAnyApiCallSaving: nextIsAnyApiCallSaving,
    } = nextProps;
    // CONFIG flag check
    if (! IS_ACTIVE) return;
    const buttonsToDisplay = this._getButtonsToDisplay(nextProps);
    let newIsVisible = false;
    nextIsAnyApiCallSaving && (newIsVisible = true);
    // we don't want to show the saving bar when editing but there are no buttons
    EDIT_MODE_MATCH_REGEXP.test(nextMode) && buttonsToDisplay.size && (newIsVisible = true);
    if (newIsVisible !== this.state.isVisible) {
      this.setState({ isVisible: newIsVisible });
    }
  }

  _getButtonsToDisplay(props = this.props) {
    const currentRoute = SaveBarHandler.getCurrentRouteSegment(props);
    const currentModeName = props.params &&
        props.params.mode &&
        props.params.mode.length ?
            props.params.mode :
            'default';

    // exit out if not in edit mode; we're not visible.
    if (! EDIT_MODE_MATCH_REGEXP.test(props.params.mode)) return new Map();

    const actionButtonsMap = {};
    let actionButtonsForMode;
    if (currentRoute.actionButtons && currentRoute.actionButtons[currentModeName]) {
      actionButtonsForMode = currentRoute.actionButtons[currentModeName];
      Object.assign(actionButtonsMap, actionButtonsForMode);
    }

    const buttonsToDisplay = Object.keys(actionButtonsMap).filter((buttonLabel) => {
      const buttonDescriptor = actionButtonsMap[buttonLabel];
      try {
        if (is.function(buttonDescriptor.visibleWhen)) {
          if (buttonDescriptor.visibleWhen.length < 2) {
            return buttonDescriptor.visibleWhen(props);
          }
          log.error('Too many arguments in signature of `visibleWhen`. Got',
              buttonDescriptor.visibleWhen.length);
        }
      } catch (err) {
        log.warn(`Error in \`visibleWhen\` in route \`${currentRoute.path}\``, err);
      }

      return true;
    });

    return new Map(actionButtonsMap)
        .filter((v, key) => buttonsToDisplay.indexOf(key) >= 0);
  }

  _getIsDisabled(buttonDescriptor) {
    const props = this.props;
    try {
      if (is.function(buttonDescriptor.disableWhen)) {
        if (buttonDescriptor.disableWhen.length < 2) {
          return buttonDescriptor.disableWhen(props);
        // handle the older form of this method where there are two args - state and props
        }
        log.error('Too many arguments in signature of `disableWhen`. Got',
            buttonDescriptor.disableWhen.length);
      }
    } catch (err) {
      const currentRoute = SaveBarHandler.getCurrentRouteSegment(this.props);
      log.warn(`Error in \`disableWhen\` in route \`${currentRoute.path}\``, err);
    }
    return false;
  }

  _getOnClickHandler(buttonDescriptor, buttonLabel) {
    if (buttonDescriptor.onClick) {
      if (buttonDescriptor.onClick.length > 1) {
        log.warn('Deprecated form of `onClick` handler found. It should now have just one argument for props.',
            buttonDescriptor);
      }
      return () => {
        registerClick();
        return buttonDescriptor.onClick.call(this, this.props);
      };
    }
    return (buttonDescriptor.route &&
      (() => {
        registerClick();
        return this._redirect(fnToValue(buttonDescriptor.route, this.props));
      }));
    function registerClick() {
      ReactGA.event({
        category: 'Navigation',
        action: 'Clicked action button',
        label: `Clicked ${buttonLabel} in the bottom save bar`,
      });
    }
  }

  _redirect(param) {
    const { pushRoute, buildRoute } = this.props;
    pushRoute(buildRoute(param));
  }

  render() {
    const { children, isAnyApiCallSaving } = this.props;  // eslint-disable-line
    const { isVisible } = this.state;
    const barClasses = {
      [styles.SaveBar]: true,
      [styles.__visible]: !! isVisible,
    };
    const overlayClasses = {
      [styles.SaveBar_overlay]: true,
      [styles.__visible]: !! isAnyApiCallSaving,
    };
    return <div className={styles.SaveBarHandler}>
      <aside className={classnames(barClasses)}>
        <div className={styles.SaveBar_inner}>
          <span className={classnames(styles.SaveBar_text, {
            [styles.__visible]: !! isAnyApiCallSaving,
            [testClass('api-saving')]: !! isAnyApiCallSaving,
          })}>
            Saving…
          </span>
          <div className={styles.SaveBar_inner_buttons}>
            {
              this._getButtonsToDisplay()
                // eslint-disable-next-line react/no-array-index-key
                .map((buttonDescriptor, buttonLabel) => <SaveBarButton key={buttonLabel}
                    label={buttonLabel}
                    isPrimary={buttonDescriptor.isPrimary}
                    disabled={this._getIsDisabled(buttonDescriptor)}
                    onClick={this._getOnClickHandler(buttonDescriptor, buttonLabel)}
                    linkHref={fnToValue(buttonDescriptor.linkHref, this.props)} />)
                .toArray()
            }
          </div>
        </div>
      </aside>
      <div className={classnames(overlayClasses)} />
      {children}
    </div>;
  }
}

SaveBarHandler.getCurrentRouteSegment = ({ routes }) =>
  (routes ? routes[routes.length - 1] : {});

SaveBarHandler.propTypes = {
  isAnyApiCallSaving: PropTypes.bool.isRequired,
  params: PropTypes.shape({
    mode: PropTypes.string,
  }).isRequired,
  routes: PropTypes.array.isRequired,
  pushRoute: PropTypes.func.isRequired,
  buildRoute: PropTypes.func.isRequired,
  children: PropTypes.node.isRequired,
};

function mapStateToProps(state) {
  return {
    isAnyApiCallSaving: !! isAnyApiCallSaving(state),
  };
}

function mapDispatchToProps(dispatch) {
  return Object.assign(
    { pushRoute: (path) => dispatch(push(path)) },
  );
}

function isAnyApiCallSaving(state) {
  return !! state.get('apiCalls')
    .filter((call, key) =>
      HTTP_METHOD_TRIGGERS.some((method) => key.startsWith(method)))
    .find((call) => ApiCall.State.isLoading(call));
}

export default connect(mapStateToProps, mapDispatchToProps)(SaveBarHandler);