sharetribe/sharetribe

View on GitHub
client/app/components/composites/SearchBar/SearchBar.js

Summary

Maintainability
C
1 day
Test Coverage
/* eslint-disable react/no-set-state */

import { Component } from 'react';
import PropTypes from 'prop-types';
import { form, input, button, span, div } from 'r-dom';
import * as placesUtils from '../../../utils/places';
import variables from '../../../assets/styles/variables';

import css from './SearchBar.css';
import icon from './images/search-icon.svg';

const SEARCH_MODE_KEYWORD = 'keyword';
const SEARCH_MODE_LOCATION = 'location';
const SEARCH_MODE_KEYWORD_AND_LOCATION = 'keyword_and_location';
const SEARCH_MODES = [
  SEARCH_MODE_KEYWORD,
  SEARCH_MODE_LOCATION,
  SEARCH_MODE_KEYWORD_AND_LOCATION,
];

class SearchBar extends Component {
  constructor(props) {
    super(props);

    // Bind `this` within methods
    this.handleSubmit = this.handleSubmit.bind(this);
    this.handleResize = this.handleResize.bind(this);

    this.state = {
      selectedPlace: null,
      mobileSearchOpen: false,
    };
  }
  componentDidMount() {
    document.body.classList.add(css.topLevelBody);
    window.addEventListener('resize', this.handleResize);

    if (!this.locationInput) {
      return;
    }
    const bounds = { north: -90, east: -180, south: 90, west: 180 };
    if (window.google) {
      const autocomplete = new window.google.maps.places.Autocomplete(this.locationInput, { bounds });
      autocomplete.setTypes(['geocode']);
      autocomplete.setFields(['address_components', 'geometry', 'icon', 'name']);
      this.placeChangedListener = window.google.maps.event.addListener(
        autocomplete,
        'place_changed',
        () => {
          this.setState({ selectedPlace: autocomplete.getPlace() });
        }
      );
    }
  }
  componentWillUnmount() {
    document.body.classList.remove(css.topLevelBody);
    window.removeEventListener('resize', this.handleResize);

    if (this.placeChangedListener) {
      this.placeChangedListener.remove();
    }

    // Clean up the generated autocompletion UI. Assumes there is only one.
    const container = document.body.querySelector('.pac-container');

    if (container) {
      document.body.removeChild(container);
    }
  }
  handleResize() {

    // We need to remove mobileSearchOpen state to get correct mode
    // when resizing browser window
    const searchFormBreakpoint = variables['--breakpointLarge'];
    if (window.matchMedia(`(min-width: ${searchFormBreakpoint}px)`).matches) {
      this.setState({ mobileSearchOpen: false });
    }
  }
  handleSubmit() {
    if (!this.keywordInput && !this.locationInput) {
      throw new Error('No input refs saved to submit SearchBar form');
    }

    this.setState({ mobileSearchOpen: false });

    const keywordValueStr = this.keywordInput ? this.keywordInput.value.trim() : '';
    const locationValueStr = this.locationInput ? this.locationInput.value.trim() : '';

    const onSubmit = this.props.onSubmit;

    if (!this.locationInput) {
      // Only keyword input, submitting current input value
      onSubmit({
        keywordQuery: keywordValueStr,
        locationQuery: null,
        place: null,
      });
      return;
    }

    const keywordValue = this.keywordInput ? this.keywordInput.value : null;

    if (this.state.selectedPlace && this.state.selectedPlace.geometry) {
      // Place already selected, submitting selected value
      onSubmit({
        keywordQuery: keywordValue,
        locationQuery: locationValueStr,
        place: this.state.selectedPlace,
      });
    } else if (locationValueStr) {
      // Predict location from the typed value
      placesUtils.getPrediction(locationValueStr)
        .then((place) => {
          onSubmit({
            keywordQuery: keywordValue,
            locationQuery: locationValueStr,
            place,
          });
        })
        .catch((e) => {
          console.error('failed to predict location:', e); // eslint-disable-line no-console
          onSubmit({
            keywordQuery: keywordValue,
            locationQuery: locationValueStr,
            place: null,
            errorStatus: e.serviceStatus,
          });
        });
    } else {
      // Only keyword value present, submit that
      onSubmit({
        keywordQuery: keywordValue,
        locationQuery: '',
        place: null,
      });
    }
  }
  render() {
    const { mode, keywordPlaceholder, locationPlaceholder, keywordQuery, locationQuery } = this.props;

    // Custom color support disabled for now until further discussion.
    // const bgColor = customColor || variables['--SearchBar_mobileBackgroundColor'];
    // const bgColorDarkened = brightness(bgColor, 80);
    const bgColor = '#34495E';
    const bgColorDarkened = '#2C3E50 ';

    const keywordInput = input({
      type: 'search',
      className: css.keywordInput,
      placeholder: keywordPlaceholder,
      defaultValue: keywordQuery,
      ref: (c) => {
        this.keywordInput = c;
      },
    });
    const locationInput = input({
      type: 'search',
      className: css.locationInput,
      placeholder: locationPlaceholder,
      defaultValue: locationQuery,
      autoComplete: 'off',

      // When the user edits the selected location value, the fetched
      // place object is not up to date anymore and has to be cleared.
      onChange: () => this.setState({ selectedPlace: null }),

      ref: (c) => {
        this.locationInput = c;
      },

    });

    const hasKeywordInput = mode === SEARCH_MODE_KEYWORD || mode === SEARCH_MODE_KEYWORD_AND_LOCATION;
    const hasLocationInput = mode === SEARCH_MODE_LOCATION || mode === SEARCH_MODE_KEYWORD_AND_LOCATION;

    // Ugly, but we have to add the class to body since the Google
    // Maps Places Autocomplete .pac-container is within the body
    // element.
    if (typeof document === 'object' && document.body) {
      if (this.state.mobileSearchOpen) {
        if (!document.body.classList.contains(css.mobileSearchOpen)) {
          document.body.classList.add(css.mobileSearchOpen);
        }
      } else if (document.body.classList.contains(css.mobileSearchOpen)) {
        document.body.classList.remove(css.mobileSearchOpen);
      }
    }

    return div({
      className: css.root,
      classSet: {
        [css.root]: true,
        [css.mobileSearchOpen]: this.state.mobileSearchOpen,
      },
    }, [
      button({
        className: css.mobileToggle,
        onClick: () => this.setState({ mobileSearchOpen: !this.state.mobileSearchOpen }),
      }, [
        div({
          dangerouslySetInnerHTML: { __html: icon },
        }),
        span({
          className: css.mobileToggleArrow,
          style: {
            borderBottomColor: this.state.mobileSearchOpen ? bgColor : 'transparent',
          },
        }),
      ]),
      form({
        classSet: { [css.form]: true },
        onSubmit: (e) => {
          e.preventDefault();
          this.handleSubmit();
        },
        style: {
          backgroundColor: this.state.mobileSearchOpen ? bgColor : 'transparent',
        },
      }, [
        hasKeywordInput ? keywordInput : null,
        hasLocationInput ? locationInput : null,
        button({
          type: 'submit',
          className: css.searchButton,
          dangerouslySetInnerHTML: { __html: icon },
          style: {
            backgroundColor: this.state.mobileSearchOpen ? bgColorDarkened : 'transparent',
          },
        }),
        span({ className: css.focusContainer }),
      ]),
    ]);
  }
}

SearchBar.propTypes = {
  mode: PropTypes.oneOf(SEARCH_MODES).isRequired,
  keywordPlaceholder: PropTypes.string.isRequired,
  locationPlaceholder: PropTypes.string.isRequired,
  keywordQuery: PropTypes.string,
  locationQuery: PropTypes.string,
  onSubmit: PropTypes.func.isRequired,
  customColor: PropTypes.string,
};

export default SearchBar;