AbdulElahGwaith's picture
Upload folder using huggingface_hub
88df9e4 verified
import React, { useState, useRef, RefObject, useEffect, SetStateAction, useMemo } from 'react'
import cx from 'classnames'
import { useRouter } from 'next/router'
import { ActionList, IconButton, Overlay, Spinner, Stack, TextInput, Banner } from '@primer/react'
import {
SearchIcon,
XCircleFillIcon,
CommentIcon,
CopilotIcon,
FileIcon,
ArrowRightIcon,
ArrowLeftIcon,
} from '@primer/octicons-react'
import { focusTrap } from '@primer/behaviors'
import { useTranslation } from '@/languages/components/useTranslation'
import { useVersion } from '@/versions/components/useVersion'
import {
AI_SEARCH_CONTEXT,
executeGeneralSearch,
GENERAL_SEARCH_CONTEXT,
} from '../helpers/execute-search-actions'
import { useCombinedSearchResults } from '@/search/components/hooks/useAISearchAutocomplete'
import { AskAIResults } from './AskAIResults'
import { sendEvent, uuidv4 } from '@/events/components/events'
import { EventType } from '@/events/types'
import { ASK_AI_EVENT_GROUP, SEARCH_OVERLAY_EVENT_GROUP } from '@/events/components/event-groups'
import { useSharedUIContext } from '@/frame/components/context/SharedUIContext'
import type { AIReference } from '../types'
import type { AutocompleteSearchHit, GeneralSearchHit } from '@/search/types'
import { sanitizeSearchQuery } from '@/search/lib/sanitize-search-query'
import styles from './SearchOverlay.module.scss'
type Props = {
searchOverlayOpen: boolean
parentRef: RefObject<HTMLElement>
debug: boolean
onClose: () => void
params: {
'search-overlay-input': string
'search-overlay-ask-ai': string
}
updateParams: (
updates: Partial<{
'search-overlay-input': string
'search-overlay-ask-ai': string
}>,
) => void
}
// Upon clicking the SearchInput component this overlay will be displayed
export function SearchOverlay({
searchOverlayOpen,
parentRef,
debug,
onClose,
params,
updateParams,
}: Props) {
const { t } = useTranslation('search')
const { currentVersion } = useVersion()
const router = useRouter()
// Map props from multi query state
const urlSearchInputQuery = params['search-overlay-input']
const isAskAIState = params['search-overlay-ask-ai'] === 'true'
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>(-1)
const [aiQuery, setAIQuery] = useState<string>(urlSearchInputQuery)
const [aiSearchError, setAISearchError] = useState<boolean>(false)
const [aiReferences, setAIReferences] = useState<AIReference[]>([] as AIReference[])
const [aiCouldNotAnswer, setAICouldNotAnswer] = useState<boolean>(false)
const [showSpinner, setShowSpinner] = useState(false)
const [scrollPos, setScrollPos] = useState(0)
const [announcement, setAnnouncement] = useState<string>('')
const { hasOpenHeaderNotifications } = useSharedUIContext()
// Group all events between open / close of the overlay together
const searchEventGroupId = useRef<string>('')
const overlayRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (searchOverlayOpen && overlayRef.current) {
focusTrap(overlayRef.current, inputRef.current || undefined)
}
}, [searchOverlayOpen])
useEffect(() => {
searchEventGroupId.current = uuidv4()
}, [searchOverlayOpen])
// Group all events within an "Ask AI" session together
const askAIEventGroupId = useRef<string>('')
// When there is a notification above the header, we need to adjust the top position of the overlay to account for it
useEffect(() => {
if (hasOpenHeaderNotifications) {
const handleScroll = () => {
setScrollPos(window.scrollY)
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}
}, [hasOpenHeaderNotifications])
const overlayTopValue = scrollPos > 72 ? '0px' : `${88 - scrollPos}px !important`
const {
autoCompleteOptions,
searchLoading,
setSearchLoading,
searchError: autoCompleteSearchError,
updateAutocompleteResults,
clearAutocompleteResults,
} = useCombinedSearchResults({
router,
currentVersion,
debug,
})
const { aiAutocompleteOptions, generalSearchResults, totalGeneralSearchResults } =
autoCompleteOptions
// Whenever "searchLoading" changes, decide whether to show the spinner after 1s.
useEffect(() => {
let timer: ReturnType<typeof setTimeout>
if (autoCompleteSearchError) {
return setShowSpinner(false)
}
// If it's the initial fetch, show the spinner immediately
if (!aiAutocompleteOptions.length && !generalSearchResults.length && searchLoading) {
return setShowSpinner(true)
}
if (searchLoading) {
timer = setTimeout(() => setShowSpinner(true), 1000)
} else {
setShowSpinner(false)
}
return () => {
clearTimeout(timer)
}
}, [
searchLoading,
aiAutocompleteOptions.length,
generalSearchResults.length,
autoCompleteSearchError,
])
// 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,
title: urlSearchInputQuery,
highlights: [],
isUserQuery: true,
},
]
: []
// Combine options for key navigation
const [combinedOptions, generalOptionsWithViewStatus, aiOptionsWithUserInput] = useMemo(() => {
setAnnouncement('')
let generalWithView = [...generalSearchResults]
const aiWithUser = [...userInputOptions, ...filteredAIOptions]
const combined = [] as Array<{
group: 'general' | 'ai' | string
url?: string
option: AutocompleteSearchHitWithUserQuery | GeneralSearchHitWithOptions
}>
if (generalSearchResults.length > 0) {
generalWithView.push({
title: t('search.overlay.view_all_search_results'),
isViewAllResults: true,
} as any)
} else if (autoCompleteSearchError) {
if (urlSearchInputQuery.trim() !== '') {
generalWithView.push({
...(userInputOptions[0] || {}),
isSearchDocsOption: true,
} as unknown as GeneralSearchHit)
}
} else if (urlSearchInputQuery.trim() !== '' && !searchLoading) {
setAnnouncement(t('search.overlay.no_results_found_announcement'))
generalWithView.push({
title: t('search.overlay.no_results_found'),
isNoResultsFound: true,
} as any)
} else {
generalWithView = []
}
// NOTE: Order of combinedOptions is important, since 'selectedIndex' is used to navigate the combinedOptions array
// Add general options _before_ AI options
combined.push(...generalWithView.map((option) => ({ group: 'general', option })))
// On AI Error, don't include AI suggestions, only user input
if (!aiSearchError && !isAskAIState) {
combined.push(...aiWithUser.map((option) => ({ group: 'ai', option })))
} else if (isAskAIState && !aiCouldNotAnswer) {
// When "ask ai" state is reached, we have references that are ActionList items.
// We want to navigate these items via the keyboard, so include them in the combinedOptions array
combined.push(
...aiReferences.map((option) => ({
group: 'reference', // The references are actually article URLs that we want to navigate to
url: option.url,
option: {
term: option.title,
highlights: [],
isUserQuery: false,
},
})),
)
}
return [combined, generalWithView, aiWithUser]
}, [
generalSearchResults,
totalGeneralSearchResults,
urlSearchInputQuery,
aiSearchError,
aiReferences,
isAskAIState,
autoCompleteSearchError,
])
// Rather than use `initialFocusRef` to have our Primer <Overlay> component auto-focus our input
// We manually focus on open using a useEffect so we can focus _without_ scrolling since we don't want
// to scroll to the top of the page each time the SearchOverlay is opened
useEffect(() => {
if (searchOverlayOpen) {
inputRef.current?.focus({
preventScroll: true,
})
}
}, [searchOverlayOpen])
// Fetch initial search results on open
useEffect(() => {
if (searchOverlayOpen) {
if (!searchEventGroupId.current) {
searchEventGroupId.current = uuidv4()
}
updateAutocompleteResults(urlSearchInputQuery)
} else {
// When opening the overlay via query params, we don't need to fetch autocomplete results
// However, on initial open, we need to clear the loading state
setSearchLoading(false)
}
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,
aiCouldNotAnswer,
])
// 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,
generalOptionsWithViewStatus.length + aiOptionsWithUserInput.length,
)
}, [generalOptionsWithViewStatus, aiOptionsWithUserInput])
// When loading, capture the last height of the suggestions list so we can use it for the loading div
const previousSuggestionsListHeight = useMemo(() => {
if (generalSearchResults.length || aiAutocompleteOptions.length) {
return `${7 * (generalSearchResults.length + aiAutocompleteOptions.length)}`
} else {
return '150' // Default height for just 2 suggestions
}
}, [searchLoading])
// When the user types in the search input, update the local query and fetch autocomplete results
const handleSearchQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => {
event.preventDefault()
const newQuery = event.target.value
setSelectedIndex(-1) // Reset selected index when query changes
// Whenever the query changes, we want to leave the Ask AI state
setSearchLoading(true)
updateAutocompleteResults(newQuery)
if (isAskAIState) {
updateParams({
'search-overlay-ask-ai': '',
'search-overlay-input': newQuery,
})
} else {
updateParams({
'search-overlay-input': newQuery,
})
}
}
// When a general option is selected, open the article in the current window
const generalSearchResultOnSelect = (selectedOption: GeneralSearchHit) => {
sendEvent({
type: EventType.search,
search_query: sanitizeSearchQuery(urlSearchInputQuery),
search_context: GENERAL_SEARCH_CONTEXT,
eventGroupKey: SEARCH_OVERLAY_EVENT_GROUP,
eventGroupId: searchEventGroupId.current,
})
sendEvent({
type: EventType.searchResult,
search_result_query: sanitizeSearchQuery(urlSearchInputQuery),
search_result_index: selectedIndex,
search_result_total: totalGeneralSearchResults,
search_result_url: selectedOption.url || '',
search_result_rank: (totalGeneralSearchResults - selectedIndex) / totalGeneralSearchResults,
eventGroupKey: SEARCH_OVERLAY_EVENT_GROUP,
eventGroupId: searchEventGroupId.current,
})
const searchParams = new URLSearchParams((router.query as Record<string, string>) || {})
if (searchParams.has('search-overlay-open')) {
searchParams.delete('search-overlay-open')
}
if (searchParams.has('search-overlay-input')) {
searchParams.delete('search-overlay-input')
}
if (searchParams.has('search-overlay-ask-ai')) {
searchParams.delete('search-overlay-ask-ai')
}
if (searchParams.has('query')) {
searchParams.delete('query')
}
router.push(`${selectedOption.url}?${searchParams.toString()}`)
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) {
askAIEventGroupId.current = uuidv4()
// Fire event from onSelect instead of inside the API request function (executeAISearch), because the result could be cached and not trigger an event
sendEvent({
type: EventType.search,
search_query: 'REDACTED',
search_context: AI_SEARCH_CONTEXT,
eventGroupKey: ASK_AI_EVENT_GROUP,
eventGroupId: askAIEventGroupId.current,
})
setSelectedIndex(-1)
setSearchLoading(true)
updateParams({
'search-overlay-ask-ai': 'true',
'search-overlay-input': selectedOption.term,
})
setAIQuery(selectedOption.term)
inputRef.current?.focus()
}
}
const performGeneralSearch = () => {
executeGeneralSearch(router, currentVersion, urlSearchInputQuery, debug)
onClose()
}
// When a reference from an "Ask AI" result is selected, navigate to the reference
const referenceOnSelect = (url: string) => {
sendEvent({
type: EventType.link,
link_url: url || '',
eventGroupKey: ASK_AI_EVENT_GROUP,
eventGroupId: askAIEventGroupId.current,
})
setSelectedIndex(-1)
const searchParams = new URLSearchParams((router.query as Record<string, string>) || {})
if (searchParams.has('search-overlay-open')) {
searchParams.delete('search-overlay-open')
}
if (searchParams.has('search-overlay-input')) {
searchParams.delete('search-overlay-input')
}
if (searchParams.has('search-overlay-ask-ai')) {
searchParams.delete('search-overlay-ask-ai')
}
if (searchParams.has('query')) {
searchParams.delete('query')
}
window.open(`${url}?${searchParams.toString()}`, '_blank')
}
// Handle keyboard navigation of suggestions
const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
const optionsLength = listElementsRef.current?.length ?? 0
if (event.key === 'ArrowDown') {
event.preventDefault()
if (optionsLength > 0) {
let newIndex = 0
// If no item is selected, select the first item
if (selectedIndex === -1) {
newIndex = 0
} else {
newIndex = (selectedIndex + 1) % optionsLength
// If we go "out of bounds" (i.e. the index is less than the selected index), unselect the item
if (newIndex < selectedIndex) {
newIndex = -1
}
}
// If it's the "no results found" option, skip it
if (
newIndex >= selectedIndex &&
(combinedOptions[newIndex]?.option as any)?.isNoResultsFound
) {
newIndex += 1
}
setSelectedIndex(newIndex)
if (newIndex !== -1 && listElementsRef.current[newIndex]) {
listElementsRef.current[newIndex]?.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
}
}
} else if (event.key === 'ArrowUp') {
event.preventDefault()
if (optionsLength > 0) {
let newIndex = 0
// If no item is selected, select the last item
if (selectedIndex === -1) {
newIndex = optionsLength - 1
} else {
// Otherwise, select the previous item
newIndex = (selectedIndex - 1 + optionsLength) % optionsLength
// If we go "out of bounds" (i.e. the index is greater than the selected index), unselect the item
if (newIndex > selectedIndex) {
newIndex = -1
// If it's the "no results found" option, skip it
}
}
// If it's the "no results found" option, skip it
if (
newIndex <= selectedIndex &&
(combinedOptions[newIndex]?.option as any)?.isNoResultsFound
) {
newIndex -= 1
}
setSelectedIndex(newIndex)
if (newIndex !== -1 && listElementsRef.current[newIndex]) {
listElementsRef.current[newIndex]?.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
}
}
} else if (event.key === 'Enter') {
event.preventDefault()
let pressedGroupKey = SEARCH_OVERLAY_EVENT_GROUP
let pressedGroupId = searchEventGroupId
let pressedOnContext = ''
// When enter is pressed and no option is manually selected (-1), perform an AI search with the user input
if (selectedIndex === -1) {
pressedOnContext = AI_SEARCH_CONTEXT
pressedGroupKey = ASK_AI_EVENT_GROUP
pressedGroupId = askAIEventGroupId
sendKeyboardEvent(event.key, pressedOnContext, pressedGroupId, pressedGroupKey)
aiSearchOptionOnSelect({ term: urlSearchInputQuery } as AutocompleteSearchHit)
} else if (
combinedOptions.length > 0 &&
selectedIndex >= 0 &&
selectedIndex < combinedOptions.length
) {
const selectedItem = combinedOptions[selectedIndex]
if (!selectedItem) {
return
}
let action = () => {} // Execute the action after we send the event
if (selectedItem?.group === 'general') {
if (
(selectedItem.option as GeneralSearchHitWithOptions).isViewAllResults ||
(selectedItem.option as GeneralSearchHitWithOptions).isSearchDocsOption
) {
pressedOnContext = 'view-all'
action = performGeneralSearch
} else {
pressedOnContext = 'general-option'
action = () => generalSearchResultOnSelect(selectedItem.option as GeneralSearchHit)
}
} else if (selectedItem?.group === 'ai') {
pressedOnContext = 'ai-option'
action = () => aiSearchOptionOnSelect(selectedItem.option as AutocompleteSearchHit)
} else if (selectedItem?.group === 'reference') {
// On a reference select, we are in the Ask AI State / Screen
pressedGroupKey = ASK_AI_EVENT_GROUP
pressedGroupId = askAIEventGroupId
pressedOnContext = 'reference-option'
action = () => referenceOnSelect(selectedItem.url || '')
}
sendKeyboardEvent(event.key, pressedOnContext, pressedGroupId, pressedGroupKey)
return action()
}
} else if (event.key === 'Escape') {
event.preventDefault()
onClose() // Close the input overlay when Escape is pressed
}
}
const onBackButton = () => {
// Leave the Ask AI state when the user clicks the back button
setSelectedIndex(-1)
updateParams({
'search-overlay-ask-ai': '',
'search-overlay-input': urlSearchInputQuery,
})
// Focus the search input
inputRef.current?.focus()
}
// We render the AI Result in the searchGroups call, so we pass the props down via an object
// TODO: Move stateful logic to Context since we now have so many props:
const askAIState = {
isAskAIState,
aiQuery,
debug,
currentVersion,
setAISearchError: (isError = true) => {
setAISearchError(isError)
if (isError) {
updateParams({
'search-overlay-ask-ai': '',
})
}
},
references: aiReferences,
setReferences: setAIReferences,
referencesIndexOffset: generalOptionsWithViewStatus.length,
referenceOnSelect,
askAIEventGroupId,
aiSearchError,
aiCouldNotAnswer,
setAICouldNotAnswer,
}
// 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')}
id="search-suggestions-list"
showDividers
className={styles.suggestionsList}
ref={suggestionsListHeightRef}
style={{
minHeight:
autoCompleteSearchError && !generalOptionsWithViewStatus.length
? '0'
: `${previousSuggestionsListHeight}px`,
}}
>
{/* Always show the AI Search UI error message when it is needed */}
{aiSearchError && (
<>
<ActionList.Divider key="error-top-divider" />
<li tabIndex={-1}>
<ActionList.GroupHeading
as="h3"
tabIndex={-1}
aria-label={t('search.overlay.ai_suggestions_list_aria_label')}
>
<CopilotIcon className="mr-1" />
{t('search.overlay.ai_autocomplete_list_heading')}
</ActionList.GroupHeading>
</li>
<li>
<div className={styles.overlayPadding}>
<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>
</li>
{/* If there are general results, show bottom divider */}
{generalOptionsWithViewStatus.length > 0 && (
<ActionList.Divider key="error-middle-divider" />
)}
</>
)}
{renderSearchGroups(
t,
generalOptionsWithViewStatus,
aiSearchError ? [] : aiOptionsWithUserInput,
generalSearchResultOnSelect,
aiSearchOptionOnSelect,
performGeneralSearch,
selectedIndex,
listElementsRef,
askAIState,
showSpinner,
searchLoading,
previousSuggestionsListHeight,
)}
</ActionList>
</>
)
} else {
OverlayContents = (
<ActionList
id="search-suggestions-list"
aria-label={t('search.overlay.suggestions_list_aria_label')}
showDividers
className={styles.suggestionsList}
ref={suggestionsListHeightRef}
style={{
minHeight: `${previousSuggestionsListHeight}px`,
}}
>
{renderSearchGroups(
t,
generalOptionsWithViewStatus,
aiOptionsWithUserInput,
generalSearchResultOnSelect,
aiSearchOptionOnSelect,
performGeneralSearch,
selectedIndex,
listElementsRef,
askAIState,
showSpinner,
searchLoading,
previousSuggestionsListHeight,
)}
</ActionList>
)
}
return (
<>
<div className={styles.overlayBackdrop} />
<Overlay
preventFocusOnOpen
initialFocusRef={inputRef}
returnFocusRef={parentRef}
ignoreClickRefs={[parentRef]}
onEscape={onClose}
onClickOutside={onClose}
anchorSide="inside-center"
className={cx(styles.overlayContainer, 'position-fixed')}
// We need to override the top value of the overlay when there are header notifications
style={
hasOpenHeaderNotifications
? {
top: overlayTopValue,
}
: undefined
}
role="dialog"
aria-modal="true"
aria-label={t('search.overlay.aria_label')}
ref={overlayRef}
>
<div className={styles.header}>
<div className={isAskAIState ? styles.askAILabel : styles.askAILabelHidden}>
<IconButton
aria-label={t('search.ai.back_to_search')}
icon={ArrowLeftIcon}
onClick={onBackButton}
variant="invisible"
></IconButton>
</div>
<TextInput
className="width-full"
data-testid="overlay-search-input"
ref={inputRef}
value={urlSearchInputQuery}
onChange={handleSearchQueryChange}
leadingVisual={<SearchIcon />}
role="combobox"
// In AskAI the search input not longer "controls" the suggestions list, because there is no list, so we remove the aria-controls attribute
aria-controls={isAskAIState ? 'ask-ai-result-container' : 'search-suggestions-list'}
aria-expanded={combinedOptions.length > 0}
aria-label={t('search.overlay.input_aria_label')}
aria-activedescendant={
selectedIndex >= 0
? `search-option-${combinedOptions[selectedIndex]?.group}-${selectedIndex}`
: undefined
}
onKeyDown={handleKeyDown}
placeholder={t('search.input.placeholder_no_icon')}
trailingAction={
<Stack justify="center" className={styles.stackMinWidth}>
<TextInput.Action
onClick={() => {
setSelectedIndex(-1)
if (isAskAIState) {
updateParams({
'search-overlay-ask-ai': '',
'search-overlay-input': '',
})
} else {
updateParams({
'search-overlay-input': '',
})
}
if (!isAskAIState) {
updateAutocompleteResults('')
}
}}
icon={XCircleFillIcon}
aria-label={t('search.overlay.clear_search_query')}
/>
</Stack>
}
/>
</div>
<ActionList.Divider
className={inErrorState ? styles.dividerTopMarginHidden : styles.dividerTopMargin}
aria-hidden="true"
/>
{OverlayContents}
<ActionList.Divider className={styles.dividerFullWidth} />
<div key="description" className={styles.footer}>
<p
className={styles.privacyDisclaimer}
dangerouslySetInnerHTML={{ __html: t('search.overlay.privacy_disclaimer') }}
/>
</div>
<div aria-live="assertive" className={styles.screenReaderOnly}>
{announcement}
</div>
</Overlay>
</>
)
}
interface AutocompleteSearchHitWithUserQuery extends AutocompleteSearchHit {
isUserQuery?: boolean
}
interface GeneralSearchHitWithOptions extends GeneralSearchHit {
isViewAllResults?: boolean
isNoResultsFound?: boolean
isSearchDocsOption?: boolean
}
// Render the autocomplete suggestions with AI suggestions first, headings, and a divider between the two
function renderSearchGroups(
t: any,
generalSearchOptions: GeneralSearchHitWithOptions[],
aiOptionsWithUserInput: AutocompleteSearchHitWithUserQuery[],
generalSearchResultOnSelect: (selectedOption: GeneralSearchHit) => void,
aiAutocompleteOnSelect: (selectedOption: AutocompleteSearchHit) => void,
performGeneralSearch: () => void,
selectedIndex: number,
listElementsRef: RefObject<Array<HTMLLIElement | null>>,
askAIState: {
isAskAIState: boolean
aiQuery: string
debug: boolean
currentVersion: string
setAISearchError: () => void
references: AIReference[]
setReferences: (value: SetStateAction<AIReference[]>) => void
referencesIndexOffset: number
referenceOnSelect: (url: string) => void
askAIEventGroupId: React.MutableRefObject<string>
aiSearchError: boolean
aiCouldNotAnswer: boolean
setAICouldNotAnswer: (value: boolean) => void
},
showSpinner: boolean,
searchLoading: boolean,
previousSuggestionsListHeight: number | string,
) {
const groups = []
const isInAskAIState = askAIState?.isAskAIState && !askAIState.aiSearchError
const isInAskAIStateButNoAnswer = isInAskAIState && askAIState.aiCouldNotAnswer
// This spinner is for both the AI search and the general search results.
// We already show a spinner when streaming AI response, so don't want to show 2 here
if (showSpinner && !isInAskAIState) {
groups.push(
<div
key="loading"
role="status"
className={styles.loadingContainer}
style={{
height: `${previousSuggestionsListHeight}px`,
}}
>
<Spinner />
</div>,
)
return groups
}
// We want to show general search suggestions above the AI Response section if the AI could not answer
if (generalSearchOptions.length || isInAskAIStateButNoAnswer) {
const items = []
for (let index = 0; index < generalSearchOptions.length; index++) {
const option = generalSearchOptions[index]
if (option.isNoResultsFound) {
items.push(
<ActionList.Item
key={`general-${index}`}
id={`search-option-general-${index}`}
className={styles.noResultsFound}
tabIndex={-1}
aria-label={t('search.overlay.no_results_found')}
disabled
>
{option.title}
</ActionList.Item>,
)
// There should be no more items after the no results found item
break
// This is a special case where there is an error loading search results and we want to be able to search the docs using the user's query
} else if (option.isSearchDocsOption) {
const isActive = selectedIndex === index
items.push(
<ActionList.Item
key={`general-${index}`}
id={`search-option-general-${index}`}
tabIndex={-1}
active={isActive}
onSelect={() => performGeneralSearch()}
aria-label={t('search.overlay.search_docs_with_query').replace('{query}', option.title)}
ref={(element: HTMLLIElement | null) => {
if (listElementsRef.current) {
listElementsRef.current[index] = element
}
}}
>
<ActionList.LeadingVisual aria-hidden>
<SearchIcon />
</ActionList.LeadingVisual>
{option.title}
<ActionList.TrailingVisual
aria-hidden
className={isActive ? styles.trailingVisualActive : styles.trailingVisualHidden}
>
<ArrowRightIcon />
</ActionList.TrailingVisual>
</ActionList.Item>,
)
} else if (option.title) {
const isActive = selectedIndex === index
items.push(
<ActionList.Item
key={`general-${index}`}
id={`search-option-general-${index}`}
aria-describedby="search-suggestions-list"
onSelect={() =>
option.isViewAllResults ? performGeneralSearch() : generalSearchResultOnSelect(option)
}
className={option.isViewAllResults ? styles.viewAllSearchResults : ''}
active={isActive}
tabIndex={-1}
ref={(element: HTMLLIElement | null) => {
if (listElementsRef.current) {
listElementsRef.current[index] = element
}
}}
>
{!option.isNoResultsFound && (
<ActionList.LeadingVisual
aria-hidden
className={
option.isViewAllResults ? styles.leadingVisualHidden : styles.leadingVisualVisible
}
>
<FileIcon />
</ActionList.LeadingVisual>
)}
{option.title}
<ActionList.TrailingVisual
aria-hidden
className={isActive ? styles.trailingVisualActive : styles.trailingVisualHidden}
>
<ArrowRightIcon />
</ActionList.TrailingVisual>
</ActionList.Item>,
)
}
}
groups.push(
<ActionList.Group key="general" data-testid="general-autocomplete-suggestions">
<ActionList.GroupHeading as="h3" tabIndex={-1}>
{t('search.overlay.general_suggestions_list_heading')}
</ActionList.GroupHeading>
{searchLoading && isInAskAIState ? (
<div
role="status"
className={styles.loadingContainer}
style={{
height: `${previousSuggestionsListHeight}px`,
}}
>
<Spinner />
</div>
) : (
items
)}
</ActionList.Group>,
)
if (isInAskAIState || isInAskAIStateButNoAnswer) {
groups.push(<ActionList.Divider key="no-answer-divider" />)
}
if (isInAskAIState) {
groups.push(
<ActionList.Group key="ai" data-testid="ask-ai">
<li tabIndex={-1}>
<AskAIResults
query={askAIState.aiQuery}
debug={askAIState.debug}
version={askAIState.currentVersion}
setAISearchError={askAIState.setAISearchError}
references={askAIState.references}
setReferences={askAIState.setReferences}
referencesIndexOffset={askAIState.referencesIndexOffset}
referenceOnSelect={askAIState.referenceOnSelect}
selectedIndex={selectedIndex}
askAIEventGroupId={askAIState.askAIEventGroupId}
aiCouldNotAnswer={askAIState.aiCouldNotAnswer}
setAICouldNotAnswer={askAIState.setAICouldNotAnswer}
listElementsRef={listElementsRef}
/>
</li>
</ActionList.Group>,
)
}
// Don't show the bottom divider if:
// 1. We are in the AI could not answer state
// 2. We are in the AI Search error state
// 3. There are no AI suggestions to show in suggestions state
if (
!isInAskAIState &&
!askAIState.aiSearchError &&
generalSearchOptions.filter((option) => !option.isViewAllResults && !option.isNoResultsFound)
.length &&
aiOptionsWithUserInput.length
) {
groups.push(<ActionList.Divider key="bottom-divider" />)
}
}
if (aiOptionsWithUserInput.length && !isInAskAIState) {
groups.push(
<ActionList.Group key="ai-suggestions" data-testid="ai-autocomplete-suggestions">
<ActionList.GroupHeading as="h3" id="copilot-suggestions" tabIndex={-1}>
<CopilotIcon className="mr-1" />
{t('search.overlay.ai_autocomplete_list_heading')}
</ActionList.GroupHeading>
{aiOptionsWithUserInput.map((option: AutocompleteSearchHitWithUserQuery, index: number) => {
// Since general search comes first, we need to add an offset for AI suggestions
const indexWithOffset = generalSearchOptions.length + index
const isActive = selectedIndex === indexWithOffset
const item = (
<ActionList.Item
key={`ai-${indexWithOffset}`}
id={`search-option-ai-${indexWithOffset}`}
aria-describedby="copilot-suggestions"
onSelect={() => aiAutocompleteOnSelect(option)}
active={isActive}
tabIndex={-1}
ref={(element: HTMLLIElement | null) => {
if (listElementsRef.current) {
listElementsRef.current[index] = element
}
}}
>
<ActionList.LeadingVisual aria-hidden>
<CommentIcon />
</ActionList.LeadingVisual>
{option.term}
<ActionList.TrailingVisual
aria-hidden
className={isActive ? styles.trailingVisualActive : styles.trailingVisualHidden}
>
<ArrowRightIcon />
</ActionList.TrailingVisual>
</ActionList.Item>
)
return item
})}
</ActionList.Group>,
)
}
return groups
}
function sendKeyboardEvent(
pressedKey: string,
pressedOn: string,
searchEventGroupId: React.MutableRefObject<string>,
searchEventGroupKey = SEARCH_OVERLAY_EVENT_GROUP,
) {
sendEvent({
type: EventType.keyboard,
pressed_key: pressedKey,
pressed_on: pressedOn,
eventGroupKey: searchEventGroupKey,
eventGroupId: searchEventGroupId.current,
})
}