erikras/redux-form

View on GitHub
src/createReducer.js

Summary

Maintainability
F
3 days
Test Coverage
// @flow
import {
  ARRAY_INSERT,
  ARRAY_MOVE,
  ARRAY_POP,
  ARRAY_PUSH,
  ARRAY_REMOVE,
  ARRAY_REMOVE_ALL,
  ARRAY_SHIFT,
  ARRAY_SPLICE,
  ARRAY_SWAP,
  ARRAY_UNSHIFT,
  AUTOFILL,
  BLUR,
  CHANGE,
  CLEAR_ASYNC_ERROR,
  CLEAR_SUBMIT,
  CLEAR_SUBMIT_ERRORS,
  DESTROY,
  FOCUS,
  INITIALIZE,
  prefix,
  REGISTER_FIELD,
  RESET,
  RESET_SECTION,
  SET_SUBMIT_FAILED,
  SET_SUBMIT_SUCCEEDED,
  START_ASYNC_VALIDATION,
  START_SUBMIT,
  STOP_ASYNC_VALIDATION,
  STOP_SUBMIT,
  SUBMIT,
  TOUCH,
  UNREGISTER_FIELD,
  UNTOUCH,
  UPDATE_SYNC_ERRORS,
  CLEAR_FIELDS,
  UPDATE_SYNC_WARNINGS
} from './actionTypes'
import createDeleteInWithCleanUp from './deleteInWithCleanUp'
import plain from './structure/plain'
import type { Action, Structure } from './types.js.flow'
import { isFunction } from 'lodash'

const shouldDelete = ({ getIn }) => (state, path) => {
  let initialValuesPath = null

  if (/^values/.test(path)) {
    initialValuesPath = path.replace('values', 'initial')
  }

  const initialValueComparison = initialValuesPath
    ? getIn(state, initialValuesPath) === undefined
    : true

  return getIn(state, path) !== undefined && initialValueComparison
}

const isReduxFormAction = action =>
  action &&
  action.type &&
  action.type.length > prefix.length &&
  action.type.substring(0, prefix.length) === prefix

function createReducer<M, L>(structure: Structure<M, L>) {
  const {
    deepEqual,
    empty,
    forEach,
    getIn,
    setIn,
    deleteIn,
    fromJS,
    keys,
    size,
    some,
    splice
  } = structure
  const deleteInWithCleanUp = createDeleteInWithCleanUp(structure)(shouldDelete)
  const plainDeleteInWithCleanUp = createDeleteInWithCleanUp(plain)(
    shouldDelete
  )
  const doSplice = (state, key, field, index, removeNum, value, force) => {
    const existing = getIn(state, `${key}.${field}`)
    return existing || force
      ? setIn(
          state,
          `${key}.${field}`,
          splice(existing, index, removeNum, value)
        )
      : state
  }
  const doPlainSplice = (state, key, field, index, removeNum, value, force) => {
    const slice = getIn(state, key)
    const existing = plain.getIn(slice, field)
    return existing || force
      ? setIn(
          state,
          key,
          plain.setIn(
            slice,
            field,
            plain.splice(existing, index, removeNum, value)
          )
        )
      : state
  }
  const rootKeys = ['values', 'fields', 'submitErrors', 'asyncErrors']
  const arraySplice = (state, field, index, removeNum, value) => {
    let result = state
    const nonValuesValue = value != null ? empty : undefined
    result = doSplice(result, 'values', field, index, removeNum, value, true)
    result = doSplice(result, 'fields', field, index, removeNum, nonValuesValue)
    result = doPlainSplice(
      result,
      'syncErrors',
      field,
      index,
      removeNum,
      undefined
    )
    result = doPlainSplice(
      result,
      'syncWarnings',
      field,
      index,
      removeNum,
      undefined
    )
    result = doSplice(
      result,
      'submitErrors',
      field,
      index,
      removeNum,
      undefined
    )
    result = doSplice(result, 'asyncErrors', field, index, removeNum, undefined)
    return result
  }

  const behaviors: { [string]: { (state: any, action: Action): M } } = {
    [ARRAY_INSERT](
      state,
      {
        meta: { field, index },
        payload
      }
    ) {
      return arraySplice(state, field, index, 0, payload)
    },
    [ARRAY_MOVE](
      state,
      {
        meta: { field, from, to }
      }
    ) {
      const array = getIn(state, `values.${field}`)
      const length = array ? size(array) : 0
      let result = state
      if (length) {
        rootKeys.forEach(key => {
          const path = `${key}.${field}`
          if (getIn(result, path)) {
            const value = getIn(result, `${path}[${from}]`)
            result = setIn(result, path, splice(getIn(result, path), from, 1)) // remove
            result = setIn(
              result,
              path,
              splice(getIn(result, path), to, 0, value)
            ) // insert
          }
        })
      }
      return result
    },
    [ARRAY_POP](
      state,
      {
        meta: { field }
      }
    ) {
      const array = getIn(state, `values.${field}`)
      const length = array ? size(array) : 0
      return length ? arraySplice(state, field, length - 1, 1) : state
    },
    [ARRAY_PUSH](
      state,
      {
        meta: { field },
        payload
      }
    ) {
      const array = getIn(state, `values.${field}`)
      const length = array ? size(array) : 0
      return arraySplice(state, field, length, 0, payload)
    },
    [ARRAY_REMOVE](
      state,
      {
        meta: { field, index }
      }
    ) {
      return arraySplice(state, field, index, 1)
    },
    [ARRAY_REMOVE_ALL](
      state,
      {
        meta: { field }
      }
    ) {
      const array = getIn(state, `values.${field}`)
      const length = array ? size(array) : 0
      return length ? arraySplice(state, field, 0, length) : state
    },
    [ARRAY_SHIFT](
      state,
      {
        meta: { field }
      }
    ) {
      return arraySplice(state, field, 0, 1)
    },
    [ARRAY_SPLICE](
      state,
      {
        meta: { field, index, removeNum },
        payload
      }
    ) {
      return arraySplice(state, field, index, removeNum, payload)
    },
    [ARRAY_SWAP](
      state,
      {
        meta: { field, indexA, indexB }
      }
    ) {
      let result = state
      rootKeys.forEach(key => {
        const valueA = getIn(result, `${key}.${field}[${indexA}]`)
        const valueB = getIn(result, `${key}.${field}[${indexB}]`)
        if (valueA !== undefined || valueB !== undefined) {
          result = setIn(result, `${key}.${field}[${indexA}]`, valueB)
          result = setIn(result, `${key}.${field}[${indexB}]`, valueA)
        }
      })
      return result
    },
    [ARRAY_UNSHIFT](
      state,
      {
        meta: { field },
        payload
      }
    ) {
      return arraySplice(state, field, 0, 0, payload)
    },
    [AUTOFILL](
      state,
      {
        meta: { field },
        payload
      }
    ) {
      let result: any = state
      result = deleteInWithCleanUp(result, `asyncErrors.${field}`)
      result = deleteInWithCleanUp(result, `submitErrors.${field}`)
      result = setIn(result, `fields.${field}.autofilled`, true)
      result = setIn(result, `values.${field}`, payload)
      return result
    },
    [BLUR](
      state,
      {
        meta: { field, touch },
        payload
      }
    ) {
      let result = state
      const initial = getIn(result, `initial.${field}`)
      if (initial === undefined && payload === '') {
        result = deleteInWithCleanUp(result, `values.${field}`)
      } else if (payload !== undefined) {
        result = setIn(result, `values.${field}`, payload)
      }
      if (field === getIn(result, 'active')) {
        result = deleteIn(result, 'active')
      }
      result = deleteIn(result, `fields.${field}.active`)
      if (touch) {
        result = setIn(result, `fields.${field}.touched`, true)
        result = setIn(result, 'anyTouched', true)
      }
      return result
    },
    [CHANGE](
      state,
      {
        meta: { field, touch, persistentSubmitErrors },
        payload
      }
    ) {
      let result = state
      const initial = getIn(result, `initial.${field}`)
      if ((initial === undefined && payload === '') || payload === undefined) {
        result = deleteInWithCleanUp(result, `values.${field}`)
      } else if (isFunction(payload)) {
        const fieldCurrentValue = getIn(state, `values.${field}`)
        result = setIn(
          result,
          `values.${field}`,
          payload(fieldCurrentValue, state.values)
        )
      } else {
        result = setIn(result, `values.${field}`, payload)
      }
      result = deleteInWithCleanUp(result, `asyncErrors.${field}`)
      if (!persistentSubmitErrors) {
        result = deleteInWithCleanUp(result, `submitErrors.${field}`)
      }
      result = deleteInWithCleanUp(result, `fields.${field}.autofilled`)
      if (touch) {
        result = setIn(result, `fields.${field}.touched`, true)
        result = setIn(result, 'anyTouched', true)
      }
      return result
    },
    [CLEAR_SUBMIT](state) {
      return deleteIn(state, 'triggerSubmit')
    },
    [CLEAR_SUBMIT_ERRORS](state) {
      let result = state
      result = deleteInWithCleanUp(result, 'submitErrors')
      result = deleteIn(result, 'error')
      return result
    },
    [CLEAR_ASYNC_ERROR](
      state,
      {
        meta: { field }
      }
    ) {
      return deleteIn(state, `asyncErrors.${field}`)
    },
    [CLEAR_FIELDS](
      state,
      {
        meta: { keepTouched, persistentSubmitErrors, fields }
      }
    ) {
      let result = state
      fields.forEach(field => {
        result = deleteInWithCleanUp(result, `asyncErrors.${field}`)
        if (!persistentSubmitErrors) {
          result = deleteInWithCleanUp(result, `submitErrors.${field}`)
        }
        result = deleteInWithCleanUp(result, `fields.${field}.autofilled`)
        if (!keepTouched) {
          result = deleteIn(result, `fields.${field}.touched`)
        }
        const values = getIn(state, `initial.${field}`)
        result = values
          ? setIn(result, `values.${field}`, values)
          : deleteInWithCleanUp(result, `values.${field}`)
      })
      const anyTouched = some(keys(getIn(result, 'registeredFields')), key =>
        getIn(result, `fields.${key}.touched`)
      )
      result = anyTouched
        ? setIn(result, 'anyTouched', true)
        : deleteIn(result, 'anyTouched')
      return result
    },
    [FOCUS](
      state,
      {
        meta: { field }
      }
    ) {
      let result = state
      const previouslyActive = getIn(state, 'active')
      result = deleteIn(result, `fields.${previouslyActive}.active`)
      result = setIn(result, `fields.${field}.visited`, true)
      result = setIn(result, `fields.${field}.active`, true)
      result = setIn(result, 'active', field)
      return result
    },
    [INITIALIZE](
      state,
      {
        payload,
        meta: {
          keepDirty,
          keepSubmitSucceeded,
          updateUnregisteredFields,
          keepValues
        }
      }
    ) {
      const mapData = fromJS(payload)
      let result = empty // clean all field state

      // persist old warnings, they will get recalculated if the new form values are different from the old values
      const warning = getIn(state, 'warning')
      if (warning) {
        result = setIn(result, 'warning', warning)
      }
      const syncWarnings = getIn(state, 'syncWarnings')
      if (syncWarnings) {
        result = setIn(result, 'syncWarnings', syncWarnings)
      }

      // persist old errors, they will get recalculated if the new form values are different from the old values
      const error = getIn(state, 'error')
      if (error) {
        result = setIn(result, 'error', error)
      }
      const syncErrors = getIn(state, 'syncErrors')
      if (syncErrors) {
        result = setIn(result, 'syncErrors', syncErrors)
      }

      const registeredFields = getIn(state, 'registeredFields')
      if (registeredFields) {
        result = setIn(result, 'registeredFields', registeredFields)
      }

      const previousValues = getIn(state, 'values')
      const previousInitialValues = getIn(state, 'initial')

      let newInitialValues = mapData
      let newValues = previousValues

      if (keepDirty && registeredFields) {
        if (!deepEqual(newInitialValues, previousInitialValues)) {
          //
          // Keep the value of dirty fields while updating the value of
          // pristine fields. This way, apps can reinitialize forms while
          // avoiding stomping on user edits.
          //
          // Note 1: The initialize action replaces all initial values
          // regardless of keepDirty.
          //
          // Note 2: When a field is dirty, keepDirty is enabled, and the field
          // value is the same as the new initial value for the field, the
          // initialize action causes the field to become pristine. That effect
          // is what we want.
          //
          const overwritePristineValue = name => {
            const previousInitialValue = getIn(previousInitialValues, name)
            const previousValue = getIn(previousValues, name)

            if (deepEqual(previousValue, previousInitialValue)) {
              // Overwrite the old pristine value with the new pristine value
              const newInitialValue = getIn(newInitialValues, name)

              // This check prevents any 'setIn' call that would create useless
              // nested objects, since the path to the new field value would
              // evaluate to the same (especially for undefined values)
              if (getIn(newValues, name) !== newInitialValue) {
                newValues = setIn(newValues, name, newInitialValue)
              }
            }
          }

          if (!updateUnregisteredFields) {
            forEach(keys(registeredFields), name =>
              overwritePristineValue(name)
            )
          }

          forEach(keys(newInitialValues), name => {
            const previousInitialValue = getIn(previousInitialValues, name)
            if (typeof previousInitialValue === 'undefined') {
              // Add new values at the root level.
              const newInitialValue = getIn(newInitialValues, name)
              newValues = setIn(newValues, name, newInitialValue)
            }

            if (updateUnregisteredFields) {
              overwritePristineValue(name)
            }
          })
        }
      } else {
        newValues = newInitialValues
      }

      if (keepValues) {
        forEach(keys(previousValues), name => {
          const previousValue = getIn(previousValues, name)

          newValues = setIn(newValues, name, previousValue)
        })

        forEach(keys(previousInitialValues), name => {
          const previousInitialValue = getIn(previousInitialValues, name)

          newInitialValues = setIn(newInitialValues, name, previousInitialValue)
        })
      }

      if (keepSubmitSucceeded && getIn(state, 'submitSucceeded')) {
        result = setIn(result, 'submitSucceeded', true)
      }
      result = setIn(result, 'values', newValues)
      result = setIn(result, 'initial', newInitialValues)
      return result
    },
    [REGISTER_FIELD](
      state,
      {
        payload: { name, type }
      }
    ) {
      const key = `registeredFields['${name}']`
      let field = getIn(state, key)
      if (field) {
        const count = getIn(field, 'count') + 1
        field = setIn(field, 'count', count)
      } else {
        field = fromJS({ name, type, count: 1 })
      }
      return setIn(state, key, field)
    },
    [RESET](state) {
      let result = empty
      const registeredFields = getIn(state, 'registeredFields')
      if (registeredFields) {
        result = setIn(result, 'registeredFields', registeredFields)
      }
      const values = getIn(state, 'initial')
      if (values) {
        result = setIn(result, 'values', values)
        result = setIn(result, 'initial', values)
      }
      return result
    },
    [RESET_SECTION](
      state,
      {
        meta: { sections }
      }
    ) {
      let result = state

      sections.forEach(section => {
        result = deleteInWithCleanUp(result, `asyncErrors.${section}`)
        result = deleteInWithCleanUp(result, `submitErrors.${section}`)
        result = deleteInWithCleanUp(result, `fields.${section}`)

        const values = getIn(state, `initial.${section}`)
        result = values
          ? setIn(result, `values.${section}`, values)
          : deleteInWithCleanUp(result, `values.${section}`)
      })

      const anyTouched = some(keys(getIn(result, 'registeredFields')), key =>
        getIn(result, `fields.${key}.touched`)
      )
      result = anyTouched
        ? setIn(result, 'anyTouched', true)
        : deleteIn(result, 'anyTouched')

      return result
    },
    [SUBMIT](state) {
      return setIn(state, 'triggerSubmit', true)
    },
    [START_ASYNC_VALIDATION](
      state,
      {
        meta: { field }
      }
    ) {
      return setIn(state, 'asyncValidating', field || true)
    },
    [START_SUBMIT](state) {
      return setIn(state, 'submitting', true)
    },
    [STOP_ASYNC_VALIDATION](state, { payload }) {
      let result = state
      result = deleteIn(result, 'asyncValidating')
      if (payload && Object.keys(payload).length) {
        const { _error, ...fieldErrors } = payload
        if (_error) {
          result = setIn(result, 'error', _error)
        }
        if (Object.keys(fieldErrors).length) {
          result = setIn(result, 'asyncErrors', fromJS(fieldErrors))
        }
      } else {
        result = deleteIn(result, 'error')
        result = deleteIn(result, 'asyncErrors')
      }
      return result
    },
    [STOP_SUBMIT](state, { payload }) {
      let result = state
      result = deleteIn(result, 'submitting')
      result = deleteIn(result, 'submitFailed')
      result = deleteIn(result, 'submitSucceeded')
      if (payload && Object.keys(payload).length) {
        const { _error, ...fieldErrors } = payload
        if (_error) {
          result = setIn(result, 'error', _error)
        } else {
          result = deleteIn(result, 'error')
        }
        if (Object.keys(fieldErrors).length) {
          result = setIn(result, 'submitErrors', fromJS(fieldErrors))
        } else {
          result = deleteIn(result, 'submitErrors')
        }
        result = setIn(result, 'submitFailed', true)
      } else {
        result = deleteIn(result, 'error')
        result = deleteIn(result, 'submitErrors')
      }
      return result
    },
    [SET_SUBMIT_FAILED](
      state,
      {
        meta: { fields }
      }
    ) {
      let result = state
      result = setIn(result, 'submitFailed', true)
      result = deleteIn(result, 'submitSucceeded')
      result = deleteIn(result, 'submitting')
      fields.forEach(
        field => (result = setIn(result, `fields.${field}.touched`, true))
      )
      if (fields.length) {
        result = setIn(result, 'anyTouched', true)
      }
      return result
    },
    [SET_SUBMIT_SUCCEEDED](state) {
      let result = state
      result = deleteIn(result, 'submitFailed')
      result = setIn(result, 'submitSucceeded', true)
      return result
    },
    [TOUCH](
      state,
      {
        meta: { fields }
      }
    ) {
      let result = state
      fields.forEach(
        field => (result = setIn(result, `fields.${field}.touched`, true))
      )
      result = setIn(result, 'anyTouched', true)
      return result
    },
    [UNREGISTER_FIELD](
      state,
      {
        payload: { name, destroyOnUnmount }
      }
    ) {
      let result = state
      const key = `registeredFields['${name}']`
      let field = getIn(result, key)
      if (!field) {
        return result
      }

      const count = getIn(field, 'count') - 1
      if (count <= 0 && destroyOnUnmount) {
        // Note: Cannot use deleteWithCleanUp here because of the flat nature of registeredFields
        result = deleteIn(result, key)
        if (deepEqual(getIn(result, 'registeredFields'), empty)) {
          result = deleteIn(result, 'registeredFields')
        }
        let syncErrors = getIn(result, 'syncErrors')
        if (syncErrors) {
          syncErrors = plainDeleteInWithCleanUp(syncErrors, name)
          if (plain.deepEqual(syncErrors, plain.empty)) {
            result = deleteIn(result, 'syncErrors')
          } else {
            result = setIn(result, 'syncErrors', syncErrors)
          }
        }
        let syncWarnings = getIn(result, 'syncWarnings')
        if (syncWarnings) {
          syncWarnings = plainDeleteInWithCleanUp(syncWarnings, name)
          if (plain.deepEqual(syncWarnings, plain.empty)) {
            result = deleteIn(result, 'syncWarnings')
          } else {
            result = setIn(result, 'syncWarnings', syncWarnings)
          }
        }
        result = deleteInWithCleanUp(result, `submitErrors.${name}`)
        result = deleteInWithCleanUp(result, `asyncErrors.${name}`)
      } else {
        field = setIn(field, 'count', count)
        result = setIn(result, key, field)
      }
      return result
    },
    [UNTOUCH](
      state,
      {
        meta: { fields }
      }
    ) {
      let result = state
      fields.forEach(
        field => (result = deleteIn(result, `fields.${field}.touched`))
      )
      const anyTouched = some(keys(getIn(result, 'registeredFields')), key =>
        getIn(result, `fields.${key}.touched`)
      )
      result = anyTouched
        ? setIn(result, 'anyTouched', true)
        : deleteIn(result, 'anyTouched')
      return result
    },
    [UPDATE_SYNC_ERRORS](
      state,
      {
        payload: { syncErrors, error }
      }
    ) {
      let result = state
      if (error) {
        result = setIn(result, 'error', error)
        result = setIn(result, 'syncError', true)
      } else {
        result = deleteIn(result, 'error')
        result = deleteIn(result, 'syncError')
      }
      if (Object.keys(syncErrors).length) {
        result = setIn(result, 'syncErrors', syncErrors)
      } else {
        result = deleteIn(result, 'syncErrors')
      }
      return result
    },
    [UPDATE_SYNC_WARNINGS](
      state,
      {
        payload: { syncWarnings, warning }
      }
    ) {
      let result = state
      if (warning) {
        result = setIn(result, 'warning', warning)
      } else {
        result = deleteIn(result, 'warning')
      }
      if (Object.keys(syncWarnings).length) {
        result = setIn(result, 'syncWarnings', syncWarnings)
      } else {
        result = deleteIn(result, 'syncWarnings')
      }
      return result
    }
  }

  const reducer = (state: any = empty, action: Action) => {
    const behavior = behaviors[action.type]
    return behavior ? behavior(state, action) : state
  }

  const byForm = reducer => (
    state: any = empty,
    action: Action = { type: 'NONE' }
  ) => {
    const form = action && action.meta && action.meta.form
    if (!form || !isReduxFormAction(action)) {
      return state
    }
    if (action.type === DESTROY && action.meta && action.meta.form) {
      return action.meta.form.reduce(
        (result, form) => deleteInWithCleanUp(result, form),
        state
      )
    }
    const formState = getIn(state, form)
    const result = reducer(formState, action)
    return result === formState ? state : setIn(state, form, result)
  }

  /**
   * Adds additional functionality to the reducer
   */
  function decorate(target) {
    target.plugin = function(reducers, config = {}) {
      // use 'function' keyword to enable 'this'
      return decorate(
        (state: any = empty, action: Action = { type: 'NONE' }) => {
          const callPlugin = (processed: any, key: string) => {
            const previousState = getIn(processed, key)
            const nextState = reducers[key](
              previousState,
              action,
              getIn(state, key)
            )
            return nextState !== previousState
              ? setIn(processed, key, nextState)
              : processed
          }

          const processed = this(state, action) // run through redux-form reducer
          const form = action && action.meta && action.meta.form

          if (form && !config.receiveAllFormActions) {
            // this is an action aimed at forms, so only give it to the specified form's plugin
            return reducers[form] ? callPlugin(processed, form) : processed
          } else {
            // this is not a form-specific action, so send it to all the plugins
            return Object.keys(reducers).reduce(callPlugin, processed)
          }
        }
      )
    }

    return target
  }

  return decorate(byForm(reducer))
}

export default createReducer