@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
70
src/search/components/Aggregations.tsx
Normal file
70
src/search/components/Aggregations.tsx
Normal 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
|
||||
}
|
||||
18
src/search/components/SidebarSearchAggregates.tsx
Normal file
18
src/search/components/SidebarSearchAggregates.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
src/search/components/context/SearchContext.tsx
Normal file
19
src/search/components/context/SearchContext.tsx
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user