diff --git a/src/applications/search/actions/index.js b/src/applications/search/actions/index.js index 8ec0f9d58150..4b938e3caf9e 100644 --- a/src/applications/search/actions/index.js +++ b/src/applications/search/actions/index.js @@ -16,6 +16,7 @@ export function fetchSearchResults(query, page, options) { if (page) { queryString = queryString.concat(`&page=${page}`); } + if (!query) { return dispatch({ type: FETCH_SEARCH_RESULTS_EMPTY, @@ -34,7 +35,6 @@ export function fetchSearchResults(query, page, options) { 'search-results-total-pages': response?.meta?.pagination?.totalPages, 'search-selection': 'All VA.gov', - 'search-typeahead-enabled': options?.typeaheadEnabled, 'search-location': options?.searchLocation, 'sitewide-search-app-used': options?.sitewideSearch, 'type-ahead-option-keyword-selected': options?.keywordSelected, diff --git a/src/applications/search/components/Breadcrumbs.jsx b/src/applications/search/components/Breadcrumbs.jsx new file mode 100644 index 000000000000..1e1c35b5e8e7 --- /dev/null +++ b/src/applications/search/components/Breadcrumbs.jsx @@ -0,0 +1,31 @@ +import React, { useEffect } from 'react'; +import { VaBreadcrumbs } from '@department-of-veterans-affairs/web-components/react-bindings'; +import { focusElement } from 'platform/utilities/ui'; + +const Breadcrumbs = () => { + useEffect(() => { + focusElement('#search-breadcrumbs'); + }, []); + + return ( +
+ +
+ ); +}; + +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 */} + +

Your search didn’t go through

+

{errorMessage}

+
+
+ ); +}; + +Errors.propTypes = { + userInput: PropTypes.string.isRequired, +}; + +export default Errors; diff --git a/src/applications/search/components/MoreVASearchTools.jsx b/src/applications/search/components/MoreVASearchTools.jsx new file mode 100644 index 000000000000..032432d0c3e0 --- /dev/null +++ b/src/applications/search/components/MoreVASearchTools.jsx @@ -0,0 +1,29 @@ +import React from 'react'; + +const MoreVASearchTools = () => ( + +); + +export default MoreVASearchTools; diff --git a/src/applications/search/components/RecommendedResults.jsx b/src/applications/search/components/RecommendedResults.jsx new file mode 100644 index 000000000000..e4c05f6f86b6 --- /dev/null +++ b/src/applications/search/components/RecommendedResults.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Result from './Result'; + +const RecommendedResults = ({ query, searchData, typeaheadUsed }) => { + const { recommendedResults } = searchData; + + if (recommendedResults && recommendedResults.length > 0) { + return ( +
+

+ Our top recommendations for you +

+ + +
+ ); + } + + return null; +}; + +RecommendedResults.propTypes = { + query: PropTypes.string, + searchData: PropTypes.object, + typeaheadUsed: PropTypes.bool, +}; + +export default RecommendedResults; diff --git a/src/applications/search/components/Result.jsx b/src/applications/search/components/Result.jsx new file mode 100644 index 000000000000..88ddbd40d0f6 --- /dev/null +++ b/src/applications/search/components/Result.jsx @@ -0,0 +1,162 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import * as Sentry from '@sentry/browser'; +import recordEvent from 'platform/monitoring/record-event'; +import { apiRequest } from 'platform/utilities/api'; +import { replaceWithStagingDomain } from 'platform/utilities/environment/stagingDomains'; +import { + formatResponseString, + truncateResponseString, + removeDoubleBars, +} from '../utils'; + +const MAX_DESCRIPTION_LENGTH = 186; + +const onSearchResultClick = ({ + bestBet, + index, + query, + searchData, + title, + typeaheadUsed, + url, +}) => async e => { + const { currentPage, recommendedResults, totalEntries } = searchData; + 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 + (recommendedResults?.length || 0) + 1; + const searchResultPosition = bestBet ? bestBetPosition : normalResultPosition; + + 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( + `https://api.va.gov/v0/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': currentPage, + 'search-results-position': searchResultPosition, + 'search-results-total-count': totalEntries, + 'search-results-total-pages': Math.ceil(totalEntries / 10), + 'search-results-top-recommendation': bestBet, + 'search-result-type': 'title', + 'search-selection': 'All VA.gov', + 'search-typeahead-used': typeaheadUsed, + }); + + // relocate to clicked link page + window.location.href = url; +}; + +const Result = ({ + index, + isBestBet, + query, + result, + searchData, + snippetKey = 'snippet', + typeaheadUsed, +}) => { + const strippedTitle = removeDoubleBars( + formatResponseString(result?.title, true), + ); + + if (result?.title && result?.url) { + return ( +
  • +

    + +

    +

    + {replaceWithStagingDomain(result.url)} +

    +

    +

  • + ); + } + + return null; +}; + +Result.propTypes = { + index: PropTypes.number.isRequired, + isBestBet: PropTypes.bool.isRequired, + query: PropTypes.string.isRequired, + result: PropTypes.shape({ + title: PropTypes.string, + url: PropTypes.string, + }).isRequired, + searchData: PropTypes.shape({ + currentPage: PropTypes.number, + recommendedResults: PropTypes.array, + totalEntries: PropTypes.number, + }).isRequired, + typeaheadUsed: PropTypes.bool.isRequired, + snippetKey: PropTypes.string, +}; + +export default Result; diff --git a/src/applications/search/components/ResultsCounter.jsx b/src/applications/search/components/ResultsCounter.jsx new file mode 100644 index 000000000000..7b47bfb7660e --- /dev/null +++ b/src/applications/search/components/ResultsCounter.jsx @@ -0,0 +1,83 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const SCREENREADER_FOCUS_CLASSNAME = 'sr-focus'; + +const ResultsCounter = ({ + currentPage, + loading, + perPage, + query, + results, + spellingCorrection, + totalPages, + totalEntries, +}) => { + let resultRangeEnd = currentPage * perPage; + + if (currentPage === totalPages) { + resultRangeEnd = totalEntries; + } + + const resultRangeStart = (currentPage - 1) * perPage + 1; + + if (loading || !totalEntries) { + return null; + } + + if (spellingCorrection) { + return ( + <> +

    + No results for " + {query}" +

    +

    + Showing{' '} + {totalEntries === 0 ? '0' : `${resultRangeStart}-${resultRangeEnd}`}{' '} + of {totalEntries} results for " + {spellingCorrection} + " +

    + + + ); + } + + if (results && results.length > 0) { + return ( + <> +

    + Showing{' '} + {totalEntries === 0 ? '0' : `${resultRangeStart}-${resultRangeEnd}`}{' '} + of {totalEntries} results for " + {query}" +

    + + + ); + } + + return null; +}; + +ResultsCounter.propTypes = { + loading: PropTypes.bool.isRequired, + query: PropTypes.string.isRequired, + currentPage: PropTypes.number, + perPage: PropTypes.number, + results: PropTypes.array, + spellingCorrection: PropTypes.bool, + totalEntries: PropTypes.number, + totalPages: PropTypes.number, +}; + +export default ResultsCounter; diff --git a/src/applications/search/components/ResultsList.jsx b/src/applications/search/components/ResultsList.jsx new file mode 100644 index 000000000000..6090fdc44933 --- /dev/null +++ b/src/applications/search/components/ResultsList.jsx @@ -0,0 +1,65 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Result from './Result'; + +const ResultsList = ({ loading, query, searchData, typeaheadUsed }) => { + const SCREENREADER_FOCUS_CLASSNAME = 'sr-focus'; + const { results } = searchData; + + if (loading) { + return ; + } + + if (results && results.length > 0) { + return ( + <> +

    More search results

    + + + ); + } + + if (query) { + return ( +

    + We didn’t find any results for "{query} + ." Try using different words or checking the spelling of the words + you’re using. +

    + ); + } + + return ( +

    + We didn’t find any results. Enter a keyword in the search box to try + again. +

    + ); +}; + +ResultsList.propTypes = { + loading: PropTypes.bool, + query: PropTypes.string, + searchData: PropTypes.object, + typeaheadUsed: PropTypes.bool, +}; + +export default ResultsList; diff --git a/src/applications/search/components/SearchBreadcrumbs.jsx b/src/applications/search/components/SearchBreadcrumbs.jsx deleted file mode 100644 index f93ff6a4b921..000000000000 --- a/src/applications/search/components/SearchBreadcrumbs.jsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { VaBreadcrumbs } from '@department-of-veterans-affairs/web-components/react-bindings'; -import { focusElement } from 'platform/utilities/ui'; - -class SearchBreadcrumbs extends React.Component { - static defaultProps = { - breadcrumbId: 'search-breadcrumbs', - }; - - componentDidMount() { - focusElement(`#${this.props.breadcrumbId}`); - } - - render() { - return ( -
    - -
    - ); - } -} - -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. +
    +
    +
    + ); +}; + +SearchMaintenance.propTypes = { + unexpectedMaintenance: PropTypes.bool, +}; + +export default SearchMaintenance; diff --git a/src/applications/search/components/SimplePagination.jsx b/src/applications/search/components/SimplePagination.jsx deleted file mode 100644 index b083d75f4928..000000000000 --- a/src/applications/search/components/SimplePagination.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; - -import { PAGE_SIZE } from '../constants'; - -const SimplePagination = ({ prevOffset, nextOffset, handlePageChange }) => { - let pageNumber = 1; - - if (nextOffset) { - pageNumber = nextOffset / PAGE_SIZE; - } else if (prevOffset) { - pageNumber = prevOffset / PAGE_SIZE + 1; - } - - return ( -
    - {prevOffset && ( - - )} - Page {pageNumber} - {nextOffset && ( - - )} -
    - ); -}; - -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 = ( -
    -
    Enter a keyword
    -
    - {!this.props.searchDropdownComponentEnabled && ( -
    - - -
    - )} - {this.props.searchDropdownComponentEnabled && ( - - )} -
    -
    - ); - - 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 */} - -

    Your search didn't go through

    -

    {errorMessage}

    -
    - {searchInput} -
    - ); + setUserInput(event.target.value); + + if (userInput?.length <= 2) { + setSuggestions([]); + setSavedSuggestions([]); } + }; + const renderResults = () => { return (
    - {searchInput} - {this.renderResultsInformation()} - {this.renderRecommendedResults()} - {this.renderResultsList()} + + {!searchIsLoading && ( + + )} + - -
    +
    {results && results.length > 0 && ( this.handlePageChange(e.detail.page)} + class="vads-u-border-top--0" + onPageSelect={e => handleSearch(e.detail.page)} page={currentPage} pages={totalPages} - maxPageListLength={5} - uswds + maxPageListLength={7} /> )} Powered by Search.gov
    ); - } - - renderRecommendedResults() { - const { loading, recommendedResults } = this.props.search; - if (!loading && recommendedResults && recommendedResults.length > 0) { - return ( -
    -

    - Our top recommendations for you -

    -
      - {recommendedResults.map((result, index) => - this.renderWebResult(result, 'description', true, index), - )} -
    - -
    - ); - } - - 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 ( - <> -

    - No results for " - - {this.props.router.location.query.query} - - " -

    -

    - Showing{' '} - {totalEntries === 0 ? '0' : `${resultRangeStart}-${resultRangeEnd}`}{' '} - of {totalEntries} results for " - - {this.props.search.spellingCorrection} - - " -

    - - - ); - } + }; - // regular display for how many search results total are available. - /* eslint-disable prettier/prettier */ - if (results && results.length > 0) { - return ( - <> -

    creates a maintenance banner for: + // 1. Search.gov errors during their maintenance windows (Tues & Thurs 3-6pm EST) + // 2. Sitewide team using the search_gov_maintenance feature flipper + // when Search.gov is experiencing major outages + const searchGovIssuesWithinMaintenanceWindow = + isWithinMaintenanceWindow() && + results && + results.length === 0 && + !hasErrors && + !searchIsLoading; + + const searchGovIssues = + searchGovIssuesWithinMaintenanceWindow || searchGovMaintenance; + + return ( +
    + +
    +
    +

    + Search VA.gov +

    +
    +
    +
    +
    + - Showing{' '} - {totalEntries === 0 ? '0' : `${resultRangeStart}-${resultRangeEnd}`}{' '} - of {totalEntries} results for " - - {this.props.router.location.query.query} - - " -

    - - - ); - } - - return null; - /* eslint-enable prettier/prettier */ - } - - renderResultsList() { - const { results, loading } = this.props.search; - const query = this.props.router?.location?.query?.query || ''; - if (loading) { - return ; - } - - if (results && results.length > 0) { - return ( - <> -

    More search results

    -
      - {results.map((result, index) => - this.renderWebResult(result, undefined, undefined, index), + {// Search API returned errors OR + // errors with user input before submitting + shouldShowErrorMessage && } + {searchGovIssues && ( + )} -
    - - ); - } - if (query) { - return ( -

    - We didn't find any results for "{query} - ." Try using different words or checking the spelling of the words - you're using. -

    - ); - } - return ( -

    - We didn't find any results. Enter a keyword in the search box to try - again. -

    - ); - } - - /* eslint-disable react/no-danger */ - renderWebResult(result, snippetKey = 'snippet', isBestBet = false, index) { - const strippedTitle = removeDoubleBars( - formatResponseString(result.title, true), - ); - return ( -
  • - -

    - -

    - {replaceWithStagingDomain(result.url)} -

    -

    -

  • - ); - } - - /* eslint-enable react/no-danger */ - render() { - return ( -
    - -
    -
    -

    - Search VA.gov -

    -
    -
    -
    -
    - - {this.renderResults()} - -
    -
    -

    - More VA search tools -

    -
      -
    • - -
    • -
    • - -
    • -
    • - -
    • -
    • - +

      + Enter a keyword, phrase, or question +

      +
      + clearTimeout(instance.current.typeaheadTimer)} + onInput={handleInputChange} + onSubmit={event => onInputSubmit(event)} + suggestions={suggestions} + value={userInput} /> -
    • -
    -
    +
    +
    + {!shouldShowErrorMessage && !searchGovIssues && renderResults()} + +
    +
    +

    + More VA search tools +

    +
    - ); - } -} + + ); +}; const mapStateToProps = state => ({ search: state.search, - searchDropdownComponentEnabled: toggleValues(state)[ - FEATURE_FLAG_NAMES.searchDropdownComponentEnabled - ], searchGovMaintenance: toggleValues(state)[ FEATURE_FLAG_NAMES.searchGovMaintenance ], }); const mapDispatchToProps = { - fetchSearchResults, + fetchSearchResults: retrieveSearchResults, +}; + +SearchApp.propTypes = { + fetchSearchResults: PropTypes.func.isRequired, + search: PropTypes.shape({ + currentPage: PropTypes.number, + errors: PropTypes.array, + loading: PropTypes.bool, + perPage: PropTypes.number, + recommendedResults: PropTypes.array, + results: PropTypes.array, + searchesPerformed: PropTypes.number, + spellingCorrection: PropTypes.bool, + totalEntries: PropTypes.number, + totalPages: PropTypes.number, + }).isRequired, + router: PropTypes.shape({ + location: PropTypes.shape({ + query: PropTypes.shape({ + page: PropTypes.string, + query: PropTypes.string, + t: PropTypes.string, + }), + }), + push: PropTypes.func, + }), + searchGovMaintenance: PropTypes.bool, }; const SearchAppContainer = withRouter( @@ -843,7 +404,3 @@ const SearchAppContainer = withRouter( ); export default SearchAppContainer; - -SearchAppContainer.defaultProps = { - searchDropdownComponentEnabled: false, -}; diff --git a/src/applications/search/search-entry.jsx b/src/applications/search/search-entry.jsx index 42f435fa7c68..9792ca520bbb 100644 --- a/src/applications/search/search-entry.jsx +++ b/src/applications/search/search-entry.jsx @@ -1,9 +1,6 @@ import 'platform/polyfills'; import './styles.scss'; -// necessary styles for the search dropdown component -import './components/SearchDropdown/SearchDropdownStyles.scss'; - import startApp from 'platform/startup'; import routes from './routes'; diff --git a/src/applications/search/selectors/index.js b/src/applications/search/selectors/index.js deleted file mode 100644 index f85927651755..000000000000 --- a/src/applications/search/selectors/index.js +++ /dev/null @@ -1,6 +0,0 @@ -// import the toggleValues helper -import { toggleValues } from 'platform/site-wide/feature-toggles/selectors'; -import FEATURE_FLAG_NAMES from 'platform/utilities/feature-toggles/featureFlagNames'; - -export const showPdfWarningBanner = state => - toggleValues(state)[FEATURE_FLAG_NAMES.pdfWarningBanner]; diff --git a/src/applications/search/styles.scss b/src/applications/search/styles.scss index 17e04387c5ee..90cc79d24f09 100644 --- a/src/applications/search/styles.scss +++ b/src/applications/search/styles.scss @@ -23,54 +23,14 @@ &.results-list { li.result-item { - h4, - p { - font-family: "Helvetica", "Arial", "Bitter", "Georgia", "Cambria", - "Times New Roman", "Times", serif; - } - - - .result-url, .result-desc { - color: var(--vads-color-base-darker); - font-family: "Source Sans Pro"; - font-size: 1rem; - } - - - a.result-title { - text-decoration: none; - color: var(--vads-color-link); - - - &:visited { - span { - color: var(--vads-color-link-visited); - } - } - h4 { - &:hover { - color: var(--vads-color-base-darker); - background: rgba(0, 0, 0, 0.05); - } - } - - - &:focus, - &:active { - outline: none; - h4 { - color: var(--vads-color-base-darker); - background: rgba(0, 0, 0, 0.05); - outline: 2px solid var(--vads-color-action-focus-on-light); - outline-offset: 2px; - } - } + color: $color-gray-dark; } p { margin: 0; + word-wrap: break-word; } } } @@ -127,47 +87,14 @@ @include media($small-screen) { margin: 0.625rem 0 1.25rem; } - @include media($medium-screen) { - margin: 1.25rem 0 0.625rem; - } - } - - - .va-pagination { - border-top: none; - - - .va-pagination-prev:empty { - padding: 0; - } - - - @include media($large-screen) { - .va-pagination-inner { - width: 12.5rem !important; - } - } - @include media($medium-screen) { - width: unset; - } - } - - - .simple-pagination { - span.current-page { - padding: 0 0.625rem; + margin: 1.25rem 0 0.625rem; } } } - a:visited { - color: var(--vads-color-link); - } - - .search-box { flex-direction: column; @@ -175,66 +102,6 @@ @media (min-width: $medium-screen) { flex-direction: row; } - - - input { - max-width: none; - margin: 0; - font-family: "Source Sans Pro"; - - - } - button { - margin: 10px 0 5px; - font-family: "Source Sans Pro"; - - - @media (min-width: $medium-screen) { - margin: 0; - } - } - - - input { - color: var(--vads-color-base-darker); - font-weight: 400; - } - - - button { - align-items: center; - background: var(--vads-color-primary); - border-radius: 5px; - color: var(--vads-color-white); - display: flex; - font-size: 1rem; - font-weight: 700; - justify-content: center; - padding: 0.625rem; - width: 100%; - - @media (min-width: $medium-screen) { - border-radius: 0 5px 5px 0; - padding: 0 0.9375rem 0 0.625rem; - width: auto; - } - - &:hover { - background: var(--vads-color-primary-dark); - } - - &:focus, - &:active { - background: var(--vads-color-primary-dark); - outline: 2px solid var(--vads-color-action-focus-on-light); - outline-offset: 2px; - } - } - } - - nav[aria-label="Breadcrumb"], - h4.sr-focus { - outline: 0; } .search-row { @@ -248,4 +115,4 @@ flex-direction: row; } } -} +} \ No newline at end of file diff --git a/src/applications/search/tests/e2e/00-required.cypress.spec.js b/src/applications/search/tests/e2e/00-required.cypress.spec.js deleted file mode 100644 index 7018f168347d..000000000000 --- a/src/applications/search/tests/e2e/00-required.cypress.spec.js +++ /dev/null @@ -1,265 +0,0 @@ -import stub from '../../constants/stub.json'; -import zeroResultsStub from '../../constants/stubZeroResults.json'; - -const SELECTORS = { - APP: '[data-e2e-id="search-app"]', - SEARCH_INPUT: '[data-e2e-id="search-results-page-dropdown-input-field"]', - SEARCH_BUTTON: '[data-e2e-id="search-results-page-dropdown-submit-button"]', - SEARCH_RESULTS: '[data-e2e-id="search-results"]', - SEARCH_RESULTS_EMPTY: '[data-e2e-id="search-results-empty"]', - SEARCH_RESULTS_TITLE: '[data-e2e-id="result-title"]', - ERROR_ALERT_BOX: '[data-e2e-id="alert-box"]', -}; - -const enableDropdownComponent = () => { - cy.intercept('GET', '/v0/feature_toggles*', { - data: { - features: [ - { - name: 'search_dropdown_component_enabled', - value: true, - }, - ], - }, - }); -}; - -describe('Sitewide Search smoke test', () => { - it('successfully searches and renders results from the global search', () => { - enableDropdownComponent(); - cy.intercept('GET', '/v0/search?query=benefits', { - body: stub, - statusCode: 200, - }).as('getSearchResultsGlobal'); - // navigate to page - cy.visit('/search?query=benefits'); - cy.injectAxeThenAxeCheck(); - - // Ensure App is present - cy.get(SELECTORS.APP).should('exist'); - - cy.get(`${SELECTORS.SEARCH_INPUT}`).should('exist'); - cy.get(`${SELECTORS.SEARCH_BUTTON}`).should('exist'); - - // Await search results - cy.wait('@getSearchResultsGlobal'); - - // A11y check the search results. - cy.axeCheck(); - - // Check results to see if variety of nodes exist. - cy.get(SELECTORS.SEARCH_RESULTS_TITLE) - // Check title. - .should('contain', 'Veterans Benefits Administration Home'); - - cy.get(`${SELECTORS.SEARCH_RESULTS} li`) - // Check url. - .should('contain', 'https://benefits.va.gov/benefits/'); - }); - - it('successfully searches and renders results from the results page', () => { - enableDropdownComponent(); - cy.intercept('GET', '/v0/search?query=*', { - body: stub, - statusCode: 200, - }).as('getSearchResultsPage'); - - // navigate to page - cy.visit('/search/?query='); - cy.injectAxeThenAxeCheck(); - - // Ensure App is present - cy.get(SELECTORS.APP).should('exist'); - - cy.get(`${SELECTORS.SEARCH_INPUT}`).should('exist'); - cy.get(`${SELECTORS.SEARCH_INPUT}`).focus(); - cy.get(`${SELECTORS.SEARCH_INPUT}`).clear(); - cy.get(`${SELECTORS.SEARCH_INPUT}`).type('benefits'); - cy.get(`${SELECTORS.SEARCH_BUTTON}`).should('exist'); - cy.get(`${SELECTORS.SEARCH_BUTTON}`).click(); - - // Await search results - cy.wait('@getSearchResultsPage'); - - // A11y check the search results. - cy.axeCheck(); - - // Check results to see if variety of nodes exist. - cy.get(SELECTORS.SEARCH_RESULTS_TITLE) - // Check title. - .should('contain', 'Veterans Benefits Administration Home'); - - cy.get(`${SELECTORS.SEARCH_RESULTS} li`) - // Check url. - .should('contain', 'https://benefits.va.gov/benefits/'); - }); - - it('fails to search and has an error', () => { - enableDropdownComponent(); - cy.intercept('GET', '/v0/search?query=benefits', { - body: [], - statusCode: 500, - }).as('getSearchResultsFailed'); - // navigate to page - cy.visit('/search/?query=benefits'); - cy.injectAxeThenAxeCheck(); - - // Ensure App is present - cy.get(SELECTORS.APP).should('exist'); - - cy.get(`${SELECTORS.SEARCH_INPUT}`).should('exist'); - cy.get(`${SELECTORS.SEARCH_BUTTON}`).should('exist'); - - // Fill out and submit the form. - cy.wait('@getSearchResultsFailed'); - - // Ensure ERROR Alert Box exists - cy.get(SELECTORS.ERROR_ALERT_BOX) - // Check contain error message - .should('contain', `Your search didn't go through`); - - // A11y check the search results. - cy.axeCheck(); - }); - - describe('Maintenance Window Message Display', () => { - const maintenanceTestCases = [ - { date: '2021-03-16T20:00:00.000Z', description: 'Tuesday at 4 PM EST' }, // Within Tuesday maintenance window - { date: '2021-03-18T20:00:00.000Z', description: 'Thursday at 4 PM EST' }, // Within Thursday maintenance window - { date: '2021-03-16T21:00:00.000Z', description: 'Tuesday at 5 PM EST' }, // Within Tuesday maintenance window - { date: '2021-03-18T21:00:00.000Z', description: 'Thursday at 5 PM EST' }, // Within Thursday maintenance window - ]; - - maintenanceTestCases.forEach(testCase => { - it(`should display maintenance message during maintenance window when 0 results on ${ - testCase.description - }`, () => { - cy.clock(new Date(testCase.date).getTime(), ['Date']); - - enableDropdownComponent(); - cy.intercept('GET', '/v0/search?query=benefits', { - body: zeroResultsStub, - statusCode: 200, - }).as('getSearchResultsGlobal'); - - cy.visit('/search?query=benefits'); - cy.injectAxeThenAxeCheck(); - - cy.get('[data-e2e-id="search-app"]').within(() => { - cy.get('va-maintenance-banner') - .should('exist') - .and('contain', 'We’re working on Search VA.gov right now.'); - }); - - cy.axeCheck(); - }); - }); - - it('should not display message if returns with search results at 4 PM EST on a Tuesday', () => { - // Mocking the date and time to Tuesday at 4 PM EST (EDT, so UTC-4) - cy.clock(new Date('2021-03-16T20:00:00.000Z').getTime(), ['Date']); - - enableDropdownComponent(); - cy.intercept('GET', '/v0/search?query=benefits', { - // Using your provided "stub" for successful search results here - body: stub, - statusCode: 200, - }).as('getSearchResultsDuringMaintenance'); - - cy.visit('/search?query=benefits'); - cy.injectAxeThenAxeCheck(); - - cy.wait('@getSearchResultsDuringMaintenance'); - - cy.get('[data-e2e-id="search-app"]').within(() => { - // Ensuring the maintenance banner does not appear - cy.get('va-maintenance-banner').should('not.exist'); - // Ensuring search results are displayed - cy.get('[data-e2e-id="search-results"]').should('exist'); - cy.get('[data-e2e-id="result-title"]').should( - 'have.length.at.least', - 1, - ); - }); - cy.axeCheck(); - }); - - const nonMaintenanceTestCases = [ - { date: '2021-03-15T18:00:00.000Z', description: 'Monday at 2 PM EST' }, // Monday - { date: '2021-03-20T22:00:00.000Z', description: 'Saturday at 6 PM EST' }, // Saturday - { date: '2021-03-21T13:00:00.000Z', description: 'Sunday at 9 AM EST' }, // Sunday - ]; - - nonMaintenanceTestCases.forEach(testCase => { - it(`should not display maintenance message with no results on ${ - testCase.description - }`, () => { - cy.clock(new Date(testCase.date).getTime(), ['Date']); - - enableDropdownComponent(); - cy.intercept('GET', '/v0/search?query=benefits', { - body: { - data: { - id: '', - type: 'search_results_responses', - attributes: { - body: { - query: 'benefits', - web: { - total: 0, - nextOffset: 0, - spellingCorrection: null, - results: [], - }, - }, - }, - }, - meta: { - pagination: { - currentPage: 1, - perPage: 10, - totalPages: 0, - totalEntries: 0, - }, - }, - }, - statusCode: 200, - }).as('getSearchResultsGlobal'); - - cy.visit('/search?query=benefits'); - cy.injectAxeThenAxeCheck(); - - cy.get('[data-e2e-id="search-app"]').within(() => { - cy.get('va-maintenance-banner').should('not.exist'); - }); - - cy.axeCheck(); - }); - }); - - it('should resume normal functionality immediately after maintenance window ends', () => { - // Mock a time immediately after the maintenance window, e.g., 6:01 PM EST on a Tuesday - cy.clock(new Date('2021-03-16T23:01:00.000Z').getTime(), ['Date']); - - enableDropdownComponent(); - cy.intercept('GET', '/v0/search?query=benefits', { - body: stub, // Assuming 'stub' is your normal response fixture for search results - statusCode: 200, - }).as('getSearchResultsGlobal'); - - cy.visit('/search?query=benefits'); - cy.injectAxeThenAxeCheck(); - - cy.get('[data-e2e-id="search-app"]').within(() => { - cy.get('va-maintenance-banner').should('not.exist'); - cy.get('[data-e2e-id="search-results"]').should('exist'); - }); - - cy.wait('@getSearchResultsGlobal') - .its('response.statusCode') - .should('eq', 200); - - cy.axeCheck(); - }); - }); -}); diff --git a/src/applications/search/tests/e2e/01-required.cypress.spec.js b/src/applications/search/tests/e2e/01-required.cypress.spec.js deleted file mode 100644 index 8654b5b9c85f..000000000000 --- a/src/applications/search/tests/e2e/01-required.cypress.spec.js +++ /dev/null @@ -1,110 +0,0 @@ -/** - * [TestRail-integrated] Spec for Search Type Ahead 2.0 - * @testrailinfo projectId 31 - * @testrailinfo suiteId 150 - * @testrailinfo groupId 2925 - * @testrailinfo runName TA-2.0-e2e - */ -import SearchComponent from '../page-object/searchComponent'; - -describe('Site-wide Search functionality with search dropdown component enabled', () => { - const healthSearchTerm = 'health'; - const benefitsSearchTerm = 'benefits'; - const searchComponent = new SearchComponent(); - - it('passes axe requirements - - C12121', () => { - searchComponent.loadComponent(benefitsSearchTerm); - searchComponent.confirmDropDown(); - - cy.injectAxe(); - cy.axeCheck(); - }); - - it('shows suggestions when user input is present and typeahead is enabled - - C12122', () => { - searchComponent.loadComponent(benefitsSearchTerm); - searchComponent.confirmDropDown(); - - cy.injectAxe(); - cy.axeCheck(); - }); - - it('Focusing the search button hides user input - - C12123', () => { - searchComponent.loadComponent(benefitsSearchTerm); - searchComponent.confirmDropDown(); - searchComponent.confirmSearchFocusHidesInput(); - - cy.injectAxe(); - cy.axeCheck(); - }); - - it('Focusing the input field repopulates suggestions - - C12124', () => { - searchComponent.loadComponent(healthSearchTerm); - searchComponent.confirmDropDown(); - searchComponent.confirmSearchFocusHidesInput(); - searchComponent.focusOnInputField(); - searchComponent.confirmDropDown(); - - cy.injectAxe(); - cy.axeCheck(); - }); - - it('Clicking search button initiates search for input - C12125', () => { - searchComponent.loadComponent(healthSearchTerm); - searchComponent.clickSubmitButton(); - searchComponent.checkIfUrlContains(`/search/?query=${healthSearchTerm}`); - - cy.injectAxe(); - cy.axeCheck(); - }); - - it('Pressing enter (focus on input field) initiates search for input - C12126', () => { - searchComponent.loadComponent(healthSearchTerm); - searchComponent.clickEnterInInputField(); - searchComponent.checkIfUrlContains(`/search/?query=${healthSearchTerm}`); - - cy.injectAxe(); - cy.axeCheck(); - }); - - it('Pressing enter (focus on search button) initiates search for input - C12127', () => { - searchComponent.loadComponent(benefitsSearchTerm); - searchComponent.clickSubmitButton(); - searchComponent.checkIfUrlContains(`/search/?query=${benefitsSearchTerm}`); - - cy.injectAxe(); - cy.axeCheck(); - }); - - it('Pressing space (focus on search button) initiates search for input - C12128', () => { - searchComponent.loadComponent(healthSearchTerm); - searchComponent.clickSubmitButton(); - searchComponent.checkIfUrlContains(`/search/?query=${healthSearchTerm}`); - - cy.injectAxe(); - cy.axeCheck(); - }); - - it('Clicking a dropdown option initiates a search using the suggestion - C12129', () => { - searchComponent.loadComponent(healthSearchTerm); - searchComponent.confirmDropDown(); - searchComponent.clickTypeAheadItem(); - searchComponent.checkIfUrlContains( - `/search/?query=${healthSearchTerm}%20response%204`, - ); - - cy.injectAxe(); - cy.axeCheck(); - }); - - it('Can use the arrow keys to navigate suggestions, and press enter to search using them - C12130', () => { - searchComponent.loadComponent(benefitsSearchTerm); - searchComponent.confirmDropDown(); - searchComponent.navigateSearchSuggestions(); - searchComponent.checkIfUrlContains( - `/search/?query=${benefitsSearchTerm}%20response%203`, - ); - - cy.injectAxe(); - cy.axeCheck(); - }); -}); diff --git a/src/applications/search/tests/e2e/error-states.cypress.spec.js b/src/applications/search/tests/e2e/error-states.cypress.spec.js new file mode 100644 index 000000000000..fff1acadf56a --- /dev/null +++ b/src/applications/search/tests/e2e/error-states.cypress.spec.js @@ -0,0 +1,74 @@ +import stub from '../../constants/stub.json'; +import { SELECTORS as s } from './helpers'; + +describe('Error states', () => { + it('shows an error when no search term is given', () => { + cy.intercept('GET', '/v0/search?query=*', { + body: stub, + statusCode: 200, + }); + + cy.visit('/search'); + cy.injectAxeThenAxeCheck(); + + cy.get(s.APP).within(() => { + cy.get(s.SEARCH_INPUT).should('be.visible'); + cy.get(s.SEARCH_BUTTON) + .should('be.visible') + .click(); + cy.get(`${s.ERROR_ALERT_BOX} p`) + .should('be.visible') + .should( + 'have.text', + `Enter a search term that contains letters or numbers to find what you're looking for.`, + ); + }); + }); + + it('shows an error when a very long (255+ chars) search term is given', () => { + const longSearchString = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. In eros mi, mattis id mauris non, commodo tempor justo. Nulla suscipit molestie nulla. Curabitur ac pellentesque lectus, id vulputate enim. Fusce vel dui nec urna congue lacinia. In non tempus erose.'; + cy.intercept('GET', '/v0/search?query=*', { + body: stub, + statusCode: 200, + }); + + cy.visit('/search'); + cy.injectAxeThenAxeCheck(); + + cy.get(s.APP).within(() => { + cy.get(s.SEARCH_INPUT) + .should('be.visible') + .type(longSearchString); + cy.get(s.SEARCH_BUTTON) + .should('be.visible') + .click(); + cy.get(`${s.ERROR_ALERT_BOX} p`) + .should('be.visible') + .should( + 'have.text', + 'The search is over the character limit. Shorten the search and try again.', + ); + }); + }); + + it('fails to search and has an error', () => { + cy.intercept('GET', '/v0/search?query=benefits', { + body: [], + statusCode: 500, + }); + + cy.visit('/search/?query=benefits'); + cy.injectAxeThenAxeCheck(); + + cy.get(s.APP).within(() => { + cy.get(s.SEARCH_INPUT).should('be.visible'); + cy.get(s.SEARCH_BUTTON).should('be.visible'); + cy.get(`${s.ERROR_ALERT_BOX} h2`) + .should('be.visible') + .should('have.text', 'Your search didn’t go through'); + }); + + cy.axeCheck(); + }); +}); diff --git a/src/applications/search/tests/e2e/helpers.js b/src/applications/search/tests/e2e/helpers.js new file mode 100644 index 000000000000..c48564f485f4 --- /dev/null +++ b/src/applications/search/tests/e2e/helpers.js @@ -0,0 +1,42 @@ +export const SELECTORS = { + APP: '[data-e2e-id="search-app"]', + SEARCH_INPUT: '#search-field', + SEARCH_BUTTON: '#search-field + button[type="submit"]', + SEARCH_RESULTS: '[data-e2e-id="search-results"]', + SEARCH_RESULTS_EMPTY: '[data-e2e-id="search-results-empty"]', + SEARCH_RESULTS_TITLE: '[data-e2e-id="result-title"]', + SEARCH_RESULTS_COUNTER: '[data-e2e-id="results-counter"]', + TOP_RECOMMENDATIONS: '[data-e2e-id="top-recommendations"]', + TYPEAHEAD_DROPDOWN: '#va-search-listbox', + TYPEAHEAD_OPTIONS: '#va-search-listbox li', + ERROR_ALERT_BOX: '[data-e2e-id="alert-box"]', + MAINT_BOX: 'va-maintenance-banner', + OUTAGE_BOX: 'va-banner', + PAGINATION: 'va-pagination', + HEADER_SEARCH_TRIGGER: 'button.sitewide-search-drop-down-panel-button', + HEADER_SEARCH_FIELD: '.search-dropdown-input-field', + HEADER_TYPEAHEAD_DROPDOWN: '[data-e2e-id="search-dropdown-options"]', + HEADER_SEARCH_SUBMIT: '.search-dropdown-submit-button', +}; + +export const verifyRecommendationsLink = (text, href) => { + cy.get( + `${SELECTORS.TOP_RECOMMENDATIONS} ${ + SELECTORS.SEARCH_RESULTS_TITLE + } va-link[text="${text}"]`, + ).should('be.visible'); + cy.get( + `${SELECTORS.TOP_RECOMMENDATIONS} ${ + SELECTORS.SEARCH_RESULTS_TITLE + } va-link[href*="${href}"]`, + ).should('be.visible'); +}; + +export const verifyResultsLink = (text, href) => { + cy.get(`${SELECTORS.SEARCH_RESULTS} li va-link[text="${text}"]`).should( + 'be.visible', + ); + cy.get(`${SELECTORS.SEARCH_RESULTS} li va-link[href*="${href}"]`).should( + 'be.visible', + ); +}; diff --git a/src/applications/search/tests/e2e/maintenance-window.cypress.spec.js b/src/applications/search/tests/e2e/maintenance-window.cypress.spec.js new file mode 100644 index 000000000000..fd319b7e5ff0 --- /dev/null +++ b/src/applications/search/tests/e2e/maintenance-window.cypress.spec.js @@ -0,0 +1,138 @@ +import stub from '../../constants/stub.json'; +import zeroResultsStub from '../../constants/stubZeroResults.json'; +import { SELECTORS as s } from './helpers'; + +describe('Search.gov maintenance window message', () => { + const mockResultsEmpty = () => { + cy.intercept('GET', '/v0/search?query=benefits', { + body: zeroResultsStub, + statusCode: 200, + }).as('getSearchResultsGlobal'); + }; + + const mockResults = () => { + cy.intercept('GET', '/v0/search?query=benefits', { + body: stub, + statusCode: 200, + }).as('getSearchResultsGlobal'); + }; + + const setClockAndSearch = date => { + cy.clock(new Date(date).getTime(), ['Date']); + cy.visit('/search?query=benefits'); + cy.injectAxeThenAxeCheck(); + }; + + const verifyBanner = () => { + cy.get(s.APP).within(() => { + cy.get(s.MAINT_BOX) + .should('exist') + .and('contain', 'We’re working on Search VA.gov right now.'); + }); + }; + + const verifyNoBanner = () => { + cy.get(s.APP).within(() => { + cy.get(s.MAINT_BOX).should('not.exist'); + }); + }; + + const checkForResults = () => { + cy.get(s.APP).within(() => { + cy.get(s.SEARCH_INPUT).should('be.visible'); + cy.get(s.SEARCH_BUTTON).should('be.visible'); + cy.get(s.SEARCH_RESULTS).should('be.visible'); + cy.get(s.SEARCH_RESULTS_TITLE).should('have.length.at.least', 1); + }); + }; + + const verifyNoResults = () => { + cy.get(s.APP).within(() => { + cy.get(s.SEARCH_INPUT).should('be.visible'); + cy.get(s.SEARCH_BUTTON).should('be.visible'); + cy.get(s.SEARCH_RESULTS).should('not.exist'); + cy.get(s.SEARCH_RESULTS_TITLE).should('not.exist'); + }); + }; + + it('should display maintenance message during maintenance window when 0 results on Tuesday at 4 PM EST', () => { + mockResultsEmpty(); + setClockAndSearch('2021-03-16T20:00:00.000Z'); + verifyBanner(); + verifyNoResults(); + + cy.axeCheck(); + }); + + it('should display maintenance message during maintenance window when 0 results on Thursday at 4 PM EST', () => { + mockResultsEmpty(); + setClockAndSearch('2021-03-18T20:00:00.000Z'); + verifyBanner(); + verifyNoResults(); + + cy.axeCheck(); + }); + + it('should display maintenance message during maintenance window when 0 results on Tuesday at 5 PM EST', () => { + mockResultsEmpty(); + setClockAndSearch('2021-03-16T21:00:00.000Z'); + verifyBanner(); + verifyNoResults(); + + cy.axeCheck(); + }); + + it('should display maintenance message during maintenance window when 0 results on Thursday at 5 PM EST', () => { + mockResultsEmpty(); + setClockAndSearch('2021-03-18T21:00:00.000Z'); + verifyBanner(); + verifyNoResults(); + + cy.axeCheck(); + }); + + it('should NOT display message if returns with search results at 4 PM EST on a Tuesday', () => { + mockResults(); + setClockAndSearch('2021-03-16T20:00:00.000Z'); + verifyNoBanner(); + checkForResults(); + + cy.axeCheck(); + }); + + it('should NOT display maintenance message when 0 results on Monday at 2 PM EST', () => { + mockResultsEmpty(); + setClockAndSearch('2021-03-15T18:00:00.000Z'); + verifyNoBanner(); + verifyNoResults(); + + cy.axeCheck(); + }); + + it('should NOT display maintenance message when 0 results on Saturday at 6 PM EST', () => { + mockResultsEmpty(); + setClockAndSearch('2021-03-20T22:00:00.000Z'); + verifyNoBanner(); + verifyNoResults(); + + cy.axeCheck(); + }); + + it('should NOT display maintenance message when 0 results on Sunday at 9 AM EST', () => { + mockResultsEmpty(); + setClockAndSearch('2021-03-21T13:00:00.000Z'); + verifyNoBanner(); + verifyNoResults(); + + cy.axeCheck(); + }); + + it('should resume normal functionality immediately after maintenance window ends', () => { + mockResults(); + setClockAndSearch('2021-03-16T23:01:00.000Z'); + verifyNoBanner(); + checkForResults(); + + cy.axeCheck(); + }); +}); diff --git a/src/applications/search/tests/e2e/search-and-pagination.cypress.spec.js b/src/applications/search/tests/e2e/search-and-pagination.cypress.spec.js new file mode 100644 index 000000000000..45a47fa7af42 --- /dev/null +++ b/src/applications/search/tests/e2e/search-and-pagination.cypress.spec.js @@ -0,0 +1,239 @@ +import stub from '../../constants/stub.json'; +import stubPage2 from '../../constants/stub-page-2.json'; +import stubNewTerm from '../../constants/stub-new-term.json'; +import zeroResultsStub from '../../constants/stubZeroResults.json'; +import { + SELECTORS as s, + verifyRecommendationsLink, + verifyResultsLink, +} from './helpers'; + +describe('Global search', () => { + it('successfully searches and renders results when landing on the page with a search term', () => { + cy.intercept('GET', '/v0/search?query=benefits', { + body: stub, + statusCode: 200, + }); + + cy.intercept('GET', '/v0/search?query=benefits&page=2', { + body: stubPage2, + statusCode: 200, + }); + + cy.visit('/search?query=benefits'); + cy.injectAxeThenAxeCheck(); + + cy.get(s.APP).within(() => { + cy.get(s.SEARCH_INPUT).should('be.visible'); + cy.get(s.SEARCH_BUTTON).should('be.visible'); + cy.get(s.SEARCH_RESULTS_COUNTER) + .should('be.visible') + .should('have.text', 'Showing 1-10 of 12 results for "benefits"'); + + verifyRecommendationsLink('VA Health Home Page', '/health'); + + verifyRecommendationsLink( + 'CHAMPVA Benefits', + '/health-care/family-caregiver-benefits/champva/', + ); + + verifyResultsLink( + 'Veterans Benefits Administration Home', + 'https://benefits.va.gov/benefits/', + ); + + verifyResultsLink('Download VA benefit letters', '/download-va-letters/'); + verifyResultsLink( + 'VA benefits for spouses, dependents, survivors, and family caregivers', + '/family-member-benefits/', + ); + + cy.get(s.ERROR_ALERT_BOX).should('not.exist'); + cy.get(s.MAINT_BOX).should('not.exist'); + cy.get(s.OUTAGE_BOX).should('not.exist'); + + cy.get(s.PAGINATION) + .should('exist') + .scrollIntoView(); + + cy.get(s.PAGINATION).within(() => { + cy.get('nav li').should('have.length', 3); // includes Next link + cy.get('nav li') + .eq(1) + .click(); + }); + }); + + cy.get(s.SEARCH_RESULTS_COUNTER) + .should('exist') + .should('have.text', 'Showing 11-12 of 12 results for "benefits"'); + + verifyResultsLink( + 'Burial Benefits - Compensation', + 'https://benefits.va.gov/COMPENSATION/claims-special-burial.asp', + ); + + verifyResultsLink( + 'Direct deposit for your VA benefit payments', + '/resources/direct-deposit-for-your-va-benefit-payments/', + ); + + cy.axeCheck(); + }); + + it('successfully searches and renders results when searching on the results page', () => { + cy.intercept('GET', '/v0/search?query=*', { + body: stub, + statusCode: 200, + }); + + cy.visit('/search/?query='); + cy.injectAxeThenAxeCheck(); + + cy.get(s.APP).within(() => { + cy.get(s.SEARCH_INPUT).should('be.visible'); + cy.get(s.SEARCH_BUTTON).should('be.visible'); + + cy.get(s.SEARCH_INPUT).focus(); + cy.get(s.SEARCH_INPUT).clear(); + cy.get(s.SEARCH_INPUT).type('benefits'); + cy.get(s.SEARCH_BUTTON).click(); + + verifyResultsLink( + 'Veterans Benefits Administration Home', + 'https://benefits.va.gov/benefits/', + ); + + verifyResultsLink('Download VA benefit letters', '/download-va-letters/'); + + verifyResultsLink( + 'VA benefits for spouses, dependents, survivors, and family caregivers', + '/family-member-benefits/', + ); + + cy.get(s.ERROR_ALERT_BOX).should('not.exist'); + cy.get(s.MAINT_BOX).should('not.exist'); + cy.get(s.OUTAGE_BOX).should('not.exist'); + }); + + cy.axeCheck(); + }); + + it('shows the correct messaging when the API calls succeed but there are no results', () => { + cy.intercept('GET', '/v0/search?query=*', { + body: zeroResultsStub, + statusCode: 200, + }); + + cy.visit('/search/?query='); + cy.injectAxeThenAxeCheck(); + + cy.get(s.APP).within(() => { + cy.get(s.SEARCH_INPUT).type('benefits'); + cy.get(s.SEARCH_BUTTON).click(); + cy.get(s.SEARCH_RESULTS_EMPTY) + .should('be.visible') + .should('contain.text', `We didn’t find any results for "benefits."`); + cy.get(s.ERROR_ALERT_BOX).should('not.exist'); + cy.get(s.MAINT_BOX).should('not.exist'); + cy.get(s.OUTAGE_BOX).should('not.exist'); + cy.get(s.TOP_RECOMMENDATIONS).should('not.exist'); + cy.get(s.SEARCH_RESULTS).should('not.exist'); + }); + + cy.axeCheck(); + }); + + it('shows the correct messaging when the page is loaded with no search term', () => { + cy.intercept('GET', '/v0/search?query=*', { + body: stub, + statusCode: 200, + }); + + cy.visit('/search/?query='); + cy.injectAxeThenAxeCheck(); + + cy.get(s.APP).within(() => { + cy.get(s.SEARCH_RESULTS_EMPTY) + .should('be.visible') + .should('contain.text', `We didn’t find any results.`); + cy.get(s.ERROR_ALERT_BOX).should('not.exist'); + cy.get(s.MAINT_BOX).should('not.exist'); + cy.get(s.OUTAGE_BOX).should('not.exist'); + cy.get(s.TOP_RECOMMENDATIONS).should('not.exist'); + cy.get(s.SEARCH_RESULTS).should('not.exist'); + }); + + cy.axeCheck(); + }); + + it('shows the correct results when a new term is searched', () => { + cy.intercept('GET', '/v0/search?query=benefits', { + body: stub, + statusCode: 200, + }); + + cy.intercept('GET', '/v0/search?query=military&page=1', { + body: stubNewTerm, + statusCode: 200, + }); + + cy.visit('/search/?query=benefits'); + + cy.injectAxeThenAxeCheck(); + + cy.get(s.APP).within(() => { + cy.get(s.SEARCH_INPUT).should('be.visible'); + cy.get(s.SEARCH_BUTTON).should('be.visible'); + cy.get(s.SEARCH_RESULTS_COUNTER) + .should('be.visible') + .should('have.text', 'Showing 1-10 of 12 results for "benefits"'); + + verifyRecommendationsLink('VA Health Home Page', '/health'); + + verifyRecommendationsLink( + 'CHAMPVA Benefits', + '/health-care/family-caregiver-benefits/champva/', + ); + + verifyResultsLink( + 'Veterans Benefits Administration Home', + 'https://benefits.va.gov/benefits/', + ); + + cy.get(s.SEARCH_INPUT) + .focus() + .clear(); + cy.get(s.SEARCH_INPUT).type('military'); + cy.get(s.SEARCH_BUTTON).click(); + + cy.get(s.SEARCH_RESULTS_COUNTER) + .should('be.visible') + .should('have.text', 'Showing 1-5 of 5 results for "military"'); + + verifyRecommendationsLink( + 'What to Expect at a Military Funeral', + '/burials-memorials/what-to-expect-at-military-funeral/', + ); + + verifyRecommendationsLink( + 'Military Sexual Trauma (MST)', + '/health-care/health-needs-conditions/military-sexual-trauma/', + ); + + verifyResultsLink( + 'Request your military service records (including DD214)', + '/records/get-military-service-records', + ); + + verifyResultsLink( + 'Military sexual trauma (MST)', + '/health-care/health-needs-conditions/military-sexual-trauma/', + ); + + verifyResultsLink('About VA Form SF180', '/find-forms/about-form-sf180/'); + }); + + cy.axeCheck(); + }); +}); diff --git a/src/applications/search/tests/e2e/search-from-header.cypress.spec.js b/src/applications/search/tests/e2e/search-from-header.cypress.spec.js new file mode 100644 index 000000000000..97c656ff3ee1 --- /dev/null +++ b/src/applications/search/tests/e2e/search-from-header.cypress.spec.js @@ -0,0 +1,129 @@ +import { SELECTORS as s } from './helpers'; + +// Since the search bar in the header connects to the main search app, +// we need these tests to make sure the connection works as expected +describe('Global search from the header', () => { + beforeEach(() => { + cy.intercept('GET', 'v0/search_typeahead?query=benefits', [ + 'benefits response 1', + 'benefits response 2', + 'benefits response 3', + 'benefits response 4', + 'benefits response 5', + ]); + + cy.intercept('GET', 'v0/search_typeahead?query=health', [ + 'health response 1', + 'health response 2', + 'health response 3', + 'health response 4', + 'health response 5', + ]); + }); + + const loadAndAddSearchTerm = term => { + cy.visit('/'); + cy.get(s.HEADER_SEARCH_TRIGGER).click(); + cy.get(s.HEADER_SEARCH_FIELD).click(); + cy.get(s.HEADER_SEARCH_FIELD) + .should('exist') + .should('not.be.disabled') + .type(term, { force: true }); + }; + + const verifyTypeaheadDropdown = () => { + cy.get(s.HEADER_TYPEAHEAD_DROPDOWN).should('be.visible'); + cy.get(s.HEADER_TYPEAHEAD_DROPDOWN) + .children() + .should('have.length', 5); + }; + + const verifyTypeaheadDropdownCloses = () => { + cy.get(s.HEADER_SEARCH_SUBMIT).focus(); + cy.get(s.HEADER_TYPEAHEAD_DROPDOWN).should('not.exist'); + }; + + const verifyUrlContains = queryString => { + cy.url().should('contain', queryString); + }; + + const focusOnSubmitButton = () => { + cy.get(s.HEADER_SEARCH_SUBMIT).focus(); + }; + + it('shows the dropdown from the header', () => { + loadAndAddSearchTerm('benefits'); + cy.injectAxeThenAxeCheck(); + }); + + it('shows suggestions when a search term is present', () => { + loadAndAddSearchTerm('benefits'); + verifyTypeaheadDropdown(); + cy.injectAxeThenAxeCheck(); + }); + + it('closes the typeahead dropdown when the search button is focused', () => { + loadAndAddSearchTerm('benefits'); + verifyTypeaheadDropdown(); + verifyTypeaheadDropdownCloses(); + cy.injectAxeThenAxeCheck(); + }); + + it('opens the typeahead dropdown again when the input field is focused again', () => { + loadAndAddSearchTerm('health'); + verifyTypeaheadDropdown(); + verifyTypeaheadDropdownCloses(); + cy.get(s.HEADER_SEARCH_FIELD).focus(); + verifyTypeaheadDropdown(); + cy.injectAxeThenAxeCheck(); + }); + + it('should navigate to the main search page when the input is focused and the search button is clicked', () => { + loadAndAddSearchTerm('health'); + cy.get(s.HEADER_SEARCH_SUBMIT).click(); + verifyUrlContains(`/search/?query=health`); + cy.injectAxeThenAxeCheck(); + }); + + it('should navigate to the main search page when the input is focused and the "Enter" key is pressed', () => { + loadAndAddSearchTerm('health'); + cy.get(s.HEADER_SEARCH_FIELD).type('{enter}'); + verifyUrlContains(`/search/?query=health`); + cy.injectAxeThenAxeCheck(); + }); + + it('should navigate to the main search page when the focus is on the search button and the "Enter" key is pressed', () => { + loadAndAddSearchTerm('health'); + focusOnSubmitButton(); + cy.realPress('{enter}'); + verifyUrlContains(`/search/?query=health`); + cy.injectAxeThenAxeCheck(); + }); + + it('should navigate to the main search page when the focus is on the search button and the "Space" key is pressed', () => { + loadAndAddSearchTerm('health'); + focusOnSubmitButton(); + cy.realPress(' '); + verifyUrlContains(`/search/?query=health`); + cy.injectAxeThenAxeCheck(); + }); + + it('should navigate to the main search page when a typeahead suggestion is clicked', () => { + loadAndAddSearchTerm('health'); + verifyTypeaheadDropdown(); + cy.get(`#search-header-dropdown-option-3`).click(); + verifyUrlContains(`/search/?query=health%20response%204`); + cy.injectAxeThenAxeCheck(); + }); + + it('allows the user to use the arrow keys to navigate suggestions and press enter to search one', () => { + loadAndAddSearchTerm('benefits'); + verifyTypeaheadDropdown(); + cy.get(s.HEADER_SEARCH_FIELD).type('{downarrow}'); + cy.get(s.HEADER_SEARCH_FIELD).type('{downarrow}'); + cy.get(s.HEADER_SEARCH_FIELD).type('{downarrow}'); + cy.get(s.HEADER_SEARCH_FIELD).type('{enter}'); + verifyUrlContains(`/search/?query=benefits%20response%203`); + cy.injectAxeThenAxeCheck(); + }); +}); diff --git a/src/applications/search/tests/e2e/typeahead-behavior.cypress.spec.js b/src/applications/search/tests/e2e/typeahead-behavior.cypress.spec.js new file mode 100644 index 000000000000..d7307d6fa201 --- /dev/null +++ b/src/applications/search/tests/e2e/typeahead-behavior.cypress.spec.js @@ -0,0 +1,85 @@ +import stub from '../../constants/stub.json'; +// import stubPage2 from '../../constants/stub-page-2.json'; +// import zeroResultsStub from '../../constants/stubZeroResults.json'; +import { + SELECTORS as s, + verifyRecommendationsLink, + verifyResultsLink, +} from './helpers'; + +describe('Global search typeahead behavior', () => { + const verifyTAResult = (index, text) => { + cy.get(s.TYPEAHEAD_OPTIONS) + .eq(index) + .should('be.visible') + .should('have.text', text); + }; + + it('correctly shows typeahead results', () => { + cy.intercept('GET', '/v0/search?query=*', { + body: stub, + statusCode: 200, + }); + + cy.intercept('GET', '/v0/search_typeahead?query=*', { + body: [ + 'benefits for assisted living', + 'benefits for terminally ill', + 'benefits', + 'benefits letter', + 'benefits for family of deceased', + ], + statusCode: 200, + }); + + cy.visit('/search/?query='); + cy.injectAxeThenAxeCheck(); + + cy.get(s.APP).within(() => { + cy.get(s.SEARCH_INPUT).type('benefits'); + cy.get(s.TYPEAHEAD_DROPDOWN).should('be.visible'); + cy.get(s.SEARCH_INPUT).focus(); + + verifyTAResult(0, 'benefits'); + verifyTAResult(1, 'benefits for assisted living'); + verifyTAResult(2, 'benefits for family of deceased'); + verifyTAResult(3, 'benefits for terminally ill'); + verifyTAResult(4, 'benefits letter'); + + cy.get(s.ERROR_ALERT_BOX).should('not.exist'); + cy.get(s.MAINT_BOX).should('not.exist'); + cy.get(s.OUTAGE_BOX).should('not.exist'); + + cy.get(s.TYPEAHEAD_OPTIONS) + .eq(3) + .click(); + + cy.get(s.SEARCH_RESULTS_COUNTER) + .should('be.visible') + .should( + 'have.text', + 'Showing 1-10 of 12 results for "benefits for terminally ill"', + ); + + verifyRecommendationsLink('VA Health Home Page', '/health'); + + verifyRecommendationsLink( + 'CHAMPVA Benefits', + '/health-care/family-caregiver-benefits/champva/', + ); + + verifyResultsLink( + 'Veterans Benefits Administration Home', + 'https://benefits.va.gov/benefits/', + ); + + verifyResultsLink('Download VA benefit letters', '/download-va-letters/'); + verifyResultsLink( + 'VA benefits for spouses, dependents, survivors, and family caregivers', + '/family-member-benefits/', + ); + }); + + cy.axeCheck(); + }); +}); diff --git a/src/applications/search/tests/e2e/unexpected-outage.cypress.spec.js b/src/applications/search/tests/e2e/unexpected-outage.cypress.spec.js new file mode 100644 index 000000000000..2d444ac52896 --- /dev/null +++ b/src/applications/search/tests/e2e/unexpected-outage.cypress.spec.js @@ -0,0 +1,39 @@ +import { SELECTORS as s } from './helpers'; + +describe('Unexpected outage from Search.gov', () => { + const enableToggle = () => { + cy.intercept('GET', '/v0/feature_toggles*', { + data: { + features: [ + { + name: 'search_gov_maintenance', + value: true, + }, + ], + }, + }); + }; + + const verifyBanner = () => { + cy.get(s.APP).within(() => { + cy.get(s.OUTAGE_BOX) + .should('exist') + .and('contain', 'We’re working on Search VA.gov right now.'); + }); + }; + + const verifyNoResults = () => { + cy.get(s.APP).within(() => { + cy.get(s.SEARCH_RESULTS).should('not.exist'); + cy.get(s.SEARCH_RESULTS_TITLE).should('not.exist'); + }); + }; + + it('should show the outage banner when the toggle is on', () => { + enableToggle(); + cy.visit('/search?query=benefits'); + cy.injectAxeThenAxeCheck(); + verifyBanner(); + verifyNoResults(); + }); +}); diff --git a/src/applications/search/tests/page-object/searchComponent.js b/src/applications/search/tests/page-object/searchComponent.js deleted file mode 100644 index 0aba86635cb5..000000000000 --- a/src/applications/search/tests/page-object/searchComponent.js +++ /dev/null @@ -1,110 +0,0 @@ -/** - * This uses the page object to enhance the writing of Cypress E2E tests by modularizing the code snippets - */ - -class SearchComponent { - /** Loads this component for testing by setting up the page and entering a search term */ - loadComponent = term => { - this.enableDropdownComponent(); - this.mockFetchSuggestions(); - this.prepareDropdownSearch(term); - }; - - /** Enables the dropdown component on the page */ - enableDropdownComponent = () => { - cy.intercept('GET', '/v0/feature_toggles*', { - data: { - features: [ - { - name: 'search_dropdown_component_enabled', - value: true, - }, - ], - }, - }); - }; - - /** Visits the va.gov page, clicks the Search field, and enters the search term */ - prepareDropdownSearch = term => { - cy.visit('/'); - cy.get('button.sitewide-search-drop-down-panel-button').click(); - cy.get('#search-header-dropdown-input-field').click(); - cy.get('#search-header-dropdown-input-field') - .should('exist') - .should('not.be.disabled') - .type(term, { force: true }); - }; - - /** Mocks the query typeahead suggestions */ - mockFetchSuggestions = () => { - cy.intercept('GET', 'v0/search_typeahead?query=benefits', [ - 'benefits response 1', - 'benefits response 2', - 'benefits response 3', - 'benefits response 4', - 'benefits response 5', - ]); - - cy.intercept('GET', 'v0/search_typeahead?query=health', [ - 'health response 1', - 'health response 2', - 'health response 3', - 'health response 4', - 'health response 5', - ]); - }; - - /** Opens the dropdown and checks its length is 5 */ - confirmDropDown = () => { - cy.get('#search-header-dropdown-listbox').should('be.visible'); - cy.get('#search-header-dropdown-listbox') - .children() - .should('have.length', 5); - }; - - /** Gets the input field dropdown, moves down three items, and presses Enter */ - navigateSearchSuggestions = () => { - cy.get('#search-header-dropdown-input-field').type('{downarrow}'); - cy.get('#search-header-dropdown-input-field').type('{downarrow}'); - cy.get('#search-header-dropdown-input-field').type('{downarrow}'); - cy.get('#search-header-dropdown-input-field').type('{enter}'); - }; - - /** Focuses on the Search button and checks that the listbox disappears */ - confirmSearchFocusHidesInput = () => { - cy.get('[data-e2e-id="search-header-dropdown-submit-button"]').focus(); - cy.get('#search-header-dropdown-listbox').should('not.exist'); - }; - - /** Presses enter in the Search input field */ - clickEnterInInputField = () => { - cy.get('#search-header-dropdown-input-field').type('{enter}'); - }; - - /** Click the Search submit button */ - clickSubmitButton = () => { - cy.get('[data-e2e-id="search-header-dropdown-submit-button"]').click(); - }; - - /** Focuses on the Search input field */ - focusOnInputField = () => { - cy.get('#search-header-dropdown-input-field').focus(); - }; - - /** Focuses on the Search submit button */ - focusOnSubmitButton = () => { - cy.get('[data-e2e-id="search-header-dropdown-submit-button"]').focus(); - }; - - /** Checks the current URL for the queryString */ - checkIfUrlContains = queryString => { - cy.url().should('contain', queryString); - }; - - /** Clicks on the third item in the Search dropdown list */ - clickTypeAheadItem = () => { - cy.get(`#search-header-dropdown-option-3`).click(); - }; -} - -export default SearchComponent; diff --git a/src/applications/search/utils.js b/src/applications/search/utils.js index 140a510e0e03..60b417c15baa 100644 --- a/src/applications/search/utils.js +++ b/src/applications/search/utils.js @@ -14,5 +14,5 @@ export function truncateResponseString(string, maxLength) { } export function removeDoubleBars(string) { - return string.replace('| Veterans Affairs', ''); + return string.replace(' | Veterans Affairs', ''); } diff --git a/src/applications/static-pages/homepage/HomepageSearch.jsx b/src/applications/static-pages/homepage/HomepageSearch.jsx index 21cf87ae801e..a56ee2f402ca 100644 --- a/src/applications/static-pages/homepage/HomepageSearch.jsx +++ b/src/applications/static-pages/homepage/HomepageSearch.jsx @@ -1,10 +1,9 @@ import React, { useState } from 'react'; import { VaSearchInput } from '@department-of-veterans-affairs/component-library/dist/react-bindings'; import PropTypes from 'prop-types'; -import * as Sentry from '@sentry/browser'; import recordEvent from 'platform/monitoring/record-event'; -import { apiRequest } from 'platform/utilities/api'; import { replaceWithStagingDomain } from 'platform/utilities/environment/stagingDomains'; +import { fetchTypeaheadSuggestions } from 'platform/utilities/search-utilities'; /** * Homepage redesign @@ -15,39 +14,6 @@ const HomepageSearch = () => { const [userInput, setUserInput] = useState(''); const [latestSuggestions, setLatestSuggestions] = useState([]); - // fetch Typeahead suggestions from API - const fetchDropDownSuggestions = async inputValue => { - // encode user input for query to suggestions url - const encodedInput = encodeURIComponent(inputValue); - - // fetch suggestions - try { - const apiRequestOptions = { - method: 'GET', - }; - const fetchedSuggestions = await apiRequest( - `/search_typeahead?query=${encodedInput}`, - apiRequestOptions, - ); - - if (fetchedSuggestions.length !== 0) { - return fetchedSuggestions.sort((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 []; - } - }; - // clear all suggestions and saved suggestions const clearSuggestions = () => { setLatestSuggestions([]); @@ -63,8 +29,7 @@ const HomepageSearch = () => { clearSuggestions(); return; } - - const results = await fetchDropDownSuggestions(inputValue); + const results = await fetchTypeaheadSuggestions(inputValue); setLatestSuggestions(results); }; diff --git a/src/applications/search/components/SearchDropdown/SearchDropdownComponent.js b/src/platform/site-wide/header/components/Search/SearchDropdownComponent.js similarity index 97% rename from src/applications/search/components/SearchDropdown/SearchDropdownComponent.js rename to src/platform/site-wide/header/components/Search/SearchDropdownComponent.js index 9e26f9505f8c..43aef122f5a2 100644 --- a/src/applications/search/components/SearchDropdown/SearchDropdownComponent.js +++ b/src/platform/site-wide/header/components/Search/SearchDropdownComponent.js @@ -1,7 +1,13 @@ +// March 2024: This file is duplicated from the search application (src/applications/search) because we +// converted the search app used on the page in /search/?query={query} to use web components +// The header cannot support web components yet due to its integration with TeamSites, so this is the original +// non-web-component version of the Search app import React from 'react'; import PropTypes from 'prop-types'; - -import { isSearchTermValid } from '~/platform/utilities/search-utilities'; +import { + fetchTypeaheadSuggestions, + isSearchTermValid, +} from '~/platform/utilities/search-utilities'; const Keycodes = { Backspace: 8, @@ -87,12 +93,6 @@ class SearchDropdownComponent extends React.Component { * A function that is passed to retrieve the input for the search app component * */ fetchInputValue: PropTypes.func, - /** - * A function that is passed the input value as a param, - * and is called (with a debounce) whenever the value of the input field changes - * this function MUST return an array of strings (suggestions) - * */ - fetchSuggestions: PropTypes.func.isRequired, /** * A boolean value for whether or not the search button shall move underneath the input field when viewed on a small screen * */ @@ -120,7 +120,6 @@ class SearchDropdownComponent extends React.Component { canSubmit: false, id: '', debounceRate: 200, - fetchSuggestions: undefined, formatSuggestions: false, fullWidthSuggestions: false, mobileResponsive: false, @@ -246,9 +245,8 @@ class SearchDropdownComponent extends React.Component { // call the fetchSuggestions prop and save the returned value into state fetchSuggestions = async inputValue => { - const { fetchSuggestions } = this.props; this.setState({ fetchingSuggestions: true }); - const suggestions = await fetchSuggestions(inputValue); + const suggestions = await fetchTypeaheadSuggestions(inputValue); this.setState({ suggestions, fetchingSuggestions: false }); }; @@ -730,6 +728,7 @@ class SearchDropdownComponent extends React.Component { role="listbox" aria-label="Search Suggestions" id={`${id}-listbox`} + data-e2e-id="search-dropdown-options" > {suggestions.map((suggestionString, i) => { const suggestion = formatSuggestions @@ -815,6 +814,7 @@ class SearchDropdownComponent extends React.Component { className={`search-dropdown-options full-width-suggestions vads-u-width--full vads-u-padding--x-1 vads-u-background-color--white vads-u-width--full ${suggestionsListClassName}`} role="listbox" id={`${id}-listbox`} + data-e2e-id="search-dropdown-options" > {suggestions.map((suggestionString, i) => { const suggestion = formatSuggestions diff --git a/src/platform/site-wide/header/components/Search/index.js b/src/platform/site-wide/header/components/Search/index.js index 3583d39f64fe..6c89ec887f2a 100644 --- a/src/platform/site-wide/header/components/Search/index.js +++ b/src/platform/site-wide/header/components/Search/index.js @@ -1,12 +1,10 @@ import React, { useState } from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import * as Sentry from '@sentry/browser'; -import { apiRequest } from '~/platform/utilities/api'; import recordEvent from '~/platform/monitoring/record-event'; import { toggleValues } from '~/platform/site-wide/feature-toggles/selectors'; import FEATURE_FLAG_NAMES from '~/platform/utilities/feature-toggles/featureFlagNames'; -import SearchDropdownComponent from '~/applications/search/components/SearchDropdown/SearchDropdownComponent'; +import SearchDropdownComponent from './SearchDropdownComponent'; import { replaceWithStagingDomain } from '../../../../utilities/environment/stagingDomains'; export const Search = ({ searchDropdownComponentEnabled }) => { @@ -36,40 +34,6 @@ export const Search = ({ searchDropdownComponentEnabled }) => { window.location.href = searchResultsPage; }; - // TA2.0 - const fetchDropDownSuggestions = async inputValue => { - // encode user input for query to suggestions url - const encodedInput = encodeURIComponent(inputValue); - - // fetch suggestions - try { - const apiRequestOptions = { - method: 'GET', - }; - const fetchedSuggestions = await apiRequest( - `/search_typeahead?query=${encodedInput}`, - apiRequestOptions, - ); - - if (fetchedSuggestions.length !== 0) { - return fetchedSuggestions.sort((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 []; - }; - const onInputSubmit = componentState => { const savedSuggestions = componentState?.savedSuggestions || []; const suggestions = componentState?.suggestions || []; @@ -162,7 +126,6 @@ export const Search = ({ searchDropdownComponentEnabled }) => { startingValue="" submitOnClick submitOnEnter - fetchSuggestions={fetchDropDownSuggestions} onInputSubmit={onInputSubmit} onSuggestionSubmit={onSuggestionSubmit} /> diff --git a/src/platform/site-wide/header/components/Search/styles.scss b/src/platform/site-wide/header/components/Search/styles.scss index 55e220207321..6d80ca013115 100644 --- a/src/platform/site-wide/header/components/Search/styles.scss +++ b/src/platform/site-wide/header/components/Search/styles.scss @@ -1,3 +1,5 @@ +@import "~@department-of-veterans-affairs/formation/sass/shared-variables"; + .header-search { input { max-width: unset; @@ -17,4 +19,44 @@ border-bottom-left-radius: 0; height: 42px; width: 45px; +} + +.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%; + } +} + +.suggestion { + line-height: 24px; + cursor: pointer; + + strong { + font-weight: 700; + } +} + +.search-dropdown-input-field { + height: 42px; +} + +.search-dropdown-submit-button { + height: 42px; } \ No newline at end of file diff --git a/src/platform/site-wide/user-nav/components/SearchDropdownComponent.js b/src/platform/site-wide/user-nav/components/SearchDropdownComponent.js new file mode 100644 index 000000000000..207fde64460f --- /dev/null +++ b/src/platform/site-wide/user-nav/components/SearchDropdownComponent.js @@ -0,0 +1,862 @@ +// March 2024: This file is duplicated from the search application (src/applications/search) because we +// converted the search app used on the page in /search/?query={query} to use web components +// The header cannot support web components yet due to its integration with TeamSites, so this is the original +// non-web-component version of the Search app +import React from 'react'; +import PropTypes from 'prop-types'; +import { + fetchTypeaheadSuggestions, + isSearchTermValid, +} from '~/platform/utilities/search-utilities'; + +const Keycodes = { + Backspace: 8, + Down: 40, + End: 35, + Enter: 13, + Escape: 27, + Home: 36, + Left: 37, + PageDown: 34, + PageUp: 33, + Right: 39, + // Space: 32, + Tab: 9, + Up: 38, +}; + +class SearchDropdownComponent extends React.Component { + static propTypes = { + /** + * A boolean value for whether the submit button should be rendered or not. + * */ + showButton: PropTypes.bool, + /** + * A string value that should be displayed on the submit button + * */ + buttonText: PropTypes.string, + /** + * A string value that will be prepended to the classnames for the button + * */ + buttonClassName: PropTypes.string, + /** + * A boolean value for whether or not the component has "submit" functionality + * */ + canSubmit: PropTypes.bool, + /** + * A string value that will be prepended on each id + * */ + id: PropTypes.string, + /** + * A string value that will be prepended to the classnames for the base component + * */ + componentClassName: PropTypes.string, + /** + * A string value that will be prepended to the classnames for the container + * */ + containerClassName: PropTypes.string, + /** + * A string value that will be prepended to the classnames for the input field + * */ + inputClassName: PropTypes.string, + /** + * A string value that will be prepended to the classnames for the suggestionsList + * */ + suggestionsListClassName: PropTypes.string, + /** + * A string value that will be prepended to the classnames for the individual suggestions + * */ + suggestionClassName: PropTypes.string, + /** + * the debounce rate at which to fetch suggestions + * */ + debounceRate: PropTypes.number, + /** + * A boolean value for whether or not suggestions are formatted to have the suggested values highlighted + * */ + formatSuggestions: PropTypes.bool, + /** + * A function that is called every time the input value changes, which is passed the current Input Value + * */ + getInputValue: PropTypes.func, + /** + * A function that is passed the current state as a param, + * and is called whenever the input field's current value is submitted + * */ + onInputSubmit: PropTypes.func, + /** + * A function that is passed the current state as a param, + * and is called whenever a suggested value is submitted + * */ + onSuggestionSubmit: PropTypes.func, + /** + * A function that is passed to retrieve the input for the search app component + * */ + fetchInputValue: PropTypes.func, + /** + * A boolean value for whether or not the search button shall move underneath the input field when viewed on a small screen + * */ + mobileResponsive: PropTypes.bool, + /** + * A string value for the default value of the input field. + * */ + startingValue: PropTypes.string, + /** + * A boolean value for whether clicking on a suggestion will "submit" it + * */ + submitOnClick: PropTypes.bool, + /** + * A boolean value for whether pressing enter with a suggestion will "submit" it + * */ + submitOnEnter: PropTypes.bool, + /** + * A boolean value for whether suggestions should take up the width of the input field and the button, or just the input field. + * */ + fullWidthSuggestions: PropTypes.bool, + }; + + static defaultProps = { + buttonText: '', + canSubmit: false, + id: '', + debounceRate: 200, + formatSuggestions: false, + fullWidthSuggestions: false, + mobileResponsive: false, + fetchInputValue: undefined, + getInputValue: undefined, + onInputSubmit: undefined, + onSuggestionSubmit: undefined, + showButton: true, + startingValue: '', + submitOnClick: false, + submitOnEnter: false, + buttonClassName: '', + componentClassName: '', + containerClassName: '', + inputClassName: '', + suggestionsListClassName: '', + suggestionClassName: '', + }; + + constructor(props) { + super(props); + this.state = { + activeIndex: undefined, + ignoreBlur: false, + inputValue: this.props.startingValue, + isOpen: false, + savedSuggestions: [], + suggestions: [], + a11yStatusMessage: '', + a11yLongStringMessage: '', + displayA11yDescriptionFlag: undefined, + fetchingSuggestions: true, + }; + } + + // when the component loads, fetch suggestions for our starting input value + componentDidMount() { + const { startingValue } = this.props; + + if (startingValue) { + const suggestions = this.fetchSuggestions(startingValue); + this.setState({ suggestions }); + } + + const displayA11yDescriptionFlag = window.sessionStorage.getItem( + 'searchA11yDescriptionFlag', + ); + this.setState({ displayA11yDescriptionFlag: !displayA11yDescriptionFlag }); + } + + // whenever the Input Value changes, call the prop function to export its value to the parent component + componentDidUpdate(prevProps, prevState) { + const { inputValue } = this.state; + const { getInputValue, fetchInputValue } = this.props; + + const inputChanged = prevState.inputValue !== inputValue; + + if (getInputValue && inputChanged) { + getInputValue(inputValue); + } + + if (fetchInputValue && inputChanged) { + fetchInputValue(inputValue); + } + + clearTimeout(this.updateA11yTimeout); + this.updateA11yTimeout = setTimeout(() => { + this.setA11yStatusMessage(); + }, 300); + + clearTimeout(this.updateCheckInputForErrors); + this.updateCheckInputForErrors = setTimeout(() => { + this.checkInputForErrors(); + }, 5000); + } + + // when the component unmounts, clear the timeout if we have one. + componentWillUnmount() { + clearTimeout(this.fetchSuggestionsTimeout); + clearTimeout(this.updateA11yTimeout); + } + + // format suggestions so that the suggested text is BOLD + formatSuggestion = suggestion => { + const { inputValue } = this.state; + + if (!suggestion || !inputValue) { + return suggestion; + } + const lowerSuggestion = suggestion?.toLowerCase(); + const lowerQuery = inputValue?.toLowerCase(); + if (lowerSuggestion.includes(lowerQuery)) { + return ( + <> + {inputValue} + {lowerSuggestion.replace(lowerQuery, '')} + + ); + } + return {lowerSuggestion}; + }; + + handleInputChange = event => { + // update the input value to the new value + const inputValue = event.target.value; + this.setState({ + inputValue, + activeIndex: undefined, + }); + + // clear suggestions if the input is too short + if (inputValue?.length <= 2) { + this.clearSuggestions(); + return; + } + + // reset the timeout so we only fetch for suggestions after the debounce timer has elapsed + clearTimeout(this.fetchSuggestionsTimeout); + this.fetchSuggestionsTimeout = setTimeout(() => { + this.fetchSuggestions(inputValue); + }, this.props.debounceRate); + }; + + // call the fetchSuggestions prop and save the returned value into state + fetchSuggestions = async inputValue => { + this.setState({ fetchingSuggestions: true }); + const suggestions = await fetchTypeaheadSuggestions(inputValue); + this.setState({ suggestions, fetchingSuggestions: false }); + }; + + // handle blur logic + onInputBlur() { + const { ignoreBlur, isOpen } = this.state; + + if (ignoreBlur) { + this.setState({ ignoreBlur: false }); + return; + } + + if (isOpen) { + this.updateMenuState(false, false); + } + } + + focusIndex(index) { + this.setState({ activeIndex: index, ignoreBlur: true }, () => { + if (index !== undefined) { + document.getElementById(`${this.props.id}-option-${index}`).focus({ + preventScroll: true, + }); + } + }); + } + + // this is the handler for all of the keypress logic + onKeyDown = event => { + const { suggestions, isOpen, activeIndex } = this.state; + const { + canSubmit, + onInputSubmit, + submitOnEnter, + onSuggestionSubmit, + } = this.props; + const max = suggestions.length - 1; + + const currentKeyPress = event.which || event.keyCode; + + // if the menu is not open and the DOWN arrow key is pressed, open the menu + if (!isOpen && currentKeyPress === Keycodes.Down) { + event.preventDefault(); + this.updateMenuState(true, false); + return; + } + + // if the menu is not open and the ENTER key is pressed, search for the term currently in the input field + if (!isOpen && currentKeyPress === Keycodes.Enter && canSubmit) { + event.preventDefault(); + this.setA11yDescriptionFlag(false); + onInputSubmit(this.state); + return; + } + + // handle keys when open + // next + // when the DOWN key is pressed, select the next option in the drop down. + // if the last option is selected, cycle to the first option instead + if (currentKeyPress === Keycodes.Down) { + event.preventDefault(); + if (suggestions.length > 0) { + if (activeIndex === undefined || activeIndex + 1 > max) { + this.focusIndex(0); + + return; + } + + this.focusIndex(activeIndex + 1); + return; + } + } + + // previous + // when the UP key is pressed, select the previous option in the drop down. + // if the first option is selected, cycle to the last option instead + if (currentKeyPress === Keycodes.Up) { + event.preventDefault(); + + if (suggestions.length > 0) { + if (activeIndex - 1 < 0) { + this.focusIndex(max); + + return; + } + this.focusIndex(activeIndex - 1); + return; + } + } + + // first + // when the HOME key is pressed, select the first option in the drop down menu + if (currentKeyPress === Keycodes.Home) { + if (suggestions.length > 0) { + this.focusIndex(0); + } + return; + } + + // last + // when the END key is pressed, select the last option in the drop down menu + if (currentKeyPress === Keycodes.End) { + if (suggestions.length > 0) { + this.focusIndex(max); + } + return; + } + + // close + // when the ESCAPE key is pressed, close the drop down menu WITHOUT selecting any of the options + if (currentKeyPress === Keycodes.Escape) { + document.getElementById(`${this.props.id}-input-field`).focus(); + + this.setState({ activeIndex: undefined, isOpen: false }); + return; + } + + // close and select + // when the enter key is pressed, fire off a search of the Input Value if no menu items are selected + // if a menu item is selected, close the drown down menu and select the currently highlighted option + // if submitOnEnter is true, fire off the onInputSubmit function as well + if (currentKeyPress === Keycodes.Enter) { + event.preventDefault(); + + if (activeIndex === undefined && canSubmit) { + this.setA11yDescriptionFlag(false); + onInputSubmit(this.state); + return; + } + if (!submitOnEnter) { + this.updateMenuState(false, true); + this.selectOption(activeIndex); + + return; + } + if (canSubmit) { + this.setA11yDescriptionFlag(false); + this.selectOption(activeIndex); + this.setState({ isOpen: false }, () => { + onSuggestionSubmit(activeIndex, this.state); + }); + } + } + + // when the tab key is pressed, close the suggestions list and select the search button + if (currentKeyPress === Keycodes.Tab) { + // if focused on the input field and press shift + tab, close the menu and allow default behavior + if (activeIndex === undefined && event.shiftKey) { + this.updateMenuState(false, false); + return; + } + + // each of the below events should override default tab behavior + event.preventDefault(); + + // if in the dropdown options and press shift+tab, close the suggestions and focus the input bar + if (event.shiftKey && activeIndex !== undefined) { + this.setState({ activeIndex: undefined }, () => { + this.updateMenuState(false, true); + }); + return; + } + // in in the dropdown options and press tab, select the current item and focus the search button + if (!event.shiftKey && activeIndex !== undefined) { + this.selectOption(activeIndex); + document.getElementById(`${this.props.id}-submit-button`).focus(); + return; + } + + // else if on the input bar and you press tab, focus the button + this.setState({ savedSuggestions: suggestions, suggestions: [] }); + document.getElementById(`${this.props.id}-submit-button`).focus(); + } + }; + + // when an option is clicked + // if submitOnClick is true, then initiate the onSuggestionSubmit function + // otherwise, select the option and close the drop down menu + onOptionClick(index) { + const { submitOnClick, onSuggestionSubmit, canSubmit } = this.props; + + if (!submitOnClick) { + this.setState({ activeIndex: index }); + this.updateMenuState(false, true); + this.selectOption(index); + return; + } + if (canSubmit) { + this.setA11yDescriptionFlag(false); + this.selectOption(index); + this.setState({ isOpen: false }, () => { + onSuggestionSubmit(index, this.state); + }); + } + } + + // select an option from the dropdown menu, updating the inputValue state + selectOption(index) { + const { suggestions } = this.state; + const inputValue = suggestions[index]; + + this.setState({ + inputValue, + activeIndex: undefined, + savedSuggestions: suggestions, + }); + } + + setA11yDescriptionFlag = value => { + window.sessionStorage.setItem('searchA11yDescriptionFlag', value); + this.setState({ displayA11yDescriptionFlag: value }); + }; + + // update whether the menu is open or closed, and refocus the menu if called for + updateMenuState(open, callFocus = true) { + this.setState({ isOpen: open }); + + if (callFocus) { + document.getElementById(`${this.props.id}-input-field`).focus(); + } + } + + // clear all suggestions and saved suggestions + clearSuggestions = () => { + this.setState({ suggestions: [], savedSuggestions: [] }); + }; + + // save the current suggestions list into saved suggestions + saveSuggestions = () => { + const { suggestions } = this.state; + this.setState({ savedSuggestions: suggestions }); + }; + + // handle shift tabbing to reset suggestions list + handleButtonShift = event => { + const { savedSuggestions } = this.state; + const { id } = this.props; + const currentKeyPress = event.which || event.keycode; + if (event.shiftKey && currentKeyPress === Keycodes.Tab) { + event.preventDefault(); + this.setState( + { + suggestions: savedSuggestions, + savedSuggestions: [], + }, + () => { + document.getElementById(`${id}-input-field`).focus(); + }, + ); + } + }; + + // derive the ally status message for screen reader + setA11yStatusMessage = () => { + const { + isOpen, + suggestions, + activeIndex, + inputValue, + fetchingSuggestions, + } = this.state; + + const suggestionsCount = suggestions?.length; + + if (inputValue.length > 255) { + this.setState({ + a11yStatusMessage: + 'The search is over the character limit. Shorten the search and try again.', + }); + return; + } + + if ( + !isOpen && + (document.activeElement !== + document.getElementById(`${this.props.id}-input-field`) || + document.activeElement !== + document.getElementById(`${this.props.id}-submit-button`)) + ) { + this.setState({ + a11yStatusMessage: '', + }); + return; + } + + if (!isOpen && suggestionsCount) { + this.setState({ + a11yStatusMessage: `Closed, ${suggestionsCount} suggestion${ + suggestionsCount === 1 ? ' is' : 's are' + } + available`, + }); + return; + } + + if (!isOpen) { + this.setState({ + a11yStatusMessage: '', + }); + return; + } + + if (!suggestionsCount && inputValue?.length > 3 && !fetchingSuggestions) { + this.setState({ + a11yStatusMessage: 'No suggestions are available.', + }); + return; + } + + if (!(activeIndex + 1) && inputValue?.length > 2) { + this.setState({ + a11yStatusMessage: `Expanded, ${suggestionsCount} suggestion${ + suggestionsCount === 1 ? ' is' : 's are' + } + available`, + }); + return; + } + + if (activeIndex !== undefined) { + this.setState({ + a11yStatusMessage: `${ + suggestions[activeIndex] + }, selected ${activeIndex + 1} of ${suggestionsCount}`, + }); + return; + } + + this.setState({ + a11yStatusMessage: '', + }); + }; + + checkInputForErrors = () => { + if (!isSearchTermValid(this.state.inputValue)) { + this.setState({ + a11yLongStringMessage: + 'The search is over the character limit. Shorten the search and try again.', + }); + clearTimeout(this.resetA11yMessage); + this.resetA11yMessage = setTimeout(() => { + this.setState({ + a11yLongStringMessage: '', + }); + }, 5000); + } + }; + + resetInputForErrors = () => { + this.setState({ + a11yLongStringMessage: '', + }); + }; + + // render + render() { + const { + activeIndex, + isOpen, + inputValue, + suggestions, + a11yStatusMessage, + displayA11yDescriptionFlag, + a11yLongStringMessage, + } = this.state; + + const { + componentClassName, + containerClassName, + buttonClassName, + inputClassName, + suggestionsListClassName, + suggestionClassName, + id, + fullWidthSuggestions, + formatSuggestions, + showButton, + canSubmit, + onInputSubmit, + buttonText, + mobileResponsive, + } = this.props; + + let activeId; + if (isOpen && activeIndex !== undefined) { + activeId = `${id}-option-${activeIndex}`; + } + const inputHasLengthError = inputValue.length >= 255; + + const assistiveHintid = `${id}-assistive-hint`; + + const errorStringLengthId = + 'search-results-page-dropdown-a11y-status-message'; + + const mobileResponsiveClass = mobileResponsive ? 'shrink-to-column' : ''; + + const ariaDescribedProp = displayA11yDescriptionFlag + ? { + 'aria-describedby': inputHasLengthError + ? `${errorStringLengthId} ${assistiveHintid}` + : assistiveHintid, + } + : null; + + const validOpen = isOpen && suggestions.length > 0; + + return ( +
    +
    + + {a11yStatusMessage} + + + {inputHasLengthError && ( + + {a11yLongStringMessage} + + )} + + + Use up and down arrows to review autocomplete results and enter to + search. Touch device users, explore by touch or with swipe gestures. + + this.onInputBlur()} + onChange={this.handleInputChange} + onClick={() => this.updateMenuState(true)} + onFocus={() => this.updateMenuState(true)} + onKeyDown={this.onKeyDown} + /> + {validOpen && + !fullWidthSuggestions && ( +
    + {suggestions.map((suggestionString, i) => { + const suggestion = formatSuggestions + ? this.formatSuggestion(suggestionString) + : suggestionString; + return ( +
    { + this.onOptionClick(i); + }} + onMouseDown={() => { + this.setState({ ignoreBlur: true }); + }} + onMouseOver={() => { + this.setState({ activeIndex: i }); + }} + onKeyDown={this.onKeyDown} + onFocus={() => { + this.setState({ activeIndex: i }); + }} + > + {suggestion} +
    + ); + })} +
    + )} +
    + {/* only show the submit button if the component has submit capabilities */} + {showButton && + canSubmit && ( + + )} + {validOpen && + fullWidthSuggestions && ( +
    + {suggestions.map((suggestionString, i) => { + const suggestion = formatSuggestions + ? this.formatSuggestion(suggestionString) + : suggestionString; + return ( +
    { + this.onOptionClick(i); + }} + onMouseDown={() => { + this.setState({ ignoreBlur: true }); + }} + onMouseOver={() => { + this.setState({ activeIndex: i }); + }} + onKeyDown={this.onKeyDown} + onFocus={() => { + this.setState({ activeIndex: i }); + }} + > + {suggestion} +
    + ); + })} +
    + )} +
    + ); + } +} + +export default SearchDropdownComponent; diff --git a/src/platform/site-wide/user-nav/components/SearchMenu.jsx b/src/platform/site-wide/user-nav/components/SearchMenu.jsx index bb5a2d15bb31..fd8a94e8015e 100644 --- a/src/platform/site-wide/user-nav/components/SearchMenu.jsx +++ b/src/platform/site-wide/user-nav/components/SearchMenu.jsx @@ -5,10 +5,8 @@ import { toggleValues } from 'platform/site-wide/feature-toggles/selectors'; import FEATURE_FLAG_NAMES from 'platform/utilities/feature-toggles/featureFlagNames'; import classNames from 'classnames'; import recordEvent from 'platform/monitoring/record-event'; -import * as Sentry from '@sentry/browser'; -import { apiRequest } from 'platform/utilities/api'; -import SearchDropdownComponent from 'applications/search/components/SearchDropdown/SearchDropdownComponent'; +import SearchDropdownComponent from './SearchDropdownComponent'; import DropDownPanel from './DropDownPanel/DropDownPanel'; import { replaceWithStagingDomain } from '../../../utilities/environment/stagingDomains'; @@ -107,39 +105,6 @@ export class SearchMenu extends React.Component { window.location.assign(searchUrl); }; - // TA2.0 - fetchDropDownSuggestions = async inputValue => { - // encode user input for query to suggestions url - const encodedInput = encodeURIComponent(inputValue); - - // fetch suggestions - try { - const apiRequestOptions = { - method: 'GET', - }; - const fetchedSuggestions = await apiRequest( - `/search_typeahead?query=${encodedInput}`, - apiRequestOptions, - ); - - if (fetchedSuggestions.length !== 0) { - return fetchedSuggestions.sort((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 []; - }; - onInputSubmit = componentState => { const savedSuggestions = componentState?.savedSuggestions || []; const suggestions = componentState?.suggestions || []; @@ -212,7 +177,7 @@ export class SearchMenu extends React.Component { }; makeForm = () => { - const { searchDropdownComponentEnabled } = this.props; + const { searchDropdownComponentEnabled = true } = this.props; const { handleInputChange, handleSearchEvent } = this; // default search experience @@ -234,7 +199,7 @@ export class SearchMenu extends React.Component { aria-label="search" autoComplete="off" ref="searchField" - className="usagov-search-autocomplete vads-u-margin-left--0p5" + className="usagov-search-autocomplete vads-u-margin-left--0p5 search-dropdown-input-field" id="query" name="query" type="text" @@ -286,7 +251,6 @@ export class SearchMenu extends React.Component { startingValue="" submitOnClick submitOnEnter - fetchSuggestions={this.fetchDropDownSuggestions} onInputSubmit={this.onInputSubmit} onSuggestionSubmit={this.onSuggestionSubmit} /> diff --git a/src/platform/site-wide/user-nav/index.js b/src/platform/site-wide/user-nav/index.js index 109999e1276f..43c121357f96 100644 --- a/src/platform/site-wide/user-nav/index.js +++ b/src/platform/site-wide/user-nav/index.js @@ -8,8 +8,6 @@ import React from 'react'; import { Provider } from 'react-redux'; -// necessary styles for the search dropdown component -import 'applications/search/components/SearchDropdown/SearchDropdownStyles.scss'; import './sass/user-nav.scss'; import startReactApp from '../../startup/react'; import Main from './containers/Main'; diff --git a/src/platform/site-wide/user-nav/sass/user-nav.scss b/src/platform/site-wide/user-nav/sass/user-nav.scss index b8e97846bea4..02765f423808 100644 --- a/src/platform/site-wide/user-nav/sass/user-nav.scss +++ b/src/platform/site-wide/user-nav/sass/user-nav.scss @@ -1,6 +1,73 @@ @import "~@department-of-veterans-affairs/formation/sass/shared-variables"; @import "~@department-of-veterans-affairs/css-library/dist/stylesheets/modules/m-modal"; +.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; +} + +.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%; + } +} + +.suggestion { + line-height: 24px; + cursor: pointer; + + strong { + font-weight: 700; + } +} + +.search-dropdown-input-field { + height: 42px; +} + +.search-dropdown-submit-button { + height: 42px; +} + #loginGovExperimentModal { ul { list-style: unset; @@ -94,6 +161,7 @@ span.sidelines { } #login-root { + .sitewide-search-drop-down-panel-button, .sign-in-drop-down-panel-button { border-top: none; @@ -383,4 +451,4 @@ span.sidelines { text-decoration: none; border-bottom: 0; } -} +} \ No newline at end of file diff --git a/src/platform/utilities/search-utilities.js b/src/platform/utilities/search-utilities.js index e6dcc665d53a..3ce0ab82f378 100644 --- a/src/platform/utilities/search-utilities.js +++ b/src/platform/utilities/search-utilities.js @@ -1,3 +1,6 @@ +import * as Sentry from '@sentry/browser'; +import { apiRequest } from '~/platform/utilities/api'; + export const isSearchTermValid = term => { if (!term) { return false; @@ -5,3 +8,41 @@ export const isSearchTermValid = term => { return term.trim().length <= 255; }; + +export const fetchTypeaheadSuggestions = 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((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 []; +};