1
0
mirror of synced 2025-12-19 18:10:59 -05:00
Files
docs/components/Search.tsx

592 lines
20 KiB
TypeScript

import React, { useState, useEffect, useRef, ReactNode, RefObject } from 'react'
import { useRouter } from 'next/router'
import useSWR from 'swr'
import cx from 'classnames'
import { Flash, Label, ActionList, ActionMenu } from '@primer/react'
import { ItemInput } from '@primer/react/lib/deprecated/ActionList/List'
import { InfoIcon } from '@primer/octicons-react'
import { useLanguages } from 'components/context/LanguagesContext'
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 styles from './Search.module.scss'
const SEARCH_API_ENDPOINT = '/api/search/v1'
type Hit = {
id: string
url: string
title: string
breadcrumbs: string
highlights: {
title: Array<string>
content: Array<string>
}
score?: number
popularity?: number
es_url?: string
}
// Note, the JSON will contain other keys too, but since we don't use
// them in this component, we don't need to specify them.
type Meta = {
found: {
value: number
}
}
type Data = {
hits: Hit[]
meta: Meta
}
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<string>(localQuery, 300)
const inputRef = useRef<HTMLInputElement>(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 = languages
? (Object.keys(languages).includes(router.locale || '') && router.locale) || 'en'
: 'en'
const fetchURL = query
? `${SEARCH_API_ENDPOINT}?${new URLSearchParams({
language,
version,
query,
// In its current state, can't with confidence know if the user
// initiated a search because of the input debounce or if the
// user has finished typing.
autocomplete: 'true',
})}`
: null
const { data, error: searchError } = useSWR<Data, Error>(
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,
// By default, when the URL changes to the hook, the `data` and `error`
// are reset. Just as if it was the very first mount.
// Previously we used to keep a copy of the "last" data output
// so that we don't immediately wipe away any search results
// when waiting for the new ones.
// With the `keepPreviousData` option, it simply doesn't reset the
// `data` output just because the URL is changing from what it
// was previously.
keepPreviousData: true,
}
)
// 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 && !data && !searchError)
const [isLoadingDebounced] = useDebounce<boolean>(isLoadingRaw, 500)
const isLoading = isLoadingRaw && isLoadingDebounced
useEffect(() => {
// Because we don't want to have to type .trim() everywhere we
// use this variable and we also don't want to change the origin.
// This variable is used to decide if and what we should change
// the URL to.
// Trim whitespace to make sure there's anything left and when
// do put this debounced query into the query string, we use it
// with the whitespace trimmed.
const query = debouncedQuery.trim()
if ((router.query.query || '') !== query) {
const [asPathRoot, asPathQuery = ''] = router.asPath.split('#')[0].split('?')
const params = new URLSearchParams(asPathQuery)
if (query) {
params.set('query', query)
} 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<HTMLInputElement>) {
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 = (
<>
<div
id="search-results-container"
className={cx(
'z-1 pb-5 px-3',
isHeaderSearch &&
'pt-9 color-bg-default color-shadow-medium position-absolute top-0 right-0',
styles.resultsContainer,
isHeaderSearch && styles.resultsContainerHeader,
query || searchError ? 'd-block' : 'd-none',
(query || searchError) && styles.resultsContainerOpen
)}
>
{searchError ? (
<ShowSearchError
error={searchError}
isHeaderSearch={isHeaderSearch}
isMobileSearch={isMobileSearch}
/>
) : (
<ShowSearchResults
anchorRef={inputRef}
isHeaderSearch={isHeaderSearch}
isMobileSearch={isMobileSearch}
isLoading={isLoading}
results={data}
closeSearch={closeSearch}
debug={debug}
query={query}
/>
)}
</div>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
className={cx(
'-z-1',
isHeaderSearch && query
? 'position-fixed top-0 right-0 bottom-0 left-0 d-block'
: 'd-none',
isHeaderSearch && query && styles.headerSearchOpen
)}
onClick={closeSearch}
/>
</>
)
const SearchInput = (
<div data-testid="search">
<div className="position-relative z-2">
<form role="search" className="width-full d-flex" noValidate onSubmit={onFormSubmit}>
<label className="text-normal width-full">
<span
className="visually-hidden"
aria-label={t`label`}
aria-describedby={t`description`}
>{t`placeholder`}</span>
<input
data-testid="site-search-input"
ref={inputRef}
className={cx(
styles.searchInput,
iconSize === 24 && styles.searchIconBackground24,
iconSize === 24 && 'form-control px-6 f4',
iconSize === 16 && styles.searchIconBackground16,
iconSize === 16 && 'form-control px-5 f4',
variant === 'compact' && 'py-2',
variant === 'expanded' && 'py-3',
isHeaderSearch && styles.searchInputHeader,
!isHeaderSearch && 'width-full',
isHeaderSearch && query && styles.searchInputExpanded,
isHeaderSearch && query && 'position-absolute top-0 right-0'
)}
type="search"
placeholder={t`placeholder`}
autoComplete={localQuery ? 'on' : 'off'}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
maxLength={512}
onChange={onSearch}
value={localQuery}
aria-label={t`label`}
aria-describedby={t`description`}
/>
</label>
<button className="d-none" type="submit" title="Submit the search query." hidden />
</form>
</div>
</div>
)
return (
<>
{typeof children === 'function' ? (
children({ SearchInput, SearchResults })
) : (
<>
{SearchInput}
{SearchResults}
</>
)}
</>
)
}
function useDebounce<T>(value: T, delay?: number): [T, (value: T) => void] {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500)
return () => {
clearTimeout(timer)
}
}, [value, delay])
return [debouncedValue, setDebouncedValue]
}
function ShowSearchError({
error,
}: {
error: Error
isHeaderSearch: boolean
isMobileSearch: boolean
}) {
const { t } = useTranslation('search')
return (
<Flash variant="danger" sx={{ margin: '2rem 2rem 0 2em' }}>
<p>{t('search_error')}</p>
{process.env.NODE_ENV === 'development' && (
<p>
<small>
<code>{error.toString()}</code>
</small>
</p>
)}
</Flash>
)
}
function ShowSearchResults({
isHeaderSearch,
isLoading,
results,
debug,
query,
}: {
anchorRef: RefObject<HTMLElement>
isHeaderSearch: boolean
isMobileSearch: boolean
isLoading: boolean
results: Data | undefined
closeSearch: () => void
debug: boolean
query: string
}) {
const { t } = useTranslation(['pages', 'search'])
const router = useRouter()
const { currentVersion } = useVersion()
const { allVersions } = useMainContext()
const searchVersion = allVersions[currentVersion].versionTitle
const [selectedVersion, setSelectedVersion] = useState<ItemInput | undefined>()
const currentVersionPathSegment = currentVersion === DEFAULT_VERSION ? '' : `/${currentVersion}`
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,
}
})
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 = (
<div
data-testid="search-results"
className={cx(
'mt-3',
isHeaderSearch && styles.headerSearchResults,
isHeaderSearch && 'overflow-auto'
)}
>
<div className={cx(styles.versionSearchContainer, 'mt-4 pb-4 width-full border-bottom')}>
<p className={cx(styles.searchWording, 'f6 ml-4 d-inline-block')}>
You're searching the <strong>{searchVersion}</strong> version.
</p>
<div className="float-right mr-4">
<p
aria-describedby={`You're searching the ${searchVersion} version`}
className={cx(styles.selectWording, 'f6 d-inline-block')}
>
Select version:
</p>
<ActionMenu>
<ActionMenu.Button sx={{ display: 'inline-block' }}>
{selectedVersion ? selectedVersion.text : searchVersion}
</ActionMenu.Button>
<ActionMenu.Overlay>
<ActionList selectionVariant="single">
{searchVersions.map((searchVersion) => {
return (
<ActionList.Item
onSelect={() => setSelectedVersion(searchVersion)}
key={searchVersion.key}
>
{searchVersion.text}
</ActionList.Item>
)
})}
<ActionList.LinkItem
className="f6"
href={`/${router.locale}${currentVersionPathSegment}/get-started/learning-about-github/about-versions-of-github-docs`}
>
{t('about_versions')} <InfoIcon />
</ActionList.LinkItem>
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</div>
</div>
{/* We might have results AND isLoading. For example, the user typed
a first word, and is now typing more. */}
{isLoading && (
<p className="d-block ml-4 mt-4">
<span>{t('loading')}...</span>
</p>
)}
<h1 className="ml-4 f2 mt-4">
{t('search_results_for')}: {query}
</h1>
<p className="ml-4 mb-4 text-normal f5">
{results.meta.found.value === 0 ? (
t('no_results')
) : (
<span>
{t('matches_found')}: {results.meta.found.value.toLocaleString()}
</span>
)}
.{' '}
{results.meta.found.value > results.hits.length && (
<span>
{t('matches_displayed')}:{' '}
{results.meta.found.value === 0 ? t('no_results') : results.hits.length}
</span>
)}
</p>
<ActionList variant="full">
{results.hits.map((hit, index) => {
const { url, breadcrumbs, title, highlights, score, popularity } = hit
const contentHTML =
highlights.content && highlights.content.length > 0
? highlights.content.join('<br>')
: ''
const titleHTML =
highlights.title && highlights.title.length > 0 ? highlights.title[0] : title
return (
<ActionList.Item className="width-full" key={url}>
<Link
href={url}
className="no-underline color-fg-default"
onClick={() => {
sendEvent({
type: EventType.searchResult,
search_result_query: Array.isArray(query) ? query[0] : query,
search_result_index: index,
search_result_total: results.hits.length,
search_result_rank: (results.hits.length - index) / results.hits.length,
search_result_url: url,
})
}}
>
<div
data-testid="search-result"
className={cx('list-style-none', styles.resultsContainer)}
>
<div className={cx('py-2 px-3')}>
<Label size="small" variant="accent">
{breadcrumbs ? breadcrumbs.split(' / ')[0] : title}
</Label>
{debug && score !== undefined && popularity !== undefined && (
<small className="float-right">
score: {score.toFixed(4)} popularity: {popularity.toFixed(4)}
</small>
)}
<h2
className={cx('mt-2 text-normal f3 d-block')}
dangerouslySetInnerHTML={{
__html: titleHTML,
}}
/>
<div
className={cx(styles.searchResultContent, 'mt-1 d-block overflow-hidden')}
style={{ maxHeight: '2.5rem' }}
dangerouslySetInnerHTML={{ __html: contentHTML }}
/>
<div
className={'d-block mt-2 opacity-70 text-small'}
dangerouslySetInnerHTML={
breadcrumbs.length === 0
? { __html: `${title}`.replace(/<\/?[^>]+(>|$)|(\/)/g, '') }
: {
__html: breadcrumbs
.split(' / ')
.slice(0, breadcrumbs.length - 1)
.join(' / ')
.replace(/<\/?[^>]+(>|$)/g, ''),
}
}
/>
</div>
</div>
</Link>
</ActionList.Item>
)
})}
</ActionList>
</div>
)
return <div>{ActionListResults}</div>
}
// We have no results at all, but perhaps we're waiting.
if (isHeaderSearch) {
return (
<div className="mt-2 px-6 pt-3">
{isLoading ? <span>{t('loading')}...</span> : <span>&nbsp;</span>}
</div>
)
}
return (
<p data-testid="results-spacer" className="d-block mt-4">
{/*
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 ? <span>{t('loading')}...</span> : <span>&nbsp;</span>}
</p>
)
}