Ai search UI (#53026)
Co-authored-by: Kevin Heis <heiskr@users.noreply.github.com> Co-authored-by: Ashish Keshan <ashishkeshan@github.com>
This commit is contained in:
32
data/ui.yml
32
data/ui.yml
@@ -15,6 +15,7 @@ header:
|
||||
ghes_release_notes_upgrade_patch_and_release: 📣 This is not the <a href="#{{ latestPatch }}">latest patch release</a> of this release series, and this is not the <a href="/enterprise-server@{{ latestRelease }}/admin/release-notes">latest release</a> of Enterprise Server.
|
||||
sign_up_cta: Sign up
|
||||
menu: Menu
|
||||
open_menu_label: Open menu
|
||||
go_home: Home
|
||||
picker:
|
||||
language_picker_label: Language
|
||||
@@ -23,6 +24,37 @@ picker:
|
||||
release_notes:
|
||||
banner_text: GitHub began rolling these changes out to enterprises on
|
||||
search:
|
||||
input:
|
||||
experimental_tag: Experimental
|
||||
aria_label: Open search overlay
|
||||
placeholder: Search or ask AI assistant
|
||||
overlay:
|
||||
input_aria_label: Search or ask AI assistant
|
||||
suggestions_list_aria_label: Search suggestions
|
||||
ai_suggestions_list_aria_label: AI search suggestions
|
||||
general_suggestions_list_aria_label: Docs search suggestions
|
||||
general_autocomplete_list_heading: Search docs
|
||||
ai_autocomplete_list_heading: Ask AI
|
||||
give_feedback: Give feedback
|
||||
beta_tag: Beta
|
||||
return_to_search: Return to search
|
||||
clear_search_query: Clear
|
||||
ai:
|
||||
disclaimer: This is an experimental generative AI response. All information should be verified prior to use.
|
||||
references: References from these articles
|
||||
loading_status_message: Loading AI response...
|
||||
done_loading_status_message: Done loading AI response
|
||||
unable_to_answer: Sorry, I'm unable to answer that question. Please try a different query.
|
||||
copy_answer: Copy answer
|
||||
copied_announcement: Copied!
|
||||
thumbs_up: This answer was helpful
|
||||
thumbs_down: This answer was not helpful
|
||||
thumbs_announcement: Thank you for your feedback!
|
||||
failure:
|
||||
autocomplete_title: There was an error loading autocomplete results.
|
||||
ai_title: There was an error loading the AI assistant.
|
||||
description: You can still use this field to search our docs.
|
||||
old_search:
|
||||
description: Enter a search term to find it in the GitHub Docs.
|
||||
placeholder: Search GitHub Docs
|
||||
label: Search GitHub Docs
|
||||
|
||||
2087
package-lock.json
generated
2087
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -246,8 +246,8 @@
|
||||
"@primer/behaviors": "^1.7.0",
|
||||
"@primer/css": "^21.3.1",
|
||||
"@primer/live-region-element": "^0.7.0",
|
||||
"@primer/octicons": "^19.11.0",
|
||||
"@primer/octicons-react": "^19.11.0",
|
||||
"@primer/octicons": "^19.14.0",
|
||||
"@primer/octicons-react": "^19.14.0",
|
||||
"@primer/react": "36.27.0",
|
||||
"accept-language-parser": "^1.5.0",
|
||||
"ajv": "^8.17.1",
|
||||
@@ -304,6 +304,7 @@
|
||||
"quick-lru": "7.0.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"rehype-highlight": "^7.0.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
@@ -356,6 +357,7 @@
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/styled-components": "^5.1.34",
|
||||
"@types/tcp-port-used": "1.0.4",
|
||||
"@types/website-scraper": "^1.2.10",
|
||||
"@typescript-eslint/eslint-plugin": "^8.7.0",
|
||||
|
||||
3
src/events/components/event-groups.ts
Normal file
3
src/events/components/event-groups.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const ASK_AI_EVENT_GROUP = 'ask-ai'
|
||||
export const SEARCH_OVERLAY_EVENT_GROUP = 'search-overlay'
|
||||
export const GENERAL_SEARCH_RESULTS = 'general-search-results'
|
||||
@@ -36,7 +36,7 @@ function resetPageParams() {
|
||||
|
||||
// Temporary polyfill for crypto.randomUUID()
|
||||
// Necessary for localhost development (doesn't have https://)
|
||||
function uuidv4(): string {
|
||||
export function uuidv4(): string {
|
||||
try {
|
||||
return crypto.randomUUID()
|
||||
} catch {
|
||||
@@ -64,10 +64,14 @@ function getMetaContent(name: string) {
|
||||
export function sendEvent<T extends EventType>({
|
||||
type,
|
||||
version = '1.0.0',
|
||||
eventGroupKey,
|
||||
eventGroupId,
|
||||
...props
|
||||
}: {
|
||||
type: T
|
||||
version?: string
|
||||
eventGroupKey?: string
|
||||
eventGroupId?: string
|
||||
} & EventPropsByType[T]) {
|
||||
const body = {
|
||||
type,
|
||||
@@ -113,6 +117,10 @@ export function sendEvent<T extends EventType>({
|
||||
code_display_preference: Cookies.get('annotate-mode'),
|
||||
|
||||
experiment_variation: getExperimentVariationForContext(getMetaContent('path-language')),
|
||||
|
||||
// Event grouping
|
||||
event_group_key: eventGroupKey,
|
||||
event_group_id: eventGroupId,
|
||||
},
|
||||
|
||||
...props,
|
||||
@@ -295,6 +303,7 @@ function initCopyButtonEvent() {
|
||||
const target = evt.target as HTMLElement
|
||||
const button = target.closest('.js-btn-copy') as HTMLButtonElement
|
||||
if (!button) return
|
||||
|
||||
sendEvent({
|
||||
type: EventType.clipboard,
|
||||
clipboard_operation: 'copy',
|
||||
@@ -310,12 +319,19 @@ function initLinkEvent() {
|
||||
if (!link) return
|
||||
const sameSite = link.origin === location.origin
|
||||
const container = target.closest(`[data-container]`) as HTMLElement | null
|
||||
|
||||
// We can attach `data-group-key` and `data-group-id` to any anchor element to include them in the event
|
||||
const eventGroupKey = link?.dataset?.groupKey || undefined
|
||||
const eventGroupId = link?.dataset?.groupId || undefined
|
||||
|
||||
sendEvent({
|
||||
type: EventType.link,
|
||||
link_url: link.href,
|
||||
link_samesite: sameSite,
|
||||
link_samepage: sameSite && link.pathname === location.pathname,
|
||||
link_container: container?.dataset.container,
|
||||
eventGroupKey,
|
||||
eventGroupId,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -16,6 +16,14 @@ type Experiment = {
|
||||
export type ExperimentNames = 'ai_search_experiment'
|
||||
|
||||
export const EXPERIMENTS = {
|
||||
ai_search_experiment: {
|
||||
key: 'ai_search_experiment',
|
||||
isActive: true, // Set to false when the experiment is over
|
||||
percentOfUsersToGetExperiment: 0, // 10% of users will get the experiment
|
||||
includeVariationInContext: true, // All events will include the `experiment_variation` of the `ai_search_experiment`
|
||||
limitToLanguages: ['en'], // Only users with the `en` language will be included in the experiment
|
||||
alwaysShowForStaff: true, // When set to true, staff will always see the experiment (determined by the `staffonly` cookie)
|
||||
},
|
||||
/* Add new experiments here, example:
|
||||
'example_experiment': {
|
||||
key: 'example_experiment',
|
||||
|
||||
@@ -182,6 +182,17 @@ const context = {
|
||||
type: 'string',
|
||||
description: 'The variation this user we bucketed in is in, such as control or treatment.',
|
||||
},
|
||||
|
||||
// Event Grouping. The comination of key + id should be unique
|
||||
event_group_key: {
|
||||
type: 'string',
|
||||
description: 'A enum indentifier (e.g. "ask-ai") used to put events into a specific group.',
|
||||
},
|
||||
event_group_id: {
|
||||
type: 'string',
|
||||
description:
|
||||
'A unique id (uuid) that can be used to identify a group of events made by a user during the same session.',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -380,6 +391,38 @@ const searchResult = {
|
||||
},
|
||||
}
|
||||
|
||||
const aiSearchResult = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: [
|
||||
'type',
|
||||
'context',
|
||||
'ai_search_result_query',
|
||||
'ai_search_result_response',
|
||||
'ai_search_result_links_json',
|
||||
],
|
||||
properties: {
|
||||
context,
|
||||
type: {
|
||||
type: 'string',
|
||||
pattern: '^aiSearchResult$',
|
||||
},
|
||||
ai_search_result_query: {
|
||||
type: 'string',
|
||||
description: 'The query the user searched for.',
|
||||
},
|
||||
ai_search_result_response: {
|
||||
type: 'number',
|
||||
description: "The GPT's response to the query.",
|
||||
},
|
||||
ai_search_result_links_json: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Dynamic JSON string of an array of "link" objects in the form: [{ "type": "reference" | "inline", "url": "https://..", "product": "issues" | "pages" | ... }, ...]',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const survey = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -432,7 +475,7 @@ const experiment = {
|
||||
},
|
||||
experiment_variation: {
|
||||
type: 'string',
|
||||
description: 'The variation this user we bucketed in, such as control or treatment.',
|
||||
description: 'The variation this user we bucketed in is in, such as control or treatment.',
|
||||
},
|
||||
experiment_success: {
|
||||
type: 'boolean',
|
||||
@@ -545,6 +588,7 @@ export const schemas = {
|
||||
hover,
|
||||
search,
|
||||
searchResult,
|
||||
aiSearchResult,
|
||||
survey,
|
||||
experiment,
|
||||
clipboard,
|
||||
@@ -560,6 +604,7 @@ export const hydroNames = {
|
||||
hover: 'docs.v0.HoverEvent',
|
||||
search: 'docs.v0.SearchEvent',
|
||||
searchResult: 'docs.v0.SearchResultEvent',
|
||||
aiSearchResult: 'docs.v0.AISearchResultsEvent',
|
||||
survey: 'docs.v0.SurveyEvent',
|
||||
experiment: 'docs.v0.ExperimentEvent',
|
||||
clipboard: 'docs.v0.ClipboardEvent',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export enum EventType {
|
||||
aiSearchResult = 'aiSearchResult',
|
||||
page = 'page',
|
||||
exit = 'exit',
|
||||
link = 'link',
|
||||
@@ -46,10 +47,19 @@ export type EventProps = {
|
||||
color_mode_preference: string
|
||||
os_preference: string
|
||||
code_display_preference: string
|
||||
event_group_key?: string
|
||||
event_group_id?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventPropsByType = {
|
||||
[EventType.aiSearchResult]: {
|
||||
ai_search_result_query: string
|
||||
ai_search_result_response: string
|
||||
// Dynamic JSON string of an array of "link" objects in the form:
|
||||
// [{ "type": "reference" | "inline", "url": "https://..", "product": "issues" | "pages" | ... }, ...]
|
||||
ai_search_result_links_json: string
|
||||
}
|
||||
[EventType.clipboard]: {
|
||||
clipboard_operation: string
|
||||
clipboard_target?: string
|
||||
|
||||
@@ -15,6 +15,7 @@ header:
|
||||
ghes_release_notes_upgrade_patch_and_release: 📣 This is not the <a href="#{{ latestPatch }}">latest patch release</a> of this release series, and this is not the <a href="/enterprise-server@{{ latestRelease }}/admin/release-notes">latest release</a> of Enterprise Server.
|
||||
sign_up_cta: Sign up
|
||||
menu: Menu
|
||||
open_menu_label: Open menu
|
||||
go_home: Home
|
||||
picker:
|
||||
language_picker_label: Language
|
||||
@@ -23,6 +24,37 @@ picker:
|
||||
release_notes:
|
||||
banner_text: GitHub began rolling these changes out to enterprises on
|
||||
search:
|
||||
input:
|
||||
experimental_tag: Experimental
|
||||
aria_label: Open search overlay
|
||||
placeholder: Search or ask AI assistant
|
||||
overlay:
|
||||
input_aria_label: Search or ask AI assistant
|
||||
suggestions_list_aria_label: Search suggestions
|
||||
ai_suggestions_list_aria_label: AI search suggestions
|
||||
general_suggestions_list_aria_label: Docs search suggestions
|
||||
general_autocomplete_list_heading: Search docs
|
||||
ai_autocomplete_list_heading: Ask AI
|
||||
give_feedback: Give feedback
|
||||
beta_tag: Beta
|
||||
return_to_search: Return to search
|
||||
clear_search_query: Clear
|
||||
ai:
|
||||
disclaimer: This is an experimental generative AI response. All information should be verified prior to use.
|
||||
references: References from these articles
|
||||
loading_status_message: Loading AI response...
|
||||
done_loading_status_message: Done loading AI response
|
||||
unable_to_answer: Sorry, I'm unable to answer that question. Please try a different query.
|
||||
copy_answer: Copy answer
|
||||
copied_announcement: Copied!
|
||||
thumbs_up: This answer was helpful
|
||||
thumbs_down: This answer was not helpful
|
||||
thumbs_announcement: Thank you for your feedback!
|
||||
failure:
|
||||
autocomplete_title: There was an error loading autocomplete results.
|
||||
ai_title: There was an error loading the AI assistant.
|
||||
description: You can still use this field to search our docs.
|
||||
old_search:
|
||||
description: Enter a search term to find it in the GitHub Docs.
|
||||
placeholder: Search GitHub Docs
|
||||
label: Search GitHub Docs
|
||||
|
||||
@@ -68,6 +68,90 @@ test('do a search from home page and click on "Foo" page', async ({ page }) => {
|
||||
await expect(page).toHaveTitle(/For Playwright/)
|
||||
})
|
||||
|
||||
test('open new search, and perform a general search', async ({ page }) => {
|
||||
test.skip(!SEARCH_TESTS, 'No local Elasticsearch, no tests involving search')
|
||||
|
||||
await page.goto('/')
|
||||
|
||||
// Enable the AI search experiment by overriding the control group
|
||||
await page.evaluate(() => {
|
||||
// @ts-expect-error overrideControlGroup is a custom function added to the window object
|
||||
window.overrideControlGroup('ai_search_experiment', 'treatment')
|
||||
})
|
||||
|
||||
await page.getByTestId('search').click()
|
||||
|
||||
await page.getByTestId('overlay-search-input').fill('serve playwright')
|
||||
// Let new suggestions load
|
||||
await page.waitForTimeout(1000)
|
||||
// Navigate to general search item, "serve playwright"
|
||||
await page.keyboard.press('ArrowDown')
|
||||
// Select the general search item, "serve playwright"
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect(page).toHaveURL(/\/search\?query=serve\+playwright/)
|
||||
await expect(page).toHaveTitle(/\d Search results for "serve playwright"/)
|
||||
|
||||
await page.getByRole('link', { name: 'For Playwright' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/get-started\/foo\/for-playwright$/)
|
||||
await expect(page).toHaveTitle(/For Playwright/)
|
||||
})
|
||||
|
||||
test('open new search, and get auto-complete results', async ({ page }) => {
|
||||
test.skip(!SEARCH_TESTS, 'No local Elasticsearch, no tests involving search')
|
||||
|
||||
await page.goto('/')
|
||||
|
||||
// Enable the AI search experiment by overriding the control group
|
||||
await page.evaluate(() => {
|
||||
// @ts-expect-error overrideControlGroup is a custom function added to the window object
|
||||
window.overrideControlGroup('ai_search_experiment', 'treatment')
|
||||
})
|
||||
|
||||
await page.getByTestId('search').click()
|
||||
|
||||
let listGroup = page.getByTestId('ai-autocomplete-suggestions')
|
||||
|
||||
await expect(listGroup).toBeVisible()
|
||||
let listItems = listGroup.locator('li')
|
||||
await expect(listItems).toHaveCount(5)
|
||||
|
||||
// Top 5 queries from queries.json fixture's 'topQueries'
|
||||
let expectedTexts = [
|
||||
'What is GitHub and how do I get started?',
|
||||
'What is GitHub Copilot and how do I get started?',
|
||||
'How do I connect to GitHub with SSH?',
|
||||
'How do I generate a personal access token?',
|
||||
'How do I clone a repository?',
|
||||
]
|
||||
for (let i = 0; i < expectedTexts.length; i++) {
|
||||
await expect(listItems.nth(i)).toHaveText(expectedTexts[i])
|
||||
}
|
||||
|
||||
const searchInput = await page.getByTestId('overlay-search-input')
|
||||
|
||||
await expect(searchInput).toBeVisible()
|
||||
await expect(searchInput).toBeEnabled()
|
||||
|
||||
// Type the text "rest" into the search input
|
||||
await searchInput.fill('rest')
|
||||
|
||||
// Ask AI suggestions
|
||||
listGroup = page.getByTestId('ai-autocomplete-suggestions')
|
||||
listItems = listGroup.locator('li')
|
||||
await expect(listItems).toHaveCount(3)
|
||||
await expect(listGroup).toBeVisible()
|
||||
expectedTexts = [
|
||||
'rest',
|
||||
'How do I manage OAuth app access restrictions for my organization?',
|
||||
'How do I test my SSH connection to GitHub?',
|
||||
]
|
||||
for (let i = 0; i < expectedTexts.length; i++) {
|
||||
await expect(listItems.nth(i)).toHaveText(expectedTexts[i])
|
||||
}
|
||||
})
|
||||
|
||||
test('search from enterprise-cloud and filter by top-level Fooing', async ({ page }) => {
|
||||
test.skip(!SEARCH_TESTS, 'No local Elasticsearch, no tests involving search')
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ const DEFAULT_UI_NAMESPACES = [
|
||||
'alerts',
|
||||
'header',
|
||||
'search',
|
||||
'old_search',
|
||||
'survey',
|
||||
'toc',
|
||||
'meta',
|
||||
|
||||
92
src/frame/components/hooks/useQueryParam.ts
Normal file
92
src/frame/components/hooks/useQueryParam.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// A generic hook for getting and setting a query parameter without reloading the page
|
||||
// The `queryParam` variable returned from this method are stateful and will be set to the query param on page load
|
||||
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { parseDebug } from '@/search/components/hooks/useQuery'
|
||||
|
||||
type UseQueryParamReturn<T extends string | boolean> = {
|
||||
debug: boolean
|
||||
queryParam: T
|
||||
setQueryParam: (value: T) => void
|
||||
}
|
||||
|
||||
// Overloads so we can use this for a boolean or string query param
|
||||
// eslint-disable-next-line no-redeclare
|
||||
export function useQueryParam(queryParamKey: string, isBoolean: true): UseQueryParamReturn<boolean>
|
||||
// eslint-disable-next-line no-redeclare
|
||||
export function useQueryParam(queryParamKey: string, isBoolean?: false): UseQueryParamReturn<string>
|
||||
// eslint-disable-next-line no-redeclare
|
||||
export function useQueryParam(
|
||||
queryParamKey: string,
|
||||
isBoolean?: boolean,
|
||||
): UseQueryParamReturn<any> {
|
||||
const router = useRouter()
|
||||
|
||||
// Determine the initial value of the query param
|
||||
let initialQueryParam = ''
|
||||
const paramValue = router.query[queryParamKey]
|
||||
|
||||
if (paramValue) {
|
||||
if (Array.isArray(paramValue)) {
|
||||
initialQueryParam = paramValue[0]
|
||||
} else {
|
||||
initialQueryParam = paramValue
|
||||
}
|
||||
}
|
||||
|
||||
const debugValue = parseDebug(router.query.debug)
|
||||
|
||||
// Return type will be set based on overloads
|
||||
const [queryParamString, setQueryParamState] = useState<string>(initialQueryParam)
|
||||
const [debug] = useState<boolean>(debugValue)
|
||||
|
||||
// If the query param changes in the URL, update the state
|
||||
useEffect(() => {
|
||||
console.log('updating state')
|
||||
const paramValue = router.query[queryParamKey]
|
||||
|
||||
if (paramValue) {
|
||||
if (Array.isArray(paramValue)) {
|
||||
setQueryParamState(paramValue[0])
|
||||
} else {
|
||||
setQueryParamState(paramValue)
|
||||
}
|
||||
}
|
||||
}, [router.query, queryParamKey])
|
||||
|
||||
// Determine the type of queryParam based on isBoolean
|
||||
const queryParam: string | boolean = isBoolean ? queryParamString === 'true' : queryParamString
|
||||
|
||||
const setQueryParam = (value: string | boolean) => {
|
||||
const { pathname, hash, search } = window.location
|
||||
|
||||
let newValue: string = value as string
|
||||
|
||||
// If it's a false boolean or empty string, just remove the query param
|
||||
if (!value) {
|
||||
newValue = ''
|
||||
} else if (typeof value === 'boolean') {
|
||||
newValue = 'true'
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(search)
|
||||
if (newValue) {
|
||||
params.set(queryParamKey, newValue)
|
||||
} else {
|
||||
params.delete(queryParamKey)
|
||||
}
|
||||
|
||||
const newSearch = params.toString()
|
||||
const newUrl = newSearch ? `${pathname}?${newSearch}${hash}` : `${pathname}${hash}`
|
||||
|
||||
window.history.replaceState({}, '', newUrl)
|
||||
setQueryParamState(newValue)
|
||||
}
|
||||
|
||||
return {
|
||||
debug,
|
||||
queryParam: queryParam as any, // Type will be set based on overloads
|
||||
setQueryParam: setQueryParam as any,
|
||||
}
|
||||
}
|
||||
@@ -10,43 +10,6 @@
|
||||
z-index: 3 !important;
|
||||
}
|
||||
|
||||
// Contains the search input, language picker, and sign-up button. When the
|
||||
// search input is open and up to sm (where the language picker and sign-up
|
||||
// button are hidden) we need to take up almost all the header width but then at
|
||||
// md and above we don't want the search input to take up the header width.
|
||||
.widgetsContainer {
|
||||
width: 100%;
|
||||
|
||||
@include breakpoint(md) {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// Contains the search input and used when the smaller width search input UI is
|
||||
// closed to hide the full width input, but as the width increases to md and
|
||||
// above we show the search input along the other UI widgets (the menu button,
|
||||
// the language picker, etc.)
|
||||
.searchContainerWithClosedSearch {
|
||||
display: none;
|
||||
|
||||
@include breakpoint(md) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
// Contains the search input and used when the smaller width search input UI is
|
||||
// open and we set it full width but as the browser width increases to md and
|
||||
// above we don't take up the whole width anymore since we now show other UI
|
||||
// widgets.
|
||||
.searchContainerWithOpenSearch {
|
||||
width: 100%;
|
||||
margin-right: -1px;
|
||||
|
||||
@include breakpoint(md) {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// Contains the logo and version picker and used when the smaller width search
|
||||
// input UI is closed.
|
||||
.logoWithClosedSearch {
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import cx from 'classnames'
|
||||
import { useRouter } from 'next/router'
|
||||
import { ActionList, ActionMenu, Dialog, IconButton } from '@primer/react'
|
||||
import {
|
||||
KebabHorizontalIcon,
|
||||
LinkExternalIcon,
|
||||
MarkGithubIcon,
|
||||
SearchIcon,
|
||||
ThreeBarsIcon,
|
||||
XIcon,
|
||||
} from '@primer/octicons-react'
|
||||
import { Dialog, IconButton } from '@primer/react'
|
||||
import { MarkGithubIcon, ThreeBarsIcon } from '@primer/octicons-react'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
import { DEFAULT_VERSION, useVersion } from 'src/versions/components/useVersion'
|
||||
import { Link } from 'src/frame/components/Link'
|
||||
import { useMainContext } from 'src/frame/components/context/MainContext'
|
||||
import { useHasAccount } from 'src/frame/components/hooks/useHasAccount'
|
||||
import { LanguagePicker } from 'src/languages/components/LanguagePicker'
|
||||
import { HeaderNotifications } from 'src/frame/components/page-header/HeaderNotifications'
|
||||
import { ApiVersionPicker } from 'src/rest/components/ApiVersionPicker'
|
||||
import { useTranslation } from 'src/languages/components/useTranslation'
|
||||
import { Search } from 'src/search/components/Search'
|
||||
import { Breadcrumbs } from 'src/frame/components/page-header/Breadcrumbs'
|
||||
import { VersionPicker } from 'src/versions/components/VersionPicker'
|
||||
import { SidebarNav } from 'src/frame/components/sidebar/SidebarNav'
|
||||
import { AllProductsLink } from 'src/frame/components/sidebar/AllProductsLink'
|
||||
|
||||
import styles from './Header.module.scss'
|
||||
import { OldHeaderSearchAndWidgets } from './OldHeaderSearchAndWidgets'
|
||||
import { HeaderSearchAndWidgets } from './HeaderSearchAndWidgets'
|
||||
import { useInnerWindowWidth } from './hooks/useInnerWindowWidth'
|
||||
import { EXPERIMENTS } from '@/events/components/experiments/experiments'
|
||||
import { useShouldShowExperiment } from '@/events/components/experiments/useShouldShowExperiment'
|
||||
import { useQueryParam } from '@/frame/components/hooks/useQueryParam'
|
||||
|
||||
const DomainNameEdit = dynamic(() => import('src/links/components/DomainNameEdit'), {
|
||||
ssr: false,
|
||||
@@ -39,9 +35,11 @@ export const Header = () => {
|
||||
const { currentVersion } = useVersion()
|
||||
const { t } = useTranslation(['header'])
|
||||
const isRestPage = currentProduct && currentProduct.id === 'rest'
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false)
|
||||
const { queryParam: isSearchOpen, setQueryParam: setIsSearchOpen } = useQueryParam(
|
||||
'search-overlay-open',
|
||||
true,
|
||||
)
|
||||
const [scroll, setScroll] = useState(false)
|
||||
const { hasAccount } = useHasAccount()
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
||||
const openSidebar = useCallback(() => setIsSidebarOpen(true), [isSidebarOpen])
|
||||
const closeSidebar = useCallback(() => setIsSidebarOpen(false), [isSidebarOpen])
|
||||
@@ -50,12 +48,14 @@ export const Header = () => {
|
||||
const { asPath } = useRouter()
|
||||
const isSearchResultsPage = router.route === '/search'
|
||||
const isEarlyAccessPage = currentProduct && currentProduct.id === 'early-access'
|
||||
const signupCTAVisible =
|
||||
hasAccount === false && // don't show if `null`
|
||||
(currentVersion === DEFAULT_VERSION || currentVersion === 'enterprise-cloud@latest')
|
||||
const { width } = useWidth()
|
||||
const { width } = useInnerWindowWidth()
|
||||
const returnFocusRef = useRef(null)
|
||||
|
||||
const showNewSearch = useShouldShowExperiment(
|
||||
EXPERIMENTS.ai_search_experiment,
|
||||
router.locale as string,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
function onScroll() {
|
||||
setScroll(window.scrollY > 10)
|
||||
@@ -120,32 +120,6 @@ export const Header = () => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
function useWidth() {
|
||||
const hasWindow = typeof window !== 'undefined'
|
||||
|
||||
function getWidth() {
|
||||
const width = hasWindow ? window.innerWidth : null
|
||||
return {
|
||||
width,
|
||||
}
|
||||
}
|
||||
|
||||
const [width, setWidth] = useState(getWidth())
|
||||
|
||||
useEffect(() => {
|
||||
if (hasWindow) {
|
||||
const handleResize = function () {
|
||||
setWidth(getWidth())
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [hasWindow])
|
||||
|
||||
return width
|
||||
}
|
||||
|
||||
let homeURL = `/${router.locale}`
|
||||
if (currentVersion !== DEFAULT_VERSION) {
|
||||
homeURL += `/${currentVersion}`
|
||||
@@ -172,6 +146,10 @@ export const Header = () => {
|
||||
>
|
||||
<div
|
||||
className="d-flex flex-justify-between p-2 flex-items-center flex-wrap"
|
||||
style={{
|
||||
// In the rare case of header overflow, create a pleasant gap between the rows
|
||||
rowGap: '1rem',
|
||||
}}
|
||||
data-testid="desktop-header"
|
||||
>
|
||||
<div
|
||||
@@ -198,160 +176,19 @@ export const Header = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={cx('d-flex flex-items-center', isSearchOpen && styles.widgetsContainer)}>
|
||||
{/* <!-- GitHub.com homepage and 404 page has a stylized search; Enterprise homepages do not --> */}
|
||||
{error !== '404' && (
|
||||
<div
|
||||
className={cx(
|
||||
isSearchOpen
|
||||
? styles.searchContainerWithOpenSearch
|
||||
: styles.searchContainerWithClosedSearch,
|
||||
'mr-3',
|
||||
)}
|
||||
>
|
||||
<Search isSearchOpen={isSearchOpen} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cx('d-none d-lg-flex flex-items-center', signupCTAVisible && 'mr-3')}>
|
||||
<LanguagePicker />
|
||||
</div>
|
||||
|
||||
{signupCTAVisible && (
|
||||
<div data-testid="header-signup" className="border-left">
|
||||
<a
|
||||
href="https://github.com/signup?ref_cta=Sign+up&ref_loc=docs+header&ref_page=docs"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="d-none d-lg-flex ml-3 btn color-fg-muted"
|
||||
>
|
||||
{t`sign_up_cta`}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
className={cx(
|
||||
'hide-lg hide-xl',
|
||||
!isSearchOpen ? 'd-flex flex-items-center' : 'd-none',
|
||||
)}
|
||||
data-testid="mobile-search-button"
|
||||
onClick={() => setIsSearchOpen(!isSearchOpen)}
|
||||
aria-label="Open Search Bar"
|
||||
aria-expanded={isSearchOpen ? 'true' : 'false'}
|
||||
icon={SearchIcon}
|
||||
{showNewSearch ? (
|
||||
<HeaderSearchAndWidgets
|
||||
isSearchOpen={isSearchOpen}
|
||||
setIsSearchOpen={setIsSearchOpen}
|
||||
width={width}
|
||||
/>
|
||||
<IconButton
|
||||
className="px-3"
|
||||
data-testid="mobile-search-button"
|
||||
onClick={() => setIsSearchOpen(!isSearchOpen)}
|
||||
aria-label="Close Search Bar"
|
||||
aria-expanded={isSearchOpen ? 'true' : 'false'}
|
||||
icon={XIcon}
|
||||
sx={
|
||||
isSearchOpen
|
||||
? {
|
||||
// The x button to close the small width search UI when search is open, as the
|
||||
// browser width increases to md and above we no longer show that search UI so
|
||||
// the close search button is hidden as well.
|
||||
// breakpoint(md)
|
||||
'@media (min-width: 768px)': {
|
||||
display: 'none',
|
||||
},
|
||||
}
|
||||
: {
|
||||
display: 'none',
|
||||
}
|
||||
}
|
||||
) : (
|
||||
<OldHeaderSearchAndWidgets
|
||||
isSearchOpen={isSearchOpen}
|
||||
setIsSearchOpen={setIsSearchOpen}
|
||||
width={width}
|
||||
/>
|
||||
|
||||
{/* The ... navigation menu at medium and smaller widths */}
|
||||
<div>
|
||||
<ActionMenu aria-labelledby="menu-title">
|
||||
<ActionMenu.Anchor>
|
||||
<IconButton
|
||||
data-testid="mobile-menu"
|
||||
icon={KebabHorizontalIcon}
|
||||
aria-label="Open Menu"
|
||||
sx={
|
||||
isSearchOpen
|
||||
? // The ... menu button when the smaller width search UI is open. Since the search
|
||||
// UI is open, we don't show the button at smaller widths but we do show it as
|
||||
// the browser width increases to md, and then at lg and above widths we hide
|
||||
// the button again since the pickers and sign-up button are shown in the header.
|
||||
{
|
||||
marginLeft: '8px',
|
||||
display: 'none',
|
||||
// breakpoint(md)
|
||||
'@media (min-width: 768px)': {
|
||||
display: 'inline-block',
|
||||
marginLeft: '4px',
|
||||
},
|
||||
// breakpoint(lg)
|
||||
'@media (min-width: 1012px)': {
|
||||
display: 'none',
|
||||
},
|
||||
}
|
||||
: // The ... menu button when the smaller width search UI is closed, the button is
|
||||
// shown up to md. At lg and above we don't show the button since the pickers
|
||||
// and sign-up button are shown in the header.
|
||||
{
|
||||
marginLeft: '16px',
|
||||
'@media (min-width: 768px)': {
|
||||
marginLeft: '0',
|
||||
},
|
||||
'@media (min-width: 1012px)': {
|
||||
display: 'none',
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
</ActionMenu.Anchor>
|
||||
<ActionMenu.Overlay align="start">
|
||||
<ActionList>
|
||||
<ActionList.Group data-testid="open-mobile-menu">
|
||||
{width && width > 544 ? (
|
||||
<LanguagePicker mediumOrLower={true} />
|
||||
) : (
|
||||
<LanguagePicker xs={true} />
|
||||
)}
|
||||
<ActionList.Divider />
|
||||
{width && width < 545 && (
|
||||
<>
|
||||
<VersionPicker xs={true} />
|
||||
<ActionList.Divider />
|
||||
{showDomainNameEdit && (
|
||||
<>
|
||||
<Suspense>
|
||||
<DomainNameEdit xs={true} />
|
||||
</Suspense>
|
||||
<ActionList.Divider />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{signupCTAVisible && (
|
||||
<ActionList.LinkItem
|
||||
href="https://github.com/signup?ref_cta=Sign+up&ref_loc=docs+header&ref_page=docs"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
data-testid="mobile-signup"
|
||||
className="d-flex color-fg-muted"
|
||||
>
|
||||
{t`sign_up_cta`}
|
||||
<LinkExternalIcon
|
||||
className="height-full float-right"
|
||||
aria-label="(external site)"
|
||||
/>
|
||||
</ActionList.LinkItem>
|
||||
)}{' '}
|
||||
</ActionList.Group>
|
||||
</ActionList>
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isHomepageVersion && !isSearchResultsPage && (
|
||||
<div className="d-flex flex-items-center d-xxl-none mt-2" data-testid="header-subnav">
|
||||
|
||||
149
src/frame/components/page-header/HeaderSearchAndWidgets.tsx
Normal file
149
src/frame/components/page-header/HeaderSearchAndWidgets.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { Suspense } from 'react'
|
||||
import cx from 'classnames'
|
||||
import { KebabHorizontalIcon, LinkExternalIcon } from '@primer/octicons-react'
|
||||
import { IconButton, ActionMenu, ActionList } from '@primer/react'
|
||||
|
||||
import { LanguagePicker } from '@/languages/components/LanguagePicker'
|
||||
import { useTranslation } from '@/languages/components/useTranslation'
|
||||
import DomainNameEdit from '@/links/components/DomainNameEdit'
|
||||
import { VersionPicker } from '@/versions/components/VersionPicker'
|
||||
import { DEFAULT_VERSION, useVersion } from '@/versions/components/useVersion'
|
||||
import { useHasAccount } from '../hooks/useHasAccount'
|
||||
|
||||
import { SearchBarButton } from '@/search/components/input/SearchBarButton'
|
||||
import { useBreakpoint } from '@/search/components/hooks/useBreakpoint'
|
||||
|
||||
type Props = {
|
||||
isSearchOpen: boolean
|
||||
setIsSearchOpen: (value: boolean) => void
|
||||
width: number | null
|
||||
}
|
||||
|
||||
export function HeaderSearchAndWidgets({ isSearchOpen, setIsSearchOpen, width }: Props) {
|
||||
const { currentVersion } = useVersion()
|
||||
const { t } = useTranslation(['header'])
|
||||
const isLarge = useBreakpoint('large')
|
||||
const { hasAccount } = useHasAccount()
|
||||
const signupCTAVisible =
|
||||
hasAccount === false && // don't show if `null`
|
||||
(currentVersion === DEFAULT_VERSION || currentVersion === 'enterprise-cloud@latest')
|
||||
|
||||
const showDomainNameEdit = currentVersion.startsWith('enterprise-server@')
|
||||
|
||||
const SearchButton = (
|
||||
<SearchBarButton isSearchOpen={isSearchOpen} setIsSearchOpen={setIsSearchOpen} />
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* At larger & up widths we show the search as an input. This doesn't need to be grouped with the widgets */}
|
||||
{isLarge ? SearchButton : null}
|
||||
<div className={cx('d-flex flex-items-center', isSearchOpen && 'd-none')}>
|
||||
<div className={cx('d-none d-lg-flex flex-items-center', signupCTAVisible && 'mr-3')}>
|
||||
<LanguagePicker />
|
||||
</div>
|
||||
|
||||
{signupCTAVisible && (
|
||||
<div data-testid="header-signup" className="border-left">
|
||||
<a
|
||||
href="https://github.com/signup?ref_cta=Sign+up&ref_loc=docs+header&ref_page=docs"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="d-none d-lg-flex ml-3 btn color-fg-muted"
|
||||
>
|
||||
{t`sign_up_cta`}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Below large widths we show the search as a button which needs to be grouped with the widgets */}
|
||||
{!isLarge ? SearchButton : null}
|
||||
|
||||
{/* The ... navigation menu at medium and smaller widths */}
|
||||
<div>
|
||||
<ActionMenu aria-labelledby="menu-title">
|
||||
<ActionMenu.Anchor>
|
||||
<IconButton
|
||||
data-testid="mobile-menu"
|
||||
icon={KebabHorizontalIcon}
|
||||
aria-label={t('header.open_menu_label')}
|
||||
sx={
|
||||
isSearchOpen
|
||||
? // The ... menu button when the smaller width search UI is open. Since the search
|
||||
// UI is open, we don't show the button at smaller widths but we do show it as
|
||||
// the browser width increases to md, and then at lg and above widths we hide
|
||||
// the button again since the pickers and sign-up button are shown in the header.
|
||||
{
|
||||
marginLeft: '8px',
|
||||
display: 'none',
|
||||
// breakpoint(md)
|
||||
'@media (min-width: 768px)': {
|
||||
display: 'inline-block',
|
||||
marginLeft: '4px',
|
||||
},
|
||||
// breakpoint(lg)
|
||||
'@media (min-width: 1012px)': {
|
||||
display: 'inline-block',
|
||||
marginLeft: '4px',
|
||||
},
|
||||
}
|
||||
: // The ... menu button when the smaller width search UI is closed, the button is
|
||||
// shown up to md. At lg and above we don't show the button since the pickers
|
||||
// and sign-up button are shown in the header.
|
||||
{
|
||||
marginLeft: '16px',
|
||||
'@media (min-width: 1012px)': {
|
||||
marginLeft: '0',
|
||||
display: 'none',
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
</ActionMenu.Anchor>
|
||||
<ActionMenu.Overlay align="start">
|
||||
<ActionList>
|
||||
<ActionList.Group data-testid="open-mobile-menu">
|
||||
{width && width > 544 ? (
|
||||
<LanguagePicker mediumOrLower={true} />
|
||||
) : (
|
||||
<LanguagePicker xs={true} />
|
||||
)}
|
||||
<ActionList.Divider />
|
||||
{width && width < 545 && (
|
||||
<>
|
||||
<VersionPicker xs={true} />
|
||||
<ActionList.Divider />
|
||||
{showDomainNameEdit && (
|
||||
<>
|
||||
<Suspense>
|
||||
<DomainNameEdit xs={true} />
|
||||
</Suspense>
|
||||
<ActionList.Divider />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{signupCTAVisible && (
|
||||
<ActionList.LinkItem
|
||||
href="https://github.com/signup?ref_cta=Sign+up&ref_loc=docs+header&ref_page=docs"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
data-testid="mobile-signup"
|
||||
className="d-flex color-fg-muted"
|
||||
>
|
||||
{t`sign_up_cta`}
|
||||
<LinkExternalIcon
|
||||
className="height-full float-right"
|
||||
aria-label="(external site)"
|
||||
/>
|
||||
</ActionList.LinkItem>
|
||||
)}{' '}
|
||||
</ActionList.Group>
|
||||
</ActionList>
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
@import "@primer/css/support/variables/layout.scss";
|
||||
@import "@primer/css/support/mixins/layout.scss";
|
||||
|
||||
// Contains the search input, language picker, and sign-up button. When the
|
||||
// search input is open and up to sm (where the language picker and sign-up
|
||||
// button are hidden) we need to take up almost all the header width but then at
|
||||
// md and above we don't want the search input to take up the header width.
|
||||
.widgetsContainer {
|
||||
width: 100%;
|
||||
|
||||
@include breakpoint(md) {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// Contains the search input and used when the smaller width search input UI is
|
||||
// closed to hide the full width input, but as the width increases to md and
|
||||
// above we show the search input along the other UI widgets (the menu button,
|
||||
// the language picker, etc.)
|
||||
.searchContainerWithClosedSearch {
|
||||
display: none;
|
||||
|
||||
@include breakpoint(md) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
// Contains the search input and used when the smaller width search input UI is
|
||||
// open and we set it full width but as the browser width increases to md and
|
||||
// above we don't take up the whole width anymore since we now show other UI
|
||||
// widgets.
|
||||
.searchContainerWithOpenSearch {
|
||||
width: 100%;
|
||||
margin-right: -1px;
|
||||
|
||||
@include breakpoint(md) {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
186
src/frame/components/page-header/OldHeaderSearchAndWidgets.tsx
Normal file
186
src/frame/components/page-header/OldHeaderSearchAndWidgets.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Suspense } from 'react'
|
||||
import cx from 'classnames'
|
||||
import { SearchIcon, XIcon, KebabHorizontalIcon, LinkExternalIcon } from '@primer/octicons-react'
|
||||
import { IconButton, ActionMenu, ActionList } from '@primer/react'
|
||||
|
||||
import { LanguagePicker } from '@/languages/components/LanguagePicker'
|
||||
import { useTranslation } from '@/languages/components/useTranslation'
|
||||
import DomainNameEdit from '@/links/components/DomainNameEdit'
|
||||
import { OldSearchInput } from '@/search/components/input/OldSearchInput'
|
||||
import { VersionPicker } from '@/versions/components/VersionPicker'
|
||||
import { DEFAULT_VERSION, useVersion } from '@/versions/components/useVersion'
|
||||
import { useHasAccount } from '../hooks/useHasAccount'
|
||||
import { useMainContext } from '../context/MainContext'
|
||||
|
||||
import styles from './OldHeaderSearchAndWidgets.module.scss'
|
||||
|
||||
type Props = {
|
||||
isSearchOpen: boolean
|
||||
setIsSearchOpen: (value: boolean) => void
|
||||
width: number | null
|
||||
}
|
||||
|
||||
export function OldHeaderSearchAndWidgets({ isSearchOpen, setIsSearchOpen, width }: Props) {
|
||||
const { error } = useMainContext()
|
||||
const { currentVersion } = useVersion()
|
||||
const { t } = useTranslation(['header'])
|
||||
const { hasAccount } = useHasAccount()
|
||||
const signupCTAVisible =
|
||||
hasAccount === false && // don't show if `null`
|
||||
(currentVersion === DEFAULT_VERSION || currentVersion === 'enterprise-cloud@latest')
|
||||
|
||||
const showDomainNameEdit = currentVersion.startsWith('enterprise-server@')
|
||||
|
||||
return (
|
||||
<div className={cx('d-flex flex-items-center', isSearchOpen && styles.widgetsContainer)}>
|
||||
{/* <!-- GitHub.com homepage and 404 page has a stylized search; Enterprise homepages do not --> */}
|
||||
{error !== '404' && (
|
||||
<div
|
||||
className={cx(
|
||||
isSearchOpen
|
||||
? styles.searchContainerWithOpenSearch
|
||||
: styles.searchContainerWithClosedSearch,
|
||||
'mr-3',
|
||||
)}
|
||||
>
|
||||
<OldSearchInput isSearchOpen={isSearchOpen} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cx('d-none d-lg-flex flex-items-center', signupCTAVisible && 'mr-3')}>
|
||||
<LanguagePicker />
|
||||
</div>
|
||||
|
||||
{signupCTAVisible && (
|
||||
<div data-testid="header-signup" className="border-left">
|
||||
<a
|
||||
href="https://github.com/signup?ref_cta=Sign+up&ref_loc=docs+header&ref_page=docs"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="d-none d-lg-flex ml-3 btn color-fg-muted"
|
||||
>
|
||||
{t`sign_up_cta`}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
className={cx('hide-lg hide-xl', !isSearchOpen ? 'd-flex flex-items-center' : 'd-none')}
|
||||
data-testid="mobile-search-button"
|
||||
onClick={() => setIsSearchOpen(!isSearchOpen)}
|
||||
aria-label="Open Search Bar"
|
||||
aria-expanded={isSearchOpen ? 'true' : 'false'}
|
||||
icon={SearchIcon}
|
||||
/>
|
||||
<IconButton
|
||||
className="px-3"
|
||||
data-testid="mobile-search-button"
|
||||
onClick={() => setIsSearchOpen(!isSearchOpen)}
|
||||
aria-label="Close Search Bar"
|
||||
aria-expanded={isSearchOpen ? 'true' : 'false'}
|
||||
icon={XIcon}
|
||||
sx={
|
||||
isSearchOpen
|
||||
? {
|
||||
// The x button to close the small width search UI when search is open, as the
|
||||
// browser width increases to md and above we no longer show that search UI so
|
||||
// the close search button is hidden as well.
|
||||
// breakpoint(md)
|
||||
'@media (min-width: 768px)': {
|
||||
display: 'none',
|
||||
},
|
||||
}
|
||||
: {
|
||||
display: 'none',
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
||||
{/* The ... navigation menu at medium and smaller widths */}
|
||||
<div>
|
||||
<ActionMenu aria-labelledby="menu-title">
|
||||
<ActionMenu.Anchor>
|
||||
<IconButton
|
||||
data-testid="mobile-menu"
|
||||
icon={KebabHorizontalIcon}
|
||||
aria-label="Open Menu"
|
||||
sx={
|
||||
isSearchOpen
|
||||
? // The ... menu button when the smaller width search UI is open. Since the search
|
||||
// UI is open, we don't show the button at smaller widths but we do show it as
|
||||
// the browser width increases to md, and then at lg and above widths we hide
|
||||
// the button again since the pickers and sign-up button are shown in the header.
|
||||
{
|
||||
marginLeft: '8px',
|
||||
display: 'none',
|
||||
// breakpoint(md)
|
||||
'@media (min-width: 768px)': {
|
||||
display: 'inline-block',
|
||||
marginLeft: '4px',
|
||||
},
|
||||
// breakpoint(lg)
|
||||
'@media (min-width: 1012px)': {
|
||||
display: 'none',
|
||||
},
|
||||
}
|
||||
: // The ... menu button when the smaller width search UI is closed, the button is
|
||||
// shown up to md. At lg and above we don't show the button since the pickers
|
||||
// and sign-up button are shown in the header.
|
||||
{
|
||||
marginLeft: '16px',
|
||||
'@media (min-width: 768px)': {
|
||||
marginLeft: '0',
|
||||
},
|
||||
'@media (min-width: 1012px)': {
|
||||
display: 'none',
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
</ActionMenu.Anchor>
|
||||
<ActionMenu.Overlay align="start">
|
||||
<ActionList>
|
||||
<ActionList.Group data-testid="open-mobile-menu">
|
||||
{width && width > 544 ? (
|
||||
<LanguagePicker mediumOrLower={true} />
|
||||
) : (
|
||||
<LanguagePicker xs={true} />
|
||||
)}
|
||||
<ActionList.Divider />
|
||||
{width && width < 545 && (
|
||||
<>
|
||||
<VersionPicker xs={true} />
|
||||
<ActionList.Divider />
|
||||
{showDomainNameEdit && (
|
||||
<>
|
||||
<Suspense>
|
||||
<DomainNameEdit xs={true} />
|
||||
</Suspense>
|
||||
<ActionList.Divider />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{signupCTAVisible && (
|
||||
<ActionList.LinkItem
|
||||
href="https://github.com/signup?ref_cta=Sign+up&ref_loc=docs+header&ref_page=docs"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
data-testid="mobile-signup"
|
||||
className="d-flex color-fg-muted"
|
||||
>
|
||||
{t`sign_up_cta`}
|
||||
<LinkExternalIcon
|
||||
className="height-full float-right"
|
||||
aria-label="(external site)"
|
||||
/>
|
||||
</ActionList.LinkItem>
|
||||
)}{' '}
|
||||
</ActionList.Group>
|
||||
</ActionList>
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function useInnerWindowWidth() {
|
||||
const hasWindow = typeof window !== 'undefined'
|
||||
|
||||
function getWidth() {
|
||||
const width = hasWindow ? window.innerWidth : null
|
||||
return {
|
||||
width,
|
||||
}
|
||||
}
|
||||
|
||||
const [width, setWidth] = useState(getWidth())
|
||||
|
||||
useEffect(() => {
|
||||
if (hasWindow) {
|
||||
const handleResize = function () {
|
||||
setWidth(getWidth())
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [hasWindow])
|
||||
|
||||
return width
|
||||
}
|
||||
@@ -3,7 +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 { SidebarSearchAggregates } from 'src/search/components/results/SidebarSearchAggregates'
|
||||
import { AllProductsLink } from './AllProductsLink'
|
||||
import { ApiVersionPicker } from 'src/rest/components/ApiVersionPicker'
|
||||
import { Link } from 'src/frame/components/Link'
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import type { Components } from 'react-markdown'
|
||||
import cx from 'classnames'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
import styles from './MarkdownContent.module.scss'
|
||||
|
||||
export type MarkdownContentPropsT = {
|
||||
children: string
|
||||
className?: string
|
||||
openLinksInNewTab?: boolean
|
||||
eventGroupKey?: string
|
||||
eventGroupId?: string
|
||||
as?: keyof JSX.IntrinsicElements
|
||||
tabIndex?: number
|
||||
}
|
||||
|
||||
// For content that comes in a Markdown string
|
||||
// e.g. a GPT Response
|
||||
|
||||
export const UnrenderedMarkdownContent = ({
|
||||
children,
|
||||
className,
|
||||
openLinksInNewTab = true,
|
||||
eventGroupKey = '',
|
||||
eventGroupId = '',
|
||||
...restProps
|
||||
}: MarkdownContentPropsT) => {
|
||||
// Overrides for ReactMarkdown components
|
||||
const components = {} as Components
|
||||
if (openLinksInNewTab) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
components.a = ({ node, ...props }) => (
|
||||
<a {...props} target="_blank" data-group-key={eventGroupKey} data-group-id={eventGroupId}>
|
||||
{props.children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
{...restProps}
|
||||
className={cx(styles.markdownBody, 'markdown-body', className)}
|
||||
components={components}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export { default, getServerSideProps } from 'src/search/pages/search'
|
||||
export { default, getServerSideProps } from 'src/search/pages/search-results'
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Spinner } from '@primer/react'
|
||||
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 ? <ShowSpinner /> : <ShowNothing />
|
||||
}
|
||||
|
||||
function ShowSpinner() {
|
||||
return (
|
||||
<div className="my-12 d-flex flex-justify-center">
|
||||
<Spinner size="medium" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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.
|
||||
<div className="my-12" style={{ minHeight: 105 }}>
|
||||
{/* Deliberately empty */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
src/search/components/helpers/ai-search-links-json.ts
Normal file
76
src/search/components/helpers/ai-search-links-json.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
type LinksJSON = Array<{
|
||||
type: 'reference' | 'inline'
|
||||
url: string
|
||||
product: string
|
||||
}>
|
||||
|
||||
// We use this to generate a JSON string that includes all of the links:
|
||||
// 1. Included in the AI response (inline)
|
||||
// 2. Used to generate the AI response via an embedding (reference)
|
||||
//
|
||||
// We include the JSON string in our analytics events so we can see the
|
||||
// most popular sourced references, among other things.
|
||||
export function generateAiSearchLinksJson(
|
||||
sourcesBuffer: Array<{ url: string }>,
|
||||
aiResponse: string,
|
||||
): string {
|
||||
const linksJson = [] as LinksJSON
|
||||
const inlineLinks = extractMarkdownLinks(aiResponse)
|
||||
for (const link of inlineLinks) {
|
||||
const product = extractProductFromDocsUrl(link)
|
||||
linksJson.push({
|
||||
type: 'inline',
|
||||
url: link,
|
||||
product,
|
||||
})
|
||||
}
|
||||
for (const source of sourcesBuffer) {
|
||||
const product = extractProductFromDocsUrl(source.url)
|
||||
linksJson.push({
|
||||
type: 'reference',
|
||||
url: source.url,
|
||||
product,
|
||||
})
|
||||
}
|
||||
|
||||
return JSON.stringify(linksJson)
|
||||
}
|
||||
|
||||
// Get all links in a markdown text
|
||||
function extractMarkdownLinks(markdownResponse: string) {
|
||||
// This regex matches markdown links of the form [text](url)
|
||||
// Explanation:
|
||||
// \[([^\]]+)\] : Matches the link text inside square brackets (one or more non-']' characters).
|
||||
// \( : Matches the opening parenthesis.
|
||||
// ([^)]+) : Captures the URL (one or more characters that are not a closing parenthesis).
|
||||
// \) : Matches the closing parenthesis.
|
||||
const regex = /\[([^\]]+)\]\(([^)]+)\)/g
|
||||
|
||||
const urls = []
|
||||
let match
|
||||
|
||||
while ((match = regex.exec(markdownResponse)) !== null) {
|
||||
urls.push(match[2])
|
||||
}
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
// Given a Docs URL, extract the product name
|
||||
function extractProductFromDocsUrl(url: string): string {
|
||||
const pathname = new URL(url).pathname
|
||||
|
||||
const segments = pathname.split('/').filter((segment) => segment)
|
||||
|
||||
// If the first segment is a language code (2 characters), then product is the next segment.
|
||||
// Otherwise, assume the first segment is the product.
|
||||
if (segments.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (segments[0].length === 2) {
|
||||
return segments[1] || ''
|
||||
}
|
||||
|
||||
return segments[0]
|
||||
}
|
||||
123
src/search/components/helpers/execute-search-actions.ts
Normal file
123
src/search/components/helpers/execute-search-actions.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { EventType } from '@/events/types'
|
||||
import { AutocompleteSearchResponse } from '@/search/types'
|
||||
import { DEFAULT_VERSION } from '@/versions/components/useVersion'
|
||||
import { NextRouter } from 'next/router'
|
||||
import { sendEvent } from 'src/events/components/events'
|
||||
import { ASK_AI_EVENT_GROUP, SEARCH_OVERLAY_EVENT_GROUP } from '@/events/components/event-groups'
|
||||
|
||||
// Search context values for identifying each search event
|
||||
export const GENERAL_SEARCH_CONTEXT = 'general-search'
|
||||
export const AI_SEARCH_CONTEXT = 'ai-search'
|
||||
export const AI_AUTOCOMPLETE_SEARCH_CONTEXT = 'ai-search-autocomplete'
|
||||
|
||||
// The logic that redirects to the /search page with the proper query params
|
||||
// The query params will be consumed in the general search middleware
|
||||
export function executeGeneralSearch(
|
||||
router: NextRouter,
|
||||
currentVersion: string,
|
||||
localQuery: string,
|
||||
debug = false,
|
||||
eventGroupId?: string,
|
||||
) {
|
||||
sendEvent({
|
||||
type: EventType.search,
|
||||
search_query: localQuery,
|
||||
search_context: GENERAL_SEARCH_CONTEXT,
|
||||
eventGroupKey: SEARCH_OVERLAY_EVENT_GROUP,
|
||||
eventGroupId,
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
export async function executeAISearch(
|
||||
router: NextRouter,
|
||||
version: string,
|
||||
query: string,
|
||||
debug = false,
|
||||
eventGroupId?: string,
|
||||
) {
|
||||
sendEvent({
|
||||
type: EventType.search,
|
||||
// TODO: Remove PII so we can include the actual query
|
||||
search_query: 'REDACTED',
|
||||
search_context: AI_SEARCH_CONTEXT,
|
||||
eventGroupKey: ASK_AI_EVENT_GROUP,
|
||||
eventGroupId,
|
||||
})
|
||||
|
||||
let language = router.locale || 'en'
|
||||
|
||||
const body = {
|
||||
query,
|
||||
version,
|
||||
language,
|
||||
...(debug && { debug: '1' }),
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/ai-search/v1`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// The AJAX request logic that fetches the autocomplete options for AI autocomplete sugggestions
|
||||
export async function executeAIAutocompleteSearch(
|
||||
router: NextRouter,
|
||||
version: string,
|
||||
query: string,
|
||||
debug = false,
|
||||
abortSignal?: AbortSignal,
|
||||
eventGroupId?: string,
|
||||
) {
|
||||
sendEvent({
|
||||
type: EventType.search,
|
||||
// TODO: Remove PII so we can include the actual query
|
||||
search_query: 'REDACTED',
|
||||
search_context: AI_AUTOCOMPLETE_SEARCH_CONTEXT,
|
||||
eventGroupKey: SEARCH_OVERLAY_EVENT_GROUP,
|
||||
eventGroupId: eventGroupId,
|
||||
})
|
||||
|
||||
let language = router.locale || 'en'
|
||||
|
||||
const params = new URLSearchParams({ query: query, version, language })
|
||||
if (debug) {
|
||||
params.set('debug', '1')
|
||||
}
|
||||
|
||||
// Always fetch 5 results for autocomplete
|
||||
params.set('size', '5')
|
||||
|
||||
const response = await fetch(`/api/search/ai-search-autocomplete/v1?${params}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// Allow the caller to pass in an AbortSignal to cancel the request
|
||||
signal: abortSignal || undefined,
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch ai autocomplete search results.\nStatus ${response.status}\n${response.statusText}`,
|
||||
)
|
||||
}
|
||||
const results = (await response.json()) as AutocompleteSearchResponse
|
||||
return {
|
||||
aiAutocompleteOptions: results?.hits || [],
|
||||
}
|
||||
}
|
||||
153
src/search/components/helpers/fix-incomplete-markdown.ts
Normal file
153
src/search/components/helpers/fix-incomplete-markdown.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
// When streaming markdown response, e.g., from a GPT, the response will come in chunks that may have opening tags but no closing tags.
|
||||
// This function seeks to fix the partial markdown by closing the tags it detects.
|
||||
export function fixIncompleteMarkdown(content: string): string {
|
||||
// First, fix code blocks
|
||||
content = fixCodeBlocks(content)
|
||||
|
||||
// Then, fix inline code
|
||||
content = fixInlineCode(content)
|
||||
|
||||
// Then, fix links
|
||||
content = fixLinks(content)
|
||||
|
||||
// Then, fix images
|
||||
content = fixImages(content)
|
||||
|
||||
// Then, fix emphasis (bold, italic, strikethrough)
|
||||
content = fixEmphasis(content)
|
||||
|
||||
// Then, fix tables
|
||||
content = fixTables(content)
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
function fixCodeBlocks(content: string): string {
|
||||
const codeBlockRegex = /```/g
|
||||
const matches = content.match(codeBlockRegex)
|
||||
const count = matches ? matches.length : 0
|
||||
if (count % 2 !== 0) {
|
||||
content += '\n```'
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
function fixInlineCode(content: string): string {
|
||||
const inlineCodeRegex = /`/g
|
||||
const matches = content.match(inlineCodeRegex)
|
||||
const count = matches ? matches.length : 0
|
||||
if (count % 2 !== 0) {
|
||||
content += '`'
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
function fixLinks(content: string): string {
|
||||
// Handle unclosed link text '['
|
||||
const linkTextRegex = /\[([^\]]*)$/
|
||||
if (linkTextRegex.test(content)) {
|
||||
content += ']'
|
||||
}
|
||||
|
||||
// Handle unclosed link URL '('
|
||||
const linkURLRegex = /\]\(([^)]*)$/
|
||||
if (linkURLRegex.test(content)) {
|
||||
content += ')'
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
function fixImages(content: string): string {
|
||||
// Handle unclosed image alt text '!['
|
||||
const imageAltTextRegex = /!\[([^\]]*)$/
|
||||
if (imageAltTextRegex.test(content)) {
|
||||
content += ']'
|
||||
}
|
||||
|
||||
// Handle unclosed image URL '('
|
||||
const imageURLRegex = /!\[[^\]]*\]\(([^)]*)$/
|
||||
if (imageURLRegex.test(content)) {
|
||||
content += ')'
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
function fixEmphasis(content: string): string {
|
||||
const tokens = ['***', '**', '__', '*', '_', '~~', '~']
|
||||
const stack: { token: string; index: number }[] = []
|
||||
|
||||
let i = 0
|
||||
while (i < content.length) {
|
||||
let matched = false
|
||||
for (const token of tokens) {
|
||||
if (content.substr(i, token.length) === token) {
|
||||
if (stack.length > 0 && stack[stack.length - 1].token === token) {
|
||||
// Closing token found
|
||||
stack.pop()
|
||||
} else {
|
||||
// Opening token found
|
||||
stack.push({ token, index: i })
|
||||
}
|
||||
i += token.length
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// Close any remaining tokens in reverse order
|
||||
while (stack.length > 0) {
|
||||
const { token } = stack.pop()!
|
||||
content += token
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
function fixTables(content: string): string {
|
||||
const lines = content.split('\n')
|
||||
let inTable = false
|
||||
let headerPipeCount = 0
|
||||
let i = 0
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
if (/^\s*\|.*$/.test(line)) {
|
||||
// Line starts with '|', possible table line
|
||||
if (!inTable) {
|
||||
// Potential start of table
|
||||
if (i + 1 < lines.length && /^\s*\|[-\s|:]*$/.test(lines[i + 1])) {
|
||||
// Next line is separator, confirm table header
|
||||
inTable = true
|
||||
// Count number of '|' in header line
|
||||
headerPipeCount = (lines[i].match(/\|/g) || []).length
|
||||
i += 1 // Move to separator line
|
||||
} else {
|
||||
// Not a table, continue
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// In table body
|
||||
const linePipeCount = (line.match(/\|/g) || []).length
|
||||
if (linePipeCount < headerPipeCount) {
|
||||
// Calculate missing pipes
|
||||
const missingPipes = headerPipeCount - linePipeCount
|
||||
// Append missing ' |' to match header columns
|
||||
lines[i] = line.trimEnd() + ' |'.repeat(missingPipes)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Exiting table
|
||||
inTable = false
|
||||
headerPipeCount = 0
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
152
src/search/components/hooks/useAISearchAutocomplete.ts
Normal file
152
src/search/components/hooks/useAISearchAutocomplete.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import debounce from 'lodash/debounce'
|
||||
import { NextRouter } from 'next/router'
|
||||
import { AutocompleteSearchHit } from '@/search/types'
|
||||
import { executeAIAutocompleteSearch } from '@/search/components/helpers/execute-search-actions'
|
||||
|
||||
type AutocompleteOptions = {
|
||||
aiAutocompleteOptions: AutocompleteSearchHit[]
|
||||
}
|
||||
|
||||
type UseAutocompleteProps = {
|
||||
router: NextRouter
|
||||
currentVersion: string
|
||||
debug: boolean
|
||||
eventGroupIdRef: React.MutableRefObject<string>
|
||||
}
|
||||
|
||||
type UseAutocompleteReturn = {
|
||||
autoCompleteOptions: AutocompleteOptions
|
||||
searchLoading: boolean
|
||||
searchError: boolean
|
||||
updateAutocompleteResults: (query: string) => void
|
||||
clearAutocompleteResults: () => void
|
||||
}
|
||||
|
||||
const DEBOUNCE_TIME = 300 // In milliseconds
|
||||
|
||||
// Results are only cached for the current session
|
||||
// We cache results so if a user presses backspace, we can show the results immediately without burdening the API
|
||||
let sessionCache = {} as Record<string, AutocompleteOptions>
|
||||
|
||||
// Helpers surrounding the ai-search-autocomplete request to lessen the # of requests made to our API
|
||||
// There are 3 methods for reducing the # of requests:
|
||||
// 1. Debouncing the request to prevent multiple requests while the user is typing
|
||||
// 2. Caching the results of the request so if the user presses backspace, we can show the results immediately without burdening the API
|
||||
// 3. Aborting in-flight requests if the user types again before the previous request has completed
|
||||
export function useAISearchAutocomplete({
|
||||
router,
|
||||
currentVersion,
|
||||
debug,
|
||||
eventGroupIdRef,
|
||||
}: UseAutocompleteProps): UseAutocompleteReturn {
|
||||
const [autoCompleteOptions, setAutoCompleteOptions] = useState<AutocompleteOptions>({
|
||||
aiAutocompleteOptions: [],
|
||||
})
|
||||
const [searchLoading, setSearchLoading] = useState<boolean>(true)
|
||||
const [searchError, setSearchError] = useState<boolean>(false)
|
||||
|
||||
// Support for aborting in-flight requests (e.g. user starts typing while a request is still pending)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
// Debounce to prevent requests while user is (quickly) typing
|
||||
const debouncedFetchRef = useRef<ReturnType<typeof debounce> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
debouncedFetchRef.current = debounce((value: string) => {
|
||||
fetchAutocompleteResults(value)
|
||||
}, DEBOUNCE_TIME) // 300ms debounce
|
||||
|
||||
return () => {
|
||||
debouncedFetchRef.current?.cancel()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchAutocompleteResults = useCallback(
|
||||
async (queryValue: string) => {
|
||||
// Cancel any ongoing request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
|
||||
// Check if the result is in cache
|
||||
if (sessionCache[queryValue]) {
|
||||
setAutoCompleteOptions(sessionCache[queryValue])
|
||||
setSearchLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setSearchLoading(true)
|
||||
|
||||
// Create a new AbortController for the new request
|
||||
const controller = new AbortController()
|
||||
abortControllerRef.current = controller
|
||||
|
||||
try {
|
||||
const { aiAutocompleteOptions } = await executeAIAutocompleteSearch(
|
||||
router,
|
||||
currentVersion,
|
||||
queryValue,
|
||||
debug,
|
||||
controller.signal, // Pass in the signal to allow the request to be aborted
|
||||
eventGroupIdRef.current,
|
||||
)
|
||||
|
||||
const results: AutocompleteOptions = {
|
||||
aiAutocompleteOptions,
|
||||
}
|
||||
|
||||
// Update cache
|
||||
sessionCache[queryValue] = results
|
||||
|
||||
// Update state with fetched results
|
||||
setAutoCompleteOptions(results)
|
||||
setSearchLoading(false)
|
||||
} catch (error: any) {
|
||||
if (error.name === 'AbortError') {
|
||||
return
|
||||
}
|
||||
console.error(error)
|
||||
setSearchError(true)
|
||||
setSearchLoading(false)
|
||||
}
|
||||
},
|
||||
[router, currentVersion, debug],
|
||||
)
|
||||
|
||||
// Entry function called when the user types in the search input
|
||||
const updateAutocompleteResults = useCallback((queryValue: string) => {
|
||||
// When the input is empty, don't debounce the request
|
||||
// We want to immediately show the autocomplete options (that may be cached)
|
||||
if (queryValue === '') {
|
||||
debouncedFetchRef.current?.cancel()
|
||||
fetchAutocompleteResults('')
|
||||
return
|
||||
} else {
|
||||
debouncedFetchRef.current?.(queryValue)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clearAutocompleteResults = useCallback(() => {
|
||||
setAutoCompleteOptions({
|
||||
aiAutocompleteOptions: [],
|
||||
})
|
||||
setSearchLoading(false)
|
||||
setSearchError(false)
|
||||
}, [])
|
||||
|
||||
// Cleanup function to cancel any ongoing requests when unmounting
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortControllerRef.current?.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
autoCompleteOptions,
|
||||
searchLoading,
|
||||
searchError,
|
||||
updateAutocompleteResults,
|
||||
clearAutocompleteResults,
|
||||
}
|
||||
}
|
||||
152
src/search/components/hooks/useLocalStorageCache.ts
Normal file
152
src/search/components/hooks/useLocalStorageCache.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
interface CachedItem<T> {
|
||||
data: T
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
interface CacheIndexEntry {
|
||||
key: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for managing a localStorage cache
|
||||
* The cache uses an index to track the keys of cached items, and a separate item in localStorage
|
||||
* This allows the cache to be updated without having to read a single large entry into memory and parse it each time a key is accessed
|
||||
*
|
||||
* Cached items are cached under a prefix, for a fixed number of days, and the cache is limited to a fixed number of entries set by the following:
|
||||
* @param cacheKeyPrefix - Prefix for cache keys in localStorage.
|
||||
* @param maxEntries - Maximum number of entries that can be stored in the cache.
|
||||
* @param expirationDays - Number of days before a cache entry expires.
|
||||
* @returns An object containing getItem and setItem functions.
|
||||
*/
|
||||
function useLocalStorageCache<T = any>(
|
||||
cacheKeyPrefix: string = 'ai-query-cache',
|
||||
maxEntries: number = 1000,
|
||||
expirationDays: number = 30,
|
||||
) {
|
||||
const cacheIndexKey = `${cacheKeyPrefix}-index`
|
||||
|
||||
/**
|
||||
* Generates a unique key based on the query string.
|
||||
* @param query - The query string to generate the key from.
|
||||
* @returns A unique string key.
|
||||
*/
|
||||
const generateKey = (query: string): string => {
|
||||
query = query.trim().toLowerCase()
|
||||
// Simple hash function to generate a unique key from the query
|
||||
let hash = 0
|
||||
for (let i = 0; i < query.length; i++) {
|
||||
const char = query.charCodeAt(i)
|
||||
hash = (hash << 5) - hash + char
|
||||
hash |= 0 // Convert to 32bit integer
|
||||
}
|
||||
return `${cacheKeyPrefix}-${Math.abs(hash)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an item from the cache.
|
||||
* @param query - The query string associated with the cached data.
|
||||
* @returns The cached data if valid, otherwise null.
|
||||
*/
|
||||
const getItem = useCallback(
|
||||
(query: string): T | null => {
|
||||
const key = generateKey(query)
|
||||
const itemStr = localStorage.getItem(key)
|
||||
if (!itemStr) return null
|
||||
|
||||
let cachedItem: CachedItem<T>
|
||||
try {
|
||||
cachedItem = JSON.parse(itemStr)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse cached item from localStorage', e)
|
||||
localStorage.removeItem(key)
|
||||
return null
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const expirationTime = cachedItem.timestamp + expirationDays * 24 * 60 * 60 * 1000
|
||||
if (now < expirationTime) {
|
||||
// Item is still valid
|
||||
return cachedItem.data
|
||||
} else {
|
||||
// Item expired, remove it
|
||||
localStorage.removeItem(key)
|
||||
updateCacheIndex((index) => index.filter((entry) => entry.key !== key))
|
||||
return null
|
||||
}
|
||||
},
|
||||
[cacheKeyPrefix, expirationDays],
|
||||
)
|
||||
|
||||
/**
|
||||
* Stores an item in the cache.
|
||||
* @param query - The query string associated with the data.
|
||||
* @param data - The data to cache.
|
||||
*/
|
||||
const setItem = useCallback(
|
||||
(query: string, data: T): void => {
|
||||
const key = generateKey(query)
|
||||
const now = Date.now()
|
||||
const cachedItem: CachedItem<T> = { data, timestamp: now }
|
||||
|
||||
// Store the item
|
||||
localStorage.setItem(key, JSON.stringify(cachedItem))
|
||||
|
||||
// Update index
|
||||
const indexStr = localStorage.getItem(cacheIndexKey)
|
||||
let index: CacheIndexEntry[] = []
|
||||
if (indexStr) {
|
||||
try {
|
||||
index = JSON.parse(indexStr)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse cache index from localStorage', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove existing entry for this key if any
|
||||
index = index.filter((entry) => entry.key !== key)
|
||||
index.push({ key, timestamp: now })
|
||||
|
||||
// If cache exceeds max entries, remove oldest entries
|
||||
if (index.length > maxEntries) {
|
||||
// Sort entries by timestamp
|
||||
index.sort((a, b) => a.timestamp - b.timestamp)
|
||||
const excess = index.length - maxEntries
|
||||
const entriesToRemove = index.slice(0, excess)
|
||||
entriesToRemove.forEach((entry) => {
|
||||
localStorage.removeItem(entry.key)
|
||||
})
|
||||
index = index.slice(excess)
|
||||
}
|
||||
|
||||
// Store updated index
|
||||
localStorage.setItem(cacheIndexKey, JSON.stringify(index))
|
||||
},
|
||||
[cacheKeyPrefix, maxEntries],
|
||||
)
|
||||
|
||||
/**
|
||||
* Updates the cache index using a provided updater function.
|
||||
* @param updateFn - A function that takes the current index and returns the updated index.
|
||||
*/
|
||||
const updateCacheIndex = (updateFn: (index: CacheIndexEntry[]) => CacheIndexEntry[]): void => {
|
||||
const indexStr = localStorage.getItem(cacheIndexKey)
|
||||
let index: CacheIndexEntry[] = []
|
||||
if (indexStr) {
|
||||
try {
|
||||
index = JSON.parse(indexStr)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse cache index from localStorage', e)
|
||||
}
|
||||
}
|
||||
|
||||
index = updateFn(index)
|
||||
localStorage.setItem(cacheIndexKey, JSON.stringify(index))
|
||||
}
|
||||
|
||||
return { getItem, setItem }
|
||||
}
|
||||
|
||||
export default useLocalStorageCache
|
||||
@@ -19,7 +19,7 @@ export const useQuery = (): QueryInfo => {
|
||||
}
|
||||
}
|
||||
|
||||
function parseDebug(debug: string | Array<string> | undefined) {
|
||||
export function parseDebug(debug: string | Array<string> | undefined) {
|
||||
if (debug === '') {
|
||||
// E.g. `?query=foo&debug` should be treated as truthy
|
||||
return true
|
||||
76
src/search/components/input/AskAIResults.module.scss
Normal file
76
src/search/components/input/AskAIResults.module.scss
Normal file
@@ -0,0 +1,76 @@
|
||||
@import "@primer/css/support/variables/layout.scss";
|
||||
|
||||
$bodyPadding: 0 16px 0px 16px;
|
||||
$mutedTextColor: var(--fgColor-muted, var(--color-fg-muted, #656d76));
|
||||
|
||||
.container {
|
||||
max-height: 95vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.disclaimerText {
|
||||
display: block;
|
||||
font-size: small !important;
|
||||
font-weight: var(--base-text-weight-normal, 400) !important;
|
||||
color: $mutedTextColor;
|
||||
margin: 8px 0px 8px 0px;
|
||||
padding: $bodyPadding;
|
||||
}
|
||||
|
||||
.markdownBodyOverrides {
|
||||
font-size: small;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 16px;
|
||||
padding: $bodyPadding;
|
||||
}
|
||||
|
||||
.referencesTitle {
|
||||
font-size: small !important;
|
||||
font-weight: var(--base-text-weight-normal, 400) !important;
|
||||
margin: 0;
|
||||
color: $mutedTextColor;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
.referencesList {
|
||||
padding: 0 !important;
|
||||
padding-left: 16px !important;
|
||||
li {
|
||||
padding: $bodyPadding;
|
||||
margin-left: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
a {
|
||||
color: var(--color-accent-emphasis);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.displayForScreenReader {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.postAnswerWidgets {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding-left: 12px !important;
|
||||
}
|
||||
297
src/search/components/input/AskAIResults.tsx
Normal file
297
src/search/components/input/AskAIResults.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { executeAISearch } from '../helpers/execute-search-actions'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useTranslation } from '@/languages/components/useTranslation'
|
||||
import { ActionList, IconButton, Spinner } from '@primer/react'
|
||||
import { BookIcon, CheckIcon, CopyIcon, ThumbsdownIcon, ThumbsupIcon } from '@primer/octicons-react'
|
||||
import { announce } from '@primer/live-region-element'
|
||||
import useLocalStorageCache from '../hooks/useLocalStorageCache'
|
||||
import { UnrenderedMarkdownContent } from '@/frame/components/ui/MarkdownContent/UnrenderedMarkdownContent'
|
||||
import styles from './AskAIResults.module.scss'
|
||||
import { fixIncompleteMarkdown } from '../helpers/fix-incomplete-markdown'
|
||||
import useClipboard from '@/rest/components/useClipboard'
|
||||
import { sendEvent, uuidv4 } from '@/events/components/events'
|
||||
import { EventType } from '@/events/types'
|
||||
import { generateAiSearchLinksJson } from '../helpers/ai-search-links-json'
|
||||
import { ASK_AI_EVENT_GROUP } from '@/events/components/event-groups'
|
||||
|
||||
type AIQueryResultsProps = {
|
||||
query: string
|
||||
version: string
|
||||
debug: boolean
|
||||
setAISearchError: () => void
|
||||
}
|
||||
|
||||
type Source = {
|
||||
url: string
|
||||
title: string
|
||||
index: string
|
||||
}
|
||||
|
||||
export function AskAIResults({ query, version, debug, setAISearchError }: AIQueryResultsProps) {
|
||||
const router = useRouter()
|
||||
const { t } = useTranslation('search')
|
||||
const [message, setMessage] = useState('')
|
||||
const [sources, setSources] = useState<Source[]>([] as Source[])
|
||||
const [initialLoading, setInitialLoading] = useState(true)
|
||||
const [responseLoading, setResponseLoading] = useState(false)
|
||||
const eventGroupId = useRef<string>('')
|
||||
const disclaimerRef = useRef<HTMLDivElement>(null)
|
||||
// We cache up to 1000 queries, and expire them after 30 days
|
||||
const { getItem, setItem } = useLocalStorageCache<{
|
||||
query: string
|
||||
message: string
|
||||
sources: Source[]
|
||||
}>('ai-query-cache', 1000, 30)
|
||||
|
||||
const [isCopied, setCopied] = useClipboard(message, { successDuration: 1400 })
|
||||
const [feedbackSelected, setFeedbackSelected] = useState<null | 'up' | 'down'>(null)
|
||||
|
||||
// On query change, fetch the new results
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
setMessage('')
|
||||
setSources([])
|
||||
setInitialLoading(true)
|
||||
setResponseLoading(true)
|
||||
eventGroupId.current = uuidv4()
|
||||
disclaimerRef.current?.focus()
|
||||
|
||||
const cachedData = getItem(query)
|
||||
if (cachedData) {
|
||||
setMessage(cachedData.message)
|
||||
setSources(cachedData.sources)
|
||||
setInitialLoading(false)
|
||||
setResponseLoading(false)
|
||||
sendAISearchResultEvent(cachedData.sources, cachedData.message, eventGroupId.current)
|
||||
return
|
||||
}
|
||||
|
||||
// Handler for streamed response from GPT
|
||||
async function fetchData() {
|
||||
let messageBuffer = ''
|
||||
let sourcesBuffer: Source[] = []
|
||||
try {
|
||||
const response = await executeAISearch(router, version, query, debug, eventGroupId.current)
|
||||
// Serve canned response. A question that cannot be answered was asked
|
||||
if (response.status === 400) {
|
||||
setInitialLoading(false)
|
||||
setResponseLoading(false)
|
||||
const cannedResponse = t('search.ai.unable_to_answer')
|
||||
setItem(query, { query, message: cannedResponse, sources: [] })
|
||||
return setMessage(cannedResponse)
|
||||
}
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`Failed to fetch search results.\nStatus ${response.status}\n${response.statusText}`,
|
||||
)
|
||||
return setAISearchError()
|
||||
}
|
||||
if (!response.body) {
|
||||
console.error(`ReadableStream not supported in this browser`)
|
||||
return setAISearchError()
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
const reader = response.body.getReader()
|
||||
let done = false
|
||||
setInitialLoading(false)
|
||||
while (!done && !isCancelled) {
|
||||
const { value, done: readerDone } = await reader.read()
|
||||
done = readerDone
|
||||
if (value) {
|
||||
const chunkStr = decoder.decode(value, { stream: true })
|
||||
const chunkLines = chunkStr.split('\n').filter((line) => line.trim() !== '')
|
||||
for (const line of chunkLines) {
|
||||
let parsedLine
|
||||
try {
|
||||
parsedLine = JSON.parse(line)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse JSON:', e, 'Line:', line)
|
||||
continue
|
||||
}
|
||||
|
||||
if (parsedLine.chunkType === 'SOURCES') {
|
||||
if (!isCancelled) {
|
||||
sourcesBuffer = sourcesBuffer.concat(parsedLine.sources)
|
||||
setSources(parsedLine.sources)
|
||||
}
|
||||
} else if (parsedLine.chunkType === 'MESSAGE_CHUNK') {
|
||||
if (!isCancelled) {
|
||||
messageBuffer += parsedLine.text
|
||||
setMessage(messageBuffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (!isCancelled) {
|
||||
console.error('Failed to fetch search results:', error)
|
||||
setAISearchError()
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled && messageBuffer) {
|
||||
setItem(query, { query, message: messageBuffer, sources: sourcesBuffer })
|
||||
setInitialLoading(false)
|
||||
setResponseLoading(false)
|
||||
sendAISearchResultEvent(sourcesBuffer, messageBuffer, eventGroupId.current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [query])
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Hidden status message for screen readers */}
|
||||
<span role="status" aria-live="polite" className={styles.displayForScreenReader}>
|
||||
{initialLoading || responseLoading
|
||||
? t('search.ai.loading_status_message')
|
||||
: t('search.ai.done_loading_status_message')}
|
||||
</span>
|
||||
{initialLoading ? (
|
||||
<div className={styles.loadingContainer} role="status">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<article aria-busy={responseLoading} aria-live="polite">
|
||||
<span ref={disclaimerRef} className={styles.disclaimerText}>
|
||||
{t('search.ai.disclaimer')}
|
||||
</span>
|
||||
<UnrenderedMarkdownContent
|
||||
className={styles.markdownBodyOverrides}
|
||||
eventGroupKey={ASK_AI_EVENT_GROUP}
|
||||
eventGroupId={eventGroupId.current}
|
||||
>
|
||||
{responseLoading ? fixIncompleteMarkdown(message) : message}
|
||||
</UnrenderedMarkdownContent>
|
||||
</article>
|
||||
)}
|
||||
{!responseLoading ? (
|
||||
<div className={styles.postAnswerWidgets}>
|
||||
<IconButton
|
||||
icon={ThumbsupIcon}
|
||||
className={'btn-octicon'}
|
||||
aria-label={t('ai.thumbs_up')}
|
||||
sx={{
|
||||
border: 'none',
|
||||
backgroundColor: feedbackSelected === 'up' ? '' : 'unset',
|
||||
boxShadow: 'unset',
|
||||
color: feedbackSelected === 'up' ? 'var(--fgColor-accent) !important;' : '',
|
||||
}}
|
||||
onClick={() => {
|
||||
setFeedbackSelected('up')
|
||||
announce(t('ai.thumbs_announcement'))
|
||||
sendEvent({
|
||||
type: EventType.survey,
|
||||
survey_vote: true,
|
||||
eventGroupKey: ASK_AI_EVENT_GROUP,
|
||||
eventGroupId: eventGroupId.current,
|
||||
})
|
||||
}}
|
||||
></IconButton>
|
||||
<IconButton
|
||||
icon={ThumbsdownIcon}
|
||||
className={'btn-octicon'}
|
||||
aria-label={t('ai.thumbs_down')}
|
||||
sx={{
|
||||
border: 'none',
|
||||
backgroundColor: feedbackSelected === 'down' ? '' : 'unset',
|
||||
boxShadow: 'unset',
|
||||
color: feedbackSelected === 'down' ? 'var(--fgColor-accent) !important;' : '',
|
||||
}}
|
||||
onClick={() => {
|
||||
setFeedbackSelected('down')
|
||||
announce(t('ai.thumbs_announcement'))
|
||||
sendEvent({
|
||||
type: EventType.survey,
|
||||
survey_vote: false,
|
||||
eventGroupKey: ASK_AI_EVENT_GROUP,
|
||||
eventGroupId: eventGroupId.current,
|
||||
})
|
||||
}}
|
||||
></IconButton>
|
||||
<IconButton
|
||||
sx={{
|
||||
border: 'none',
|
||||
backgroundColor: 'unset',
|
||||
boxShadow: 'unset',
|
||||
color: isCopied ? 'var(--fgColor-accent) !important;' : '',
|
||||
}}
|
||||
icon={isCopied ? CheckIcon : CopyIcon}
|
||||
className="btn-octicon"
|
||||
aria-label={t('ai.copy_answer')}
|
||||
onClick={() => {
|
||||
setCopied()
|
||||
announce(t('ai.copied_announcement'))
|
||||
sendEvent({
|
||||
type: EventType.clipboard,
|
||||
clipboard_operation: 'copy',
|
||||
eventGroupKey: ASK_AI_EVENT_GROUP,
|
||||
eventGroupId: eventGroupId.current,
|
||||
})
|
||||
}}
|
||||
></IconButton>
|
||||
</div>
|
||||
) : null}
|
||||
{sources && sources.length > 0 ? (
|
||||
<>
|
||||
<ActionList.Divider aria-hidden="true" />
|
||||
<ActionList className={styles.referencesList}>
|
||||
<ActionList.Group>
|
||||
<ActionList.GroupHeading
|
||||
as="h2"
|
||||
aria-label={t('search.ai.references')}
|
||||
className={styles.referencesTitle}
|
||||
>
|
||||
{t('search.ai.references')}
|
||||
</ActionList.GroupHeading>
|
||||
{sources.map((source, index) => (
|
||||
<ActionList.LinkItem
|
||||
key={index}
|
||||
target="_blank"
|
||||
href={`https://docs.github.com${source.index}`}
|
||||
data-group-key={ASK_AI_EVENT_GROUP}
|
||||
data-group-id={eventGroupId.current}
|
||||
>
|
||||
<ActionList.LeadingVisual aria-hidden="true">
|
||||
<BookIcon />
|
||||
</ActionList.LeadingVisual>
|
||||
{source.title}
|
||||
</ActionList.LinkItem>
|
||||
))}
|
||||
</ActionList.Group>
|
||||
</ActionList>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function sendAISearchResultEvent(
|
||||
sources: Array<{ url: string }>,
|
||||
message: string,
|
||||
eventGroupId: string,
|
||||
) {
|
||||
let searchResultLinksJson = '[]'
|
||||
try {
|
||||
searchResultLinksJson = generateAiSearchLinksJson(sources, message)
|
||||
} catch (e) {
|
||||
console.error('Failed to generate search result links JSON:', e)
|
||||
}
|
||||
sendEvent({
|
||||
type: EventType.aiSearchResult,
|
||||
// TODO: Remove PII so we can include the actual data
|
||||
ai_search_result_query: 'REDACTED',
|
||||
ai_search_result_response: 'REDACTED',
|
||||
ai_search_result_links_json: searchResultLinksJson,
|
||||
eventGroupKey: ASK_AI_EVENT_GROUP,
|
||||
eventGroupId: eventGroupId,
|
||||
})
|
||||
}
|
||||
@@ -5,18 +5,19 @@ import { SearchIcon } from '@primer/octicons-react'
|
||||
|
||||
import { useTranslation } from 'src/languages/components/useTranslation'
|
||||
import { DEFAULT_VERSION, useVersion } from 'src/versions/components/useVersion'
|
||||
import { useQuery } from 'src/search/components/useQuery'
|
||||
import { useBreakpoint } from 'src/search/components/useBreakpoint'
|
||||
import { useQuery } from 'src/search/components/hooks/useQuery'
|
||||
import { useBreakpoint } from 'src/search/components/hooks/useBreakpoint'
|
||||
import { sendEvent } from 'src/events/components/events'
|
||||
import { EventType } from 'src/events/types'
|
||||
import { GENERAL_SEARCH_CONTEXT } from '../helpers/execute-search-actions'
|
||||
|
||||
type Props = { isSearchOpen: boolean }
|
||||
|
||||
export function Search({ isSearchOpen }: Props) {
|
||||
export function OldSearchInput({ isSearchOpen }: Props) {
|
||||
const router = useRouter()
|
||||
const { query, debug } = useQuery()
|
||||
const [localQuery, setLocalQuery] = useState(query)
|
||||
const { t } = useTranslation('search')
|
||||
const { t } = useTranslation('old_search')
|
||||
const { currentVersion } = useVersion()
|
||||
const atMediumViewport = useBreakpoint('medium')
|
||||
|
||||
@@ -56,6 +57,7 @@ export function Search({ isSearchOpen }: Props) {
|
||||
sendEvent({
|
||||
type: EventType.search,
|
||||
search_query: localQuery,
|
||||
search_context: GENERAL_SEARCH_CONTEXT,
|
||||
})
|
||||
|
||||
redirectSearch()
|
||||
10
src/search/components/input/README.md
Normal file
10
src/search/components/input/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Search Input
|
||||
|
||||
This directory contains the view logic (React components) for:
|
||||
|
||||
- The search button (that looks like an input)
|
||||
- The search overlay (that pops up when you press the search button)
|
||||
- The search overlay shows autocomplete suggestions as the user types
|
||||
- If the user selects a general search option, we use [../results](../results) to render a new page with results
|
||||
- If the user selects an "Ask AI" search option, we show AI Results
|
||||
- AI Results: This component "takes over" a large part of the search overlay after a user asks AI a question.
|
||||
168
src/search/components/input/SearchBarButton.module.scss
Normal file
168
src/search/components/input/SearchBarButton.module.scss
Normal file
@@ -0,0 +1,168 @@
|
||||
@use "./variables.scss" as searchVariables;
|
||||
|
||||
@import "@primer/css/support/variables/layout.scss";
|
||||
@import "@primer/css/support/mixins/layout.scss";
|
||||
|
||||
// Shown at smaller widths, just a button
|
||||
.searchIconButton {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
|
||||
@include breakpoint(sm) {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
@include breakpoint(md) {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
@include breakpoint(lg) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@include breakpoint(xl) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Only shown at larger widths, a button that looks like an input
|
||||
.searchInputButton {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
align-items: center;
|
||||
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background-color: var(
|
||||
--bgColor-default,
|
||||
var(--color-canvas-default, #ffffff)
|
||||
) !important;
|
||||
|
||||
display: none;
|
||||
width: searchVariables.$smHeaderSearchInputWidth !important;
|
||||
|
||||
@include breakpoint(sm) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@include breakpoint(md) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@include breakpoint(lg) {
|
||||
display: flex;
|
||||
width: searchVariables.$lgHeaderSearchInputWidth !important;
|
||||
}
|
||||
|
||||
@include breakpoint(xl) {
|
||||
display: flex;
|
||||
width: searchVariables.$xlHeaderSearchInputWidth !important;
|
||||
}
|
||||
}
|
||||
|
||||
.searchInputContainer {
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 2rem;
|
||||
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
|
||||
background-color: var(
|
||||
--bgColor-default,
|
||||
var(--color-canvas-default, #ffffff)
|
||||
) !important;
|
||||
|
||||
border: 1px solid
|
||||
var(
|
||||
--control-borderColor-rest,
|
||||
var(--borderColor-default, var(--color-border-default, #d0d7de))
|
||||
);
|
||||
|
||||
border-radius: 6px;
|
||||
border-bottom-right-radius: unset;
|
||||
border-top-right-radius: unset;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.searchInputContainer svg {
|
||||
margin-left: 12px;
|
||||
overflow: visible !important;
|
||||
|
||||
width: 16;
|
||||
height: 16;
|
||||
|
||||
fill: currentColor;
|
||||
color: var(--fgColor-muted, var(--color-fg-muted, #656d76));
|
||||
}
|
||||
|
||||
.queryText {
|
||||
line-height: 2rem;
|
||||
margin-left: var(--base-size-8, 8px) !important;
|
||||
font-size: var(--h5-size, 14px) !important;
|
||||
|
||||
/* Hide overflow */
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
justify-self: start;
|
||||
min-width: 0; /* Essential for proper truncation in some browsers */
|
||||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: var(--fgColor-muted, var(--color-fg-muted, #656d76));
|
||||
font-weight: var(--base-text-weight-normal, 400) !important;
|
||||
}
|
||||
|
||||
.searchIconContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
// Should be "flat" next to search bar
|
||||
border: 1px solid;
|
||||
border-radius: 6px;
|
||||
border-top-left-radius: unset;
|
||||
border-bottom-left-radius: unset;
|
||||
|
||||
min-width: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
|
||||
color: var(--fgColor-muted, var(--color-fg-muted, #656d76));
|
||||
border-color: var(
|
||||
--button-default-borderColor-rest,
|
||||
var(
|
||||
--button-default-borderColor-rest,
|
||||
var(--color-btn-border, rgba(31, 35, 40, 0.15))
|
||||
)
|
||||
);
|
||||
background-color: var(
|
||||
--button-default-bgColor-rest,
|
||||
var(--color-btn-bg, #f6f8fa)
|
||||
);
|
||||
box-shadow: var(
|
||||
--button-default-shadow-resting,
|
||||
var(--color-btn-shadow, 0 1px 0 rgba(31, 35, 40, 0.04))
|
||||
),
|
||||
var(
|
||||
--button-default-shadow-inset,
|
||||
var(--color-btn-inset-shadow, inset 0 1px 0 rgba(255, 255, 255, 0.25))
|
||||
);
|
||||
}
|
||||
|
||||
.searchIconContainer svg {
|
||||
overflow: visible !important;
|
||||
|
||||
width: 16;
|
||||
height: 16;
|
||||
|
||||
fill: currentColor;
|
||||
color: var(--fgColor-muted, var(--color-fg-muted, #656d76));
|
||||
}
|
||||
107
src/search/components/input/SearchBarButton.tsx
Normal file
107
src/search/components/input/SearchBarButton.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useRef } from 'react'
|
||||
import cx from 'classnames'
|
||||
import { IconButton, Token } from '@primer/react'
|
||||
import { SearchIcon, SparklesFillIcon } from '@primer/octicons-react'
|
||||
|
||||
import { useTranslation } from 'src/languages/components/useTranslation'
|
||||
import { SearchOverlay } from './SearchOverlay'
|
||||
|
||||
import styles from './SearchBarButton.module.scss'
|
||||
import { useQueryParam } from '@/frame/components/hooks/useQueryParam'
|
||||
|
||||
type Props = {
|
||||
isSearchOpen: boolean
|
||||
setIsSearchOpen: (value: boolean) => void
|
||||
}
|
||||
|
||||
export function SearchBarButton({ isSearchOpen, setIsSearchOpen }: Props) {
|
||||
const { t } = useTranslation('search')
|
||||
const {
|
||||
debug,
|
||||
queryParam: urlSearchInputQuery,
|
||||
setQueryParam: setUrlSearchInputQuery,
|
||||
} = useQueryParam('search-overlay-input')
|
||||
const { queryParam: isAskAIState, setQueryParam: setIsAskAIState } = useQueryParam(
|
||||
'search-overlay-ask-ai',
|
||||
true,
|
||||
)
|
||||
const buttonRef = useRef(null)
|
||||
|
||||
// Handle click events
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
setIsSearchOpen(true)
|
||||
}
|
||||
|
||||
// Handle key down events
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === 'Enter' || event.key === 'Space') {
|
||||
event.preventDefault()
|
||||
setIsSearchOpen(true)
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setIsSearchOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* We don't want to show the input when overlay is open */}
|
||||
{!isSearchOpen ? (
|
||||
<>
|
||||
{/* On mobile only the IconButton is shown */}
|
||||
<IconButton
|
||||
data-testid="mobile-search-button"
|
||||
ref={buttonRef}
|
||||
className={styles.searchIconButton}
|
||||
onClick={handleClick}
|
||||
tabIndex={0}
|
||||
aria-label={t('search.input.aria_label')}
|
||||
icon={SearchIcon}
|
||||
/>
|
||||
{/* On large and up the SearchBarButton is shown */}
|
||||
<button
|
||||
data-testid="search"
|
||||
tabIndex={0}
|
||||
aria-label={t`search.input.aria_label`}
|
||||
className={styles.searchInputButton}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={handleClick}
|
||||
ref={buttonRef}
|
||||
>
|
||||
{/* Styled to look like an input */}
|
||||
<div
|
||||
className={cx('d-flex align-items-center flex-grow-1', styles.searchInputContainer)}
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
>
|
||||
<SparklesFillIcon aria-hidden className="mr-1" />
|
||||
<Token aria-hidden as="span" text={t('search.input.experimental_tag')} />
|
||||
<span
|
||||
className={cx(styles.queryText, !urlSearchInputQuery ? styles.placeholder : null)}
|
||||
>
|
||||
{urlSearchInputQuery ? urlSearchInputQuery : t('search.input.placeholder')}
|
||||
</span>
|
||||
</div>
|
||||
<span className={styles.searchIconContainer} aria-hidden tabIndex={-1}>
|
||||
<SearchIcon />
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<SearchOverlay
|
||||
searchOverlayOpen={isSearchOpen}
|
||||
parentRef={buttonRef}
|
||||
debug={debug}
|
||||
urlSearchInputQuery={urlSearchInputQuery}
|
||||
setUrlSearchInputQuery={setUrlSearchInputQuery}
|
||||
isAskAIState={isAskAIState}
|
||||
setIsAskAIState={setIsAskAIState}
|
||||
onClose={() => {
|
||||
setIsSearchOpen(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
99
src/search/components/input/SearchOverlay.module.scss
Normal file
99
src/search/components/input/SearchOverlay.module.scss
Normal file
@@ -0,0 +1,99 @@
|
||||
@use "./variables.scss" as searchVariables;
|
||||
|
||||
@import "@primer/css/support/variables/layout.scss";
|
||||
@import "@primer/css/support/mixins/layout.scss";
|
||||
|
||||
.overlayBackdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(
|
||||
--overlay-backdrop-bgColor,
|
||||
var(--color-primer-fg-canvas-backdrop, rgba(31, 35, 40, 0.5))
|
||||
);
|
||||
z-index: 1000; /* Ensure it's above other content other than overlay */
|
||||
}
|
||||
|
||||
.overlayContainer {
|
||||
z-index: 1001; /* Above the backdrop */
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: searchVariables.$smSearchOverlayWidth !important;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
@include breakpoint(sm) {
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: searchVariables.$smSearchOverlayWidth !important;
|
||||
}
|
||||
|
||||
@include breakpoint(md) {
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: searchVariables.$mdSearchOverlayWidth !important;
|
||||
}
|
||||
|
||||
@include breakpoint(lg) {
|
||||
// Using header padding: 8px (p-2 padding) x2
|
||||
top: 16px !important;
|
||||
left: calc(50vw - searchVariables.$lgSearchOverlayWidth / 2) !important;
|
||||
width: searchVariables.$lgSearchOverlayWidth !important;
|
||||
}
|
||||
|
||||
@include breakpoint(xl) {
|
||||
top: 16px !important;
|
||||
left: calc(50vw - searchVariables.$xlSearchOverlayWidth / 2) !important;
|
||||
width: searchVariables.$xlSearchOverlayWidth !important;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 12px 14px 0px 14px !important;
|
||||
width: 100%;
|
||||
background-color: var(--overlay-bgColor) !important;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 5px 16px 13px 16px;
|
||||
width: 100%;
|
||||
background-color: var(--overlay-bgColor);
|
||||
}
|
||||
|
||||
.betaToken {
|
||||
color: var(--fgColor-success, var(--fgColor-open, green)) !important;
|
||||
background-color: var(--overlay-bgColor);
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.suggestionsList {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
// In the rare viewport case where the results are taller than the viewport, scroll
|
||||
max-height: 95vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 4px 0 4px 0 !important;
|
||||
}
|
||||
|
||||
.errorBanner {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
609
src/search/components/input/SearchOverlay.tsx
Normal file
609
src/search/components/input/SearchOverlay.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
RefObject,
|
||||
useEffect,
|
||||
KeyboardEvent,
|
||||
SetStateAction,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import cx from 'classnames'
|
||||
import { useRouter } from 'next/router'
|
||||
import {
|
||||
ActionList,
|
||||
Box,
|
||||
Header,
|
||||
Link,
|
||||
Overlay,
|
||||
Spinner,
|
||||
Stack,
|
||||
TextInput,
|
||||
Token,
|
||||
} from '@primer/react'
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
SearchIcon,
|
||||
XCircleFillIcon,
|
||||
SparklesFillIcon,
|
||||
ChevronLeftIcon,
|
||||
} from '@primer/octicons-react'
|
||||
|
||||
import { useTranslation } from 'src/languages/components/useTranslation'
|
||||
import { useVersion } from 'src/versions/components/useVersion'
|
||||
import { executeGeneralSearch } from '../helpers/execute-search-actions'
|
||||
|
||||
import styles from './SearchOverlay.module.scss'
|
||||
import { Banner } from '@primer/react/drafts'
|
||||
import { AutocompleteSearchHit } from '@/search/types'
|
||||
import { useAISearchAutocomplete } from '@/search/components/hooks/useAISearchAutocomplete'
|
||||
import { AskAIResults } from './AskAIResults'
|
||||
import { uuidv4 } from '@/events/components/events'
|
||||
|
||||
type Props = {
|
||||
searchOverlayOpen: boolean
|
||||
parentRef: RefObject<HTMLElement>
|
||||
debug: boolean
|
||||
urlSearchInputQuery: string
|
||||
setUrlSearchInputQuery: (value: string) => void
|
||||
isAskAIState: boolean
|
||||
setIsAskAIState: (value: boolean) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
// Upon clicking the SearchInput component this overlay will be displayed
|
||||
export function SearchOverlay({
|
||||
searchOverlayOpen,
|
||||
parentRef,
|
||||
debug,
|
||||
urlSearchInputQuery,
|
||||
setUrlSearchInputQuery,
|
||||
isAskAIState,
|
||||
setIsAskAIState,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('search')
|
||||
const { currentVersion } = useVersion()
|
||||
const router = useRouter()
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const suggestionsListHeightRef = useRef<HTMLUListElement>(null)
|
||||
// We need an array of refs to the list elements so we can focus them when the user uses the arrow keys
|
||||
const listElementsRef = React.useRef<Array<HTMLLIElement | null>>([])
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState<number>(0)
|
||||
const [aiQuery, setAiQuery] = useState<string>(urlSearchInputQuery)
|
||||
const [aiSearchError, setAISearchError] = useState<boolean>(false)
|
||||
|
||||
// Group all events between open / close of the overlay together
|
||||
const searchEventGroupId = useRef<string>('')
|
||||
useEffect(() => {
|
||||
searchEventGroupId.current = uuidv4()
|
||||
}, [searchOverlayOpen])
|
||||
|
||||
const {
|
||||
autoCompleteOptions,
|
||||
searchLoading,
|
||||
searchError: autoCompleteSearchError,
|
||||
updateAutocompleteResults,
|
||||
clearAutocompleteResults,
|
||||
} = useAISearchAutocomplete({
|
||||
router,
|
||||
currentVersion,
|
||||
debug,
|
||||
eventGroupIdRef: searchEventGroupId,
|
||||
})
|
||||
|
||||
const { aiAutocompleteOptions } = autoCompleteOptions
|
||||
|
||||
// Filter out any options that match the local query and replace them with a custom user query option that include isUserQuery: true
|
||||
const filteredAiOptions = aiAutocompleteOptions.filter(
|
||||
(option) => option.term !== urlSearchInputQuery,
|
||||
)
|
||||
|
||||
// Create new arrays that prepend the user input
|
||||
const userInputOptions =
|
||||
urlSearchInputQuery.trim() !== ''
|
||||
? [{ term: urlSearchInputQuery, highlights: [], isUserQuery: true }]
|
||||
: []
|
||||
const generalOptionsWithUserInput = [...userInputOptions]
|
||||
const aiOptionsWithUserInput = [...userInputOptions, ...filteredAiOptions]
|
||||
|
||||
// Combine options for key navigation
|
||||
const combinedOptions = [] as Array<{
|
||||
group: 'general' | 'ai' | string
|
||||
option: AutocompleteSearchHitWithUserQuery
|
||||
}>
|
||||
// On AI Error, don't include AI suggestions, only user input
|
||||
if (!aiSearchError) {
|
||||
combinedOptions.push(...aiOptionsWithUserInput.map((option) => ({ group: 'ai', option })))
|
||||
}
|
||||
// NOTE: Order of combinedOptions is important, since 'selectedIndex' is used to navigate the combinedOptions array
|
||||
// Add general options after AI options
|
||||
combinedOptions.push(
|
||||
...generalOptionsWithUserInput.map((option) => ({ group: 'general', option })),
|
||||
)
|
||||
|
||||
// Fetch initial search results on open
|
||||
useEffect(() => {
|
||||
if (searchOverlayOpen && !isAskAIState) {
|
||||
searchEventGroupId.current = uuidv4()
|
||||
updateAutocompleteResults(urlSearchInputQuery)
|
||||
}
|
||||
return () => {
|
||||
clearAutocompleteResults()
|
||||
}
|
||||
// We need to update when isAskAIState changes, because we might start a session in the "Ask AI" state, and then switch to the "Search" state
|
||||
// In this scenario we don't have pre-existing autocomplete results to show, so we need to fetch them
|
||||
// Additionally, the query may change in the "Ask AI" state, so we need to update the results when we switch back to the "Search" state
|
||||
}, [searchOverlayOpen, updateAutocompleteResults, clearAutocompleteResults, isAskAIState])
|
||||
|
||||
// For keyboard controls, we need to use a ref for the list elements that updates when the options change
|
||||
useEffect(() => {
|
||||
listElementsRef.current = listElementsRef.current.slice(
|
||||
0,
|
||||
generalOptionsWithUserInput.length + aiOptionsWithUserInput.length,
|
||||
)
|
||||
}, [generalOptionsWithUserInput, aiOptionsWithUserInput])
|
||||
|
||||
// When loading, capture the last height of the suggestions list so we can use it for the loading div
|
||||
const previousSuggestionsListHeight = useMemo(() => {
|
||||
if (suggestionsListHeightRef.current?.clientHeight) {
|
||||
return suggestionsListHeightRef.current.clientHeight
|
||||
} else {
|
||||
return '250' // Default height that looks very close to 5 suggestions (in px)
|
||||
}
|
||||
}, [searchLoading])
|
||||
|
||||
// When the user types in the search input, update the local query and fetch autocomplete results
|
||||
const handleSearchQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newQuery = event.target.value
|
||||
setUrlSearchInputQuery(newQuery)
|
||||
setSelectedIndex(0) // Reset selected index when query changes
|
||||
// We don't need to fetch autocomplete results when asking the AI
|
||||
if (!isAskAIState) {
|
||||
updateAutocompleteResults(newQuery)
|
||||
}
|
||||
}
|
||||
|
||||
// When a general option is selected, execute the search and close the overlay since general search results are in a new page
|
||||
const generalSearchOptionOnSelect = (selectedOption: AutocompleteSearchHit) => {
|
||||
if (selectedOption.term) {
|
||||
executeGeneralSearch(
|
||||
router,
|
||||
currentVersion,
|
||||
selectedOption.term,
|
||||
debug,
|
||||
searchEventGroupId.current,
|
||||
)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
// When an AI option is selected, set the AI query and focus the input since ask AI results replace the suggestions
|
||||
const aiSearchOptionOnSelect = (selectedOption: AutocompleteSearchHit) => {
|
||||
if (selectedOption.term) {
|
||||
setIsAskAIState(true)
|
||||
setUrlSearchInputQuery(selectedOption.term)
|
||||
setAiQuery(selectedOption.term)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
// On keydown can be called from the input or a list item
|
||||
// In either case, we want to deal with both focus & selection when up, down, or enter are pressed
|
||||
const handleKeyDown = (
|
||||
event: React.KeyboardEvent<HTMLElement>,
|
||||
// Passed when called from a list item's handler
|
||||
manuallyPassedIndex?: number,
|
||||
) => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
if (combinedOptions.length > 0) {
|
||||
let newIndex = 0
|
||||
if (typeof manuallyPassedIndex !== 'undefined') {
|
||||
newIndex = (manuallyPassedIndex + 1) % combinedOptions.length
|
||||
} else {
|
||||
newIndex = (selectedIndex + 1) % combinedOptions.length
|
||||
}
|
||||
setSelectedIndex(newIndex)
|
||||
if (listElementsRef.current?.[newIndex]) {
|
||||
listElementsRef.current?.[newIndex]?.focus()
|
||||
}
|
||||
}
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
if (manuallyPassedIndex === 0 || selectedIndex === 0) {
|
||||
// Focus the input when the first item is selected
|
||||
inputRef.current?.focus()
|
||||
} else if (combinedOptions.length > 0) {
|
||||
let newIndex = 0
|
||||
if (typeof manuallyPassedIndex !== 'undefined') {
|
||||
newIndex = (manuallyPassedIndex - 1 + combinedOptions.length) % combinedOptions.length
|
||||
} else {
|
||||
newIndex = (selectedIndex - 1 + combinedOptions.length) % combinedOptions.length
|
||||
}
|
||||
setSelectedIndex(newIndex)
|
||||
if (listElementsRef.current?.[newIndex]) {
|
||||
listElementsRef.current?.[newIndex]?.focus()
|
||||
}
|
||||
} else {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
} else if (event.key === 'Enter') {
|
||||
// When AI Search is already open, ask subsequent queries
|
||||
if (isAskAIState && !aiSearchError) {
|
||||
if (isAskAIState && urlSearchInputQuery === aiQuery) {
|
||||
// User has typed same query and pressed enter. Do nothing
|
||||
return
|
||||
} else {
|
||||
event.preventDefault()
|
||||
return aiSearchOptionOnSelect({ term: urlSearchInputQuery } as AutocompleteSearchHit)
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
if (
|
||||
combinedOptions.length > 0 &&
|
||||
selectedIndex >= 0 &&
|
||||
selectedIndex < combinedOptions.length
|
||||
) {
|
||||
const selectedItem = combinedOptions[selectedIndex]
|
||||
if (selectedItem.group === 'general') {
|
||||
generalSearchOptionOnSelect(selectedItem.option)
|
||||
} else if (selectedItem.group === 'ai') {
|
||||
aiSearchOptionOnSelect(selectedItem.option)
|
||||
}
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onClose() // Close the input overlay when Escape is pressed
|
||||
}
|
||||
}
|
||||
|
||||
// We display different content in the overlay based:
|
||||
// 1. If either search (autocomplete results or ask AI) has an error
|
||||
// 2. The user has selected an AI query and we are showing the ask AI results
|
||||
// 3. The search is loading
|
||||
// 4. Otherwise, we show the autocomplete suggestions
|
||||
let OverlayContents = null
|
||||
// We can still ask AI if there is an autocomplete search error
|
||||
const inErrorState = aiSearchError || (autoCompleteSearchError && !isAskAIState)
|
||||
if (inErrorState) {
|
||||
OverlayContents = (
|
||||
<>
|
||||
<ActionList
|
||||
aria-label={t('search.overlay.suggestions_list_aria_label')}
|
||||
showDividers
|
||||
selectionVariant="single"
|
||||
className={styles.suggestionsList}
|
||||
ref={suggestionsListHeightRef}
|
||||
>
|
||||
{renderSearchGroups(
|
||||
t,
|
||||
autoCompleteSearchError ? userInputOptions : generalOptionsWithUserInput,
|
||||
aiSearchError ? [] : aiOptionsWithUserInput,
|
||||
generalSearchOptionOnSelect,
|
||||
aiSearchOptionOnSelect,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
listElementsRef,
|
||||
handleKeyDown,
|
||||
)}
|
||||
</ActionList>
|
||||
{/* Always show the AI Search UI error message when it is needed */}
|
||||
{aiSearchError && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<ActionList.GroupHeading
|
||||
as="h3"
|
||||
tabIndex={-1}
|
||||
aria-label={t('search.overlay.ai_suggestions_list_aria_label')}
|
||||
>
|
||||
<SparklesFillIcon className="mr-1" />
|
||||
{t('search.overlay.ai_autocomplete_list_heading')}
|
||||
</ActionList.GroupHeading>
|
||||
<Banner
|
||||
tabIndex={0}
|
||||
className={styles.errorBanner}
|
||||
title={t('search.failure.ai_title')}
|
||||
description={t('search.failure.description')}
|
||||
variant="info"
|
||||
aria-live="assertive"
|
||||
role="alert"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Only show the autocomplete search UI error message in Dev */}
|
||||
{process.env.NODE_ENV === 'development' && autoCompleteSearchError && !aiSearchError && (
|
||||
<Banner
|
||||
tabIndex={0}
|
||||
className={styles.errorBanner}
|
||||
title={t('search.failure.autocomplete_title')}
|
||||
description={t('search.failure.description')}
|
||||
variant="info"
|
||||
aria-live="assertive"
|
||||
role="alert"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
} else if (isAskAIState) {
|
||||
OverlayContents = (
|
||||
<AskAIResults
|
||||
query={aiQuery}
|
||||
debug={debug}
|
||||
version={currentVersion}
|
||||
setAISearchError={() => {
|
||||
setAISearchError(true)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
} else if (searchLoading) {
|
||||
OverlayContents = (
|
||||
<Box
|
||||
role="status"
|
||||
className={styles.loadingContainer}
|
||||
sx={{
|
||||
height: `${previousSuggestionsListHeight}px`,
|
||||
}}
|
||||
>
|
||||
<Spinner />
|
||||
</Box>
|
||||
)
|
||||
} else {
|
||||
OverlayContents = (
|
||||
<ActionList
|
||||
aria-label={t('search.overlay.suggestions_list_aria_label')}
|
||||
showDividers
|
||||
selectionVariant="single"
|
||||
className={styles.suggestionsList}
|
||||
ref={suggestionsListHeightRef}
|
||||
>
|
||||
{renderSearchGroups(
|
||||
t,
|
||||
generalOptionsWithUserInput,
|
||||
aiOptionsWithUserInput,
|
||||
generalSearchOptionOnSelect,
|
||||
aiSearchOptionOnSelect,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
listElementsRef,
|
||||
handleKeyDown,
|
||||
)}
|
||||
</ActionList>
|
||||
)
|
||||
}
|
||||
|
||||
const overlayHeadingId = 'overlay-heading'
|
||||
return (
|
||||
<>
|
||||
<div className={styles.overlayBackdrop} />
|
||||
<Overlay
|
||||
initialFocusRef={inputRef}
|
||||
returnFocusRef={parentRef}
|
||||
ignoreClickRefs={[parentRef]}
|
||||
onEscape={onClose}
|
||||
onClickOutside={onClose}
|
||||
anchorSide="inside-center"
|
||||
className={cx(styles.overlayContainer, 'position-fixed')}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={overlayHeadingId}
|
||||
>
|
||||
<Header className={styles.header}>
|
||||
<TextInput
|
||||
className="width-full"
|
||||
data-testid="overlay-search-input"
|
||||
ref={inputRef}
|
||||
value={urlSearchInputQuery}
|
||||
onChange={handleSearchQueryChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
leadingVisual={
|
||||
isAskAIState ? (
|
||||
<Stack justify="center">
|
||||
<TextInput.Action
|
||||
onClick={() => {
|
||||
setIsAskAIState(false)
|
||||
}}
|
||||
icon={ChevronLeftIcon}
|
||||
aria-label={t('search.overlay.return_to_search')}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<SearchIcon />
|
||||
)
|
||||
}
|
||||
aria-labelledby={overlayHeadingId}
|
||||
placeholder={t('search.input.placeholder')}
|
||||
trailingAction={
|
||||
<Stack
|
||||
justify="center"
|
||||
sx={{
|
||||
minWidth: '34px',
|
||||
}}
|
||||
>
|
||||
<TextInput.Action
|
||||
onClick={() => {
|
||||
setUrlSearchInputQuery('')
|
||||
if (!isAskAIState) {
|
||||
updateAutocompleteResults('')
|
||||
}
|
||||
}}
|
||||
icon={XCircleFillIcon}
|
||||
aria-label={t('search.overlay.clear_search_query')}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
</Header>
|
||||
<ActionList.Divider
|
||||
sx={{
|
||||
display: inErrorState ? 'none' : 'block',
|
||||
marginTop: '16px',
|
||||
width: '100%',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{OverlayContents}
|
||||
<ActionList.Divider
|
||||
sx={{
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
<footer key="description" className={styles.footer}>
|
||||
<Token
|
||||
as="span"
|
||||
text="Beta"
|
||||
className={styles.betaToken}
|
||||
sx={{
|
||||
backgroundColor: 'var(--overlay-bg-color)',
|
||||
}}
|
||||
/>
|
||||
<Link as="button">Give Feedback</Link>
|
||||
</footer>
|
||||
</Overlay>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface AutocompleteSearchHitWithUserQuery extends AutocompleteSearchHit {
|
||||
isUserQuery?: boolean
|
||||
}
|
||||
|
||||
// Render the autocomplete suggestions with AI suggestions first, headings, and a divider between the two
|
||||
function renderSearchGroups(
|
||||
t: any,
|
||||
generalOptionsWithUserInput: AutocompleteSearchHitWithUserQuery[],
|
||||
aiOptionsWithUserInput: AutocompleteSearchHitWithUserQuery[],
|
||||
generalAutocompleteOnSelect: (selectedOption: AutocompleteSearchHit) => void,
|
||||
aiAutocompleteOnSelect: (selectedOption: AutocompleteSearchHit) => void,
|
||||
selectedIndex: number,
|
||||
setSelectedIndex: (value: SetStateAction<number>) => void,
|
||||
listElementsRef: RefObject<Array<HTMLLIElement | null>>,
|
||||
handleKeyDown: (
|
||||
event: KeyboardEvent<HTMLElement>,
|
||||
manuallyPassedIndex?: number | undefined,
|
||||
) => void,
|
||||
) {
|
||||
const groups = []
|
||||
|
||||
if (aiOptionsWithUserInput.length) {
|
||||
groups.push(
|
||||
<ActionList.Group key="ai" data-testid="ai-autocomplete-suggestions">
|
||||
<ActionList.GroupHeading
|
||||
as="h3"
|
||||
tabIndex={-1}
|
||||
aria-label={t('search.overlay.ai_suggestions_list_aria_label')}
|
||||
>
|
||||
<SparklesFillIcon className="mr-1" />
|
||||
{t('search.overlay.ai_autocomplete_list_heading')}
|
||||
</ActionList.GroupHeading>
|
||||
{aiOptionsWithUserInput.map((option: AutocompleteSearchHitWithUserQuery, index: number) => {
|
||||
const isActive = selectedIndex === index
|
||||
const item = (
|
||||
<ActionList.Item
|
||||
key={`ai-${index}`}
|
||||
className={styles.searchSuggestion}
|
||||
onClick={() => aiAutocompleteOnSelect(option)}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLLIElement>) => {
|
||||
handleKeyDown(e, index)
|
||||
}}
|
||||
onFocus={() => {
|
||||
setSelectedIndex(index)
|
||||
}}
|
||||
active={isActive}
|
||||
tabIndex={0}
|
||||
ref={(element) => {
|
||||
if (listElementsRef.current) {
|
||||
listElementsRef.current[index] = element
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ActionList.LeadingVisual aria-hidden>
|
||||
<SparklesFillIcon />
|
||||
</ActionList.LeadingVisual>
|
||||
{option.term}
|
||||
<ActionList.TrailingVisual
|
||||
aria-hidden
|
||||
sx={{
|
||||
visibility: isActive ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
<ArrowRightIcon />
|
||||
</ActionList.TrailingVisual>
|
||||
</ActionList.Item>
|
||||
)
|
||||
return item
|
||||
})}
|
||||
</ActionList.Group>,
|
||||
)
|
||||
if (generalOptionsWithUserInput.length) {
|
||||
groups.push(<ActionList.Divider key="general-divider" />)
|
||||
}
|
||||
}
|
||||
|
||||
if (generalOptionsWithUserInput.length) {
|
||||
groups.push(
|
||||
<ActionList.Group key="general" data-testid="general-autocomplete-suggestions">
|
||||
<ActionList.GroupHeading
|
||||
as="h3"
|
||||
tabIndex={-1}
|
||||
aria-label={t('search.overlay.general_suggestions_list_aria_label')}
|
||||
>
|
||||
{t('search.overlay.general_autocomplete_list_heading')}
|
||||
</ActionList.GroupHeading>
|
||||
{generalOptionsWithUserInput.map(
|
||||
(option: AutocompleteSearchHitWithUserQuery, index: number) => {
|
||||
// Since AI Search comes first, we need to add an offset for general search options
|
||||
const indexWithOffset = aiOptionsWithUserInput.length + index
|
||||
const isActive = selectedIndex === indexWithOffset
|
||||
const item = (
|
||||
<ActionList.Item
|
||||
key={`general-${indexWithOffset}`}
|
||||
className={styles.searchSuggestion}
|
||||
onClick={() => generalAutocompleteOnSelect(option)}
|
||||
onKeyDown={(e) => {
|
||||
handleKeyDown(e, indexWithOffset)
|
||||
}}
|
||||
onFocus={() => {
|
||||
setSelectedIndex(indexWithOffset)
|
||||
}}
|
||||
active={isActive}
|
||||
tabIndex={0}
|
||||
ref={(element) => {
|
||||
if (listElementsRef.current) {
|
||||
listElementsRef.current[indexWithOffset] = element
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ActionList.LeadingVisual aria-hidden>
|
||||
<SearchIcon />
|
||||
</ActionList.LeadingVisual>
|
||||
{option.term}
|
||||
<ActionList.TrailingVisual
|
||||
aria-hidden
|
||||
sx={{
|
||||
// Hold the space even when not visible to prevent layout shift
|
||||
visibility: isActive ? 'visible' : 'hidden',
|
||||
width: '1rem',
|
||||
}}
|
||||
>
|
||||
<ArrowRightIcon />
|
||||
</ActionList.TrailingVisual>
|
||||
</ActionList.Item>
|
||||
)
|
||||
return item
|
||||
},
|
||||
)}
|
||||
</ActionList.Group>,
|
||||
)
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
11
src/search/components/input/variables.scss
Normal file
11
src/search/components/input/variables.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
// Widths of the search bar button at different breakpoints
|
||||
$smHeaderSearchInputWidth: 100%; // Technically we don't show the search bar at this breakpoint
|
||||
$mdHeaderSearchInputWidth: 100%; // Technically we don't show the search bar at this breakpoint
|
||||
$lgHeaderSearchInputWidth: 30rem;
|
||||
$xlHeaderSearchInputWidth: 40rem;
|
||||
|
||||
// Widths of the search overlay popup at different breakpoints
|
||||
$smSearchOverlayWidth: 100vw;
|
||||
$mdSearchOverlayWidth: 100vw;
|
||||
$lgSearchOverlayWidth: 40rem;
|
||||
$xlSearchOverlayWidth: 50rem;
|
||||
@@ -4,7 +4,7 @@ import { useMainContext } from 'src/frame/components/context/MainContext'
|
||||
import { useTranslation } from 'src/languages/components/useTranslation'
|
||||
|
||||
export function NoQuery() {
|
||||
const { t } = useTranslation(['search'])
|
||||
const { t } = useTranslation('old_search')
|
||||
const mainContext = useMainContext()
|
||||
// Use TypeScript's "not null assertion" because `context.page` should
|
||||
// will present in main context if it's gotten to the stage of React
|
||||
6
src/search/components/results/README.md
Normal file
6
src/search/components/results/README.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Search Results
|
||||
|
||||
This directory contains the view logic (React components) for:
|
||||
|
||||
- Search Results: When a user performs a general search, we show this page with a list of search results
|
||||
- Sidebar Aggregates: On larger widths we show the "categories" or "aggregates" of the search results that a user can select to filter their results
|
||||
@@ -14,6 +14,7 @@ import styles from './SearchResults.module.scss'
|
||||
import type { SearchQueryContentT } from 'src/search/components/types'
|
||||
import type { GeneralSearchHitWithoutIncludes, GeneralSearchResponse } from 'src/search/types'
|
||||
import type { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types'
|
||||
import { GENERAL_SEARCH_RESULTS } from '@/events/components/event-groups'
|
||||
|
||||
type Props = {
|
||||
results: GeneralSearchResponse
|
||||
@@ -105,6 +106,7 @@ function SearchResultHit({
|
||||
href={hit.url}
|
||||
className="color-fg-accent search-result-link"
|
||||
dangerouslySetInnerHTML={{ __html: title }}
|
||||
data-group-key={GENERAL_SEARCH_RESULTS}
|
||||
onClick={() => {
|
||||
sendEvent({
|
||||
type: EventType.searchResult,
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useSearchContext } from './context/SearchContext'
|
||||
import { useSearchContext } from '../context/SearchContext'
|
||||
import { SearchResultsAggregations } from './Aggregations'
|
||||
|
||||
export function SidebarSearchAggregates() {
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Flash } from '@primer/react'
|
||||
|
||||
import { useTranslation } from 'src/languages/components/useTranslation'
|
||||
import type { SearchValidationErrorEntry } from '../types'
|
||||
import type { SearchValidationErrorEntry } from '../../types'
|
||||
|
||||
interface Props {
|
||||
errors: SearchValidationErrorEntry[]
|
||||
@@ -3,11 +3,11 @@ import { Heading } from '@primer/react'
|
||||
|
||||
import { useTranslation } from 'src/languages/components/useTranslation'
|
||||
import { DEFAULT_VERSION, useVersion } from 'src/versions/components/useVersion'
|
||||
import { useNumberFormatter } from 'src/search/components/useNumberFormatter'
|
||||
import { SearchResults } from 'src/search/components/SearchResults'
|
||||
import { NoQuery } from 'src/search/components/NoQuery'
|
||||
import { useNumberFormatter } from 'src/search/components/hooks/useNumberFormatter'
|
||||
import { SearchResults } from 'src/search/components/results/SearchResults'
|
||||
import { NoQuery } from 'src/search/components/results/NoQuery'
|
||||
import { useMainContext } from 'src/frame/components/context/MainContext'
|
||||
import { ValidationErrors } from 'src/search/components/ValidationErrors'
|
||||
import { ValidationErrors } from 'src/search/components/results/ValidationErrors'
|
||||
import { useSearchContext } from 'src/search/components/context/SearchContext'
|
||||
import type { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types'
|
||||
|
||||
@@ -40,29 +40,48 @@ export const aiSearchProxy = async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = got.post(`${process.env.CSE_COPILOT_ENDPOINT}/answers`, {
|
||||
const stream = got.stream.post(`${process.env.CSE_COPILOT_ENDPOINT}/answers`, {
|
||||
json: body,
|
||||
headers: {
|
||||
Authorization: getHmacWithEpoch(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
isStream: true,
|
||||
})
|
||||
|
||||
// Set response headers
|
||||
res.setHeader('Content-Type', 'application/x-ndjson')
|
||||
res.flushHeaders()
|
||||
// Handle the upstream response before piping
|
||||
stream.on('response', (upstreamResponse) => {
|
||||
if (upstreamResponse.statusCode !== 200) {
|
||||
const errorMessage = `Upstream server responded with status code ${upstreamResponse.statusCode}`
|
||||
console.error(errorMessage)
|
||||
res.status(500).json({ errors: [{ message: errorMessage }] })
|
||||
stream.destroy()
|
||||
} else {
|
||||
// Set response headers
|
||||
res.setHeader('Content-Type', 'application/x-ndjson')
|
||||
res.flushHeaders()
|
||||
|
||||
// Pipe the got stream directly to the response
|
||||
stream.pipe(res)
|
||||
// Pipe the got stream directly to the response
|
||||
stream.pipe(res)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle stream errors
|
||||
stream.on('error', (error) => {
|
||||
stream.on('error', (error: any) => {
|
||||
console.error('Error streaming from cse-copilot:', error)
|
||||
// Only send error response if headers haven't been sent
|
||||
|
||||
if (error?.code === 'ERR_NON_2XX_3XX_RESPONSE') {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ errors: [{ message: 'Sorry I am unable to answer this question.' }] })
|
||||
}
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ errors: [{ message: 'Internal server error' }] })
|
||||
} else {
|
||||
// Send error message via the stream
|
||||
const errorMessage =
|
||||
JSON.stringify({ errors: [{ message: 'Internal server error' }] }) + '\n'
|
||||
res.write(errorMessage)
|
||||
res.end()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -14,30 +14,38 @@ export async function getAISearchAutocompleteResults({
|
||||
indexName,
|
||||
query,
|
||||
size,
|
||||
debug = false,
|
||||
}: AutocompleteResultsArgs): Promise<AutocompleteSearchResponse> {
|
||||
const t0 = new Date()
|
||||
const client = getElasticsearchClient() as Client
|
||||
|
||||
const matchQueries = getAISearchAutocompleteMatchQueries(query.trim(), {
|
||||
fuzzy: {
|
||||
minLength: 3,
|
||||
maxLength: 20,
|
||||
},
|
||||
})
|
||||
const matchQuery = {
|
||||
bool: {
|
||||
should: matchQueries,
|
||||
},
|
||||
let searchQuery: any = {
|
||||
index: indexName,
|
||||
size,
|
||||
// Send absolutely minimal from Elasticsearch to here. Less data => faster.
|
||||
_source_includes: ['term'],
|
||||
}
|
||||
|
||||
const highlight = getHighlightConfiguration(query, ['term'])
|
||||
const trimmedQuery = query.trim()
|
||||
// When the query is empty, we want to return the top `size` most popular terms
|
||||
if (trimmedQuery === '') {
|
||||
searchQuery.query = { match_all: {} }
|
||||
searchQuery.sort = [{ popularity: { order: 'desc' } }]
|
||||
} else {
|
||||
const matchQueries = getAISearchAutocompleteMatchQueries(trimmedQuery, {
|
||||
fuzzy: {
|
||||
minLength: 3,
|
||||
maxLength: 20,
|
||||
},
|
||||
})
|
||||
const matchQuery: QueryDslQueryContainer = {
|
||||
bool: {
|
||||
should: matchQueries,
|
||||
},
|
||||
}
|
||||
|
||||
const searchQuery = {
|
||||
index: indexName,
|
||||
highlight,
|
||||
size,
|
||||
query: matchQuery,
|
||||
_source_includes: ['term'],
|
||||
searchQuery.query = matchQuery
|
||||
searchQuery.highlight = getHighlightConfiguration(trimmedQuery, ['term'])
|
||||
}
|
||||
|
||||
const result = await client.search<{ term: string }>(searchQuery)
|
||||
@@ -46,6 +54,13 @@ export async function getAISearchAutocompleteResults({
|
||||
const hits = hitsAll.hits.map((hit) => ({
|
||||
term: hit._source?.term,
|
||||
highlights: (hit.highlight && hit.highlight.term) || [],
|
||||
...(debug && {
|
||||
score: hit._score ?? 0.0,
|
||||
es_url:
|
||||
process.env.NODE_ENV !== 'production'
|
||||
? `http://localhost:9200/${indexName}/_doc/${hit._id}`
|
||||
: '',
|
||||
}),
|
||||
}))
|
||||
|
||||
return {
|
||||
|
||||
@@ -15,39 +15,62 @@ export async function getAutocompleteSearchResults({
|
||||
indexName,
|
||||
query,
|
||||
size,
|
||||
debug = false,
|
||||
}: AutocompleteResultsArgs): Promise<AutocompleteSearchResponse> {
|
||||
const t0 = new Date()
|
||||
const client = getElasticsearchClient() as Client
|
||||
|
||||
const matchQueries = getAutocompleteMatchQueries(query.trim(), {
|
||||
fuzzy: {
|
||||
minLength: 3,
|
||||
maxLength: 20,
|
||||
},
|
||||
})
|
||||
const matchQuery = {
|
||||
bool: {
|
||||
should: matchQueries,
|
||||
},
|
||||
}
|
||||
|
||||
const highlight = getHighlightConfiguration(query, ['term'])
|
||||
|
||||
const searchQuery = {
|
||||
let searchQuery: any = {
|
||||
index: indexName,
|
||||
highlight,
|
||||
size,
|
||||
query: matchQuery,
|
||||
// Send absolutely minimal from Elasticsearch to here. Less data => faster.
|
||||
_source_includes: ['term'],
|
||||
}
|
||||
|
||||
const trimmedQuery = query.trim()
|
||||
// When the query is empty, return no results
|
||||
if (trimmedQuery === '') {
|
||||
return {
|
||||
meta: {
|
||||
found: {
|
||||
value: 0,
|
||||
relation: 'eq',
|
||||
},
|
||||
took: { query_msec: 0, total_msec: new Date().getTime() - t0.getTime() },
|
||||
size,
|
||||
},
|
||||
hits: [],
|
||||
}
|
||||
} else {
|
||||
const matchQueries = getAutocompleteMatchQueries(trimmedQuery, {
|
||||
fuzzy: {
|
||||
minLength: 3,
|
||||
maxLength: 20,
|
||||
},
|
||||
})
|
||||
const matchQuery: QueryDslQueryContainer = {
|
||||
bool: {
|
||||
should: matchQueries,
|
||||
},
|
||||
}
|
||||
|
||||
searchQuery.query = matchQuery
|
||||
searchQuery.highlight = getHighlightConfiguration(trimmedQuery, ['term'])
|
||||
}
|
||||
|
||||
const result = await client.search<AutocompleteElasticsearchItem>(searchQuery)
|
||||
|
||||
const hitsAll = result.hits
|
||||
const hits = hitsAll.hits.map((hit) => ({
|
||||
term: hit._source?.term,
|
||||
highlights: (hit.highlight && hit.highlight.term) || [],
|
||||
...(debug && {
|
||||
score: hit._score ?? 0.0,
|
||||
es_url:
|
||||
process.env.NODE_ENV !== 'production'
|
||||
? `http://localhost:9200/${indexName}/_doc/${hit._id}`
|
||||
: '',
|
||||
}),
|
||||
}))
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface AutocompleteResultsArgs {
|
||||
indexName: string
|
||||
query: string
|
||||
size: number
|
||||
debug?: boolean
|
||||
}
|
||||
|
||||
export interface FuzzyConfig {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Versions used by cse-copilot
|
||||
import { allVersions } from '@/versions/lib/all-versions'
|
||||
import { versionToIndexVersionMap } from '../elasticsearch-versions'
|
||||
const CSE_COPILOT_DOCS_VERSIONS = ['dotcom', 'ghec', 'ghes']
|
||||
|
||||
// Languages supported by cse-copilot
|
||||
@@ -12,7 +13,8 @@ export function getCSECopilotSource(
|
||||
version: (typeof CSE_COPILOT_DOCS_VERSIONS)[number],
|
||||
language: (typeof DOCS_LANGUAGES)[number],
|
||||
) {
|
||||
const cseCopilotDocsVersion = getMiscBaseNameFromVersion(version)
|
||||
const mappedVersion = versionToIndexVersionMap[version]
|
||||
const cseCopilotDocsVersion = getMiscBaseNameFromVersion(mappedVersion)
|
||||
if (!CSE_COPILOT_DOCS_VERSIONS.includes(cseCopilotDocsVersion)) {
|
||||
throw new Error(
|
||||
`Invalid 'version' in request body: '${version}'. Must be one of: ${CSE_COPILOT_DOCS_VERSIONS.join(', ')}`,
|
||||
@@ -23,7 +25,7 @@ export function getCSECopilotSource(
|
||||
`Invalid 'language' in request body '${language}'. Must be one of: ${DOCS_LANGUAGES.join(', ')}`,
|
||||
)
|
||||
}
|
||||
return `docs_${version}_${language}`
|
||||
return `docs_${cseCopilotDocsVersion}_${language}`
|
||||
}
|
||||
|
||||
function getMiscBaseNameFromVersion(Version: string): string {
|
||||
|
||||
120
src/search/lib/routes/combined-autocomplete-route.ts
Normal file
120
src/search/lib/routes/combined-autocomplete-route.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { getSearchFromRequestParams } from '@/search/lib/search-request-params/get-search-from-request-params'
|
||||
import { getAISearchAutocompleteResults } from '@/search/lib/get-elasticsearch-results/ai-search-autocomplete'
|
||||
import { getAutocompleteSearchResults } from '@/search/lib/get-elasticsearch-results/general-autocomplete'
|
||||
import { searchCacheControl } from '@/frame/middleware/cache-control'
|
||||
import { SURROGATE_ENUMS, setFastlySurrogateKey } from '@/frame/middleware/set-fastly-surrogate-key'
|
||||
import { handleGetSearchResultsError } from '@/search/middleware/search-routes'
|
||||
|
||||
import type { Request, Response } from 'express'
|
||||
import type { CombinedAutocompleteSearchResponse } from '@/search/types'
|
||||
|
||||
interface CacheEntry {
|
||||
timestamp: number
|
||||
data: CombinedAutocompleteSearchResponse
|
||||
}
|
||||
|
||||
// We want to cache when the query is just an empty string
|
||||
// These are the defaults that are shown when the user first opens the search overlay
|
||||
const autocompleteCache = new Map<string, CacheEntry>()
|
||||
|
||||
// Within 24 hours the top autocomplete options might be updated
|
||||
const EMPTY_QUERY_CACHE_KEY = 'emptyQueries'
|
||||
const CACHE_DURATION_MS = 24 * 60 * 60 * 1000 // 24 hours
|
||||
const size = 5
|
||||
|
||||
export async function combinedAutocompleteRoute(req: Request, res: Response) {
|
||||
const {
|
||||
indexName: aiIndexName,
|
||||
validationErrors: aiValidationErrors,
|
||||
searchParams: { query: aiQuery, debug },
|
||||
} = getSearchFromRequestParams(req, 'aiSearchAutocomplete', {
|
||||
// Force query to override validation to allow empty string
|
||||
query: typeof req.query.query !== 'string' ? '' : req.query.query,
|
||||
})
|
||||
|
||||
const {
|
||||
indexName: generalIndexName,
|
||||
validationErrors: generalValidationErrors,
|
||||
searchParams: { query: generalQuery },
|
||||
} = getSearchFromRequestParams(req, 'generalAutocomplete', {
|
||||
query: typeof req.query.query !== 'string' ? '' : req.query.query,
|
||||
})
|
||||
|
||||
const combinedValidationErrors = aiValidationErrors.concat(generalValidationErrors)
|
||||
if (combinedValidationErrors.length) {
|
||||
return res.status(400).json(combinedValidationErrors[0])
|
||||
}
|
||||
|
||||
// Check if both queries are empty
|
||||
const isEmptyQuery =
|
||||
(!aiQuery || aiQuery.trim() === '') && (!generalQuery || generalQuery.trim() === '')
|
||||
|
||||
if (isEmptyQuery) {
|
||||
const cached = autocompleteCache.get(EMPTY_QUERY_CACHE_KEY)
|
||||
const now = Date.now()
|
||||
|
||||
if (cached && now - cached.timestamp < CACHE_DURATION_MS) {
|
||||
// Serve cached results
|
||||
return res.status(200).json(cached.data)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Async fetch both results from Elasticsearch
|
||||
const [aiSearchResults, generalSearchResults] = await Promise.all([
|
||||
getAISearchAutocompleteResults({
|
||||
indexName: aiIndexName,
|
||||
query: aiQuery,
|
||||
size,
|
||||
debug,
|
||||
}),
|
||||
getAutocompleteSearchResults({
|
||||
indexName: generalIndexName,
|
||||
query: generalQuery,
|
||||
size,
|
||||
debug,
|
||||
}),
|
||||
])
|
||||
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
searchCacheControl(res)
|
||||
setFastlySurrogateKey(res, SURROGATE_ENUMS.MANUAL)
|
||||
}
|
||||
|
||||
const results: CombinedAutocompleteSearchResponse = {
|
||||
aiAutocomplete: {
|
||||
meta: aiSearchResults.meta,
|
||||
hits: aiSearchResults.hits,
|
||||
},
|
||||
generalAutocomplete: {
|
||||
meta: generalSearchResults.meta,
|
||||
hits: generalSearchResults.hits,
|
||||
},
|
||||
}
|
||||
|
||||
// If both queries are empty, cache the results
|
||||
if (isEmptyQuery) {
|
||||
autocompleteCache.set(EMPTY_QUERY_CACHE_KEY, {
|
||||
timestamp: Date.now(),
|
||||
data: results,
|
||||
})
|
||||
}
|
||||
|
||||
res.status(200).json(results)
|
||||
} catch (error) {
|
||||
await handleGetSearchResultsError(
|
||||
req,
|
||||
res,
|
||||
error,
|
||||
JSON.stringify(
|
||||
{
|
||||
indexName: { aiIndexName, generalIndexName },
|
||||
query: { aiQuery, generalQuery },
|
||||
size,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
For general search (client searches on docs.github.com) we use the middleware in ./general-search-middleware to get the search results
|
||||
*/
|
||||
// TODO: Move the routes implementations in this files to lib/routes so you can at-a-glance see all of the routes without the implementation logic
|
||||
import express, { Request, Response } from 'express'
|
||||
|
||||
import FailBot from '@/observability/lib/failbot.js'
|
||||
@@ -16,13 +17,14 @@ import { getAutocompleteSearchResults } from '@/search/lib/get-elasticsearch-res
|
||||
import { getAISearchAutocompleteResults } from '@/search/lib/get-elasticsearch-results/ai-search-autocomplete'
|
||||
import { getSearchFromRequestParams } from '@/search/lib/search-request-params/get-search-from-request-params'
|
||||
import { getGeneralSearchResults } from '@/search/lib/get-elasticsearch-results/general-search'
|
||||
import { createRateLimiter } from '#src/shielding/middleware/rate-limit.js'
|
||||
import { combinedAutocompleteRoute } from '@/search/lib/routes/combined-autocomplete-route'
|
||||
import { createRateLimiter } from '@/shielding/middleware/rate-limit.js'
|
||||
|
||||
const router = express.Router()
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
router.use(createRateLimiter(10)) // just 1 worker in dev so 10 requests per minute allowed
|
||||
} else if (process.env.NODE_ENV === 'production') {
|
||||
router.use(createRateLimiter(1)) // 1 * 25 requests per minute for prod
|
||||
router.use(createRateLimiter(30)) // 30 requests per minute allowed
|
||||
}
|
||||
|
||||
router.get('/legacy', (req: Request, res: Response) => {
|
||||
@@ -69,7 +71,7 @@ router.get(
|
||||
const {
|
||||
indexName,
|
||||
validationErrors,
|
||||
searchParams: { query, size },
|
||||
searchParams: { query, size, debug },
|
||||
} = getSearchFromRequestParams(req, 'generalAutocomplete')
|
||||
if (validationErrors.length) {
|
||||
return res.status(400).json(validationErrors[0])
|
||||
@@ -79,6 +81,7 @@ router.get(
|
||||
indexName,
|
||||
query,
|
||||
size,
|
||||
debug,
|
||||
}
|
||||
try {
|
||||
const { meta, hits } = await getAutocompleteSearchResults(options)
|
||||
@@ -98,11 +101,18 @@ router.get(
|
||||
router.get(
|
||||
'/ai-search-autocomplete/v1',
|
||||
catchMiddlewareError(async (req: Request, res: Response) => {
|
||||
// If no query is provided, we want to return the top 5 most popular terms
|
||||
// This is a special case for AI search autocomplete
|
||||
// So we use `force` to allow the query to be empty without the usual validation error
|
||||
let force = {} as any
|
||||
if (!req.query.query) {
|
||||
force.query = ''
|
||||
}
|
||||
const {
|
||||
indexName,
|
||||
validationErrors,
|
||||
searchParams: { query, size },
|
||||
} = getSearchFromRequestParams(req, 'aiSearchAutocomplete')
|
||||
searchParams: { query, size, debug },
|
||||
} = getSearchFromRequestParams(req, 'aiSearchAutocomplete', force)
|
||||
if (validationErrors.length) {
|
||||
return res.status(400).json(validationErrors[0])
|
||||
}
|
||||
@@ -111,6 +121,7 @@ router.get(
|
||||
indexName,
|
||||
query,
|
||||
size,
|
||||
debug,
|
||||
}
|
||||
try {
|
||||
const { meta, hits } = await getAISearchAutocompleteResults(getResultOptions)
|
||||
@@ -127,7 +138,20 @@ router.get(
|
||||
}),
|
||||
)
|
||||
|
||||
async function handleGetSearchResultsError(req: Request, res: Response, error: any, options: any) {
|
||||
// Route used by our frontend to fetch ai & general autocomplete search results in a single request
|
||||
router.get(
|
||||
'/combined-autocomplete/v1',
|
||||
catchMiddlewareError(async (req: Request, res: Response) => {
|
||||
combinedAutocompleteRoute(req, res)
|
||||
}),
|
||||
)
|
||||
|
||||
export async function handleGetSearchResultsError(
|
||||
req: Request,
|
||||
res: Response,
|
||||
error: any,
|
||||
options: any,
|
||||
) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error(`Error calling getSearchResults(${options})`, error)
|
||||
} else {
|
||||
@@ -137,7 +161,7 @@ async function handleGetSearchResultsError(req: Request, res: Response, error: a
|
||||
res.status(500).json({ error: error.message })
|
||||
}
|
||||
|
||||
// Redirects for latest versions
|
||||
// Redirects search routes to their latest versions
|
||||
router.get('/', (req: Request, res: Response) => {
|
||||
res.redirect(307, req.originalUrl.replace('/search', '/search/v1'))
|
||||
})
|
||||
@@ -153,4 +177,11 @@ router.get('/ai-search-autocomplete', (req: Request, res: Response) => {
|
||||
)
|
||||
})
|
||||
|
||||
router.get('/combined-autocomplete', (req: Request, res: Response) => {
|
||||
res.redirect(
|
||||
307,
|
||||
req.originalUrl.replace('/search/combined-autocomplete', '/search/combined-autocomplete/v1'),
|
||||
)
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from 'src/frame/components/context/MainContext'
|
||||
import { DefaultLayout } from 'src/frame/components/DefaultLayout'
|
||||
import { SearchContext } from 'src/search/components/context/SearchContext'
|
||||
import { Search } from 'src/search/components/index'
|
||||
import { Search } from 'src/search/components/results/index'
|
||||
import { SearchOnReqObject } from 'src/search/types'
|
||||
import type { SearchContextT } from 'src/search/components/types'
|
||||
|
||||
@@ -66,7 +66,7 @@ describeIfElasticsearchURL('search/ai-search-autocomplete v1 middleware', () =>
|
||||
const sp = new URLSearchParams()
|
||||
sp.set('query', 'fo')
|
||||
sp.set('version', 'never-heard-of')
|
||||
const res = await get(`${aiSearchEndpoint}?{sp}`)
|
||||
const res = await get(`${aiSearchEndpoint}?${sp}`)
|
||||
expect(res.statusCode).toBe(400)
|
||||
expect(JSON.parse(res.body).error).toBeTruthy()
|
||||
})
|
||||
@@ -134,31 +134,24 @@ describeIfElasticsearchURL('search/ai-search-autocomplete v1 middleware', () =>
|
||||
const res = await get(getSearchEndpointWithParams(sp))
|
||||
expect(res.statusCode).toBe(200)
|
||||
const results = JSON.parse(res.body) as AutocompleteSearchResponse
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
const hit = results.hits[0]
|
||||
expect(hit.term).toBe('How do I clone a repository?')
|
||||
expect(hit.highlights).toBeTruthy()
|
||||
expect(hit.highlights[0]).toBe('How do I <mark>clone</mark> a repository?')
|
||||
})
|
||||
|
||||
test('invalid query', async () => {
|
||||
test('support empty query', async () => {
|
||||
const sp = new URLSearchParams()
|
||||
// No query at all
|
||||
{
|
||||
const res = await get(getSearchEndpointWithParams(sp))
|
||||
expect(res.statusCode).toBe(400)
|
||||
expect(res.statusCode).toBe(200)
|
||||
}
|
||||
// Empty query
|
||||
{
|
||||
sp.set('query', '')
|
||||
const res = await get(getSearchEndpointWithParams(sp))
|
||||
expect(res.statusCode).toBe(400)
|
||||
}
|
||||
// Empty when trimmed
|
||||
{
|
||||
sp.set('query', ' ')
|
||||
const res = await get(getSearchEndpointWithParams(sp))
|
||||
expect(res.statusCode).toBe(400)
|
||||
expect(res.statusCode).toBe(200)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
170
src/search/tests/api-combined-search.ts
Normal file
170
src/search/tests/api-combined-search.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* To be able to run these tests you need to index the fixtures!
|
||||
* And you need to have an Elasticsearch URL to connect to for the server.
|
||||
*
|
||||
* To index the fixtures, run:
|
||||
*
|
||||
* ELASTICSEARCH_URL=http://localhost:9200 npm run index-test-fixtures
|
||||
*
|
||||
* This will replace any "real" Elasticsearch indexes you might have so
|
||||
* once you're done working on vitest tests you need to index real
|
||||
* content again.
|
||||
*/
|
||||
|
||||
import { expect, test, vi } from 'vitest'
|
||||
|
||||
import { describeIfElasticsearchURL } from '@/tests/helpers/conditional-runs.js'
|
||||
import { get } from '@/tests/helpers/e2etest-ts'
|
||||
|
||||
import type { CombinedAutocompleteSearchResponse } from '@/search/types'
|
||||
|
||||
if (!process.env.ELASTICSEARCH_URL) {
|
||||
console.warn(
|
||||
'None of the API search middleware tests are run because ' +
|
||||
"the environment variable 'ELASTICSEARCH_URL' is currently not set.",
|
||||
)
|
||||
}
|
||||
|
||||
const combinedSearchEndpoint = '/api/search/combined-autocomplete/v1'
|
||||
const getSearchEndpointWithParams = (searchParams: URLSearchParams) =>
|
||||
`${combinedSearchEndpoint}?${searchParams}`
|
||||
|
||||
// This suite only runs if $ELASTICSEARCH_URL is set.
|
||||
describeIfElasticsearchURL('search/combined-autocomplete v1 middleware', () => {
|
||||
vi.setConfig({ testTimeout: 60 * 1000 })
|
||||
|
||||
test('basic search', async () => {
|
||||
const sp = new URLSearchParams()
|
||||
sp.set('query', 'how do I')
|
||||
const res = await get(getSearchEndpointWithParams(sp))
|
||||
expect(res.statusCode).toBe(200)
|
||||
const results = JSON.parse(res.body) as CombinedAutocompleteSearchResponse
|
||||
|
||||
// For aiAutocomplete
|
||||
expect(results.aiAutocomplete.meta).toBeTruthy()
|
||||
expect(results.aiAutocomplete.meta.found.value).toBeGreaterThanOrEqual(1)
|
||||
expect(results.aiAutocomplete.meta.found.relation).toBeTruthy()
|
||||
|
||||
expect(results.aiAutocomplete.hits).toBeTruthy()
|
||||
|
||||
const aiHit = results.aiAutocomplete.hits[0]
|
||||
expect(aiHit.term).toBe('How do I clone a repository?')
|
||||
expect(aiHit.highlights).toBeTruthy()
|
||||
expect(aiHit.highlights[0]).toBe('<mark>How do I</mark> clone a repository?')
|
||||
|
||||
// For generalAutocomplete
|
||||
expect(results.generalAutocomplete.meta).toBeTruthy()
|
||||
expect(results.generalAutocomplete.meta.found.value).toBeGreaterThanOrEqual(1)
|
||||
expect(results.generalAutocomplete.meta.found.relation).toBeTruthy()
|
||||
|
||||
expect(results.generalAutocomplete.hits).toBeTruthy()
|
||||
|
||||
const generalHit = results.generalAutocomplete.hits[0]
|
||||
expect(generalHit.term).toBe('inputs')
|
||||
expect(generalHit.highlights).toBeTruthy()
|
||||
expect(generalHit.highlights[0]).toBe('<mark>inputs</mark>')
|
||||
|
||||
// Check that it can be cached at the CDN
|
||||
expect(res.headers['set-cookie']).toBeUndefined()
|
||||
expect(res.headers['cache-control']).toContain('public')
|
||||
expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/)
|
||||
expect(res.headers['surrogate-control']).toContain('public')
|
||||
expect(res.headers['surrogate-control']).toMatch(/max-age=[1-9]/)
|
||||
expect(res.headers['surrogate-key']).toBe('manual-purge')
|
||||
})
|
||||
|
||||
test('invalid version', async () => {
|
||||
const sp = new URLSearchParams()
|
||||
sp.set('query', 'rest')
|
||||
sp.set('version', 'never-heard-of')
|
||||
const res = await get(getSearchEndpointWithParams(sp))
|
||||
expect(res.statusCode).toBe(400)
|
||||
expect(JSON.parse(res.body).error).toBeTruthy()
|
||||
})
|
||||
|
||||
test('variations on version name', async () => {
|
||||
const sp = new URLSearchParams()
|
||||
sp.set('query', 'rest')
|
||||
const versions = ['enterprise-cloud', 'ghec', 'fpt', 'free-pro-team@latest']
|
||||
for (const version of versions) {
|
||||
sp.set('version', version)
|
||||
const res = await get(getSearchEndpointWithParams(sp))
|
||||
expect(res.statusCode).toBe(200)
|
||||
}
|
||||
})
|
||||
|
||||
test('invalid language', async () => {
|
||||
const sp = new URLSearchParams()
|
||||
sp.set('query', 'rest')
|
||||
sp.set('language', 'xx')
|
||||
const res = await get(getSearchEndpointWithParams(sp))
|
||||
expect(res.statusCode).toBe(400)
|
||||
expect(JSON.parse(res.body).error).toBeTruthy()
|
||||
})
|
||||
|
||||
test('only english supported', async () => {
|
||||
const sp = new URLSearchParams()
|
||||
sp.set('query', 'rest')
|
||||
sp.set('language', 'ja')
|
||||
const res = await get(getSearchEndpointWithParams(sp))
|
||||
expect(res.statusCode).toBe(400)
|
||||
expect(JSON.parse(res.body).error).toBeTruthy()
|
||||
})
|
||||
|
||||
test('autocomplete term search', async () => {
|
||||
const sp = new URLSearchParams()
|
||||
sp.set('query', 'rest')
|
||||
const res = await get(getSearchEndpointWithParams(sp))
|
||||
expect(res.statusCode).toBe(200)
|
||||
const results = JSON.parse(res.body) as CombinedAutocompleteSearchResponse
|
||||
|
||||
// aiAutocomplete results
|
||||
const aiHits = results.aiAutocomplete.hits
|
||||
expect(aiHits.length).toBeGreaterThanOrEqual(2)
|
||||
expect(aiHits[0].term).toBe(
|
||||
'How do I manage OAuth app access restrictions for my organization?',
|
||||
)
|
||||
expect(aiHits[0].highlights[0]).toBe(
|
||||
'How do I manage OAuth app access <mark>restrictions</mark> for my organization?',
|
||||
)
|
||||
expect(aiHits[1].term).toBe('How do I test my SSH connection to GitHub?')
|
||||
expect(aiHits[1].highlights[0]).toBe('How do I <mark>test</mark> my SSH connection to GitHub?')
|
||||
|
||||
// generalAutocomplete results
|
||||
const generalHits = results.generalAutocomplete.hits
|
||||
expect(generalHits.length).toBeGreaterThanOrEqual(3)
|
||||
expect(generalHits[0].term).toBe('rest')
|
||||
expect(generalHits[0].highlights[0]).toBe('<mark>rest</mark>')
|
||||
expect(generalHits[1].term).toBe('rest api')
|
||||
expect(generalHits[1].highlights[0]).toBe('<mark>rest</mark> api')
|
||||
expect(generalHits[2].term).toBe('rest api endpoints')
|
||||
expect(generalHits[2].highlights[0]).toBe('<mark>rest</mark> api endpoints')
|
||||
})
|
||||
|
||||
test('empty query returns default results', async () => {
|
||||
const sp = new URLSearchParams()
|
||||
// No query at all
|
||||
{
|
||||
const res = await get(getSearchEndpointWithParams(sp))
|
||||
expect(res.statusCode).toBe(200)
|
||||
const results = JSON.parse(res.body) as CombinedAutocompleteSearchResponse
|
||||
expect(results).toBeTruthy()
|
||||
}
|
||||
// Empty query
|
||||
{
|
||||
sp.set('query', '')
|
||||
const res = await get(getSearchEndpointWithParams(sp))
|
||||
expect(res.statusCode).toBe(200)
|
||||
const results = JSON.parse(res.body) as CombinedAutocompleteSearchResponse
|
||||
expect(results).toBeTruthy()
|
||||
}
|
||||
// Empty when trimmed
|
||||
{
|
||||
sp.set('query', ' ')
|
||||
const res = await get(getSearchEndpointWithParams(sp))
|
||||
expect(res.statusCode).toBe(200)
|
||||
const results = JSON.parse(res.body) as CombinedAutocompleteSearchResponse
|
||||
expect(results).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
147
src/search/tests/fix-incomplete-markdown.ts
Normal file
147
src/search/tests/fix-incomplete-markdown.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { expect, test, describe } from 'vitest'
|
||||
import { fixIncompleteMarkdown } from '@/search/components/helpers/fix-incomplete-markdown'
|
||||
|
||||
// Unit tests for the `fixIncompleteMarkdown` function
|
||||
describe('fixIncompleteMarkdown', () => {
|
||||
test('should close unclosed bold syntax with double asterisks', () => {
|
||||
const input = 'This is **bold text'
|
||||
const expected = 'This is **bold text**'
|
||||
const result = fixIncompleteMarkdown(input)
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
test('should close unclosed bold syntax with double underscores', () => {
|
||||
const input = 'This is __bold text'
|
||||
const expected = 'This is __bold text__'
|
||||
const result = fixIncompleteMarkdown(input)
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
test('should close unclosed italics syntax with single asterisk', () => {
|
||||
const input = 'This is *italic text'
|
||||
const expected = 'This is *italic text*'
|
||||
const result = fixIncompleteMarkdown(input)
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
test('should close unclosed italics syntax with single underscore', () => {
|
||||
const input = 'This is _italic text'
|
||||
const expected = 'This is _italic text_'
|
||||
const result = fixIncompleteMarkdown(input)
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
test('should close unclosed bold and italics syntax', () => {
|
||||
const input = 'This is ***bold and italic text'
|
||||
const expected = 'This is ***bold and italic text***'
|
||||
const result = fixIncompleteMarkdown(input)
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
test('should close unclosed link syntax without URL', () => {
|
||||
const input = 'This is a [link text'
|
||||
const expected = 'This is a [link text]'
|
||||
const result = fixIncompleteMarkdown(input)
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
test('should close unclosed link syntax with URL', () => {
|
||||
const input = 'This is a [link text](https://example.com'
|
||||
const expected = 'This is a [link text](https://example.com)'
|
||||
const result = fixIncompleteMarkdown(input)
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
test('should close unclosed inline code syntax', () => {
|
||||
const input = 'This is `inline code'
|
||||
const expected = 'This is `inline code`'
|
||||
const result = fixIncompleteMarkdown(input)
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
test('should close unclosed code block syntax', () => {
|
||||
const input = 'Here is some code:\n```\nconst x = 10;'
|
||||
const expected = 'Here is some code:\n```\nconst x = 10;\n```'
|
||||
const result = fixIncompleteMarkdown(input)
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
test('should handle nested markdown elements', () => {
|
||||
const input = 'This is **bold and _italic text'
|
||||
const expected = 'This is **bold and _italic text_**'
|
||||
const result = fixIncompleteMarkdown(input)
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
test('should not alter complete markdown', () => {
|
||||
const input = 'This is **bold text** and *italic text*'
|
||||
const expected = 'This is **bold text** and *italic text*'
|
||||
const result = fixIncompleteMarkdown(input)
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
test('should handle multiline input with unclosed link', () => {
|
||||
const input =
|
||||
'Start of text [link text](https://example.com) and **bold text**\n\nI am a new paragraph with *italic text*\n\nThis is the end of the text, with a [link to the end'
|
||||
const expected =
|
||||
'Start of text [link text](https://example.com) and **bold text**\n\nI am a new paragraph with *italic text*\n\nThis is the end of the text, with a [link to the end]'
|
||||
const result = fixIncompleteMarkdown(input)
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
test('should close unclosed strikethrough syntax', () => {
|
||||
const input = 'This is ~~strikethrough text'
|
||||
const expected = 'This is ~~strikethrough text~~'
|
||||
const result = fixIncompleteMarkdown(input)
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
test('should close unclosed images syntax', () => {
|
||||
const input = ''
|
||||
const result = fixIncompleteMarkdown(input)
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
test('should handle unclosed nested emphasis', () => {
|
||||
const input = 'Some _italic and **bold text'
|
||||
const expected = 'Some _italic and **bold text**_'
|
||||
const result = fixIncompleteMarkdown(input)
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
test('should handle unclosed code blocks with language specified', () => {
|
||||
const input = '```javascript\nconsole.log("Hello, world!");'
|
||||
const expected = '```javascript\nconsole.log("Hello, world!");\n```'
|
||||
const result = fixIncompleteMarkdown(input)
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
test('should not alter headings', () => {
|
||||
const input = '### Heading level 3'
|
||||
const expected = '### Heading level 3'
|
||||
const result = fixIncompleteMarkdown(input)
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
test('should not alter incomplete horizontal rules', () => {
|
||||
const input = 'Some text\n---'
|
||||
const expected = 'Some text\n---'
|
||||
const result = fixIncompleteMarkdown(input)
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
test('should handle incomplete tables', () => {
|
||||
const input = '| Header1 | Header2 |\n|---------|---------|\n| Row1Col1'
|
||||
const expected = '| Header1 | Header2 |\n|---------|---------|\n| Row1Col1 | |'
|
||||
const result = fixIncompleteMarkdown(input)
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
|
||||
test('should handle unclosed emphasis with tildes', () => {
|
||||
const input = 'This is ~tilde emphasis'
|
||||
const expected = 'This is ~tilde emphasis~'
|
||||
const result = fixIncompleteMarkdown(input)
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types'
|
||||
|
||||
import type {
|
||||
AdditionalIncludes,
|
||||
ComputedSearchQueryParamsMap,
|
||||
@@ -20,6 +21,11 @@ export interface AutocompleteSearchResponse {
|
||||
hits: AutocompleteSearchHit[]
|
||||
}
|
||||
|
||||
export interface CombinedAutocompleteSearchResponse {
|
||||
aiAutocomplete: AutocompleteSearchResponse
|
||||
generalAutocomplete: AutocompleteSearchResponse
|
||||
}
|
||||
|
||||
// Response to middleware /search route
|
||||
export interface SearchOnReqObject<Type extends SearchTypes> {
|
||||
searchParams: ComputedSearchQueryParamsMap[Type]
|
||||
@@ -52,7 +58,7 @@ export type GeneralSearchHit = GeneralSearchHitWithoutIncludes & {
|
||||
[key in AdditionalIncludes]?: string
|
||||
}
|
||||
|
||||
interface AutocompleteSearchHit {
|
||||
export interface AutocompleteSearchHit {
|
||||
term?: string
|
||||
highlights: string[]
|
||||
}
|
||||
|
||||
@@ -28,8 +28,12 @@ const RECOGNIZED_KEYS_BY_ANY = new Set([
|
||||
'tool',
|
||||
// When apiVersion isn't the only one. E.g. ?apiVersion=XXX&tool=vscode
|
||||
'apiVersion',
|
||||
// Search
|
||||
// Search results page
|
||||
'query',
|
||||
// Any page, Search Overlay
|
||||
'search-overlay-input',
|
||||
'search-overlay-open',
|
||||
'search-overlay-ask-ai',
|
||||
// The drop-downs on "Webhook events and payloads"
|
||||
'actionType',
|
||||
// Used by the tracking middleware
|
||||
|
||||
Reference in New Issue
Block a user