18F/e-QIP-prototype

View on GitHub
src/components/Form/BranchCollection/BranchCollection.jsx

Summary

Maintainability
A
2 hrs
Test Coverage
import React from 'react'
import PropTypes from 'prop-types'

// eslint-disable-next-line import/no-cycle
import { Branch } from '../index'
import { newGuid } from '../ValidationElement'
import { scrollToBottom as scrollToBottomFn } from '../Accordion/Accordion'

export default class BranchCollection extends React.Component {
  constructor(props) {
    super(props)
    this.content = this.content.bind(this)
  }

  /**
   * Updates existing selected branch values
   */
  onBranchClick(item, index, values) {
    const {
      items, valueKey, removable, onUpdate,
    } = this.props

    const newItems = [...items]
    switch (values.value) {
      case 'Yes':
        if (!newItems[index].Item) {
          newItems[index].Item = {}
        }
        newItems[index].Item = {
          ...newItems[index].Item,
          [valueKey]: values,
        }
        break
      default: {
        const isLastItem = index + 1 >= items.length
        if (!isLastItem && removable) {
          // If it's not the last item being marked as No, then remove it. This addresses the issue
          // where the user must click No twice on the last item.
          newItems.splice(index, 1)
        } else if (index === 0 && newItems.length === 1) {
          // If this is the first and last item, and "no" has been selected, then clear out
          // any persisted data **except** the branch value.
          newItems[index] = {
            Item: {
              [valueKey]: values,
            },
            index: item.index,
          }
        } else {
          // If this is the last item then we still need to remove it.
          newItems.splice(index, 1)
        }
      }
    }

    onUpdate({ items: newItems })
  }

  /**
   * Used when populating branch values for the first time
   */
  onDefaultBranchClick(values) {
    const { valueKey, onUpdate } = this.props

    const item = {
      Item: { [valueKey]: values },
      index: newGuid(),
    }

    const items = [item]
    onUpdate({ items })
  }

  /**
   * Used when a user decides to add new information. This refers to the last
   * displayed yes/no branch
   */
  onLastBranchClick(values) {
    const {
      items, valueKey, scrollToBottom, onUpdate,
    } = this.props

    const item = {
      Item: { [valueKey]: values },
      index: newGuid(),
    }

    if (values.value === 'Yes') {
      const newItems = [...items]
      newItems.push(item)
      onUpdate({ items: newItems })
    } else {
      const newItems = [...items]
      newItems.push(item)
      if (scrollToBottom) {
        scrollToBottomFn(scrollToBottom)
      }
      onUpdate({ items: newItems })
    }
  }

  recursiveCloneChildren(children, item, index) {
    return React.Children.map(children, (child) => {
      let childProps = {}
      if (React.isValidElement(child)) {
        if (child.props.bind) {
          const {
            items, onUpdate, onError, errors,
          } = this.props

          const field = item[child.props.name]
          childProps = { ...field }
          childProps.onUpdate = (value) => {
            const newItems = [...items]
            const newItem = newItems[index][child.props.name]
            newItems[index][child.props.name] = {
              ...newItem,
              ...value,
            }
            onUpdate({ items: newItems })
          }
          childProps.onError = onError

          // Add errors to child component
          const childErrors = errors && errors.filter(e => e.indexOf(item.index) > -1)
          if (childErrors && childErrors.length) childProps.errors = childErrors
        }
      }

      const typeOfChildren = Object.prototype.toString.call(
        child.props.children
      )
      if (child.props.children
        && ['[object Object]', '[object Array]'].includes(typeOfChildren)
      ) {
        childProps.children = this.recursiveCloneChildren(
          child.props.children,
          item,
          index
        )
      }

      return React.cloneElement(child, childProps)
    })
  }

  /**
   * Helper that renders branch information. Allows props to be overriden
   */
  branch(props) {
    const { required, scrollIntoView } = this.props

    return (
      <Branch
        name={props.name}
        label={props.label}
        labelSize={props.labelSize}
        className={props.className}
        help={props.help}
        {...props.value || {}}
        warning={props.warning}
        onUpdate={props.onUpdate}
        required={required}
        onError={props.onError}
        scrollIntoView={scrollIntoView}
      >
        {props.children}
      </Branch>
    )
  }

  content() {
    const {
      items, valueKey, branchName, label, labelSize, help, content, onError,
      branchClassName, appendLabel, appendSize, appendContent, children,
    } = this.props

    const renderItems = (items || []).map((item) => {
      if (!item.index) {
        // eslint-disable-next-line no-param-reassign
        item.index = newGuid()
      }
      return item
    })

    const hasNo = !!renderItems.find(item => ((item.Item || {})[valueKey] || {}).value !== 'Yes')

    // When no items are present, render default branch yes/no
    if (renderItems.length === 0) {
      return (
        <div>
          {this.branch({
            name: branchName,
            label,
            labelSize,
            help,
            value: {},
            warning: false,
            children: content,
            onUpdate: this.onDefaultBranchClick.bind(this),
            onError,
          })}
        </div>
      )
    }

    // If a branch has been selected but it has a `No` value, rather than deleting, we'll update
    // its value
    if (renderItems.length === 1
      && ((renderItems[0].Item || {})[valueKey] || {}).value === 'No'
    ) {
      const [item] = items
      return (
        <div key={item.index}>
          {this.branch({
            name: branchName,
            label,
            labelSize,
            help,
            value: { value: 'No' },
            warning: false,
            children: content,
            onUpdate: this.onBranchClick.bind(this, item, 0),
            onError,
          })}
        </div>
      )
    }

    // When more than 1 item is in
    const top = (index, item) => {
      const className = ((item.Item || {})[valueKey] || {}).value === 'Yes'
        ? branchClassName
        : null

      if (index === 0) {
        return this.branch({
          name: branchName,
          label,
          labelSize,
          className,
          value: (item.Item || {})[valueKey],
          warning: true,
          help,
          children: content,
          onUpdate: this.onBranchClick.bind(this, item, index),
          onError,
        })
      }

      return this.branch({
        name: branchName,
        label: appendLabel,
        labelSize: appendSize,
        className,
        help,
        value: (item.Item || {})[valueKey],
        warning: true,
        children: appendContent,
        onUpdate: this.onBranchClick.bind(this, item, index),
        onError,
      })
    }

    // Render the branch question at the very end
    const bottom = (index, item, arr) => {
      if (index < arr.length - 1 || hasNo) {
        return null
      }

      return this.branch({
        name: branchName,
        className: 'last-branch',
        label: appendLabel,
        labelSize: appendSize,
        help,
        onUpdate: this.onLastBranchClick.bind(this),
        children: appendContent,
        value: {},
        warning: false,
      })
    }

    const kiddos = (index, item) => {
      const key = (item.Item || {})[valueKey] || {}
      return key.value === 'Yes'
        ? this.recursiveCloneChildren(children, item, index)
        : null
    }

    const rows = renderItems.map((item, index, arr) => (
      <div key={item.index}>
        {top(index, item, arr)}
        <div>{kiddos(index, item)}</div>
        {bottom(index, item, arr)}
      </div>
    ))

    return <div>{rows}</div>
  }

  render() {
    const { className } = this.props
    return <div className={className}>{this.content()}</div>
  }
}

BranchCollection.propTypes = {
  items: PropTypes.array,
  removable: PropTypes.bool,
  branchName: PropTypes.string,
  help: PropTypes.string,
  valueKey: PropTypes.string,
  label: PropTypes.string,
  labelSize: PropTypes.string,
  content: PropTypes.node,
  appendLabel: PropTypes.string,
  appendSize: PropTypes.string,
  appendContent: PropTypes.node,
  onUpdate: PropTypes.func,
  scrollToBottom: PropTypes.string,
  branchClassName: PropTypes.string,
  required: PropTypes.bool,
  scrollIntoView: PropTypes.bool,
  onError: PropTypes.func,
  children: PropTypes.node,
  className: PropTypes.string,
  errors: PropTypes.array,
}

BranchCollection.defaultProps = {
  // Items in the collection to render
  items: [],

  // If selecting No removes the item
  removable: true,

  // Input name for the supporting Branch component
  branchName: 'branchcollection',

  // Branch help id
  help: '',

  // Key name that stores whether yes/no has been selected
  valueKey: 'Has',

  label: '',
  labelSize: 'h4',
  content: null,
  appendLabel: '',
  appendSize: 'h4',
  appendContent: null,

  onUpdate: () => {
    console.warn(
      'onUpdate function not provided in BranchCollection. Please add one or your updates will not work'
    )
  },
  scrollToBottom: '',
  branchClassName: '',
  required: false,
  scrollIntoView: false,
  onError: () => {},
  children: null,
  className: undefined,
  errors: [],
}

BranchCollection.errors = []