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.
|
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
|
sign_up_cta: Sign up
|
||||||
menu: Menu
|
menu: Menu
|
||||||
|
open_menu_label: Open menu
|
||||||
go_home: Home
|
go_home: Home
|
||||||
picker:
|
picker:
|
||||||
language_picker_label: Language
|
language_picker_label: Language
|
||||||
@@ -23,6 +24,37 @@ picker:
|
|||||||
release_notes:
|
release_notes:
|
||||||
banner_text: GitHub began rolling these changes out to enterprises on
|
banner_text: GitHub began rolling these changes out to enterprises on
|
||||||
search:
|
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.
|
description: Enter a search term to find it in the GitHub Docs.
|
||||||
placeholder: Search GitHub Docs
|
placeholder: Search GitHub Docs
|
||||||
label: 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/behaviors": "^1.7.0",
|
||||||
"@primer/css": "^21.3.1",
|
"@primer/css": "^21.3.1",
|
||||||
"@primer/live-region-element": "^0.7.0",
|
"@primer/live-region-element": "^0.7.0",
|
||||||
"@primer/octicons": "^19.11.0",
|
"@primer/octicons": "^19.14.0",
|
||||||
"@primer/octicons-react": "^19.11.0",
|
"@primer/octicons-react": "^19.14.0",
|
||||||
"@primer/react": "36.27.0",
|
"@primer/react": "36.27.0",
|
||||||
"accept-language-parser": "^1.5.0",
|
"accept-language-parser": "^1.5.0",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
@@ -304,6 +304,7 @@
|
|||||||
"quick-lru": "7.0.0",
|
"quick-lru": "7.0.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
|
"react-markdown": "^9.0.1",
|
||||||
"rehype-highlight": "^7.0.0",
|
"rehype-highlight": "^7.0.0",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"rehype-slug": "^6.0.0",
|
"rehype-slug": "^6.0.0",
|
||||||
@@ -356,6 +357,7 @@
|
|||||||
"@types/react": "18.3.3",
|
"@types/react": "18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/semver": "^7.5.8",
|
"@types/semver": "^7.5.8",
|
||||||
|
"@types/styled-components": "^5.1.34",
|
||||||
"@types/tcp-port-used": "1.0.4",
|
"@types/tcp-port-used": "1.0.4",
|
||||||
"@types/website-scraper": "^1.2.10",
|
"@types/website-scraper": "^1.2.10",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.7.0",
|
"@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()
|
// Temporary polyfill for crypto.randomUUID()
|
||||||
// Necessary for localhost development (doesn't have https://)
|
// Necessary for localhost development (doesn't have https://)
|
||||||
function uuidv4(): string {
|
export function uuidv4(): string {
|
||||||
try {
|
try {
|
||||||
return crypto.randomUUID()
|
return crypto.randomUUID()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -64,10 +64,14 @@ function getMetaContent(name: string) {
|
|||||||
export function sendEvent<T extends EventType>({
|
export function sendEvent<T extends EventType>({
|
||||||
type,
|
type,
|
||||||
version = '1.0.0',
|
version = '1.0.0',
|
||||||
|
eventGroupKey,
|
||||||
|
eventGroupId,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
type: T
|
type: T
|
||||||
version?: string
|
version?: string
|
||||||
|
eventGroupKey?: string
|
||||||
|
eventGroupId?: string
|
||||||
} & EventPropsByType[T]) {
|
} & EventPropsByType[T]) {
|
||||||
const body = {
|
const body = {
|
||||||
type,
|
type,
|
||||||
@@ -113,6 +117,10 @@ export function sendEvent<T extends EventType>({
|
|||||||
code_display_preference: Cookies.get('annotate-mode'),
|
code_display_preference: Cookies.get('annotate-mode'),
|
||||||
|
|
||||||
experiment_variation: getExperimentVariationForContext(getMetaContent('path-language')),
|
experiment_variation: getExperimentVariationForContext(getMetaContent('path-language')),
|
||||||
|
|
||||||
|
// Event grouping
|
||||||
|
event_group_key: eventGroupKey,
|
||||||
|
event_group_id: eventGroupId,
|
||||||
},
|
},
|
||||||
|
|
||||||
...props,
|
...props,
|
||||||
@@ -295,6 +303,7 @@ function initCopyButtonEvent() {
|
|||||||
const target = evt.target as HTMLElement
|
const target = evt.target as HTMLElement
|
||||||
const button = target.closest('.js-btn-copy') as HTMLButtonElement
|
const button = target.closest('.js-btn-copy') as HTMLButtonElement
|
||||||
if (!button) return
|
if (!button) return
|
||||||
|
|
||||||
sendEvent({
|
sendEvent({
|
||||||
type: EventType.clipboard,
|
type: EventType.clipboard,
|
||||||
clipboard_operation: 'copy',
|
clipboard_operation: 'copy',
|
||||||
@@ -310,12 +319,19 @@ function initLinkEvent() {
|
|||||||
if (!link) return
|
if (!link) return
|
||||||
const sameSite = link.origin === location.origin
|
const sameSite = link.origin === location.origin
|
||||||
const container = target.closest(`[data-container]`) as HTMLElement | null
|
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({
|
sendEvent({
|
||||||
type: EventType.link,
|
type: EventType.link,
|
||||||
link_url: link.href,
|
link_url: link.href,
|
||||||
link_samesite: sameSite,
|
link_samesite: sameSite,
|
||||||
link_samepage: sameSite && link.pathname === location.pathname,
|
link_samepage: sameSite && link.pathname === location.pathname,
|
||||||
link_container: container?.dataset.container,
|
link_container: container?.dataset.container,
|
||||||
|
eventGroupKey,
|
||||||
|
eventGroupId,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ type Experiment = {
|
|||||||
export type ExperimentNames = 'ai_search_experiment'
|
export type ExperimentNames = 'ai_search_experiment'
|
||||||
|
|
||||||
export const EXPERIMENTS = {
|
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:
|
/* Add new experiments here, example:
|
||||||
'example_experiment': {
|
'example_experiment': {
|
||||||
key: 'example_experiment',
|
key: 'example_experiment',
|
||||||
|
|||||||
@@ -182,6 +182,17 @@ const context = {
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'The variation this user we bucketed in is in, such as control or treatment.',
|
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 = {
|
const survey = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
@@ -432,7 +475,7 @@ const experiment = {
|
|||||||
},
|
},
|
||||||
experiment_variation: {
|
experiment_variation: {
|
||||||
type: 'string',
|
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: {
|
experiment_success: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
@@ -545,6 +588,7 @@ export const schemas = {
|
|||||||
hover,
|
hover,
|
||||||
search,
|
search,
|
||||||
searchResult,
|
searchResult,
|
||||||
|
aiSearchResult,
|
||||||
survey,
|
survey,
|
||||||
experiment,
|
experiment,
|
||||||
clipboard,
|
clipboard,
|
||||||
@@ -560,6 +604,7 @@ export const hydroNames = {
|
|||||||
hover: 'docs.v0.HoverEvent',
|
hover: 'docs.v0.HoverEvent',
|
||||||
search: 'docs.v0.SearchEvent',
|
search: 'docs.v0.SearchEvent',
|
||||||
searchResult: 'docs.v0.SearchResultEvent',
|
searchResult: 'docs.v0.SearchResultEvent',
|
||||||
|
aiSearchResult: 'docs.v0.AISearchResultsEvent',
|
||||||
survey: 'docs.v0.SurveyEvent',
|
survey: 'docs.v0.SurveyEvent',
|
||||||
experiment: 'docs.v0.ExperimentEvent',
|
experiment: 'docs.v0.ExperimentEvent',
|
||||||
clipboard: 'docs.v0.ClipboardEvent',
|
clipboard: 'docs.v0.ClipboardEvent',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export enum EventType {
|
export enum EventType {
|
||||||
|
aiSearchResult = 'aiSearchResult',
|
||||||
page = 'page',
|
page = 'page',
|
||||||
exit = 'exit',
|
exit = 'exit',
|
||||||
link = 'link',
|
link = 'link',
|
||||||
@@ -46,10 +47,19 @@ export type EventProps = {
|
|||||||
color_mode_preference: string
|
color_mode_preference: string
|
||||||
os_preference: string
|
os_preference: string
|
||||||
code_display_preference: string
|
code_display_preference: string
|
||||||
|
event_group_key?: string
|
||||||
|
event_group_id?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EventPropsByType = {
|
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]: {
|
[EventType.clipboard]: {
|
||||||
clipboard_operation: string
|
clipboard_operation: string
|
||||||
clipboard_target?: 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.
|
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
|
sign_up_cta: Sign up
|
||||||
menu: Menu
|
menu: Menu
|
||||||
|
open_menu_label: Open menu
|
||||||
go_home: Home
|
go_home: Home
|
||||||
picker:
|
picker:
|
||||||
language_picker_label: Language
|
language_picker_label: Language
|
||||||
@@ -23,6 +24,37 @@ picker:
|
|||||||
release_notes:
|
release_notes:
|
||||||
banner_text: GitHub began rolling these changes out to enterprises on
|
banner_text: GitHub began rolling these changes out to enterprises on
|
||||||
search:
|
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.
|
description: Enter a search term to find it in the GitHub Docs.
|
||||||
placeholder: Search GitHub Docs
|
placeholder: Search GitHub Docs
|
||||||
label: 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/)
|
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('search from enterprise-cloud and filter by top-level Fooing', async ({ page }) => {
|
||||||
test.skip(!SEARCH_TESTS, 'No local Elasticsearch, no tests involving search')
|
test.skip(!SEARCH_TESTS, 'No local Elasticsearch, no tests involving search')
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ const DEFAULT_UI_NAMESPACES = [
|
|||||||
'alerts',
|
'alerts',
|
||||||
'header',
|
'header',
|
||||||
'search',
|
'search',
|
||||||
|
'old_search',
|
||||||
'survey',
|
'survey',
|
||||||
'toc',
|
'toc',
|
||||||
'meta',
|
'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;
|
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
|
// Contains the logo and version picker and used when the smaller width search
|
||||||
// input UI is closed.
|
// input UI is closed.
|
||||||
.logoWithClosedSearch {
|
.logoWithClosedSearch {
|
||||||
|
|||||||
@@ -1,32 +1,28 @@
|
|||||||
import { Suspense, useCallback, useEffect, useRef, useState } from 'react'
|
import { Suspense, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import cx from 'classnames'
|
import cx from 'classnames'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { ActionList, ActionMenu, Dialog, IconButton } from '@primer/react'
|
import { Dialog, IconButton } from '@primer/react'
|
||||||
import {
|
import { MarkGithubIcon, ThreeBarsIcon } from '@primer/octicons-react'
|
||||||
KebabHorizontalIcon,
|
|
||||||
LinkExternalIcon,
|
|
||||||
MarkGithubIcon,
|
|
||||||
SearchIcon,
|
|
||||||
ThreeBarsIcon,
|
|
||||||
XIcon,
|
|
||||||
} from '@primer/octicons-react'
|
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
|
|
||||||
import { DEFAULT_VERSION, useVersion } from 'src/versions/components/useVersion'
|
import { DEFAULT_VERSION, useVersion } from 'src/versions/components/useVersion'
|
||||||
import { Link } from 'src/frame/components/Link'
|
import { Link } from 'src/frame/components/Link'
|
||||||
import { useMainContext } from 'src/frame/components/context/MainContext'
|
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 { HeaderNotifications } from 'src/frame/components/page-header/HeaderNotifications'
|
||||||
import { ApiVersionPicker } from 'src/rest/components/ApiVersionPicker'
|
import { ApiVersionPicker } from 'src/rest/components/ApiVersionPicker'
|
||||||
import { useTranslation } from 'src/languages/components/useTranslation'
|
import { useTranslation } from 'src/languages/components/useTranslation'
|
||||||
import { Search } from 'src/search/components/Search'
|
|
||||||
import { Breadcrumbs } from 'src/frame/components/page-header/Breadcrumbs'
|
import { Breadcrumbs } from 'src/frame/components/page-header/Breadcrumbs'
|
||||||
import { VersionPicker } from 'src/versions/components/VersionPicker'
|
import { VersionPicker } from 'src/versions/components/VersionPicker'
|
||||||
import { SidebarNav } from 'src/frame/components/sidebar/SidebarNav'
|
import { SidebarNav } from 'src/frame/components/sidebar/SidebarNav'
|
||||||
import { AllProductsLink } from 'src/frame/components/sidebar/AllProductsLink'
|
import { AllProductsLink } from 'src/frame/components/sidebar/AllProductsLink'
|
||||||
|
|
||||||
import styles from './Header.module.scss'
|
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'), {
|
const DomainNameEdit = dynamic(() => import('src/links/components/DomainNameEdit'), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
@@ -39,9 +35,11 @@ export const Header = () => {
|
|||||||
const { currentVersion } = useVersion()
|
const { currentVersion } = useVersion()
|
||||||
const { t } = useTranslation(['header'])
|
const { t } = useTranslation(['header'])
|
||||||
const isRestPage = currentProduct && currentProduct.id === 'rest'
|
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 [scroll, setScroll] = useState(false)
|
||||||
const { hasAccount } = useHasAccount()
|
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
||||||
const openSidebar = useCallback(() => setIsSidebarOpen(true), [isSidebarOpen])
|
const openSidebar = useCallback(() => setIsSidebarOpen(true), [isSidebarOpen])
|
||||||
const closeSidebar = useCallback(() => setIsSidebarOpen(false), [isSidebarOpen])
|
const closeSidebar = useCallback(() => setIsSidebarOpen(false), [isSidebarOpen])
|
||||||
@@ -50,12 +48,14 @@ export const Header = () => {
|
|||||||
const { asPath } = useRouter()
|
const { asPath } = useRouter()
|
||||||
const isSearchResultsPage = router.route === '/search'
|
const isSearchResultsPage = router.route === '/search'
|
||||||
const isEarlyAccessPage = currentProduct && currentProduct.id === 'early-access'
|
const isEarlyAccessPage = currentProduct && currentProduct.id === 'early-access'
|
||||||
const signupCTAVisible =
|
const { width } = useInnerWindowWidth()
|
||||||
hasAccount === false && // don't show if `null`
|
|
||||||
(currentVersion === DEFAULT_VERSION || currentVersion === 'enterprise-cloud@latest')
|
|
||||||
const { width } = useWidth()
|
|
||||||
const returnFocusRef = useRef(null)
|
const returnFocusRef = useRef(null)
|
||||||
|
|
||||||
|
const showNewSearch = useShouldShowExperiment(
|
||||||
|
EXPERIMENTS.ai_search_experiment,
|
||||||
|
router.locale as string,
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
setScroll(window.scrollY > 10)
|
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}`
|
let homeURL = `/${router.locale}`
|
||||||
if (currentVersion !== DEFAULT_VERSION) {
|
if (currentVersion !== DEFAULT_VERSION) {
|
||||||
homeURL += `/${currentVersion}`
|
homeURL += `/${currentVersion}`
|
||||||
@@ -172,6 +146,10 @@ export const Header = () => {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="d-flex flex-justify-between p-2 flex-items-center flex-wrap"
|
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"
|
data-testid="desktop-header"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -198,160 +176,19 @@ export const Header = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{showNewSearch ? (
|
||||||
<div className={cx('d-flex flex-items-center', isSearchOpen && styles.widgetsContainer)}>
|
<HeaderSearchAndWidgets
|
||||||
{/* <!-- GitHub.com homepage and 404 page has a stylized search; Enterprise homepages do not --> */}
|
isSearchOpen={isSearchOpen}
|
||||||
{error !== '404' && (
|
setIsSearchOpen={setIsSearchOpen}
|
||||||
<div
|
width={width}
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
<IconButton
|
) : (
|
||||||
className="px-3"
|
<OldHeaderSearchAndWidgets
|
||||||
data-testid="mobile-search-button"
|
isSearchOpen={isSearchOpen}
|
||||||
onClick={() => setIsSearchOpen(!isSearchOpen)}
|
setIsSearchOpen={setIsSearchOpen}
|
||||||
aria-label="Close Search Bar"
|
width={width}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
{!isHomepageVersion && !isSearchResultsPage && (
|
{!isHomepageVersion && !isSearchResultsPage && (
|
||||||
<div className="d-flex flex-items-center d-xxl-none mt-2" data-testid="header-subnav">
|
<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 { useMainContext } from 'src/frame/components/context/MainContext'
|
||||||
import { SidebarProduct } from 'src/landings/components/SidebarProduct'
|
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 { AllProductsLink } from './AllProductsLink'
|
||||||
import { ApiVersionPicker } from 'src/rest/components/ApiVersionPicker'
|
import { ApiVersionPicker } from 'src/rest/components/ApiVersionPicker'
|
||||||
import { Link } from 'src/frame/components/Link'
|
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 === '') {
|
if (debug === '') {
|
||||||
// E.g. `?query=foo&debug` should be treated as truthy
|
// E.g. `?query=foo&debug` should be treated as truthy
|
||||||
return true
|
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 { useTranslation } from 'src/languages/components/useTranslation'
|
||||||
import { DEFAULT_VERSION, useVersion } from 'src/versions/components/useVersion'
|
import { DEFAULT_VERSION, useVersion } from 'src/versions/components/useVersion'
|
||||||
import { useQuery } from 'src/search/components/useQuery'
|
import { useQuery } from 'src/search/components/hooks/useQuery'
|
||||||
import { useBreakpoint } from 'src/search/components/useBreakpoint'
|
import { useBreakpoint } from 'src/search/components/hooks/useBreakpoint'
|
||||||
import { sendEvent } from 'src/events/components/events'
|
import { sendEvent } from 'src/events/components/events'
|
||||||
import { EventType } from 'src/events/types'
|
import { EventType } from 'src/events/types'
|
||||||
|
import { GENERAL_SEARCH_CONTEXT } from '../helpers/execute-search-actions'
|
||||||
|
|
||||||
type Props = { isSearchOpen: boolean }
|
type Props = { isSearchOpen: boolean }
|
||||||
|
|
||||||
export function Search({ isSearchOpen }: Props) {
|
export function OldSearchInput({ isSearchOpen }: Props) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { query, debug } = useQuery()
|
const { query, debug } = useQuery()
|
||||||
const [localQuery, setLocalQuery] = useState(query)
|
const [localQuery, setLocalQuery] = useState(query)
|
||||||
const { t } = useTranslation('search')
|
const { t } = useTranslation('old_search')
|
||||||
const { currentVersion } = useVersion()
|
const { currentVersion } = useVersion()
|
||||||
const atMediumViewport = useBreakpoint('medium')
|
const atMediumViewport = useBreakpoint('medium')
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ export function Search({ isSearchOpen }: Props) {
|
|||||||
sendEvent({
|
sendEvent({
|
||||||
type: EventType.search,
|
type: EventType.search,
|
||||||
search_query: localQuery,
|
search_query: localQuery,
|
||||||
|
search_context: GENERAL_SEARCH_CONTEXT,
|
||||||
})
|
})
|
||||||
|
|
||||||
redirectSearch()
|
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'
|
import { useTranslation } from 'src/languages/components/useTranslation'
|
||||||
|
|
||||||
export function NoQuery() {
|
export function NoQuery() {
|
||||||
const { t } = useTranslation(['search'])
|
const { t } = useTranslation('old_search')
|
||||||
const mainContext = useMainContext()
|
const mainContext = useMainContext()
|
||||||
// Use TypeScript's "not null assertion" because `context.page` should
|
// Use TypeScript's "not null assertion" because `context.page` should
|
||||||
// will present in main context if it's gotten to the stage of React
|
// 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 { SearchQueryContentT } from 'src/search/components/types'
|
||||||
import type { GeneralSearchHitWithoutIncludes, GeneralSearchResponse } from 'src/search/types'
|
import type { GeneralSearchHitWithoutIncludes, GeneralSearchResponse } from 'src/search/types'
|
||||||
import type { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types'
|
import type { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types'
|
||||||
|
import { GENERAL_SEARCH_RESULTS } from '@/events/components/event-groups'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
results: GeneralSearchResponse
|
results: GeneralSearchResponse
|
||||||
@@ -105,6 +106,7 @@ function SearchResultHit({
|
|||||||
href={hit.url}
|
href={hit.url}
|
||||||
className="color-fg-accent search-result-link"
|
className="color-fg-accent search-result-link"
|
||||||
dangerouslySetInnerHTML={{ __html: title }}
|
dangerouslySetInnerHTML={{ __html: title }}
|
||||||
|
data-group-key={GENERAL_SEARCH_RESULTS}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
sendEvent({
|
sendEvent({
|
||||||
type: EventType.searchResult,
|
type: EventType.searchResult,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useSearchContext } from './context/SearchContext'
|
import { useSearchContext } from '../context/SearchContext'
|
||||||
import { SearchResultsAggregations } from './Aggregations'
|
import { SearchResultsAggregations } from './Aggregations'
|
||||||
|
|
||||||
export function SidebarSearchAggregates() {
|
export function SidebarSearchAggregates() {
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Flash } from '@primer/react'
|
import { Flash } from '@primer/react'
|
||||||
|
|
||||||
import { useTranslation } from 'src/languages/components/useTranslation'
|
import { useTranslation } from 'src/languages/components/useTranslation'
|
||||||
import type { SearchValidationErrorEntry } from '../types'
|
import type { SearchValidationErrorEntry } from '../../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
errors: SearchValidationErrorEntry[]
|
errors: SearchValidationErrorEntry[]
|
||||||
@@ -3,11 +3,11 @@ import { Heading } from '@primer/react'
|
|||||||
|
|
||||||
import { useTranslation } from 'src/languages/components/useTranslation'
|
import { useTranslation } from 'src/languages/components/useTranslation'
|
||||||
import { DEFAULT_VERSION, useVersion } from 'src/versions/components/useVersion'
|
import { DEFAULT_VERSION, useVersion } from 'src/versions/components/useVersion'
|
||||||
import { useNumberFormatter } from 'src/search/components/useNumberFormatter'
|
import { useNumberFormatter } from 'src/search/components/hooks/useNumberFormatter'
|
||||||
import { SearchResults } from 'src/search/components/SearchResults'
|
import { SearchResults } from 'src/search/components/results/SearchResults'
|
||||||
import { NoQuery } from 'src/search/components/NoQuery'
|
import { NoQuery } from 'src/search/components/results/NoQuery'
|
||||||
import { useMainContext } from 'src/frame/components/context/MainContext'
|
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 { useSearchContext } from 'src/search/components/context/SearchContext'
|
||||||
import type { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types'
|
import type { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types'
|
||||||
|
|
||||||
@@ -40,29 +40,48 @@ export const aiSearchProxy = async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stream = got.post(`${process.env.CSE_COPILOT_ENDPOINT}/answers`, {
|
const stream = got.stream.post(`${process.env.CSE_COPILOT_ENDPOINT}/answers`, {
|
||||||
json: body,
|
json: body,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: getHmacWithEpoch(),
|
Authorization: getHmacWithEpoch(),
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
isStream: true,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Set response headers
|
// Handle the upstream response before piping
|
||||||
res.setHeader('Content-Type', 'application/x-ndjson')
|
stream.on('response', (upstreamResponse) => {
|
||||||
res.flushHeaders()
|
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
|
// Pipe the got stream directly to the response
|
||||||
stream.pipe(res)
|
stream.pipe(res)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Handle stream errors
|
// Handle stream errors
|
||||||
stream.on('error', (error) => {
|
stream.on('error', (error: any) => {
|
||||||
console.error('Error streaming from cse-copilot:', error)
|
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) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({ errors: [{ message: 'Internal server error' }] })
|
res.status(500).json({ errors: [{ message: 'Internal server error' }] })
|
||||||
} else {
|
} else {
|
||||||
|
// Send error message via the stream
|
||||||
|
const errorMessage =
|
||||||
|
JSON.stringify({ errors: [{ message: 'Internal server error' }] }) + '\n'
|
||||||
|
res.write(errorMessage)
|
||||||
res.end()
|
res.end()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,30 +14,38 @@ export async function getAISearchAutocompleteResults({
|
|||||||
indexName,
|
indexName,
|
||||||
query,
|
query,
|
||||||
size,
|
size,
|
||||||
|
debug = false,
|
||||||
}: AutocompleteResultsArgs): Promise<AutocompleteSearchResponse> {
|
}: AutocompleteResultsArgs): Promise<AutocompleteSearchResponse> {
|
||||||
const t0 = new Date()
|
const t0 = new Date()
|
||||||
const client = getElasticsearchClient() as Client
|
const client = getElasticsearchClient() as Client
|
||||||
|
|
||||||
const matchQueries = getAISearchAutocompleteMatchQueries(query.trim(), {
|
let searchQuery: any = {
|
||||||
fuzzy: {
|
index: indexName,
|
||||||
minLength: 3,
|
size,
|
||||||
maxLength: 20,
|
// Send absolutely minimal from Elasticsearch to here. Less data => faster.
|
||||||
},
|
_source_includes: ['term'],
|
||||||
})
|
|
||||||
const matchQuery = {
|
|
||||||
bool: {
|
|
||||||
should: matchQueries,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = {
|
searchQuery.query = matchQuery
|
||||||
index: indexName,
|
searchQuery.highlight = getHighlightConfiguration(trimmedQuery, ['term'])
|
||||||
highlight,
|
|
||||||
size,
|
|
||||||
query: matchQuery,
|
|
||||||
_source_includes: ['term'],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await client.search<{ term: string }>(searchQuery)
|
const result = await client.search<{ term: string }>(searchQuery)
|
||||||
@@ -46,6 +54,13 @@ export async function getAISearchAutocompleteResults({
|
|||||||
const hits = hitsAll.hits.map((hit) => ({
|
const hits = hitsAll.hits.map((hit) => ({
|
||||||
term: hit._source?.term,
|
term: hit._source?.term,
|
||||||
highlights: (hit.highlight && hit.highlight.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 {
|
return {
|
||||||
|
|||||||
@@ -15,39 +15,62 @@ export async function getAutocompleteSearchResults({
|
|||||||
indexName,
|
indexName,
|
||||||
query,
|
query,
|
||||||
size,
|
size,
|
||||||
|
debug = false,
|
||||||
}: AutocompleteResultsArgs): Promise<AutocompleteSearchResponse> {
|
}: AutocompleteResultsArgs): Promise<AutocompleteSearchResponse> {
|
||||||
const t0 = new Date()
|
const t0 = new Date()
|
||||||
const client = getElasticsearchClient() as Client
|
const client = getElasticsearchClient() as Client
|
||||||
|
|
||||||
const matchQueries = getAutocompleteMatchQueries(query.trim(), {
|
let searchQuery: any = {
|
||||||
fuzzy: {
|
|
||||||
minLength: 3,
|
|
||||||
maxLength: 20,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const matchQuery = {
|
|
||||||
bool: {
|
|
||||||
should: matchQueries,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const highlight = getHighlightConfiguration(query, ['term'])
|
|
||||||
|
|
||||||
const searchQuery = {
|
|
||||||
index: indexName,
|
index: indexName,
|
||||||
highlight,
|
|
||||||
size,
|
size,
|
||||||
query: matchQuery,
|
|
||||||
// Send absolutely minimal from Elasticsearch to here. Less data => faster.
|
// Send absolutely minimal from Elasticsearch to here. Less data => faster.
|
||||||
_source_includes: ['term'],
|
_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 result = await client.search<AutocompleteElasticsearchItem>(searchQuery)
|
||||||
|
|
||||||
const hitsAll = result.hits
|
const hitsAll = result.hits
|
||||||
const hits = hitsAll.hits.map((hit) => ({
|
const hits = hitsAll.hits.map((hit) => ({
|
||||||
term: hit._source?.term,
|
term: hit._source?.term,
|
||||||
highlights: (hit.highlight && hit.highlight.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 {
|
return {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export interface AutocompleteResultsArgs {
|
|||||||
indexName: string
|
indexName: string
|
||||||
query: string
|
query: string
|
||||||
size: number
|
size: number
|
||||||
|
debug?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FuzzyConfig {
|
export interface FuzzyConfig {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Versions used by cse-copilot
|
// Versions used by cse-copilot
|
||||||
import { allVersions } from '@/versions/lib/all-versions'
|
import { allVersions } from '@/versions/lib/all-versions'
|
||||||
|
import { versionToIndexVersionMap } from '../elasticsearch-versions'
|
||||||
const CSE_COPILOT_DOCS_VERSIONS = ['dotcom', 'ghec', 'ghes']
|
const CSE_COPILOT_DOCS_VERSIONS = ['dotcom', 'ghec', 'ghes']
|
||||||
|
|
||||||
// Languages supported by cse-copilot
|
// Languages supported by cse-copilot
|
||||||
@@ -12,7 +13,8 @@ export function getCSECopilotSource(
|
|||||||
version: (typeof CSE_COPILOT_DOCS_VERSIONS)[number],
|
version: (typeof CSE_COPILOT_DOCS_VERSIONS)[number],
|
||||||
language: (typeof DOCS_LANGUAGES)[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)) {
|
if (!CSE_COPILOT_DOCS_VERSIONS.includes(cseCopilotDocsVersion)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid 'version' in request body: '${version}'. Must be one of: ${CSE_COPILOT_DOCS_VERSIONS.join(', ')}`,
|
`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(', ')}`,
|
`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 {
|
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
|
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 express, { Request, Response } from 'express'
|
||||||
|
|
||||||
import FailBot from '@/observability/lib/failbot.js'
|
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 { getAISearchAutocompleteResults } from '@/search/lib/get-elasticsearch-results/ai-search-autocomplete'
|
||||||
import { getSearchFromRequestParams } from '@/search/lib/search-request-params/get-search-from-request-params'
|
import { getSearchFromRequestParams } from '@/search/lib/search-request-params/get-search-from-request-params'
|
||||||
import { getGeneralSearchResults } from '@/search/lib/get-elasticsearch-results/general-search'
|
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()
|
const router = express.Router()
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
router.use(createRateLimiter(10)) // just 1 worker in dev so 10 requests per minute allowed
|
router.use(createRateLimiter(10)) // just 1 worker in dev so 10 requests per minute allowed
|
||||||
} else if (process.env.NODE_ENV === 'production') {
|
} 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) => {
|
router.get('/legacy', (req: Request, res: Response) => {
|
||||||
@@ -69,7 +71,7 @@ router.get(
|
|||||||
const {
|
const {
|
||||||
indexName,
|
indexName,
|
||||||
validationErrors,
|
validationErrors,
|
||||||
searchParams: { query, size },
|
searchParams: { query, size, debug },
|
||||||
} = getSearchFromRequestParams(req, 'generalAutocomplete')
|
} = getSearchFromRequestParams(req, 'generalAutocomplete')
|
||||||
if (validationErrors.length) {
|
if (validationErrors.length) {
|
||||||
return res.status(400).json(validationErrors[0])
|
return res.status(400).json(validationErrors[0])
|
||||||
@@ -79,6 +81,7 @@ router.get(
|
|||||||
indexName,
|
indexName,
|
||||||
query,
|
query,
|
||||||
size,
|
size,
|
||||||
|
debug,
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const { meta, hits } = await getAutocompleteSearchResults(options)
|
const { meta, hits } = await getAutocompleteSearchResults(options)
|
||||||
@@ -98,11 +101,18 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
'/ai-search-autocomplete/v1',
|
'/ai-search-autocomplete/v1',
|
||||||
catchMiddlewareError(async (req: Request, res: Response) => {
|
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 {
|
const {
|
||||||
indexName,
|
indexName,
|
||||||
validationErrors,
|
validationErrors,
|
||||||
searchParams: { query, size },
|
searchParams: { query, size, debug },
|
||||||
} = getSearchFromRequestParams(req, 'aiSearchAutocomplete')
|
} = getSearchFromRequestParams(req, 'aiSearchAutocomplete', force)
|
||||||
if (validationErrors.length) {
|
if (validationErrors.length) {
|
||||||
return res.status(400).json(validationErrors[0])
|
return res.status(400).json(validationErrors[0])
|
||||||
}
|
}
|
||||||
@@ -111,6 +121,7 @@ router.get(
|
|||||||
indexName,
|
indexName,
|
||||||
query,
|
query,
|
||||||
size,
|
size,
|
||||||
|
debug,
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const { meta, hits } = await getAISearchAutocompleteResults(getResultOptions)
|
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') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.error(`Error calling getSearchResults(${options})`, error)
|
console.error(`Error calling getSearchResults(${options})`, error)
|
||||||
} else {
|
} else {
|
||||||
@@ -137,7 +161,7 @@ async function handleGetSearchResultsError(req: Request, res: Response, error: a
|
|||||||
res.status(500).json({ error: error.message })
|
res.status(500).json({ error: error.message })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirects for latest versions
|
// Redirects search routes to their latest versions
|
||||||
router.get('/', (req: Request, res: Response) => {
|
router.get('/', (req: Request, res: Response) => {
|
||||||
res.redirect(307, req.originalUrl.replace('/search', '/search/v1'))
|
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
|
export default router
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from 'src/frame/components/context/MainContext'
|
} from 'src/frame/components/context/MainContext'
|
||||||
import { DefaultLayout } from 'src/frame/components/DefaultLayout'
|
import { DefaultLayout } from 'src/frame/components/DefaultLayout'
|
||||||
import { SearchContext } from 'src/search/components/context/SearchContext'
|
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 { SearchOnReqObject } from 'src/search/types'
|
||||||
import type { SearchContextT } from 'src/search/components/types'
|
import type { SearchContextT } from 'src/search/components/types'
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ describeIfElasticsearchURL('search/ai-search-autocomplete v1 middleware', () =>
|
|||||||
const sp = new URLSearchParams()
|
const sp = new URLSearchParams()
|
||||||
sp.set('query', 'fo')
|
sp.set('query', 'fo')
|
||||||
sp.set('version', 'never-heard-of')
|
sp.set('version', 'never-heard-of')
|
||||||
const res = await get(`${aiSearchEndpoint}?{sp}`)
|
const res = await get(`${aiSearchEndpoint}?${sp}`)
|
||||||
expect(res.statusCode).toBe(400)
|
expect(res.statusCode).toBe(400)
|
||||||
expect(JSON.parse(res.body).error).toBeTruthy()
|
expect(JSON.parse(res.body).error).toBeTruthy()
|
||||||
})
|
})
|
||||||
@@ -134,31 +134,24 @@ describeIfElasticsearchURL('search/ai-search-autocomplete v1 middleware', () =>
|
|||||||
const res = await get(getSearchEndpointWithParams(sp))
|
const res = await get(getSearchEndpointWithParams(sp))
|
||||||
expect(res.statusCode).toBe(200)
|
expect(res.statusCode).toBe(200)
|
||||||
const results = JSON.parse(res.body) as AutocompleteSearchResponse
|
const results = JSON.parse(res.body) as AutocompleteSearchResponse
|
||||||
console.log(JSON.stringify(results, null, 2))
|
|
||||||
const hit = results.hits[0]
|
const hit = results.hits[0]
|
||||||
expect(hit.term).toBe('How do I clone a repository?')
|
expect(hit.term).toBe('How do I clone a repository?')
|
||||||
expect(hit.highlights).toBeTruthy()
|
expect(hit.highlights).toBeTruthy()
|
||||||
expect(hit.highlights[0]).toBe('How do I <mark>clone</mark> a repository?')
|
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()
|
const sp = new URLSearchParams()
|
||||||
// No query at all
|
// No query at all
|
||||||
{
|
{
|
||||||
const res = await get(getSearchEndpointWithParams(sp))
|
const res = await get(getSearchEndpointWithParams(sp))
|
||||||
expect(res.statusCode).toBe(400)
|
expect(res.statusCode).toBe(200)
|
||||||
}
|
}
|
||||||
// Empty query
|
// Empty query
|
||||||
{
|
{
|
||||||
sp.set('query', '')
|
sp.set('query', '')
|
||||||
const res = await get(getSearchEndpointWithParams(sp))
|
const res = await get(getSearchEndpointWithParams(sp))
|
||||||
expect(res.statusCode).toBe(400)
|
expect(res.statusCode).toBe(200)
|
||||||
}
|
|
||||||
// Empty when trimmed
|
|
||||||
{
|
|
||||||
sp.set('query', ' ')
|
|
||||||
const res = await get(getSearchEndpointWithParams(sp))
|
|
||||||
expect(res.statusCode).toBe(400)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
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 { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AdditionalIncludes,
|
AdditionalIncludes,
|
||||||
ComputedSearchQueryParamsMap,
|
ComputedSearchQueryParamsMap,
|
||||||
@@ -20,6 +21,11 @@ export interface AutocompleteSearchResponse {
|
|||||||
hits: AutocompleteSearchHit[]
|
hits: AutocompleteSearchHit[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CombinedAutocompleteSearchResponse {
|
||||||
|
aiAutocomplete: AutocompleteSearchResponse
|
||||||
|
generalAutocomplete: AutocompleteSearchResponse
|
||||||
|
}
|
||||||
|
|
||||||
// Response to middleware /search route
|
// Response to middleware /search route
|
||||||
export interface SearchOnReqObject<Type extends SearchTypes> {
|
export interface SearchOnReqObject<Type extends SearchTypes> {
|
||||||
searchParams: ComputedSearchQueryParamsMap[Type]
|
searchParams: ComputedSearchQueryParamsMap[Type]
|
||||||
@@ -52,7 +58,7 @@ export type GeneralSearchHit = GeneralSearchHitWithoutIncludes & {
|
|||||||
[key in AdditionalIncludes]?: string
|
[key in AdditionalIncludes]?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AutocompleteSearchHit {
|
export interface AutocompleteSearchHit {
|
||||||
term?: string
|
term?: string
|
||||||
highlights: string[]
|
highlights: string[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,12 @@ const RECOGNIZED_KEYS_BY_ANY = new Set([
|
|||||||
'tool',
|
'tool',
|
||||||
// When apiVersion isn't the only one. E.g. ?apiVersion=XXX&tool=vscode
|
// When apiVersion isn't the only one. E.g. ?apiVersion=XXX&tool=vscode
|
||||||
'apiVersion',
|
'apiVersion',
|
||||||
// Search
|
// Search results page
|
||||||
'query',
|
'query',
|
||||||
|
// Any page, Search Overlay
|
||||||
|
'search-overlay-input',
|
||||||
|
'search-overlay-open',
|
||||||
|
'search-overlay-ask-ai',
|
||||||
// The drop-downs on "Webhook events and payloads"
|
// The drop-downs on "Webhook events and payloads"
|
||||||
'actionType',
|
'actionType',
|
||||||
// Used by the tracking middleware
|
// Used by the tracking middleware
|
||||||
|
|||||||
Reference in New Issue
Block a user