зеркало из https://github.com/github/docs.git
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:
Родитель
2206e82584
Коммит
8765c628ff
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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 { useRouter } from 'next/router'
|
||||
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 { useMainContext } from 'components/context/MainContext'
|
||||
|
@ -12,6 +12,7 @@ import { HeaderNotifications } from 'components/page-header/HeaderNotifications'
|
|||
import { ProductPicker } from 'components/page-header/ProductPicker'
|
||||
import { useTranslation } from 'components/hooks/useTranslation'
|
||||
import { Search } from 'components/Search'
|
||||
import { BasicSearch } from 'components/BasicSearch'
|
||||
import { VersionPicker } from 'components/page-header/VersionPicker'
|
||||
import { Breadcrumbs } from './Breadcrumbs'
|
||||
import styles from './Header.module.scss'
|
||||
|
@ -30,7 +31,7 @@ export const Header = () => {
|
|||
|
||||
const signupCTAVisible =
|
||||
hasAccount === false && // don't show if `null`
|
||||
(currentVersion === 'free-pro-team@latest' || currentVersion === 'enterprise-cloud@latest')
|
||||
(currentVersion === DEFAULT_VERSION || currentVersion === 'enterprise-cloud@latest')
|
||||
|
||||
useEffect(() => {
|
||||
function onScroll() {
|
||||
|
@ -52,6 +53,15 @@ export const Header = () => {
|
|||
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 (
|
||||
<div
|
||||
className={cx(
|
||||
|
@ -96,7 +106,7 @@ export const Header = () => {
|
|||
{/* <!-- GitHub.com homepage and 404 page has a stylized search; Enterprise homepages do not --> */}
|
||||
{error !== '404' && (
|
||||
<div className="d-inline-block ml-3">
|
||||
<Search iconSize={16} isHeaderSearch={true} />
|
||||
<SearchComponent iconSize={16} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
@ -161,7 +171,7 @@ export const Header = () => {
|
|||
{/* <!-- GitHub.com homepage and 404 page has a stylized search; Enterprise homepages do not --> */}
|
||||
{error !== '404' && (
|
||||
<div className="my-2 pt-2">
|
||||
<Search iconSize={16} isMobileSearch={true} />
|
||||
<SearchComponent iconSize={16} isMobileSearch={true} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
</div>
|
||||
<nav>
|
||||
{error === '404' || currentProduct === null ? <SidebarHomepage /> : <SidebarProduct />}
|
||||
{error === '404' || !currentProduct || currentProduct.id === 'search' ? (
|
||||
<SidebarHomepage />
|
||||
) : (
|
||||
<SidebarProduct />
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -19,6 +19,7 @@ redirect_from:
|
|||
- /troubleshooting-common-issues
|
||||
versions: '*'
|
||||
children:
|
||||
- search
|
||||
- get-started
|
||||
- account-and-profile
|
||||
- authentication
|
||||
|
@ -124,4 +125,3 @@ externalProducts:
|
|||
href: 'https://docs.npmjs.com/'
|
||||
external: true
|
||||
---
|
||||
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
title: Search
|
||||
hidden: true
|
||||
versions:
|
||||
fpt: '*'
|
||||
ghec: '*'
|
||||
ghes: '*'
|
||||
ghae: '*'
|
||||
---
|
|
@ -8,6 +8,7 @@ files:
|
|||
- '/content/early-access'
|
||||
- '/content/site-policy/site-policy-deprecated'
|
||||
- '/content/github/index'
|
||||
- '/content/search'
|
||||
excluded_target_languages: ['de', 'ko', 'ru']
|
||||
- source: /data/**/*.yml
|
||||
translation: /translations/%locale%/%original_path%/%original_file_name%
|
||||
|
|
|
@ -36,6 +36,9 @@ search:
|
|||
search_error: An error occurred trying to perform the search.
|
||||
description: Enter a search term to find it in the GitHub Documentation.
|
||||
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:
|
||||
explore_by_product: Explore by product
|
||||
version_picker: Version
|
||||
|
|
|
@ -126,10 +126,20 @@ router.get(
|
|||
})
|
||||
)
|
||||
|
||||
class ValidationError extends Error {}
|
||||
|
||||
const validationMiddleware = (req, res, next) => {
|
||||
const params = [
|
||||
{ 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: 'size',
|
||||
|
@ -160,10 +170,17 @@ const validationMiddleware = (req, res, next) => {
|
|||
if (cast) {
|
||||
value = cast(value)
|
||||
}
|
||||
if (validate && !validate(value)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: `Not a valid value (${JSON.stringify(value)}) for key '${key}'` })
|
||||
try {
|
||||
if (validate && !validate(value)) {
|
||||
return res
|
||||
.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
|
||||
}
|
||||
|
|
|
@ -7,7 +7,11 @@ export default async function genericToc(req, res, next) {
|
|||
if (!req.context.page) return next()
|
||||
if (req.context.currentLayoutName !== 'default') return next()
|
||||
// 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()
|
||||
|
||||
// This one product TOC is weird.
|
||||
|
|
|
@ -24,22 +24,54 @@ export default function handleRedirects(req, res, next) {
|
|||
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
|
||||
let redirect = req.path
|
||||
let queryParams = req._parsedUrl.query
|
||||
|
||||
// update old-style query params (#9467)
|
||||
if ('q' in req.query) {
|
||||
const newQueryParams = new URLSearchParams(queryParams)
|
||||
newQueryParams.set('query', newQueryParams.get('q'))
|
||||
newQueryParams.delete('q')
|
||||
return res.redirect(301, `${req.path}?${newQueryParams.toString()}`)
|
||||
if (
|
||||
'q' in req.query ||
|
||||
('query' in req.query && !(req.path.endsWith('/search') || req.path.startsWith('/api/search')))
|
||||
) {
|
||||
// If you had the old legacy format of /some/uri?q=stuff
|
||||
// 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
|
||||
if (queryParams) {
|
||||
queryParams = '?' + queryParams
|
||||
redirect = (redirect + queryParams).replace(patterns.searchPath, '$1')
|
||||
}
|
||||
|
||||
// remove query params temporarily so we can find the path in the redirects object
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
import Search from '../search'
|
||||
export { getServerSideProps } from '../search'
|
||||
|
||||
export default Search
|
|
@ -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')
|
||||
const res = await get('/api/search/v1?' + sp)
|
||||
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
|
||||
{
|
||||
|
|
|
@ -20,7 +20,13 @@ describe('category pages', () => {
|
|||
|
||||
const walkOptions = {
|
||||
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,
|
||||
includeBasePath: true,
|
||||
}
|
||||
|
|
|
@ -22,8 +22,8 @@ describe('siteTree', () => {
|
|||
})
|
||||
|
||||
test('object order and structure', () => {
|
||||
expect(siteTree.en[nonEnterpriseDefaultVersion].childPages[0].href).toBe('/en/get-started')
|
||||
expect(siteTree.en[nonEnterpriseDefaultVersion].childPages[0].childPages[0].href).toBe(
|
||||
expect(siteTree.en[nonEnterpriseDefaultVersion].childPages[1].href).toBe('/en/get-started')
|
||||
expect(siteTree.en[nonEnterpriseDefaultVersion].childPages[1].childPages[0].href).toBe(
|
||||
'/en/get-started/quickstart'
|
||||
)
|
||||
})
|
||||
|
|
|
@ -409,6 +409,7 @@ describe('lint markdown content', () => {
|
|||
isHidden,
|
||||
isEarlyAccess,
|
||||
isSitePolicy,
|
||||
isSearch,
|
||||
hasExperimentalAlternative,
|
||||
frontmatterData
|
||||
|
||||
|
@ -420,8 +421,10 @@ describe('lint markdown content', () => {
|
|||
frontmatterData = data
|
||||
ast = fromMarkdown(content)
|
||||
isHidden = data.hidden === true
|
||||
isEarlyAccess = markdownRelPath.split('/').includes('early-access')
|
||||
isSitePolicy = markdownRelPath.split('/').includes('site-policy-deprecated')
|
||||
const split = markdownRelPath.split('/')
|
||||
isEarlyAccess = split.includes('early-access')
|
||||
isSitePolicy = split.includes('site-policy-deprecated')
|
||||
isSearch = split.includes('search') && !split.includes('reusables')
|
||||
hasExperimentalAlternative = data.hasExperimentalAlternative === true
|
||||
|
||||
links = []
|
||||
|
@ -457,10 +460,10 @@ describe('lint markdown content', () => {
|
|||
.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, or Experimental', async () => {
|
||||
test('hidden docs must be Early Access, Site Policy, Search, or Experimental', async () => {
|
||||
// We need to support some non-Early Access hidden docs in Site Policy
|
||||
if (isHidden) {
|
||||
expect(isEarlyAccess || isSitePolicy || hasExperimentalAlternative).toBe(true)
|
||||
expect(isEarlyAccess || isSitePolicy || isSearch || hasExperimentalAlternative).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
@ -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', () => {
|
||||
test('are preserved in redirected URLs', async () => {
|
||||
const res = await get('/enterprise/admin?query=pulls')
|
||||
expect(res.statusCode).toBe(302)
|
||||
const expected = `/en/enterprise-server@${enterpriseServerReleases.latest}/admin?query=pulls`
|
||||
expect(res.statusCode).toBe(301)
|
||||
const expected = `/en/enterprise-server@${enterpriseServerReleases.latest}/search?query=pulls`
|
||||
expect(res.headers.location).toBe(expected)
|
||||
})
|
||||
|
||||
test('have q= converted to query=', async () => {
|
||||
const res = await get('/en/enterprise/admin?q=pulls')
|
||||
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)
|
||||
})
|
||||
|
||||
|
@ -74,13 +74,6 @@ describe('redirects', () => {
|
|||
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 () => {
|
||||
const reqPath = `/en/enterprise-server@${enterpriseServerReleases.latest}/admin/configuration/configuring-github-connect/enabling-unified-search-for-your-enterprise`
|
||||
const res = await get(reqPath)
|
||||
|
|
Загрузка…
Ссылка в новой задаче