+ );
+};
+
+export default Breadcrumbs;
diff --git a/src/applications/search/components/Errors.jsx b/src/applications/search/components/Errors.jsx
new file mode 100644
index 000000000000..d1d4675a78c7
--- /dev/null
+++ b/src/applications/search/components/Errors.jsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const Errors = ({ userInput }) => {
+ 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 (
+
+ {/* this is the alert box for when searches fail due to server issues */}
+
+
- );
- }
-}
-
-SearchBreadcrumbs.propTypes = {
- breadcrumbId: PropTypes.string.isRequired,
-};
-
-export default SearchBreadcrumbs;
diff --git a/src/applications/search/components/SearchDropdown/SearchDropdownComponent.unit.spec.jsx b/src/applications/search/components/SearchDropdown/SearchDropdownComponent.unit.spec.jsx
deleted file mode 100644
index 7c99dd26f29a..000000000000
--- a/src/applications/search/components/SearchDropdown/SearchDropdownComponent.unit.spec.jsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import React from 'react';
-import { expect } from 'chai';
-import sinon from 'sinon';
-import { mount } from 'enzyme';
-// Relative imports.
-import SearchDropdownComponent from './SearchDropdownComponent';
-
-describe('Search Dropdown Component ', () => {
- it('renders what we expect', () => {
- const fetchSuggestions = sinon.spy();
- const wrapper = mount(
- ,
- );
-
- expect(fetchSuggestions.called).to.be.true;
- wrapper.unmount();
- });
-});
diff --git a/src/applications/search/components/SearchDropdown/SearchDropdownStyles.scss b/src/applications/search/components/SearchDropdown/SearchDropdownStyles.scss
deleted file mode 100644
index eeb02ece0c0f..000000000000
--- a/src/applications/search/components/SearchDropdown/SearchDropdownStyles.scss
+++ /dev/null
@@ -1,51 +0,0 @@
-@import "~@department-of-veterans-affairs/formation/sass/shared-variables";
-
-.search-dropdown-component {
- flex-direction: row;
-
- &.full-width-suggestions {
- position: relative;
- }
-
- &.shrink-to-column {
- @media (max-width: $medium-screen) {
- flex-direction: column;
- }
- }
-}
-
-.search-dropdown-container {
- position: relative;
-
- &.full-width-suggestions {
- position: static;
- max-width: 80%;
- }
-}
-
-.search-dropdown-options {
- position: absolute;
- box-shadow: 0px 7px 10px -4px var(--vads-color-base);
-
- &.full-width-suggestions {
- top: 58px;
- right: 0;
- }
-}
-
-.suggestion {
- line-height: 24px;
- cursor: pointer;
-
- strong {
- font-weight: 700;
- }
-}
-
-.search-dropdown-input-field {
- height: 42px;
-}
-
-.search-dropdown-submit-button {
- height: 42px;
-}
diff --git a/src/applications/search/components/SearchMaintenance.jsx b/src/applications/search/components/SearchMaintenance.jsx
new file mode 100644
index 000000000000..81e1e9297d0a
--- /dev/null
+++ b/src/applications/search/components/SearchMaintenance.jsx
@@ -0,0 +1,95 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { getDay, getHours, setHours, setMinutes, setSeconds } from 'date-fns';
+import { utcToZonedTime, format as tzFormat } from 'date-fns-tz';
+
+export const 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
+ );
+};
+
+const 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,
+ };
+};
+
+const SearchMaintenance = ({ unexpectedMaintenance }) => {
+ const { start, end } = calculateCurrentMaintenanceWindow(); // Use this for the next scheduled maintenance window
+
+ if (unexpectedMaintenance) {
+ return (
+
+
+ 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.
+
+
+ );
+ }
+
+ return (
+
+
+
+ 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.
+
- );
-};
-
-export default SimplePagination;
diff --git a/src/applications/search/constants/index.js b/src/applications/search/constants/index.js
deleted file mode 100644
index 215abdfaeff1..000000000000
--- a/src/applications/search/constants/index.js
+++ /dev/null
@@ -1,5 +0,0 @@
-export const BASE_URI = 'https://search.usa.gov/api/v2/search';
-export const AFFILIATE = 'vets.gov_search';
-export const ACCESS_KEY = '_____YOUR ACCESS KEY HERE_____';
-export const UTF8 = '✓';
-export const PAGE_SIZE = 20;
diff --git a/src/applications/search/constants/stub-new-term.json b/src/applications/search/constants/stub-new-term.json
new file mode 100644
index 000000000000..132b9612c2f4
--- /dev/null
+++ b/src/applications/search/constants/stub-new-term.json
@@ -0,0 +1,80 @@
+{
+ "data": {
+ "id": "",
+ "type": "search_results_responses",
+ "attributes": {
+ "body": {
+ "query": "military",
+ "web": {
+ "total": 5,
+ "nextOffset": 10,
+ "spellingCorrection": null,
+ "results": [
+ {
+ "title": "Request your military service records (including DD214) | Veterans Affairs",
+ "url": "https://www.va.gov/records/get-military-service-records/",
+ "snippet": "...request a copy of your DD214 and other military service records from the National...In this section Request your military service records (including DD214) You...request a copy of your DD214 and other military service records from the National",
+ "publicationDate": "2024-02-07",
+ "thumbnailUrl": null
+ },
+ {
+ "title": "Military sexual trauma (MST) | Veterans Affairs",
+ "url": "https://www.va.gov/health-care/health-needs-conditions/military-sexual-trauma/",
+ "snippet": "...Military sexual trauma (MST) refers to sexual assault or threatening sexual...sexual harassment experienced during military service. Veterans of all genders and...In this section Military sexual trauma (MST) Military sexual trauma (MST) refers...sexual harassment experienced during military service. Veterans of all genders and",
+ "publicationDate": null,
+ "thumbnailUrl": null
+ },
+ {
+ "title": "Military funeral honors and the committal service | Veterans Affairs",
+ "url": "https://www.va.gov/burials-memorials/what-to-expect-at-military-funeral/",
+ "snippet": "Find out what happens at a military funeral for a Veteran or service member...In this section Military funeral honors and the committal service Find out what...what happens at a military funeral for a Veteran or service member. We carry",
+ "publicationDate": null,
+ "thumbnailUrl": null
+ },
+ {
+ "title": "All Veterans exposed to toxins and other hazards during military service – at home or abroad – eligible for VA health care | VA Gulf Coast health care | Veterans Affairs",
+ "url": "https://www.va.gov/gulf-coast-health-care/news-releases/all-veterans-exposed-to-toxins-and-other-hazards-during-military-service-at-home-or-abroad-eligible-for-va/",
+ "snippet": "...to toxins and other hazards during military service – at home or abroad – are...to toxins and other hazards during military service – at home or abroad – eligible...to toxins and other hazards during military service – at home or abroad – are",
+ "publicationDate": null,
+ "thumbnailUrl": null
+ },
+ {
+ "title": "About VA Form SF180 | Veterans Affairs",
+ "url": "https://www.va.gov/find-forms/about-form-sf180/",
+ "snippet": "Use GSA Form SF180 to request your military service records, like your DD214 or...orders and endorsements, and your military medical records....Form name: Request Pertaining to Military Records Related to: A non-VA form...Use GSA Form SF180 to request your military service records, like your DD214 or",
+ "publicationDate": "2022-12-06",
+ "thumbnailUrl": null
+ }
+ ]
+ },
+ "textBestBets": [
+ {
+ "id": 140341,
+ "title": "What to Expect at a Military Funeral",
+ "url": "https://www.va.gov/burials-memorials/what-to-expect-at-military-funeral/",
+ "description": "What to Expect at a Military Funeral"
+ },
+ {
+ "id": 140301,
+ "title": "Military Sexual Trauma (MST)",
+ "url": "https://www.va.gov/health-care/health-needs-conditions/military-sexual-trauma/",
+ "description": "Find out how to access free services that can help you recover if you experienced any sexual activity against your will during your time in the military,"
+ }
+ ],
+ "graphicBestBets": [],
+ "healthTopics": [],
+ "jobOpenings": [],
+ "federalRegisterDocuments": [],
+ "relatedSearchTerms": []
+ }
+ }
+ },
+ "meta": {
+ "pagination": {
+ "currentPage": 1,
+ "perPage": 10,
+ "totalPages": 1,
+ "totalEntries": 5
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/applications/search/constants/stub-page-2.json b/src/applications/search/constants/stub-page-2.json
new file mode 100644
index 000000000000..b8c63384da1f
--- /dev/null
+++ b/src/applications/search/constants/stub-page-2.json
@@ -0,0 +1,54 @@
+{
+ "data": {
+ "id": "",
+ "type": "search_results_responses",
+ "attributes": {
+ "body": {
+ "query": "benefits",
+ "web": {
+ "total": 12,
+ "nextOffset": 20,
+ "spellingCorrection": null,
+ "results": [
+ {
+ "title": "Burial Benefits - Compensation",
+ "url": "https://benefits.va.gov/COMPENSATION/claims-special-burial.asp",
+ "snippet": "Apply for and manage the VA benefits and services you’ve earned as a Veteran...expand a main menu option (Health, Benefits, etc). 3. To enter and activate the...Effective Dates Fully",
+ "publicationDate": "2023-06-27"
+ },
+ {
+ "title": "Direct deposit for your VA benefit payments",
+ "url": "https://www.va.gov/resources/direct-deposit-for-your-va-benefit-payments/",
+ "snippet": "Learn about getting your VA benefit payments through direct deposit. If you...have a bank account, the Veterans Benefits Banking Program (VBBP) can connect...for your VA benefit pa",
+ "publicationDate": "2021-04-20"
+ }
+ ]
+ },
+ "textBestBets": [],
+ "graphicBestBets": [],
+ "healthTopics": [],
+ "jobOpenings": [],
+ "recentTweets": [
+ {
+ "text": "VA burial benefits can help service members, Veterans, and their family members plan and pay for a burial or memori… https://t.co/4J1VxPLupP",
+ "url": "https://twitter.com/VAVetBenefits/status/1374374650268950531",
+ "name": "Veterans Benefits",
+ "screenName": "VAVetBenefits",
+ "profileImageUrl": "https://pbs.twimg.com/profile_images/344513261572743396/a9fcce7feb947b2ec498491c6c6d6985_normal.png",
+ "createdAt": "2021-03-23T14:57:05+00:00"
+ }
+ ],
+ "federalRegisterDocuments": [],
+ "relatedSearchTerms": []
+ }
+ }
+ },
+ "meta": {
+ "pagination": {
+ "currentPage": 2,
+ "perPage": 10,
+ "totalPages": 2,
+ "totalEntries": 12
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/applications/search/constants/stub.json b/src/applications/search/constants/stub.json
index cd1cf2ae1474..2bae86a42cfb 100644
--- a/src/applications/search/constants/stub.json
+++ b/src/applications/search/constants/stub.json
@@ -6,68 +6,68 @@
"body": {
"query": "benefits",
"web": {
- "total": 90487,
+ "total": 12,
"nextOffset": 10,
"spellingCorrection": null,
"results": [
{
"title": "Veterans Benefits Administration Home",
"url": "https://benefits.va.gov/benefits/",
- "snippet": "Apply for and manage the VA benefits and services you’ve earned as a Veteran...expand a main menu option (Health, Benefits, etc). 3. To enter and activate the...Effective Dates Fully Developed Claims Benefit Rates Add a Dependent Education &",
+ "snippet": "Apply for and manage the VA benefits and services you’ve earned as a Veteran...expand a main menu option (Health, Benefits, etc). 3. To enter and activate the...Effective Dates Fully Developed Claims Benefit Rates Add a Dependent Education &",
"publicationDate": null
},
{
"title": "Download VA benefit letters",
"url": "https://www.va.gov/records/download-va-letters/",
- "snippet": "Download your VA Benefit Summary Letter (sometimes called a VA award letter)...letter) and other benefit letters and documents online....section Download VA benefit letters To receive some benefits, Veterans need a letter...status. Access and download your VA Benefit Summary Letter (sometimes called a",
+ "snippet": "Download your VA Benefit Summary Letter (sometimes called a VA award letter)...letter) and other benefit letters and documents online....section Download VA benefit letters To receive some benefits, Veterans need a letter...status. Access and download your VA Benefit Summary Letter (sometimes called a",
"publicationDate": "2020-10-30"
},
{
"title": "VA benefits for spouses, dependents, survivors, and family caregivers",
"url": "https://www.va.gov/family-member-benefits/",
- "snippet": "Learn about VA benefits for spouses, dependents, survivors, and family caregivers...VA benefits for spouses, dependents, survivors, and family caregivers As the...member, you may qualify for certain benefits, like health care, life insurance",
+ "snippet": "Learn about VA benefits for spouses, dependents, survivors, and family caregivers...VA benefits for spouses, dependents, survivors, and family caregivers As the...member, you may qualify for certain benefits, like health care, life insurance",
"publicationDate": "2020-12-07"
},
{
- "title": "About GI Bill benefits",
+ "title": "About GI Bill benefits",
"url": "https://www.va.gov/education/about-gi-bill-benefits/",
- "snippet": "Learn how GI Bill benefits work and explore your options to pay for school or...training. You may qualify for VA GI Bill benefits if you're a Veteran, service member...this section About GI Bill benefits GI Bill benefits help you pay for college...training. Learn more about GI Bill benefits below—and how to apply for them. If",
+ "snippet": "Learn how GI Bill benefits work and explore your options to pay for school or...training. You may qualify for VA GI Bill benefits if you're a Veteran, service member...this section About GI Bill benefits GI Bill benefits help you pay for college...training. Learn more about GI Bill benefits below—and how to apply for them. If",
"publicationDate": "2020-12-30"
},
{
- "title": "CHAMPVA benefits",
+ "title": "CHAMPVA benefits",
"url": "https://www.va.gov/health-care/family-caregiver-benefits/champva/",
- "snippet": "Learn about CHAMPVA benefits, which cover the cost of health care for the spouse...In this section CHAMPVA benefits Are you the spouse or surviving spouse of—or...both may now qualify for CHAMPVA benefits. Each time they need medical care",
+ "snippet": "Learn about CHAMPVA benefits, which cover the cost of health care for the spouse...In this section CHAMPVA benefits Are you the spouse or surviving spouse of—or...both may now qualify for CHAMPVA benefits. Each time they need medical care",
"publicationDate": "2020-09-18"
},
{
- "title": "Add dependents to your VA disability benefits",
+ "title": "Add dependents to your VA disability benefits",
"url": "https://www.va.gov/disability/add-remove-dependent/",
- "snippet": "...dependents to your VA disability benefits for additional compensation. Learn...dependents to your VA disability benefits Find out how to add a dependent spouse...and/or parent to your VA disability benefits for additional compensation. Am I",
+ "snippet": "...dependents to your VA disability benefits for additional compensation. Learn...dependents to your VA disability benefits Find out how to add a dependent spouse...and/or parent to your VA disability benefits for additional compensation. Am I",
"publicationDate": "2020-07-22"
},
{
- "title": "Federal Benefits for Veterans, Dependents and Survivors - Office of Public and Intergovernmental Affairs",
+ "title": "Federal Benefits for Veterans, Dependents and Survivors - Office of Public and Intergovernmental Affairs",
"url": "https://www.va.gov/opa/publications/benefits_book.asp",
- "snippet": "Apply for and manage the VA benefits and services you’ve earned as a Veteran...expand a main menu option (Health, Benefits, etc). 3. To enter and activate the...and Clinics Vet Centers Regional Benefits Offices Regional Loan Centers Cemetery",
+ "snippet": "Apply for and manage the VA benefits and services you’ve earned as a Veteran...expand a main menu option (Health, Benefits, etc). 3. To enter and activate the...and Clinics Vet Centers Regional Benefits Offices Regional Loan Centers Cemetery",
"publicationDate": null
},
{
- "title": "VA burial benefits and memorial items",
+ "title": "VA burial benefits and memorial items",
"url": "https://www.va.gov/burials-memorials/",
- "snippet": "...apply for VA burial benefits. Veterans burial benefits include burial in a VA...qualify for compensation and other benefits....VA burial benefits and memorial items VA burial benefits can help service members...Find out how to apply for the burial benefits you've earned, and how to plan for",
+ "snippet": "...apply for VA burial benefits. Veterans burial benefits include burial in a VA...qualify for compensation and other benefits....VA burial benefits and memorial items VA burial benefits can help service members...Find out how to apply for the burial benefits you've earned, and how to plan for",
"publicationDate": "2020-10-30"
},
{
- "title": "VA education and training benefits",
+ "title": "VA education and training benefits",
"url": "https://www.va.gov/education/",
- "snippet": "...and how to apply for VA education benefits for Veterans, service members, and...members. VA education and training benefits can help you pay for college tuition...education and training benefits VA education benefits help Veterans, service...manage the education and training benefits you've earned. On this page Get GI",
+ "snippet": "...and how to apply for VA education benefits for Veterans, service members, and...members. VA education and training benefits can help you pay for college tuition...education and training benefits VA education benefits help Veterans, service...manage the education and training benefits you've earned. On this page Get GI",
"publicationDate": "2020-12-07"
},
{
- "title": "Post-9/11 GI Bill Statement of Benefits",
+ "title": "Post-9/11 GI Bill Statement of Benefits",
"url": "https://www.va.gov/education/gi-bill/post-9-11/ch-33-benefit/",
- "snippet": "Bill education benefits, your GI Bill Statement of Benefits will show you how...how much of your benefits you’ve used and how much you have left to use for your...Statement of Benefits If you were awarded Post-9/11 GI Bill education benefits, your...Statement of Benefits will show you how much of your benefits you’ve used and",
+ "snippet": "Bill education benefits, your GI Bill Statement of Benefits will show you how...how much of your benefits you’ve used and how much you have left to use for your...Statement of Benefits If you were awarded Post-9/11 GI Bill education benefits, your...Statement of Benefits will show you how much of your benefits you’ve used and",
"publicationDate": "2020-11-13"
}
]
@@ -75,15 +75,15 @@
"textBestBets": [
{
"id": 141942,
- "title": "VA Home Page",
- "url": "https://www.va.gov/",
- "description": "Explore, access, and manage your VA benefits and health care."
+ "title": "VA Health Home Page",
+ "url": "https://www.va.gov/health",
+ "description": "Explore, access, and manage your VA benefits and health care."
},
{
"id": 139967,
- "title": "CHAMPVA Benefits",
+ "title": "CHAMPVA Benefits",
"url": "https://www.va.gov/health-care/family-caregiver-benefits/champva/",
- "description": "CHAMPVA benefits cover the cost of health care for the spouse, surviving spouse, or child of a Veteran who has disabilities or who is deceased."
+ "description": "CHAMPVA benefits cover the cost of health care for the spouse, surviving spouse, or child of a Veteran who has disabilities or who is deceased."
}
],
"graphicBestBets": [],
@@ -91,7 +91,7 @@
"jobOpenings": [],
"recentTweets": [
{
- "text": "VA burial benefits can help service members, Veterans, and their family members plan and pay for a burial or memori… https://t.co/4J1VxPLupP",
+ "text": "VA burial benefits can help service members, Veterans, and their family members plan and pay for a burial or memori… https://t.co/4J1VxPLupP",
"url": "https://twitter.com/VAVetBenefits/status/1374374650268950531",
"name": "Veterans Benefits",
"screenName": "VAVetBenefits",
@@ -108,8 +108,8 @@
"pagination": {
"currentPage": 1,
"perPage": 10,
- "totalPages": 99,
- "totalEntries": 999
+ "totalPages": 2,
+ "totalEntries": 12
}
}
-}
+}
\ No newline at end of file
diff --git a/src/applications/search/containers/SearchApp.jsx b/src/applications/search/containers/SearchApp.jsx
index 90bb7b10e4cf..9f0a1380e672 100644
--- a/src/applications/search/containers/SearchApp.jsx
+++ b/src/applications/search/containers/SearchApp.jsx
@@ -1,153 +1,131 @@
-import React from 'react';
+import React, { useCallback, useEffect, useRef, useState } 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 {
+ VaPagination,
+ VaSearchInput,
+} from '@department-of-veterans-affairs/component-library/dist/react-bindings';
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 { toggleValues } from 'platform/site-wide/feature-toggles/selectors';
+import FEATURE_FLAG_NAMES from 'platform/utilities/feature-toggles/featureFlagNames';
+import { focusElement } from 'platform/utilities/ui';
import {
- formatResponseString,
- truncateResponseString,
- removeDoubleBars,
-} from '../utils';
-import { fetchSearchResults } from '../actions';
-
-import SearchBreadcrumbs from '../components/SearchBreadcrumbs';
-import SearchDropdownComponent from '../components/SearchDropdown/SearchDropdownComponent';
+ fetchTypeaheadSuggestions,
+ isSearchTermValid,
+} from '~/platform/utilities/search-utilities';
+
+import { fetchSearchResults as retrieveSearchResults } from '../actions';
+
+import Breadcrumbs from '../components/Breadcrumbs';
+import Errors from '../components/Errors';
+import SearchMaintenance, {
+ isWithinMaintenanceWindow,
+} from '../components/SearchMaintenance';
+import MoreVASearchTools from '../components/MoreVASearchTools';
+import RecommendedResults from '../components/RecommendedResults';
+import ResultsCounter from '../components/ResultsCounter';
+import ResultsList from '../components/ResultsList';
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, {
+const SearchApp = ({
+ fetchSearchResults,
+ router,
+ search,
+ searchGovMaintenance,
+}) => {
+ const userInputFromURL = router?.location?.query?.query || '';
+ const pageFromURL = router?.location?.query?.page || undefined;
+ const typeaheadUsed = router?.location?.query?.t === 'true' || false;
+
+ const [userInput, setUserInput] = useState(userInputFromURL);
+ const [savedSuggestions, setSavedSuggestions] = useState([]);
+ const [suggestions, setSuggestions] = useState([]);
+ const [currentResultsQuery, setCurrentResultsQuery] = useState(
+ userInputFromURL,
+ );
+ const [page, setPage] = useState(pageFromURL);
+ const [typeAheadWasUsed, setTypeAheadWasUsed] = useState(typeaheadUsed);
+ const [formWasSubmitted, setFormWasSubmitted] = useState(false);
+
+ const instance = useRef({ typeaheadTimer: null });
+
+ const {
+ currentPage,
+ errors,
+ loading: searchIsLoading,
+ perPage,
+ results,
+ searchesPerformed,
+ spellingCorrection,
+ totalEntries,
+ totalPages,
+ } = search;
+
+ const hasErrors = !!(errors && errors.length > 0);
+
+ // If there's data in userInput when this component loads,
+ // it came from the address bar, so we immediately hit the API
+ useEffect(() => {
+ const initialUserInput = router?.location?.query?.query || '';
+
+ if (initialUserInput && isSearchTermValid(initialUserInput)) {
+ setFormWasSubmitted(true);
+
+ fetchSearchResults(initialUserInput, page, {
trackEvent: true,
eventName: 'onload_view_search_results',
path: document.location.pathname,
- userInput,
- typeaheadEnabled: false,
+ userInput: initialUserInput,
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) {
+ useEffect(
+ () => {
+ if (searchesPerformed) {
focusElement(`.${SCREENREADER_FOCUS_CLASSNAME}`);
}
- }
- }
-
- handlePageChange = page => {
- this.setState({ page }, () => this.handleSearch());
- };
-
- handleSearch = e => {
- if (e) e.preventDefault();
- const { userInput, currentResultsQuery, page } = this.state;
+ },
+ [searchIsLoading, searchesPerformed],
+ );
- const userInputFromURL = this.props.router?.location?.query?.query;
- const rawPageFromURL = this.props.router?.location?.query?.page;
- const pageFromURL = rawPageFromURL
- ? parseInt(rawPageFromURL, 10)
- : undefined;
+ const fetchSuggestions = useCallback(
+ async searchValue => {
+ const typeaheadSuggestions = await fetchTypeaheadSuggestions(searchValue);
- 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,
- });
- };
+ if (typeaheadSuggestions?.length) {
+ setSuggestions(typeaheadSuggestions);
+ }
+ },
+ [setSuggestions],
+ );
+
+ useEffect(
+ () => {
+ // We landed on the page with a search term in the URL; fetch suggestions
+ if (userInput) {
+ const initialSuggestions = fetchSuggestions(userInput);
+
+ if (initialSuggestions?.length) {
+ setSuggestions(initialSuggestions);
+ }
+ }
+ },
+ [fetchSuggestions, setSuggestions],
+ );
- updateURL = options => {
- // Update URL
- this.props.router.push({
+ const updateURL = options => {
+ router.push({
pathname: '',
query: {
query: options?.query,
@@ -157,682 +135,265 @@ class SearchApp extends React.Component {
});
};
- 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,
- });
+ const updateQueryInfo = options => {
+ setCurrentResultsQuery(options?.query);
+ setPage(options?.page);
+ setTypeAheadWasUsed(options?.typeaheadUsed);
};
- 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,
- });
+ const handleSearch = clickedPage => {
+ const newPage = clickedPage.toString();
+ setPage(newPage);
+ setFormWasSubmitted(true);
- this.updateURL({
- query: suggestions[index],
- page: 1,
- typeaheadUsed: true,
- });
+ const rawPageFromURL = pageFromURL ? parseInt(pageFromURL, 10) : undefined;
- this.setState({
- userInput: inputValue,
- });
- };
+ if (isSearchTermValid(userInput) || isSearchTermValid(userInputFromURL)) {
+ const isRepeatSearch =
+ userInputFromURL === userInput && rawPageFromURL === newPage;
- fetchSuggestions = async inputValue => {
- // encode user input for query to suggestions url
- const encodedInput = encodeURIComponent(inputValue);
+ const queryChanged = userInput !== currentResultsQuery;
+ const nextPage = queryChanged ? 1 : newPage;
- // fetch suggestions
- try {
- if (!isSearchTermValid(inputValue)) {
- return [];
- }
+ updateURL({ query: userInput, page: nextPage });
+ // Fetch new results
+ fetchSearchResults(userInput, nextPage, {
+ trackEvent: queryChanged || isRepeatSearch,
+ eventName: 'view_search_results',
+ path: document.location.pathname,
+ userInput,
+ searchLocation: 'Search Results Page',
+ keywordSelected: undefined,
+ keywordPosition: undefined,
+ suggestionsList: undefined,
+ sitewideSearch: false,
+ });
- 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;
+ // Update query is necessary
+ if (queryChanged) {
+ updateQueryInfo({
+ query: userInput,
+ page: 1,
+ typeaheadUsed: false,
});
}
- 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,
- });
- };
+ const onInputSubmit = event => {
+ event.preventDefault();
+ setFormWasSubmitted(true);
- 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 = (
-
- );
-
- 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';
+ if (!userInput) {
+ return;
+ }
- const now = new Date();
- const zonedNow = utcToZonedTime(now, timeZone);
+ const validSuggestions =
+ savedSuggestions.length > 0 ? savedSuggestions : suggestions;
- return (
- maintenanceDays.includes(getDay(zonedNow)) &&
- getHours(zonedNow) >= maintenanceStartHour &&
- getHours(zonedNow) < maintenanceEndHour
- );
- }
+ if (isSearchTermValid(userInput)) {
+ fetchSearchResults(userInput, 1, {
+ trackEvent: true,
+ eventName: 'view_search_results',
+ path: document.location.pathname,
+ userInput,
+ searchLocation: 'Search Results Page',
+ keywordSelected: undefined,
+ keywordPosition: undefined,
+ suggestionsList: validSuggestions,
+ sitewideSearch: false,
+ });
- 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,
+ updateQueryInfo({
+ query: userInput,
+ page: 1,
+ typeaheadUsed: true,
});
- return {
- start: startFormatted,
- end: endFormatted,
- };
+ updateURL({
+ query: userInput,
+ page: 1,
+ typeaheadUsed: true,
+ });
}
+ };
- if (searchGovMaintenance) {
- return (
-
-
- 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.
-
- {searchInput}
-
- );
+ const handleInputChange = event => {
+ if (formWasSubmitted) {
+ setFormWasSubmitted(false);
}
- if (
- isWithinMaintenanceWindow() &&
- results &&
- results.length === 0 &&
- !hasErrors &&
- !loading
- ) {
- const { start, end } = calculateCurrentMaintenanceWindow(); // Use this for the next scheduled maintenance window
-
- return (
-
-
-
- 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.
-
-
- {searchInput}
-
- );
- }
+ clearTimeout(instance.current.typeaheadTimer);
- // 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.`;
- }
+ instance.current.typeaheadTimer = setTimeout(() => {
+ fetchSuggestions(userInput);
+ }, 200);
- return (
-
- {/* this is the alert box for when searches fail due to server issues */}
-
-