diff --git a/components/BasicSearch.tsx b/components/BasicSearch.tsx new file mode 100644 index 0000000000..452005a1a5 --- /dev/null +++ b/components/BasicSearch.tsx @@ -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(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 ( +
+
+
{ + event.preventDefault() + redirectSearch() + }} + > + +
+
+ ) +} diff --git a/components/hooks/usePage.ts b/components/hooks/usePage.ts new file mode 100644 index 0000000000..a58a3a8410 --- /dev/null +++ b/components/hooks/usePage.ts @@ -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, + } +} diff --git a/components/page-header/Header.tsx b/components/page-header/Header.tsx index 09b8c1c0f3..cdd132dbb9 100644 --- a/components/page-header/Header.tsx +++ b/components/page-header/Header.tsx @@ -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 (
{ {/* */} {error !== '404' && (
- +
)}
@@ -161,7 +171,7 @@ export const Header = () => { {/* */} {error !== '404' && (
- +
)} diff --git a/components/search/Loading.tsx b/components/search/Loading.tsx new file mode 100644 index 0000000000..8f32f3f9e6 --- /dev/null +++ b/components/search/Loading.tsx @@ -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 ? : +} + +function ShowSpinner() { + const { t } = useTranslation(['search']) + return ( +
+ +

{t('loading')}

+
+ ) +} + +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. +
+ {/* Deliberately empty */} +
+ ) +} diff --git a/components/search/NoQuery.tsx b/components/search/NoQuery.tsx new file mode 100644 index 0000000000..c281b012ab --- /dev/null +++ b/components/search/NoQuery.tsx @@ -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 ( + <> + {page.title} + + + {t('description')} + + + ) +} diff --git a/components/search/SearchError.tsx b/components/search/SearchError.tsx new file mode 100644 index 0000000000..a117fc959f --- /dev/null +++ b/components/search/SearchError.tsx @@ -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 ( +
+ {' '} + + {t('search_error')} +
+ {process.env.NODE_ENV === 'development' && {error.toString()}} +
+ + Try reloading the page + +
+ ) +} diff --git a/components/search/SearchResults.tsx b/components/search/SearchResults.tsx new file mode 100644 index 0000000000..a65351a532 --- /dev/null +++ b/components/search/SearchResults.tsx @@ -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 ( +
+

+ + {t('results_found') + .replace('{n}', results.meta.found.value.toLocaleString()) + .replace('{s}', results.meta.took.total_msec.toFixed(0))}{' '} + +
+ {pages > 1 && ( + + {t('results_page').replace('{page}', page).replace('{pages}', pages.toLocaleString())} + + )} +

+ + + + {pages > 1 && } +
+ ) +} + +function SearchResultHits({ hits }: { hits: SearchResultHitT[] }) { + const { debug } = useQuery() + return ( +
+ {hits.length === 0 && } + {hits.map((hit) => ( + + ))} +
+ ) +} + +function NoSearchResults() { + const { t } = useTranslation('search') + return ( +
+ + {t('nothing_found')} + +
+ ) +} + +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 ( +
+

+ +

+

{hit.breadcrumbs}

+
    + {(hit.highlights.content || []).map((highlight, i) => { + return
  • + })} +
+ {debug && ( + + score: {hit.score} popularity:{' '} + {hit.popularity} + + )} +
+ ) +} + +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 ( + + { + 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 }) + }} + /> + + ) +} diff --git a/components/search/index.tsx b/components/search/index.tsx new file mode 100644 index 0000000000..2cb8196f99 --- /dev/null +++ b/components/search/index.tsx @@ -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( + 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 ( +
+ + {pageTitle} + + {hasQuery && ( + + {t('search_results_for')} {query} + + )} + + {error ? ( + + ) : results ? ( + + ) : hasQuery ? ( + + ) : ( + + )} +
+ ) +} diff --git a/components/search/types.ts b/components/search/types.ts new file mode 100644 index 0000000000..95f7a8ab29 --- /dev/null +++ b/components/search/types.ts @@ -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[] +} diff --git a/components/sidebar/SidebarNav.tsx b/components/sidebar/SidebarNav.tsx index e0e7a6f605..9b3d46adcd 100644 --- a/components/sidebar/SidebarNav.tsx +++ b/components/sidebar/SidebarNav.tsx @@ -40,7 +40,11 @@ export const SidebarNav = () => { ) diff --git a/content/index.md b/content/index.md index b2b746d556..73055c6ab3 100644 --- a/content/index.md +++ b/content/index.md @@ -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 --- - diff --git a/content/search/index.md b/content/search/index.md new file mode 100644 index 0000000000..d914f0b19a --- /dev/null +++ b/content/search/index.md @@ -0,0 +1,9 @@ +--- +title: Search +hidden: true +versions: + fpt: '*' + ghec: '*' + ghes: '*' + ghae: '*' +--- \ No newline at end of file diff --git a/crowdin.yml b/crowdin.yml index b010db53de..2627f22415 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -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% diff --git a/data/ui.yml b/data/ui.yml index 45c2699f51..62e2fc52ec 100644 --- a/data/ui.yml +++ b/data/ui.yml @@ -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 diff --git a/middleware/api/search.js b/middleware/api/search.js index e1ab7f5754..65456bbbb2 100644 --- a/middleware/api/search.js +++ b/middleware/api/search.js @@ -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 } diff --git a/middleware/contextualizers/generic-toc.js b/middleware/contextualizers/generic-toc.js index 79e23d089f..595e04f1b8 100644 --- a/middleware/contextualizers/generic-toc.js +++ b/middleware/contextualizers/generic-toc.js @@ -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. diff --git a/middleware/redirects/handle-redirects.js b/middleware/redirects/handle-redirects.js index 7755d9f30a..7544d57c96 100644 --- a/middleware/redirects/handle-redirects.js +++ b/middleware/redirects/handle-redirects.js @@ -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 diff --git a/pages/[versionId]/search.tsx b/pages/[versionId]/search.tsx new file mode 100644 index 0000000000..bf6f3d1654 --- /dev/null +++ b/pages/[versionId]/search.tsx @@ -0,0 +1,4 @@ +import Search from '../search' +export { getServerSideProps } from '../search' + +export default Search diff --git a/pages/search.tsx b/pages/search.tsx new file mode 100644 index 0000000000..51872ff8f9 --- /dev/null +++ b/pages/search.tsx @@ -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 ( + + + + + + ) +} + +export const getServerSideProps: GetServerSideProps = 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, + }, + } +} diff --git a/tests/content/api-search.js b/tests/content/api-search.js index 144cd3d0f3..1b326fadb5 100644 --- a/tests/content/api-search.js +++ b/tests/content/api-search.js @@ -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 { diff --git a/tests/content/category-pages.js b/tests/content/category-pages.js index cc7a4b8243..b607856fb6 100644 --- a/tests/content/category-pages.js +++ b/tests/content/category-pages.js @@ -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, } diff --git a/tests/content/site-tree.js b/tests/content/site-tree.js index 510edefa66..64551eb266 100644 --- a/tests/content/site-tree.js +++ b/tests/content/site-tree.js @@ -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' ) }) diff --git a/tests/linting/lint-files.js b/tests/linting/lint-files.js index dad3a0a0ac..93cf500f81 100644 --- a/tests/linting/lint-files.js +++ b/tests/linting/lint-files.js @@ -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) } }) diff --git a/tests/rendering/search.js b/tests/rendering/search.js new file mode 100644 index 0000000000..eafe42ee75 --- /dev/null +++ b/tests/rendering/search.js @@ -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'/) + }) +}) diff --git a/tests/routing/redirects.js b/tests/routing/redirects.js index 760f69db5d..113e22daaf 100644 --- a/tests/routing/redirects.js +++ b/tests/routing/redirects.js @@ -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)