department-of-veterans-affairs/vets-website

View on GitHub
src/applications/search/containers/SearchApp.jsx

Summary

Maintainability
F
5 days
Test Coverage
import React from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router';
import { connect } from 'react-redux';
import { getDay, getHours, setHours, setMinutes, setSeconds } from 'date-fns';
import { utcToZonedTime, format as tzFormat } from 'date-fns-tz';

import { toggleValues } from 'platform/site-wide/feature-toggles/selectors';
import FEATURE_FLAG_NAMES from 'platform/utilities/feature-toggles/featureFlagNames';

import recordEvent from 'platform/monitoring/record-event';
import { replaceWithStagingDomain } from 'platform/utilities/environment/stagingDomains';

import { focusElement } from 'platform/utilities/ui';

import DowntimeNotification, {
  externalServices,
} from 'platform/monitoring/DowntimeNotification';
import { VaPagination } from '@department-of-veterans-affairs/component-library/dist/react-bindings';
import * as Sentry from '@sentry/browser';
import { apiRequest } from 'platform/utilities/api';
import { isSearchTermValid } from '~/platform/utilities/search-utilities';
import {
  formatResponseString,
  truncateResponseString,
  removeDoubleBars,
} from '../utils';
import { fetchSearchResults } from '../actions';

import SearchBreadcrumbs from '../components/SearchBreadcrumbs';
import SearchDropdownComponent from '../components/SearchDropdown/SearchDropdownComponent';

const SCREENREADER_FOCUS_CLASSNAME = 'sr-focus';
const MAX_DESCRIPTION_LENGTH = 186;

class SearchApp extends React.Component {
  static propTypes = {
    search: PropTypes.shape({
      results: PropTypes.array,
    }).isRequired,
    fetchSearchResults: PropTypes.func.isRequired,
    searchGovMaintenance: PropTypes.bool,
  };

  constructor(props) {
    super(props);

    const userInputFromURL = this.props.router?.location?.query?.query || '';
    const pageFromURL = this.props.router?.location?.query?.page || undefined;
    const typeaheadUsed =
      this.props.router?.location?.query?.t === 'true' || false;

    this.state = {
      userInput: userInputFromURL,
      currentResultsQuery: userInputFromURL,
      page: pageFromURL,
      typeaheadUsed,
    };
  }

  componentDidMount() {
    // If there's data in userInput, it must have come from the address bar, so we immediately hit the API.
    const { userInput, page } = this.state;
    if (userInput) {
      if (!isSearchTermValid(userInput)) {
        return;
      }
      this.props.fetchSearchResults(userInput, page, {
        trackEvent: true,
        eventName: 'onload_view_search_results',
        path: document.location.pathname,
        userInput,
        typeaheadEnabled: false,
        keywordSelected: undefined,
        keywordPosition: undefined,
        suggestionsList: undefined,
        sitewideSearch: false,
      });
    }
  }

  componentDidUpdate(prevProps) {
    const hasNewResults =
      prevProps.search.loading && !this.props.search.loading;

    if (hasNewResults) {
      const shouldFocusOnResults = this.props.search.searchesPerformed >= 1;

      if (shouldFocusOnResults) {
        focusElement(`.${SCREENREADER_FOCUS_CLASSNAME}`);
      }
    }
  }

  handlePageChange = page => {
    this.setState({ page }, () => this.handleSearch());
  };

  handleSearch = e => {
    if (e) e.preventDefault();
    const { userInput, currentResultsQuery, page } = this.state;

    const userInputFromURL = this.props.router?.location?.query?.query;
    const rawPageFromURL = this.props.router?.location?.query?.page;
    const pageFromURL = rawPageFromURL
      ? parseInt(rawPageFromURL, 10)
      : undefined;

    if (!isSearchTermValid(userInput) || !isSearchTermValid(userInputFromURL)) {
      return;
    }

    const repeatSearch = userInputFromURL === userInput && pageFromURL === page;

    const queryChanged = userInput !== currentResultsQuery;
    const nextPage = queryChanged ? 1 : page;

    this.updateURL({ query: userInput, page: nextPage });

    // Fetch new results
    this.props.fetchSearchResults(userInput, nextPage, {
      trackEvent: queryChanged || repeatSearch,
      eventName: 'view_search_results',
      path: document.location.pathname,
      userInput,
      typeaheadEnabled: false,
      searchLocation: 'Search Results Page',
      keywordSelected: undefined,
      keywordPosition: undefined,
      suggestionsList: undefined,
      sitewideSearch: false,
    });

    // Update query is necessary
    if (queryChanged) {
      this.updateQueryInfo({ query: userInput, page: 1, typeaheadUsed: false });
    }
  };

  updateQueryInfo = options => {
    this.setState({
      currentResultsQuery: options?.query,
      page: options?.page,
      typeaheadUsed: options?.typeaheadUsed,
    });
  };

  updateURL = options => {
    // Update URL
    this.props.router.push({
      pathname: '',
      query: {
        query: options?.query,
        page: options?.page,
        t: options?.typeaheadUsed || false,
      },
    });
  };

  onSearchResultClick = ({ bestBet, title, index, url }) => e => {
    e.preventDefault();

    // clear the &t query param which is used to track typeahead searches
    // removing this will better reflect how many typeahead searches result in at least one click
    window.history.replaceState(
      null,
      document.title,
      `${window.location.href.replace('&t=true', '')}`,
    );

    const bestBetPosition = index + 1;
    const normalResultPosition =
      index + (this.props.search?.recommendedResults?.length || 0) + 1;
    const searchResultPosition = bestBet
      ? bestBetPosition
      : normalResultPosition;

    const query = this.props.router?.location?.query?.query || '';

    const encodedUrl = encodeURIComponent(url);
    const userAgent = encodeURIComponent(navigator.userAgent);
    const encodedQuery = encodeURIComponent(query);
    const apiRequestOptions = {
      method: 'POST',
    };
    const moduleCode = bestBet ? 'BOOS' : 'I14Y';

    // By implementing in this fashion (i.e. a promise chain), code that follows is not blocked by this api request. Following the link at the end of the
    // function should happen regardless of the result of this api request, and it can happen before this request resolves.
    apiRequest(
      `/search_click_tracking?position=${searchResultPosition}&query=${encodedQuery}&url=${encodedUrl}&user_agent=${userAgent}&module_code=${moduleCode}`,
      apiRequestOptions,
    ).catch(error => {
      Sentry.captureException(error);
      Sentry.captureMessage('search_click_tracking_error');
    });

    if (bestBet) {
      recordEvent({
        event: 'nav-searchresults',
        'nav-path': `Recommended Results -> ${title}`,
      });
    }

    recordEvent({
      event: 'onsite-search-results-click',
      'search-page-path': document.location.pathname,
      'search-query': query,
      'search-result-chosen-page-url': url,
      'search-result-chosen-title': title,
      'search-results-n-current-page': this.props.search?.currentPage,
      'search-results-position': searchResultPosition,
      'search-results-total-count': this.props.search?.totalEntries,
      'search-results-total-pages': Math.ceil(
        this.props.search?.totalEntries / 10,
      ),
      'search-results-top-recommendation': bestBet,
      'search-result-type': 'title',
      'search-selection': 'All VA.gov',
      'search-typeahead-used': this.state.typeaheadUsed,
    });

    // relocate to clicked link page
    window.location.href = url;
  };

  onInputSubmit = componentState => {
    const savedSuggestions = componentState?.savedSuggestions || [];
    const suggestions = componentState?.suggestions || [];
    const inputValue = componentState?.inputValue;
    const validSuggestions =
      savedSuggestions.length > 0 ? savedSuggestions : suggestions;

    if (!isSearchTermValid(inputValue)) {
      return;
    }

    this.props.fetchSearchResults(inputValue, 1, {
      trackEvent: true,
      eventName: 'view_search_results',
      path: document.location.pathname,
      userInput: inputValue,
      typeaheadEnabled: true,
      searchLocation: 'Search Results Page',
      keywordSelected: undefined,
      keywordPosition: undefined,
      suggestionsList: validSuggestions,
      sitewideSearch: false,
    });

    this.updateQueryInfo({
      query: inputValue,
      page: 1,
      typeaheadUsed: true,
    });

    this.updateURL({
      query: inputValue,
      page: 1,
      typeaheadUsed: true,
    });

    this.setState({
      userInput: inputValue,
    });
  };

  onSuggestionSubmit = (index, componentState) => {
    const savedSuggestions = componentState?.savedSuggestions || [];
    const suggestions = componentState?.suggestions || [];
    const inputValue = componentState?.inputValue;

    const validSuggestions =
      savedSuggestions?.length > 0 ? savedSuggestions : suggestions;

    this.props.fetchSearchResults(validSuggestions[index], 1, {
      trackEvent: true,
      eventName: 'view_search_results',
      path: document.location.pathname,
      userInput: inputValue,
      typeaheadEnabled: true,
      searchLocation: 'Search Results Page',
      keywordSelected: validSuggestions[index],
      keywordPosition: index + 1,
      suggestionsList: validSuggestions,
      sitewideSearch: false,
    });

    this.updateQueryInfo({
      query: suggestions[index],
      page: 1,
      typeaheadUsed: true,
    });

    this.updateURL({
      query: suggestions[index],
      page: 1,
      typeaheadUsed: true,
    });

    this.setState({
      userInput: inputValue,
    });
  };

  fetchSuggestions = async inputValue => {
    // encode user input for query to suggestions url
    const encodedInput = encodeURIComponent(inputValue);

    // fetch suggestions
    try {
      if (!isSearchTermValid(inputValue)) {
        return [];
      }

      const apiRequestOptions = {
        method: 'GET',
      };
      const fetchedSuggestions = await apiRequest(
        `/search_typeahead?query=${encodedInput}`,
        apiRequestOptions,
      );

      if (fetchedSuggestions.length !== 0) {
        return fetchedSuggestions.sort(function(a, b) {
          return a.length - b.length;
        });
      }
      return [];
      // if we fail to fetch suggestions
    } catch (error) {
      if (error?.error?.code === 'OVER_RATE_LIMIT') {
        Sentry.captureException(
          new Error(`"OVER_RATE_LIMIT" - Search Typeahead`),
        );
      }
      Sentry.captureException(error);
    }
    return [];
  };

  handleInputChange = e => {
    this.setState({
      userInput: e.target.value,
    });
  };

  fetchInputValue = input => {
    this.setState({
      userInput: input,
    });
  };

  renderResults() {
    const { searchGovMaintenance } = this.props;
    const {
      loading,
      errors,
      currentPage,
      totalPages,
      results,
    } = this.props.search;
    const hasErrors = !!(errors && errors.length > 0);
    const { userInput } = this.state;

    // Reusable search input
    const searchInput = (
      <div
        className="vads-u-background-color--gray-lightest vads-u-padding-x--3 vads-u-padding-bottom--3 vads-u-padding-top--1p5 vads-u-margin-top--1p5 vads-u-margin-bottom--4"
        role="search"
        aria-labelledby="h1-search-title"
      >
        <div>Enter a keyword</div>
        <div className="va-flex search-box vads-u-margin-top--1 vads-u-margin-bottom--0">
          {!this.props.searchDropdownComponentEnabled && (
            <form
              onSubmit={this.handleSearch}
              className="va-flex vads-u-width--full"
            >
              <input
                type="text"
                name="query"
                aria-label="Enter a keyword"
                value={userInput}
                onChange={this.handleInputChange}
              />
              <button type="submit">
                {/* search icon on the header dropdown (next to the input on mobile) */}
                {/* Convert to va-icon when injected header/footer split is in prod: https://github.com/department-of-veterans-affairs/vets-website/pull/27590 */}
                <svg
                  aria-hidden="true"
                  focusable="false"
                  width="18"
                  viewBox="3 0 20 20"
                  xmlns="http://www.w3.org/2000/svg"
                >
                  <path
                    fill="#fff"
                    fillRule="evenodd"
                    clipRule="evenodd"
                    d="M15.5 14H14.71L14.43 13.73C15.41 12.59 16 11.11 16 9.5C16 5.91 13.09 3 9.5 3C5.91 3 3 5.91 3 9.5C3 13.09 5.91 16 9.5 16C11.11 16 12.59 15.41 13.73 14.43L14 14.71V15.5L19 20.49L20.49 19L15.5 14ZM9.5 14C7.01 14 5 11.99 5 9.5C5 7.01 7.01 5 9.5 5C11.99 5 14 7.01 14 9.5C14 11.99 11.99 14 9.5 14Z"
                  />
                </svg>
                <span className="button-text">Search</span>
              </button>
            </form>
          )}
          {this.props.searchDropdownComponentEnabled && (
            <SearchDropdownComponent
              buttonText="Search"
              canSubmit
              id="search-results-page-dropdown"
              componentClassName=""
              containerClassName=""
              buttonClassName=""
              inputClassName=""
              suggestionsListClassName=""
              suggestionClassName=""
              formatSuggestions
              fullWidthSuggestions={false}
              mobileResponsive
              startingValue={userInput}
              submitOnClick
              submitOnEnter
              fetchInputValue={this.fetchInputValue}
              fetchSuggestions={this.fetchSuggestions}
              onInputSubmit={this.onInputSubmit}
              onSuggestionSubmit={this.onSuggestionSubmit}
            />
          )}
        </div>
      </div>
    );

    function isWithinMaintenanceWindow() {
      const maintenanceDays = [2, 4]; // Days: 2 for Tuesday, 4 for Thursday
      const maintenanceStartHour = 15; // Start time: 3 PM in 24-hour format
      const maintenanceEndHour = 18; // End time: 6 PM in 24-hour format
      const timeZone = 'America/New_York';

      const now = new Date();
      const zonedNow = utcToZonedTime(now, timeZone);

      return (
        maintenanceDays.includes(getDay(zonedNow)) &&
        getHours(zonedNow) >= maintenanceStartHour &&
        getHours(zonedNow) < maintenanceEndHour
      );
    }

    function calculateCurrentMaintenanceWindow() {
      const maintenanceStartHour = 15; // 3 PM in 24-hour format
      const maintenanceDurationHours = 3; // Duration of the maintenance window in hours
      const timeZone = 'America/New_York';

      // Current date and time in the specified timezone
      let start = new Date();
      start = utcToZonedTime(start, timeZone);
      start = setHours(start, maintenanceStartHour);
      start = setMinutes(start, 0);
      start = setSeconds(start, 0);

      // Calculate end time by adding the duration to the start time
      let end = new Date(
        start.getTime() + maintenanceDurationHours * 60 * 60 * 1000,
      );
      end = utcToZonedTime(end, timeZone); // Ensure the end time is also adjusted to the specified timezone

      // Format start and end dates to include timezone offset correctly
      const startFormatted = tzFormat(
        start,
        "EEE MMM d yyyy HH:mm:ss 'GMT'XXXX",
        { timeZone },
      );
      const endFormatted = tzFormat(end, "EEE MMM d yyyy HH:mm:ss 'GMT'XXXX", {
        timeZone,
      });

      return {
        start: startFormatted,
        end: endFormatted,
      };
    }

    if (searchGovMaintenance) {
      return (
        <div className="columns vads-u-margin-bottom--4">
          <va-banner
            data-label="Error banner"
            headline="Search Maintenance"
            type="error"
          >
            We’re working on Search VA.gov right now. If you have trouble using
            the search tool, check back later. Thank you for your patience.
          </va-banner>
          {searchInput}
        </div>
      );
    }

    if (
      isWithinMaintenanceWindow() &&
      results &&
      results.length === 0 &&
      !hasErrors &&
      !loading
    ) {
      const { start, end } = calculateCurrentMaintenanceWindow(); // Use this for the next scheduled maintenance window

      return (
        <div className="columns vads-u-margin-bottom--4">
          <va-maintenance-banner
            banner-id="search-gov-maintenance-banner"
            maintenance-title="Search Maintenance"
            maintenance-start-date-time={start}
            maintenance-end-date-time={end}
            isError
          >
            <div slot="maintenance-content">
              We’re working on Search VA.gov right now. If you have trouble
              using the search tool, check back after we’re finished. Thank you
              for your patience.
            </div>
          </va-maintenance-banner>
          {searchInput}
        </div>
      );
    }

    // Failed call to Search.gov (successful vets-api response) AND Failed call to vets-api endpoint
    if (hasErrors && !loading) {
      let errorMessage;

      if (!userInput.trim().length) {
        errorMessage = `Enter a search term that contains letters or numbers to find what you're looking for.`;
      } else if (userInput.length > 255) {
        errorMessage =
          'The search is over the character limit. Shorten the search and try again.';
      } else {
        errorMessage = `We’re sorry. Something went wrong on our end, and your search
        didn't go through. Please try again.`;
      }

      return (
        <div className="columns vads-u-margin-bottom--4">
          {/* this is the alert box for when searches fail due to server issues */}
          <va-alert status="error" data-e2e-id="alert-box" uswds>
            <h2 slot="headline">Your search didn't go through</h2>
            <p>{errorMessage}</p>
          </va-alert>
          {searchInput}
        </div>
      );
    }

    return (
      <div>
        {searchInput}
        {this.renderResultsInformation()}
        {this.renderRecommendedResults()}
        {this.renderResultsList()}
        <hr
          aria-hidden="true"
          id="hr-search-bottom"
          className="vads-u-margin-y--3"
        />

        <div className="va-flex results-footer">
          {results &&
            results.length > 0 && (
              <VaPagination
                onPageSelect={e => this.handlePageChange(e.detail.page)}
                page={currentPage}
                pages={totalPages}
                maxPageListLength={5}
                uswds
              />
            )}
          <span className="powered-by">Powered by Search.gov</span>
        </div>
      </div>
    );
  }

  renderRecommendedResults() {
    const { loading, recommendedResults } = this.props.search;
    if (!loading && recommendedResults && recommendedResults.length > 0) {
      return (
        <div>
          <h3 className="vads-u-font-size--base vads-u-font-family--sans vads-u-color--gray-dark vads-u-font-weight--bold">
            Our top recommendations for you
          </h3>
          <ul className="results-list">
            {recommendedResults.map((result, index) =>
              this.renderWebResult(result, 'description', true, index),
            )}
          </ul>
          <hr aria-hidden="true" />
        </div>
      );
    }

    return null;
  }

  renderResultsInformation() {
    const {
      currentPage,
      perPage,
      totalPages,
      totalEntries,
      loading,
      results,
    } = this.props.search;

    let resultRangeEnd = currentPage * perPage;

    if (currentPage === totalPages) {
      resultRangeEnd = totalEntries;
    }

    const resultRangeStart = (currentPage - 1) * perPage + 1;

    if (loading || !totalEntries) return null;

    // if there is a spelling correction, change the information message displayed
    if (this.props.search.spellingCorrection) {
      return (
        <>
          <p className="vads-u-font-size--base vads-u-font-family--sans vads-u-color--gray-dark vads-u-font-weight--normal vads-u-margin-top--2p5 vads-u-margin-bottom--1p5">
            No results for "
            <span className="vads-u-font-weight--bold">
              {this.props.router.location.query.query}
            </span>
            "
          </p>
          <h2
            className={`${SCREENREADER_FOCUS_CLASSNAME} vads-u-font-size--base vads-u-font-family--sans vads-u-color--gray-dark vads-u-font-weight--normal vads-u-margin-y--0p5`}
          >
            Showing{' '}
            {totalEntries === 0 ? '0' : `${resultRangeStart}-${resultRangeEnd}`}{' '}
            of {totalEntries} results for "
            <span className="vads-u-font-weight--bold">
              {this.props.search.spellingCorrection}
            </span>
            "
          </h2>
          <hr className="vads-u-margin-y--3" aria-hidden="true" />
        </>
      );
    }

    // regular display for how many search results total are available.
    /* eslint-disable prettier/prettier */
    if (results && results.length > 0) {
      return (
        <>
          <h2
            aria-live="polite"
            aria-relevant="additions text"
            className={`${SCREENREADER_FOCUS_CLASSNAME} vads-u-font-size--base vads-u-font-family--sans vads-u-color--gray-dark vads-u-font-weight--normal`}
          >
            Showing{' '}
            {totalEntries === 0 ? '0' : `${resultRangeStart}-${resultRangeEnd}`}{' '}
            of {totalEntries} results for "
            <span className="vads-u-font-weight--bold">
              {this.props.router.location.query.query}
            </span>
            "
          </h2>
          <hr className="vads-u-margin-y--3" aria-hidden="true" />
        </>
      );
    }

    return null;
    /* eslint-enable prettier/prettier */
  }

  renderResultsList() {
    const { results, loading } = this.props.search;
    const query = this.props.router?.location?.query?.query || '';
    if (loading) {
      return <va-loading-indicator message="Loading results..." />;
    }

    if (results && results.length > 0) {
      return (
        <>
          <h3 className="sr-only">More search results</h3>
          <ul className="results-list" data-e2e-id="search-results">
            {results.map((result, index) =>
              this.renderWebResult(result, undefined, undefined, index),
            )}
          </ul>
        </>
      );
    }
    if (query) {
      return (
        <p
          className={`${SCREENREADER_FOCUS_CLASSNAME}`}
          data-e2e-id="search-results-empty"
        >
          We didn't find any results for "<strong>{query}</strong>
          ." Try using different words or checking the spelling of the words
          you're using.
        </p>
      );
    }
    return (
      <p
        className={`${SCREENREADER_FOCUS_CLASSNAME}`}
        data-e2e-id="search-results-empty"
      >
        We didn't find any results. Enter a keyword in the search box to try
        again.
      </p>
    );
  }

  /* eslint-disable react/no-danger */
  renderWebResult(result, snippetKey = 'snippet', isBestBet = false, index) {
    const strippedTitle = removeDoubleBars(
      formatResponseString(result.title, true),
    );
    return (
      <li
        key={result.url}
        className="result-item vads-u-margin-top--1p5 vads-u-margin-bottom--4"
      >
        <a
          className="result-title"
          href={replaceWithStagingDomain(result.url)}
          onClick={this.onSearchResultClick({
            bestBet: isBestBet,
            title: strippedTitle,
            index,
            url: replaceWithStagingDomain(result.url),
            // Trigger a new build
          })}
        >
          <h4
            className="vads-u-display--inline  vads-u-margin-top--1 vads-u-margin-bottom--0p25 vads-u-font-size--md vads-u-font-weight--bold vads-u-font-family--serif vads-u-text-decoration--underline"
            data-e2e-id="result-title"
            dangerouslySetInnerHTML={{
              __html: strippedTitle,
            }}
          />
        </a>
        <p className="result-url vads-u-color--green vads-u-font-size--base">
          {replaceWithStagingDomain(result.url)}
        </p>
        <p
          className="result-desc"
          dangerouslySetInnerHTML={{
            __html: formatResponseString(
              truncateResponseString(
                result[snippetKey],
                MAX_DESCRIPTION_LENGTH,
              ),
            ),
          }}
        />
      </li>
    );
  }

  /* eslint-enable react/no-danger */
  render() {
    return (
      <div className="search-app" data-e2e-id="search-app">
        <SearchBreadcrumbs />
        <div className="row">
          <div className="columns">
            <h1 className="vads-u-font-size--2xl" id="h1-search-title">
              Search VA.gov
            </h1>
          </div>
        </div>
        <div className="search-row">
          <div className="usa-width-three-fourths columns">
            <DowntimeNotification
              appTitle="Search App"
              dependencies={[externalServices.search]}
            >
              {this.renderResults()}
            </DowntimeNotification>
          </div>
          <div className="usa-width-one-fourth columns">
            <h2 className="highlight vads-u-font-size--h4">
              More VA search tools
            </h2>
            <ul>
              <li>
                <va-link
                  href="https://search.usa.gov/search?affiliate=bvadecisions"
                  text="Look up Board of Veterans' Appeals (BVA) decisions"
                />
              </li>
              <li>
                <va-link href="/find-forms/" text="Find a VA form" />
              </li>
              <li>
                <va-link
                  href="https://www.va.gov/vapubs/"
                  text="VA handbooks and other publications"
                />
              </li>
              <li>
                <va-link
                  href="https://www.vacareers.va.gov/job-search/index.asp"
                  text="Explore and apply for open VA jobs"
                />
              </li>
            </ul>
          </div>
        </div>
      </div>
    );
  }
}

const mapStateToProps = state => ({
  search: state.search,
  searchDropdownComponentEnabled: toggleValues(state)[
    FEATURE_FLAG_NAMES.searchDropdownComponentEnabled
  ],
  searchGovMaintenance: toggleValues(state)[
    FEATURE_FLAG_NAMES.searchGovMaintenance
  ],
});

const mapDispatchToProps = {
  fetchSearchResults,
};

const SearchAppContainer = withRouter(
  connect(
    mapStateToProps,
    mapDispatchToProps,
  )(SearchApp),
);

export default SearchAppContainer;

SearchAppContainer.defaultProps = {
  searchDropdownComponentEnabled: false,
};