import React, { useState, useEffect, useRef, ReactNode, RefObject } from 'react' import { useRouter } from 'next/router' import debounce from 'lodash/debounce' import cx from 'classnames' import { useTranslation } from 'components/hooks/useTranslation' import { sendEvent, EventType } from 'components/lib/events' import { useMainContext } from './context/MainContext' import { useVersion } from 'components/hooks/useVersion' import { useLanguages } from './context/LanguagesContext' import styles from './Search.module.scss' import { ActionList, Label, Link, Overlay } from '@primer/components' type SearchResult = { url: string breadcrumbs: string title: string content: string score: number popularity: number } type Props = { isHeaderSearch?: boolean variant?: 'compact' | 'expanded' updateSearchParams?: boolean iconSize: number children?: (props: { SearchInput: ReactNode; SearchResults: ReactNode }) => ReactNode } export function Search({ isHeaderSearch = false, updateSearchParams = true, variant = 'compact', iconSize = 24, children, }: Props) { const router = useRouter() const [query, setQuery] = useState(router.query.query || '') const [results, setResults] = useState | null>(null) const [isLoading, setIsLoading] = useState(false) const inputRef = useRef(null) const { t } = useTranslation('search') const { currentVersion } = useVersion() const { languages } = useLanguages() const initialRender = useRef(true) // 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 isHomePage = !('productId' in router.query) // If the user shows up with a query in the URL, go ahead and search for it useEffect(() => { if ( (!isHeaderSearch && isHomePage && router.query.query) || (isHeaderSearch && updateSearchParams && router.query.query) ) { /* await */ fetchSearchResults((router.query.query as string).trim()) } }, []) // If the version changed from the dropdown version or language picker // close the search pane and clear the query // If the version changed from the search result window, keep the query // and results but reset the versionFromSearchPane value useEffect(() => { if (initialRender.current) { initialRender.current = false } else { closeSearch() } }, [currentVersion, language]) // When the user finishes typing, update the results async function onSearch(e: React.ChangeEvent) { const xquery = e.target?.value?.trim() setQuery(xquery) // Update the URL with the search parameters in the query string if (updateSearchParams) { const pushUrl = new URL(location.toString()) pushUrl.searchParams.set('query', xquery) history.pushState({}, '', pushUrl.toString()) } return await fetchSearchResults(xquery) } // If there's a query, call the endpoint // Otherwise, there's no results by default async function fetchSearchResults(xquery: string) { setIsLoading(true) try { if (xquery) { const endpointUrl = new URL(location.origin) endpointUrl.pathname = '/search' const endpointParams: Record = { language, version, query: xquery, } endpointUrl.search = new URLSearchParams(endpointParams).toString() const response = await fetch(endpointUrl.toString(), { method: 'GET', headers: { 'Content-Type': 'application/json' }, }) setResults(response.ok ? await response.json() : []) } else { setResults(null) } } finally { setIsLoading(false) } // Analytics tracking if (xquery) { sendEvent({ type: EventType.search, search_query: xquery, // search_context }) } } // Close panel if overlay is clicked function closeSearch() { setQuery('') setResults(null) if (inputRef.current) { inputRef.current.value = '' } } // Prevent the page from refreshing when you "submit" the form function preventRefresh(evt: React.FormEvent) { evt.preventDefault() } const SearchResults = ( <>
{/* 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 ShowSearchResults({ anchorRef, isHeaderSearch, isLoading, results, closeSearch, debug, query, }: { anchorRef: RefObject isHeaderSearch: boolean isLoading: boolean results: SearchResult[] | null closeSearch: () => void debug: boolean query: string | string[] }) { const { t } = useTranslation('search') const router = useRouter() const { currentVersion } = useVersion() const { allVersions } = useMainContext() const searchVersion = allVersions[currentVersion].versionTitle 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 redirectQuery = query ? `?query=${query}` : '' if (results !== null) { if (results.length === 0) { // When there results, but exactly 0, it matters if this is the overlay or not. if (isHeaderSearch) { return (
{isLoading ? {t('loading')}... : {t('no_results')}.}
) } else { return (

{t('no_results')}.

) } } // When there are search results, it doesn't matter if this is overlay or not. return (
{ 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', } } >

You're searching the {searchVersion}{' '} version. Didn't find what you're looking for? Click a different version to try again.

{versions.map(({ title, version }) => { return ( ) })}
{ 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, ''), } } />
  • ), } })} />
    }
    ) } // 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')}... :  }

    ) }