department-of-veterans-affairs/vets-website

View on GitHub
src/applications/gi/containers/search/LocationSearchResults.jsx

Summary

Maintainability
F
4 days
Test Coverage
/* eslint-disable react/jsx-no-bind */
/* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable react/prop-types */
/* eslint-disable jsx-a11y/no-noninteractive-tabindex */

import environment from 'platform/utilities/environment';
import React, { useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import 'mapbox-gl/dist/mapbox-gl.css';
import mapboxgl from 'mapbox-gl';
import { focusElement, getScrollOptions } from 'platform/utilities/ui';
import { connect } from 'react-redux';
import classNames from 'classnames';
import scrollTo from 'platform/utilities/ui/scrollTo';
import recordEvent from 'platform/monitoring/record-event';
import { VaPagination } from '@department-of-veterans-affairs/component-library/dist/react-bindings';
import ResultCard from './ResultCard';
import { mapboxToken } from '../../utils/mapboxToken';
import { MapboxInit, MAX_SEARCH_AREA_DISTANCE, TABS } from '../../constants';
import TuitionAndHousingEstimates from '../TuitionAndHousingEstimates';
import FilterYourResults from '../FilterYourResults';
import { createId } from '../../utils/helpers';
import {
  fetchSearchByLocationCoords,
  updateEligibilityAndFilters,
  mapChanged,
} from '../../actions';
import { getFiltersChanged } from '../../selectors/filters';
import MobileFilterControls from '../../components/MobileFilterControls';
// import FilterByLocation from './FilterByLocation';

const MILE_METER_CONVERSION_RATE = 1609.34;
const LIST_TAB = 'List';
const MAP_TAB = 'Map';

function LocationSearchResults({
  search,
  filters,
  preview,
  dispatchUpdateEligibilityAndFilters,
  dispatchFetchSearchByLocationCoords,
  filtersChanged,
  smallScreen,
  landscape,
  dispatchMapChanged,
}) {
  const { inProgress } = search;
  const { count, results } = search.location;
  const { location, streetAddress } = search.query;
  const map = useRef(null);
  const [markers, setMarkers] = useState([]);
  const [mapState, setMapState] = useState({ changed: false, distance: null });
  const [usedFilters, setUsedFilters] = useState(filtersChanged);
  const [cardResults, setCardResults] = useState(null);
  const [dataReturned, setDataReturned] = useState(null);
  const [mobileTab, setMobileTab] = useState(LIST_TAB);
  const [markerClicked, setMarkerClicked] = useState(null);
  const [activeMarker, setActiveMarker] = useState(null);
  const [myLocation, setMyLocation] = useState(null);
  const MAX_PAGE_LIST_LENGTH = 10;
  const paginationRef = useRef(null);

  const [pagination, setPagination] = useState({
    currentPage: 1,
    totalPages: 0,
  });

  // set Total Pages and skip map when hit tap

  useEffect(
    () => {
      setPagination(prevState => ({
        ...prevState,
        totalPages: Math.ceil(cardResults?.length / MAX_PAGE_LIST_LENGTH),
      }));
      if (cardResults?.length > 0) {
        document.querySelector('canvas.mapboxgl-canvas').tabIndex = '-1';
        document.querySelector('button.mapboxgl-ctrl-zoom-in').tabIndex = '-1';
        document.querySelector('button.mapboxgl-ctrl-zoom-out').tabIndex = '-1';
        const mapMarkers = [
          ...document.getElementsByClassName('mapboxgl-marker'),
        ];
        mapMarkers.forEach(marker => {
          const newMarker = marker;
          newMarker.tabIndex = '-1';
        });
      }
    },
    [cardResults],
  );
  const startIdx = (pagination.currentPage - 1) * MAX_PAGE_LIST_LENGTH;
  const endIdx = pagination.currentPage * MAX_PAGE_LIST_LENGTH;
  const paginatedRenewablePrescriptions = cardResults?.slice(startIdx, endIdx);

  const onPageChange = page => {
    setPagination(prevState => ({
      ...prevState,
      currentPage: page,
    }));
  };
  const usingUserLocation = () => {
    const currentPositions = document.getElementsByClassName(
      'current-position',
    );

    if (currentPositions.length === 0) return false;
    if (myLocation === null) setMyLocation(search.query.location);
    if (search.query.location !== myLocation) return false;

    return true;
  };

  /**
   * When map is moved update distance from center to NorthEast corner
   */
  const updateMapState = () => {
    const mapBounds = map.current.getBounds();
    const newMapState = {
      distance:
        mapBounds.getNorthEast().distanceTo(mapBounds.getCenter()) /
        MILE_METER_CONVERSION_RATE,
      changed: true,
    };
    dispatchMapChanged(newMapState);
  };

  /**
   * When LocationSearchForm triggers a search it will set the value of changed to false disabling behavior
   * related to "Search this area of the map"
   */
  useEffect(
    () => {
      setMapState(search.query.mapState);
    },
    [search.query.mapState],
  );

  // This Effect is to change the style of the pagination
  useEffect(
    () => {
      document.querySelector('va-pagination')?.shadowRoot.append(
        Object.assign(document.createElement('STYLE'), {
          innerText: `nav ul li.va-pagination__item:not(:has(a.usa-current)) {
                                              display:none
                                            }`,
        }),
      );
    },
    [pagination],
  );

  /**
   * Initialize map if the element is present
   */
  const setupMap = () => {
    if (map.current) return; // initialize map only once
    const container = document.getElementById('mapbox-gl-container');
    if (!container) return;

    mapboxgl.accessToken = mapboxToken;

    const mapInit = new mapboxgl.Map({
      container: 'mapbox-gl-container',
      style: 'mapbox://styles/mapbox/outdoors-v11',
      center: [MapboxInit.centerInit.longitude, MapboxInit.centerInit.latitude],
      zoom: MapboxInit.zoomInit,
    });

    mapInit.addControl(
      new mapboxgl.NavigationControl({
        // Hide rotation control.
        showCompass: false,
      }),
      'top-left',
    );

    // Remove mapbox logo from tab order
    const mapBoxLogo = document.querySelector(
      'a.mapboxgl-ctrl-logo.mapboxgl-compact',
    );
    if (mapBoxLogo) mapBoxLogo.setAttribute('tabIndex', -1);

    mapInit.on('load', () => {
      mapInit.resize();
    });

    mapInit.on('dragstart', () => {
      updateMapState();
    });

    mapInit.on('zoomend', e => {
      // Only trigger mapMoved and speakZoom for manual events,
      // e.g. zoom in/out button click, mouse wheel, etc.
      // which will have an originalEvent defined
      if (!e.originalEvent) {
        return;
      }

      updateMapState();
    });

    mapInit.on('dblclick', _e => {
      updateMapState();
    });

    map.current = mapInit;
  };

  /**
   * Initialize the map on load and if the mobileTab changes
   */
  useEffect(
    () => {
      setupMap();
    },
    [mobileTab],
  );

  /**
   * Used to exclude results from appearing in cards or as a marker when using "Search this area of the map" button
   *
   * @param institution
   * @return {boolean}
   */
  const markerIsVisible = institution => {
    const { latitude, longitude } = institution;
    const lngLat = new mapboxgl.LngLat(longitude, latitude);
    return (
      smallScreen ||
      landscape ||
      !mapState.changed ||
      map.current.getBounds().contains(lngLat)
    );
  };

  /**
   * Called when during the resulting action of clicking on a map marker either on desktop or smallScreen
   * Scrolls to the search result card within the Search results and collapses eligibility and filters accordions if
   * expanded
   * @param name
   */
  const mapMarkerClicked = name => {
    const locationSearchResults = document.getElementById(
      'location-search-results-container',
    );
    const targetElement = document.getElementById(
      `${createId(name)}-result-card-placeholder`,
    );
    const containerOffsetTop = locationSearchResults.getBoundingClientRect()
      .top;
    const targetOffsetTop = targetElement.getBoundingClientRect().top;
    scrollTo(
      `${createId(name)}-result-card-placeholder`,
      getScrollOptions({
        containerId: 'location-search-results-container',
        top:
          targetOffsetTop -
          containerOffsetTop +
          locationSearchResults.scrollTop,
      }),
    );
    setActiveMarker(name);
    dispatchUpdateEligibilityAndFilters(
      { expanded: false },
      { expanded: false },
    );
  };

  /**
   * Used when a map marker is clicked
   * Using a useEffect since on smallScreen need to switch tabs first before scrolling to search result card
   * Both desktop and mobile will trigger this useEffect
   */
  useEffect(
    () => {
      if (markerClicked && (!smallScreen || mobileTab === LIST_TAB)) {
        mapMarkerClicked(markerClicked);
        setMarkerClicked(null);
        recordEvent({
          event: 'map-pin-click',
          'map-location': markerClicked,
        });
      }
    },
    [markerClicked],
  );

  /**
   * Adds a map marker to the map and includes in a LngLatBounds object if provided
   * Sets the map marker to have a "on click" event that scrolls to the corresponding result card
   * @param institution
   * @param index
   * @param locationBounds
   * @param mapMarkers
   */
  const addMapMarker = (institution, index, locationBounds, mapMarkers) => {
    const { latitude, longitude, name } = institution;
    const lngLat = new mapboxgl.LngLat(longitude, latitude);

    const markerElement = document.createElement('div');
    markerElement.className = 'location-letter-marker';
    markerElement.innerText = index + 1;

    const currentPage = Math.ceil((index + 1) / MAX_PAGE_LIST_LENGTH);
    markerElement.addEventListener('click', () => {
      setPagination(prev => {
        return {
          ...prev,
          currentPage,
        };
      });
    });
    const popup = new mapboxgl.Popup();
    popup.on('open', () => {
      if (smallScreen || landscape) {
        setMobileTab(LIST_TAB);
      }
      setMarkerClicked(name);
    });

    if (locationBounds) {
      locationBounds.extend(lngLat);
    }

    new mapboxgl.Marker(markerElement)
      .setLngLat([longitude, latitude])
      .setPopup(popup)
      .addTo(map.current);

    mapMarkers.push(markerElement);
  };

  /**
   * Adds a map marker if user used "Find my location"
   * @param bounds
   */
  const currentLocationMapMarker = bounds => {
    if (!streetAddress.position.longitude || !streetAddress.position.latitude)
      return;

    const currentMarkerElement = document.createElement('div');
    currentMarkerElement.className = 'current-position';
    new mapboxgl.Marker(currentMarkerElement)
      .setLngLat([
        streetAddress.position.longitude,
        streetAddress.position.latitude,
      ])
      .addTo(map.current);
    bounds.extend([
      streetAddress.position.longitude,
      streetAddress.position.latitude,
    ]);
    markers.push(currentMarkerElement);
  };
  // This useEffect to scroll to the top and focus the first card of each page
  useEffect(
    () => {
      if (paginationRef.current?.[pagination.currentPage]) {
        paginationRef.current?.[pagination.currentPage].scrollIntoView({
          behavior: 'smooth',
        });
        paginationRef.current[pagination.currentPage].focus();
      }
    },
    [pagination.currentPage],
  );
  /**
   * Takes results and puts them on the map
   * Excludes results that are not visible on the map when using "Search this area of the map"
   */
  useEffect(
    () => {
      if (smallScreen || landscape) {
        map.current = null;
      }
      setupMap();
      markers.forEach(marker => marker.remove());
      setActiveMarker(null);

      let visibleResults = [];
      const mapMarkers = [];

      if (smallScreen || landscape) {
        visibleResults = results;
      }

      // reset map if no results found
      if (map.current && results.length === 0 && !mapState.changed) {
        map.current.setCenter([
          MapboxInit.centerInit.longitude,
          MapboxInit.centerInit.latitude,
        ]);
        map.current.zoomTo(MapboxInit.zoomInit, { duration: 300 });
      }

      // wait for map to initialize or no results are returned
      if (!map.current || results.length === 0) {
        setUsedFilters(getFiltersChanged(filters));
        setCardResults(visibleResults);
        setMarkers(mapMarkers);
        return;
      }

      const locationBounds = !mapState.changed
        ? new mapboxgl.LngLatBounds()
        : null;

      visibleResults = results.filter(institution =>
        markerIsVisible(institution),
      );
      visibleResults.forEach((institution, index) =>
        addMapMarker(institution, index, locationBounds, mapMarkers),
      );

      if (locationBounds) {
        if (
          location &&
          location !== '' &&
          streetAddress.searchString &&
          streetAddress.searchString !== '' &&
          streetAddress.searchString === location
        ) {
          currentLocationMapMarker(locationBounds);
        }
        map.current.fitBounds(locationBounds, { padding: 20 });
      }

      setDataReturned(true);
      setCardResults(visibleResults);
      setUsedFilters(getFiltersChanged(filters));
      setMarkers(mapMarkers);
    },
    [results, smallScreen, landscape, mobileTab],
  );
  /**
   * Creates result cards for display
   */
  const resultCards = paginatedRenewablePrescriptions?.map(
    (institution, index) => {
      const { distance, name } = institution;
      const miles = Number.parseFloat(distance).toFixed(2);
      const { currentPage } = pagination;

      const cardNumber = (currentPage - 1) * MAX_PAGE_LIST_LENGTH + index + 1;
      const header = (
        <div
          className="location-header vads-u-display--flex vads-u-padding-top--1 vads-u-padding-bottom--2"
          id="cards-container"
        >
          <span className="location-letter vads-u-font-size--sm">
            {cardNumber}
          </span>
          {usingUserLocation() && (
            <span className="vads-u-padding-x--0p5 vads-u-font-size--sm">
              <strong>{miles} miles</strong>
            </span>
          )}
        </div>
      );

      return (
        <ResultCard
          institution={institution}
          location
          header={header}
          active={activeMarker === name}
          version={preview.version}
          key={institution.facilityCode}
          paginationRef={el => {
            if (index === 0) {
              if (!paginationRef.current) {
                paginationRef.current = [];
              }
              paginationRef.current[pagination.currentPage] = el;
            }
          }}
        />
      );
    },
  );

  /**
   * Called when user uses "Search this area of the map"
   * @param e
   */
  const searchArea = e => {
    if (e) {
      e.preventDefault();
    }
    updateMapState();
    recordEvent({
      event: `Search this area of map clicked`,
    });
    dispatchFetchSearchByLocationCoords(
      search.query.location,
      map.current.getCenter().toArray(),
      mapState.distance,
      filters,
      preview.version,
    );
  };

  /**
   * Triggers a search for "Search this area of the map" when the "Update results" button in "Filter your results"
   * is clicked
   */
  useEffect(
    () => {
      if (
        !search.loadFromUrl &&
        filters.search &&
        search.tab === TABS.location &&
        search.query.mapState.changed
      ) {
        searchArea(null);
      }
    },
    [filters.search],
  );

  useEffect(
    () => {
      focusElement('#location-search-results-count');
      // Avoid blank searches or double events
      if (location && count !== null) {
        recordEvent({
          event: 'view_search_results',
          'search-page-path': document.location.pathname,
          'search-query': '[redacted]',
          'search-results-total-count': count,
          'search-results-total-pages': undefined,
          'search-selection': 'GIBCT',
          'search-typeahead-enabled': false,
          'search-location': 'Location',
          'sitewide-search-app-used': false,
          'type-ahead-option-keyword-selected': undefined,
          'type-ahead-option-position': undefined,
          'type-ahead-options-list': undefined,
          'type-ahead-options-count': undefined,
        });
      }
    },
    [results],
  );

  /**
   * Renders the Eligibility and Filters accordions/buttons
   * @type {function(JSX.Element): (*|null)}
   */
  const eligibilityAndFilters = cnt => {
    const showTuitionAndFilters = cnt > 0 || usedFilters;

    if (showTuitionAndFilters) {
      return (
        <>
          {!smallScreen && (
            <>
              <TuitionAndHousingEstimates />
              {environment.isProduction() && (
                <FilterYourResults searchType="location" />
              )}
              {!environment.isProduction() && (
                <FilterYourResults searchType="location" />
              )}
              {/* {!environment.isProduction() && <FilterByLocation />} */}
            </>
          )}
          {environment.isProduction()
            ? (smallScreen || landscape) && (
                <MobileFilterControls className="vads-u-margin-top--2" />
              )
            : smallScreen &&
              !landscape &&
              results.length > 0 && (
                <MobileFilterControls className="vads-u-margin-top--2" />
              )}
        </>
      );
    }
    return null;
  };

  /**
   * Content for when no results are found with or without the use of filters
   * smallScreen count is different from desktop count
   * @param cnt
   * @return {JSX.Element}
   */
  const noResultsFound = cnt => {
    const noResultsNoFilters = cnt === 0 && !usedFilters;
    const noResultsWithFilters = cnt === 0 && usedFilters;

    return (
      <>
        {noResultsNoFilters && (
          <div>
            <p>We didn’t find any institutions based on your search.</p>
            <p>
              <strong>For better results:</strong>
            </p>
            <ul>
              <li>
                <strong>Zoom in or out</strong> to view a different area of the
                map, or
              </li>
              <li>
                <strong>Move the map</strong> to a different area
              </li>
            </ul>
            <p>
              Then click the <strong>"Search this area of map"</strong> button.
            </p>
            <p>
              If we still haven’t found any facilities near you,{' '}
              <strong>please enter a different search term</strong> (street,
              city, state, or postal code).
            </p>
          </div>
        )}
        {noResultsWithFilters && (
          <div>
            We didn’t find any institutions near this location based on the
            filters you’ve applied. Please update the filters and search again.
          </div>
        )}
      </>
    );
  };

  /**
   * smallScreen tabs for List and Map views
   * @param tabName
   * @return {JSX.Element}
   */
  const getTab = tabName => {
    const activeTab = tabName === mobileTab;
    const tabClasses = classNames(
      {
        'active-results-tab': activeTab,
        'vads-u-color--gray-dark': activeTab,
        'vads-u-background-color--white': activeTab,
        'inactive-results-tab': !activeTab,
        'vads-u-color--gray-medium': !activeTab,
        'vads-u-background-color--gray-light-alt': !activeTab,
      },
      'vads-u-font-family--sans',
      'vads-u-flex--1',
      'vads-u-text-align--center',
      'vads-l-grid-container',
      'vads-u-padding-y--1',
      `${tabName.toLowerCase()}-results-tab`,
    );

    return (
      <div
        className={tabClasses}
        onClick={() => setMobileTab(tabName)}
        onKeyPress={() => setMobileTab(tabName)}
        tabIndex={-1}
        role="button"
      >
        View {tabName}
      </div>
    );
  };

  /**
   * Content for how many search results are showing
   * smallScreen count is different from desktop count
   * @param cnt
   * @return {JSX.Element}
   */
  const searchResultsShowing = cnt => (
    <p id="location-search-results-count">
      Showing {cnt} search results for "<strong>{location}</strong>"
    </p>
  );

  /**
   * Renders the result cards
   * smallScreen count is different from desktop count
   * @param cnt
   * @param visible
   * @return {boolean|JSX.Element}
   */
  const searchResults = (cnt, visible = true) => {
    if (cnt > 0) {
      const containerClassNames = classNames(
        'location-search-results-container',
        'usa-grid',
        'vads-u-padding--1p5',
        {
          'vads-u-display--none': !visible,
          'vads-u-flex-wrap--wrap': !smallScreen,
        },
      );

      return (
        <div
          id="location-search-results-container"
          className={containerClassNames}
        >
          <h2 className="sr-only">Search results</h2>
          {resultCards}
          <div>
            <VaPagination
              className="vads-u-border-top--0 location-pagination"
              onPageSelect={e => onPageChange(e.detail.page)}
              page={pagination.currentPage}
              pages={pagination.totalPages}
              unbounded
              uswds
              maxPageListLength={5}
              showLastPage
            />
          </div>
        </div>
      );
    }
    return null;
  };

  const areaSearchWithinBounds = mapState.distance <= MAX_SEARCH_AREA_DISTANCE;
  const areaSearchLabel = areaSearchWithinBounds
    ? 'Search this area of the map'
    : 'Zoom in to search';

  /**
   * Creates the map element container and if not on smallScreen the areaSearch button
   * @type {function(JSX.Element=): *}
   */
  const mapElement = (visible = true) => {
    const containerClassNames = classNames({
      'vads-u-display--none': !visible,
    });
    const isMobileDevice = smallScreen || landscape;
    return (
      <div
        tabIndex="-1"
        role="region"
        className={containerClassNames}
        aria-label="Find VA locations on an interactive map. Tab again to interact with map"
      >
        <map
          id="mapbox-gl-container"
          className="desktop-map-container"
          role="region"
        >
          {mapState.changed &&
            !isMobileDevice && (
              <div
                id="search-area-control-container"
                className="mapboxgl-ctrl-top-center"
              >
                <button
                  type="button"
                  id="search-area-control"
                  className="usa-button"
                  onClick={searchArea}
                  disabled={!areaSearchWithinBounds}
                >
                  {areaSearchLabel}
                </button>
              </div>
            )}
        </map>
      </div>
    );
  };

  const hasSearchLatLong = search.query.latitude && search.query.longitude;

  // Results shouldn't be filtered out on mobile because "Search this area of the map" is disabled
  const smallScreenCount = search.location.count;

  // returns content ordered and setup for smallScreens
  if (smallScreen || landscape) {
    return (
      <div className="location-search vads-u-padding--1">
        {inProgress && (
          <va-loading-indicator message="Loading search results..." />
        )}
        {!inProgress && (
          <>
            <div>
              {eligibilityAndFilters(smallScreenCount)}
              {noResultsFound(smallScreenCount)}
            </div>
            {smallScreenCount > 0 && (
              <>
                <div className="vads-u-font-size--base vads-u-padding-top--1p5">
                  {searchResultsShowing(smallScreenCount)}
                </div>
                <div className="vads-u-display--flex tab-form">
                  {getTab(LIST_TAB)}
                  {getTab(MAP_TAB)}
                </div>
                <hr className="vads-u-margin-y--1p5" />
                {searchResults(smallScreenCount, mobileTab === LIST_TAB)}
                {mapElement(mobileTab === MAP_TAB)}
              </>
            )}
          </>
        )}
      </div>
    );
  }

  // Only needed on desktop as can do "Search this area of the map" which causes differences in count between what is
  // returned and what is visible
  const desktopCount = dataReturned ? cardResults.length : 0;

  // Returns content setup for desktop screens
  return (
    <div className="location-search vads-u-padding-top--1">
      <div className="usa-width-one-third">
        &nbsp;
        {inProgress && (
          <va-loading-indicator message="Loading search results..." />
        )}
        {!inProgress && (
          <>
            {!hasSearchLatLong && (
              <div>
                Please enter a location (street, city, state, or postal code)
                then click search above to find institutions.
              </div>
            )}
            {hasSearchLatLong && (
              <>
                {dataReturned && searchResultsShowing(desktopCount)}
                {eligibilityAndFilters(desktopCount)}
                {searchResults(desktopCount)}
                {noResultsFound(desktopCount)}
              </>
            )}
          </>
        )}
      </div>

      <div className="usa-width-two-thirds">{mapElement()}</div>
    </div>
  );
}

const mapStateToProps = state => ({
  search: state.search,
  filters: state.filters,
  preview: state.preview,
  filtersChanged: getFiltersChanged(state.filters),
});

const mapDispatchToProps = {
  dispatchUpdateEligibilityAndFilters: updateEligibilityAndFilters,
  dispatchFetchSearchByLocationCoords: fetchSearchByLocationCoords,
  dispatchMapChanged: mapChanged,
};

LocationSearchResults.propTypes = {
  dispatchFetchSearchByLocationCoords: PropTypes.func,
  dispatchMapChanged: PropTypes.func,
  dispatchUpdateEligibilityAndFilters: PropTypes.func,
  filters: PropTypes.object,
  filtersChanged: PropTypes.bool,
  preview: PropTypes.object,
  search: PropTypes.object,
  smallScreen: PropTypes.bool,
};

export default connect(
  mapStateToProps,
  mapDispatchToProps,
)(LocationSearchResults);