Skip to content

Commit 0b2516e

Browse files
authored
Refactor SearchOverlay to use Context for state management (#58768)
1 parent af26fc9 commit 0b2516e

File tree

3 files changed

+352
-316
lines changed

3 files changed

+352
-316
lines changed
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { createContext, useContext, RefObject, SetStateAction, MutableRefObject } from 'react'
2+
import type { AIReference } from '../types'
3+
import type { AutocompleteSearchHit, GeneralSearchHit } from '@/search/types'
4+
5+
export interface AutocompleteSearchHitWithUserQuery extends AutocompleteSearchHit {
6+
isUserQuery?: boolean
7+
}
8+
9+
export interface GeneralSearchHitWithOptions extends GeneralSearchHit {
10+
isViewAllResults?: boolean
11+
isNoResultsFound?: boolean
12+
isSearchDocsOption?: boolean
13+
}
14+
15+
export interface AskAIState {
16+
isAskAIState: boolean
17+
aiQuery: string
18+
debug: boolean
19+
currentVersion: string
20+
setAISearchError: (isError?: boolean) => void
21+
references: AIReference[]
22+
setReferences: (value: SetStateAction<AIReference[]>) => void
23+
referencesIndexOffset: number
24+
referenceOnSelect: (url: string) => void
25+
askAIEventGroupId: MutableRefObject<string>
26+
aiSearchError: boolean
27+
aiCouldNotAnswer: boolean
28+
setAICouldNotAnswer: (value: boolean) => void
29+
}
30+
31+
export interface SearchContextType {
32+
t: any
33+
generalSearchOptions: GeneralSearchHitWithOptions[]
34+
aiOptionsWithUserInput: AutocompleteSearchHitWithUserQuery[]
35+
generalSearchResultOnSelect: (selectedOption: GeneralSearchHit) => void
36+
aiAutocompleteOnSelect: (selectedOption: AutocompleteSearchHit) => void
37+
performGeneralSearch: () => void
38+
selectedIndex: number
39+
listElementsRef: RefObject<Array<HTMLLIElement | null>>
40+
askAIState: AskAIState
41+
showSpinner: boolean
42+
searchLoading: boolean
43+
previousSuggestionsListHeight: number | string
44+
}
45+
46+
export const SearchContext = createContext<SearchContextType | null>(null)
47+
48+
export const useSearchContext = () => {
49+
const context = useContext(SearchContext)
50+
if (!context) {
51+
throw new Error('useSearchContext must be used within a SearchContext.Provider')
52+
}
53+
return context
54+
}
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import React from 'react'
2+
import { ActionList, Spinner } from '@primer/react'
3+
import {
4+
SearchIcon,
5+
FileIcon,
6+
ArrowRightIcon,
7+
CopilotIcon,
8+
CommentIcon,
9+
} from '@primer/octicons-react'
10+
11+
import { AskAIResults } from './AskAIResults'
12+
import { useSearchContext, AutocompleteSearchHitWithUserQuery } from './SearchContext'
13+
import styles from './SearchOverlay.module.scss'
14+
15+
export function SearchGroups() {
16+
const {
17+
t,
18+
generalSearchOptions,
19+
aiOptionsWithUserInput,
20+
generalSearchResultOnSelect,
21+
aiAutocompleteOnSelect,
22+
performGeneralSearch,
23+
selectedIndex,
24+
listElementsRef,
25+
askAIState,
26+
showSpinner,
27+
searchLoading,
28+
previousSuggestionsListHeight,
29+
} = useSearchContext()
30+
31+
const isInAskAIState = askAIState?.isAskAIState && !askAIState.aiSearchError
32+
const isInAskAIStateButNoAnswer = isInAskAIState && askAIState.aiCouldNotAnswer
33+
34+
// This spinner is for both the AI search and the general search results.
35+
// We already show a spinner when streaming AI response, so don't want to show 2 here
36+
if (showSpinner && !isInAskAIState) {
37+
return (
38+
<div
39+
key="loading"
40+
role="status"
41+
className={styles.loadingContainer}
42+
style={{
43+
height: `${previousSuggestionsListHeight}px`,
44+
}}
45+
>
46+
<Spinner />
47+
</div>
48+
)
49+
}
50+
51+
const groups = []
52+
53+
// We want to show general search suggestions above the AI Response section if the AI could not answer
54+
if (generalSearchOptions.length || isInAskAIStateButNoAnswer) {
55+
const items = []
56+
for (let index = 0; index < generalSearchOptions.length; index++) {
57+
const option = generalSearchOptions[index]
58+
if (option.isNoResultsFound) {
59+
items.push(
60+
<ActionList.Item
61+
key={`general-${index}`}
62+
id={`search-option-general-${index}`}
63+
className={styles.noResultsFound}
64+
tabIndex={-1}
65+
aria-label={t('search.overlay.no_results_found')}
66+
disabled
67+
>
68+
{option.title}
69+
</ActionList.Item>,
70+
)
71+
// There should be no more items after the no results found item
72+
break
73+
// 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
74+
} else if (option.isSearchDocsOption) {
75+
const isActive = selectedIndex === index
76+
items.push(
77+
<ActionList.Item
78+
key={`general-${index}`}
79+
id={`search-option-general-${index}`}
80+
tabIndex={-1}
81+
active={isActive}
82+
onSelect={() => performGeneralSearch()}
83+
aria-label={t('search.overlay.search_docs_with_query').replace('{query}', option.title)}
84+
ref={(element: HTMLLIElement | null) => {
85+
if (listElementsRef.current) {
86+
listElementsRef.current[index] = element
87+
}
88+
}}
89+
>
90+
<ActionList.LeadingVisual aria-hidden>
91+
<SearchIcon />
92+
</ActionList.LeadingVisual>
93+
{option.title}
94+
<ActionList.TrailingVisual
95+
aria-hidden
96+
className={isActive ? styles.trailingVisualActive : styles.trailingVisualHidden}
97+
>
98+
<ArrowRightIcon />
99+
</ActionList.TrailingVisual>
100+
</ActionList.Item>,
101+
)
102+
} else if (option.title) {
103+
const isActive = selectedIndex === index
104+
items.push(
105+
<ActionList.Item
106+
key={`general-${index}`}
107+
id={`search-option-general-${index}`}
108+
aria-describedby="search-suggestions-list"
109+
onSelect={() =>
110+
option.isViewAllResults ? performGeneralSearch() : generalSearchResultOnSelect(option)
111+
}
112+
className={option.isViewAllResults ? styles.viewAllSearchResults : ''}
113+
active={isActive}
114+
tabIndex={-1}
115+
ref={(element: HTMLLIElement | null) => {
116+
if (listElementsRef.current) {
117+
listElementsRef.current[index] = element
118+
}
119+
}}
120+
>
121+
{!option.isNoResultsFound && (
122+
<ActionList.LeadingVisual
123+
aria-hidden
124+
className={
125+
option.isViewAllResults ? styles.leadingVisualHidden : styles.leadingVisualVisible
126+
}
127+
>
128+
<FileIcon />
129+
</ActionList.LeadingVisual>
130+
)}
131+
{option.title}
132+
<ActionList.TrailingVisual
133+
aria-hidden
134+
className={isActive ? styles.trailingVisualActive : styles.trailingVisualHidden}
135+
>
136+
<ArrowRightIcon />
137+
</ActionList.TrailingVisual>
138+
</ActionList.Item>,
139+
)
140+
}
141+
}
142+
143+
groups.push(
144+
<ActionList.Group key="general" data-testid="general-autocomplete-suggestions">
145+
<ActionList.GroupHeading as="h3" tabIndex={-1}>
146+
{t('search.overlay.general_suggestions_list_heading')}
147+
</ActionList.GroupHeading>
148+
{searchLoading && isInAskAIState ? (
149+
<div
150+
role="status"
151+
className={styles.loadingContainer}
152+
style={{
153+
height: `${previousSuggestionsListHeight}px`,
154+
}}
155+
>
156+
<Spinner />
157+
</div>
158+
) : (
159+
items
160+
)}
161+
</ActionList.Group>,
162+
)
163+
164+
if (isInAskAIState || isInAskAIStateButNoAnswer) {
165+
groups.push(<ActionList.Divider key="no-answer-divider" />)
166+
}
167+
168+
if (isInAskAIState) {
169+
groups.push(
170+
<ActionList.Group key="ai" data-testid="ask-ai">
171+
<li tabIndex={-1}>
172+
<AskAIResults
173+
query={askAIState.aiQuery}
174+
debug={askAIState.debug}
175+
version={askAIState.currentVersion}
176+
setAISearchError={askAIState.setAISearchError}
177+
references={askAIState.references}
178+
setReferences={askAIState.setReferences}
179+
referencesIndexOffset={askAIState.referencesIndexOffset}
180+
referenceOnSelect={askAIState.referenceOnSelect}
181+
selectedIndex={selectedIndex}
182+
askAIEventGroupId={askAIState.askAIEventGroupId}
183+
aiCouldNotAnswer={askAIState.aiCouldNotAnswer}
184+
setAICouldNotAnswer={askAIState.setAICouldNotAnswer}
185+
listElementsRef={listElementsRef}
186+
/>
187+
</li>
188+
</ActionList.Group>,
189+
)
190+
}
191+
192+
// Don't show the bottom divider if:
193+
// 1. We are in the AI could not answer state
194+
// 2. We are in the AI Search error state
195+
// 3. There are no AI suggestions to show in suggestions state
196+
if (
197+
!isInAskAIState &&
198+
!askAIState.aiSearchError &&
199+
generalSearchOptions.filter((option) => !option.isViewAllResults && !option.isNoResultsFound)
200+
.length &&
201+
aiOptionsWithUserInput.length
202+
) {
203+
groups.push(<ActionList.Divider key="bottom-divider" />)
204+
}
205+
}
206+
207+
if (aiOptionsWithUserInput.length && !isInAskAIState) {
208+
groups.push(
209+
<ActionList.Group key="ai-suggestions" data-testid="ai-autocomplete-suggestions">
210+
<ActionList.GroupHeading as="h3" id="copilot-suggestions" tabIndex={-1}>
211+
<CopilotIcon className="mr-1" />
212+
{t('search.overlay.ai_autocomplete_list_heading')}
213+
</ActionList.GroupHeading>
214+
{aiOptionsWithUserInput.map((option: AutocompleteSearchHitWithUserQuery, index: number) => {
215+
// Since general search comes first, we need to add an offset for AI suggestions
216+
const indexWithOffset = generalSearchOptions.length + index
217+
const isActive = selectedIndex === indexWithOffset
218+
const item = (
219+
<ActionList.Item
220+
key={`ai-${indexWithOffset}`}
221+
id={`search-option-ai-${indexWithOffset}`}
222+
aria-describedby="copilot-suggestions"
223+
onSelect={() => aiAutocompleteOnSelect(option)}
224+
active={isActive}
225+
tabIndex={-1}
226+
ref={(element: HTMLLIElement | null) => {
227+
if (listElementsRef.current) {
228+
listElementsRef.current[indexWithOffset] = element
229+
}
230+
}}
231+
>
232+
<ActionList.LeadingVisual aria-hidden>
233+
<CommentIcon />
234+
</ActionList.LeadingVisual>
235+
{option.term}
236+
<ActionList.TrailingVisual
237+
aria-hidden
238+
className={isActive ? styles.trailingVisualActive : styles.trailingVisualHidden}
239+
>
240+
<ArrowRightIcon />
241+
</ActionList.TrailingVisual>
242+
</ActionList.Item>
243+
)
244+
return item
245+
})}
246+
</ActionList.Group>,
247+
)
248+
}
249+
250+
return <>{groups}</>
251+
}

0 commit comments

Comments
 (0)