dedicated search results page (redux) (#29902)
* dedicated search results page (redux) * Update SearchResults.tsx * adding pagination * fix pagination * say something on NoQuery * better Flash * tidying link * small fixes for results * debug info * l18n the meta info * inDebugMode * basic jest rendering of the skeleton page * basic jest rendering test * fix content tests * better document title * fix tests * quote query in page title * use home page sidebar * something when nothing is found * parseInt no longer needs the 10 * fix linting tests * fix test * prettier * Update pages/search.tsx Co-authored-by: Rachael Sewell <rachmari@github.com> Co-authored-by: Kevin Heis <heiskr@users.noreply.github.com> Co-authored-by: Rachael Sewell <rachmari@github.com>
This commit is contained in:
89
components/BasicSearch.tsx
Normal file
89
components/BasicSearch.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import cx from 'classnames'
|
||||||
|
|
||||||
|
import { useTranslation } from 'components/hooks/useTranslation'
|
||||||
|
import { DEFAULT_VERSION, useVersion } from 'components/hooks/useVersion'
|
||||||
|
import { useQuery } from 'components/hooks/useQuery'
|
||||||
|
|
||||||
|
import styles from './Search.module.scss'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isHeaderSearch?: true
|
||||||
|
variant?: 'compact' | 'expanded'
|
||||||
|
iconSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BasicSearch({ isHeaderSearch = true, variant = 'compact', iconSize = 24 }: Props) {
|
||||||
|
const router = useRouter()
|
||||||
|
const { query, debug } = useQuery()
|
||||||
|
const [localQuery, setLocalQuery] = useState(query)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const { t } = useTranslation('search')
|
||||||
|
const { currentVersion } = useVersion()
|
||||||
|
|
||||||
|
function redirectSearch() {
|
||||||
|
let asPath = `/${router.locale}`
|
||||||
|
if (currentVersion !== DEFAULT_VERSION) {
|
||||||
|
asPath += `/${currentVersion}`
|
||||||
|
}
|
||||||
|
asPath += '/search'
|
||||||
|
const params = new URLSearchParams({ query: localQuery })
|
||||||
|
if (debug) {
|
||||||
|
params.set('debug', '1')
|
||||||
|
}
|
||||||
|
asPath += `?${params}`
|
||||||
|
router.push(asPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="search">
|
||||||
|
<div className="position-relative z-2">
|
||||||
|
<form
|
||||||
|
role="search"
|
||||||
|
className="width-full d-flex"
|
||||||
|
noValidate
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
redirectSearch()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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'
|
||||||
|
)}
|
||||||
|
type="search"
|
||||||
|
placeholder={t`placeholder`}
|
||||||
|
autoComplete={localQuery ? 'on' : 'off'}
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
spellCheck="false"
|
||||||
|
maxLength={512}
|
||||||
|
onChange={(e) => setLocalQuery(e.target.value)}
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
components/hooks/usePage.ts
Normal file
16
components/hooks/usePage.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
|
type Info = {
|
||||||
|
page: number
|
||||||
|
}
|
||||||
|
export const usePage = (): Info => {
|
||||||
|
const router = useRouter()
|
||||||
|
const page = parseInt(
|
||||||
|
router.query.page && Array.isArray(router.query.page)
|
||||||
|
? router.query.page[0]
|
||||||
|
: router.query.page || ''
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
page: !isNaN(page) && page >= 1 ? page : 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import cx from 'classnames'
|
import cx from 'classnames'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { MarkGithubIcon, ThreeBarsIcon, XIcon } from '@primer/octicons-react'
|
import { MarkGithubIcon, ThreeBarsIcon, XIcon } from '@primer/octicons-react'
|
||||||
import { useVersion } from 'components/hooks/useVersion'
|
import { DEFAULT_VERSION, useVersion } from 'components/hooks/useVersion'
|
||||||
|
|
||||||
import { Link } from 'components/Link'
|
import { Link } from 'components/Link'
|
||||||
import { useMainContext } from 'components/context/MainContext'
|
import { useMainContext } from 'components/context/MainContext'
|
||||||
@@ -12,6 +12,7 @@ import { HeaderNotifications } from 'components/page-header/HeaderNotifications'
|
|||||||
import { ProductPicker } from 'components/page-header/ProductPicker'
|
import { ProductPicker } from 'components/page-header/ProductPicker'
|
||||||
import { useTranslation } from 'components/hooks/useTranslation'
|
import { useTranslation } from 'components/hooks/useTranslation'
|
||||||
import { Search } from 'components/Search'
|
import { Search } from 'components/Search'
|
||||||
|
import { BasicSearch } from 'components/BasicSearch'
|
||||||
import { VersionPicker } from 'components/page-header/VersionPicker'
|
import { VersionPicker } from 'components/page-header/VersionPicker'
|
||||||
import { Breadcrumbs } from './Breadcrumbs'
|
import { Breadcrumbs } from './Breadcrumbs'
|
||||||
import styles from './Header.module.scss'
|
import styles from './Header.module.scss'
|
||||||
@@ -30,7 +31,7 @@ export const Header = () => {
|
|||||||
|
|
||||||
const signupCTAVisible =
|
const signupCTAVisible =
|
||||||
hasAccount === false && // don't show if `null`
|
hasAccount === false && // don't show if `null`
|
||||||
(currentVersion === 'free-pro-team@latest' || currentVersion === 'enterprise-cloud@latest')
|
(currentVersion === DEFAULT_VERSION || currentVersion === 'enterprise-cloud@latest')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
@@ -52,6 +53,15 @@ export const Header = () => {
|
|||||||
return () => window.removeEventListener('keydown', close)
|
return () => window.removeEventListener('keydown', close)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// If you're on `/pt/search` the `router.asPath` will be `/search`
|
||||||
|
// but `/pt/search` is just shorthand for `/pt/free-pro-team@latest/search`
|
||||||
|
// so we need to make exception to that.
|
||||||
|
const onSearchResultPage =
|
||||||
|
currentVersion === DEFAULT_VERSION
|
||||||
|
? router.asPath.split('?')[0] === '/search'
|
||||||
|
: router.asPath.split('?')[0] === `/${currentVersion}/search`
|
||||||
|
const SearchComponent = onSearchResultPage ? BasicSearch : Search
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
@@ -96,7 +106,7 @@ export const Header = () => {
|
|||||||
{/* <!-- GitHub.com homepage and 404 page has a stylized search; Enterprise homepages do not --> */}
|
{/* <!-- GitHub.com homepage and 404 page has a stylized search; Enterprise homepages do not --> */}
|
||||||
{error !== '404' && (
|
{error !== '404' && (
|
||||||
<div className="d-inline-block ml-3">
|
<div className="d-inline-block ml-3">
|
||||||
<Search iconSize={16} isHeaderSearch={true} />
|
<SearchComponent iconSize={16} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -161,7 +171,7 @@ export const Header = () => {
|
|||||||
{/* <!-- GitHub.com homepage and 404 page has a stylized search; Enterprise homepages do not --> */}
|
{/* <!-- GitHub.com homepage and 404 page has a stylized search; Enterprise homepages do not --> */}
|
||||||
{error !== '404' && (
|
{error !== '404' && (
|
||||||
<div className="my-2 pt-2">
|
<div className="my-2 pt-2">
|
||||||
<Search iconSize={16} isMobileSearch={true} />
|
<SearchComponent iconSize={16} isMobileSearch={true} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
42
components/search/Loading.tsx
Normal file
42
components/search/Loading.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { Spinner } from '@primer/react'
|
||||||
|
|
||||||
|
import { useTranslation } from 'components/hooks/useTranslation'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export function Loading() {
|
||||||
|
const [showLoading, setShowLoading] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true
|
||||||
|
setTimeout(() => {
|
||||||
|
if (mounted) {
|
||||||
|
setShowLoading(true)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
return showLoading ? <ShowSpinner /> : <ShowNothing />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShowSpinner() {
|
||||||
|
const { t } = useTranslation(['search'])
|
||||||
|
return (
|
||||||
|
<div className="my-12">
|
||||||
|
<Spinner size="large" />
|
||||||
|
<h2>{t('loading')}</h2>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShowNothing() {
|
||||||
|
return (
|
||||||
|
// The min heigh is based on inspecting what the height became when it
|
||||||
|
// does render. Making this match makes the footer to not flicker
|
||||||
|
// up or down when it goes from showing nothing to something.
|
||||||
|
<div className="my-12" style={{ minHeight: 105 }}>
|
||||||
|
{/* Deliberately empty */}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
components/search/NoQuery.tsx
Normal file
19
components/search/NoQuery.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Heading, Flash } from '@primer/react'
|
||||||
|
|
||||||
|
import { useMainContext } from 'components/context/MainContext'
|
||||||
|
import { useTranslation } from 'components/hooks/useTranslation'
|
||||||
|
|
||||||
|
export function NoQuery() {
|
||||||
|
const { t } = useTranslation(['search'])
|
||||||
|
const { page } = useMainContext()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Heading as="h1">{page.title}</Heading>
|
||||||
|
|
||||||
|
<Flash variant="danger" sx={{ margin: '2rem' }}>
|
||||||
|
{t('description')}
|
||||||
|
</Flash>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
components/search/SearchError.tsx
Normal file
27
components/search/SearchError.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Box, Flash } from '@primer/react'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
|
import { useTranslation } from 'components/hooks/useTranslation'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
error: Error
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchError({ error }: Props) {
|
||||||
|
const { t } = useTranslation('search')
|
||||||
|
const { locale, asPath } = useRouter()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{' '}
|
||||||
|
<Flash variant="danger" sx={{ margin: '3rem' }}>
|
||||||
|
{t('search_error')}
|
||||||
|
<br />
|
||||||
|
{process.env.NODE_ENV === 'development' && <code>{error.toString()}</code>}
|
||||||
|
</Flash>
|
||||||
|
<Box>
|
||||||
|
<a href={`/${locale}${asPath}`}>Try reloading the page</a>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
133
components/search/SearchResults.tsx
Normal file
133
components/search/SearchResults.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { Box, Pagination, Text, Heading } from '@primer/react'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
|
import type { SearchResultsT, SearchResultHitT } from './types'
|
||||||
|
import { useTranslation } from 'components/hooks/useTranslation'
|
||||||
|
import { Link } from 'components/Link'
|
||||||
|
import { useQuery } from 'components/hooks/useQuery'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
results: SearchResultsT
|
||||||
|
}
|
||||||
|
export function SearchResults({ results }: Props) {
|
||||||
|
const { t } = useTranslation('search')
|
||||||
|
|
||||||
|
const pages = Math.ceil(results.meta.found.value / results.meta.size)
|
||||||
|
const { page } = results.meta
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
<Text>
|
||||||
|
{t('results_found')
|
||||||
|
.replace('{n}', results.meta.found.value.toLocaleString())
|
||||||
|
.replace('{s}', results.meta.took.total_msec.toFixed(0))}{' '}
|
||||||
|
</Text>
|
||||||
|
<br />
|
||||||
|
{pages > 1 && (
|
||||||
|
<Text>
|
||||||
|
{t('results_page').replace('{page}', page).replace('{pages}', pages.toLocaleString())}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<SearchResultHits hits={results.hits} />
|
||||||
|
|
||||||
|
{pages > 1 && <ResultsPagination page={page} totalPages={pages} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchResultHits({ hits }: { hits: SearchResultHitT[] }) {
|
||||||
|
const { debug } = useQuery()
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{hits.length === 0 && <NoSearchResults />}
|
||||||
|
{hits.map((hit) => (
|
||||||
|
<SearchResultHit key={hit.id} hit={hit} debug={debug} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoSearchResults() {
|
||||||
|
const { t } = useTranslation('search')
|
||||||
|
return (
|
||||||
|
<div className="my-6">
|
||||||
|
<Heading as="h2" sx={{ fontSize: 1 }}>
|
||||||
|
{t('nothing_found')}
|
||||||
|
</Heading>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchResultHit({ hit, debug }: { hit: SearchResultHitT; debug: boolean }) {
|
||||||
|
const title =
|
||||||
|
hit.highlights.title && hit.highlights.title.length > 0 ? hit.highlights.title[0] : hit.title
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-6">
|
||||||
|
<h2 className="f3">
|
||||||
|
<Link
|
||||||
|
href={hit.url}
|
||||||
|
className="color-fg-accent"
|
||||||
|
dangerouslySetInnerHTML={{ __html: title }}
|
||||||
|
></Link>
|
||||||
|
</h2>
|
||||||
|
<h3 className="text-normal f4 mb-2">{hit.breadcrumbs}</h3>
|
||||||
|
<ul className="ml-3">
|
||||||
|
{(hit.highlights.content || []).map((highlight, i) => {
|
||||||
|
return <li key={highlight + i} dangerouslySetInnerHTML={{ __html: highlight }}></li>
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{debug && (
|
||||||
|
<Text as="p" fontWeight="bold">
|
||||||
|
score: <code style={{ marginRight: 10 }}>{hit.score}</code> popularity:{' '}
|
||||||
|
<code>{hit.popularity}</code>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultsPagination({ page, totalPages }: { page: number; totalPages: number }) {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const [asPathRoot, asPathQuery = ''] = router.asPath.split('?')
|
||||||
|
|
||||||
|
function hrefBuilder(page: number) {
|
||||||
|
const params = new URLSearchParams(asPathQuery)
|
||||||
|
if (page === 1) {
|
||||||
|
params.delete('page')
|
||||||
|
} else {
|
||||||
|
params.set('page', `${page}`)
|
||||||
|
}
|
||||||
|
return `/${router.locale}${asPathRoot}?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box borderRadius={2} p={2}>
|
||||||
|
<Pagination
|
||||||
|
pageCount={totalPages}
|
||||||
|
currentPage={page}
|
||||||
|
hrefBuilder={hrefBuilder}
|
||||||
|
onPageChange={(event, page) => {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const [asPathRoot, asPathQuery = ''] = router.asPath.split('#')[0].split('?')
|
||||||
|
const params = new URLSearchParams(asPathQuery)
|
||||||
|
if (page !== 1) {
|
||||||
|
params.set('page', `${page}`)
|
||||||
|
} else {
|
||||||
|
params.delete('page')
|
||||||
|
}
|
||||||
|
let asPath = `/${router.locale}${asPathRoot}`
|
||||||
|
if (params.toString()) {
|
||||||
|
asPath += `?${params.toString()}`
|
||||||
|
}
|
||||||
|
router.push(asPath, undefined, { shallow: true })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
components/search/index.tsx
Normal file
103
components/search/index.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import useSWR from 'swr'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import Head from 'next/head'
|
||||||
|
import { Heading } from '@primer/react'
|
||||||
|
|
||||||
|
import { sendEvent, EventType } from 'components/lib/events'
|
||||||
|
import { useTranslation } from 'components/hooks/useTranslation'
|
||||||
|
import { DEFAULT_VERSION, useVersion } from 'components/hooks/useVersion'
|
||||||
|
import type { SearchResultsT } from 'components/search/types'
|
||||||
|
import { SearchResults } from 'components/search/SearchResults'
|
||||||
|
import { SearchError } from 'components/search/SearchError'
|
||||||
|
import { NoQuery } from 'components/search/NoQuery'
|
||||||
|
import { Loading } from 'components/search/Loading'
|
||||||
|
import { useQuery } from 'components/hooks/useQuery'
|
||||||
|
import { usePage } from 'components/hooks/usePage'
|
||||||
|
import { useMainContext } from 'components/context/MainContext'
|
||||||
|
|
||||||
|
export function Search() {
|
||||||
|
const { locale } = useRouter()
|
||||||
|
const { t } = useTranslation('search')
|
||||||
|
const { currentVersion } = useVersion()
|
||||||
|
const { query, debug } = useQuery()
|
||||||
|
const { page } = usePage()
|
||||||
|
|
||||||
|
// A reference to the `content/search/index.md` Page object.
|
||||||
|
// Not to be confused with the "page" that is for paginating
|
||||||
|
// results.
|
||||||
|
const { allVersions, page: documentPage } = useMainContext()
|
||||||
|
const searchVersion = allVersions[currentVersion].versionTitle
|
||||||
|
|
||||||
|
const sp = new URLSearchParams()
|
||||||
|
const hasQuery = Boolean(query.trim())
|
||||||
|
if (hasQuery) {
|
||||||
|
sp.set('query', query.trim())
|
||||||
|
sp.set('language', locale || 'en')
|
||||||
|
if (debug) sp.set('debug', 'true')
|
||||||
|
sp.set('version', currentVersion)
|
||||||
|
if (page !== 1) {
|
||||||
|
sp.set('page', `${page}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inDebugMode = process.env.NODE_ENV === 'development'
|
||||||
|
|
||||||
|
const { data: results, error } = useSWR<SearchResultsT | null, Error | null>(
|
||||||
|
hasQuery ? `/api/search/v1?${sp.toString()}` : null,
|
||||||
|
async (url) => {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
// Because the backend never changes between fetches, we can treat
|
||||||
|
// it as an immutable resource and disable these revalidation
|
||||||
|
// checks.
|
||||||
|
revalidateIfStale: inDebugMode,
|
||||||
|
revalidateOnFocus: inDebugMode,
|
||||||
|
revalidateOnReconnect: inDebugMode,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
let pageTitle = documentPage.fullTitle
|
||||||
|
if (hasQuery) {
|
||||||
|
pageTitle = `${t('search_results_for')} '${query}'`
|
||||||
|
if (currentVersion !== DEFAULT_VERSION) {
|
||||||
|
pageTitle += ` (${searchVersion})`
|
||||||
|
}
|
||||||
|
if (results) {
|
||||||
|
pageTitle = `${results.meta.found.value.toLocaleString()} ${pageTitle}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-xl px-3 px-md-6 my-4" data-testid="search-results">
|
||||||
|
<Head>
|
||||||
|
<title>{pageTitle}</title>
|
||||||
|
</Head>
|
||||||
|
{hasQuery && (
|
||||||
|
<Heading as="h1">
|
||||||
|
{t('search_results_for')} <i>{query}</i>
|
||||||
|
</Heading>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<SearchError error={error} />
|
||||||
|
) : results ? (
|
||||||
|
<SearchResults results={results} />
|
||||||
|
) : hasQuery ? (
|
||||||
|
<Loading />
|
||||||
|
) : (
|
||||||
|
<NoQuery />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
components/search/types.ts
Normal file
31
components/search/types.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export type SearchResultHitT = {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
breadcrumbs: string
|
||||||
|
highlights: {
|
||||||
|
title?: string[]
|
||||||
|
content?: string[]
|
||||||
|
}
|
||||||
|
score?: number
|
||||||
|
popularity?: number
|
||||||
|
es_url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchResultsMeta = {
|
||||||
|
found: {
|
||||||
|
value: number
|
||||||
|
relation: string
|
||||||
|
}
|
||||||
|
took: {
|
||||||
|
query_msec: number
|
||||||
|
total_msec: number
|
||||||
|
}
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SearchResultsT = {
|
||||||
|
meta: SearchResultsMeta
|
||||||
|
hits: SearchResultHitT[]
|
||||||
|
}
|
||||||
@@ -40,7 +40,11 @@ export const SidebarNav = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<nav>
|
<nav>
|
||||||
{error === '404' || currentProduct === null ? <SidebarHomepage /> : <SidebarProduct />}
|
{error === '404' || !currentProduct || currentProduct.id === 'search' ? (
|
||||||
|
<SidebarHomepage />
|
||||||
|
) : (
|
||||||
|
<SidebarProduct />
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ redirect_from:
|
|||||||
- /troubleshooting-common-issues
|
- /troubleshooting-common-issues
|
||||||
versions: '*'
|
versions: '*'
|
||||||
children:
|
children:
|
||||||
|
- search
|
||||||
- get-started
|
- get-started
|
||||||
- account-and-profile
|
- account-and-profile
|
||||||
- authentication
|
- authentication
|
||||||
@@ -124,4 +125,3 @@ externalProducts:
|
|||||||
href: 'https://docs.npmjs.com/'
|
href: 'https://docs.npmjs.com/'
|
||||||
external: true
|
external: true
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
9
content/search/index.md
Normal file
9
content/search/index.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
title: Search
|
||||||
|
hidden: true
|
||||||
|
versions:
|
||||||
|
fpt: '*'
|
||||||
|
ghec: '*'
|
||||||
|
ghes: '*'
|
||||||
|
ghae: '*'
|
||||||
|
---
|
||||||
@@ -8,6 +8,7 @@ files:
|
|||||||
- '/content/early-access'
|
- '/content/early-access'
|
||||||
- '/content/site-policy/site-policy-deprecated'
|
- '/content/site-policy/site-policy-deprecated'
|
||||||
- '/content/github/index'
|
- '/content/github/index'
|
||||||
|
- '/content/search'
|
||||||
excluded_target_languages: ['de', 'ko', 'ru']
|
excluded_target_languages: ['de', 'ko', 'ru']
|
||||||
- source: /data/**/*.yml
|
- source: /data/**/*.yml
|
||||||
translation: /translations/%locale%/%original_path%/%original_file_name%
|
translation: /translations/%locale%/%original_path%/%original_file_name%
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ search:
|
|||||||
search_error: An error occurred trying to perform the search.
|
search_error: An error occurred trying to perform the search.
|
||||||
description: Enter a search term to find it in the GitHub Documentation.
|
description: Enter a search term to find it in the GitHub Documentation.
|
||||||
label: Search GitHub Docs
|
label: Search GitHub Docs
|
||||||
|
results_found: Found {n} results in {s}ms
|
||||||
|
results_page: This is page {page} of {pages}.
|
||||||
|
nothing_found: Nothing found 😿
|
||||||
homepage:
|
homepage:
|
||||||
explore_by_product: Explore by product
|
explore_by_product: Explore by product
|
||||||
version_picker: Version
|
version_picker: Version
|
||||||
|
|||||||
@@ -126,10 +126,20 @@ router.get(
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class ValidationError extends Error {}
|
||||||
|
|
||||||
const validationMiddleware = (req, res, next) => {
|
const validationMiddleware = (req, res, next) => {
|
||||||
const params = [
|
const params = [
|
||||||
{ key: 'query' },
|
{ key: 'query' },
|
||||||
{ key: 'version', default_: 'dotcom', validate: (v) => versionAliases[v] || allVersions[v] },
|
{
|
||||||
|
key: 'version',
|
||||||
|
default_: 'dotcom',
|
||||||
|
validate: (v) => {
|
||||||
|
if (versionAliases[v] || allVersions[v]) return true
|
||||||
|
const valid = [...Object.keys(versionAliases), ...Object.keys(allVersions)]
|
||||||
|
throw new ValidationError(`'${v}' not in ${valid}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
{ key: 'language', default_: 'en', validate: (v) => v in languages },
|
{ key: 'language', default_: 'en', validate: (v) => v in languages },
|
||||||
{
|
{
|
||||||
key: 'size',
|
key: 'size',
|
||||||
@@ -160,10 +170,17 @@ const validationMiddleware = (req, res, next) => {
|
|||||||
if (cast) {
|
if (cast) {
|
||||||
value = cast(value)
|
value = cast(value)
|
||||||
}
|
}
|
||||||
if (validate && !validate(value)) {
|
try {
|
||||||
return res
|
if (validate && !validate(value)) {
|
||||||
.status(400)
|
return res
|
||||||
.json({ error: `Not a valid value (${JSON.stringify(value)}) for key '${key}'` })
|
.status(400)
|
||||||
|
.json({ error: `Not a valid value (${JSON.stringify(value)}) for key '${key}'` })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ValidationError) {
|
||||||
|
return res.status(400).json({ error: err.toString(), field: key })
|
||||||
|
}
|
||||||
|
throw err
|
||||||
}
|
}
|
||||||
search[key] = value
|
search[key] = value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ export default async function genericToc(req, res, next) {
|
|||||||
if (!req.context.page) return next()
|
if (!req.context.page) return next()
|
||||||
if (req.context.currentLayoutName !== 'default') return next()
|
if (req.context.currentLayoutName !== 'default') return next()
|
||||||
// This middleware can only run on product, category, and map topics.
|
// This middleware can only run on product, category, and map topics.
|
||||||
if (req.context.page.documentType === 'homepage' || req.context.page.documentType === 'article')
|
if (
|
||||||
|
req.context.page.documentType === 'homepage' ||
|
||||||
|
req.context.page.documentType === 'article' ||
|
||||||
|
req.context.page.relativePath === 'search/index.md'
|
||||||
|
)
|
||||||
return next()
|
return next()
|
||||||
|
|
||||||
// This one product TOC is weird.
|
// This one product TOC is weird.
|
||||||
|
|||||||
@@ -24,22 +24,54 @@ export default function handleRedirects(req, res, next) {
|
|||||||
return res.redirect(302, `/${language}`)
|
return res.redirect(302, `/${language}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't try to redirect if the URL is `/search` which is the XHR
|
||||||
|
// endpoint. It should not become `/en/search`.
|
||||||
|
// It's unfortunate and looks a bit needlessly complicated. But
|
||||||
|
// it comes from the legacy that the JSON API endpoint was and needs to
|
||||||
|
// continue to be `/search` when it would have been more neat if it
|
||||||
|
// was something like `/api/search`.
|
||||||
|
// If someone types in `/search?query=foo` manually, they'll get JSON.
|
||||||
|
// Maybe sometime in 2023 we remove `/search` as an endpoint for the
|
||||||
|
// JSON.
|
||||||
|
if (req.path === '/search') return next()
|
||||||
|
|
||||||
// begin redirect handling
|
// begin redirect handling
|
||||||
let redirect = req.path
|
let redirect = req.path
|
||||||
let queryParams = req._parsedUrl.query
|
let queryParams = req._parsedUrl.query
|
||||||
|
|
||||||
// update old-style query params (#9467)
|
if (
|
||||||
if ('q' in req.query) {
|
'q' in req.query ||
|
||||||
const newQueryParams = new URLSearchParams(queryParams)
|
('query' in req.query && !(req.path.endsWith('/search') || req.path.startsWith('/api/search')))
|
||||||
newQueryParams.set('query', newQueryParams.get('q'))
|
) {
|
||||||
newQueryParams.delete('q')
|
// If you had the old legacy format of /some/uri?q=stuff
|
||||||
return res.redirect(301, `${req.path}?${newQueryParams.toString()}`)
|
// it needs to redirect to /en/search?query=stuff.
|
||||||
|
// If you have the new format of /some/uri?query=stuff it too needs
|
||||||
|
// to redirect to /en/search?query=stuff
|
||||||
|
// ...or /en/{version}/search?query=stuff
|
||||||
|
const language = getLanguage(req)
|
||||||
|
const sp = new URLSearchParams(req.query)
|
||||||
|
if (sp.has('q') && !sp.has('query')) {
|
||||||
|
sp.set('query', sp.get('q'))
|
||||||
|
sp.delete('q')
|
||||||
|
}
|
||||||
|
|
||||||
|
let redirectTo = `/${language}`
|
||||||
|
const { currentVersion } = req.context
|
||||||
|
if (currentVersion !== 'free-pro-team@latest') {
|
||||||
|
redirectTo += `/${currentVersion}`
|
||||||
|
// The `req.context.currentVersion` is just the portion of the URL
|
||||||
|
// pathname. It could be that the currentVersion is something
|
||||||
|
// like `enterprise` which needs to be redirected to its new name.
|
||||||
|
redirectTo = getRedirect(redirectTo, req.context)
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectTo += `/search?${sp.toString()}`
|
||||||
|
return res.redirect(301, redirectTo)
|
||||||
}
|
}
|
||||||
|
|
||||||
// have to do this now because searchPath replacement changes the path as well as the query params
|
// have to do this now because searchPath replacement changes the path as well as the query params
|
||||||
if (queryParams) {
|
if (queryParams) {
|
||||||
queryParams = '?' + queryParams
|
queryParams = '?' + queryParams
|
||||||
redirect = (redirect + queryParams).replace(patterns.searchPath, '$1')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove query params temporarily so we can find the path in the redirects object
|
// remove query params temporarily so we can find the path in the redirects object
|
||||||
|
|||||||
4
pages/[versionId]/search.tsx
Normal file
4
pages/[versionId]/search.tsx
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import Search from '../search'
|
||||||
|
export { getServerSideProps } from '../search'
|
||||||
|
|
||||||
|
export default Search
|
||||||
46
pages/search.tsx
Normal file
46
pages/search.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { GetServerSideProps } from 'next'
|
||||||
|
|
||||||
|
import searchVersions from '../lib/search/versions.js'
|
||||||
|
|
||||||
|
import { MainContextT, MainContext, getMainContext } from 'components/context/MainContext'
|
||||||
|
import { DefaultLayout } from 'components/DefaultLayout'
|
||||||
|
import { Search } from 'components/search/index'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mainContext: MainContextT
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Page({ mainContext }: Props) {
|
||||||
|
return (
|
||||||
|
<MainContext.Provider value={mainContext}>
|
||||||
|
<DefaultLayout>
|
||||||
|
<Search />
|
||||||
|
</DefaultLayout>
|
||||||
|
</MainContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getServerSideProps: GetServerSideProps<Props> = async (context) => {
|
||||||
|
const req = context.req as any
|
||||||
|
const res = context.res as any
|
||||||
|
|
||||||
|
const version = req.context.currentVersion
|
||||||
|
|
||||||
|
const searchVersion = searchVersions[Array.isArray(version) ? version[0] : version]
|
||||||
|
if (!searchVersion) {
|
||||||
|
// E.g. someone loaded `/en/enterprisy-server@2.99/search`
|
||||||
|
// That's going to 404 in the XHR later but it simply shouldn't be
|
||||||
|
// a valid starting page.
|
||||||
|
return {
|
||||||
|
notFound: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainContext = getMainContext(req, res)
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
mainContext,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,7 +144,8 @@ describeIfElasticsearchURL('search middleware', () => {
|
|||||||
sp.set('version', 'xxxxx')
|
sp.set('version', 'xxxxx')
|
||||||
const res = await get('/api/search/v1?' + sp)
|
const res = await get('/api/search/v1?' + sp)
|
||||||
expect(res.statusCode).toBe(400)
|
expect(res.statusCode).toBe(400)
|
||||||
expect(JSON.parse(res.text).error).toMatch('version')
|
expect(JSON.parse(res.text).error).toMatch("'xxxxx'")
|
||||||
|
expect(JSON.parse(res.text).field).toMatch('version')
|
||||||
}
|
}
|
||||||
// unrecognized size
|
// unrecognized size
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ describe('category pages', () => {
|
|||||||
|
|
||||||
const walkOptions = {
|
const walkOptions = {
|
||||||
globs: ['*/index.md', 'enterprise/*/index.md'],
|
globs: ['*/index.md', 'enterprise/*/index.md'],
|
||||||
ignore: ['{rest,graphql}/**', 'enterprise/index.md', '**/articles/**', 'early-access/**'],
|
ignore: [
|
||||||
|
'{rest,graphql}/**',
|
||||||
|
'enterprise/index.md',
|
||||||
|
'**/articles/**',
|
||||||
|
'early-access/**',
|
||||||
|
'search/index.md',
|
||||||
|
],
|
||||||
directories: false,
|
directories: false,
|
||||||
includeBasePath: true,
|
includeBasePath: true,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ describe('siteTree', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('object order and structure', () => {
|
test('object order and structure', () => {
|
||||||
expect(siteTree.en[nonEnterpriseDefaultVersion].childPages[0].href).toBe('/en/get-started')
|
expect(siteTree.en[nonEnterpriseDefaultVersion].childPages[1].href).toBe('/en/get-started')
|
||||||
expect(siteTree.en[nonEnterpriseDefaultVersion].childPages[0].childPages[0].href).toBe(
|
expect(siteTree.en[nonEnterpriseDefaultVersion].childPages[1].childPages[0].href).toBe(
|
||||||
'/en/get-started/quickstart'
|
'/en/get-started/quickstart'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -409,6 +409,7 @@ describe('lint markdown content', () => {
|
|||||||
isHidden,
|
isHidden,
|
||||||
isEarlyAccess,
|
isEarlyAccess,
|
||||||
isSitePolicy,
|
isSitePolicy,
|
||||||
|
isSearch,
|
||||||
hasExperimentalAlternative,
|
hasExperimentalAlternative,
|
||||||
frontmatterData
|
frontmatterData
|
||||||
|
|
||||||
@@ -420,8 +421,10 @@ describe('lint markdown content', () => {
|
|||||||
frontmatterData = data
|
frontmatterData = data
|
||||||
ast = fromMarkdown(content)
|
ast = fromMarkdown(content)
|
||||||
isHidden = data.hidden === true
|
isHidden = data.hidden === true
|
||||||
isEarlyAccess = markdownRelPath.split('/').includes('early-access')
|
const split = markdownRelPath.split('/')
|
||||||
isSitePolicy = markdownRelPath.split('/').includes('site-policy-deprecated')
|
isEarlyAccess = split.includes('early-access')
|
||||||
|
isSitePolicy = split.includes('site-policy-deprecated')
|
||||||
|
isSearch = split.includes('search') && !split.includes('reusables')
|
||||||
hasExperimentalAlternative = data.hasExperimentalAlternative === true
|
hasExperimentalAlternative = data.hasExperimentalAlternative === true
|
||||||
|
|
||||||
links = []
|
links = []
|
||||||
@@ -457,10 +460,10 @@ describe('lint markdown content', () => {
|
|||||||
.map((schedule) => schedule.cron)
|
.map((schedule) => schedule.cron)
|
||||||
})
|
})
|
||||||
|
|
||||||
// We need to support some non-Early Access hidden docs in Site Policy
|
test('hidden docs must be Early Access, Site Policy, Search, or Experimental', async () => {
|
||||||
test('hidden docs must be Early Access, Site Policy, or Experimental', async () => {
|
// We need to support some non-Early Access hidden docs in Site Policy
|
||||||
if (isHidden) {
|
if (isHidden) {
|
||||||
expect(isEarlyAccess || isSitePolicy || hasExperimentalAlternative).toBe(true)
|
expect(isEarlyAccess || isSitePolicy || isSearch || hasExperimentalAlternative).toBe(true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
30
tests/rendering/search.js
Normal file
30
tests/rendering/search.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { expect, jest } from '@jest/globals'
|
||||||
|
|
||||||
|
import { getDOM } from '../helpers/e2etest.js'
|
||||||
|
|
||||||
|
describe('search results page', () => {
|
||||||
|
jest.setTimeout(5 * 60 * 1000)
|
||||||
|
|
||||||
|
test('says something if no query is provided', async () => {
|
||||||
|
const $ = await getDOM('/en/search')
|
||||||
|
const $container = $('[data-testid="search-results"]')
|
||||||
|
expect($container.text()).toMatch(/Enter a search term/)
|
||||||
|
// Default is the frontmatter title of the content/search/index.md
|
||||||
|
expect($('title').text()).toMatch('Search - GitHub Docs')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('says something if query is empty', async () => {
|
||||||
|
const $ = await getDOM(`/en/search?${new URLSearchParams({ query: ' ' })}`)
|
||||||
|
const $container = $('[data-testid="search-results"]')
|
||||||
|
expect($container.text()).toMatch(/Enter a search term/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('mention search term in h1', async () => {
|
||||||
|
const $ = await getDOM(`/en/search?${new URLSearchParams({ query: 'peterbe' })}`)
|
||||||
|
const $container = $('[data-testid="search-results"]')
|
||||||
|
const h1Text = $container.find('h1').text()
|
||||||
|
expect(h1Text).toMatch(/Search results for/)
|
||||||
|
expect(h1Text).toMatch(/peterbe/)
|
||||||
|
expect($('title').text()).toMatch(/Search results for 'peterbe'/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -53,15 +53,15 @@ describe('redirects', () => {
|
|||||||
describe('query params', () => {
|
describe('query params', () => {
|
||||||
test('are preserved in redirected URLs', async () => {
|
test('are preserved in redirected URLs', async () => {
|
||||||
const res = await get('/enterprise/admin?query=pulls')
|
const res = await get('/enterprise/admin?query=pulls')
|
||||||
expect(res.statusCode).toBe(302)
|
expect(res.statusCode).toBe(301)
|
||||||
const expected = `/en/enterprise-server@${enterpriseServerReleases.latest}/admin?query=pulls`
|
const expected = `/en/enterprise-server@${enterpriseServerReleases.latest}/search?query=pulls`
|
||||||
expect(res.headers.location).toBe(expected)
|
expect(res.headers.location).toBe(expected)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('have q= converted to query=', async () => {
|
test('have q= converted to query=', async () => {
|
||||||
const res = await get('/en/enterprise/admin?q=pulls')
|
const res = await get('/en/enterprise/admin?q=pulls')
|
||||||
expect(res.statusCode).toBe(301)
|
expect(res.statusCode).toBe(301)
|
||||||
const expected = '/en/enterprise/admin?query=pulls'
|
const expected = `/en/enterprise-server@${enterpriseServerReleases.latest}/search?query=pulls`
|
||||||
expect(res.headers.location).toBe(expected)
|
expect(res.headers.location).toBe(expected)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -74,13 +74,6 @@ describe('redirects', () => {
|
|||||||
expect(res.headers.location).toBe(expected)
|
expect(res.headers.location).toBe(expected)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('work with redirected search paths', async () => {
|
|
||||||
const res = await get('/en/enterprise/admin/search?utf8=%E2%9C%93&query=pulls')
|
|
||||||
expect(res.statusCode).toBe(301)
|
|
||||||
const expected = `/en/enterprise-server@${enterpriseServerReleases.latest}/admin?utf8=%E2%9C%93&query=pulls`
|
|
||||||
expect(res.headers.location).toBe(expected)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('do not work on other paths that include "search"', async () => {
|
test('do not work on other paths that include "search"', async () => {
|
||||||
const reqPath = `/en/enterprise-server@${enterpriseServerReleases.latest}/admin/configuration/configuring-github-connect/enabling-unified-search-for-your-enterprise`
|
const reqPath = `/en/enterprise-server@${enterpriseServerReleases.latest}/admin/configuration/configuring-github-connect/enabling-unified-search-for-your-enterprise`
|
||||||
const res = await get(reqPath)
|
const res = await get(reqPath)
|
||||||
|
|||||||
Reference in New Issue
Block a user