diff --git a/client/src/components/search/WithInstantSearch.js b/client/src/components/search/WithInstantSearch.js deleted file mode 100644 index c4c4d6ca20..0000000000 --- a/client/src/components/search/WithInstantSearch.js +++ /dev/null @@ -1,177 +0,0 @@ -import { Location } from '@reach/router'; -import algoliasearch from 'algoliasearch/lite'; -import { navigate } from 'gatsby'; -import PropTypes from 'prop-types'; -import qs from 'query-string'; -import React, { Component } from 'react'; -import { InstantSearch, Configure } from 'react-instantsearch-dom'; -import { connect } from 'react-redux'; -import Media from 'react-responsive'; -import { createSelector } from 'reselect'; -import envData from '../../../../config/env.json'; -import { newsIndex } from '../../utils/algolia-locale-setup'; - -import { - isSearchDropdownEnabledSelector, - searchQuerySelector, - toggleSearchDropdown, - updateSearchQuery -} from './redux'; - -const { algoliaAppId, algoliaAPIKey } = envData; - -const DEBOUNCE_TIME = 100; - -// If a key is missing, searches will fail, but the client will still render. -const searchClient = - algoliaAppId && algoliaAPIKey - ? algoliasearch(algoliaAppId, algoliaAPIKey) - : { search: () => {} }; - -const propTypes = { - children: PropTypes.any, - isDropdownEnabled: PropTypes.bool, - location: PropTypes.object.isRequired, - query: PropTypes.string, - toggleSearchDropdown: PropTypes.func.isRequired, - updateSearchQuery: PropTypes.func.isRequired -}; - -const mapStateToProps = createSelector( - searchQuerySelector, - isSearchDropdownEnabledSelector, - (query, isDropdownEnabled) => ({ query, isDropdownEnabled }) -); -const mapDispatchToProps = { - toggleSearchDropdown, - updateSearchQuery -}; - -const searchStateToUrl = ({ pathname }, query) => - `${pathname}${query ? `?${qs.stringify({ query })}` : ''}`; - -const urlToSearchState = ({ search }) => qs.parse(search.slice(1)); - -class InstantSearchRoot extends Component { - componentDidMount() { - const { toggleSearchDropdown } = this.props; - toggleSearchDropdown(!this.isSearchPage()); - this.setQueryFromURL(); - } - - componentDidUpdate(prevProps) { - const { location, toggleSearchDropdown, isDropdownEnabled } = this.props; - - const enableDropdown = !this.isSearchPage(); - if (isDropdownEnabled !== enableDropdown) { - toggleSearchDropdown(enableDropdown); - } - - if (location !== prevProps.location) { - const { query, updateSearchQuery } = this.props; - if (this.isSearchPage()) { - if ( - location.state && - typeof location.state === 'object' && - location.state.hasOwnProperty('query') - ) { - updateSearchQuery(location.state.query); - } else if (location.search) { - this.setQueryFromURL(); - } else { - navigate(searchStateToUrl(this.props.location, query), { - state: { query }, - replace: true - }); - } - } else if (query) { - updateSearchQuery(''); - } - } - } - - isSearchPage = () => this.props.location.pathname.startsWith('/search'); - - setQueryFromURL = () => { - if (this.isSearchPage()) { - const { updateSearchQuery, location, query } = this.props; - const { query: queryFromURL } = urlToSearchState(location); - if (query !== queryFromURL) { - updateSearchQuery(queryFromURL); - } - } - }; - - onSearchStateChange = ({ query }) => { - const { updateSearchQuery, query: propsQuery } = this.props; - if (propsQuery === query || typeof query === 'undefined') { - return; - } - updateSearchQuery(query); - this.updateBrowserHistory(query); - }; - - updateBrowserHistory = query => { - if (this.isSearchPage()) { - clearTimeout(this.debouncedSetState); - - this.debouncedSetState = setTimeout(() => { - if (this.isSearchPage()) { - navigate(searchStateToUrl(this.props.location, query), { - state: { query } - }); - } - }, DEBOUNCE_TIME); - } - }; - - render() { - const { query } = this.props; - const MAX_MOBILE_HEIGHT = 768; - return ( - - {this.isSearchPage() ? ( - - ) : ( - <> - - - - - - - - )} - {this.props.children} - - ); - } -} - -InstantSearchRoot.displayName = 'InstantSearchRoot'; -InstantSearchRoot.propTypes = propTypes; - -const InstantSearchRootConnected = connect( - mapStateToProps, - mapDispatchToProps -)(InstantSearchRoot); - -const WithInstantSearch = ({ children }) => ( - - {({ location }) => ( - - {children} - - )} - -); - -WithInstantSearch.displayName = 'WithInstantSearch'; -WithInstantSearch.propTypes = { children: PropTypes.any }; - -export default WithInstantSearch; diff --git a/client/src/components/search/searchBar/search-bar.tsx b/client/src/components/search/searchBar/search-bar.tsx index 8fea9ce5d9..aa60b92dcd 100644 --- a/client/src/components/search/searchBar/search-bar.tsx +++ b/client/src/components/search/searchBar/search-bar.tsx @@ -9,8 +9,6 @@ import { AnyAction, bindActionCreators, Dispatch } from 'redux'; import { createSelector } from 'reselect'; import { searchPageUrl } from '../../../utils/algolia-locale-setup'; -import WithInstantSearch from '../WithInstantSearch'; - import { isSearchDropdownEnabledSelector, isSearchBarFocusedSelector, @@ -18,6 +16,8 @@ import { toggleSearchFocused, updateSearchQuery } from '../redux'; +import WithInstantSearch from '../with-instant-search'; + import SearchHits from './search-hits'; import './searchbar-base.css'; diff --git a/client/src/components/search/with-instant-search.tsx b/client/src/components/search/with-instant-search.tsx new file mode 100644 index 0000000000..1455a69096 --- /dev/null +++ b/client/src/components/search/with-instant-search.tsx @@ -0,0 +1,204 @@ +import { Location } from '@reach/router'; +import type { WindowLocation } from '@reach/router'; +import algoliasearch from 'algoliasearch/lite'; +import { navigate } from 'gatsby'; +import qs from 'query-string'; +import React, { useEffect, useRef } from 'react'; +import type { ReactNode } from 'react'; +import { InstantSearch, Configure } from 'react-instantsearch-dom'; +import { connect } from 'react-redux'; +import Media from 'react-responsive'; +import { createSelector } from 'reselect'; +import envData from '../../../../config/env.json'; +import { newsIndex } from '../../utils/algolia-locale-setup'; + +import { + isSearchDropdownEnabledSelector, + searchQuerySelector, + toggleSearchDropdown, + updateSearchQuery +} from './redux'; + +const { algoliaAppId, algoliaAPIKey } = envData; + +const DEBOUNCE_TIME = 100; + +// If a key is missing, searches will fail, but the client will still render. +const searchClient = + algoliaAppId && algoliaAPIKey + ? algoliasearch(algoliaAppId, algoliaAPIKey) + : { + // eslint-disable-next-line @typescript-eslint/no-empty-function + search: () => {} + }; + +const mapStateToProps = createSelector( + searchQuerySelector, + isSearchDropdownEnabledSelector, + function ( + query: string, + isDropdownEnabled: boolean + ): { query: string; isDropdownEnabled: boolean } { + return { query, isDropdownEnabled }; + } +); +const mapDispatchToProps = { + toggleSearchDropdown, + updateSearchQuery +}; + +function searchStateToUrl( + { pathname }: { pathname: string }, + query: string +): string { + return `${pathname}${query ? `?${qs.stringify({ query })}` : ''}`; +} +function urlToSearchState({ search }: WindowLocation): { query: string } { + return qs.parse(search.slice(1)) as { query: string }; +} + +interface InstantSearchRootProps { + isDropdownEnabled: boolean; + location: WindowLocation<{ query: string }>; + query: string; + toggleSearchDropdown: (toggle: boolean) => void; + updateSearchQuery: (query: string) => void; + children: ReactNode; +} +function InstantSearchRoot({ + isDropdownEnabled, + location, + query, + toggleSearchDropdown, + updateSearchQuery, + children +}: InstantSearchRootProps) { + function isSearchPage(): boolean { + return location.pathname.startsWith('/search'); + } + + function setQueryFromURL(): void { + if (isSearchPage()) { + const { query: queryFromURL } = urlToSearchState(location); + if (query !== queryFromURL) { + updateSearchQuery(queryFromURL); + } + } + } + + useEffect(() => { + toggleSearchDropdown(!isSearchPage()); + setQueryFromURL(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // https://reactjs.org/docs/hooks-faq.html#how-to-get-the-previous-props-or-state + const prevLocationRef = useRef(); + useEffect(() => { + prevLocationRef.current = location; + }); + const prevLocation = prevLocationRef.current; + useEffect(() => { + const enableDropdown = !isSearchPage(); + if (isDropdownEnabled !== enableDropdown) { + toggleSearchDropdown(enableDropdown); + } + + if (location !== prevLocation) { + if (isSearchPage()) { + if ( + location.state && + typeof location.state === 'object' && + location.state.hasOwnProperty('query') + ) { + updateSearchQuery(location.state.query); + } else if (location.search) { + setQueryFromURL(); + } else { + void navigate(searchStateToUrl(location, query), { + state: { query }, + replace: true + }); + } + } else if (query) { + updateSearchQuery(''); + } + } + }); + + const debouncedSetState = useRef(); + function updateBrowserHistory(query: string): void { + if (isSearchPage()) { + clearTimeout(debouncedSetState.current); + + debouncedSetState.current = window.setTimeout(() => { + if (isSearchPage()) { + void navigate(searchStateToUrl(location, query), { + state: { query } + }); + } + }, DEBOUNCE_TIME); + } + } + + const propsQuery = query; + function onSearchStateChange({ query }: { query: string | undefined }): void { + if (propsQuery === query || typeof query === 'undefined') { + return; + } + updateSearchQuery(query); + updateBrowserHistory(query); + } + + const MAX_MOBILE_HEIGHT = 768; + return ( + + {isSearchPage() ? ( + + ) : ( + <> + + + + + + + + )} + {children} + + ); +} + +InstantSearchRoot.displayName = 'InstantSearchRoot'; + +const InstantSearchRootConnected = connect( + mapStateToProps, + mapDispatchToProps +)(InstantSearchRoot); + +interface WithInstantSearchProps { + children: ReactNode; +} +function WithInstantSearch({ children }: WithInstantSearchProps): JSX.Element { + return ( + + {({ location }) => ( + + {children} + + )} + + ); +} + +WithInstantSearch.displayName = 'WithInstantSearch'; + +export default WithInstantSearch; diff --git a/config/read-env.js b/config/read-env.js index 392ca3759c..da9f581148 100644 --- a/config/read-env.js +++ b/config/read-env.js @@ -46,11 +46,11 @@ module.exports = Object.assign(locations, { environment: process.env.FREECODECAMP_NODE_ENV || 'development', algoliaAppId: !algoliaAppId || algoliaAppId === 'app_id_from_algolia_dashboard' - ? null + ? '' : algoliaAppId, algoliaAPIKey: !algoliaAPIKey || algoliaAPIKey === 'api_key_from_algolia_dashboard' - ? null + ? '' : algoliaAPIKey, paypalClientId: !paypalClientId || paypalClientId === 'id_from_paypal_dashboard'