1
0
mirror of synced 2025-12-30 03:01:36 -05:00

Merge pull request #33256 from github/repo-sync

Repo sync
This commit is contained in:
docs-bot
2024-05-30 14:29:32 -07:00
committed by GitHub
11 changed files with 215 additions and 15 deletions

View File

@@ -31,6 +31,8 @@ search_results:
matches_displayed: Matches displayed
n_results: '{n} results'
search_validation_error: Validation error with search query
filter: Filter
clear_filter: Clear
homepage:
explore_by_product: Explore by product
version_picker: Version

View File

@@ -31,6 +31,8 @@ search_results:
matches_displayed: Matches displayed
n_results: '{n} results'
search_validation_error: Validation error with search query
filter: Filter
clear_filter: Clear
homepage:
explore_by_product: Explore by product
version_picker: Version

View File

@@ -65,6 +65,21 @@ test('do a search from home page and click on "Foo" page', async ({ page }) => {
await expect(page).toHaveTitle(/For Playwright/)
})
test('search from enterprise-cloud and filter by top-level Fooing', async ({ page }) => {
test.skip(!SEARCH_TESTS, 'No local Elasticsearch, no tests involving search')
await page.goto('/enterprise-cloud@latest')
await page.getByTestId('site-search-input').fill('fixture')
await page.getByTestId('site-search-input').press('Enter')
await page.getByText('Fooing (1)').click()
await page.getByRole('link', { name: 'Clear' }).click()
// At the moment this test isn't great because it's not proving that
// certain things cease to be visible, that was visible before. Room
// for improvement!
})
test.describe('platform picker', () => {
test('switch operating systems', async ({ page }) => {
await page.goto('/get-started/liquid/platform-specific')

View File

@@ -3,6 +3,7 @@ import { useRouter } from 'next/router'
import { useMainContext } from 'src/frame/components/context/MainContext'
import { SidebarProduct } from 'src/landings/components/SidebarProduct'
import { SidebarSearchAggregates } from 'src/search/components/SidebarSearchAggregates'
import { AllProductsLink } from './AllProductsLink'
import { ApiVersionPicker } from 'src/rest/components/ApiVersionPicker'
import { Link } from 'src/frame/components/Link'
@@ -26,6 +27,8 @@ export const SidebarNav = ({ variant = 'full' }: Props) => {
// so we don't cut off the bottom of the sidebar
const sidebarPaddingBottom = isRestPage ? '250px' : '185px'
const isSearch = currentProduct?.id === 'search'
return (
<div
data-container="nav"
@@ -60,6 +63,8 @@ export const SidebarNav = ({ variant = 'full' }: Props) => {
style={{ width: 326, height: 'calc(100vh - 175px)', paddingBottom: sidebarPaddingBottom }}
>
<SidebarProduct key={router.asPath} />
{isSearch && <SidebarSearchAggregates />}
</div>
</nav>
</div>

View File

@@ -0,0 +1,70 @@
import { CheckboxGroup, Checkbox, FormControl } from '@primer/react'
import { useRouter } from 'next/router'
import Link from 'next/link'
import type { SearchResultAggregations } from './types'
import { useTranslation } from 'src/languages/components/useTranslation'
type Props = {
aggregations: SearchResultAggregations
}
export function SearchResultsAggregations({ aggregations }: Props) {
const { t } = useTranslation('search_results')
const { query, locale, asPath, push } = useRouter()
const selectedQuery = query.toplevel ? query.toplevel : []
const selected = Array.isArray(selectedQuery) ? selectedQuery : [selectedQuery]
function makeHref(toplevel: string) {
const [asPathRoot, asPathQuery = ''] = asPath.split('#')[0].split('?')
const params = new URLSearchParams(asPathQuery)
if (selected.includes(toplevel)) {
const before = params.getAll('toplevel')
params.delete('toplevel')
for (const other of before) {
if (other !== toplevel) {
params.append('toplevel', other)
}
}
} else {
params.append('toplevel', toplevel)
}
return `/${locale}${asPathRoot}?${params}`
}
function makeClearHref() {
const [asPathRoot, asPathQuery = ''] = asPath.split('#')[0].split('?')
const params = new URLSearchParams(asPathQuery)
params.delete('toplevel')
return `/${locale}${asPathRoot}?${params}`
}
if (aggregations.toplevel && aggregations.toplevel.length > 0) {
return (
<div>
<CheckboxGroup>
<CheckboxGroup.Label>
{t('filter')}{' '}
{selected.length > 0 && <Link href={makeClearHref()}>{t('clear_filter')}</Link>}
</CheckboxGroup.Label>
{aggregations.toplevel.map((aggregation) => (
<FormControl key={aggregation.key}>
<Checkbox
value={aggregation.key}
checked={selected.includes(aggregation.key)}
onChange={() => {
push(makeHref(aggregation.key))
}}
/>
<FormControl.Label>
{aggregation.key} ({aggregation.count})
</FormControl.Label>
</FormControl>
))}
</CheckboxGroup>
</div>
)
}
return null
}

View File

@@ -0,0 +1,18 @@
import { Box } from '@primer/react'
import { useSearchContext } from './context/SearchContext'
import { SearchResultsAggregations } from './Aggregations'
export function SidebarSearchAggregates() {
const { search } = useSearchContext()
const { results } = search
if (!results?.aggregations) {
return null
}
return (
<Box className="px-4 pb-3 mt-4">
<SearchResultsAggregations aggregations={results.aggregations} />
</Box>
)
}

View File

@@ -0,0 +1,19 @@
import { createContext, useContext } from 'react'
import type { SearchT } from '../types'
export type SearchContextT = {
search: SearchT
}
export const SearchContext = createContext<SearchContextT | null>(null)
export const useSearchContext = (): SearchContextT => {
const context = useContext(SearchContext)
if (!context) {
throw new Error('"useSearchContext" may only be used inside "SearchContext.Provider"')
}
return context
}

View File

@@ -1,7 +1,6 @@
import Head from 'next/head'
import { Heading } from '@primer/react'
import type { SearchT } from 'src/search/components/types'
import { useTranslation } from 'src/languages/components/useTranslation'
import { DEFAULT_VERSION, useVersion } from 'src/versions/components/useVersion'
import { useNumberFormatter } from 'src/search/components/useNumberFormatter'
@@ -9,12 +8,11 @@ import { SearchResults } from 'src/search/components/SearchResults'
import { NoQuery } from 'src/search/components/NoQuery'
import { useMainContext } from 'src/frame/components/context/MainContext'
import { ValidationErrors } from './ValidationErrors'
import { useSearchContext } from './context/SearchContext'
type Props = {
search: SearchT
}
export function Search() {
const { search } = useSearchContext()
export function Search({ search }: Props) {
const { formatInteger } = useNumberFormatter()
const { t } = useTranslation('search_results')
const { currentVersion } = useVersion()

View File

@@ -26,9 +26,19 @@ type SearchResultsMeta = {
size: number
}
type Aggregation = {
key: string
count: number
}
export type SearchResultAggregations = {
[key: string]: Aggregation[]
}
export type SearchResultsT = {
meta: SearchResultsMeta
hits: SearchResultHitT[]
aggregations?: SearchResultAggregations
}
export type SearchQueryT = {

View File

@@ -46,6 +46,11 @@ export default async function contextualizeSearch(req, res, next) {
}
}
// Feature flag for now XXX
if (req.context.currentVersion === 'enterprise-cloud@latest') {
search.aggregate = ['toplevel']
}
req.context.search = { search, validationErrors }
if (!validationErrors.length && search.query) {
@@ -56,7 +61,18 @@ export default async function contextualizeSearch(req, res, next) {
// set up Elasticsearch.
// This same proxying logic happens in `middleware/api/index.js`
// too for the outwards facing `/api/search/v1` endpoint.
req.context.search.results = await getProxySearch(search)
if (search.aggregate && search.toplevel && search.toplevel.length > 0) {
// Do 2 searches. One without filtering
const { toplevel, ...searchWithoutFilter } = search
searchWithoutFilter.size = 0
const { meta, aggregations } = await getProxySearch(searchWithoutFilter)
const { aggregate, ...searchWithoutAggregate } = search
req.context.search.results = await getProxySearch(searchWithoutAggregate)
req.context.search.results.meta = meta
req.context.search.results.aggregations = aggregations
} else {
req.context.search.results = await getProxySearch(search)
}
} else {
// If this throws, so be it. Let it bubble up.
// In local dev, you get to see the error. In production,
@@ -65,7 +81,17 @@ export default async function contextualizeSearch(req, res, next) {
const tags = [`indexName:${search.indexName}`]
const timed = statsd.asyncTimer(getSearchResults, 'contextualize.search', tags)
try {
req.context.search.results = await timed(search)
if (search.aggregate && search.toplevel && search.toplevel.length > 0) {
// Do 2 searches. One without filtering
const { toplevel, ...searchWithoutFilter } = search
searchWithoutFilter.size = 0
const { meta, aggregations } = await timed(searchWithoutFilter)
req.context.search.results = await timed(search)
req.context.search.results.meta = meta
req.context.search.results.aggregations = aggregations
} else {
req.context.search.results = await timed(search)
}
} catch (error) {
// If the error coming from the Elasticsearch client is any sort
// of 4xx error, it will be bubbled up to the next middleware
@@ -92,10 +118,38 @@ export default async function contextualizeSearch(req, res, next) {
return next()
}
// When you use the proxy to prod, using its API, we need to "convert"
// the parameters we have figured out here in the contextualizer.
// Thankfully all the names match. For example, we might figure
// the page by doing `req.context.search.page = 123` and now we need to
// add that to the query string for the `/api/search/v1`.
// We inclusion-list all the keys that we want to take from the search
// object into the query string URL.
const SEARCH_KEYS_TO_QUERY_STRING = [
'query',
'version',
'language',
'page',
'aggregate',
'toplevel',
'size',
]
async function getProxySearch(search) {
const url = new URL('https://docs.github.com/api/search/v1')
for (const key of ['query', 'version', 'language', 'page']) {
url.searchParams.set(key, `${search[key] || ''}`)
for (const key of SEARCH_KEYS_TO_QUERY_STRING) {
const value = search[key]
if (typeof value === 'boolean') {
url.searchParams.set(key, value ? 'true' : 'false')
} else if (Array.isArray(value)) {
for (const v of value) {
url.searchParams.append(key, v)
}
} else if (typeof value === 'number') {
url.searchParams.set(key, `${value}`)
} else if (value) {
url.searchParams.set(key, value)
}
}
console.log(`Proxying search to ${url}`)
return got(url).json()

View File

@@ -8,19 +8,22 @@ import {
} from 'src/frame/components/context/MainContext'
import { DefaultLayout } from 'src/frame/components/DefaultLayout'
import type { SearchT } from 'src/search/components/types'
import { SearchContext, SearchContextT } from 'src/search/components/context/SearchContext'
import { Search } from 'src/search/components/index'
type Props = {
mainContext: MainContextT
search: SearchT
searchContext: SearchContextT
}
export default function Page({ mainContext, search }: Props) {
export default function Page({ mainContext, searchContext }: Props) {
return (
<MainContext.Provider value={mainContext}>
<DefaultLayout>
<Search search={search} />
</DefaultLayout>
<SearchContext.Provider value={searchContext}>
<DefaultLayout>
<Search />
</DefaultLayout>
</SearchContext.Provider>
</MainContext.Provider>
)
}
@@ -59,13 +62,17 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) =>
search.results = {
meta: req.context.search.results.meta,
hits: req.context.search.results.hits,
// Use `null` instead of `undefined` for JSON serialization.
// The only reason it would ever not be truthy is if the aggregates
// functionality is not enabled for this version.
aggregations: req.context.search.results.aggregations || null,
}
}
return {
props: {
mainContext,
search,
searchContext: { search },
},
}
}