import React, { useState, useEffect, useRef, ReactNode, RefObject } from 'react' import { useRouter } from 'next/router' import useSWR from 'swr' import cx from 'classnames' import { ActionList, DropdownMenu, Flash, Label, Overlay } from '@primer/react' import { ItemInput } from '@primer/react/lib/ActionList/List' import { useTranslation } from 'components/hooks/useTranslation' import { sendEvent, EventType } from 'components/lib/events' import { useMainContext } from './context/MainContext' import { DEFAULT_VERSION, useVersion } from 'components/hooks/useVersion' import { useQuery } from 'components/hooks/useQuery' import { Link } from 'components/Link' import { useLanguages } from './context/LanguagesContext' import styles from './Search.module.scss' type SearchResult = { url: string breadcrumbs: string title: string content: string score: number popularity: number } type Props = { isHeaderSearch?: boolean isMobileSearch?: boolean variant?: 'compact' | 'expanded' iconSize: number children?: (props: { SearchInput: ReactNode; SearchResults: ReactNode }) => ReactNode } export function Search({ isHeaderSearch = false, isMobileSearch = false, variant = 'compact', iconSize = 24, children, }: Props) { const router = useRouter() const { query, debug } = useQuery() const [localQuery, setLocalQuery] = useState(query) const [debouncedQuery, setDebouncedQuery] = useDebounce(localQuery, 300) const inputRef = useRef(null) const { t } = useTranslation('search') const { currentVersion } = useVersion() const { languages } = useLanguages() // Figure out language and version for index const { searchVersions, nonEnterpriseDefaultVersion } = useMainContext() // fall back to the non-enterprise default version (FPT currently) on the homepage, 404 page, etc. const version = searchVersions[currentVersion] || searchVersions[nonEnterpriseDefaultVersion] const language = (Object.keys(languages).includes(router.locale || '') && router.locale) || 'en' const fetchURL = query ? `/search?${new URLSearchParams({ language, version, query, })}` : null const { data: results, error: searchError } = useSWR( fetchURL, async (url: string) => { const response = await fetch(url) if (!response.ok) { throw new Error(`${response.status} on ${url}`) } return await response.json() }, { onSuccess: () => { sendEvent({ type: EventType.search, search_query: query, // search_context }) }, // Because the backend never changes between fetches, we can treat // it as an immutable resource and disable these revalidation // checks. revalidateIfStale: false, revalidateOnFocus: false, revalidateOnReconnect: false, } ) const [previousResults, setPreviousResults] = useState() useEffect(() => { if (results) { setPreviousResults(results) } else if (!query) { setPreviousResults(undefined) } }, [results, query]) // The `isLoading` boolean will become false every time the useSWR hook // fires off a new XHR. So it toggles from false/true often. // But we don't want to display "Loading..." every time a new XHR query // begins, immediately, because the XHR requests are usually very fast // so that you just see it flicker by. That's why we introduce a // debounced version of that same boolean value. // The problem is that the debounce is *trailing*. Meaning, it will // always yield the last thing you sent to it, but with a delay. // The problem is that, by the time the debounce finally fires, // it might say 'true' when in fact the XHR has finished! That would // mean saying "Loading..." is a lie! // That's why we combine them into a final one. We're basically doing // this to favor *NOT* saying "Loading...". const isLoadingRaw = Boolean(query && !results && !searchError) const [isLoadingDebounced] = useDebounce(isLoadingRaw, 500) const isLoading = isLoadingRaw && isLoadingDebounced useEffect(() => { if ((router.query.query || '') !== debouncedQuery) { const [asPathRoot, asPathQuery = ''] = router.asPath.split('?') const params = new URLSearchParams(asPathQuery) if (debouncedQuery) { params.set('query', debouncedQuery) } else { params.delete('query') } let asPath = `/${router.locale}${asPathRoot}` if (params.toString()) { asPath += `?${params.toString()}` } // Workaround a next.js routing behavior that // will cause the default locale path of the index page // "/en" to change to just "/". if (router.pathname === '/') { // Don't include router.locale so next doesn't attempt a // request to `/_next/static/chunks/pages/en.js` router.replace(`/?${params.toString()}`, asPath, { shallow: true }) } else { router.replace(asPath, undefined, { shallow: true }) } } }, [debouncedQuery]) // When the user finishes typing, update the results function onSearch(e: React.ChangeEvent) { setLocalQuery(e.target.value) } useEffect(() => { if (localQuery.trim()) { if (localQuery.endsWith(' ')) { setDebouncedQuery(localQuery.trim()) } } else { setDebouncedQuery('') } }, [localQuery]) // Close panel if overlay is clicked function closeSearch() { setLocalQuery('') } // Prevent the page from refreshing when you "submit" the form function onFormSubmit(evt: React.FormEvent) { evt.preventDefault() if (localQuery.trim()) { setDebouncedQuery(localQuery.trim()) } } const SearchResults = ( <>
{searchError ? ( ) : ( )}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
) const SearchInput = (
) return ( <> {typeof children === 'function' ? ( children({ SearchInput, SearchResults }) ) : ( <> {SearchInput} {SearchResults} )} ) } function useDebounce(value: T, delay?: number): [T, (value: T) => void] { const [debouncedValue, setDebouncedValue] = useState(value) useEffect(() => { const timer = setTimeout(() => setDebouncedValue(value), delay || 500) return () => { clearTimeout(timer) } }, [value, delay]) return [debouncedValue, setDebouncedValue] } function ShowSearchError({ error, isHeaderSearch, isMobileSearch, }: { error: Error isHeaderSearch: boolean isMobileSearch: boolean }) { const { t } = useTranslation('search') return (

{t('search_error')}

{process.env.NODE_ENV === 'development' && (

{error.toString()}

)}
) } function ShowSearchResults({ anchorRef, isHeaderSearch, isMobileSearch, isLoading, results, closeSearch, debug, query, }: { anchorRef: RefObject isHeaderSearch: boolean isMobileSearch: boolean isLoading: boolean results: SearchResult[] | undefined closeSearch: () => void debug: boolean query: string }) { const { t } = useTranslation('search') const router = useRouter() const { currentVersion } = useVersion() const { allVersions } = useMainContext() const searchVersion = allVersions[currentVersion].versionTitle const [selectedVersion, setSelectedVersion] = useState() const latestVersions = new Set( Object.keys(allVersions) .map((version) => allVersions[version].latestVersion) .filter((version) => version !== currentVersion) ) const versions = Array.from(latestVersions).map((version) => { return { title: allVersions[version].versionTitle, version: version, } }) const searchVersions: ItemInput[] = versions.map(({ title, version }) => { return { text: title, key: version, } }) const redirectParams: { query: string debug?: string } = { query } if (debug) redirectParams.debug = JSON.stringify(debug) const redirectQuery = `?${new URLSearchParams(redirectParams).toString()}` useEffect(() => { if (selectedVersion) { const params = new URLSearchParams(redirectParams) let asPath = `/${router.locale}` if (params.toString()) { asPath += `?${params.toString()}` } if (selectedVersion.key === DEFAULT_VERSION) { router.push(`/?${params.toString()}`, asPath) } else { router.push(`/${router.locale}/${selectedVersion.key}${redirectQuery}`) } } }, [selectedVersion]) if (results) { const ActionListResults = (

You're searching the {searchVersion} version.

Select version:

{/* We might have results AND isLoading. For example, the user typed a first word, and is now typing more. */} {isLoading && (

{t('loading')}...

)}

{t('search_results_for')}: {query}

{t('matches_displayed')}: {results.length === 0 ? t('no_results') : results.length}

{ return { key: url, text: title, renderItem: () => (
  • {/* Breadcrumbs in search records don't include the page title. These fields may contain elements that we need to render */} {debug && ( score: {score.toFixed(4)} popularity: {popularity.toFixed(4)} )}

    ]+(>|$)|(\/)/g, '') } : { __html: breadcrumbs .split(' / ') .slice(0, breadcrumbs.length - 1) .join(' / ') .replace(/<\/?[^>]+(>|$)/g, ''), } } />

  • ), } })} />
    ) // When there are search results, it doesn't matter if this is overlay or not. return (
    {!isHeaderSearch && !isMobileSearch ? ( <> closeSearch()} onClickOutside={() => closeSearch()} aria-labelledby="title" sx={ isHeaderSearch ? { background: 'none', boxShadow: 'none', position: 'static', overflowY: 'auto', maxHeight: '80vh', maxWidth: '96%', margin: '1.5em 2em 0 0.5em', scrollbarWidth: 'none', } : window.innerWidth < 1012 ? { marginTop: '28rem', marginLeft: '5rem', } : { marginTop: '15rem', marginLeft: '5rem', } } > {ActionListResults} ) : ( ActionListResults )}
    ) } // We have no results at all, but perhaps we're waiting. if (isHeaderSearch) { return (
    {isLoading ? {t('loading')}... :  }
    ) } return (

    {/* This exists so that there's always *something* displayed in the DOM with or without a search result. That way, the vertical space is predetermined as a minimum. Note: Perhaps it would be better to use CSS but by using a real, but empty, DOM element, the height is always minimal and always perfectly accurate. */} {isLoading ? {t('loading')}... :  }

    ) }