concord-consortium/rigse

View on GitHub
rails/react-components/src/library/components/search/auto-suggest.tsx

Summary

Maintainability
C
7 hrs
Test Coverage
import React from "react";
import { throttle, debounce } from "throttle-debounce";

import css from "./auto-suggest.scss";

class Suggestion extends React.Component<any, any> {
  render () {
    const { suggestion } = this.props;
    const onClick = () => this.props.onClick(suggestion);
    return <div className={css.suggestion} onClick={onClick}>{ suggestion }</div>;
  }
}

// adapted from https://www.peterbe.com/plog/how-to-throttle-and-debounce-an-autocomplete-input-in-react
export default class AutoSuggest extends React.Component<any, any> {
  containerRef: any;
  currentQuery: any;
  debouncedSearch: any;
  inputRef: any;
  queryCache: any;
  throttledSearch: any;
  constructor (props: any) {
    super(props);
    this.state = {
      query: props.query || "",
      suggestions: [],
      selectedSuggestionIndex: -1,
      showSuggestions: false
    };
    this.handleInputChange = this.handleInputChange.bind(this);
    this.handleKeyDown = this.handleKeyDown.bind(this);
    this.handleSuggestionClick = this.handleSuggestionClick.bind(this);
    this.handleOuterClick = this.handleOuterClick.bind(this);

    this.debouncedSearch = debounce(1000, this.search);
    this.throttledSearch = throttle(500, this.search);
    this.currentQuery = "";
    this.queryCache = {};
    this.inputRef = React.createRef();
    this.containerRef = React.createRef();
  }

  // eslint-disable-next-line camelcase
  UNSAFE_componentWillMount () {
    window.addEventListener("click", this.handleOuterClick);
    const { query } = this.state;
    if (query.length > 0) {
      this.search(query);
    }
  }

  componentWillUnmount () {
    window.removeEventListener("click", this.handleOuterClick);
  }

  handleOuterClick (e: any) {
    let el = e.target;
    const container = this.containerRef.current;
    if (container && this.state.showSuggestions) {
      while (el && (el !== container)) {
        el = el.parentNode;
      }
      if (!el) {
        this.setState({ showSuggestions: false });
      }
    }
  }

  // eslint-disable-next-line camelcase
  UNSAFE_componentWillReceiveProps (nextProps: any) {
    const { query, skipAutoSearch } = nextProps;
    if (query !== undefined) {
      // reset and hide the suggestions when the query is changed
      this.setState({ query, suggestions: [], selectedSuggestionIndex: -1, showSuggestions: false }, () => {
        if (!skipAutoSearch && (query.length > 0)) {
          this.search(query);
        }
      });
    }
  }

  search (query: any) {
    const setSuggestions = (suggestions: any, callback?: any) => {
      const showSuggestions = suggestions.length > 0;
      this.setState({ suggestions, selectedSuggestionIndex: -1, showSuggestions }, callback);
    };
    const trimmedQuery = query.trim();
    this.currentQuery = trimmedQuery;
    if (trimmedQuery.length === 0) {
      setSuggestions([]);
    } else {
      const { getQueryParams } = this.props;
      const queryParams = getQueryParams ? (getQueryParams() || "").replace(/search_term=([^&]*&?)/, "") : "";
      const data = `search_term=${encodeURIComponent(trimmedQuery)}${queryParams.length > 0 ? `&${queryParams}` : ""}`;

      if (this.queryCache[data]) {
        setSuggestions(this.queryCache[data]);
      } else {
        setSuggestions([], () => {
          jQuery.ajax({
            url: "/api/v1/search/search_suggestions",
            data,
            dataType: "json",
            success: results => {
              this.queryCache[results.search_term] = results.suggestions;
              if (results.search_term === this.currentQuery) {
                setSuggestions(results.suggestions);
              }
            },
            error: () => {
              console.error("GET search suggestions failed");
            }
          });
        });
      }
    }
  }

  userInitiatedSearch (query: any, onHandler: any) {
    this.setState({ query }, () => {
      if (onHandler) {
        onHandler(query);
      }
      if ((query.length < 5) || query.endsWith(" ")) {
        this.throttledSearch(query);
      } else {
        this.debouncedSearch(query);
      }
    });
  }

  handleSuggestionClick (query: any) {
    this.setState({ showSuggestions: false }, () => this.userInitiatedSearch(query, this.props.onSubmit));
  }

  handleInputChange (e: any) {
    this.userInitiatedSearch(e.target.value, this.props.onChange);
  }

  handleKeyDown (e: any) {
    let handledKey = false;
    const { query, suggestions, selectedSuggestionIndex, showSuggestions } = this.state;
    const { onChange, onSubmit } = this.props;
    const suggestion = suggestions[selectedSuggestionIndex];
    const hasSuggestionSelected = suggestion !== undefined;

    switch (e.keyCode) {
      case 13: // enter
        if (showSuggestions && hasSuggestionSelected) {
          this.setState({ query: suggestion, showSuggestions: false, selectedSuggestionIndex: -1 }, () => {
            if (onChange) {
              onChange(suggestion);
            }
            if (onSubmit) {
              onSubmit(suggestion);
            }
          });
          handledKey = true;
        } else if (onSubmit) {
          onSubmit(query);
          handledKey = true;
        }
        break;
      case 27: // escape
        if (showSuggestions) {
          this.setState({ showSuggestions: false, selectedSuggestionIndex: -1 });
          handledKey = true;
        }
        break;
      case 38: // up arrow
        if (showSuggestions) {
          if (selectedSuggestionIndex > 0) {
            const index = selectedSuggestionIndex - 1;
            this.setState({ selectedSuggestionIndex: index, query: suggestions[index] });
          } else {
            this.setState({ selectedSuggestionIndex: -1, showSuggestions: false });
          }
          handledKey = true;
        }
        break;
      case 40: // down arrow
        if (showSuggestions) {
          if (selectedSuggestionIndex < suggestions.length - 1) {
            const index = selectedSuggestionIndex + 1;
            this.setState({ selectedSuggestionIndex: index, query: suggestions[index] });
            handledKey = true;
          }
        } else if (suggestions.length > 0) {
          this.setState({ selectedSuggestionIndex: 0, showSuggestions: true });
          handledKey = true;
        }
        break;
    }

    if (handledKey) {
      e.preventDefault();
      e.stopPropagation();
    }
  }

  renderSuggestions () {
    const { suggestions, showSuggestions } = this.state;

    if (!showSuggestions || (suggestions.length === 0)) {
      return undefined;
    }

    const items = suggestions.map((suggestion: any, index: any) => {
      return <Suggestion key={suggestion} suggestion={suggestion} onClick={this.handleSuggestionClick} />;
    });

    let style = {};
    if (this.inputRef.current) {
      const width = this.inputRef.current.getBoundingClientRect().width;
      style = { width };
    }

    return (
      <div id="suggestions" className={css.suggestions} style={style}>
        { items }
      </div>
    );
  }

  render () {
    const { name, placeholder, id } = this.props;

    return (
      <div className={css.autoSuggest} ref={this.containerRef}>
        <input
          id={id || undefined}
          ref={this.inputRef}
          name={name || undefined}
          placeholder={placeholder}
          type="text"
          autoComplete="off"
          value={this.state.query}
          onChange={this.handleInputChange}
          onKeyDown={this.handleKeyDown}
        />
        <input
          id={css.keywordSubmit}
          type="submit"
          name="keywordSubmit"
          value="Go"
          onKeyDown={this.handleKeyDown}
          onClick={this.handleKeyDown}
        />
        { this.renderSuggestions() }
      </div>
    );
  }
}