AugurProject/augur-ui

View on GitHub
src/modules/create-market/components/create-market-form/create-market-form.jsx

Summary

Maintainability
F
3 days
Test Coverage
/* eslint react/no-array-index-key: 0 */ // It's OK in this specific instance as order remains the same

import React, { Component } from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
import { augur } from "services/augurjs";

import CreateMarketDefine from "modules/create-market/components/create-market-form-define/create-market-form-define";
import CreateMarketOutcome from "modules/create-market/components/create-market-form-outcome/create-market-form-outcome";
import CreateMarketResolution from "modules/create-market/components/create-market-form-resolution/create-market-form-resolution";
import CreateMarketLiquidity from "modules/create-market/components/create-market-form-liquidity/create-market-form-liquidity";
import CreateMarketLiquidityOrders from "modules/create-market/components/create-market-form-liquidity-orders/create-market-form-liquidity-orders";
import CreateMarketReview from "modules/create-market/components/create-market-form-review/create-market-form-review";
import Styles from "modules/create-market/components/create-market-form/create-market-form.styles";
import { ExclamationCircle as InputErrorIcon } from "modules/common/components/icons";
import { createBigNumber } from "utils/create-big-number";
import { CATEGORICAL, SCALAR } from "modules/markets/constants/market-types";
import { BID } from "modules/transactions/constants/types";
import moment from "moment";
import { formatDate } from "utils/format-date";

const NEW_ORDER_GAS_ESTIMATE = createBigNumber(700000);
const STEP_NAME = {
  0: "Market details",
  1: "Add initial market liquidity",
  2: "Review"
};
const TIME_FIELDNAMES = ["setEndTime", "hour", "minute", "meridiem"];

export default class CreateMarketForm extends Component {
  static processEndTime(newMarket) {
    if (!newMarket.setEndTime) {
      return newMarket.endTime;
    }

    const endTime = moment(newMarket.setEndTime.timestamp * 1000).utc();
    endTime.set({
      hour: newMarket.hour,
      minute: newMarket.minute
    });

    if (newMarket.delayDays !== "" && newMarket.delayDays !== undefined) {
      endTime.add(newMarket.delayDays, "day");
    }
    if (newMarket.delayHours !== "" && newMarket.delayHours !== undefined) {
      endTime.add(newMarket.delayHours, "hour");
    }

    if (
      (newMarket.meridiem === "" || newMarket.meridiem === "AM") &&
      endTime.hours() >= 12
    ) {
      endTime.hours(endTime.hours() - 12);
    } else if (
      newMarket.meridiem &&
      newMarket.meridiem === "PM" &&
      endTime.hours() < 12
    ) {
      endTime.hours(endTime.hours() + 12);
    }
    return formatDate(endTime.toDate());
  }

  static propTypes = {
    categories: PropTypes.array.isRequired,
    isMobileSmall: PropTypes.bool.isRequired,
    currentTimestamp: PropTypes.number.isRequired,
    gasPrice: PropTypes.number.isRequired,
    history: PropTypes.object.isRequired,
    newMarket: PropTypes.object.isRequired,
    universe: PropTypes.object.isRequired,
    addOrderToNewMarket: PropTypes.func.isRequired,
    estimateSubmitNewMarket: PropTypes.func.isRequired,
    removeOrderFromNewMarket: PropTypes.func.isRequired,
    submitNewMarket: PropTypes.func.isRequired,
    updateNewMarket: PropTypes.func.isRequired,
    availableEth: PropTypes.string,
    availableRep: PropTypes.string,
    meta: PropTypes.object.isRequired
  };

  static defaultProps = {
    availableEth: "0",
    availableRep: "0"
  };

  constructor(props) {
    super(props);

    this.state = {
      pages: ["Define", "Liquidity", "Review"],
      liquidityState: {},
      awaitingSignature: false,
      insufficientFunds: true
    };

    this.prevPage = this.prevPage.bind(this);
    this.nextPage = this.nextPage.bind(this);
    this.validateField = this.validateField.bind(this);
    this.validateNumber = this.validateNumber.bind(this);
    this.isValid = this.isValid.bind(this);
    this.keyPressed = this.keyPressed.bind(this);
    this.updateState = this.updateState.bind(this);
    this.updateStateValue = this.updateStateValue.bind(this);
    this.updateInitialLiquidityCosts = this.updateInitialLiquidityCosts.bind(
      this
    );
    this.handleCancelOrder = this.handleCancelOrder.bind(this);
  }

  componentWillReceiveProps(nextProps) {
    const { newMarket, updateNewMarket } = this.props;
    if (
      newMarket.currentStep !== nextProps.newMarket.currentStep &&
      nextProps.newMarket.currentStep !== 2
    ) {
      updateNewMarket({
        isValid: this.isValid(nextProps.newMarket.currentStep)
      });
    }
  }

  prevPage() {
    const { newMarket, updateNewMarket } = this.props;
    const newStep = newMarket.currentStep <= 0 ? 0 : newMarket.currentStep - 1;
    updateNewMarket({ currentStep: newStep });
  }

  nextPage() {
    const { newMarket, updateNewMarket } = this.props;
    if (newMarket.isValid) {
      const newStep =
        newMarket.currentStep >= this.state.pages.length - 1
          ? this.state.pages.length - 1
          : newMarket.currentStep + 1;
      updateNewMarket({ currentStep: newStep });
    }
  }

  keyPressed(event) {
    if (event.key === "Enter") {
      this.nextPage();
    }
  }

  updateState(newState) {
    this.setState(newState);
  }

  updateStateValue(property, value) {
    this.setState({ [property]: value });
  }

  validateField(fieldName, value, maxLength) {
    const { newMarket, updateNewMarket } = this.props;
    const { currentStep } = newMarket;

    const updatedMarket = { ...newMarket };

    switch (true) {
      case typeof value === "string" && !value.trim().length:
        updatedMarket.validations[currentStep][fieldName] =
          "This field is required.";
        break;
      case maxLength && value.length > maxLength:
        updatedMarket.validations[currentStep][
          fieldName
        ] = `Maximum length is ${maxLength}.`;
        break;
      default:
        updatedMarket.validations[currentStep][fieldName] = "";
    }

    updatedMarket[fieldName] = value;

    if (TIME_FIELDNAMES.indexOf(fieldName) !== -1) {
      updatedMarket.endTime = CreateMarketForm.processEndTime(updatedMarket);
      updatedMarket.validations[currentStep].endTime = "";
    }

    updatedMarket.isValid = this.isValid(currentStep);

    updateNewMarket(updatedMarket);
  }

  validateNumber(
    fieldName,
    rawValue,
    humanName,
    min,
    max,
    decimals = 0,
    leadingZero = false
  ) {
    const { newMarket, updateNewMarket } = this.props;
    const updatedMarket = { ...newMarket };
    const { currentStep } = newMarket;

    let value = rawValue;

    const regExp = new RegExp("^[0-9]+\\.[0]{0," + decimals + "}$");
    if (value !== "" && value !== ".0" && !regExp.test(value)) {
      value = parseFloat(value);
      value = parseFloat(value.toFixed(decimals));
    }

    switch (true) {
      case value === "":
        updatedMarket.validations[currentStep][
          fieldName
        ] = `The ${humanName} field is required.`;
        break;
      case value > max || value < min:
        updatedMarket.validations[currentStep][
          fieldName
        ] = `${humanName}`.charAt(0).toUpperCase();
        updatedMarket.validations[currentStep][
          fieldName
        ] += `${humanName} must be between ${min} and ${max}.`.slice(1);
        break;
      default:
        updatedMarket.validations[currentStep][fieldName] = "";
    }

    if (leadingZero && value < 10) {
      value = `0${value}`;
    }

    updatedMarket[fieldName] =
      typeof value === "number" ? value.toString() : value;

    if (TIME_FIELDNAMES.indexOf(fieldName) !== -1) {
      updatedMarket.validations[currentStep].endTime = "";
      updatedMarket.endTime = CreateMarketForm.processEndTime(updatedMarket);
    }

    updatedMarket.isValid = this.isValid(currentStep);

    updateNewMarket(updatedMarket);
  }

  isValid(currentStep) {
    const { newMarket } = this.props;
    const validations = newMarket.validations[currentStep];
    const validationsArray = Object.keys(validations);
    return validationsArray.every(key => validations[key] === "");
  }

  updateInitialLiquidityCosts(order, shouldReduce) {
    const { availableEth, newMarket, updateNewMarket } = this.props;
    const minPrice = newMarket.type === SCALAR ? newMarket.scalarSmallNum : 0;
    const maxPrice = newMarket.type === SCALAR ? newMarket.scalarBigNum : 1;
    const shareBalances = newMarket.outcomes.map(outcome => 0);
    let outcome;
    let initialLiquidityEth;
    let initialLiquidityGas;

    switch (newMarket.type) {
      case CATEGORICAL:
        newMarket.outcomes.forEach((outcomeName, index) => {
          if (order.outcome === outcomeName) outcome = index;
        });
        break;
      case SCALAR:
        ({ outcome } = order);
        break;
      default:
        outcome = 1;
        break;
    }

    const orderInfo = {
      orderType: order.type === BID ? 0 : 1,
      outcome,
      shares: order.quantity,
      price: order.price,
      tokenBalance: availableEth,
      minPrice,
      maxPrice,
      marketCreatorFeeRate: newMarket.settlementFee,
      reportingFeeRate: 0,
      shareBalances,
      singleOutcomeOrderBook: newMarket.orderBook[outcome] || {}
    };
    const action = augur.trading.simulateTrade(orderInfo);
    // NOTE: Fees are going to always be 0 because we are only opening orders, and there is no costs associated with opening orders other than the escrowed ETH and the gas to put the order up.
    if (shouldReduce) {
      initialLiquidityEth = newMarket.initialLiquidityEth.minus(
        action.tokensDepleted
      );
      initialLiquidityGas = newMarket.initialLiquidityGas.minus(
        NEW_ORDER_GAS_ESTIMATE
      );
    } else {
      initialLiquidityEth = newMarket.initialLiquidityEth.plus(
        action.tokensDepleted
      );
      initialLiquidityGas = newMarket.initialLiquidityGas.plus(
        NEW_ORDER_GAS_ESTIMATE
      );
    }

    updateNewMarket({ initialLiquidityEth, initialLiquidityGas });
  }

  handleCancelOrder(orderDetails) {
    const { newMarket, removeOrderFromNewMarket } = this.props;
    const order = newMarket.orderBook[orderDetails.outcome][orderDetails.index];
    this.updateInitialLiquidityCosts(
      { ...order, outcome: orderDetails.outcome },
      true
    );
    removeOrderFromNewMarket(orderDetails);
  }

  render() {
    const {
      addOrderToNewMarket,
      availableEth,
      availableRep,
      categories,
      currentTimestamp,
      history,
      isMobileSmall,
      meta,
      newMarket,
      submitNewMarket,
      universe,
      updateNewMarket,
      estimateSubmitNewMarket,
      gasPrice
    } = this.props;
    const s = this.state;

    // TODO -- refactor this to derive route based on url path rather than state value
    //  (react-router-dom declarative routing)

    return (
      <article className={Styles.CreateMarketForm}>
        <div className={Styles.CreateMarketForm_form_outer_wrapper}>
          <div className={Styles.CreateMarketForm_form_inner_wrapper}>
            <div>
              <span>Create new market</span>
              <span>
                Step {newMarket.currentStep + 1} of 3:{" "}
                {STEP_NAME[newMarket.currentStep]}
              </span>
            </div>
            {newMarket.currentStep === 0 && (
              <>
                <CreateMarketDefine
                  newMarket={newMarket}
                  updateNewMarket={updateNewMarket}
                  validateField={this.validateField}
                  categories={categories}
                  isValid={this.isValid}
                  keyPressed={this.keyPressed}
                  currentTimestamp={currentTimestamp}
                  validateNumber={this.validateNumber}
                  isMobileSmall={isMobileSmall}
                />
                <CreateMarketOutcome
                  newMarket={newMarket}
                  updateNewMarket={updateNewMarket}
                  validateField={this.validateField}
                  isValid={this.isValid}
                  isMobileSmall={isMobileSmall}
                  keyPressed={this.keyPressed}
                />
                <CreateMarketResolution
                  newMarket={newMarket}
                  updateNewMarket={updateNewMarket}
                  validateField={this.validateField}
                  isValid={this.isValid}
                  isMobileSmall={isMobileSmall}
                  keyPressed={this.keyPressed}
                />
              </>
            )}
            {newMarket.currentStep === 1 && (
              <CreateMarketLiquidity
                newMarket={newMarket}
                updateNewMarket={updateNewMarket}
                validateNumber={this.validateNumber}
                addOrderToNewMarket={addOrderToNewMarket}
                updateInitialLiquidityCosts={this.updateInitialLiquidityCosts}
                availableEth={availableEth}
                isMobileSmall={isMobileSmall}
                keyPressed={this.keyPressed}
                liquidityState={s.liquidityState}
                updateState={this.updateState}
              />
            )}
            {newMarket.currentStep === 2 && (
              <CreateMarketReview
                estimateSubmitNewMarket={estimateSubmitNewMarket}
                meta={meta}
                newMarket={newMarket}
                availableEth={availableEth}
                availableRep={availableRep}
                universe={universe}
                updateStateValue={this.updateStateValue}
                keyPressed={this.keyPressed}
                gasPrice={gasPrice}
              />
            )}
          </div>
          <div className={Styles["CreateMarketForm__button-outer-wrapper"]}>
            <div className={Styles["CreateMarketForm__button-inner-wrapper"]}>
              <div className={Styles.CreateMarketForm__navigation}>
                <button
                  className={classNames(Styles.CreateMarketForm__prev, {
                    [`${Styles["hide-button"]}`]: newMarket.currentStep === 0
                  })}
                  onClick={this.prevPage}
                >
                  Previous: {s.pages[newMarket.currentStep - 1]}
                </button>
                {newMarket.currentStep < 2 && (
                  <button
                    className={classNames(Styles.CreateMarketForm__next, {
                      [`${Styles["hide-button"]}`]:
                        newMarket.currentStep === s.pages.length - 1
                    })}
                    disabled={!newMarket.isValid}
                    onClick={this.nextPage}
                  >
                    Next: {s.pages[newMarket.currentStep + 1]}
                  </button>
                )}
                {newMarket.currentStep === 2 && (
                  <button
                    className={Styles.CreateMarketForm__submit}
                    disabled={s.insufficientFunds || s.awaitingSignature}
                    onClick={e => {
                      this.setState({ awaitingSignature: true }, () => {
                        submitNewMarket(newMarket, history, (err, market) => {
                          if (err) this.setState({ awaitingSignature: false });
                        });
                      });
                    }}
                  >
                    Create Market
                  </button>
                )}
              </div>
            </div>
          </div>
          {newMarket.currentStep === 1 && (
            <CreateMarketLiquidityOrders
              newMarket={newMarket}
              removeOrderFromNewMarket={this.handleCancelOrder}
              liquidityState={s.liquidityState}
            />
          )}
          {newMarket.currentStep === 2 &&
            s.awaitingSignature && (
              <div className={Styles["CreateMarketForm__submit-wrapper"]}>
                <div className={Styles.CreateMarketForm__submitWarning}>
                  {InputErrorIcon} Please sign transaction(s) to complete market
                  creation.
                </div>
              </div>
            )}
        </div>
      </article>
    );
  }
}