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 }, }, } }