diff --git a/src/search/components/input/SearchContext.tsx b/src/search/components/input/SearchContext.tsx new file mode 100644 index 0000000000..b1fe3ae3c1 --- /dev/null +++ b/src/search/components/input/SearchContext.tsx @@ -0,0 +1,54 @@ +import { createContext, useContext, RefObject, SetStateAction, MutableRefObject } from 'react' +import type { AIReference } from '../types' +import type { AutocompleteSearchHit, GeneralSearchHit } from '@/search/types' + +export interface AutocompleteSearchHitWithUserQuery extends AutocompleteSearchHit { + isUserQuery?: boolean +} + +export interface GeneralSearchHitWithOptions extends GeneralSearchHit { + isViewAllResults?: boolean + isNoResultsFound?: boolean + isSearchDocsOption?: boolean +} + +export interface AskAIState { + isAskAIState: boolean + aiQuery: string + debug: boolean + currentVersion: string + setAISearchError: (isError?: boolean) => void + references: AIReference[] + setReferences: (value: SetStateAction) => void + referencesIndexOffset: number + referenceOnSelect: (url: string) => void + askAIEventGroupId: MutableRefObject + aiSearchError: boolean + aiCouldNotAnswer: boolean + setAICouldNotAnswer: (value: boolean) => void +} + +export interface SearchContextType { + t: any + generalSearchOptions: GeneralSearchHitWithOptions[] + aiOptionsWithUserInput: AutocompleteSearchHitWithUserQuery[] + generalSearchResultOnSelect: (selectedOption: GeneralSearchHit) => void + aiAutocompleteOnSelect: (selectedOption: AutocompleteSearchHit) => void + performGeneralSearch: () => void + selectedIndex: number + listElementsRef: RefObject> + askAIState: AskAIState + showSpinner: boolean + searchLoading: boolean + previousSuggestionsListHeight: number | string +} + +export const SearchContext = createContext(null) + +export const useSearchContext = () => { + const context = useContext(SearchContext) + if (!context) { + throw new Error('useSearchContext must be used within a SearchContext.Provider') + } + return context +} diff --git a/src/search/components/input/SearchGroups.tsx b/src/search/components/input/SearchGroups.tsx new file mode 100644 index 0000000000..1f9adac0e0 --- /dev/null +++ b/src/search/components/input/SearchGroups.tsx @@ -0,0 +1,251 @@ +import React from 'react' +import { ActionList, Spinner } from '@primer/react' +import { + SearchIcon, + FileIcon, + ArrowRightIcon, + CopilotIcon, + CommentIcon, +} from '@primer/octicons-react' + +import { AskAIResults } from './AskAIResults' +import { useSearchContext, AutocompleteSearchHitWithUserQuery } from './SearchContext' +import styles from './SearchOverlay.module.scss' + +export function SearchGroups() { + const { + t, + generalSearchOptions, + aiOptionsWithUserInput, + generalSearchResultOnSelect, + aiAutocompleteOnSelect, + performGeneralSearch, + selectedIndex, + listElementsRef, + askAIState, + showSpinner, + searchLoading, + previousSuggestionsListHeight, + } = useSearchContext() + + 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) { + return ( +
+ +
+ ) + } + + const 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( + + {option.title} + , + ) + // 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( + 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 + } + }} + > + + + + {option.title} + + + + , + ) + } else if (option.title) { + const isActive = selectedIndex === index + items.push( + + 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 && ( + + + + )} + {option.title} + + + + , + ) + } + } + + groups.push( + + + {t('search.overlay.general_suggestions_list_heading')} + + {searchLoading && isInAskAIState ? ( +
+ +
+ ) : ( + items + )} +
, + ) + + if (isInAskAIState || isInAskAIStateButNoAnswer) { + groups.push() + } + + if (isInAskAIState) { + groups.push( + +
  • + +
  • +
    , + ) + } + + // 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() + } + } + + if (aiOptionsWithUserInput.length && !isInAskAIState) { + groups.push( + + + + {t('search.overlay.ai_autocomplete_list_heading')} + + {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 = ( + aiAutocompleteOnSelect(option)} + active={isActive} + tabIndex={-1} + ref={(element: HTMLLIElement | null) => { + if (listElementsRef.current) { + listElementsRef.current[indexWithOffset] = element + } + }} + > + + + + {option.term} + + + + + ) + return item + })} + , + ) + } + + return <>{groups} +} diff --git a/src/search/components/input/SearchOverlay.tsx b/src/search/components/input/SearchOverlay.tsx index 8ab7fd39af..a1e42fa5aa 100644 --- a/src/search/components/input/SearchOverlay.tsx +++ b/src/search/components/input/SearchOverlay.tsx @@ -1,16 +1,8 @@ -import React, { useState, useRef, RefObject, useEffect, SetStateAction, useMemo } from 'react' +import React, { useState, useRef, RefObject, useEffect, 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 { ActionList, IconButton, Overlay, Stack, TextInput, Banner } from '@primer/react' +import { SearchIcon, XCircleFillIcon, CopilotIcon, ArrowLeftIcon } from '@primer/octicons-react' import { focusTrap } from '@primer/behaviors' import { useTranslation } from '@/languages/components/useTranslation' @@ -21,7 +13,6 @@ import { 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' @@ -32,6 +23,13 @@ import type { AutocompleteSearchHit, GeneralSearchHit } from '@/search/types' import { sanitizeSearchQuery } from '@/search/lib/sanitize-search-query' +import { + SearchContext, + SearchContextType, + AutocompleteSearchHitWithUserQuery, + GeneralSearchHitWithOptions, +} from './SearchContext' +import { SearchGroups } from './SearchGroups' import styles from './SearchOverlay.module.scss' type Props = { @@ -532,7 +530,6 @@ export function SearchOverlay({ } // 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, @@ -556,6 +553,21 @@ export function SearchOverlay({ setAICouldNotAnswer, } + const searchContextValue: SearchContextType = { + t, + generalSearchOptions: generalOptionsWithViewStatus, + aiOptionsWithUserInput, + generalSearchResultOnSelect, + aiAutocompleteOnSelect: aiSearchOptionOnSelect, + performGeneralSearch, + selectedIndex, + listElementsRef: listElementsRef as RefObject>, + askAIState, + showSpinner, + searchLoading, + previousSuggestionsListHeight, + } + // 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 @@ -566,7 +578,12 @@ export function SearchOverlay({ const inErrorState = aiSearchError || (autoCompleteSearchError && !isAskAIState) if (inErrorState) { OverlayContents = ( - <> + )} - {renderSearchGroups( - t, - generalOptionsWithViewStatus, - aiSearchError ? [] : aiOptionsWithUserInput, - generalSearchResultOnSelect, - aiSearchOptionOnSelect, - performGeneralSearch, - selectedIndex, - listElementsRef, - askAIState, - showSpinner, - searchLoading, - previousSuggestionsListHeight, - )} + - + ) } else { OverlayContents = ( - - {renderSearchGroups( - t, - generalOptionsWithViewStatus, - aiOptionsWithUserInput, - generalSearchResultOnSelect, - aiSearchOptionOnSelect, - performGeneralSearch, - selectedIndex, - listElementsRef, - askAIState, - showSpinner, - searchLoading, - previousSuggestionsListHeight, - )} - + + + + + ) } @@ -759,268 +752,6 @@ export function SearchOverlay({ ) } -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>, - askAIState: { - isAskAIState: boolean - aiQuery: string - debug: boolean - currentVersion: string - setAISearchError: () => void - references: AIReference[] - setReferences: (value: SetStateAction) => void - referencesIndexOffset: number - referenceOnSelect: (url: string) => void - askAIEventGroupId: React.MutableRefObject - 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( -
    - -
    , - ) - 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( - - {option.title} - , - ) - // 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( - 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 - } - }} - > - - - - {option.title} - - - - , - ) - } else if (option.title) { - const isActive = selectedIndex === index - items.push( - - 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 && ( - - - - )} - {option.title} - - - - , - ) - } - } - - groups.push( - - - {t('search.overlay.general_suggestions_list_heading')} - - {searchLoading && isInAskAIState ? ( -
    - -
    - ) : ( - items - )} -
    , - ) - - if (isInAskAIState || isInAskAIStateButNoAnswer) { - groups.push() - } - - if (isInAskAIState) { - groups.push( - -
  • - -
  • -
    , - ) - } - - // 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() - } - } - - if (aiOptionsWithUserInput.length && !isInAskAIState) { - groups.push( - - - - {t('search.overlay.ai_autocomplete_list_heading')} - - {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 = ( - aiAutocompleteOnSelect(option)} - active={isActive} - tabIndex={-1} - ref={(element: HTMLLIElement | null) => { - if (listElementsRef.current) { - listElementsRef.current[index] = element - } - }} - > - - - - {option.term} - - - - - ) - return item - })} - , - ) - } - - return groups -} - function sendKeyboardEvent( pressedKey: string, pressedOn: string,