From 6f3fe1901651f09855da3fcae39ad53b22ff2b75 Mon Sep 17 00:00:00 2001 From: Sherakama Date: Wed, 30 Oct 2024 10:34:44 -0700 Subject: [PATCH 1/3] DS-734: Add alumni events to federated search (#936) * Add alumni events to the federated search Co-authored-by: Eric Bakenhus <1059679+ericbakenhus@users.noreply.github.com> --- .../2-travel-study/destinations-filter.cy.ts | 10 +- .../2-travel-study/registration-form.cy.ts | 8 +- cypress/e2e/4-search/search-results.cy.ts | 14 +- gatsby-browser.js | 13 +- src/components/accessibility/Skiplink.jsx | 17 + src/components/components.js | 10 +- .../content-types/perk/perkPageView.js | 2 +- .../content-types/story/storyPageView.js | 2 +- .../events-discovery/eventsDiscovery.jsx | 2 +- .../identity/GlobalHeader/GlobalHeader.jsx | 64 +-- src/components/identity/masthead.js | 74 +-- .../TripFilterPage/TripFilterPage.jsx | 4 +- .../page-types/TripPage/TripPage.jsx | 2 +- .../associatesDirectoryPage.js | 2 +- .../{basicPage.js => basicPage.jsx} | 2 +- .../page-types/{darkPage.js => darkPage.jsx} | 2 +- .../page-types/formPage/formPage.js | 2 +- .../{gsbCardPage.js => gsbCardPage.jsx} | 2 +- .../page-types/lightFormPage/lightFormPage.js | 2 +- .../membershipFormPage/membershipFormPage.js | 2 +- .../membershipFullPaymentForm.js | 2 +- .../membershipInstallmentsForm.js | 2 +- .../relatedContactSelection.js | 2 +- .../{protectedPage.js => protectedPage.jsx} | 2 +- .../registrationFormPage/interstitialPage.js | 2 +- .../registrationFormPage.js | 2 +- src/components/page-types/searchPage.js | 443 ------------------ src/components/page-types/searchPage.jsx | 78 +++ .../partials/{layout.js => Layout.jsx} | 0 .../search/Hits/SearchResultAlumniEvent.jsx | 132 ++++++ .../search/Hits/SearchResultDefault.jsx | 110 +++++ .../search/Mobile/MobileFilterFooter.jsx | 44 ++ .../search/Mobile/MobileFilterHeader.jsx | 32 ++ .../OpenSearchModalButton.jsx} | 11 +- .../search/Modal/SearchFieldModal.jsx | 209 +++++++++ .../{searchModal.js => Modal/SearchModal.jsx} | 50 +- .../search/Modal/SearchModalContext.jsx | 71 +++ src/components/search/SearchFacet.jsx | 87 ++++ src/components/search/SearchField.jsx | 216 +++++++++ ...ywordBanner.js => SearchKeywordBanner.jsx} | 7 +- src/components/search/SearchNoResults.jsx | 22 + src/components/search/SearchPageContent.jsx | 266 +++++++++++ src/components/search/SearchPager.jsx | 147 ++++++ src/components/search/SearchResults.jsx | 83 ++++ ...chSuggestions.js => SearchSuggestions.jsx} | 0 src/components/search/searchAutocomplete.js | 81 ---- src/components/search/searchFacet.js | 80 ---- src/components/search/searchField.js | 182 ------- src/components/search/searchFieldModal.js | 74 --- src/components/search/searchNoResults.js | 17 - src/components/search/searchPager.js | 82 ---- src/components/search/searchResults.js | 117 ----- src/utilities/checkUTMParams.js | 15 + src/utilities/decodeHtmlEntities.js | 10 + 54 files changed, 1638 insertions(+), 1276 deletions(-) rename src/components/page-types/{basicPage.js => basicPage.jsx} (98%) rename src/components/page-types/{darkPage.js => darkPage.jsx} (98%) rename src/components/page-types/{gsbCardPage.js => gsbCardPage.jsx} (99%) rename src/components/page-types/{protectedPage.js => protectedPage.jsx} (98%) delete mode 100644 src/components/page-types/searchPage.js create mode 100644 src/components/page-types/searchPage.jsx rename src/components/partials/{layout.js => Layout.jsx} (100%) create mode 100644 src/components/search/Hits/SearchResultAlumniEvent.jsx create mode 100644 src/components/search/Hits/SearchResultDefault.jsx create mode 100644 src/components/search/Mobile/MobileFilterFooter.jsx create mode 100644 src/components/search/Mobile/MobileFilterHeader.jsx rename src/components/search/{openSearchModalButton.js => Modal/OpenSearchModalButton.jsx} (65%) create mode 100644 src/components/search/Modal/SearchFieldModal.jsx rename src/components/search/{searchModal.js => Modal/SearchModal.jsx} (50%) create mode 100644 src/components/search/Modal/SearchModalContext.jsx create mode 100644 src/components/search/SearchFacet.jsx create mode 100644 src/components/search/SearchField.jsx rename src/components/search/{searchKeywordBanner.js => SearchKeywordBanner.jsx} (87%) create mode 100644 src/components/search/SearchNoResults.jsx create mode 100644 src/components/search/SearchPageContent.jsx create mode 100644 src/components/search/SearchPager.jsx create mode 100644 src/components/search/SearchResults.jsx rename src/components/search/{searchSuggestions.js => SearchSuggestions.jsx} (100%) delete mode 100644 src/components/search/searchAutocomplete.js delete mode 100644 src/components/search/searchFacet.js delete mode 100644 src/components/search/searchField.js delete mode 100644 src/components/search/searchFieldModal.js delete mode 100644 src/components/search/searchNoResults.js delete mode 100644 src/components/search/searchPager.js delete mode 100644 src/components/search/searchResults.js create mode 100644 src/utilities/checkUTMParams.js create mode 100644 src/utilities/decodeHtmlEntities.js diff --git a/cypress/e2e/2-travel-study/destinations-filter.cy.ts b/cypress/e2e/2-travel-study/destinations-filter.cy.ts index 3e1477593..81afec152 100644 --- a/cypress/e2e/2-travel-study/destinations-filter.cy.ts +++ b/cypress/e2e/2-travel-study/destinations-filter.cy.ts @@ -19,19 +19,19 @@ describe('Travel-Study Destinations Page', () => { cy.wait(1000); // Select Filter Month and check for URL update - cy.get('[data-test="filter-option--october"]').first().check({ force: true }); - cy.url().should('contain', 'trip-month=oct'); + cy.get('[data-test="filter-option--february"]').first().check({ force: true }); + cy.url().should('contain', 'trip-month=feb'); // Load the filter from the URL - cy.visit('/travel-study/destinations/?page=1&trip-month=oct'); + cy.visit('/travel-study/destinations/?page=1&trip-month=feb'); // Confirm chip exists - cy.get('[data-test="chip:October"]').should('exist'); + cy.get('[data-test="chip:February"]').should('exist'); // Confirm that a trip card exists cy.get('.trip-filter-page article h3').should('exist'); // Clear Filters and check for URL update cy.get('[data-test="filter-btn--clear-all"]').first().click({force: true}); - cy.url().should('not.contain', 'trip-month=oct'); + cy.url().should('not.contain', 'trip-month=feb'); // Enable family focused filter cy.visit('/travel-study/destinations/?page=1&trip-experience=family-focused'); diff --git a/cypress/e2e/2-travel-study/registration-form.cy.ts b/cypress/e2e/2-travel-study/registration-form.cy.ts index 3eb9278aa..6658d414b 100644 --- a/cypress/e2e/2-travel-study/registration-form.cy.ts +++ b/cypress/e2e/2-travel-study/registration-form.cy.ts @@ -46,16 +46,16 @@ describe('Travel-Study Trip Registration Form Page', () => { // Confirm form exists cy.get('[id="su-embed"]').should('exist'); cy.get('form').should('exist'); - + // Confirm that the user's name is prefilled cy.get('[data-fieldid="DigitalName"]').should('contain.value', 'Teri Dactyl'); - + // Confirm email is prefilled cy.get('[data-fieldid="ContactEmail"]').should('contain.value', 'tdactyl@test.com'); // Confirm phone number is prefilled cy.get('[data-fieldid="PhoneNumber"]').should('contain.value', '4081111111'); - + // Fill required questions cy.get('[data-fieldid="Pre-TripExtension__0"]').first().check({force: true}); cy.get('[data-fieldid="Post-TripExtension__0"]').first().check({force: true}); @@ -70,7 +70,7 @@ describe('Travel-Study Trip Registration Form Page', () => { // Visit the membership form page URL cy.visit('/travel-study/destinations/finland-2022/finland-reg-form/form'); - + // Confirm that the URL redirect to the expected URL cy.url().should('include', '/travel-study/destinations/finland-2022/finland-reg-form'); cy.reload(); // Needed for local Gatsby build diff --git a/cypress/e2e/4-search/search-results.cy.ts b/cypress/e2e/4-search/search-results.cy.ts index 83edaaa00..0ee80ee3f 100644 --- a/cypress/e2e/4-search/search-results.cy.ts +++ b/cypress/e2e/4-search/search-results.cy.ts @@ -9,9 +9,7 @@ describe('Search Results', () => { }) cy.get('[data-test="search--modal-input"]').should('exist').type('travel study'); - cy.get('[data-cy="search--submit-btn"]').first().click(); - - cy.reload({timeout: 1000}) // Needed for local Gatsby build + cy.get('[data-test="search--submit-btn"]').first().click(); cy.url().should('include', '/search/?q=travel%20study'); }) it('should return travel study search results', () => { @@ -25,17 +23,13 @@ describe('Search Results', () => { it('should have working Alumni facet filter', () => { cy.visit('/search/?q=travel%20study'); // Confirm Alumni facet filter exists and check - cy.get('input[value="Alumni"]').should('exist').check({ force: true }); - - cy.reload() // Needed for local Gatsby build - cy.url().should('include', '/search/?q=travel+study&site=Alumni'); + cy.get('#search-desktop-filters input[data-test="siteName-alumni"]').should('exist').click(); + cy.url().should('include', '/search/?q=travel%20study&sites%5B0%5D=Alumni'); }); it('should return no search results when searching lorem ipsum', () => { cy.visit('/search/?q=lorem%20ipsum'); - cy.reload() // Needed for local Gatsby build - cy.get('h1').should('contain.text', 'Search for...'); - cy.get('section > div form input[data-test="search--modal-input"]').should('contain.value','lorem ipsum'); + cy.get('#search-field-input').should('contain.value','lorem ipsum'); cy.get('h2').should('contain.text', 'We’re sorry, we couldn’t find results for “lorem ipsum”.') cy.get('h3').should('contain.text', 'Consider Browsing by Category:') diff --git a/gatsby-browser.js b/gatsby-browser.js index 3782e83e9..dae04e644 100644 --- a/gatsby-browser.js +++ b/gatsby-browser.js @@ -9,6 +9,7 @@ import React from 'react'; // Contexts. import { GlobalStateProvider } from './src/contexts/GlobalContext'; import { AuthContextProvider } from './src/contexts/AuthContext'; +import { SearchModalProvider } from './src/components/search/Modal/SearchModalContext'; // CSS import './src/styles/global.css'; @@ -19,10 +20,15 @@ import './src/styles/light-forms.css'; // Exports. export const wrapRootElement = ({ element }) => ( - {element} + + {element} + ); +/** + * Handle hash changes to prevent scroll. + */ export const shouldUpdateScroll = (ctx) => { const { routerProps: { location }, @@ -30,10 +36,7 @@ export const shouldUpdateScroll = (ctx) => { } = ctx; // Prevent scrolling when user clicks on filters on search page. - if ( - location.pathname.match(/^\/search/i) || - location.pathname.match(/^\/travel-study\/search/i) - ) { + if (location.pathname.match(/^\/travel-study\/search/i)) { return false; } diff --git a/src/components/accessibility/Skiplink.jsx b/src/components/accessibility/Skiplink.jsx index a77b288ca..915ae0908 100644 --- a/src/components/accessibility/Skiplink.jsx +++ b/src/components/accessibility/Skiplink.jsx @@ -1,6 +1,8 @@ import React, { useRef } from 'react'; import PropTypes from 'prop-types'; import { dcnb } from 'cnbuilder'; +import { navigate } from 'gatsby'; +import scrollTo from 'gatsby-plugin-smoothscroll'; import { ClassNameType } from '../../types/CommonType'; const SkiplinkProps = { @@ -16,9 +18,24 @@ export const Skiplink = ({ ...props }) => { const ref = useRef(null); + const currentLocation = + typeof window !== 'undefined' ? window.location.pathname : ''; + const currentSearch = + typeof window !== 'undefined' ? window.location.search : ''; + const currentUrl = `${currentLocation}${currentSearch}`; return ( { + e.preventDefault(); + navigate(currentUrl + anchorLink); + const elem = document.getElementById(anchorLink.replace('#', '')); + const prevTabIndex = elem.tabIndex; + elem.setAttribute('tabindex', -1); + elem.focus({ preventScroll: true }); + elem.setAttribute('tabindex', prevTabIndex); + scrollTo(anchorLink); + }} className={dcnb('su-skiplink', className)} ref={ref} onFocus={() => ref.current.scrollIntoView()} diff --git a/src/components/components.js b/src/components/components.js index 86f53c07c..845b7d5d1 100644 --- a/src/components/components.js +++ b/src/components/components.js @@ -59,12 +59,12 @@ import Redirect from './redirect/Redirect'; import RegistrationFormPage from './page-types/registrationFormPage/registrationFormPage'; import { SBSAAMainNav } from './storyblok/saaMainNav'; import { SBSAAMainMenuGroup } from './storyblok/saaMainMenuGroup'; -import SearchFacet from './search/searchFacet'; -import SearchField from './search/searchField'; +import SearchFacet from './search/SearchFacet'; +import SearchField from './search/SearchField'; import SearchPage from './page-types/searchPage'; -import SearchPager from './search/searchPager'; -import SearchResults from './search/searchResults'; -import SearchSuggestions from './search/searchSuggestions'; +import SearchPager from './search/SearchPager'; +import SearchResults from './search/SearchResults'; +import SearchSuggestions from './search/SearchSuggestions'; import Section from './layout/section'; import SimpleImage from './media/simpleImage'; import Story from './content-types/story/story'; diff --git a/src/components/content-types/perk/perkPageView.js b/src/components/content-types/perk/perkPageView.js index e96495782..b0e1c4220 100644 --- a/src/components/content-types/perk/perkPageView.js +++ b/src/components/content-types/perk/perkPageView.js @@ -2,7 +2,7 @@ import SbEditable from 'storyblok-react'; import React from 'react'; import { Heading } from '../../simple/Heading'; import CardImage from '../../media/cardImage'; -import Layout from '../../partials/layout'; +import Layout from '../../partials/Layout'; const PerkPageView = (props) => { // Destructure props diff --git a/src/components/content-types/story/storyPageView.js b/src/components/content-types/story/storyPageView.js index fb43d70d9..1afefb124 100644 --- a/src/components/content-types/story/storyPageView.js +++ b/src/components/content-types/story/storyPageView.js @@ -3,7 +3,7 @@ import React from 'react'; import { DateTime } from 'luxon'; import { Container } from '../../layout/Container'; import { Heading } from '../../simple/Heading'; -import Layout from '../../partials/layout'; +import Layout from '../../partials/Layout'; import CreateBloks from '../../../utilities/createBloks'; import FullWidthImage from '../../media/fullWidthImage'; import getNumBloks from '../../../utilities/getNumBloks'; diff --git a/src/components/events-discovery/eventsDiscovery.jsx b/src/components/events-discovery/eventsDiscovery.jsx index 542782a3e..233e9f060 100644 --- a/src/components/events-discovery/eventsDiscovery.jsx +++ b/src/components/events-discovery/eventsDiscovery.jsx @@ -120,7 +120,7 @@ const EventDiscoveryContent = () => { { - const [modalOpen, setModalOpen] = useState(false); - const desktopRef = useRef(null); - const mobileRef = useRef(null); - const openSearchRef = useRef(null); - const openSearchMobileRef = useRef(null); - - const returnFocus = () => { - if (openSearchRef.current) { - openSearchRef.current.focus(); - } else if (openSearchMobileRef.current) { - openSearchMobileRef.current.focus(); - } - }; - - const handleClose = () => { - setModalOpen(false); - returnFocus(); - }; - - useEscape(() => { - // Only do this if the search modal is open - if (modalOpen) { - const searchInputModal = - document.getElementsByClassName('search-input-modal')[0]; - - // Only close the modal with Escape key if the autocomplete dropdown is not open - if (searchInputModal.getAttribute('aria-expanded') !== 'true') { - setModalOpen(false); - returnFocus(); - } - } - }); - // Use the useDisplay hook to determine whether to display the desktop of mobile header const { showDesktop, showMobile } = useDisplay(); + const { desktopButtonRef, mobileButtonRef } = useContext(SearchModalContext); return ( <> {showMobile && ( -
+
@@ -108,7 +74,7 @@ const GlobalHeader = ({
)} {showDesktop && ( -
+
@@ -122,9 +88,8 @@ const GlobalHeader = ({ itemClasses={styles.utilNavItem} />
@@ -136,12 +101,7 @@ const GlobalHeader = ({
)} - + ); }; diff --git a/src/components/identity/masthead.js b/src/components/identity/masthead.js index 26bba36be..6a66380c6 100644 --- a/src/components/identity/masthead.js +++ b/src/components/identity/masthead.js @@ -1,28 +1,17 @@ -import React, { useState, useRef } from 'react'; +import React, { useContext } from 'react'; import SbEditable from 'storyblok-react'; import { dcnb } from 'cnbuilder'; import CreateBloks from '../../utilities/createBloks'; import Logo from './logo'; import { FlexBox } from '../layout/FlexBox'; -import OpenSearchModalButton from '../search/openSearchModalButton'; -import SearchModal from '../search/searchModal'; +import OpenSearchModalButton from '../search/Modal/OpenSearchModalButton'; +import SearchModal from '../search/Modal/SearchModal'; import * as styles from './GlobalHeader/GlobalHeader.styles'; -import useEscape from '../../hooks/useEscape'; import useDisplay from '../../hooks/useDisplay'; import AccountLinks from '../navigation/accountLinks'; +import SearchModalContext from '../search/Modal/SearchModalContext'; -const Masthead = ({ - blok: { mainNav, utilityNav, searchPageUrl }, - blok, - hasHero, - isDark, -}) => { - const [modalOpen, setModalOpen] = useState(false); - const desktopRef = useRef(null); - const mobileRef = useRef(null); - const openSearchRef = useRef(null); - const openSearchMobileRef = useRef(null); - +const Masthead = ({ blok: { mainNav, utilityNav }, blok, hasHero, isDark }) => { let mainNavBgColorXl = 'xl:su-bg-transparent xl:su-bg-gradient-to-b xl:su-from-masthead-black-top xl:su-to-masthead-black-bottom su-backface-hidden'; let mainNavBgColorLg = @@ -33,43 +22,16 @@ const Masthead = ({ mainNavBgColorLg = 'su-bg-saa-black'; } - const returnFocus = () => { - if (openSearchRef.current) { - openSearchRef.current.focus(); - } else if (openSearchMobileRef.current) { - openSearchMobileRef.current.focus(); - } - }; - - const handleClose = () => { - setModalOpen(false); - returnFocus(); - }; - - useEscape(() => { - // Only do this if the search modal is open - if (modalOpen) { - const searchInputModal = - document.getElementsByClassName('search-input-modal')[0]; - - // Only close the modal with Escape key if the autocomplete dropdown is not open - if (searchInputModal.getAttribute('aria-expanded') !== 'true') { - setModalOpen(false); - returnFocus(); - } - } - }); - // Use the useDisplay hook to determine whether to display the desktop of mobile header const { showDesktop, showMobile } = useDisplay(); + // Get refs from the SearchModalContext + const { desktopButtonRef, mobileButtonRef } = useContext(SearchModalContext); + return ( {showMobile && ( -
+
)} - + ); }; diff --git a/src/components/page-types/TripFilterPage/TripFilterPage.jsx b/src/components/page-types/TripFilterPage/TripFilterPage.jsx index a3b0f2626..5379e418c 100644 --- a/src/components/page-types/TripFilterPage/TripFilterPage.jsx +++ b/src/components/page-types/TripFilterPage/TripFilterPage.jsx @@ -6,7 +6,7 @@ import { GridCell } from '../../layout/GridCell'; import { Container } from '../../layout/Container'; import { Heading } from '../../simple/Heading'; import { Skiplink } from '../../accessibility/Skiplink'; -import Layout from '../../partials/layout'; +import Layout from '../../partials/Layout'; import CreateBloks from '../../../utilities/createBloks'; import { useTripFilters } from '../../../hooks/useTripFilters'; import { TripFilterList } from '../../composite/TripFilterList/TripFilterList'; @@ -183,7 +183,7 @@ const TripFilterPage = (props) => { className={dcnb('filtered-trips-list', styles.trips)} > {trips.map((trip) => ( - + ))} {totalPages > 1 && ( diff --git a/src/components/page-types/TripPage/TripPage.jsx b/src/components/page-types/TripPage/TripPage.jsx index e1229e5b4..96f5b8b15 100644 --- a/src/components/page-types/TripPage/TripPage.jsx +++ b/src/components/page-types/TripPage/TripPage.jsx @@ -5,7 +5,7 @@ import SbEditable from 'storyblok-react'; import useScrollSpy from 'react-use-scrollspy'; import { Alert } from '../../composite/Alert/Alert'; import { luxonDate, luxonToday } from '../../../utilities/dates'; -import Layout from '../../partials/layout'; +import Layout from '../../partials/Layout'; import { TripContent } from '../../../types/TripType'; import * as styles from './TripPage.styles'; import Ankle from '../../partials/ankle/ankle'; diff --git a/src/components/page-types/associatesDirectoryPage/associatesDirectoryPage.js b/src/components/page-types/associatesDirectoryPage/associatesDirectoryPage.js index b244aff15..364f58622 100644 --- a/src/components/page-types/associatesDirectoryPage/associatesDirectoryPage.js +++ b/src/components/page-types/associatesDirectoryPage/associatesDirectoryPage.js @@ -3,7 +3,7 @@ import SbEditable from 'storyblok-react'; import { dcnb } from 'cnbuilder'; import { Container } from '../../layout/Container'; import { Heading } from '../../simple/Heading'; -import Layout from '../../partials/layout'; +import Layout from '../../partials/Layout'; import hasRichText from '../../../utilities/hasRichText'; import RichTextRenderer from '../../../utilities/richTextRenderer'; import CreateBloks from '../../../utilities/createBloks'; diff --git a/src/components/page-types/basicPage.js b/src/components/page-types/basicPage.jsx similarity index 98% rename from src/components/page-types/basicPage.js rename to src/components/page-types/basicPage.jsx index e8f4b9a64..a72dd996a 100644 --- a/src/components/page-types/basicPage.js +++ b/src/components/page-types/basicPage.jsx @@ -3,7 +3,7 @@ import SbEditable from 'storyblok-react'; import { dcnb } from 'cnbuilder'; import { Container } from '../layout/Container'; import { Heading } from '../simple/Heading'; -import Layout from '../partials/layout'; +import Layout from '../partials/Layout'; import CreateBloks from '../../utilities/createBloks'; import getNumBloks from '../../utilities/getNumBloks'; import Ankle from '../partials/ankle/ankle'; diff --git a/src/components/page-types/darkPage.js b/src/components/page-types/darkPage.jsx similarity index 98% rename from src/components/page-types/darkPage.js rename to src/components/page-types/darkPage.jsx index 013291d0d..ce2013a55 100644 --- a/src/components/page-types/darkPage.js +++ b/src/components/page-types/darkPage.jsx @@ -2,7 +2,7 @@ import React from 'react'; import SbEditable from 'storyblok-react'; import { Container } from '../layout/Container'; import { Heading } from '../simple/Heading'; -import Layout from '../partials/layout'; +import Layout from '../partials/Layout'; import Ankle from '../partials/ankle/ankle'; import CreateBloks from '../../utilities/createBloks'; import getNumBloks from '../../utilities/getNumBloks'; diff --git a/src/components/page-types/formPage/formPage.js b/src/components/page-types/formPage/formPage.js index cd678386b..c387c6eb2 100644 --- a/src/components/page-types/formPage/formPage.js +++ b/src/components/page-types/formPage/formPage.js @@ -2,7 +2,7 @@ import React, { useEffect, useContext } from 'react'; import SbEditable from 'storyblok-react'; import { Container } from '../../layout/Container'; import { Heading } from '../../simple/Heading'; -import Layout from '../../partials/layout'; +import Layout from '../../partials/Layout'; import CreateBloks from '../../../utilities/createBloks'; import getNumBloks from '../../../utilities/getNumBloks'; import Ankle from '../../partials/ankle/ankle'; diff --git a/src/components/page-types/gsbCardPage.js b/src/components/page-types/gsbCardPage.jsx similarity index 99% rename from src/components/page-types/gsbCardPage.js rename to src/components/page-types/gsbCardPage.jsx index e0273c4a1..3f3c87481 100644 --- a/src/components/page-types/gsbCardPage.js +++ b/src/components/page-types/gsbCardPage.jsx @@ -9,7 +9,7 @@ import GsbLogoColor from '../../images/gsb_logo-color.png'; import RichTextRenderer from '../../utilities/richTextRenderer'; import { Grid } from '../layout/Grid'; import { GridCell } from '../layout/GridCell'; -import Layout from '../partials/layout'; +import Layout from '../partials/Layout'; import AuthenticatedPage from '../auth/AuthenticatedPage'; import { Container } from '../layout/Container'; import { Heading } from '../simple/Heading'; diff --git a/src/components/page-types/lightFormPage/lightFormPage.js b/src/components/page-types/lightFormPage/lightFormPage.js index 2a7e0ad9a..8de73d429 100644 --- a/src/components/page-types/lightFormPage/lightFormPage.js +++ b/src/components/page-types/lightFormPage/lightFormPage.js @@ -5,7 +5,7 @@ import { dcnb } from 'cnbuilder'; import { ClipLoader } from 'react-spinners'; import { Container } from '../../layout/Container'; import { Heading } from '../../simple/Heading'; -import Layout from '../../partials/layout'; +import Layout from '../../partials/Layout'; import CreateBloks from '../../../utilities/createBloks'; import getNumBloks from '../../../utilities/getNumBloks'; import Ankle from '../../partials/ankle/ankle'; diff --git a/src/components/page-types/membershipFormPage/membershipFormPage.js b/src/components/page-types/membershipFormPage/membershipFormPage.js index 2017fb131..f6c21b3d7 100644 --- a/src/components/page-types/membershipFormPage/membershipFormPage.js +++ b/src/components/page-types/membershipFormPage/membershipFormPage.js @@ -5,7 +5,7 @@ import { Link } from 'gatsby'; import { useLocation } from '@reach/router'; import { Container } from '../../layout/Container'; import { Heading } from '../../simple/Heading'; -import Layout from '../../partials/layout'; +import Layout from '../../partials/Layout'; import { HeroImage } from '../../composite/HeroImage/HeroImage'; import { Grid } from '../../layout/Grid'; import AuthenticatedPage from '../../auth/AuthenticatedPage'; diff --git a/src/components/page-types/membershipFormPage/membershipFullPaymentForm.js b/src/components/page-types/membershipFormPage/membershipFullPaymentForm.js index 43937d568..ee0d58227 100644 --- a/src/components/page-types/membershipFormPage/membershipFullPaymentForm.js +++ b/src/components/page-types/membershipFormPage/membershipFullPaymentForm.js @@ -3,7 +3,7 @@ import { Helmet } from 'react-helmet'; import SbEditable from 'storyblok-react'; import { Redirect } from '@reach/router'; import { Container } from '../../layout/Container'; -import Layout from '../../partials/layout'; +import Layout from '../../partials/Layout'; import CreateBloks from '../../../utilities/createBloks'; import getNumBloks from '../../../utilities/getNumBloks'; import Ankle from '../../partials/ankle/ankle'; diff --git a/src/components/page-types/membershipFormPage/membershipInstallmentsForm.js b/src/components/page-types/membershipFormPage/membershipInstallmentsForm.js index 57c7b3a59..3b51ff19c 100644 --- a/src/components/page-types/membershipFormPage/membershipInstallmentsForm.js +++ b/src/components/page-types/membershipFormPage/membershipInstallmentsForm.js @@ -3,7 +3,7 @@ import { Helmet } from 'react-helmet'; import SbEditable from 'storyblok-react'; import { Redirect } from '@reach/router'; import { Container } from '../../layout/Container'; -import Layout from '../../partials/layout'; +import Layout from '../../partials/Layout'; import CreateBloks from '../../../utilities/createBloks'; import getNumBloks from '../../../utilities/getNumBloks'; import Ankle from '../../partials/ankle/ankle'; diff --git a/src/components/page-types/membershipFormPage/relatedContactSelection.js b/src/components/page-types/membershipFormPage/relatedContactSelection.js index f1ede97d7..0866658a8 100644 --- a/src/components/page-types/membershipFormPage/relatedContactSelection.js +++ b/src/components/page-types/membershipFormPage/relatedContactSelection.js @@ -6,7 +6,7 @@ import { Redirect, useLocation } from '@reach/router'; import { Container } from '../../layout/Container'; import { Heading } from '../../simple/Heading'; import { HeroImage } from '../../composite/HeroImage/HeroImage'; -import Layout from '../../partials/layout'; +import Layout from '../../partials/Layout'; import { Grid } from '../../layout/Grid'; import AuthContext from '../../../contexts/AuthContext'; import AuthenticatedPage from '../../auth/AuthenticatedPage'; diff --git a/src/components/page-types/protectedPage.js b/src/components/page-types/protectedPage.jsx similarity index 98% rename from src/components/page-types/protectedPage.js rename to src/components/page-types/protectedPage.jsx index 76d74ee9e..3da810d0c 100644 --- a/src/components/page-types/protectedPage.js +++ b/src/components/page-types/protectedPage.jsx @@ -3,7 +3,7 @@ import SbEditable from 'storyblok-react'; import { dcnb } from 'cnbuilder'; import { Container } from '../layout/Container'; import { Heading } from '../simple/Heading'; -import Layout from '../partials/layout'; +import Layout from '../partials/Layout'; import CreateBloks from '../../utilities/createBloks'; import getNumBloks from '../../utilities/getNumBloks'; import Ankle from '../partials/ankle/ankle'; diff --git a/src/components/page-types/registrationFormPage/interstitialPage.js b/src/components/page-types/registrationFormPage/interstitialPage.js index 920ff1c01..de82e08e1 100644 --- a/src/components/page-types/registrationFormPage/interstitialPage.js +++ b/src/components/page-types/registrationFormPage/interstitialPage.js @@ -4,7 +4,7 @@ import SbEditable from 'storyblok-react'; import { Link } from 'gatsby'; import { Container } from '../../layout/Container'; import { Heading } from '../../simple/Heading'; -import Layout from '../../partials/layout'; +import Layout from '../../partials/Layout'; import getNumBloks from '../../../utilities/getNumBloks'; import Ankle from '../../partials/ankle/ankle'; import Hero from '../../composite/hero'; diff --git a/src/components/page-types/registrationFormPage/registrationFormPage.js b/src/components/page-types/registrationFormPage/registrationFormPage.js index 903f1c1bd..925671ab8 100644 --- a/src/components/page-types/registrationFormPage/registrationFormPage.js +++ b/src/components/page-types/registrationFormPage/registrationFormPage.js @@ -3,7 +3,7 @@ import { Helmet } from 'react-helmet'; import SbEditable from 'storyblok-react'; import { Redirect, useLocation } from '@reach/router'; import { Container } from '../../layout/Container'; -import Layout from '../../partials/layout'; +import Layout from '../../partials/Layout'; import CreateBloks from '../../../utilities/createBloks'; import getNumBloks from '../../../utilities/getNumBloks'; import Ankle from '../../partials/ankle/ankle'; diff --git a/src/components/page-types/searchPage.js b/src/components/page-types/searchPage.js deleted file mode 100644 index 4158e5a2a..000000000 --- a/src/components/page-types/searchPage.js +++ /dev/null @@ -1,443 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import SbEditable from 'storyblok-react'; -import algoliasearch from 'algoliasearch'; -import scrollTo from 'gatsby-plugin-smoothscroll'; -import { - useQueryParam, - NumberParam, - StringParam, - ArrayParam, -} from 'use-query-params'; -import Icon from 'react-hero-icon'; -import { dcnb } from 'cnbuilder'; -import { SAAButton } from '../simple/SAAButton'; -import { Container } from '../layout/Container'; -import { Grid } from '../layout/Grid'; -import { GridCell } from '../layout/GridCell'; -import { Skiplink } from '../accessibility/Skiplink'; -import { Heading } from '../simple/Heading'; -import Layout from '../partials/layout'; -import SearchField from '../search/searchField'; -import SearchResults from '../search/searchResults'; -import SearchPager from '../search/searchPager'; -import SearchFacet from '../search/searchFacet'; -import SearchNoResults from '../search/searchNoResults'; -import SearchKeywordBanner from '../search/searchKeywordBanner'; -import CreateBloks from '../../utilities/createBloks'; -import useEscape from '../../hooks/useEscape'; -import useOnClickOutside from '../../hooks/useOnClickOutside'; -import getNumBloks from '../../utilities/getNumBloks'; - -const SearchPage = (props) => { - const { blok } = props; - const [suggestions, setSuggestions] = useState([]); - const [results, setResults] = useState([]); - const [query, setQuery] = useQueryParam('q', StringParam); - const [page = 0, setPage] = useQueryParam('page', NumberParam); - const [siteParam, setSiteParam] = useQueryParam('site', ArrayParam); - const [fileTypeParam, setFileTypeParam] = useQueryParam('type', ArrayParam); - const [siteNameValues, setSiteNameValues] = useState(null); - const [fileTypeValues, setFileTypeValues] = useState(null); - const [selectedFacets, setSelectedFacets] = useState({ - siteName: siteParam || [], - fileType: fileTypeParam || [], - }); - const [showEmptyMessage, setShowEmptyMessage] = useState(false); - const [opened, setOpened] = useState(false); - - const client = algoliasearch( - process.env.GATSBY_ALGOLIA_APP_ID, - process.env.GATSBY_ALGOLIA_API_KEY - ); - const suggestionsIndex = client.initIndex( - 'crawler_federated-search_suggestions' - ); - const hitsPerPage = blok.itemsPerPage; - - const ref = useRef(null); - const filterOpenRef = useRef(null); - - const isExpanded = (x) => x.getAttribute('aria-expanded') === 'true'; - - // Close menu if escape key is pressed and return focus to the menu button - useEscape(() => { - if (filterOpenRef.current && isExpanded(filterOpenRef.current)) { - setOpened(false); - filterOpenRef.current.focus(); - } - }); - - useOnClickOutside(ref, () => setOpened(false)); - - // Update autocomplete suggestions when search input changes. - const updateAutocomplete = (queryText) => { - suggestionsIndex - .search(queryText, { - hitsPerPage: 10, - }) - .then((queryResults) => { - setSuggestions(queryResults.hits); - }); - }; - - // Submit handler for search input. - const submitSearchQuery = (queryText, action = 'submit') => { - if (!queryText.length) { - if (action === 'submit') { - setShowEmptyMessage(true); - } else { - setShowEmptyMessage(false); - } - } else { - setShowEmptyMessage(false); - setPage(undefined); - setQuery(queryText); - } - }; - - // Update page parameter when pager link is selected. - const updatePage = (pageNumber) => { - setPage(pageNumber); - scrollTo('#search-results'); - }; - - // Update facet values when facet is selected. - const updateSiteFacet = (values) => { - const newFacets = { ...selectedFacets }; - newFacets.siteName = values; - setSelectedFacets(newFacets); - setPage(undefined); - setSiteParam(values); - }; - - // Update facet values when facet is selected. - const updateFileTypeFacet = (values) => { - const newFacets = { ...selectedFacets }; - newFacets.fileType = values; - setSelectedFacets(newFacets); - setPage(undefined); - setFileTypeParam(values); - }; - - const clearFilters = (e) => { - const filters = document.getElementsByClassName('filters'); - if (filters) { - Object.values(filters).forEach((set) => { - Object.values(set.getElementsByTagName('input')).forEach((checkbox) => { - // eslint-disable-next-line no-param-reassign - checkbox.checked = false; - }); - }); - } - - setSelectedFacets({ - siteName: [], - fileType: [], - }); - - setPage(undefined); - setFileTypeParam([]); - setSiteParam([]); - }; - - // Fetch search results from Algolia. (Typically triggered by state changes in useEffect()) - const updateSearchResults = () => { - const facetFilters = Object.keys(selectedFacets).map((attribute) => - selectedFacets[attribute].map((value) => `${attribute}:${value}`) - ); - - const siteNameFilters = []; - Object.keys(selectedFacets).forEach((attribute) => { - if (attribute !== 'siteName') { - const filters = selectedFacets[attribute].map( - (value) => `${attribute}:${value}` - ); - siteNameFilters.push(filters); - } - }); - - const fileTypeFilters = []; - Object.keys(selectedFacets).forEach((attribute) => { - if (attribute !== 'fileType') { - const filters = selectedFacets[attribute].map( - (value) => `${attribute}:${value}` - ); - fileTypeFilters.push(filters); - } - }); - - client - .multipleQueries([ - // Query for search results. - { - indexName: 'crawler_federated-search', - query, - params: { - hitsPerPage, - page, - facets: ['siteName', 'fileType'], - facetFilters, - }, - }, - // Disjunctive query for siteName facet values. - { - indexName: 'crawler_federated-search', - query, - params: { - facets: ['siteName', 'fileType'], - facetFilters: siteNameFilters, - }, - }, - // Disjunctive query for fileType facet values. - { - indexName: 'crawler_federated-search', - query, - params: { - facets: ['siteName', 'fileType'], - facetFilters: fileTypeFilters, - }, - }, - ]) - .then((queryResults) => { - setResults(queryResults.results[0]); - setSiteNameValues(queryResults.results[1].facets.siteName); - setFileTypeValues(queryResults.results[2].facets.fileType); - }); - }; - - // Listen for changes to query, pager, or facets and update search results. - useEffect(() => { - updateSearchResults(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query, page, selectedFacets]); - - const wrapperClasses = `su-grow su-w-auto su-border-0 su-border-b su-border-black-60`; - - const clearBtnClasses = `su-flex su-items-center su-bg-transparent hocus:su-bg-transparent su-text-black-70 hocus:su-text-black hocus:su-underline su-text-m0 su-font-semibold su-border-none su-p-0 su-rs-mr-1 su-mt-03em`; - - const inputClasses = `su-border-0 su-text-m2 su-leading-display su-w-full su-flex-1 su-rs-px-1 su-py-10 su-outline-none focus:su-ring-0 focus:su-ring-transparent`; - - const submitBtnClasses = `su-flex su-items-center su-justify-center su-w-40 su-min-w-[4rem] su-h-40 md:children:su-w-20 md:children:su-h-20 su-rounded-full su-transition-colors su-bg-digital-red-light hocus:su-bg-cardinal-red-xdark su-ml-10`; - - const autocompleteLinkClasses = `su-cursor-pointer su-font-regular su-inline-block su-w-full su-text-white su-no-underline su-px-15 su-py-10 su-rounded-full hover:su-bg-digital-red hover:su-text-white`; - - const autocompleteLinkFocusClasses = `su-bg-digital-red`; - - const autocompleteContainerClasses = `su-absolute su-top-[100%] su-bg-cardinal-red-xxdark su-p-10 su-shadow-md su-w-full su-border su-border-digital-red-light su-rounded-b-[0.5rem] su-z-20`; - const facets = results.facets && ( - - {siteNameValues && ( - updateSiteFacet(values)} - className={!!selectedFacets.siteName.length && 'su-mb-16'} - exclude={['YouTube', 'SoundCloud', 'Apple Podcasts']} - /> - )} - {fileTypeValues && ( - updateFileTypeFacet(values)} - optionClasses="su-capitalize" - className="su-mb-16" - exclude={['html', 'pdf']} - /> - )} - - ); - - return ( - - - - - {blok.pageTitle} - - - - - 0 ? 6 : 8} - className={ - results.nbHits > 0 ? 'lg:su-col-start-4' : 'lg:su-col-start-3' - } - > - updateAutocomplete(queryText)} - onSubmit={(queryText) => submitSearchQuery(queryText)} - onReset={() => submitSearchQuery('', 'reset')} - defaultValue={query} - autocompleteSuggestions={suggestions} - clearBtnClasses={clearBtnClasses} - inputClasses={inputClasses} - wrapperClasses={wrapperClasses} - submitBtnClasses={submitBtnClasses} - autocompleteLinkClasses={autocompleteLinkClasses} - autocompleteLinkFocusClasses={autocompleteLinkFocusClasses} - autocompleteContainerClasses={autocompleteContainerClasses} - clearOnEscape - /> - {showEmptyMessage && ( -

- {blok.emptySearchMessage} -

- )} -
-
- {getNumBloks(blok.aboveResultsContent) > 0 && ( -
- -
- )} - - {results.nbHits > 0 && ( - - -
- - {opened && ( -
-
{facets}
- -
- - { - setOpened(false); - scrollTo('#search-results'); - document - .getElementById('number-search-results') - .focus(); - }} - > - View results - -
-
- )} -
-
- - - - Skip past filters to search results - - - Filter Search Results - -
{facets}
-
-
- )} - 0 ? 9 : 8} - xxl={8} - className={ - results.nbHits > 0 ? '' : 'lg:su-col-start-3 2xl:su-col-start-3' - } - id="search-results-section" - > - - {results.nbHits > 0 && ( - <> - - - )} - - {results.nbHits > hitsPerPage && ( - - )} - - {!results.nbHits && query && ( - - )} - -
- - {getNumBloks(blok.belowResultsContent) > 0 && ( -
- -
- )} -
-
-
- ); -}; - -export default SearchPage; diff --git a/src/components/page-types/searchPage.jsx b/src/components/page-types/searchPage.jsx new file mode 100644 index 000000000..1d8a9139c --- /dev/null +++ b/src/components/page-types/searchPage.jsx @@ -0,0 +1,78 @@ +import React from 'react'; +import algoliasearch from 'algoliasearch/lite'; +import { history } from 'instantsearch.js/es/lib/routers'; +import { InstantSearch, Configure } from 'react-instantsearch'; +import SearchPageContent from '../search/SearchPageContent'; + +/** + * Main Search Page Component. + * + * @param {*} props + * @returns + */ +const SearchPage = (props) => { + // No destructuring here because I want to keep the props object and pass it down. + // eslint-disable-next-line react/destructuring-assignment + const itemsPerPageInt = parseInt(props.blok.itemsPerPage, 10); + + // Algolia. + // -------------------------------------------------- + const indexName = 'federated-search-with-events'; // TODO: Change this back before merging. + const algoliaClient = algoliasearch( + process.env.GATSBY_ALGOLIA_APP_ID, + process.env.GATSBY_ALGOLIA_API_KEY + ); + + const routing = { + router: history({ + cleanUrlOnDispose: false, + writeDelay: 100, + }), + stateMapping: { + stateToRoute(uiState) { + const uiIndexState = uiState[indexName]; + return { + q: uiIndexState.query, + page: uiIndexState.page, + sites: uiIndexState.refinementList?.siteName, + media: uiIndexState.refinementList?.fileType, + }; + }, + routeToState(routeState) { + const ret = { + [indexName]: { + query: routeState?.q, + page: parseInt(routeState.page, 10) || undefined, + refinementList: { + siteName: routeState?.sites, + fileType: routeState?.media, + }, + }, + }; + return ret; + }, + }, + }; + + return ( + + + + + ); +}; + +export default SearchPage; diff --git a/src/components/partials/layout.js b/src/components/partials/Layout.jsx similarity index 100% rename from src/components/partials/layout.js rename to src/components/partials/Layout.jsx diff --git a/src/components/search/Hits/SearchResultAlumniEvent.jsx b/src/components/search/Hits/SearchResultAlumniEvent.jsx new file mode 100644 index 000000000..ada2d4f47 --- /dev/null +++ b/src/components/search/Hits/SearchResultAlumniEvent.jsx @@ -0,0 +1,132 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React from 'react'; +import sanitize from 'sanitize-html'; +import { useLocation } from '@reach/router'; +import { DateTime } from 'luxon'; +import { CalendarIcon, LocationMarkerIcon } from '@heroicons/react/outline'; +import { dcnb } from 'cnbuilder'; +import { Heading } from '../../simple/Heading'; +import HeroIcon from '../../simple/heroIcon'; +import { utmParams } from '../../../utilities/utmParams'; +import { checkUTMParams } from '../../../utilities/checkUTMParams'; +import { SrOnlyText } from '../../accessibility/SrOnlyText'; + +/** + * Alumni Event {Hit} + * @param {*} result + * @returns + */ +const SearchResultAlumniEvent = ({ result, className }) => { + const routerLocation = useLocation(); + const utms = utmParams(routerLocation.search); + + const { + objectID, + domain, + url, + title, + start, + end, + timeZone, + location, + city, + country, + } = result; + + // Format the location string. + // The format should be "location, city, country" + const formattedLocation = () => { + const locationArray = [location, city, country]; + return locationArray.filter((loc) => loc).join(', '); + }; + + // Time of events. + const startTime = DateTime.fromISO(start).setZone(timeZone); + const endTime = DateTime.fromISO(end).setZone(timeZone); + // Format a date and time span string between start and end times in the formats of + // If the start and end date and times are the same use this format + // "Friday, October 8, 2021 8:00 AM PDT" + // If the start and end date are the same but the times are different use this format + // "Friday, October 8, 2021 8:00 AM - 10:00 AM PDT" + // If the start and end date are different use this format + // "October 8, 2021 8:00 AM - October 9, 2021 10:00 AM PDT" + const formattedDateTimeSpan = () => { + if (startTime.hasSame(endTime, 'day')) { + if (startTime.hasSame(endTime, 'minute')) { + return `${startTime.toFormat('EEEE, LLLL d, yyyy h:mm a ZZZZ')}`; + } + return `${startTime.toFormat( + 'EEEE, LLLL d, yyyy h:mm a ZZZZ' + )} - ${endTime.toFormat('h:mm a ZZZZ')}`; + } + return `${startTime.toFormat('LLLL d, yyyy h:mm a')} - ${endTime.toFormat( + 'LLLL d, yyyy h:mm a ZZZZ' + )}`; + }; + + return ( +
+
+
+
{domain}
+ +
+ + + + + {start && end && ( +

+ When:{' '} +

+ )} + +

+ Where: +

+
+
+
+ ); +}; + +export default SearchResultAlumniEvent; diff --git a/src/components/search/Hits/SearchResultDefault.jsx b/src/components/search/Hits/SearchResultDefault.jsx new file mode 100644 index 000000000..1a27f3d82 --- /dev/null +++ b/src/components/search/Hits/SearchResultDefault.jsx @@ -0,0 +1,110 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React from 'react'; +import sanitize from 'sanitize-html'; +import { useLocation } from '@reach/router'; +import { dcnb } from 'cnbuilder'; +import { Heading } from '../../simple/Heading'; +import HeroIcon from '../../simple/heroIcon'; +import { utmParams } from '../../../utilities/utmParams'; +import { checkUTMParams } from '../../../utilities/checkUTMParams'; +import { decodeHtmlEntities } from '../../../utilities/decodeHtmlEntities'; + +/** + * Default {Hit} + * @param {*} result + * @returns + */ +const SearchResultDefault = ({ result, className }) => { + const location = useLocation(); + const utms = utmParams(location.search); + + const { objectID, domain, url, fileType, title, image, _snippetResult } = + result; + + return ( +
+
+
+
{domain}
+ + + {fileType === 'video' && ( + + )} + {fileType === 'audio' && ( + + )} + + + + + {/* eslint-disable-next-line no-underscore-dangle */} + {_snippetResult?.body.value && ( +

+ )} +

+ {image && ( +
+ {title} +
+ )} +
+
+ ); +}; + +export default SearchResultDefault; diff --git a/src/components/search/Mobile/MobileFilterFooter.jsx b/src/components/search/Mobile/MobileFilterFooter.jsx new file mode 100644 index 000000000..ce8af73ba --- /dev/null +++ b/src/components/search/Mobile/MobileFilterFooter.jsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { dcnb } from 'cnbuilder'; +import { useClearRefinements } from 'react-instantsearch'; + +export const MobileFilterFooter = ({ + onCloseMenu, + className, + showClear = true, +}) => { + const { canRefine, refine: clearRefinements } = useClearRefinements(); + + const rootStyles = dcnb( + 'su-flex su-flex-wrap su-gap-8 su-justify-between su-w-full su-border-t su-border-t-black-30 su-bg-fog-light su-p-26 su-mt-8 su-z-10', + className + ); + const clearButtonStyles = + 'su-flex su-items-center su-px-16 su-py-10 su-rounded-md su-border-2 su-bg-white su-border-cardinal-red su-text-cardinal-red hover:su-bg-cardinal-red-light hover:su-text-white focus-visible:su-bg-cardinal-red-light focus-visible:su-text-white disabled:su-bg-black-10 disabled:su-text-black-80 disabled:su-border-black-20'; + const viewButtonStyles = dcnb( + 'su-flex su-items-center su-px-16 su-py-10 su-rounded-md su-border-2 su-border-digital-red su-bg-digital-red su-text-white hover:su-bg-cardinal-red-xdark focus:su-bg-cardinal-red-xdark', + { + 'su-ml-auto': !showClear, + } + ); + + return ( +
+ {showClear && ( + + )} + +
+ ); +}; diff --git a/src/components/search/Mobile/MobileFilterHeader.jsx b/src/components/search/Mobile/MobileFilterHeader.jsx new file mode 100644 index 000000000..6fae320db --- /dev/null +++ b/src/components/search/Mobile/MobileFilterHeader.jsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { dcnb } from 'cnbuilder'; +import { ArrowLeftIcon } from '@heroicons/react/solid'; +import { slugify } from '../../../utilities/slugify'; + +export const MobileFilterHeader = ({ heading, count, onClose, className }) => ( +
+
+ +
+

+ {heading} +

+ {!!count && count > 0 && ( +
+ {`${count} selected`} +
+ )} +
+
+
+); diff --git a/src/components/search/openSearchModalButton.js b/src/components/search/Modal/OpenSearchModalButton.jsx similarity index 65% rename from src/components/search/openSearchModalButton.js rename to src/components/search/Modal/OpenSearchModalButton.jsx index fe49bbb03..b41efb69b 100644 --- a/src/components/search/openSearchModalButton.js +++ b/src/components/search/Modal/OpenSearchModalButton.jsx @@ -1,9 +1,10 @@ -import React from 'react'; +import React, { useContext } from 'react'; import { SearchIcon } from '@heroicons/react/solid'; -import * as styles from '../navigation/MainNav/mainNav.styles'; +import * as styles from '../../navigation/MainNav/mainNav.styles'; +import SearchModalContext from './SearchModalContext'; -const OpenSearchModalButton = React.forwardRef((props, ref) => { - const { setModalOpen, id } = props; +const OpenSearchModalButton = React.forwardRef(({ id }, ref) => { + const { open } = useContext(SearchModalContext); return ( + )} +
+
+ +
+ + {showEmptyMessage && ( +

+ {emptySearchMessage} +

+ )} + + ); +}); + +export default SearchFieldModal; diff --git a/src/components/search/searchModal.js b/src/components/search/Modal/SearchModal.jsx similarity index 50% rename from src/components/search/searchModal.js rename to src/components/search/Modal/SearchModal.jsx index c8e23d965..f3d45c8bc 100644 --- a/src/components/search/searchModal.js +++ b/src/components/search/Modal/SearchModal.jsx @@ -1,13 +1,15 @@ -import React, { useState } from 'react'; -import { useStaticQuery, graphql, navigate } from 'gatsby'; -import { Container } from '../layout/Container'; -import { Heading } from '../simple/Heading'; -import Modal from '../layout/Modal/Modal'; -import SearchFieldModal from './searchFieldModal'; -import SearchSuggestions from './searchSuggestions'; +import React, { useContext } from 'react'; +import { useStaticQuery, graphql } from 'gatsby'; +import { Container } from '../../layout/Container'; +import { Heading } from '../../simple/Heading'; +import Modal from '../../layout/Modal/Modal'; +import SearchFieldModal from './SearchFieldModal'; +import SearchSuggestions from '../SearchSuggestions'; +import SearchModalContext from './SearchModalContext'; + +const SearchModal = () => { + const { isOpen, close, modalSearchInputRef } = useContext(SearchModalContext); -const SearchModal = ({ isOpen, setIsOpen, onClose, searchPageUrl }) => { - const searchFieldRef = React.createRef(); const data = useStaticQuery(graphql` { storyblokEntry( @@ -33,25 +35,15 @@ const SearchModal = ({ isOpen, setIsOpen, onClose, searchPageUrl }) => { emptySearchMessage = content.emptySearchMessage; } - const [showEmptyMessage, setShowEmptyMessage] = useState(false); - const searchSubmit = (queryText) => { - if (!queryText.length) { - setShowEmptyMessage(true); - } else { - setShowEmptyMessage(false); - navigate(`/${searchPageUrl.cached_url || 'search'}?q=${queryText}`); - setIsOpen(false); - } + const onClose = () => { + close(); }; return ( { - onClose(); - setShowEmptyMessage(false); - }} - initialFocus={searchFieldRef} + onClose={onClose} + initialFocus={modalSearchInputRef} ariaLabel="Search Stanford Alumni websites" > @@ -67,17 +59,9 @@ const SearchModal = ({ isOpen, setIsOpen, onClose, searchPageUrl }) => { {introduction} searchSubmit(queryText)} + emptySearchMessage={emptySearchMessage} + ref={modalSearchInputRef} /> - {showEmptyMessage ? ( -

- {emptySearchMessage} -

- ) : ( - '' - )} {story && content && (
diff --git a/src/components/search/Modal/SearchModalContext.jsx b/src/components/search/Modal/SearchModalContext.jsx new file mode 100644 index 000000000..abdbec55d --- /dev/null +++ b/src/components/search/Modal/SearchModalContext.jsx @@ -0,0 +1,71 @@ +import React, { createContext, useState, useRef } from 'react'; +import scrollTo from 'gatsby-plugin-smoothscroll'; +import useEscape from '../../../hooks/useEscape'; +import useDisplay from '../../../hooks/useDisplay'; + +/** + * A context to manage the state of the search modal. + */ +const SearchModalContext = createContext({}); +export const SearchModalContextProvider = SearchModalContext.Provider; +export default SearchModalContext; + +/** + * A provider to manage the state of the search modal. + * @param {Object} props + * @param {React.ReactNode} props.children + */ +export function SearchModalProvider({ children }) { + const { showDesktop, showMobile } = useDisplay(); + const [isOpen, setIsOpen] = useState(false); + const desktopButtonRef = useRef(); + const mobileButtonRef = useRef(); + const modalSearchInputRef = useRef(); + const searchInputRef = useRef(); + + // Close handler. + const close = () => { + setIsOpen(false); + if (showDesktop) desktopButtonRef.current.focus(); + if (showMobile) mobileButtonRef.current.focus(); + }; + + // Open handler. + const open = () => { + // Don't open the modal if the user is already on the search page. + // Instead focus on the search input. + if ( + window && + window.location && + window.location.pathname.startsWith('/search') + ) { + searchInputRef.current.focus(); + scrollTo(`#${searchInputRef.current.id}`); + return; + } + + setIsOpen(true); + }; + + // Close the modal when the escape key is pressed. + useEscape(() => { + if (isOpen) close(); + }); + + // Provider wrapper. + return ( + + {children} + + ); +} diff --git a/src/components/search/SearchFacet.jsx b/src/components/search/SearchFacet.jsx new file mode 100644 index 000000000..ac033553e --- /dev/null +++ b/src/components/search/SearchFacet.jsx @@ -0,0 +1,87 @@ +import React, { useEffect } from 'react'; +import { useInstantSearch, useRefinementList } from 'react-instantsearch'; +import { Skeleton } from '@mui/material'; +import { dcnb } from 'cnbuilder'; +import { Heading } from '../simple/Heading'; +import { slugify } from '../../utilities/slugify'; + +const SearchFacet = ({ className, attribute, label, excludes = [] }) => { + const { items, refine } = useRefinementList({ attribute, limit: 100 }); + const { status } = useInstantSearch(); + const isLoading = status === 'loading'; + const isStalled = status === 'stalled'; + + // Filter out any items we don't want to show. + const filteredItems = items.filter((item) => !excludes.includes(item.value)); + // Sort the items alphabetically. + filteredItems.sort((a, b) => a.value.localeCompare(b.value)); + + // Show loading. + if (isStalled) { + return ( +
+ + + + + + + + +
+ ); + } + + // Nothing to show. + if (!filteredItems.length) { + return null; + } + + // Render stuff. + return ( +
+ + {label} + + + {filteredItems.map((option, index) => ( + + ))} +
+ ); +}; + +export default SearchFacet; diff --git a/src/components/search/SearchField.jsx b/src/components/search/SearchField.jsx new file mode 100644 index 000000000..b1953094e --- /dev/null +++ b/src/components/search/SearchField.jsx @@ -0,0 +1,216 @@ +import React, { useCallback, useContext, useEffect, useState } from 'react'; +import algoliasearch from 'algoliasearch/lite'; +import { Autocomplete, TextField } from '@mui/material'; +import { useInstantSearch, useSearchBox } from 'react-instantsearch'; +import { X, Search } from 'react-hero-icon/solid'; +import { useDebouncedValue } from '../../hooks/useDebouncedValue'; +import SearchModalContext from './Modal/SearchModalContext'; + +/** + * @type {React.FC} + * @returns {React.ReactElement} + */ +const SearchField = ({ emptySearchMessage }) => { + // Algolia Client. + const algoliaClient = algoliasearch( + process.env.GATSBY_ALGOLIA_APP_ID, + process.env.GATSBY_ALGOLIA_API_KEY + ); + const indexName = 'federated-search-with-events'; // TODO: CHANGE THIS BACK BEFORE MERGING + const index = algoliaClient.initIndex(indexName); + + // Hooks and state. + const { query, refine, clear } = useSearchBox(); + const [value, setValue] = useState(query); + const [inputValue, setInputValue] = useState(query); + const [emptySearch, setEmptySearch] = useState(false); + const debouncedInputValue = useDebouncedValue(inputValue); + const [options, setOptions] = useState([]); + const { searchInputRef } = useContext(SearchModalContext); + const { indexUiState } = useInstantSearch(); + + // Debounce the input value and fetch options. + // ------------------------------------------- + useEffect(() => { + const fetchOptions = async () => { + if (!debouncedInputValue) { + setOptions([]); + return; + } + + // Add the filters to the autocomplete as well. + const siteRefinements = indexUiState?.refinementList?.siteName || []; + const typeRefinements = indexUiState?.refinementList?.fileType || []; + const namedSiteRefinements = siteRefinements.map( + (site) => `siteName:${site}` + ); + const namedTypeRefinements = typeRefinements.map( + (type) => `fileType:${type}` + ); + const facetFilters = [namedSiteRefinements, namedTypeRefinements]; + + try { + const res = await index.search(debouncedInputValue, { + attributesToRetrieve: ['title'], + hitsPerPage: 10, + facetFilters, + }); + const newOptions = res.hits.map((hit) => hit.title); + + if (!newOptions?.length && !options?.length) { + setOptions([]); + return; + } + + setOptions(newOptions); + } catch (err) { + setOptions([]); + } + }; + + fetchOptions(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedInputValue, indexUiState]); + + // Handle input change as the user types + // -------------------------------------- + const handleInputChange = useCallback( + (v) => { + setInputValue(v); + }, + [setInputValue] + ); + + // Handle value change when the user selects an option. + // ---------------------------------------------------- + const handleValueChange = useCallback( + (v) => { + setValue(v); + refine(v); + if (v.length > 0) { + setEmptySearch(false); + } + }, + [setValue, refine, setEmptySearch] + ); + + // Handle form submission. + // ----------------------- + const handleSubmit = useCallback( + (e) => { + e.preventDefault(); + handleValueChange(inputValue, refine, setEmptySearch); + }, + [setEmptySearch, handleValueChange, refine, inputValue] + ); + + // Handle clearing the search field. + // --------------------------------- + const handleClear = useCallback(() => { + setInputValue(''); + setValue(''); + clear(); + searchInputRef.current.focus(); + }, [setInputValue, setValue, clear, searchInputRef]); + + // The search field component. + // --------------------------- + return ( + <> +
+
+ handleInputChange(v)} + value={value} + onChange={(_e, v) => handleValueChange(v)} + filterOptions={(x) => x} + options={options} + noOptionsText="No results found" + className="[&_label.MuiInputLabel-shrink]:su-text-black-80 [&_label.MuiInputLabel-shrink]:!-su-translate-y-8 [&_label.MuiInputLabel-shrink]:!su-scale-75" + renderInput={(params) => ( + + )} + renderOption={(props, option) => { + // eslint-disable-next-line no-unused-vars + const { className, ...rest } = props; + return ( +
  • + {option} +
  • + ); + }} + slotProps={{ + popper: { + sx: { + '& .Mui-focused': { + backgroundColor: 'rgb(177, 4, 14)', + }, + }, + }, + }} + classes={{ + inputRoot: + '!su-text-18 md:!su-text-21 !su-font-sans !su-p-0 focus-within:before:!su-border-lagunita before:!su-border-b-2 before:!su-border-b-black-50 after:!su-border-b-0', + input: '!su-px-20 !su-text-m2', + clearIndicator: + '!su-text-18 !su-bg-transparent !su-text-transparent', + paper: + '!su-shadow-none md:!su-shadow-lg su-mt-2 md:!su-shadow-black/30 md:!su-rounded-b !su-font-sans !su-text-18 md:!su-text-21 !su-bg-cardinal-red-xxdark !su-border !su-border-digital-red su-z-10', + }} + /> + {!!query && ( + + )} +
    +
    + +
    +
    + {emptySearch && emptySearchMessage && ( +

    + {emptySearchMessage} +

    + )} + + ); +}; + +export default SearchField; diff --git a/src/components/search/searchKeywordBanner.js b/src/components/search/SearchKeywordBanner.jsx similarity index 87% rename from src/components/search/searchKeywordBanner.js rename to src/components/search/SearchKeywordBanner.jsx index e90494134..f50a61313 100644 --- a/src/components/search/searchKeywordBanner.js +++ b/src/components/search/SearchKeywordBanner.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { useStaticQuery, graphql } from 'gatsby'; +import { useSearchBox } from 'react-instantsearch'; import CreateBloks from '../../utilities/createBloks'; // Get most recently created Banner. @@ -21,7 +22,9 @@ const getBanner = (data, q) => { return max && created[max] ? created[max].content : ''; }; -const SearchKeywordBanner = function ({ queryText }) { +const SearchKeywordBanner = function () { + const { query } = useSearchBox(); + // Get Search Keyword Banners. const data = useStaticQuery(graphql` query searchKeywordBanners { @@ -42,7 +45,7 @@ const SearchKeywordBanner = function ({ queryText }) { return null; } - const banner = getBanner(data.allStoryblokEntry, queryText); + const banner = getBanner(data.allStoryblokEntry, query); if (banner) { return (
    diff --git a/src/components/search/SearchNoResults.jsx b/src/components/search/SearchNoResults.jsx new file mode 100644 index 000000000..e1b3b71bb --- /dev/null +++ b/src/components/search/SearchNoResults.jsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { useSearchBox } from 'react-instantsearch'; +import { Heading } from '../simple/Heading'; +import CreateBloks from '../../utilities/createBloks'; + +const SearchNoResults = ({ heading, body, additionalContent }) => { + const { query } = useSearchBox(); + const parsedHeading = heading.replace('[query]', query); + return ( +
    + + {parsedHeading} + +

    {body}

    +
    + +
    +
    + ); +}; + +export default SearchNoResults; diff --git a/src/components/search/SearchPageContent.jsx b/src/components/search/SearchPageContent.jsx new file mode 100644 index 000000000..67728acb8 --- /dev/null +++ b/src/components/search/SearchPageContent.jsx @@ -0,0 +1,266 @@ +import React, { useMemo, useState } from 'react'; +import { + useStats, + useInstantSearch, + useClearRefinements, + ClearRefinements, +} from 'react-instantsearch'; +import SbEditable from 'storyblok-react'; +import { cnb, dcnb } from 'cnbuilder'; +import Icon from 'react-hero-icon'; +import scrollTo from 'gatsby-plugin-smoothscroll'; +import { Container } from '../layout/Container'; +import Layout from '../partials/Layout'; +import { Heading } from '../simple/Heading'; +import { Grid } from '../layout/Grid'; +import { GridCell } from '../layout/GridCell'; +import SearchField from './SearchField'; +import getNumBloks from '../../utilities/getNumBloks'; +import CreateBloks from '../../utilities/createBloks'; +import { Skiplink } from '../accessibility/Skiplink'; +import SearchFacet from './SearchFacet'; +import SearchResults from './SearchResults'; +import SearchPager from './SearchPager'; +import SearchNoResults from './SearchNoResults'; +import SearchKeywordBanner from './SearchKeywordBanner'; +import { SAAButton } from '../simple/SAAButton'; + +/** + * Content Block. + * + * @param {*} props + * @returns + */ +const SearchPageContent = (props) => { + const { blok } = props; + const { nbHits, areHitsSorted, nbSortedHits } = useStats(); + const { + status, + results: { __isArtificial: isArtificial }, + } = useInstantSearch(); + const [opened, setOpened] = useState(false); + const { refine: clearFilters } = useClearRefinements(); + const { maxPagerLinks, maxPagerLinksMobile } = blok; + const maxPagerLinksInt = parseInt(maxPagerLinks, 10); + const maxPagerLinksMobileInt = parseInt(maxPagerLinksMobile, 10); + + const resultCount = useMemo(() => { + if (areHitsSorted) { + return nbSortedHits; + } + return nbHits; + }, [areHitsSorted, nbHits, nbSortedHits]); + + const isLoading = status === 'loading' && !isArtificial; + const hasNoResults = resultCount === 0 && !isArtificial; + + return ( + + + + + {blok.pageTitle} + + + + + 0 ? 6 : 8} + className={ + resultCount > 0 ? 'lg:su-col-start-4' : 'lg:su-col-start-3' + } + > + + + + + {/* MOBILE FILTERS */} + + + + +
    +
    + + +
    +
    + + { + setOpened(false); + }} + > + View results + +
    +
    +
    +
    + {/* END MOBILE FILTERS */} + + {getNumBloks(blok.aboveResultsContent) > 0 && ( +
    + +
    + )} + + + {/* DESKTOP FILTERS */} + + + Skip past filters to search results + +
    +
    +

    + Filter by +

    +
    + +
    +
    + + + +
    +
    + {/* END DESKTOP FILTERS */} + + + + + + +
    + + {hasNoResults && ( + + + + )} +
    +
    +
    + ); +}; + +export default SearchPageContent; diff --git a/src/components/search/SearchPager.jsx b/src/components/search/SearchPager.jsx new file mode 100644 index 000000000..2eec2ca22 --- /dev/null +++ b/src/components/search/SearchPager.jsx @@ -0,0 +1,147 @@ +import React, { useMemo } from 'react'; +import { dcnb } from 'cnbuilder'; +import { usePagination } from 'react-instantsearch'; +import scrollTo from 'gatsby-plugin-smoothscroll'; +import useDisplay from '../../hooks/useDisplay'; + +/** + * @type {React.FC} + * @returns {React.ReactElement} + */ +const SearchPager = ({ maxDesktop = 6, maxMobile = 2 }) => { + const { showMobile } = useDisplay(); + + const { + pages, + currentRefinement, + isFirstPage, + isLastPage, + canRefine, + refine, + createURL, + } = usePagination({ padding: maxDesktop }); // Assuming maxDesktop will be the larger of the two. + + /** + * We need to calculate the pages to show. + * This is needed because changing the padding is causing the UI to 'search' and lose the current page. + */ + const renderPages = useMemo(() => { + const padding = showMobile ? maxMobile : maxDesktop; + const half = Math.floor(padding / 2); + const maxPage = pages[pages.length - 1]; + const minPage = pages[0]; + let min = Math.max(minPage, currentRefinement - half); + const max = Math.min(maxPage, currentRefinement + half); + + if (max === maxPage) { + const diff = (maxPage - currentRefinement - half) * -1; + min = Math.max(0, min - diff); + } + + // create an empty array equal to the number plus padding + const pagers = new Array(padding + 1); + // fill the array with the correct page numbers + // eslint-disable-next-line no-plusplus + for (let i = 0; i <= padding; i++) { + if (min + i <= maxPage) { + pagers[i] = min + i; + } + } + + return pagers; + }, [currentRefinement, maxDesktop, maxMobile, showMobile, pages]); + + if (!canRefine) { + return null; + } + + const pageItemCommon = + 'su-border-b-4 su-border-transparent su-flex su-items-center su-justify-center su-min-w-[3.2rem] md:su-min-w-[3.6rem] su-min-h-[3.2rem] md:su-min-h-[3.6rem] su-font-normal su-leading-none su-no-underline'; + const pageItemCommonHocus = + 'hocus:su-border-b-4 hocus:su-border-digital-red hocus:su-text-digital-red hocus:su-no-underline'; + + const directionCta = ({ isShown = false }) => + dcnb(pageItemCommon, pageItemCommonHocus, 'su-text-22', { + 'su-invisible': !isShown, + 'su-visible': isShown, + }); + + const pageCta = ({ isActive = false }) => + dcnb(pageItemCommon, pageItemCommonHocus, { + 'su-px-9 md:su-px-11 su-text-digital-red-light': !isActive, + 'su-px-9 md:su-px-11 su-text-black su-border-b-black-20 su-cursor-default su-pointer-events-none': + isActive, + }); + + /** + * Handle scroll and focus. + * */ + const scrollToResults = () => { + scrollTo('#number-search-results', 'center'); + document + .querySelector('#number-search-results') + .focus({ preventScroll: true }); + }; + + return ( + + ); +}; + +export default SearchPager; diff --git a/src/components/search/SearchResults.jsx b/src/components/search/SearchResults.jsx new file mode 100644 index 000000000..46eada2fa --- /dev/null +++ b/src/components/search/SearchResults.jsx @@ -0,0 +1,83 @@ +/* eslint-disable jsx-a11y/no-noninteractive-tabindex */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React from 'react'; +import { useHits, useInstantSearch } from 'react-instantsearch'; +import { Skeleton } from '@mui/material'; +import { cnb } from 'cnbuilder'; +import SearchResultAlumniEvent from './Hits/SearchResultAlumniEvent'; +import SearchResultDefault from './Hits/SearchResultDefault'; + +/** + * Main component + * @returns Main Search Results + */ +const SearchResults = () => { + const { results, items } = useHits(); + const { status } = useInstantSearch(); + const isLoading = status === 'loading'; + const isStalled = status === 'stalled'; + + // Show loading. + if (isStalled) { + return ( +
    + + + + + + + + +
    + ); + } + + // No Results. + if (!results || !results.nbHits) { + return null; + } + + // Show results. + return ( +
    +
    + {results.nbHits} results: +
    + {items.map((result) => { + switch (result.type) { + case 'alumni-event': { + return ( + + ); + } + default: { + return ( + + ); + } + } + })} +
    + ); +}; +export default SearchResults; diff --git a/src/components/search/searchSuggestions.js b/src/components/search/SearchSuggestions.jsx similarity index 100% rename from src/components/search/searchSuggestions.js rename to src/components/search/SearchSuggestions.jsx diff --git a/src/components/search/searchAutocomplete.js b/src/components/search/searchAutocomplete.js deleted file mode 100644 index 4561c48e3..000000000 --- a/src/components/search/searchAutocomplete.js +++ /dev/null @@ -1,81 +0,0 @@ -import React from 'react'; -import sanitize from 'sanitize-html'; -import useEscape from '../../hooks/useEscape'; - -const SearchAutocomplete = ({ - autocompleteSuggestions, - setShowAutocomplete, - showAutocomplete, - onSelect, - selectedSuggestion, - setSelectedSuggestion, - autocompleteContainerClasses, - autocompleteLinkClasses, - autocompleteLinkFocusClasses, -}) => { - // Use Escape key to close autocomplete dropdown if it's currently open - useEscape(() => { - if (showAutocomplete) { - setShowAutocomplete(false); - } - }); - - return ( -
    - {Array.isArray(autocompleteSuggestions) && ( -
      - {autocompleteSuggestions.map((suggestion, index) => ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events -
    • onSelect(e, suggestion.query)} - onKeyDown={(e) => { - // On Enter or Spacebar - if (e.key === 'Enter' || e.key === ' ') { - onSelect(e, suggestion.query); - } - }} - onFocus={(e) => setSelectedSuggestion(index)} - aria-selected={selectedSuggestion === index ? 'true' : 'false'} - id={`search-autocomplete-listbox-${index}`} - > - { - // eslint-disable-next-line no-underscore-dangle - suggestion._highlightResult && ( - - ) - } -
    • - ))} -
    - )} -
    - ); -}; - -export default SearchAutocomplete; diff --git a/src/components/search/searchFacet.js b/src/components/search/searchFacet.js deleted file mode 100644 index e342fe8fb..000000000 --- a/src/components/search/searchFacet.js +++ /dev/null @@ -1,80 +0,0 @@ -import React from 'react'; -import { Heading } from '../simple/Heading'; - -const SearchFacet = ({ - label, - facetValues, - attribute, - selectedOptions, - onChange, - className, - optionClasses, - exclude = [], -}) => { - const handleCheckboxChange = (e) => { - const values = []; - const checkboxes = document.getElementsByName(e.target.name); - - checkboxes.forEach((checkbox) => { - if (checkbox.checked) { - values.push(checkbox.value); - } - }); - - onChange(values); - }; - - let preparedFacetValues = Object.keys(facetValues).map((value) => { - if (exclude.includes(value)) { - return null; - } - return { - name: value, - count: facetValues[value], - }; - }); - - preparedFacetValues = preparedFacetValues.filter((el) => el != null); - if (preparedFacetValues.length === 0) { - return null; - } - - return ( -
    - - {label} - - - {preparedFacetValues.map((option, index) => ( - - ))} -
    - ); -}; - -export default SearchFacet; diff --git a/src/components/search/searchField.js b/src/components/search/searchField.js deleted file mode 100644 index 501e61791..000000000 --- a/src/components/search/searchField.js +++ /dev/null @@ -1,182 +0,0 @@ -import React, { useState, createRef, useEffect } from 'react'; -import { X, Search } from 'react-hero-icon/solid'; -import { useLocation } from '@reach/router'; -import SearchAutocomplete from './searchAutocomplete'; -import useOnClickOutside from '../../hooks/useOnClickOutside'; -import { utmParams } from '../../utilities/utmParams'; - -const SearchField = React.forwardRef( - ( - { - onSubmit, - onReset, - onInput, - autocompleteSuggestions, - defaultValue, - inputClasses, - wrapperClasses, - submitBtnClasses, - clearBtnClasses, - autocompleteLinkClasses, - autocompleteLinkFocusClasses, - autocompleteContainerClasses, - placeholder, - }, - ref - ) => { - const [query, setQuery] = useState(defaultValue || ''); - const [showAutocomplete, setShowAutocomplete] = useState(false); - const [selectedSuggestion, setSelectedSuggestion] = useState(null); - const inputWrapper = createRef(); - const inputRef = ref || createRef(); - - const location = useLocation(); - const utms = utmParams(location.search); - - const submitHandler = (e) => { - e.preventDefault(); - setShowAutocomplete(false); - let queryParams = query; - if (utms.length > 0) { - queryParams += `&${utms}`; - } - onSubmit(queryParams); - }; - - const inputHandler = (e) => { - setQuery(e.target.value); - onInput(e.target.value); - setShowAutocomplete(true); - setSelectedSuggestion(null); - }; - - const clearHandler = (e) => { - e.preventDefault(); - setQuery(''); - setShowAutocomplete(false); - setSelectedSuggestion(null); - onReset(); - }; - - const selectSuggestion = (e, suggestion) => { - e.preventDefault(); - setQuery(suggestion); - setShowAutocomplete(false); - setSelectedSuggestion(null); - let suggestionParams = suggestion; - if (utms.length > 0) { - suggestionParams += `&${utms}`; - } - onSubmit(suggestionParams); - }; - - useEffect(() => { - setQuery(defaultValue); - }, [defaultValue]); - - useOnClickOutside(inputWrapper, () => { - setShowAutocomplete(false); - }); - - // If no suggestion is selected, or if the last suggested item is selected, - // using the down arrow will set focus on the first suggestion - const handleArrowKeys = (e) => { - if (e.key === 'ArrowDown') { - if ( - selectedSuggestion === null || - selectedSuggestion === autocompleteSuggestions.length - 1 - ) { - setSelectedSuggestion(0); - } else { - setSelectedSuggestion(selectedSuggestion + 1); - } - // if the first suggested selection is selected, - // using the up arrow will loop back to set focus on the last suggestion - } else if (e.key === 'ArrowUp') { - if (selectedSuggestion === 0) { - setSelectedSuggestion(autocompleteSuggestions.length - 1); - } else { - setSelectedSuggestion(selectedSuggestion - 1); - } - } else if ( - e.key === 'Enter' && - autocompleteSuggestions[selectedSuggestion] - ) { - selectSuggestion(e, autocompleteSuggestions[selectedSuggestion].query); - } - }; - - return ( -
    -
    -
    - -
    - - - -
    - -
    -
    -
    - ); - } -); - -export default SearchField; diff --git a/src/components/search/searchFieldModal.js b/src/components/search/searchFieldModal.js deleted file mode 100644 index c2f6c8eca..000000000 --- a/src/components/search/searchFieldModal.js +++ /dev/null @@ -1,74 +0,0 @@ -import React, { useState } from 'react'; -import algoliasearch from 'algoliasearch'; -import SearchField from './searchField'; - -const SearchFieldModal = React.forwardRef((props, ref) => { - const client = algoliasearch( - process.env.GATSBY_ALGOLIA_APP_ID, - process.env.GATSBY_ALGOLIA_API_KEY - ); - - const suggestionsIndex = client.initIndex( - 'crawler_federated-search_suggestions' - ); - - const [suggestions, setSuggestions] = useState([]); - const [query, setQuery] = useState(''); - - const wrapperClasses = `su-border-0 su-border-b-2 su-border-black-10`; - - const inputClasses = `search-input-modal su-border-0 su-bg-transparent su-text-black-10 su-text-black-40::placeholder su-w-full su-flex-1 - su-rs-px-1 su-py-02em su-text-m2 md:su-text-m4 su-leading-display focus:su-outline-none focus:su-ring-0 focus:su-ring-transparent`; - - const submitBtnClasses = `su-flex su-items-center su-justify-center su-min-w-[4rem] su-w-40 su-h-40 md:su-min-w-[7rem] md:su-w-70 md:su-h-70 md:children:su-w-40 md:children:su-h-40 su-rounded-full su-transition-colors - su-bg-digital-red hocus:su-bg-digital-red-xlight su-origin-center su-rs-ml-0`; - - const clearBtnClasses = `su-flex su-items-end su-transition-colors su-bg-transparent hover:su-bg-transparent - hocus:su-text-digital-red-xlight hocus:su-underline su-text-m0 md:su-text-m1 su-font-semibold - su-border-none su-text-white su-p-0 focus:su-bg-transparent su-rs-mr-1 su-mt-03em`; - - const autocompleteLinkClasses = `su-cursor-pointer su-font-regular su-inline-block su-w-full su-text-white su-no-underline su-px-15 su-py-10 su-rounded-full hover:su-bg-digital-red hover:su-text-white`; - - const autocompleteLinkFocusClasses = `su-bg-digital-red`; - - const autocompleteContainerClasses = `su-absolute su-top-[100%] su-bg-cardinal-red-xxdark su-p-10 su-shadow-md su-w-full su-border su-border-digital-red su-rounded-b-[0.5rem] su-z-20`; - - // Update autocomplete suggestions when search input changes. - const updateAutocomplete = (queryText) => { - suggestionsIndex - .search(queryText, { - hitsPerPage: 10, - }) - .then((queryResults) => { - setSuggestions(queryResults.hits); - }); - }; - - const submitSearchQuery = (queryText) => { - setQuery(queryText); - props.onSubmit(queryText); - }; - - return ( -
    - updateAutocomplete(queryText)} - onSubmit={(queryText) => submitSearchQuery(queryText)} - onReset={() => null} - defaultValue={query} - autocompleteSuggestions={suggestions} - clearBtnClasses={clearBtnClasses} - wrapperClasses={wrapperClasses} - inputClasses={inputClasses} - submitBtnClasses={submitBtnClasses} - autocompleteLinkClasses={autocompleteLinkClasses} - autocompleteLinkFocusClasses={autocompleteLinkFocusClasses} - autocompleteContainerClasses={autocompleteContainerClasses} - placeholder="Search" - ref={ref} - /> -
    - ); -}); - -export default SearchFieldModal; diff --git a/src/components/search/searchNoResults.js b/src/components/search/searchNoResults.js deleted file mode 100644 index 254cb94e1..000000000 --- a/src/components/search/searchNoResults.js +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import { Heading } from '../simple/Heading'; -import CreateBloks from '../../utilities/createBloks'; - -const SearchNoResults = ({ heading, body, additionalContent }) => ( -
    - - {heading} - -

    {body}

    -
    - -
    -
    -); - -export default SearchNoResults; diff --git a/src/components/search/searchPager.js b/src/components/search/searchPager.js deleted file mode 100644 index 70dc77364..000000000 --- a/src/components/search/searchPager.js +++ /dev/null @@ -1,82 +0,0 @@ -import React from 'react'; -import { buildPager, buildMobilePager } from '../../utilities/buildPager'; - -const SearchPager = ({ activePage, nbPages, maxLinks, selectPage }) => { - if (activePage === undefined || nbPages === undefined) { - return
    ; - } - - const linkClasses = 'su-text-digital-red-light hover:su-border-b-4'; - const activeLinkClasses = - 'su-text-cardinal-red su-border-b-4 su-cursor-default su-pointer-events-none'; - - const desktopPagerLinks = buildPager(nbPages, maxLinks, activePage); - const mobilePagerLinks = buildMobilePager(nbPages, activePage); - - const linkHandler = (e, page) => { - e.preventDefault(); - selectPage(page); - }; - - const Pager = ({ pagerLinks, className }) => ( - - ); - - return ( -
    - - -
    - ); -}; - -export default SearchPager; diff --git a/src/components/search/searchResults.js b/src/components/search/searchResults.js deleted file mode 100644 index 13cad31fd..000000000 --- a/src/components/search/searchResults.js +++ /dev/null @@ -1,117 +0,0 @@ -import React from 'react'; -import sanitize from 'sanitize-html'; -import { useLocation } from '@reach/router'; -import { Heading } from '../simple/Heading'; -import HeroIcon from '../simple/heroIcon'; -import { utmParams } from '../../utilities/utmParams'; - -const SearchResults = ({ results }) => { - const location = useLocation(); - const utms = utmParams(location.search); - const checkParams = (url) => { - let linkUrl = url; - if (linkUrl.match(/\?/) && utms.length) { - linkUrl += `&${utms}`; - } else if (utms.length) { - linkUrl += `?${utms}`; - } - return linkUrl; - }; - - if (!results.hits) { - return
    ; - } - - return ( -
    -
    - {results.nbHits} results: -
    - {results.hits.map((result) => ( -
    -
    -
    -
    {result.domain}
    - - - {result.fileType === 'video' && ( - - )} - {result.fileType === 'audio' && ( - - )} - - - - - {/* eslint-disable-next-line no-underscore-dangle */} - {result._snippetResult?.body.value && ( -

    - )} -

    - {result.image && ( -
    - {result.title} -
    - )} -
    -
    - ))} -
    - ); -}; -export default SearchResults; diff --git a/src/utilities/checkUTMParams.js b/src/utilities/checkUTMParams.js new file mode 100644 index 000000000..1a6194e07 --- /dev/null +++ b/src/utilities/checkUTMParams.js @@ -0,0 +1,15 @@ +/** + * UTM Parameters + * @param {*} url + * @param {*} utms + * @returns + */ +export const checkUTMParams = (url, utms) => { + let linkUrl = url; + if (linkUrl.match(/\?/) && utms.length) { + linkUrl += `&${utms}`; + } else if (utms.length) { + linkUrl += `?${utms}`; + } + return linkUrl; +}; diff --git a/src/utilities/decodeHtmlEntities.js b/src/utilities/decodeHtmlEntities.js new file mode 100644 index 000000000..5f0077a31 --- /dev/null +++ b/src/utilities/decodeHtmlEntities.js @@ -0,0 +1,10 @@ +/** + * Use the Dom to decode HTML entities + * @param {string} str + * @returns string + */ +export const decodeHtmlEntities = (str) => { + const tempElement = document.createElement('textarea'); + tempElement.innerHTML = str; + return tempElement.value; +}; From b708f10a38b7388412cb963424b3cd8700290d50 Mon Sep 17 00:00:00 2001 From: Shea McKinney Date: Wed, 30 Oct 2024 10:57:32 -0700 Subject: [PATCH 2/3] Fixup! Return index back to crawler_federated-search --- src/components/page-types/searchPage.jsx | 2 +- src/components/search/Modal/SearchFieldModal.jsx | 2 +- src/components/search/SearchField.jsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/page-types/searchPage.jsx b/src/components/page-types/searchPage.jsx index 1d8a9139c..b5ab7338f 100644 --- a/src/components/page-types/searchPage.jsx +++ b/src/components/page-types/searchPage.jsx @@ -17,7 +17,7 @@ const SearchPage = (props) => { // Algolia. // -------------------------------------------------- - const indexName = 'federated-search-with-events'; // TODO: Change this back before merging. + const indexName = 'crawler_federated-search'; const algoliaClient = algoliasearch( process.env.GATSBY_ALGOLIA_APP_ID, process.env.GATSBY_ALGOLIA_API_KEY diff --git a/src/components/search/Modal/SearchFieldModal.jsx b/src/components/search/Modal/SearchFieldModal.jsx index fb8e5ac0f..ca4349ab1 100644 --- a/src/components/search/Modal/SearchFieldModal.jsx +++ b/src/components/search/Modal/SearchFieldModal.jsx @@ -18,7 +18,7 @@ const SearchFieldModal = React.forwardRef(({ emptySearchMessage }, ref) => { process.env.GATSBY_ALGOLIA_APP_ID, process.env.GATSBY_ALGOLIA_API_KEY ); - const indexName = 'federated-search-with-events'; // TODO: CHANGE THIS BACK BEFORE MERGING + const indexName = 'crawler_federated-search'; const index = algoliaClient.initIndex(indexName); // Hooks and state. diff --git a/src/components/search/SearchField.jsx b/src/components/search/SearchField.jsx index b1953094e..6d40312c6 100644 --- a/src/components/search/SearchField.jsx +++ b/src/components/search/SearchField.jsx @@ -16,7 +16,7 @@ const SearchField = ({ emptySearchMessage }) => { process.env.GATSBY_ALGOLIA_APP_ID, process.env.GATSBY_ALGOLIA_API_KEY ); - const indexName = 'federated-search-with-events'; // TODO: CHANGE THIS BACK BEFORE MERGING + const indexName = 'crawler_federated-search'; const index = algoliaClient.initIndex(indexName); // Hooks and state. From fa17c1e477b65681d810360d46a335fd985e1599 Mon Sep 17 00:00:00 2001 From: Shea McKinney Date: Wed, 30 Oct 2024 11:43:34 -0700 Subject: [PATCH 3/3] Fixup! Bring back the empty search notice. --- src/components/search/SearchField.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/search/SearchField.jsx b/src/components/search/SearchField.jsx index 6d40312c6..126690a0d 100644 --- a/src/components/search/SearchField.jsx +++ b/src/components/search/SearchField.jsx @@ -89,6 +89,8 @@ const SearchField = ({ emptySearchMessage }) => { refine(v); if (v.length > 0) { setEmptySearch(false); + } else { + setEmptySearch(true); } }, [setValue, refine, setEmptySearch]