edtoken/redux-tide

View on GitHub
src/action.js

Summary

Maintainability
D
1 day
Test Coverage
/**
 * @namespace action
 */

import {
  ACTION_EMPTY_TYPE_NAME,
  ACTION_CLEAN_TYPE_NAME,
  ACTION_ID_KEY,
  ACTION_IDS_KEY,
  ACTION_TYPE_PREFIX,
  IS_TEST_ENVIRONMENT,
  STATUSES
} from './config'
import { getDefaultResponseMapper, parseError, uniqPrefix } from './helper'

/**
 * Created uniq prefix for action type
 *
 * @memberOf action
 * @private
 * @param {String} name - actionSchema key
 * @returns {String} - `${name} ${uniqPrefix}` name with uniq prefix
 * @private
 */
const makeActionUniqId = name => {
  return `${ACTION_TYPE_PREFIX} ${name} ${uniqPrefix}`
}

const getHandlerErrorText = (isFetching, error, payloadSource) => {
  if (!isFetching && error) {
    return error
  }
  if (!isFetching && payloadSource === undefined) {
    return 'Empty Data'
  }
  return error || ''
}

const getHandlerActionDataKey = (isFetching, hasError, payloadSource) => {
  if (!isFetching && !hasError) {
    return Array.isArray(payloadSource) ? ACTION_IDS_KEY : ACTION_ID_KEY
  }
  return undefined
}

const getHandlerPayload = (
  Action,
  isFetching,
  hasError,
  isArrayData,
  payloadSource
) => {
  if (payloadSource && !isFetching && !hasError) {
    return isArrayData
      ? payloadSource.map(item => Action.getEntityId(item))
      : Action.getEntityId(payloadSource)
  }
  return undefined
}
/**
 * Make action handler wrapper
 *
 * @memberOf action
 * @param {Function} Action - Action func
 * @param {String} actionId - uniq action id and type
 * @param {String} parentActionId - uniq parent action id and type (when action created with clone/withname/prefix)
 * @param {String} status - name of action status pending/success/error
 * @param {Object} actionSchema - normalizr actionSchema
 * @returns {Function} - action handler wrapper
 * @private
 */
const makeActionHandler = (
  Action,
  actionId,
  parentActionId,
  status,
  actionSchema
) => {
  /**
   * Action handler
   *
   * @param {String} error - action error text
   * @param {Object|Array} payloadSource - response from action result
   * @returns {Object} - action dispatch body
   */
  return function(error, payloadSource, sourceResult) {
    if (status === 'success' && payloadSource === undefined) {
      error = 'Empty payload'
      status = 'error'
    }

    // flag data in progress (all actions is async)
    const isFetching = status === 'pending'

    // error message text
    const errorText = getHandlerErrorText(isFetching, error, payloadSource)

    // flag
    const hasError = Boolean(errorText)

    // id key, ids or id dispatch({...action.data.id}) or dispatch({...action.data.ids})
    const actionDataKey = getHandlerActionDataKey(
      isFetching,
      hasError,
      payloadSource
    )

    // action flag response data is array or not
    const isArrayData = actionDataKey === ACTION_IDS_KEY

    // action property entity (actionSchema id attribute) name
    const entityName = actionSchema.key

    // list of ids or id from payload
    const payload = getHandlerPayload(
      Action,
      isFetching,
      hasError,
      isArrayData,
      payloadSource
    )

    /**
     * @type {{
     * type: String,
     * prefix: ACTION_TYPE_PREFIX,
     * actionId: String,
     * parentActionId: String,
     * status: String,
     * time: Number,
     * isArrayData: Boolean,
     * actionDataKey: ACTION_ID_KEY|ACTION_IDS_KEY,
     * entityName: String,
     * isFetching: Boolean,
     * errorText: String,
     * hasError: Boolean,
     * actionSchema: Object,
     * payload: Number|Array,
     * payloadSource: Object|Array
     * }}
     */
    return Object.freeze({
      time: new Date().getTime(),
      type: `${actionId}`,
      prefix: ACTION_TYPE_PREFIX,
      actionId,
      parentActionId,
      status,
      isArrayData,
      actionDataKey,
      entityName,
      isFetching,
      errorText,
      hasError,
      actionSchema,
      sourceResult,
      payload,
      payloadSource
    })
  }
}

const makeResultCallback = (responseMapper, success, error) => {
  return function(dispatch, getState, err, result) {
    try {
      const errorMessage = parseError(err)

      if (errorMessage) {
        return dispatch(error(errorMessage, undefined))
      }

      dispatch(success(undefined, responseMapper(result), result))
    } catch (e) {
      dispatch(error(String(`${e.message || e}`), undefined))
      throw e
    }
  }
}

const makeQueryBuilder = (level, dispatch, getState, args, method) => {
  const nextLevel = level + 1
  const nullLevelArgs = level === 0 ? args : [dispatch, getState]

  if (method instanceof Promise) {
    return method
      .apply(this, nullLevelArgs)
      .then(resp => {
        makeQueryBuilder(nextLevel, dispatch, getState, args, resp)
      })
      .catch(err => {
        throw err
      })
  }

  if (typeof method === 'function') {
    const result = method.apply(this, nullLevelArgs)
    return makeQueryBuilder(nextLevel, dispatch, getState, args, result)
  }

  return method
}

const makeCallActionMethod = resultCallBack => {
  return (actionMethod, args, dispatch, getState) => {
    args = Array.isArray(args) ? args : [args]

    let actionResult = actionMethod.apply(this, args)

    if (actionResult instanceof Promise) {
      return actionResult
        .then(resp => resultCallBack(dispatch, getState, false, resp))
        .catch(err => resultCallBack(dispatch, getState, err, undefined))
    }

    if (typeof actionResult === 'function') {
      actionResult = actionResult.call(this, dispatch, getState)

      if (!actionResult) {
        return resultCallBack(dispatch, getState, undefined, undefined)
      }

      if (actionResult instanceof Promise) {
        return actionResult
          .then(resp => resultCallBack(dispatch, getState, false, resp))
          .catch(err => resultCallBack(dispatch, getState, err, undefined))
      }
    }

    return resultCallBack(dispatch, getState, false, actionResult)
  }
}

const actionCopyWrapper = (
  Action,
  actionSchema,
  actionMethod,
  queryBuilder,
  responseMapper
) => {
  return newActionId => {
    newActionId = newActionId.toString().trim()

    if (!newActionId) {
      throw new Error('Action id must be not empty')
    }

    const parentActionId = Action.actionId()
    const nextActionId = [parentActionId, `${newActionId}`].join(' ')

    return makeAction.apply({}, [
      nextActionId,
      parentActionId,
      actionSchema,
      actionMethod,
      queryBuilder,
      responseMapper
    ])
  }
}

/**
 * Create new action
 *
 * @memberOf action
 * @param {String} actionId - uniquie action id
 * @param {String} [parentActionId=""] - parent action id
 * @param {Object} actionSchema - normalizr actionSchema
 * @param {Function|Promise} actionMethod - action function func/promise/ajax call/ etc
 * @param {String|Function|Promise} queryBuilder - action query function builder
 * returns String (for example url),
 * or array [url:String, queryParams:Object (url params), queryBody:Object (post body params)]
 * @param {Function} [responseMapper=_defaultResponseMapper] - actionMethod response (only success) mapper
 * @returns {Action} - Action wrapper Function
 * @private
 */
const makeAction = function(
  actionId,
  parentActionId,
  actionSchema,
  actionMethod,
  queryBuilder,
  responseMapper
) {
  /**
   * Private create action function
   *
   * @memberOf action.makeAction
   *
   * @returns {Function}
   * @constructor
   */
  this.actionId = actionId
  this.parentActionId = parentActionId
  this.schema = actionSchema
  this.method = actionMethod
  this.queryBuilder = queryBuilder
  this.responseMapper = responseMapper

  this.action = (...args) => {
    this.responseMapper = responseMapper || getDefaultResponseMapper()

    const [pending, success, error] = STATUSES.map(statusName =>
      makeActionHandler(
        this.action,
        actionId,
        parentActionId,
        statusName,
        actionSchema
      )
    )

    const resultCallBack = makeResultCallback(
      this.responseMapper,
      success,
      error
    )
    const callActionMethod = makeCallActionMethod(resultCallBack)

    // action body
    return (dispatch, getState) => {
      dispatch(pending())

      try {
        const compiledActionArgs = queryBuilder
          ? makeQueryBuilder(0, dispatch, getState, args, queryBuilder)
          : args

        if (compiledActionArgs instanceof Promise) {
          return compiledActionArgs
            .then(resp =>
              callActionMethod(actionMethod, resp, dispatch, getState)
            )
            .catch(err => {
              throw err
            })
        }

        callActionMethod(actionMethod, compiledActionArgs, dispatch, getState)
      } catch (e) {
        resultCallBack(dispatch, getState, e, undefined)
      }
    }
  }

  /**
   * @memberOf action.makeAction.Action
   * @type {Function}
   * @returns {Function} - returns action id
   */
  this.action.type = this.action.actionId = this.action.toString = this.action.valueOf = () => {
    return this.actionId
  }

  /**
   * @memberOf action.makeAction.Action
   * @param {Object} item - source entity data
   *
   * @type {Function}
   * @returns {Function} - returns id from source
   */
  this.action.getEntityId = item => {
    return this.schema.getId(item)
  }

  /**
   * @memberOf action.makeAction.Action
   * @type {Function}
   * @returns {Object} - returns actionSchema of action
   */
  this.action.getSchema = () => {
    return this.schema
  }

  /**
   * @memberOf action.makeAction.Action
   * @type {Function}
   * @returns {Function} - returns entity uniq name (id)
   */
  this.action.getEntityName = () => {
    return this.schema.key
  }

  /**
   * @memberOf action.makeAction.Action
   * @type {Function}
   * @returns {Action} - returns some action with new uniq id
   */
  this.action.clone = () => {
    return createAction(
      this.schema,
      this.method,
      this.queryBuilder,
      this.responseMapper
    )
  }

  /**
   * @memberOf action.makeAction.Action
   * @type {Function}
   * @returns {Action} - returns some action with prefix-id
   */
  this.action.withPrefix = (...prefix) => {
    return actionCopyWrapper(
      this.action,
      this.schema,
      this.method,
      this.queryBuilder,
      this.responseMapper
    )(prefix.join('-'))
  }

  /**
   * @memberOf action.makeAction.Action
   * @type {Function}
   * @returns {Action} - returns some action with name-id (see prefix)
   */
  this.action.withName = name => {
    return actionCopyWrapper(
      this.action,
      this.schema,
      this.method,
      this.queryBuilder,
      this.responseMapper
    )(name)
  }

  /**
   * Clear action store data
   *
   * @memberOf action.makeAction.Action
   * @type {Function}
   *
   * @example
   * store.dispatch(userLoginAction.empty())
   *
   * @returns {Undefined} - returns None, only clear action data
   */
  this.action.empty = () => {
    return (dispatch, getState) => {
      dispatch({
        time: new Date().getTime(),
        type: ACTION_EMPTY_TYPE_NAME,
        prefix: ACTION_TYPE_PREFIX,
        actionId: this.actionId,
        actionSchema: this.schema
      })
    }
  }

  // /**
  //  * Clean entity from entity reducer
  //  *
  //  * @memberOf action.makeAction.Action
  //  * @type {Function}
  //  *
  //  * @example
  //  * store.dispatch(userLoginAction.clean())
  //  *
  //  * @returns {Undefined} - returns None, only clear entity data
  //  */
  // this.action.clean = () => {
  //   return (dispatch, getState) => {
  //     dispatch({
  //       time: new Date().getTime(),
  //       type: ACTION_CLEAN_TYPE_NAME,
  //       prefix: ACTION_TYPE_PREFIX,
  //       actionId: this.actionId,
  //       actionSchema: this.schema
  //     })
  //   }
  // }

  return this.action
}

/**
 * Action creator
 *
 * @memberOf action
 * @function
 *
 * @param {String|Object} actionSchema - normalizr actionSchema item
 * @param {Function|Promise} actionMethod
 * @param {String|Function} [queryBuilder=undefined]
 * @param {Function} [responseMapper=_defaultResponseMapper||callback from setDefaultResponseMapper]
 *
 *
 * @example
 * // CREATE ACTION
 * const get = (url) => {// returns Promise ajax call}
 *
 * const getUserAction = createAction(user, get, 'user')
 * // calling url 'user'
 *
 * const getUserAction = createAction(user, () => {
 *  return new Promise((resolve, reject) => {
 *    // cookie|local storage|other get data
 *    resolve({
 *      //data
 *    })
 *  })
 * })
 *
 * const getUserAction = createAction(user, get, (userId) => `user/${userId}`)
 * // calling url 'user/${userId}'
 *
 * const getUserAction = createAction(user, get, (userId) => [
 *  `user/${userId}`,
 *  undefined,
 *  {name, phone, email}
 * ])
 * // calling url 'user/${userId}' and post data (if you are using axios) {name, phone, email}
 *
 * // you can pass multi level functions or promises (args) => (dispatch, getState) => (dispatch, getState) => (dispatch, getState) => ...
 * const getUserAction = createAction(user, get, (userId) => {
 *  return (dispatch, getState)=>{
 *    return new Promise((resolve) => {resolve(`user/${userId}`)})
 *  }
 * })
 * // calling url 'user/${userId}'
 *
 * const getUserAction = createAction(user, get, 'user', (resp) => {resp.data})
 * // calling url 'user' but replace backend success response to resp.data
 *
 * @returns {Action} - Action handler function
 */
export const createAction = (
  actionSchema,
  actionMethod,
  queryBuilder,
  responseMapper
) => {
  if (!actionSchema) {
    throw 'actionSchema argument is required, must be normalizr actionSchema'
  }
  if (!actionMethod) {
    throw 'actionMethod argument is required, must be promise or function'
  }
  if (responseMapper && typeof responseMapper !== 'function') {
    throw 'responseMapper must be function'
  }

  const actionId = makeActionUniqId(actionSchema.key)

  return makeAction.apply({}, [
    actionId,
    '',
    actionSchema,
    actionMethod,
    queryBuilder,
    responseMapper
  ])
}

if (IS_TEST_ENVIRONMENT) {
  module.exports.makeActionUniqId = makeActionUniqId
  module.exports.makeActionHandler = makeActionHandler
  module.exports.makeAction = makeAction
}