18F/e-QIP-prototype

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

Summary

Maintainability
B
5 hrs
Test Coverage
/* eslint jsx-a11y/anchor-is-valid: 0 */
/* eslint jsx-a11y/anchor-has-content: 0 */
/* eslint no-script-url: 0 */

import React from 'react'
import classnames from 'classnames'

import { i18n } from 'config'

import ValidationElement, { newGuid } from 'components/Form/ValidationElement'
import Svg from 'components/Form/Svg'
import Textarea from 'components/Form/Textarea'

const renderMessage = (id, messageString, titleString) => {
  const noteId = `${id}.note`
  let note = i18n.m(noteId)
  if (
    Object.prototype.toString.call(note) === '[object String]'
    && note.indexOf(noteId) > -1
  ) {
    note = ''
  } else {
    note = <em>{note}</em>
  }

  const messageId = `${id}.message`
  let message = messageString || i18n.m(messageId)
  if (
    Object.prototype.toString.call(message) === '[object String]'
    && message.indexOf(messageId) > -1
  ) {
    message = ''
  } else {
    message = <span>{message}</span>
  }

  const title = titleString || i18n.t(`${id}.title`)

  return (
    <div key={newGuid()} data-i18n={id}>
      <h5 className="usa-alert-heading">{title}</h5>
      {message}
      {note}
    </div>
  )
}

// XXX All references to `comments` in this component refer to help text / info boxes

export default class Field extends ValidationElement {
  constructor(props) {
    super(props)

    this.state = {
      uuid: `field-${super.guid()}`,
      errors: props.errors,
      helpActive: props.helpActive,
      isCommentActive: !!props.commentsValue.value,
    }

    this.errorMessagesRef = null
    this.helpMessageRef = null

    this.toggleHelp = this.toggleHelp.bind(this)
    this.handleError = this.handleError.bind(this)
    this.children = this.children.bind(this)
    this.errors = props.errors || []
  }

  /**
   * Handle the click event for the rendering of messages.
   */
  toggleHelp() {
    const { helpActive } = this.state

    this.setState({ helpActive: !helpActive }, () => {
      this.scrollIntoView(this.helpMessageRef)
    })
  }

  /**
   * Toggle the comment visibility.
   */
  toggleComments = () => {
    this.setState(prevState => ({
      isCommentActive: !prevState.isCommentActive,
    }), () => {
      if (!this.state.isCommentActive) {
        this.clearComment()
      }
    })
  }

  clearComment = () => {
    const { commentsName, onUpdate } = this.props
    onUpdate({
      name: commentsName,
      value: '',
    })
  }

  /**
   * Determines if the comments should be visible.
   */
  visibleComments = () => {
    const { comments, commentsActive } = this.props
    const { isCommentActive } = this.state

    return (
      comments && (isCommentActive || commentsActive)
    )
  }

  handleError(value, arr = []) {
    const errors = [...this.errors]

    if (arr.length === 0) {
      if (errors.length && errors.some(err => err.valid === false)) {
        this.scrollIntoView(this.errorMessagesRef)
      }
      return arr
    }

    arr.forEach((e) => {
      const idx = errors.findIndex(x => x.uid === e.uid && x.code === e.code)
      if (idx !== -1) {
        errors[idx] = { ...e }
      } else {
        errors.push({ ...e })
      }
    })

    // Store in instance variable to update immediately as opposed to storing in state
    // which is an asynchronous operation. This prevents the issue where one call
    // overrides the errors of another call if both are executed almost at the time same.
    this.errors = [...errors]
    this.setState({ errors }, () => {
      if (errors.length && errors.some(err => err.valid === false)) {
        this.scrollIntoView(this.errorMessagesRef)
      }
    })

    return arr
  }

  /**
   * Render the title as needed
   */
  title(required = false) {
    const { title, titleSize, optionalText } = this.props

    if (title) {
      const titleClasses = classnames('title', titleSize)

      const optional = !required && optionalText
        ? (<span className="optional">{optionalText}</span>)
        : null

      // Apply the semantic element for accessibility
      switch (titleSize) {
        case 'h1':
          return (
            <h1 className={titleClasses}>
              {title}
              {optional}
            </h1>
          )
        case 'h2':
          return (
            <h2 className={titleClasses}>
              {title}
              {optional}
            </h2>
          )
        case 'h3':
          return (
            <h3 className={titleClasses}>
              {title}
              {optional}
            </h3>
          )
        case 'h4':
          return (
            <h4 className={titleClasses}>
              {title}
              {optional}
            </h4>
          )
        case 'h5':
          return (
            <h5 className={titleClasses}>
              {title}
              {optional}
            </h5>
          )
        case 'h6':
          return (
            <h6 className={titleClasses}>
              {title}
              {optional}
            </h6>
          )
        case 'label':
          /* eslint jsx-a11y/label-has-associated-control: 0 */
          /* eslint jsx-a11y/label-has-for: 0 */
          return (
            <label className={titleClasses}>
              {title}
              {optional}
            </label>
          )
          /* eslint jsx-a11y/label-has-associated-control: 1 */
          /* eslint jsx-a11y/label-has-for: 1 */
        default:
          return (
            <span className={titleClasses}>
              {title}
              {optional}
            </span>
          )
      }
    }

    return null
  }

  /**
   * Render the comments toggle link if needed.
   */
  commentsButton() {
    const { comments, commentsRemove, commentsAdd } = this.props
    if (!comments) {
      return null
    }

    if (this.visibleComments()) {
      return (
        <a
          href="javascript:;;"
          onClick={this.toggleComments}
          className="comments-button remove"
        >
          <span>{i18n.t(commentsRemove)}</span>
          <i className="fa fa-times-circle" />
        </a>
      )
    }

    return (
      <a
        href="javascript:;;"
        onClick={this.toggleComments}
        className="comments-button add"
      >
        <span>{i18n.t(commentsAdd)}</span>
        <i className="fa fa-plus-circle" />
      </a>
    )
  }

  /**
   * Render the comments if necessary.
   */
  comments() {
    const {
      comments, commentsName, commentsValue, onError, onUpdate, onValidate, commentsRequired,
    } = this.props
    if (!comments || !this.visibleComments()) {
      return null
    }

    return (
      <Textarea
        name={commentsName}
        value={commentsValue.value}
        className="comments"
        onError={onError}
        onUpdate={onUpdate}
        onValidate={onValidate}
        required={commentsRequired}
      />
    )
  }

  /**
   * Render the help icon if needed.
   */
  icon() {
    const {
      help, title, titleSize, adjustFor,
    } = this.props
    const { helpActive } = this.state

    if (!help) {
      return null
    }

    const iconClasses = classnames(
      'toggle',
      titleSize,
      {
        active: helpActive,
        [`adjust-for-${adjustFor}`]: !!adjustFor,
      },
    )

    const titleString = `Show help${title && ' for '}${title || ''}`.trim()

    return (
      <a
        href="javascript:;"
        title={titleString}
        aria-label={titleString}
        className={iconClasses}
        onClick={this.toggleHelp}
      >
        <Svg src="/img/info.svg" />
      </a>
    )
  }

  /**
   * Render the help messages allowing for Markdown syntax.
   */

  helpMessage() {
    const { help, helpMessage, helpTitle } = this.props

    if (this.state.helpActive && help) {
      return (
        <div className="usa-alert usa-alert-info" role="alert">
          <div className="usa-alert-body">
            {renderMessage(help, helpMessage, helpTitle)}
            <a
              href="javascript:;;"
              className="close"
              onClick={this.toggleHelp}
              title={i18n.t('help.close')}
            >
              {i18n.t('help.close')}
            </a>
          </div>
        </div>
      )
    }

    return null
  }

  /**
   * Render the error messages allowing for Markdown syntax.
   */
  errorMessages() {
    const el = []
    const stateErrors = this.props.filterErrors(this.errors || [])
    let errors = stateErrors.filter(err => (
      err.valid === false
      && err.code.indexOf('required') === -1
      && err.code.indexOf('country.notfound') === -1
    ))

    const required = stateErrors
      .filter(err => err.code.indexOf('required') > -1 && err.valid === false)
      .sort((e1, e2) => e1.code.split('.').length - e2.code.split('.').length)

    if (required.length) {
      errors = errors.concat(required[0])
    }

    if (errors.length) {
      const markup = errors.map(err => renderMessage(`error.${err.code}`))

      el.push(
        <div className="message error usa-alert usa-alert-error" key={super.guid()} role="alert" aria-live="polite">
          <div className="usa-alert-body">
            {markup}
          </div>
        </div>,
      )
    }

    return el
  }

  /**
   * Iterate through the children and bind methods to them.
   */
  children(el) {
    return React.Children.map(el, (child) => {
      if (!child || !child.props) {
        return child
      }

      const props = child.props || {}
      const extendedProps = { ...props }
      let injected = false

      if (React.isValidElement(child)) {
        // Inject ourselves in to the validation callback
        if (props.onError) {
          injected = true
          extendedProps.onError = (value, arr) => this.handleError(value, props.onError(value, arr))
        }
      }

      if (props.children && !injected) {
        const typeOfChildren = Object.prototype.toString.call(props.children)
        if (props.children
          && ['[object Object]', '[object Array]'].includes(typeOfChildren)
        ) {
          extendedProps.children = this.children(props.children)
        }
      }

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

  /**
   * Checks if the children and help message are within the current viewport. If not, scrolls the
   * help message into view so that users can see the message without having to manually scroll.
   */
  scrollIntoView(ref) {
    if (!ref) {
      return
    }

    // Grab the bottom position for the help container
    const helpBottom = ref.getBoundingClientRect().bottom

    // Grab the current window height
    const winHeight = window.innerHeight

    // Flag if help container bottom is within current viewport
    const notInView = winHeight < helpBottom

    const active = this.state.helpActive || this.errors.some(x => x.valid === false)

    if (active && this.props.scrollIntoView && notInView) {
      window.scrollBy({
        top: helpBottom - winHeight,
        left: 0,
        behavior: 'smooth',
      })
    }
  }

  render() {
    const {
      optional, className, shrink, dataTestId,
    } = this.props

    const required = !optional

    const classes = classnames(
      'field',
      {
        required,
        'with-comments': this.visibleComments(),
      },
      className,
    )

    const componentClasses = classnames(
      'component',
      {
        shrink,
      },
    )

    /* eslint jsx-a11y/anchor-has-content: 0 */
    /* eslint jsx-a11y/anchor-is-valid: 0 */
    return (
      <div
        className={classes}
        data-uuid={this.state.uuid}
        ref={(el) => { this.field = el }}
        aria-label={this.props.title}
        {...(dataTestId && { 'data-test-id': dataTestId })}
      >
        <a
          id={this.state.uuid}
          name={this.state.uuid}
          className="anchor"
          aria-hidden="true"
        />
        {this.title(required)}
        <span className="icon">{this.icon()}</span>
        <div className="table expand">
          <span
            className="messages help-messages"
            ref={(el) => { this.helpMessageRef = el }}
            aria-live="polite"
          >

            {this.helpMessage()}
          </span>
        </div>
        <div className="table expand">
          <span
            className="messages error-messages"
            ref={(el) => { this.errorMessagesRef = el }}
            role="alert"
            aria-live="polite"
          >
            {this.errorMessages()}
          </span>
        </div>
        <div className="table">
          <span className="content">
            <span className={componentClasses}>
              {this.children(this.props.children)}
              {this.comments()}
              {this.commentsButton()}
            </span>
          </span>
        </div>
      </div>
    )
  }
}

Field.defaultProps = {
  title: '',
  titleSize: 'h4',
  className: '',
  errors: [],
  errorPrefix: '',
  help: '',
  helpActive: false,
  adjustFor: '',
  comments: false,
  commentsName: 'Comments',
  commentsValue: {},
  commentsActive: false,
  commentsAdd: 'comments.add',
  commentsRemove: 'comments.remove',
  commentsRequired: false,
  optional: false,
  optionalText: '',
  validate: true,
  shrink: false,
  scrollIntoView: true,
  dataTestId: '',
  filterErrors: errors => errors,
  onUpdate: () => {},
}