diff --git a/data/ui.yml b/data/ui.yml
index e61cbbdad9..2d148392bf 100644
--- a/data/ui.yml
+++ b/data/ui.yml
@@ -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
diff --git a/src/fixtures/fixtures/data/ui.yml b/src/fixtures/fixtures/data/ui.yml
index e61cbbdad9..2d148392bf 100644
--- a/src/fixtures/fixtures/data/ui.yml
+++ b/src/fixtures/fixtures/data/ui.yml
@@ -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
diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts
index 072e014917..73e39a1a18 100644
--- a/src/fixtures/tests/playwright-rendering.spec.ts
+++ b/src/fixtures/tests/playwright-rendering.spec.ts
@@ -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')
diff --git a/src/frame/components/sidebar/SidebarNav.tsx b/src/frame/components/sidebar/SidebarNav.tsx
index da45a4045a..a19f279a65 100644
--- a/src/frame/components/sidebar/SidebarNav.tsx
+++ b/src/frame/components/sidebar/SidebarNav.tsx
@@ -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 (
{
style={{ width: 326, height: 'calc(100vh - 175px)', paddingBottom: sidebarPaddingBottom }}
>
+
+ {isSearch && }
diff --git a/src/search/components/Aggregations.tsx b/src/search/components/Aggregations.tsx
new file mode 100644
index 0000000000..cde2aadf7b
--- /dev/null
+++ b/src/search/components/Aggregations.tsx
@@ -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 (
+
+
+
+ {t('filter')}{' '}
+ {selected.length > 0 && {t('clear_filter')}}
+
+
+ {aggregations.toplevel.map((aggregation) => (
+
+ {
+ push(makeHref(aggregation.key))
+ }}
+ />
+
+ {aggregation.key} ({aggregation.count})
+
+
+ ))}
+
+
+ )
+ }
+ return null
+}
diff --git a/src/search/components/SidebarSearchAggregates.tsx b/src/search/components/SidebarSearchAggregates.tsx
new file mode 100644
index 0000000000..3b5661dded
--- /dev/null
+++ b/src/search/components/SidebarSearchAggregates.tsx
@@ -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 (
+
+
+
+ )
+}
diff --git a/src/search/components/context/SearchContext.tsx b/src/search/components/context/SearchContext.tsx
new file mode 100644
index 0000000000..08ff25d14d
--- /dev/null
+++ b/src/search/components/context/SearchContext.tsx
@@ -0,0 +1,19 @@
+import { createContext, useContext } from 'react'
+
+import type { SearchT } from '../types'
+
+export type SearchContextT = {
+ search: SearchT
+}
+
+export const SearchContext = createContext(null)
+
+export const useSearchContext = (): SearchContextT => {
+ const context = useContext(SearchContext)
+
+ if (!context) {
+ throw new Error('"useSearchContext" may only be used inside "SearchContext.Provider"')
+ }
+
+ return context
+}
diff --git a/src/search/components/index.tsx b/src/search/components/index.tsx
index cbc81a300a..39399b3c08 100644
--- a/src/search/components/index.tsx
+++ b/src/search/components/index.tsx
@@ -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()
diff --git a/src/search/components/types.ts b/src/search/components/types.ts
index a7a35fba29..ce6c8a80ef 100644
--- a/src/search/components/types.ts
+++ b/src/search/components/types.ts
@@ -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 = {
diff --git a/src/search/middleware/contextualize.js b/src/search/middleware/contextualize.js
index 54a14a314e..47c9598c00 100644
--- a/src/search/middleware/contextualize.js
+++ b/src/search/middleware/contextualize.js
@@ -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()
diff --git a/src/search/pages/search.tsx b/src/search/pages/search.tsx
index ddf122b6da..42965548ae 100644
--- a/src/search/pages/search.tsx
+++ b/src/search/pages/search.tsx
@@ -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 (
-
-
-
+
+
+
+
+
)
}
@@ -59,13 +62,17 @@ export const getServerSideProps: GetServerSideProps = 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 },
},
}
}