department-of-veterans-affairs/vets-website

View on GitHub
src/applications/pre-need-integration/components/AutosugggestField.jsx

Summary

Maintainability
F
1 wk
Test Coverage
import React from 'react';
import PropTypes from 'prop-types';
import Downshift from 'downshift';
import classNames from 'classnames';

import set from 'platform/utilities/data/set';
import debounce from 'platform/forms-system/src/js/utilities/data/debounce';
import sortListByFuzzyMatch from 'platform/forms-system/src/js/utilities/fuzzy-matching';
import escapeRegExp from 'platform/forms-system/src/js/utilities/data/escapeRegExp';
import { DesiredCemeteryNoteDescription } from '../utils/helpers';

const ESCAPE_KEY = 27;

function getInput(input, uiSchema, schema) {
  if (input && input.widget === 'autosuggest') {
    return input.label;
  }

  if (typeof input !== 'object' && input) {
    const uiOptions = uiSchema['ui:options'];
    // When using this field in an array item, editing the item will throw an error
    //  if there uiOptions.label is undefined (as when we queryForResults), so we
    //  have to have this safety valve
    if (!uiOptions.labels) {
      return input;
    }

    if (uiOptions.labels[input]) {
      return uiOptions.labels[input];
    }

    const index = schema.enum.indexOf(input) >= 0;
    if (schema.enumNames && index >= 0) {
      return uiOptions.labels[input] || schema.enumNames[index];
    }
  }

  return '';
}

export default class AutosuggestField extends React.Component {
  constructor(props) {
    super(props);
    const { uiSchema, schema, formData } = props;
    const input = getInput(formData, uiSchema, schema);
    const uiOptions = uiSchema['ui:options'];

    let options = [];
    let suggestions = [];

    if (!uiOptions.getOptions) {
      this.useEnum = true;
      options = schema.enum.map((id, index) => ({
        id,
        label: uiOptions.labels[id] || schema.enumNames[index],
      }));
      suggestions = this.getSuggestions(options, input);
    }

    const debounceRate =
      uiOptions.debounceRate === undefined ? 1000 : uiOptions.debounceRate;
    this.debouncedGetOptions = debounce(debounceRate, this.getOptions);

    this.state = {
      options,
      input,
      suggestions,
    };
  }

  componentDidMount() {
    if (!this.props.formContext.reviewMode) {
      this.getOptions();
    }
  }

  componentWillUnmount() {
    this.unmounted = true;
    this.debouncedGetOptions.cancel();
  }

  getOptions = inputValue => {
    const { getOptions } = this.props.uiSchema['ui:options'];
    if (getOptions) {
      getOptions(inputValue).then(this.setOptions);
    }
  };

  setOptions = options => {
    if (!this.unmounted) {
      const suggestions = this.getSuggestions(options, this.state.input);
      this.setState({
        options,
        suggestions,
      });

      if (this.state.input && this.state.input.length > 3) {
        const item = this.getItemFromInput(
          this.state.input,
          suggestions,
          this.props.uiSchema['ui:options'],
        );

        this.props.onChange(item);
      }
    }
  };

  getSuggestions = (options, value) => {
    if (value) {
      const uiOptions = this.props.uiSchema['ui:options'];
      return sortListByFuzzyMatch(value, options).slice(
        0,
        uiOptions.maxOptions,
      );
    }

    return options;
  };

  getFormData = suggestion => {
    if (this.useEnum) {
      return suggestion.id;
    }

    // When freeInput is true, we'll return the label to the api instead of the id
    if (this.props.uiSchema['ui:options'].freeInput) {
      return suggestion.label;
    }

    return set('widget', 'autosuggest', suggestion);
  };

  getItemFromInput = (inputValue, suggestions, uiOptions) => {
    let item = { widget: 'autosuggest', label: inputValue };
    // once the input is long enough, check for exactly matching strings so that we don't
    // force a user to click on an item when they've typed an exact match of a label
    if (inputValue && inputValue.length > 3) {
      const matchingItem = suggestions.find(
        suggestion => suggestion.label === inputValue,
      );
      if (matchingItem) {
        item = this.getFormData(matchingItem);
      }
    }

    const { freeInput, inputTransformers } = uiOptions;

    const inputToSave =
      inputTransformers &&
      Array.isArray(inputTransformers) &&
      inputTransformers.length
        ? inputTransformers.reduce(
            (userInput, transformer) => transformer(userInput),
            inputValue,
          )
        : inputValue;

    return freeInput || this.useEnum ? inputToSave : item;
  };

  handleInputValueChange = inputValue => {
    if (inputValue !== this.state.input) {
      const uiOptions = this.props.uiSchema['ui:options'];
      if (uiOptions.queryForResults) {
        this.debouncedGetOptions(inputValue);
      }

      const item = this.getItemFromInput(
        inputValue,
        this.state.suggestions,
        uiOptions,
      );

      this.props.onChange(item);
      this.setState({
        input: inputValue,
        suggestions: this.getSuggestions(this.state.options, inputValue),
      });
    } else if (inputValue === '') {
      this.props.onChange();
      this.setState({
        input: inputValue,
        suggestions: this.getSuggestions(this.state.options, inputValue),
      });
    }
  };

  handleChange = selectedItem => {
    const value = this.getFormData(selectedItem);
    this.props.onChange(value);
    if (this.state.input !== selectedItem.label) {
      this.setState({
        input: selectedItem.label,
      });
    }
  };

  handleKeyDown = event => {
    if (event.keyCode === ESCAPE_KEY) {
      this.setState({ input: '' });
    }
  };

  handleBlur = () => {
    this.props.onBlur(this.props.idSchema.$id);
  };

  render() {
    const { idSchema, formContext, formData, uiSchema, schema } = this.props;
    const id = idSchema.$id;

    // wrap matching text in a <span> element
    const highlightText = uiSchema['ui:options']?.highlightText ?? true;
    const { inputProps } = uiSchema['ui:options'];
    const value = this.state.input?.toLowerCase() || '';
    const caseInsensitiveMatch = new RegExp(`(${escapeRegExp(value)})`, 'i');
    const highLightMatchingText = query => {
      if (value.length > 2) {
        return query
          .split(caseInsensitiveMatch)
          .map(
            str =>
              str.toLowerCase() === value ? (
                <span className="vads-u-background-color--gold autosuggest-highlight">
                  {str}
                </span>
              ) : (
                str
              ),
          );
      }
      return query;
    };

    if (formContext.reviewMode) {
      const readOnlyData = <span>{getInput(formData, uiSchema, schema)}</span>;

      // If this is an non-object field then the label will
      // be included by ReviewFieldTemplate
      if (schema.type !== 'object') {
        return readOnlyData;
      }

      return (
        <div className="review-row">
          <dt>{this.props.uiSchema['ui:title']}</dt>
          <dd>{readOnlyData}</dd>
        </div>
      );
    }

    return (
      <>
        <Downshift
          onChange={this.handleChange}
          onInputValueChange={this.handleInputValueChange}
          inputValue={this.state.input}
          selectedItem={this.state.input}
          onOuterClick={this.handleBlur}
          itemToString={item => {
            if (typeof item === 'string') {
              return item;
            }

            return item.label;
          }}
          render={({
            getInputProps,
            getItemProps,
            isOpen,
            selectedItem,
            highlightedIndex,
          }) => (
            <div className="autosuggest-container">
              <input
                {...getInputProps({
                  autoComplete: 'off',
                  id,
                  name: id,
                  className: 'autosuggest-input',
                  onBlur: isOpen ? undefined : this.handleBlur,
                  onKeyDown: this.handleKeyDown,
                  ...inputProps,
                })}
              />
              {isOpen && (
                <div className="autosuggest-list" role="listbox">
                  {this.state.suggestions.map((item, index) => (
                    <div
                      {...getItemProps({ item })}
                      role="option"
                      aria-selected={
                        selectedItem === item.label ? 'true' : 'false'
                      }
                      className={classNames('autosuggest-item', {
                        'autosuggest-item-highlighted':
                          highlightedIndex === index,
                        'autosuggest-item-selected':
                          selectedItem === item.label,
                      })}
                      key={item.id}
                    >
                      {highlightText
                        ? highLightMatchingText(item.label)
                        : item.label}
                    </div>
                  ))}
                </div>
              )}
            </div>
          )}
        />
        <div className="desiredCemeteryNote">
          <div className="desiredCemeteryNoteTitle">
            <a
              href="https://www.va.gov/find-locations/"
              rel="noreferrer"
              target="_blank"
              className="desiredCemeteryNoteTitle"
            >
              Find a VA national cemetery (opens in a new tab)
            </a>
          </div>
          <div className="desiredCemeteryNoteDescription">
            <DesiredCemeteryNoteDescription />
          </div>
        </div>
      </>
    );
  }
}

AutosuggestField.propTypes = {
  schema: PropTypes.object.isRequired,
  onBlur: PropTypes.func.isRequired,
  onChange: PropTypes.func.isRequired,
  formContext: PropTypes.shape({
    reviewMode: PropTypes.bool,
  }),
  formData: PropTypes.object,
  idSchema: PropTypes.shape({
    $id: PropTypes.string,
  }),
  uiSchema: PropTypes.shape({
    'ui:options': PropTypes.shape({
      labels: PropTypes.object,
      getOptions: PropTypes.func,
      debounceRate: PropTypes.number,
      maxOptions: PropTypes.number,
      queryForResults: PropTypes.bool,
      freeInput: PropTypes.bool,
      inputProps: PropTypes.object,
      inputTransformers: PropTypes.arrayOf(PropTypes.func),
    }),
    'ui:title': PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
  }),
};